最近用到了分库分表,也见识到了某业务表数已经到了几万张。下面主要是对分库分表平滑扩容 这篇文章的学习和整理

分库分表

为了增加db的并发能力,常见的方案就是对数据进行分库分表

如果所有数据都塞到一个表里面,数据条数达到一定量级的时候,数据查询操作将会是一件很恐怖的事情

这个需要在初期对数据规划有一个预期,从而预先分配出足够的库来处理。

1
2
3
4
5
                 Service
            /       |       \
 uid%3=    0        1        2
           |        |        |
 DB        A        B        C  

数据会均衡地分到每个DB里面,查询的时候会根据余数来定位数据所在库或表

扩容及产生的问题

后续业务发展的速度很快,用户量数据大量上升,当前容量不足以支撑,应该怎么办?

需要对数据库进行水平扩容,再增加新库来分解。新库加入之后,原先sharding到3个库的数据,就可以sharding到四个库里面了

1
2
3
4
5
6
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%4=    0       1        2      3
           |       |        |      |
 DB        A       B        C      D

不过此时由于分片规则进行了变化(uid%3 变为uid%4),大部分的数据,无法命中在原有的数据库上了,需要重新分配,大量数据需要迁移。

新增的这个结点会导致90%的数据需要迁移,一般会有以下几种方式

分库

停服扩容

流程:

  • 预估停服时间,发布停服公告
  • 停服,通过事先做好的数据迁移工具,按照新的分片规则,进行迁移
  • 修改分片规则
  • 启动服务

这种方式比较安全,停服之后没有数据写入,能够保证迁移工作的正常进行,没有一致性的问题。唯一的问题,就是停服了和时间压力了。

  • 停服,伤害用户体验,同时也降低了服务器的可用性
  • 必须在制定时间内完成迁移,如果失败,需要择日再次进行。同时增加了开发人员的压力,容易发生大的事故
  • 数据量的巨大的时候,迁移需要大量时间

那有没有其他方式来改进一下,我们看下以下两种方案。

升级从库

一般都会每台主库Master配一台从库Slave,读写在主库,然后主从同步到从库。这就是所谓的主从复制 如下,A,B是主库,A0和B0是从库。

1
2
3
4
5
       Service
    /           \
    A           B
    |           |
    A0          B0

此时,当需要扩容的时候,我们把A0和B0升级为新的主库节点,如此由2个分库变为4个分库。同时在上层的分片配置,做好映射,规则如下:

uid%4=0和uid%4=2的分别指向A和A0,也就是之前指向uid%2=0的数据,分裂为uid%4=0和uid%4=2 uid%4=1和uid%4=3的指向B和B0,也就是之前指向uid%2=1的数据,分裂为uid%4=1和uid%4=3

因为A和A0库的数据相同,B和B0数据相同,所以此时无需做数据迁移即可。只需要变更一下分片配置即可,通过配置中心更新,无需重启。

1
2
3
4
5
6
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%4=    0       1        2      3
           |       |        |      |
 DB       A0       A        B      B0

由于之前uid%2的数据分配在2个库里面,此时分散到4个库中,由于老数据还存在(uid%4=0,还有一半uid%4=2的数据),所以需要对冗余数据做一次清理。

而这个清理,不会影响线上数据的一致性,可是随时随地进行。

处理完成以后,为保证高可用,以及下一步扩容需求,再分配从库

1
2
3
4
5
6
7
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%4=    0       1        2      3
           |       |        |      |
 master    A       B        C      D
 slave     A0      B0       C0     D0

归纳起来:

  • 修改分片配置,做好新库和老库的映射。
  • 同步配置,从库升级为主库
  • 解除主从关系
  • 冗余数据清理
  • 为新的数据节点搭建新的从库

双写迁移

原理和上述相同,做分裂扩容,只是数据的同步方式不同了。

1. 增加新库写链接

对需要扩容的数据库上,增加新库,并对现有的分片上增加写链接,同时写两份数据。

因为新库的数据为空,所以数据的CRUD对其没有影响,在上层的逻辑层,还是以老库的数据为主。

1
2
3
4
5
6
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%2=    0       0        1      1
           |       |        |      |
 DB        C       A        B      D

2. 新老库数据迁移

通过工具,把老库的数据迁移到新库里面,此时可以选择同步分裂后的数据(1/2)来同步,也可以全同步,一般建议全同步,最终做数据校检的时候好处理。

1
2
3
4
5
6
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%2=    0       0        1      1
           |       |        |      |
 DB        C  <-   A        B  ->  D

数据校验

按照理想环境情况下,数据迁移之后,因为是双写操作,所以两边的数据是一致的,特别是insert和update,一致性情况很高。但真实环境中会有网络延迟等情况,对于delete情况并不是很理想,比如:

A库删除数据a的时候,数据a正在迁移,还没有写入到C库中,此时C库的删除操作已经执行了,C库会多出一条数据。

此时就需要做好数据校检了,数据校检可以多做几遍,直到数据几乎一致,尽量以旧库的数据为准。

