分库分表:用应用层复杂度换数据库容量

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制
  6. Redis(下):超出缓存用途的用法:锁、队列与排行榜
  7. 数据库访问层:连接池与 MyBatis 的显式 SQL
  8. Kafka(上):吞吐的来源是顺序 IO
  9. Kafka(下):生产端、broker 与消费端的可靠性配置
  10. RPC 框架:像本地调用一样调远程的代价
  11. 消息语义:按 at-least-once 设计业务代码
  12. 熔断与限流中间件:把失败当作正常状态管理
  13. 唯一 ID 中间件 Leaf:号段模式与雪花模式
  14. RocketMQ 的事务消息与延迟消息
  15. 分布式任务调度:同一时刻只跑一份
  16. ZooKeeper/etcd:小数据强一致的协调服务
  17. 分库分表:用应用层复杂度换数据库容量(本篇)

单库接近容量上限的信号

分布式锁与唯一 ID介绍了云盘多实例部署下的全局 ID 方案。发号方案从 DB 自增改为号段,以及分库后需要处理序列语义,都与单库容量接近上限有关。本文继续讨论这一条件下的处理方式。

单库 MySQL 接近容量上限时,通常会出现三类信号。第一类是 DDL 执行时间拉长。给一张上亿行的表加索引或改字段,Online DDL 在 5.6 之后名义上不阻塞读写,但执行时间会随数据量增长;元数据锁被慢查询占用时,后续 DDL 也会阻塞。给大表加字段可能需要数小时,发布窗口需要避开这段时间。第二类是备份窗口拉长。mysqldump 或 XtraBackup 的耗时与数据量成正比。数据量增加后,备份可能持续到业务高峰。第三类是慢查询增多。数据量增大后,回表代价上升或统计信息出现偏差,原本使用索引的查询也可能让优化器选择错误的执行计划。

我在生产中处理过这三类问题,做法是垂直拆分加归档:将用户、内容、订单等可分开的业务拆到各自的库,并按时间将历史数据归档到独立表或独立库。热表数据量降低后,DDL 和备份可以回到可接受的时间范围。唯一 ID 中间件 Leaf:号段模式与雪花模式 的号段模式也在这一阶段引入。多库后自增主键不再全局唯一,需要由独立组件发号;具体实现见该篇。

我没有在生产中实施过水平拆分,即将同一张表按拆分键拆到多个库的多张同名表。下文关于水平拆分的内容基于 ShardingSphere 官方文档、MyCAT 文档和本地 ShardingSphere-JDBC 验证,属于学习与本地验证,不是生产经历。我的生产经验限于垂直拆分加归档;下文说明水平拆分的机制和代价,但不覆盖踩坑细节和大规模迁移的操作经验。

垂直拆分与水平拆分

拆分有两个独立维度。垂直拆分按业务切:用户库、订单库、内容库各自独立部署,每个库里的表结构不变,只是按业务边界分库。水平拆分按数据行切:同一张表(比如订单表)按某个键(比如 uid)把行散到多个库的多张同名表里。

通常先做垂直拆分,再考虑水平拆分。垂直拆分处理业务耦合带来的容量放大:一个库同时承载用户、内容、订单三类业务时,备份需要覆盖全部数据,DDL 和慢查询也会相互影响。按业务拆开后,每个库的容量和故障域独立,单库压力降低。水平拆分处理单表数据量过大,应在垂直拆分后单表仍无法承载时采用。若先做水平拆分,按 uid 分散的数据表还会混合三类业务字段,应用层同时需要按拆分键路由和按业务选择表,复杂度增加。按业务划分边界后,只在单个业务内部做水平拆分,路由逻辑更明确。

垂直拆分也会限制跨库 JOIN,并需要将跨库事务改为最终一致。不过每个业务内部仍是单库,业务内的 JOIN 和事务可以保持原有方式。水平拆分还会影响业务内部的 JOIN 和事务,需要按拆分键重新组织查询和操作。

拆分键影响数据分布、查询路由和扩容

水平拆分的拆分键决定数据分布、查询路由和后续扩容。变更拆分键通常需要重新迁移数据并调整路由,因此应在拆分前确定。

