什么是分库分表以及为什么分库分表

本文引用自分库分表介绍
一文读懂数据库分库分表

数据库常见优化方案

对于后端程序员来说,绕不开数据库的使用与方案选型,那么随着业务规模的逐渐扩大,其对于存储的使用上也需要随之进行升级和优化。
随着规模的扩大,数据库面临如下问题:

  • 读压力:并发QPS、索引不合理、SQL语句不合理、锁粒度
  • 写压力:并发QPS、事务、锁粒度
  • 物理性能:磁盘瓶颈、CPU瓶颈、内存瓶颈、IO瓶颈
  • 其他:宕机、网络异常

面对上述问题,常见的优化手段有:
索引优化、主从同步、缓存、分库分表每个技术手段都可以作为一个专题进行讲解,本文主要介绍分库分表的技术方案实现。

什么是分库分表?

对于阅读本文的读者来说,分库分表概念应该并不会陌生,其拆开来讲是分库和分表两个手段:

  • 分表:将一个表中的数据按照某种规则分拆到多张表中,降低锁粒度以及索引树高度,提升数据查询效率。
  • 分库:将一个数据库中的数据按照某种规则分拆到多个数据库中,以缓解单服务器的压力(CPU、内存、磁盘、IO)

为什么分库分表?

  • 性能角度:CPU、内存、磁盘、IO瓶颈
    • 随着业务体量扩大,数据规模达到百万行,数据库索引树庞大,查询性能出现瓶颈。
    • 用户并发流量规模扩大,由于单库(单服务器)物理性能限制也无法承载大流量。
  • 可用性角度:单机故障率影响面
    • 如果是单库,数据库宕机会导致100%服务不可用,N库则可以将影响面降低N倍。

如何分库分表

何谓数据切分?
简单来说,就是指通过某种特定的条件,将我们存放在同一个数据库中的数据分散存放到多个数据库(主机)上面,以达到分散单台设备负载的效果。数据的切分同一时候还能够提高系统的总体可用性,由于单台设备Crash之后。仅仅有总体数据的某部分不可用,而不是全部的数据。
数据的切分(Sharding)依据其切分规则的类型。能够分为两种切分模式:垂直切分和水平切分。
一种是依照不同的表(或者Schema)来切分到不同的数据库(主机)之上,这样的切能够称之为数据的垂直(纵向)切分。另外一种则是依据表中的数据的逻辑关系,将同一个表中的数据依照某种条件拆分到多台数据库(主机)上面。这样的切分称之为数据的水平(横向)切分。
垂直切分的最大特点就是规则简单,实施也更为方便,尤其适合各业务之间的耦合度非常低。相互影响非常小,业务逻辑非常清晰的系统。在这样的系统中,能够非常easy做到将不同业务模块所使用的表分拆到不同的数据库中。依据不同的表来进行拆分。对应用程序的影响也更小,拆分规则也会比較简单清晰。
水平切分于垂直切分相比。相对来说略微复杂一些。由于要将同一个表中的不同数据拆分到不同的数据库中,对于应用程序来说,拆分规则本身就较依据表名来拆分更为复杂,后期的数据维护也会更为复杂一些。
当我们某个(或者某些)表的数据量和访问量特别的大,通过垂直切分将其放在独立的设备上后仍然无法满足性能要求,这时候我们就必须将垂直切分和水平切分相结合。先垂直切分,然后再水平切分。才干解决这样的超大型表的性能问题。

image.png

垂直拆分:

垂直拆表
概念 把一个表的多个字段分别拆成多个表,一般按字段的冷热拆分,热字段一个表,冷字段一个表。从而提升了数据库性能。
说明:一开始商品表中包含商品的所有字段,但是我们发现:
(1)商品详情和商品属性字段较长。
(2)商品列表的时候我们是不需要显示商品详情和商品属性信息,只有在点进商品商品的时候才会展示商品详情信息。
所以可以考虑把商品详情和商品属性单独切分一张表,提高查询效率。image.png

  • 即大表拆小表,将一张表中数据不同”字段“分拆到多张表中,比如商品库将商品基本信息、商品库存、卖家信息等分拆到不同库表中。
  • 考虑因素有将不常用的,数据较大长度较长(比如text类型字段)的拆分到“扩展表“,表和表之间通过”主键外键“进行关联。
  • 好处:降低表数据规模,提升查询效率,也避免查询时数据量太大造成的“跨页”问题。

