为什么需要水平拆分
电商系统中随着业务的快速发展,订单表的数据会出现爆发性增长,当我们使用传统的RDBMS,如MySQL来存储订单数据时,很容易达到性能瓶颈。国内技术圈网传这么一个说法:MySQL数据库如果单表数据在于2000万条,性能会急剧下降,据说该结论最早由百度DBA测试MySQL性能时发现。而后来阿里巴巴的《Java开发手册》则建议单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表
那么这些说法有没有相应的理论依据了?
我们都知道MySQL InnoDB的索引存储采用的是B+树结构。B+Tree主键索引非叶子节点只存储索引信息,不存储实际数据行,叶子节点上同时存储索引和数据行信息。
众所周知,磁盘I/O的性能是非常差的,操作系统为了减少磁盘I/O次数,设计了一种预读机制:在系统调用后,会把数据起始位置的连续多页数据拷贝至内核中缓存起来。这个内核空间就是页缓存(Page Cache)。Page Cache 属于内存空间里的数据,而内存访问比磁盘访问要快很多。操作系统设定的页大小通常都为4KB。
InnoDB存储引擎中也有页(Page)的概念,页是其磁盘管理的最小单位。InnoDB存储引擎中默认每个页的大小为16KB。(操作系统页大小的4倍,即相当于4个连续的操作系统页,这巧妙利用了磁盘预读原理,数据库系统只需要一次访问,就可以将整页数据载入内存中。MySQL自身也有预读机制,会保证连续数据页被同时装载至缓存)
订单表主键设计为BigInt,大小为8个字节,同时索引地址指针也占用6个字节大小。这样一个主键索引占据14个字节空间。那么一个Page能存放多少个索引记录了,约为16 * 1024/14=1170条。
假设叶子节点一条数据占用空间大小为2k(订单表相对复杂),那一个Page能放16条记录,
由此可以推算出一个3层的B+Tree能存放的记录数为:1170 * 1170 * 8=10951200.约1100万条数据。
要将索引和数据全部装入缓存,需要超过20G的内存,这个数值还是可以达到的,只要Buffer_Pool_Size能设置的够大(综合考虑订单明细表数据及其它索引、undo 页,插入缓存、自适应哈希索引、锁信息等),就可以让订单主键查询时不需要磁盘I/O。
但如果是4层B+Tree就很难了,4层B+Tree需要11TB+内存,以目前的硬件水平基本上一次I/O是免不了的。
所以在3层B+Tree的约束下,如果硬件足够好,内存足够大的情况下,订单表单表数据量可达千万级。普通的硬件水平的确可以参考超过500w即进行分库分表的原则。
如何进行水平拆分及分片键设计
B2C业务中订单最主要的查询维度有三个:订单维度,客户维度和商家维度。
如果是单纯的2C业务,则只需要订单维度和客户维度。
按客户维度查询,只需用客户ID做分片键即可实现。具体规则算法如下:
4个库,每个库2个表的例子.dbNum=4,tableNum=2
solt=Hash(userId) % (dbNum*tableNum) //按客户ID做Hash再除以总表数获得坑位编号(solt)
dbId=solt/dbNum //用此坑位号推导出所属DB
tableId=solt % tableNum //用此坑位号推导出所属table
这样的话,同一个用户的订单必定落在同一个库表中,那么用户查询自己的订单,无论是复杂搜索还是分页查询都没有任何问题。但是仅仅这样,是没法解决按订单ID查询的需求的,光有订单ID没法知道应该去哪个库里的哪个表查询。
有一种办法是设计一个映射表,映射客户ID与订单ID的关系,先通过订单ID查询客户ID,路由到指定库和表后,再通过ID查询订单数据,这种方式虽然能解决问题,但是需要带来额外的查询性能开销和数据存储开销,那有没有更好的解决方案了?答案是有的。
如果把分片键同时作为订单ID的一部份,那就不需要额外存储订单ID与路由键的关系了,通过订单ID查询时,先把分片键截取出来,定位DB和Table就可以直接通过订单ID查询了。
比如:取客户ID后4位做分片键,进行hash取模。同时订单ID生成规则中,将客户ID后4位包含进去。那么根据订单查询时,只需要按规则把分片键取出来,找到对应的库表,然后再按订单ID查询即可。
至于B2C的场景,可选的方案是把订单数据冗余一份,分买家订单库和卖家订单库,订单状态通过消息中间件异步更新,这种场景最好将买家库的分片键(截取买家ID)和卖家库(截取卖家ID)的分片键都包含在订单ID中,这样卖家相关的业务查询订单明细时,可以直接走卖家库。
基于雪花算法的订单ID生成方案
下面分析一下订单ID的生成方案。
用客户ID转二进制,取后8位做路由键,订单ID生成时拼接8位客户码,这样就可以同时满足按客户和订单维度查询的需求了。
- 根据客户ID查询时,先取后8位路由键,定位库和表,再根据客户ID查询订单数据。
- 根据订单ID查询时,先取后8位路由键,定位库和表,再根据订单ID查询订单数据。
基于雪花算法可设计如下的订单ID规则:
1位符号+29位时间戳+14位WorkId+12位序号+8位客户码
- 29位时间戳:支持17年,足够了,历史数据可以归档,仅支持查最近10年的订单,有特殊需求可去历史库中查询。
- 14位WorkId:支持16384个实例,目前主流的云原生架构,基于K8s平台,成百上千的Pod也不是不可能。
- 12位序号:单进程同一秒4096个并发支持
- 8位客户码:作为分库分表的路由键,8位可支持256个库表。
这个规则可以根据实际需求进行各区段长度的调整。比如针对B2C订单,可调整规则如下:
1位符号+29位时间戳+10位WorkId+12位序号+6位买家码+6位商家码
这个方案里有一个问题没有解决,即WorkId定义。传统部署方式可以部署脚本中指定WorkId,绑定固定IP,而如果是基于K8s的架构,Pod的复本由K8s托管,而且是无状态的,重启后IP地址也会改变,这就需要有一个统一的WorkId管理方案。
一个简单的WorkId管理组件设计思路:
存储设计(redis,db,zk,etcd,apollo,nacos...)
- WorkId池:存储空闲的WorkID。如果是10位WorkId则支持1024个实例,需要将1024个WorkId初始化.
- Client注册表:存储当前存活的Client的IP地址与WorkId的映射关系.
服务设计
- 获取WorkId:客户端启动时,从
WorkId池获取并移除一个WorkId,获取不到时先进行回收,拿到WorkId后往Client注册表中保存客户端的IP地址与WorkId的映射关系。- 返还WorkId:客户端正常下线时,从
Client注册表删除注册信息,并返回WorkId至WorkId池。- WorkId回收:获取所有客户端注册信息,通过IP地址探测存活状态,三个心跳周期探测失败,删除注册信息,并将客户端占用的WorkId返还
WorkId池。Spring Boot应用可以使用/actuator/health端点探测存活状态。- 定时检测: 定时执行WorkId回收,可设置30秒一个探测周期。
如何进行水平扩容
如果业务发展非常迅猛,订单量呈指数型爆长,那么一段时间后单表数据量很可能会超出限制,如果不即时扩容,马上会出现性能瓶颈,此时需要即时规划扩容,以免业务受到影响。
通常可以利用MySQL主从复制机制,配合修改分片策略配置,从而达到不迁移数据即可完成扩容的方案,具体步骤如下:
1.为集群中每个数据库添加一个从库,开启主从复制数据同步功能。
2.数据完全同步完成后,断开业务流量,不允许写数据库。 3.切断主从库关系
4.配置中心发布新的分片规则,所有应用实时刷新配置。新增的从库正式成为集群中的一员。
5.根据分片规则清理冗余数据
以DB04以及其扩展库DB08举例:
DB04.Order01 删除 mod 16 != 6的数据
DB04.Order02 删除 mod 16 != 7的数据
DB08.Order01 删除 mod 16 != 14的数据
DB08.Order02 删除 mod 16 != 15的数据