常见的拆分键选择是用户 ID(uid)或时间。按 uid 哈希取模散到 N 个分片,数据均匀、写压力分散,单用户的数据落在同一分片,单用户维度的查询和事务不用跨分片。代价是跨用户的查询要广播到所有分片再归并。按时间拆(每月一个分片)好处是范围查询天然落到少数分片、归档直接删旧分片,代价是最新分片永远是写热点。

选择拆分键时还要检查热点、跨键查询和扩容。**热点:**按 uid 取模可分散单分片写压力,但大客户的一个 uid 写入量可能等于其他几千个 uid,数据仍会倾斜。这类大客户需要单独隔离或再次拆分。按时间拆分则会持续产生最新分片写热点。**跨键查询:**后台按手机号查订单等查询不带拆分键。带键查询可路由到单个分片;不带键查询需要广播到全部分片再归并。N 个分片需要执行 N 次查询并进行一次归并,代价会随分片数增加。高频的不带键查询可将字段冗余到拆分键维度,或建立手机号到 uid 的反向索引表,先查映射再带键查询。**扩容:**uid mod 4 扩到 8 时,每个分片的数据都要重新分布。翻倍扩容有明确的迁移规则,例如原分片 0 的数据迁到新分片 0 和 4,但仍需要预留停写窗口。一致性哈希扩容只迁移落到新增分片的数据,迁移量较小,但数据分布不如取模均匀,实现也更复杂。

拆分后自增主键不再全局唯一,跨分片主键需要由发号组件分配。唯一 ID 中间件 Leaf:号段模式与雪花模式 介绍了号段和雪花两种模式,2016 系列第 12 篇介绍了云盘当时的发号选择,这里不再展开。雪花算法生成的 ID 还可以编码分片位,使发号组件直接确定数据所在分片,省去一次取模计算。这样会使发号组件与分片规则耦合,扩容时需要同步调整;分片数稳定时可减少路由计算,频繁扩容时则会增加调整工作。

代理层与客户端 SDK 的职责

水平拆分的路由逻辑由中间件承担。2020 年前的两种主流形态是代理层(MyCAT)和客户端 SDK(ShardingSphere-JDBC)。

MyCAT 是独立的代理进程。应用像连接 MySQL 一样连接 MyCAT,由 MyCAT 解析 SQL、按拆分键路由到后端真实库并归并结果。应用代码无需因切换分片规则而修改,DBA 可以在代理层统一管理路由配置。MyCAT 故障会使应用无法连接数据库,因此需要高可用方案或接受这一风险。代理层还负责 SQL 解析和结果归并,复杂查询的延迟和资源开销可能高于客户端 SDK。

ShardingSphere-JDBC 是嵌入应用进程的客户端 SDK。应用取得的 DataSource 由 ShardingSphere 包装,SQL 在应用进程内解析和路由,再直连后端真实库。该模式没有代理层单点,也少一次网络转发。路由配置需要在每个应用中保存一份,通常由配置中心统一下发;它与语言绑定,JDBC 生态限 Java。DBA 修改路由规则后,需要推动应用重载配置。多个语言的应用访问同一个库时,客户端 SDK 需要维护多份规则,代理层可在一处维护这些规则。

两种模式的职责划分不同。MyCAT 模式下,DBA 管理路由规则,研发编写 SQL 时通常不直接处理分片。ShardingSphere-JDBC 模式下,研发需要知道哪些查询带拆分键、哪些查询会广播;路由规则也可以由 DBA 在配置中心维护。选型需要与团队分工相匹配。

下面是 ShardingSphere-JDBC 的最小配置片段,按 uid 取模拆 4 库,本地验证过:

# ShardingSphere-JDBC 最小配置(YAML 形式,4 库按 uid 取模)
dataSources:
  ds_0: { dataSourceClassName: com.zaxxer.hikari.HikariDataSource, jdbcUrl: jdbc:mysql://host0:3306/db0, username: root, password: ... }
  ds_1: { dataSourceClassName: com.zaxxer.hikari.HikariDataSource, jdbcUrl: jdbc:mysql://host1:3306/db1, username: root, password: ... }
  ds_2: { ... }
  ds_3: { ... }

shardingRule:
  tables:
    t_order:
      actualDataNodes: ds_${0..3}.t_order      # 4 库同名表
      databaseStrategy:
        inline:
          shardingColumn: uid                   # 拆分键
          algorithmExpression: ds_${uid % 4}    # 取模路由
      keyGenerator:
        type: SNOWFLAKE                          # 主键用雪花,衔接 Leaf 篇
        column: order_id
  bindingTables:
    - t_order,t_order_item                       # 绑定表:同拆分键保证同分片,JOIN 不广播
  broadcastTables:
    - t_config                                   # 广播表:小字典表每库一份

bindingTables 要求订单表和订单明细表都按 uid 拆分且规则一致。配置绑定后,两表 JOIN 只会路由到对应分片,不会广播到全部分片。broadcastTables 将字典类小表冗余到每个库,JOIN 时作为本地表使用。绑定表需要持续保持一致的拆分规则,广播表更新时需要同步到全部分片。

水平拆分对 JOIN、事务和全局约束的影响

水平拆分会改变 JOIN、事务和全局约束的处理方式。

跨分片 JOIN 需要由应用层拼装。 同一分片内的 JOIN 仍可使用,绑定表可以保证这类 JOIN 路由到同一分片。跨分片 JOIN 需要广播后在内存归并,或由应用层分两次查询再拼装。复杂的多表 JOIN 通常无法直接执行,需要冗余字段到同一张表,或使用 ES 建立二次索引。原本一条 SQL 可以取得的数据,拆分后需要多次查询和应用层逻辑,代码复杂度和 RTT 都会上升。

跨分片事务改为最终一致。 同一分片内仍使用 InnoDB 单库事务,跨分片事务不再由数据库保证。XA 协议可以处理跨库事务,但协调者阻塞和持锁等待会增加高并发链路的成本,因此生产中较少使用。常见做法是将跨分片操作改为最终一致,例如本地事务加消息队列,或 TCC/Saga 补偿模式。消息语义:按 at-least-once 设计业务代码介绍了至少一次投递和消费幂等,分布式事务:2PC 的代价与业务补偿模式会专门讨论 2PC 与补偿模式。拆分后,事务边界从库缩小到分片,跨分片一致性由应用层和中间件处理。

全局排序分页与跨分片唯一约束。 单库下,ORDER BY create_time DESC LIMIT 10 由索引和执行计划直接处理。拆分后查询全局最近 10 条时,中间件会从每个分片取得 TopN,再在内存中归并前 10 条。执行 LIMIT 100000, 10 时,每个分片都要取得 100010 条数据,再归并出 10 条;偏移越大,代价越高。游标分页可按 create_time 和上一页最后一条的值过滤,将偏移转为条件,但无法跳页,需由业务确认能否接受。全局 COUNT 需要扫描所有分片并累加,通常由独立统计表异步维护,而不实时查询。单库中的 UNIQUE 索引由数据库保证,拆分后唯一性仅在分片内成立。跨分片唯一约束,例如同一个手机号不能重复注册,需要在应用层先查反向索引表再写入。冲突检测由数据库约束转为应用逻辑,遗漏的冲突只能通过事后对账发现。

翻倍迁移与初始容量预留

取模拆分下,分片数从 N 扩到 2N 时,原分片 i 的数据按 uid mod 2N 重新分布:一半留在原分片 i,一半迁到新分片 i+N,迁移规则明确。迁移期间需要处理停写窗口,否则写入会使源和目标数据不一致。常见流程是停写、迁移存量、追增量 binlog、校验、切流量、恢复写入。停写窗口取决于数据量和校验速度,小时级是常态,业务需要提前确认能否接受。

非翻倍扩容,例如从 4 到 6,迁移规则更复杂,多数行都要移动,工程上通常不采用。可选择一次翻倍,或预留分片:物理库建 16 个,逻辑分片只用 8 个,扩容时启用空闲的 8 个。代价是平时有一半库闲置。云数据库的存储计算分离和按需扩容提供了其他选择,但 2020 年前的自建 MySQL 集群仍需在预留和翻倍之间选择。

取模分片数确定后,扩容受翻倍约束,从 4 到 8 再到 16,每次翻倍都需要迁移数据。因此初始分片数需要预留余量,可以先拆出多个分片并部署在一台机器上,为后续翻倍扩容保留空间。这与热点和跨键查询一样,是选择拆分键时需要确认的条件。

是否拆分的决策条件

下面这张清单里,第 1、2、4、5、6、8 条来自垂直拆分加归档的实操,第 3、7 条涉及水平拆分的部分以学习认知为基础,未在生产验证。

  1. 数据量是否达到瓶颈。 单表或单库数据量是否已使 DDL 和备份窗口不可接受,应以真实监控数据判断。业务耦合放大容量的问题可先通过垂直拆分处理,再评估是否需要水平拆分。
  2. 写入 QPS 是否达到单库上限。 单库写入 QPS 受磁盘 IOPS 和 binlog 同步延迟等物理限制。读压力优先使用缓存(本系列第 5、6 篇)和读写分离处理;只有写入压力达到单库上限时,才评估拆分。
  3. DDL 频率。 需要频繁加字段或改索引的表,拆分后每次 DDL 都要跨分片执行,复杂度随分片数增加。这类表应谨慎拆分,或接受 DDL 工具(如 pt-online-schema-change 的分片版)的额外成本。
  4. 团队承接能力。 水平拆分后,应用层需要处理路由、拼装、最终一致和深分页,DBA 需要处理分片运维和扩容。团队缺少这些经验时,可先实施垂直拆分加归档;水平拆分的运维流程还需要通过实际操作验证。
  5. 归档可解决的问题。 将历史数据归档到独立库或独立表可降低热表数据量。归档通常可逆,风险低于拆分;能够通过归档处理的容量问题,不需要直接采用拆分。
  6. 读压力与写压力的处理方式。 读压力可使用缓存处理,写压力可先评估能否异步化(本系列第 8、11 篇)。拆库用于单库无法承载的写入和数据量问题。
  7. 拆分键所需条件。 热点需要可接受,跨键查询需要有处理方式,扩容路径需要预先确定。其中任一项不明确时,不应拆分;拆分后变更拆分键通常需要重新迁移数据。
  8. 回退条件。 拆分后回到单库需要重写大量查询路径。决策时应将回退视为需要重写查询路径的变更,而非低成本操作。

三种形态的 JOIN、事务、路由对照见下图。

单库、垂直拆分、水平拆分三种形态下 JOIN、事务、路由的对照

我只部署和使用过 MyCAT 这类 Proxy 层中间件,没有阅读 SQL 解析器、结果归并器和分布式事务协调等内部模块的源码;相关机制描述以官方文档和本地验证为准。ShardingSphere-JDBC 的本地验证覆盖 4 库路由、绑定表 JOIN、广播表和深分页场景。配置片段和对应现象以本地验证为准,未在生产环境验证。

参考资料

  • ShardingSphere 官方文档(ShardingSphere-JDBC 配置、绑定表、广播表、分页归并,3.x 与 4.x 版本,2020 年前)
  • MyCAT 官方文档与社区资料(代理层架构、路由规则、多租户,1.6 版本)
  • MySQL Online DDL 文档(5.6/5.7,DDL 执行时间与元数据锁行为)
  • pt-online-schema-change 文档(大表 DDL 工具)
  • 唯一 ID 中间件 Leaf:号段模式与雪花模式(号段与雪花模式、workerId 分配,本篇衔接分库后的全局 ID)
  • 分布式锁与唯一 ID(2016 系列第 12 篇,全局 ID 方案谱系,本篇引用不复述)
  • 消息语义:按 at-least-once 设计业务代码(拆分后跨分片一致性改最终一致时的消费幂等)
  • 分布式事务:2PC 的代价与业务补偿模式(拆分后跨分片事务的补偿模式,本篇衔接)
  • 本系列第 5、6 篇《Redis(上/下)》(读压力优先用缓存解决,不靠拆库)

742 字 · 71 段落
ximing

Follow onGitHub

相关文章