垂直拆库

  • 垂直拆库则在垂直拆表的基础上,将一个系统中的不同业务场景进行拆分,比如订单表、用户表、商品表。

  • 好处:降低单数据库服务的压力(物理存储、内存、IO等)、降低单机故障的影响面

    概念 就是根据业务耦合性,将关联度低的不同表存储在不同的数据库。做法与大系统拆分为多个小系统类似,按业务分类进行独立划分。与"微服务治理"的做法相似,每个微服务使用单独的一个数据库。
    image.png

3、垂直切分优缺点
优点

  • 解决业务系统层面的耦合,业务清晰 - 与微服务的治理类似,也能对不同业务的数据进行分级管理、维护、监控、扩展等 - 高并发场景下,垂直切分一定程度的提升IO、数据库连接数、单机硬件资源的瓶颈

缺点

  • 分库后无法Join,只能通过接口聚合方式解决,提升了开发的复杂度 - 分库后分布式事务处理复杂 - 依然存在单表数据量过大的问题(需要水平切分)
  • 拆分需考虑的业务因素

一个架构设计较好的应用系统。其总体功能肯定是由非常多个功能模块所组成的。而每一个功能模块所须要的数据对应到数据库中就是一个或者多个表。
而在架构设计中,各个功能模块相互之间的交互点越统一越少,系统的耦合度就越低,系统各个模块的维护性以及扩展性也就越好。这样的系统。实现数据的垂直切分也就越easy。
当我们的功能模块越清晰,耦合度越低,数据垂直切分的规则定义也就越easy。全然能够依据功能模块来进行数据的切分,不同功能模块的数据存放于不同的数据库主机中,能够非常easy就避免掉跨数据库的Join存在。同一时候系统架构也非常的清晰。
当然,非常难有系统能够做到全部功能模块所使用的表全然独立,全然不须要訪问对方的表或者须要两个模块的表进行Join作。这样的情况下,我们就必须依据实际的应用场景进行评估权衡。
决定是迁就应用程序将须要Join的表的相关某快都存放在同一个数据库中,还是让应用程序做很多其它的事情,也就是程序全然通过模块接口取得不同数据库中的数据,然后在程序中完毕Join操作。
一般来说,假设是一个负载相对不是非常大的系统,并且表关联又非常的频繁。那可能数据库让步。将几个相关模块合并在一起降低应用程序的工作的方案能够降低较多的工作量。是一个可行的方案。
当然,通过数据库的让步,让多个模块集中共用数据源,实际上也是简单介绍的默许了各模块架构耦合度增大的发展,可能会让以后的架构越来越恶化。尤其是当发展到一定阶段之后,发现数据库实在无法承担这些表所带来的压力。不得不面临再次切分的时候。所带来的架构改造成本可能会远远大于最初的时候。
所以,在数据库进行垂直切分的时候,怎样切分,切分到什么样的程度,是一个比較考验人的难题。仅仅能在实际的应用场景中通过平衡各方面的成本和收益。才干分析出一个真正适合自己的拆分方案。

水平拆分:

当一个应用难以再细粒度的垂直切分或切分后数据量行数巨大,存在单库读写、存储性能瓶颈,这时候就需要进行水平切分了。水平切分也可以分为:水平分库和水平分表。
**水平分库的原因:**上面虽然已经把商品库分成3个库,但是随着业务的增加一个订单库也出现QPS过高,数据库响应速度来不及,一般mysql单机也就1000左右的QPS,如果超过1000就要考虑分库。
image.png
概念 一般我们一张表的数据不要超过1千万,如果表数据超过1千万,并且还在不断增加数据,那就可以考虑分表。

image.png

  • 操作:将总体数据按照某种维度(时间、用户)等分拆到多个库中或者表中,典型特征不同的库和表结构完全一下,如订单按照(日期、用户ID、区域)分库分表。
  • 水平拆表
    • 将数据按照某种维度拆分为多张表,但是由于多张表还是从属于一个库,其降低锁粒度,一定程度提升查询性能,但是仍然会有IO性能瓶颈。
  • 水平拆库
    • 将数据按照某种维度分拆到多个库中,降低单机单库的压力,提升读写性能。

水平切分优缺点

优点

  • 不存在单库数据量过大、高并发的性能瓶颈,提升系统稳定性和负载能力 - 应用端改造较小,不需要拆分业务模块
    缺点
  • 跨分片的事务一致性难以保证 - 跨库的Join关联查询性能较差 - 数据多次扩展难度和维护量极大

常见水平拆分手段

range分库分表

