从大表到分库分表:3.5亿数据迁移的深入实践

157 阅读12分钟

1. 背景

如下图,随着业务的发展,早在23年的10月底,db_user库就触发了大表的巡检提示,截止24年11月,库中按总量大小排名前三的表t_driver、t_setting、t_black,已远大于理论层面大表的定义,成为稳定性隐患,需要介入治理。

image.png

2. 里程碑

image.png

3. 主要改造

  1. 三张表均采用分库分表的解决方案(已考虑了上数据、冷备等可能性,仍采用分库分表),由于子域特性,分片键均采用uid即可;
  2. 数据迁移的同时,顺带完成了模型索引的精简优化,部分无分片键接口的改造,以及随着业务的演进,将部分接口根据职能收口到更符合的服务,即接口迁移;
  3. 同时成本在一升一降之间,造成的剪刀差还实现了降本的目标。

4. 技术难点

4.1. 是否一定要进行分库分别,数据存档可以吗?

这个问题我们在开始治理前是有过内部讨论的,特别是针对t_black表。

  1. 先来看t_driver和t_setting表,这两张表分别为司机信息和用户设置信息,这种数据因为不存在时间或版本的概念,事实上并不适合进行数据归档
  2. 再来看t_black表, 在业务上,这个是拉黑记录表,在治理前的几个月内,产品侧针对半年前的数据进行过一波整体逻辑删除的操作,理论上这个操作会提升业务效果,但实践下来发现在业务上发现并没有明显的正向作用,故产品侧要求历史数据要全部保留,之后会恢复之前的逻辑删除;

技术上,由于拉黑的记录会在业务的场景中被频繁使用到,属于高流量接口,如果归档至Hbase、ES等其它数据库,可能在稳定性和响应时间上会难以满足业务需要,

故综上,此表的数据归档的方案被否决。

所以本次大表治理的三张表,都采用了 分库分表的方案。

4.2. 如何顺利的完成迁移?

4.2.1. 迁移核心

灰度:我们是通过直接写代码的方式在Repository层实现新老库表的读、写、比对灰度控制的,通过动态配置的方式,具体到“类#方法”的去控制新老比例;

数据比对:同上,通过动态配置的方式,精确到“类#方法”的方式去控制其采样比率去埋点,之后通过内部平台进行埋点hive表内容数据比对,注意我们的比对方式并不是新老库全量数据的比较,对于读流量通过配置比例的采样埋点,而针对写流量的比对,采用了旁路验证,仅埋写时不一致的数据;

数据迁移:而数据同步是采用另外一个专门负责数据迁移的服务通过编码进行,迁移服务需引入新老库配置,采用多线程方式读老库、写新库,并配合令牌桶限流策略;

4.2.2. 迁移流程:

从最初的读老写老,切为最后的读新写新。

4.2.2.1. 双写

同时进行新库和老库的写入,让新老库同时都有最近的数据,这么做的主要原因一方面让数据不丢,另一方面也可以随时回滚

本次迁移的双写是通过repository层编码的方式来实现,并未借助canal等其它工具。

4.2.2.2. 增量数据核对

数据迁移过程中,数据核对至关重要。尤其增量数据,双写是有可能失败的,所以需要识别到双写失败的数据。

这里我们采用的是旁路测试,拿新老数据结果做对比,不一致的时候埋点,hive表深度比对后不一致再人工接入。

4.2.2.3. 增量数据更新

除insert数据当作增量数据,如果发生update/delete操作,关键应判断下新表是否有这个数据,如果有则双写更新,如果没有,就说明这个数据还是个存量数据,只改旧表就好。在之后的存量数据同步过程中,其自然而然会被写入新表。

