在能满足业务场景的情况下,单表 > 分区 > 单库分表 > 分库分表,推荐优先级从左到右逐渐降低。
本篇文章主要讲用户表(或者类似这种业务属性的表)的分表方案,至于订单表,流水表等,本文的方案可能不是很合适,可以参考笔者另一篇文章《分库分表技术演进&最佳实践-修订篇》。
我们首先来看一下分表时主要需要做的事情:
选定分片键:既然是用户表那分片键非用户ID莫属;
修改代码:以sharding-jdbc这种client模式的中间件为例,主要是引入依赖,然后新增一些配置。业务代码并不怎么需要改动。
存量数据迁移;
业务发展超过容量评估后需要开发和运维介入扩容;
做过分库分表的都知道,第3步最麻烦,而且非常不好验证迁前后数据一致性(目前业界主流的迁移方案是存量数据迁移+利用binlog进行增量数据同步,待两边的数据持平后,将业务代码中的开关切到分表模式)。
第4步同样麻烦,业务增长完全超过当初分表设计的容量评估是很常见的事情,这也成为业务高速发展的一个隐患。而且互联网类型的业务都希望能做到7x24小时不停服务,这样就给扩容带来了更大的挑战。笔者看过比较好的方案就是58沈剑提出的成倍扩容方案。如下图所示,假设现在已经有2张表:tbuser1,tbuser2。且有两个库是主备关系,并且分表算法是hash(user_id)%2:
现在要扩容到4张表,做法是将两个库的主从关系切断。然后slave晋升为master,这样就有两个主库:master-1,master-2。新的分表算法是:
库选择算法为:hash(userid)%4的结果为1或者2,就选master-1库,hash(userid)%4的结果为3或者0,就选master-2库;
表的选择算法为:hash(userid)%2的结果为1则选tbuser1表,hash(userid)%2的结果为0则选tbuser2表。
如此以来,两个库中总计4张表,都冗余了1倍的数据:master-1中tbuser1冗余了3、7、11…,master-1中tbuser2冗余了4、8、12…,master-2中tbuser1冗余了1、5、9…,master-2中tbuser2冗余了2、6、10…。将这些冗余数据删掉后,库、表、数据示意图如下所示:
即使这样方案,还是避免不了分表时的存量数据迁移,以及分表后业务发展到一定时期后的繁琐扩容。那么有没有一种很好的方案,能够一劳永逸,分表时不需要存量数据迁移,用户量无论如何增长,扩容时都不需要迁移存量数据,只需要新增一个数据库示例,修改一下配置即可。软件开发行业,一个方案能撑过3~5年就是一个很优秀的方案,我们现在YY的是整个生命周期内都不用改动的完美的方案。没错,我们在寻找银弹。
这个方案笔者在两个地方都接触到了:
某V厂面试时,部门老大提出的方案;
和美团大牛普架讨论了解到的CAT存储方案;
说明:CAT是美团点评开源的APM,目前在Github上的star已经破万(Github地址:https://github.com/dianping/cat),比skywalking和pinpoint还快,如果你正在选型APM,而且能接受代码侵入,那么CAT是一个不错的选择。
CAT存储方案是按照写入时间顺序存储,假设每小时写入量是千万级别,那么分表就按照小时维度。也就是说,2019年7月18号10点数据写入到表tbcatdata2019071810中,2019年7月18号12点数据写入到表tbcatdata2019071812中,2019年7月20号14点数据写入到表tbcatdata2019072014中。这样做的优点如下:
历史数据不用迁移;
扩容非常简单;
缺点如下:
- 读写热点集中,所有写操作全部打在最新的表上。
有没有发现,这个方案的优点就是我们需要的。BINGO,要的就是这样的方案。那么对应到用户表上来具体的分表方案非常类似:按照range切分。需要说明的是,这个方案的前提是用户ID一定要趋势递增,最好严格递增。笔者给出3种用户ID递增的方案:
- 自增ID
假设存量数据用户表的id最大值是960W,那么分表算法是这样的,表序号只需要根据user_id/10000000就能得到:
用户ID在范围[1, 10000000)中分到tbuser0中(需要将tbuser重命名为tbuser_0);
用户ID在范围[10000000, 20000000)中分到tbuser1中;
用户ID在范围[20000000, 30000000)中分到tbuser2中;
用户ID在范围[30000000, 40000000)中分到tbuser3中;
以此类推。
如果你的tbuser本来就有自增主键,那这种方案就比较好。但是需要注意几点,由于用户ID是自增的,所以这个ID不能通过HTTP暴露出去,否则可以通过新注册一个用户后,就能得到你的真实用户数,这是比较危险的。其次,存量数据在单表中可以通过自增ID生成,但是当切换分表后,用户ID如果还是用自增生成,需要注意在创建新表时设置AUTOINCREMENT,例如创建表tbuser2时,设置AUTO_INCREMENT=10000000,DDL如下:
CREATE TABLE if not exists `tb_user_2` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` varchar(16) NOT NULL COMMENT '用户名',
`remark` varchar(16) NOT NULL COMMENT '备注'
) ENGINE=InnoDB AUTO_INCREMENT=10000000;
- 这样的话,当新增用户时,用户ID就会从10000000开始,而不会与之前的用户ID冲突
insert into tb_user_2 values(null, 'afei', 'afei');
- Redis incr
第二种方案就是利用Redis的incr命令。将之前最大的ID保存到Redis中,接下来新增用户的ID值都通过incr命令得到。然后insert到表tbuser中。这种方案需要注意Redis主从切换后,晋升为主的Redis节点中的ID可能由于同步时间差不是最新ID的问题。这样的话,可能会导致插入记录到tbuser失败。需要对这种异常特殊处理一下即可。
- 利用雪花算法生成
采用类雪花算法生成用户ID,这种方式不太好精确掌握切分表的时机。因为没有高效获取tbuser表数据量的办法,也就不知道什么时候表数据量达到1000w级别,也就不知道什么时候需要往新表中插入数据(select count(*) from tbuser无论怎么优化性能都不会很高,除非是MyISAM引擎)。而且如果利用雪花算法生成用户ID,那么还需要一张表保存用户ID和分表关系:
笔者推荐第一种方案,即利用表自增ID生成用户ID:方案越简单,可靠性越高。其他两种方案,或者其他方案或多或少需要引入一些中间件或者介质,从而增加方案的复杂度。新方案效果图如下:
回顾总结
我们回头看一下这种用户表方案,满足了存量数据不需要做任何迁移(除非是存量数据远远超过单表承受能力)。而且,无论用户规模增长到多大量级,1亿,10亿,50亿,后面都不需要做数据迁移。而且也不再需要开发和运维介入。因为整个方案,会自己往新表中插入数据。我们唯一需要做的就是,根据硬件性能,约定一个库允许保存的用户表数量即可。假如一个库保存64张表,那么当扩容到第65张表时,程序会自动往第二个库的第一张表中写入。