分片配置修改

数据同步完毕,就可以把新库的分片映射重新处理了,还是按照老库分裂的方式来进行

u之前uid%2=0,变为uid%4=0和uid%4=2的 uid%2=1,变为uid%4=1和uid%4=3的

1
2
3
4
5
6
                —    Service   —
             /     /        \     \
            /      |        |      \
 uid%4=    0       2        1      3
           |       |        |      |
 DB        C       A        B      D

平滑分表

这是一个非常经典的系统设计题,也是高阶后端面试中的必考题

业界的标准答案通常是采用 “同步双写 + 历史数据回溯 + 校验 + 灰度切换” 的方案。这个方案最稳健,可回滚,能保证数据的一致性。

以下是为你整理的不停服平滑扩容标准流程,以及面试时的亮点话术

核心流程图 (Mermaid)

  graph TD
    Phase1["阶段1: 方案准备"] --> Phase2["阶段2: 开启双写(Double Write)"]
    Phase2 --> Phase3["阶段3: 历史数据迁移"]
    Phase3 --> Phase4["阶段4: 数据一致性校验"]
    Phase4 --> Phase5["阶段5: 灰度切读(Switch Read)"]
    Phase5 --> Phase6["阶段6: 停写旧表(Switch Write)"]
    Phase6 --> Phase7["阶段7: 清理旧表"]

    subgraph Phase2_Detail ["阶段2: 双写细节"]
        P2_1["应用层改造"] --> P2_2["新增/更新操作同时写旧表和新表"]
        P2_2 --> P2_3["以旧表返回结果为准"]
        P2_3 --> P2_4["新表写入失败仅记录日志,不影响主流程"]
    end

    subgraph Phase3_Detail ["阶段3: 迁移细节"]
        P3_1["记录迁移开始时的位点(如ID或时间)"] --> P3_2["后台脚本批量读取旧表存量数据"]
        P3_2 --> P3_3["写入新表(INSERT IGNORE/ON DUPLICATE KEY UPDATE)"]
        P3_3 --> P3_4["追赶增量数据"]
    end

详细步骤

第一阶段:开启双写 (Double Write)

这是最关键的一步。在不停止服务的情况下,通过代码改造(中间件或业务层),让增量数据同时写入旧表和新表。

  • 操作:修改 DAO 层代码,对 INSERT, UPDATE, DELETE 操作进行双写。
  • 原则
    • 以旧表为准:读操作依然走旧表。
    • 同步/异步:为了数据强一致,建议同步双写。如果对性能极其敏感,可以考虑异步(MQ),但增加了复杂性。
    • 异常处理:新表写入失败不能影响主流程,只需记录 Error Log,后续通过对账修复。

第二阶段:历史数据迁移 (Data Migration)

双写解决了未来新增数据的同步,现在需要把旧表里几亿条存量数据搬到新表。

  • 工具:可以使用阿里开源的 DataX,或者自写 Golang 脚本。
  • 难点:迁移过程中,数据还在变(因为在双写)。
  • 策略
    • INSERT IGNORE:如果新表已经有数据(双写进来的),以新表(双写)的最新的为准,迁移脚本不覆盖。
    • 或者利用 ON DUPLICATE KEY UPDATE,但这取决于你的双写逻辑是否包含版本号或时间戳判断。
    • 通常做法:迁移脚本只负责搬运“旧数据”。如果遇到主键冲突,通常意味着双写已经写入了更新的数据,跳过即可。

第三阶段:全量数据校验 (Data Verification)

  • 操作:编写校验脚本,分批次对比旧表和新表的数据。
  • 方法
    • 全量比对:最稳妥,但慢。
    • 抽样比对:根据时间窗口,重点比对最近修改的数据。
    • 修复:发现不一致,以旧表为准覆盖新表。
  • 直到:数据一致性达到 100%(或可接受的 99.9999% 且剩余差异可控)。

可以设置一个异步对账系统(Data Reconciler),它会读取 Binlog 或者定时扫表,对比新旧库的关键字段。一旦发现不一致,会自动触发修复逻辑,强制将新表数据修正为旧表状态。在切读之前,我们必须保证连续 N 个周期的对账差异数为 0。”

第四阶段:灰度切 (Gray Scale Read)

此时新旧表数据已一致,开始验证新表的抗压能力和数据准确性。

  • 操作:通过配置中心(如 Nacos, Apollo)控制读流量开关。
  • 策略
    • 1% 流量走新表 -> 观察日志/报错 -> 10% -> 50% -> 100%。
    • 回滚:一旦发现新表查询报错或数据不对,秒级切回旧表。这是双写方案的最大优势。

第五阶段:切写 (Switch Write) & 下线

读流量完全切到新表后,旧表实际上已经没有读流量了,但还在双写。

  • 操作
    1. 停止写旧表(此时才算真正完成迁移)。
    2. 解除双写逻辑,代码中只保留写新表。
    3. 旧表归档备份,然后 Drop。