4.2.2.4. 存量数据迁移
  1. 本次迁移并未做到断点续传,如果失败则需全部重传,若想做其实可以对老表新增迁移标识字段来区分,每次仅迁移未迁移过的数据即可;
  2. 存量数据迁移的过程中,要注意不能重复插入之前已经存在的数据(即双写进入的量或上次批量迁移进来的),就认为这条记录已经迁移成功了,本次迁移我们通过duplicate key update的方式对数据进行重复性校验,重复就跟新其中的部分字段。
4.2.2.5. 全量数据核对

数据在此时理论上新老表达成一致,所以需要比对验证,为下一步的切流做准备。

4.2.2.6. 切流读新

确保数据迁移完毕,并且比对通过后,可以着手切流,一般先切读流量,跑一段时间确保无误再切写流量

4.2.2.7. 切流写新

这是最后一步了,这也是唯一无法回滚的一个步骤。切完后双写变单写,两边数据不再一致,所以要针对前面的工作做好足够的验证和核对,并且建议从0.1%一点一点开始切。

4.3. t_black 空间换时间

t_black表的改造涉及到分页查询的问题,分库分表后数据散落在各个表中,此时跨多个库表分页或在内存排序都非常麻烦。

难点问题:接口getBlackList的功能是需要一次性查出某人主动被动的所有记录,但由于分库分表,主动拉黑和被拉黑的记录,很大概率不会在一张表中。

解决方案:我们采取的方式是以空间换时间,由于我们的查询是携带shardingId的,针对每一条拉黑记录,老表的每1条拉黑记录对应在新表中变2条(老表中1条为A拉黑B,新表中2条分别代表A拉黑B,和B被A拉黑),这样做就可以保证一个uid作为入参,路由到单表中进行操作,可以一次性以此uid为key在一张表中找到其拉黑和被拉黑的所有记录,一次性返回。

eg

场景:我们拿出现领域典型司乘的例子来说,我要查出B拉黑和B被拉黑的所有记录;

记录:A拉黑B,B是司机;B拉黑A,A是司机;A拉黑B,B是乘客;B拉黑A,A是乘客。如下:

front_uid(分片键)back_uidtypestatus备注
AB11A乘客拉黑B司机;B司机被A乘客拉黑
BA21
BA11B乘客拉黑A司机;A司机被B乘客拉黑
AB21
AB31A司机拉黑B乘客;B乘客被A司机拉黑
BA41
BA31B司机拉黑A乘客;A乘客被B司机拉黑
AB41

如表,B在同一front_uid分片位上,可以保证一次性查出其主动拉黑和被拉黑记录。

4.4. 扩展

分库分表后理论上还会有两个棘手的问题,

  1. 分库分表后,还是不够怎么办?
  2. 跨库表的事务如何保证一致性?

以上问题虽然本次改造均不会涉及,但我们可以针对解决方案稍作探讨。

4.4.1. 一致性哈希

如果分库分表后,库表仍然不够该怎么办?

首先可以想到的方案就是二次分表,再进行双写、数据迁移、切流的方案,将所有数据再挪一遍窝,但这个方案对于数据的影响面比较大,有没有更好的方案呢?

另一个值得考虑的方案就是从第一次迁移就使用一致性哈希的方案。一致性哈希就是为解决分库分表后,库表需要调整,再分表问题,提供了影响较小的方案,其目标就是在节点动态增加或删除时,尽可能的减少数据迁移和重新分布的成本。如图:

一致性哈希算法的主要思想为,首先需要构造一个哈希环,然后划分固定数量的虚拟节点,比如2^32,其编号就为0—2^32-1,然后我们把256张表的编号哈希后对2^32取模,映射到环中的虚拟节点上,再将所有的数据shardingId哈希后对232取模,也映射到环的虚拟节点上。最后沿顺时针方向,数据节点归属于 最近的表节点,这样便完成了数据分配。

如果再增加一个分表该如何分配呢

通过一致性哈希算法为新表找到环中对应的虚拟节点位置,自然而然就会有一部分数据被截断,改变其对应的第一个遇到的表,完成迁移。相比于全部数据的迁移,此方案需要改变的数据范围小了很多。