顾名思义,该方案根据数据范围划分数据的存放位置。
思路一:时间范围分库分表
举个最简单例子,我们可以把订单表按照年份为单位,每年的数据存放在单独的库(或者表)中。
时下非常流行的分布式数据库:TiDB数据库,针对TiKV中数据的打散,也是基于Range的方式进行,将不同范围内的[StartKey,EndKey)分配到不同的Region上。
缺点:

  • 需要提前建库或表。
  • 数据热点问题:当前时间的数据会集中落在某个库表。
  • 分页查询问题:涉及到库表中间分界线查询较复杂。

例子: 交易系统流水表则是按照天级别分表。

hash分库分表

hash分表是使用最普遍的使用方式,其根据“主键”进行hash计算数据存储的库表索引。原理可能大家都懂,但有时拍脑袋决定的分库分表方案可能会导致严重问题。
思路一:独立hash
对于分库分表,最常规的一种思路是通过主键计算hash值,然后hash值分别对库数和表数进行取余操作获取到库索引和表索引。比如:电商订单表,按照用户ID分配到10库100表中。

const (
    // DbCnt 库数量
    DbCnt = 10
    // TableCnt 表数量
    TableCnt = 100
    )

    // GetTableIdx 根据用户 ID 获取分库分表索引
func GetTableIdx(userID int64) (int64, int64) {
    hash := hashCode(userID)
        return hash % DbCnt, hash % TableCnt
}

上述是伪代码实现,大家可以先思考一下上述代码可能会产生什么问题?
比如1000? 1010?,1020库表索引是多少?
答:数据偏斜问题。

image.png
非互质关系导致的数据偏斜问题证明:

假设分库数分表数最大公约数为a,则分库数表示为 m*a , 分表数为 n*a (m,n为正整数)
 
某条数据的hash规则计算的值为H,
 
若某条数据在库D中,则H mod (m*a) == D 等价与  H=M*m*a+DM为整数)
 
则表序号为 T = H % (n*a) = (M*m*a+D)%(n*a) 

如果D==0T= [(M*m)%n]*a

由于库和表的hash计算中存在公共因子,导致数据偏斜问题,导致落到相同库上的数据,也倾向落到相同表上

思路二:统一hash
思路一中,由于库和表的hash计算中存在公共因子,导致数据偏斜问题,那么换种思考方式:10个库100张表,一共1000张表,那么从0到999排序,根据hash值对1000取余,得到[0,999]的索引,似乎就可以解决数据偏斜问题:

// GetTableIdx 根据用户 ID 获取分库分表索引
// 例子: 1123011 -> 1,1
func GetTableIdx(userID int64) (int64, int64) {
    hash := hashCode(userID)
    slot := DbCnt * TableCnt
	return hash % slot % DbCnt, hash % slot / DbCnt
}

上面会带来的问题?
比如1123011号用户,扩容前是1库1表,扩容后是0库11表
image.png
思路三:二次分片法
思路二中整体思路正确,只是最后计算库序号和表序号的时候,使用了库数量作为影响表序号的因子,导致扩容时表序号偏移而无法进行。事实上,我们只需要换种写法,就能得出一个比较大众化的分库分表方案。

func GetTableIdx(userId int64){
	//①算Hash
	hash:=hashCode(userId)
	//②分片序号
	slot:=hash%(DbCnt*TableCnt)
	//③重新修改二次求值方案
	dbIdx:=slot/TableCnt
	tblIdx:=slot%TableCnt
	return dbIdx,tblIdx
}

从上述代码中可以看出,其唯一不同是在计算库索引和表索引时,采用TableCnt作为基数(注:扩容操作时,一般采用库个数2倍扩容),这样在扩容时,表个数不变,则表索引不会变。
思路四:基因法
由思路二启发,我们发现案例一不合理的主要原因,就是因为库序号和表序号的计算逻辑中,有公约数这个因子在影响库表的独立性。那么我们是否可以换一种思路呢?我们使用相对独立的Hash值来计算库序号和表序号呢?

func GetTableIdx(userID int64)(int64,int64){
	hash := hashCode(userID)
	return atoi(hash[0:4]) % DbCnt,atoi(hash[4:])%TableCnt
}

