ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分库分表实战:从瓶颈判断、分片键选型到平滑扩容与中间件落地

分库分表实战:从瓶颈判断、分片键选型到平滑扩容与中间件落地 先把话说在前面这篇文章不是给那种数据量刚过百万就开始焦虑的团队看的。拆库拆表这件事一旦动手后面分布式事务、跨节点查询、平滑扩容、中间件维护每一环都是成倍往上加的复杂度。我见过太多团队因为感觉数据要爆了就匆忙上了分片方案结果半年后发现瓶颈根本不在这里白白背着这套复杂度走了一年。我希望用这篇文章把分库分表真正该解决的场景、落地时的关键选型、以及那些文档里不会主动告诉你的坑一次性讲透。这篇文章适合谁正在评估要不要分库分表的架构师、已经在分片方案里踩坑的开发者以及准备给现有系统做扩容的团队。我会从瓶颈判断讲起一直聊到中间件选型和隐藏的运维坑每部分都会带上实际工程里的决策理由和数学依据。1. 先聊清楚分库分表到底在解决什么问题先说一个我亲历的场景。某个核心业务表单表三千多万行主键查询稳定在 12 毫秒左右看起来还能扛。但到了促销时段并发一上来慢查询直接从 100 毫秒飙到几秒DBA 半夜被叫起来看报表。后来把这张表拆到了 16 个分片同等流量下 P99 从 800 毫秒降到了 40 毫秒。这个案例说明了一个经常被误解的事实单表真正的瓶颈不是纯数据量而是数据量叠加并发之后对连接数、磁盘 IO、内存缓冲的冲击。分库分表的核心目的是让每个分片的写压力、连接数、磁盘 IO 各自独立不再让整个系统被一个单点拖死。1.1 一半的瓶颈不在数据量而在连接数与并发能力很多团队看到数据量过千万就急着规划分库分表其实应该先搞清楚到底慢在哪。MySQL 单库的连接数默认上限通常是 151即便你调高到几千连接池一打满所有请求都在排队。这种情况下哪怕数据量只有几百万照样会把整个库拖垮。反过来如果数据量真的到了亿级但业务是低并发的内部系统单表大概率也能撑住。我见过不少项目拆完库之后发现问题根本没解决因为瓶颈压根不在表的大小而在查询语句和索引设计上。一堆没走索引的查询、大范围扫表、冗余字段反复拉取这些都是先要把 SQL 问题理清楚再谈分库分表的理由。拆库不是万能药它解决的是单点能力上限问题而不是SQL 写得烂的问题。1.2 B树索引下的千万级拐点InnoDB 的主键索引是 B树结构默认页大小 16KB。假设主键是 8 字节的 bigint加上 6 字节的指针一个非叶子节点页大概能存 1170 个条目。一个三层 B树第一层是根节点第二层 1170 个页第三层就能覆盖约 136 万个数据页。如果每个数据页按 16KB 容量、每行记录 1KB 来算大约能承载千万到亿级的行数。超过这个量级之后要么 B树加深到第四层要么活跃索引和数据缓存放不进 Buffer Pool随机读和回表代价陡增。这就是千万级成为普遍拐点的根本原因也是判断要不要分片时需要关注的核心指标之一。比起有多少行更应该关注三层结构装不装得下、活跃数据能否常驻内存、单条主键查询的响应时间是否明显劣化。1.3 读慢和写慢处理方向完全不同分库分表解决的是写并发以及数据分散后的读并发。如果瓶颈集中在读优先考虑的是缓存和读写分离把读流量从主库剥离而不是直接拆表。反过来说如果业务就是读多写少拆了库反而让本来可以走缓存的一次简单查询变成跨库路由得不偿失。判断读慢看的是 CPU、Buffer Pool 命中率、慢查询数量判断写慢看的是锁等待、redo 日志写入、IO util。两个维度要分开分析再决定要不要引入分片这套复杂度。我常跟团队说的一句话是分库分表是最后的手段不是第一个选项。2. 分片键与分片算法方案落地前最关键的选型分片键是分库分表方案里唯一一个选了就很难回头的决策。算法不好可以调参中间件不好可以换分片键一旦定错所有数据都已经按错误的路由分布好了想改就是全量重迁。所以这一节值得花最多的时间。2.1 分片键选错后面全是灾难分片键至少要满足三个条件高频查询带得上、数据分布足够均匀、业务值不能轻易变。高频查询带得上意思是你的核心业务查询条件里必须包含这个字段。用户表用 user_id 分片基本不会有问题。订单表如果按 order_id 分片用户查询自己的订单时应用层就得知道每个订单的 order_id 才能定位到分片否则只能广播路由到所有分片再聚合性能直接废掉。更常见的做法是订单表按 user_id 分片这样用户维度查询天然收敛到单库商家维度查询再用宽表、ES 或额外索引服务兜底这是接受不对称查询代价的典型设计。业务值不能变这一点最容易被忽略。我见过有人用手机号做分片键用户换一次手机号数据迁移就成了大工程甚至要专门写一套改号迁移工具。分片键一旦涉及可变更字段等于给整个系统埋了一颗定时炸弹。2.2 哈希分片和范围分片的数学本质取模分片比如 user_id % 16的数学本质是把数据打散到固定数量的桶里。只要 user_id 是均匀分布的流量和数据量就能近似均匀落在 16 个分片上这是高并发场景最常用的策略。但它的代价也很明确范围查询、排序、Min/Max 这类操作会变成全分片广播性能断崖式下降。范围分片的本质是按业务维度切段比如按订单时间按月分表天然支持查最近三个月这种场景。但它的弱点同样明显数据分布天然不均月初高、月末低热点始终集中在一两个分片上。实际工程里大多数人不是二选一而是混着用。比如订单表按月份做一级划分再按 user_id 做二级取模这样既保住了时间维度的查询能力又尽量把并发摊开。也可以反过来user_id % 16 做主分片时间维度靠 ES 冗余一份用于检索。没有最好的算法只有最匹配业务访问模式的算法这句话值得反复强调。2.3 一致性哈希逻辑合理运维未必划算一致性哈希通过哈希环和虚拟节点让扩缩容时只需要迁移环上部分节点附近的数据。这在缓存层面的收益很直观。但在数据库层面少量迁移只是理论上的数据落盘、索引重建、校验比对、流量切换每一步都是实打实的工程成本。虚拟节点一多元数据维护和路由复杂度也会同步上升。我个人的看法是如果扩容不频繁稳定的取模路由加预先规划好的 2 的幂次分片数4、8、16、32比一致性哈希更实用。只有当你预计节点会频繁加减并且团队有成熟的自动化迁移工具时一致性哈希才值得上。这个决策要结合团队的运维能力不能只看算法先进。3. 分布式事务分库之后最沉重的代价分片键定完之后另一个被团队低估的问题才真正浮出水面那就是分布式事务。单库时代一个事务搞定的事拆库之后可能要协调多个物理库ACID 不再自动成立这是分库分表最沉重的隐形代价。3.1 事务为什么在分片之后变难单库事务靠的是 undo log、redo log、锁整套机制在库内闭环。拆库之后比如一次下单操作要写订单表、扣库存表、扣账户余额这三张表可能分布在三个不同的物理库上。任何一个节点成功、另一个节点失败数据就处于不一致状态。所以分库分表之前必须先盘点一下核心链路里有没有这种跨库写操作。如果有要么接受分布式事务的成本要么改造业务流程把强一致需求收敛到一个库里。很多团队拆完库才意识到这个问题再回头改业务代价就大得多了。3.2 强一致方案XA 两阶段提交与 TCCXA 的 2PC 是最经典的强一致方案由事务协调者先对所有分支执行 prepare全部成功后再逐个 commit。问题在于 prepare 阶段要一直持有锁资源协调者一旦宕机分支节点可能长时间阻塞。金融对账这类对强一致要求苛刻的场景还能接受普通互联网业务的响应时间和可用性都扛不住这种阻塞。TCC 则是把一个大事务拆成 Try、Confirm、Cancel 三步。Try 阶段做业务检查并预留资源Confirm 阶段真正提交Cancel 阶段做补偿。以库存扣减为例Try 是锁定库存但没真扣Confirm 才真正扣减Cancel 则释放预留。TCC 不依赖数据库的 2PC业务控制力强但每个操作都要写三段代码开发成本高而且补偿逻辑本身还得保证幂等。这里有个很现实的问题TCC 的 Confirm 和 Cancel 如果也失败了怎么办所以补偿操作一定要设计成可重试的并且要配上告警和人工介入通道。没有完整的重试和监控体系TCC 的补偿链条反而会成为新的故障源。3.3 最终一致方案本地消息表与 Saga本地消息表是这个领域非常经典、也容易落地的方案。核心思路是业务在主库更新数据同时写一条消息表记录两个操作在同一个本地事务里提交异步任务把消息表记录推给 MQ 或对端系统对端消费成功后反查主库确认更新状态。关键点在于消息和业务数据同库同事务天然不丢消息。代价是消息表本身成了额外的写入开销消费端必须做到幂等。Saga 则是把一个长事务拆成一串本地事务每个本地事务都配补偿逻辑。比如创建订单、扣库存、扣余额三步扣库存失败就反向执行回滚订单的补偿动作。Saga 追求最终一致代码结构比 TCC 轻一些但补偿流程的设计更考验对业务的理解。说实话90% 的互联网业务场景用最终一致就足够了真正必须上 XA 的场景非常少。选方案之前先做业务分类资金类、库存强一致场景优先考虑 TCC 或 XA普通的订单状态流转、积分发放、通知类场景本地消息表加 Saga 足够。4. 跨节点查询的解法能下推就下推能不查就不查分库分表之后最难受的其实不是写入而是查询。原来一条 Join 就能搞定的事现在要么广播到所有分片要么在应用层拼装两种都是性能灾难。这一节聊的都是实际工程里被验证过的降级策略。4.1 Join 的三种替代第一种是冗余字段。下单时把商品名称、单价、快照信息直接冗余到订单表查询订单列表就不需要 Join 商品表。冗余带来的数据一致性问题可以通过异步刷新和定期对账来兜底。这是最推荐优先做的。第二种是宽表加 ES。把需要关联展示的数据同步到一张宽表或 ES 索引里订单查询直接走 ES分片库只负责写入和点查。对搜索类、列表类场景尤其合适代价是引入一套同步链路。第三种是应用层拼装。先用主键批量查出订单再拿关联表的 ID 批量去查另一张表最后在内存里合并。适合数据量可控、关联深度不深的场景。这三种方案的选型逻辑是能冗余解决的就不要 Join能走索引的不要走聚合能不实时查的不要实时查。4.2 聚合统计的降级策略跨库的 count、sum、order by、group by如果每次都全分片广播基本就废了。常规解法是预聚合定时任务把统计结果汇总到一张汇总表线上查询只读汇总表。比如订单中心要展示今日订单数、销售额完全可以每五分钟跑一次聚合任务写入汇总表线上接口直接查这张表。更重一点的场景是独立的数据仓库。把分片库的 ODS 层数据同步到数仓报表、分析、运营看板全部走数仓分片库只服务 OLTP 实时查询。这套架构虽然多了一套数据同步链路但换来的是 OLTP 和 OLAP 互不干扰也是大公司的标准做法。4.3 全局唯一 ID 的设计不能只解决唯一分库之后数据库自增主键不再全局唯一。UUID 直接当主键会让 B树索引碎片化严重写入性能下降明显。实际用得最多的是雪花算法64 位长整型时间戳加机器 ID 加序列号全局唯一、趋势递增。用的时候要特别注意时钟回拨问题代码里要做回拨保护否则会出现重复 ID。号段模式则是从发号器批量取一段 ID应用本地缓存使用适合对全局有序有要求的场景比如对账文件、业务流水号。但这里有一个容易混淆的点全局 ID 解决的是唯一性不解决路由。即使订单主键是雪花 ID查询时还是得靠 user_id 这个分片键才能定位到具体分片。所以设计主键的时候可以把分片键信息编码进 ID比如在雪花 ID 里预留分片位这样从 ID 就能直接反推分片号省一次查询。5. 平滑扩容的一次完整推演分片方案上线之后迟早会面对扩容。取模路由的扩容之所以难是因为节点数一变已有数据的路由全部失效必须做数据重分布。这一节我把整个推演过程完整走一遍。5.1 取模路由的扩容难题以 user_id % 4 为例数据分布在 4 个分片。要扩容到 8 个分片原来算出来的分片号全部失效大约一半的数据要搬到新节点。最简单的做法是停机迁移停写、备份、批量重分布、校验、切流。这种方案实现简单但窗口期内业务不可用适合对停机窗口容忍度高的系统。很多团队早期就是用这种方式扛过来的关键在于迁移窗口要短、校验要严格、回滚预案要提前演练。如果业务要求不停机就要用下面这种更平滑的方案。5.2 双写加迁移的四阶段方案四阶段平滑扩容是目前实践里比较成熟的模式。第一阶段新旧两套分片规则并行业务写操作同时写旧库和新库读还是走旧库。双写这个环节的关键是新库的写入不能影响主库的响应时间所以通常通过 MQ 异步消费来实现而不是同步双写。第二阶段批量迁移历史数据按照新规则重新计算路由逐批搬到新库。迁移的时候按主键范围分批每批几千条边迁边校验。第三阶段逐步切读流量到新库按比例灰度。比如先 10% 流量试跑观察错误率和延迟再逐步放大到 50%、100%。第四阶段旧库只保留缓冲期观察稳定后下线。这期间旧库的写入还要维持一段时间因为双写链路里可能还有延迟数据在消费。5.3 2 库扩 4 库的具体推演用一个实际数字来感受一下。当前分片是 user_id % 2两个库各占一半数据。扩容到 user_id % 4对每个旧分片来说有一半的数据要迁到对应的新分片。如果数据总量 2000 万迁移量就是 1000 万左右。迁移顺序建议按主键范围分桶进行每个批次 2000 到 5000 条具体批次大小要看单条数据的大小和网络带宽。每批迁移完成之后马上做三件事比对源库和目标库的 count、比对关键字段的 checksum、记录迁移日志水位。这个水位是断点续传的基础万一迁移程序崩了从水位直接继续而不是从头再来。还有一个容易被忽略的细节迁移过程中源库数据如果还在变化必须保证迁移的是一个一致快照或者配合增量补偿。实操里更简单的做法是先启动双写等新库开始积累增量数据之后再跑历史数据迁移。这样历史数据基本静止双写阶段的新增数据靠 MQ 链路补充两条链路不会互相打架。6. 中间件落地对比ShardingSphere、MyCat 与自研的边界分片方案的理论再完整最终还是要靠工具落地。中间件选型这件事很容易被Star 数带偏但真正应该对比的是架构形态和 SQL 兼容范围。6.1 客户端分片与服务端代理ShardingSphere-JDBC 是客户端分片模式在应用 JVM 内部完成 SQL 改写和路由性能好、延迟低但每个服务实例都要集成依赖升级时全量发版。ShardingSphere-Proxy 是独立部署的代理服务对应用透明应用只连接一个虚拟数据库代价是多一次网络转发和 SQL 解析。MyCat 是同类代理中的老牌选手功能覆盖广但 SQL 兼容性和后续维护力度需要单独评估。Vitess 则更重量级与 Kubernetes 生态绑定深适合超大规模场景但团队需要具备较强的运维能力。6.2 选型时的关键细节不要只对比功能清单要看三点对比维度客户端分片ShardingSphere-JDBC服务端代理Proxy/MyCat自研路由层部署复杂度低随应用发版中独立部署运维高需自研自维性能损耗低进程内路由中多一次网络跳转取决于实现SQL 兼容覆盖依赖引擎解析能力依赖代理解析能力只覆盖自研范围升级成本全量应用发版独立升级影响面可控完全自控适合阶段中大型业务团队内落地快多语言团队、需要统一入口访问模式极固定第一要确认你线上 SQL 的方言范围子查询、批量更新、distinct、union 这类写法在分片模式下的支持程度不同中间件差别很大。第二分布式事务的支持深度。第三全局 ID 和分片键的集成方式。最可靠的办法是把线上出现频率最高的 20 条 SQL 拉到测试环境用候选中间件分别跑一遍对比改写之后的执行计划是否合理。这个测试看起来笨但比看任何文档都有效。6.3 自研路由层的合理边界当访问模式非常固定、分片规则清晰简单、团队对 SQL 兼容性要求很窄时自研一层轻量路由也是合理选择。比如所有查询都天然带 user_idSQL 不需要复杂改写自己写一个分片路由比引入完整中间件更轻。但要注意一旦业务 SQL 开始变多自研的成本会迅速超过中间件这个边界要收敛得很清楚。我的建议是初期可以用中间件快速验证分片效果等访问模式固定、团队对路由规则完全掌握之后再考虑是不是要沉淀自己的轻量路由层。直接一步自研风险很大。7. 上线之后那些藏得比较深的坑方案上线、策略选型都做完了并不代表可以松一口气。分库分表的坑往往不在上线当天暴露而是在运行的第三个月、半年后开始慢慢冒出来。这一节聊几个藏得比较深的坑。7.1 非分片键的唯一索引分库之后唯一约束只能在分片内部保证跨分片重复是常态。比如用户名唯一如果按 user_id 分片注册接口就不能依赖数据库唯一键去重必须先查一遍所有分片或者全局索引。常见做法是把唯一字段单独做一张全局映射表或者在应用层引入一个全局唯一性校验服务。这个设计一旦漏了上线后就会冒出大量重复数据而且清洗成本极高。7.2 慢 SQL 排查的难度上升加了中间件之后同样的慢查询问题排查链路变得更长了。explain 出来的是逻辑 SQL 改写后的物理 SQL你要多一步根据日志里的分片信息直接连到对应的物理库去跑分析。排查思路具体来说有三件事要做分片路由日志留全每个物理库的慢查询日志单独采集把请求 ID 通过打印链路串联到具体分片。没有这三样慢 SQL 排查会变成一个非常痛苦的体力活。7.3 备份与恢复的复杂度单库时代的全量备份加 binlog 回放在分片场景下变成了多库并行备份、串行恢复的问题。恢复的时候要注意各分片数据的一致性时间点建议备份策略直接按分片维度做每个分片单独备份而不是所有分片混在一个备份集里。同时定期做全链路的恢复演练不然真正出故障时光恢复流程就能让值班的人崩溃。最后再分享一个小技巧。我在实际落地的过程中给每个分片库的命名用的不是 order_0、order_1 这种数字后缀而是 order_a、order_b 这种随机后缀。表面上看起来不够整齐但排查问题时能强迫自己去看完整的连接信息而不是想当然地以为某个数字分片一定对应哪台机器。这个看似不起眼的习惯帮我发现过不止一次路由配置错位的低级失误也算是一点过来人的经验了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进