优点很明显,其扩展性较高,当然缺点也比较明显,有可能会因hash倾斜造成数据倾斜。

4.4.2. 分布式事务

如果想实现强一致性,可以考虑引入协调者,使用XA规范的二阶段(2PC)提交或三阶段提交(3PC),还有最终一致性的TCC,以及阿里开源的Seata,但我想分享一种较为简单的实现最终一致性的方案:本地消息表

本地消息表是借助消息来实现的,其主要思想是将分布式事务拆分为本地事务和消息事务两个部分。

发起端,在发送消息之前,先创建一条本地消息记录,并且保证写本地业务数据的操作和写本地消息记录的操作在同一个事务中,这样能保证只要业务操作成功,本地消息就可以写成功,基于此,再发送MQ消息;

消费端,接收到消息后,处理业务,再更新本地消息表状态。

异常情况分析:

  1. 如果发起端的事务中出现失败,会回滚,之后都不会启动,此刻数据一致;
  2. 事务成功后,如果发消息失败,则需要引入定时任务去扫本地消息表,对于未成功的消息进行重新投递;
  3. 如果消息消费端失败,则触发消息重试;
  4. 如果业务流程2最后的更新消息记录失败,此时其实两边的数据已经一致,只是消息表的状态不正确,可以依靠定时任务去继续重投消息,下游做好幂等;甚至可以通过定时任务直接去查下游数据是否一致,如果已经成功则修改状态。

总体来说,本地消息表的优点相比2PC、TCC等较轻量级、可靠性和扩展性较好;缺点是需要相应的方案设计落地,且由于流程的协调,会对系统的性能造成一些影响,并且可能需要应对扫表慢的问题。

5. 思考可优化项

5.1. 分布式事务

问题:本次大表治理,t_black的方案中,由于某查询方法的特殊性,老表的一条数据要对应插入新表两条,这就导致可能出现,第一条插入成功,第二条失败的可能,我们其实针对这种情况是没有做处理的,只能通过日志打印、数据比对来发现和人工干预。

优化思考:我们其实可以针对此处逻辑做优化,比如第一条插入失败就直接返回失败,第二条失败,发一个延迟消息进行间隔重试(重试可能存在多余消息,逻辑需要幂等),重试一定次数之后如果仍然失败,以日志或埋点的形式存下来并通知人工干预,可以做到较简单的尽最大可能保证数据的一致性。

5.2. 迁移中的一致性问题

背景:我们的整体节奏是,双写->数据迁移->切流,其中

  1. 双写和数据迁移其实是并行的;
  2. 迁移的逻辑是先select再insert。

问题:因为我们的迁移逻辑是先查再插,不可避免的存在着 查询到插入期间,老表发生了更新,导致插入新表的数据和老表数据不一致了。

优化思考

  1. 首先,双写阶段,应控制仅双写insert,而update操作要判断新表是否有数据,有则执行,没有则仅更新老表,这样可以简化迁移过程中对于一致的考虑,存量数据在老表得到一致性保证,并最终迁到新表;
  2. 但到这问题还未得到彻底解决,因为上述的并发问题还存在,如果想彻底解决,有以下两种方案:

方案一:可能就需要对记录做行级锁,在查询后对老表记录加锁(第1条在这时也有优势体现),插入后解锁,但缺点是事务的范围比较大,容易有性能问题;

方案二:因为这个是一种极低概率的场景,我们也可以选择放任这种极低事件的发生,因为迁移一般都在流量低峰期(事实上我们在迁移过程中的采用埋点来看,并未发现这类问题,此处仅是延伸思考),之后通过数据比对的方式告警出来,再进行人工干预,在看我们的场景其实并不要强一致性,比如拉黑记录、司机的个性标签设置等,如果是资金类的高敏感、必须强一致类的数据,就没商量,一定要在性能和一致性之间选择后者。