这也是一种常用的方案,我们称为基因法,即使用原分片键中的某些基因(例如前四位)作为库的计算因子,而使用另外一些基因作为表的计算因子。
在使用基因法时,要主要计算hash值的片段保持充分的随机性,避免造成严重数据偏斜问题。
思路五:关系表冗余
按照索引的思想,可以通过分片的键和库表索引建立一张索引表,我们把这张索引表叫做“路由关系表”。每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。同时,对于写入操作可以采用随机选择或者顺序选择一个库表进入写入。
那么由于路由关系表的存在,我们在数据扩容时,无需迁移历史数据。同时,我们可以为每个库表指定一个权限,通过权重的比例调整来调整每个库表的写入数据量。从而实现库表数据偏斜率调整。
此种方案的缺点是每次查询操作,需要先读取一次路由关系表,所以请求耗时可能会有一定增加。本身由于写索引表和写库表操作是不同库表写操作,需要引入分布式事务保证数据一致性,极端情况可能带来数据的不一致。
索引表本身没有分库分表,自身可能会存在性能瓶颈,可以通过存储在redis进行优化处理。
image.png
思路六:分段索引关系表

思路五中,需要将全量数据存在到路由关系表中建立索引,再结合range分库分表方案思想,其实有些场景下完全没有必要全部数据建立索引,可以按照号段式建立区间索引,我们可以将分片键的区间对应库的关系通过关系表记录下来,每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。

image.png
思路七:一致性Hash法
一致性Hash算法也是一种比较流行的集群数据分区算法,比如RedisCluster即是通过一致性Hash算法,使用16384个虚拟槽节点进行每个分片数据的管理。关于一致性Hash的具体原理这边不再重复描述,读者可以自行翻阅资料。
其思想和思路五有异曲同工之妙。

分库分表带来的问题?

事务性问题

  • 分库可能导致执行一次事务所需的数据分布在不同服务器上,数据库层面无法实现事务性操作,需要更上层业务引入分布式事务操作,难免会给业务带来一定复杂性,那么要想解决事务性问题一般有两种手段:
  • 方案一:在进行分库分表方案设计过程中,从业务角度出发,尽可能保证一个事务所操作的表分布在一个库中,从而实现数据库层面的事务保证。
  • 方案二:方式一无法实现的情况下,业务层引入分布式事务组件保证事务性,如事务性消息、TCC、Seata等分布式事务方式实现数据最终一致性。

主键(自增ID)唯一性问题

  • 在数据库表设计时,经常会使用自增ID作为数据主键,这就导致后续在迁库迁表、或者分库分表操作时,会因为主键的变化或者主键不唯一产生冲突,要解决主键不唯一问题,有如下方案:
    • 方案一:自增ID做主键时,设置自增步长,采用等差数列递增,避免各个库表的主键冲突。但是这个方案仍然无法解决迁库迁表、以及分库分表扩容导致主键ID变化问题
    • 方案二:主键采用全局统一ID生成机制:如UUID、雪花算法、数据库号段等方式。

跨库多表join问题

  • 首先来自大厂DBA的建议是,线上服务尽可能不要有表的join操作,join操作往往会给后续的分库分表操作带来各种问题,可能导致数据的死锁。可以采用多次查询业务层进行数据组装(需要考虑业务上多次查询的事务性的容忍度)

跨库聚合查询问题

  • 分库分表会导致常规聚合查询操作,如group by,order by等变的异常复杂。需要复杂的业务代码才能实现上述业务逻辑,其常见操作方式有:

方案一:每次从N个库表中查询出TOP N数据,然后在业务层代码中进行聚合合并操作。

方案二:可以将经常使用到groupby,orderby字段存储到一个单一库表(可以是REDIS、ES、MYSQL)中,业务代码中先到单一表中根据查询条件查询出相应数据,然后根据查询到的主键ID,到分库分表中查询详情进行返回。2次查询操作难点会带来接口耗时的增加,以及极端情况下的数据不一致问题。

什么是好的分库分表方案?

  • 满足业务场景需要:根据业务场景的不同选择不同分库分表方案:比如按照时间划分、按照用户ID划分、按照业务能力划分等
  • 方案可持续性
    • 何为可持续性?其实就是:业务数据量级和流量量级未来进一步达到新的量级的时候,我们的分库分表方案可以持续灵活扩容处理。
  • 最小化数据迁移: 扩容时一般涉及到历史数据迁移,其扩容后需要迁移的数据量越小其可持续性越强,理想的迁移前后的状态是(同库同表>同表不同库>同库不同表>不同库不同表)
  • 数据偏斜:数据在库表中分配的均衡性,尽可能保证数据流量在各个库表中保持等量分配,避免热点数据对于单库造成压力。
    • 最大数据偏斜率:(数据量最大样本 - 数据量最小样本)/ 数据量最小样本。一般来说,如果我们的最大数据偏斜率在5%以内是可以接受的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值