ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MySQL分库分表实战:从瓶颈分析到分片键选型与平滑迁移

MySQL分库分表实战:从瓶颈分析到分片键选型与平滑迁移 我从接触 MySQL 到现在踩过最大的坑不是某条 SQL 写错了而是明明单表单库还能扛业务方却喊着要分库分表又或者单库早就快撑爆了却没人敢动最后在凌晨大促时直接雪崩。第十六章我想认真聊聊分库分表这件事。这个章节可能和你之前看过的所有分库分表教程都不一样——我不打算上来就给你一堆中间件配置而是想先把“考量”这两个字讲透。我一直觉得分库分表本质上是一次用复杂度换容量的交易。你换了什么你换来了单库单表装不下的数据容量、换来了更高的写入吞吐上限。但你付出的是什么是 SQL 能力的全面退化、是事务边界被打碎、是跨库查询基本告别、是运维和排查问题的难度直接翻倍。这笔交易值不值完全取决于你对自己业务的理解有多深。所以这一章我会按我自己的决策路径来拆先搞清楚单库单表到底在哪个环节撑不住了再决定用哪种拆分方式然后正视拆分后那些躲不掉的新问题最后把账算清楚再动手。这篇文章适合两类人一类是数据量还没到瓶颈、但想提前做好架构规划的开发者另一类是已经在分库分表的边缘试探、被各种中间件术语绕晕的运维或后端同学。我尽量不堆概念多讲实例和取舍过程。1. 先搞明白单库单表到底卡在哪一个环节很多人一提分库分表就说“数据量大了要拆”但这个说法太糊了。数据量大不一定等于必须分库分表。你得先定位到具体是哪一种“大”把你压垮了。MySQL 的性能瓶颈通常来自四个方向单表容量、连接数、锁竞争、主从延迟。我建议你拿出一张纸把这四个方向当成分诊维度按自己业务的症状去对号入座。1.1 容量和写入吞吐的硬天花板先说最容易理解的容量问题。单表数据量涨到一定程度后即使索引建得很完美B树的层级也会变深索引页在缓冲池里的命中率会下降随机 IO 成本会上升。以前我维护过一个业务表数据量在三千万行左右时响应还在百毫秒内过了五千万行后同样的 SQL 开始往一秒以上走。那不是 SQL 写得烂而是整棵索引树的体积已经超出了服务器内存能舒服承载的范围每次查询都要带着大量磁盘 IO 在跑。写入侧的瓶颈更直接。单库单表的写入吞吐受制于磁盘 IO 和事务日志的刷盘速度。你想想所有订单都往一张表里插主键索引要维护二级索引要维护Binlog 和 Redo Log 要写这一套动作在单机上是有物理极限的。很多业务在走向分库分表之前其实是先被写入吞吐卡死的——高峰期一秒几千笔订单写入单库的 IO 已经持续打满这时候哪怕表里只有一千万行数据你也得考虑水平拆分了。1.2 连接数、锁竞争和长事务的连锁反应连接数是个容易被忽视的瓶颈。MySQL 默认最大连接数就那么多每个连接还要吃线程栈内存连接数一多上下文切换和锁竞争就开始明显起来。我见过一个典型的场景业务早期所有表都在一个库里后来服务拆了微服务每个服务都按自己的习惯连同一个库连接池全开满结果数据库的线程数飙到几千CPU 全耗在线程调度上业务 SQL 反而排队等执行。锁竞争和长事务就更隐蔽了。单表行数大了之后你可能会为了统计报表跑一些大范围查询或者某个服务在事务里先查后更新把一批行锁得死死的。平时看不出来一旦赶上业务高峰更新同一行的请求全部堆积慢查询就像滚雪球一样越来越多最终把整个库拖垮。分库分表确实能从物理上把锁竞争分散——不同的分片落在不同的物理库上一个库上的锁竞争再严重也影响不到另一批分片上的请求。1.3 分库分表不是第一步先排查这些便宜方案我必须强调一句分库分表永远不应该是你想到的第一个方案它是把其他便宜方案都用尽之后的最后手段。在我见过的项目里至少有一半喊着要分库分表的其实用更便宜的手段就能解决问题。先看索引和 SQL。很多慢查询根本不是数据量大而是索引没建对或者 SQL 写法让索引失效了。你完全可以先开慢查询日志把执行时间超过几百毫秒的 SQL 捞出来一条条 EXPLAIN看看是不是走了全表扫描是不是发生了隐式类型转换导致索引失效。这块的优化空间往往非常大而且几乎零成本。然后看冷热数据分离。很多业务表的行数虚高是因为历史数据一直堆在里面。把几个月前的订单归档到历史表或者直接扔到归档存储里线上表瘦身之后性能能立刻回来一大截。这个操作比分库分表简单太多而且效果立竿见影。再往上才是读写分离和缓存。如果你的瓶颈主要在读那在主库后面挂只读从库把报表、查询类的流量全部导过去主库就能腾出手专门服务写入。热点数据再往 Redis 里放一层数据库的压力能再降一个量级。把这些低成本手段都做完了还是撑不住这时候才轮到分库分表上场。2. 分库分表的两条拆法垂直和水平到底怎么选分库分表不是只有一种拆法。很多人一听这四个字就以为是把一张大表按某种规则拆成多张表其实这只是水平拆分。在动手之前你得先分清垂直拆分和水平拆分这两条路线它们的适用场景和代价完全不同。2.1 垂直拆分按业务域拆库按字段热度拆表垂直拆分的思路是把不同的东西分开。拆库的层面是把原本塞在一个库里的多个业务域拆出去独立部署比如把订单库、用户库、商品库从一个大库中拆出来各自独立运行。这样做的好处是订单业务的写入高峰期不会再和商品业务的查询任务争抢同一个数据库的 IO 和连接资源。从微服务架构的视角看这也是很自然的一步——服务都按业务域拆了数据库还挤在一起反而奇怪。拆表的层面是把一张字段特别多的宽表按字段的访问热度拆成多张表。比如某张表有四十个字段其中十几个核心字段每次查询都要用剩下二十多个字段只有少数场景才读。那你就把高频字段留在主表里低频字段拆到扩展表用主键关联。这能显著减少单行数据占用的存储页数量让同一个数据页能容纳更多行查询时扫描的数据量自然就下来了。垂直拆分的代价相对可控它没有改变数据的分布方式事务边界也没有被破坏——订单主表和订单扩展表还在同一个库里跨这两张表的关联查询仍然可以正常做。所以我的建议是垂直拆分通常是比水平拆分更优先考虑的一步因为它简单、风险小、收益明确。2.2 水平拆分按主键取模、按时间范围、按业务键哈希垂直拆分解决不了单表数据量持续增长的问题。订单主表就算把所有低频字段都拆走了它的行数还是在涨总有一天会触及单表的容量天花板。这时候就得做水平拆分——把同一张表的数据按照某种规则分散到多张结构完全相同的表里。取模分片是最经典的方式。比如我先把表拆成 16 张子表然后按主键对 16 取模余数是几就进哪张表。这个方案实现简单数据分布也够均匀但有一个非常痛的痛点——一旦未来数据量涨到 16 张表也装不下了需要扩容到 32 张那所有已有数据的取模结果都会变意味着已经落库的数据几乎全部要重新分布。所以做取模分片的人通常会在最开始就预留大量分片比如直接拆 1024 个逻辑分片映射到 32 个物理库上以后扩容只需要动映射关系不用动数据。按时间范围分片则是另一种思路。它不追求数据在所有分片间均匀分布而是按时间自然地切段。比如订单表按月分片每个月的订单进当月的表里。好处是扩容逻辑非常自然——时间到了就自动落入新分片而且归档历史数据特别方便直接对旧分片做操作就行。坏处是热点会非常集中——当前月份的分片承载几乎全部读写流量其他分片全是冷数据。这个方式比较适合日志、流水类对实时写入吞吐要求高、但对数据分布均匀性不敏感的业务。按业务键哈希是在取模基础上的改良。它不对主键取模而是先拿分片键做哈希计算再映射到分片。和取模相比哈希可以让数据分布更均匀而且通过一致性哈希之类的算法在扩容时可以做到大部分数据不动只有一部分数据发生迁移。不过一致性哈希也有自己的复杂度比如虚拟节点的管理、迁移期间的数据一致性处理这些都需要额外的功夫。2.3 拆分的粒度分库还是分表先拆哪个分库和分表经常被放在一起说但它们解决的问题侧重点不一样。分表解决的是单表数据量大、索引层次深、查询性能下降的问题分库解决的是单库连接数有限、磁盘 IO 和 CPU 资源被占满的问题。我见过不少团队上来直接拆成 32 库 × 64 表一共两千多张物理表结果运维成本和元数据管理的复杂度一下子飙升。我个人的建议是除非你的数据量真的到了单库都撑不住的地步否则优先只做分表不做分库。多个分表可以放在同一个实例里通过表名后缀区分这样既能控制单表容量又不用付出多实例部署运维的额外成本。等到单库的连接数、IO 也顶不住了再把分表打散到多个库上。一个比较稳妥的组合是分片键为业务主键按哈希分片逻辑拆成比较多的分片数物理上先映射到较少的库。比如拆 64 个逻辑分片映射到 4 个物理库每个库 16 张表。后续如果 IO 和连接数吃紧不需要动数据只把逻辑分片重新映射到 8 个库上就行。这种方式留足了未来的扩展空间又控制住了当前的复杂度。3. 拆完才是麻烦的开始那些躲不掉的新问题分库分表真正劝退人的不是拆分动作本身而是拆分之后那一连串新问题。很多团队就是在这里翻的车——拆之前只想着容量解决了拆完之后才发现连最基础的主键生成都要重新设计。这些问题你必须在动手之前就有心理准备。3.1 全局唯一主键不能用自增之后怎么办单表单库的时候自增主键是个好东西简单、有序、性能好。但分库分表之后它立刻失效了——多张表各自自增主键必然重复。多实例部署的时候甚至连 MySQL 的 auto_increment_offset 和 auto_increment_increment 这种双实例错位方案都只能算是权宜之计它把主键的生成和实例数量绑死了以后加实例还要重新调。业界比较成熟的方案有两种。一种是用号段模式由一个中心服务统一分配 ID 段。比如每次取一万个 ID 过来业务侧在这一万个 ID 里顺序发放用完了再去取下一批。这种方式的好处是主键是数字且递增对索引友好但引入了一个新的中心组件它挂了会影响所有分片写入。另一种就是雪花算法。它把一个 64 位的长整型拆成时间戳、机器号、序列号三段不用依赖中心服务每台机器自己就能生成全局唯一的数字主键。不过雪花算法有个很经典的坑——它依赖机器时钟如果系统时钟发生回拨生成的 ID 就可能重复。我见过有人因为 NTP 校时导致时钟回拨主键撞了花了好几天排查。所以用雪花算法一定要在代码里做时钟回拨的防护逻辑比如发现时钟回拨就先阻塞等待或者直接抛异常。3.2 跨库查询与分页Join 没了排序也乱了分库分表之后原来一条 SQL 就能完成的操作现在要么做不了要么要做很多额外工作。最典型的就是跨库 Join。单库时代 Join 订单表和用户表是一条 SQL 的事分库之后这两张表里的数据可能分散在不同的物理库上数据库层面已经没有能力直接做关联了。解决思路通常有两条一条是在应用层把数据查出来自己拼。比如先查订单分片拿到订单里的用户 ID 列表再去用户库里批量查用户信息然后在内存里完成关联。这要求代码里对关联操作有清晰的设计不能什么地方都来一发。另一条是说反范式——在订单表里直接冗余用户名称之类的字段用存储空间换查询性能。全局排序分页更是重灾区。单库里的 ORDER BY ... LIMIT 10 直接执行就行分库之后每个分片都得把各自的前 10 条查出来然后到应用层做归并排序。这还算好真正的噩梦是深分页。比如要取第 999990 到第 1000000 条每个分片都得查出各自的前 100 万条再交给应用层合并这个成本高到几乎不可接受。实践里比较靠谱的做法是放弃深分页改成基于上一页最大 ID 的滚动查询——每次只取大于上次游标的 N 条这种方案在分片环境下实现简单性能也稳定。3.3 分布式事务从本地事务到最终一致性单库事务是数据库帮你保证的——要么全成功要么全回滚。分库分表之后一个业务操作可能要更新多个分片上的数据每个分片是独立的事务再也不可能做到全局原子回滚了。分布式事务不是不能做而是代价非常大。强一致方案比如两阶段提交协调者、参与者来回协商性能损耗明显而且协调者本身还会成为新的单点。对于互联网高并发业务来说大多数情况下选的是最终一致性路线——把一个大事务拆成多个本地事务通过消息队列串联起来。比如下单操作先写订单分片同时往消息队列里发一条消息库存服务消费消息后扣减库存。如果扣减失败就靠消息重试来补偿。这不是什么高深的技巧但很多没经验的人会低估它的复杂程度。你不仅要设计消息的可靠投递和消费幂等还要处理各种对账逻辑。我建议你用“能不用分布式事务就不用必须用时优先考虑最终一致性”作为基本准则。在分库分表架构里追求强事务一致性是性价比极低的事。3.4 非分片键查询最容易被低估的坑所有分库分表方案都默认一个前提——你所有的查询都能带上分片键。比如订单表按订单号分片那按订单号查详情完全没问题路由直接命中单个分片。但业务是不可能永远只按分片键查的。按用户 ID 查所有订单、按商家 ID 查订单统计这种需求几乎百分百会出现。处理非分片键查询的办法有几种每一种都有明显代价。最简单粗暴的是全分片扫描——把请求广播到所有分片再在应用层汇总结果。数据量小、请求频率低的时候可以这么干但数据量大了之后一次查询把所有分片都打一遍整个数据库集群都会被拖累。比较常见的替代方案是建立冗余索引表——单独建一张以查询维度为分片键的表比如订单表按订单号分片同时维护一张按用户 ID 分片的订单用户映射表。还有一种是倒排索引方案把非分片键的值和分片键的对应关系写到搜索服务里查询先走搜索服务拿到分片键集合再回数据库精确查询。我个人最常用的还是冗余表因为逻辑简单、可控性强。但具体怎么选得看你那个非分片键查询的重要性和频率。如果一次查询要打遍几十个分片而且天天被人调用那不管用哪种方案你都必须认真对待了。4. 真正的考量策略动手之前把这些账算清楚前面讲的都是分库分表之后会面临的问题。现在可以回到标题里的核心词——“考量”了。我自己的习惯是在决定分库分表之前先把下面这三本账算清楚要不要拆、拆多少片、分片键选谁。这三笔账一旦定下来后面基本就是按部就班执行的问题。4.1 容量估算到底要不要拆拆多少片先算第一笔账当前和未来的数据量。我一般用这个公式做粗算——日新增数据行数乘以保留周期得到单表数据量的峰值再和单表的安全容量阈值对比。单表的安全容量阈值没有绝对标准和服务器配置、查询模式、索引数量都有关但凭经验大部分 MySQL 单表超过两千万行之后查询性能会开始出现可感知的下降五千万行以上就需要非常小心了。我习惯把两千万行当作危险线超过这条线就得有明确的拆表计划。用一个实际例子算一下。假设你的业务每天新增一百万行订单数据线上最多保留 90 天那表里最多会有九千万行。按单表两千万行的安全线算需要拆成至少 5 张表。但你不能只按当前算得留出一年到两年的增长空间。如果业务每年翻一倍那两年后日新增就是四百万行保留 90 天就要有三点六亿行按两千万一表就是 18 张表。我会直接把分片数定在 32这个数量既不会让元数据和路由过于复杂又留了足够的缓冲空间。这里要特别提醒很多人算账时只看了数据总量却忽略了一个分片均衡的问题。如果你的分片键选得不好数据分配不均匀有的分片装了两千万行有的分片只有两百万行那整体容量规划就会失真。所以容量估算一定要基于分片键的分布特性来做不能想当然地总量除以分片数。4.2 分片键选型为什么说选错分片键是最大的成本分片键是整个分库分表架构里最不能改的一个决定。业务代码写错了可以重构数据分布不均匀可以通过扩容调整但分片键一旦上了线所有数据都按它散开了想换一个分片键基本等于把所有数据重新洗一遍牌。这个成本大到绝大多数团队都承受不起。选分片键有两条核心原则一是要足够均匀让每个分片的数据量和访问量都差不多不能有热点键二是要覆盖主要查询路径让最重要的查询都能直接命中分片。还是拿订单举例如果你的核心查询是“查某个订单的详情”那订单号就是天然的分片键。如果业务里更常见的是“查某个用户的所有订单”那你可能就得考虑按用户 ID 分片同时接受按订单号查询时需要多走一层映射的开销。均匀性这方面我吃过亏。以前有个业务用商家 ID 做分片键当时觉得商家数据量均匀没问题。结果后来某个大商家的数据量暴涨单个分片扛了其他分片好几倍的读写流量每次活动期间那个分片最先报警还影响同分片里其他小商家的业务。所以我现在选分片键一定先做数据分布评估把键值的分布直方图拉出来确认没有明显的长尾才敢定下来。4.3 分库分表工具与中间件选型确定要不要拆、怎么拆之后就到了选型环节。市面上常见的分库分表方案大致分成两类代理型和客户端型。代理型中间件独立部署业务代码无感知由代理层解析 SQL、做路由、归并结果。好处是业务侵入小换数据库或者调整分片规则时不用改应用代码。坏处是多了一层网络代理一次查询多一跳延迟会有轻微增加同时代理本身会成为新的性能和可用性关注点部署和运维成本都不低。客户端型则是把分片逻辑以依赖的形式集成在应用里由应用自己完成路由和结果归并。好处是少一跳网络开销性能更好也没有独立部署的中间件要运维。坏处是分片逻辑和业务代码强耦合未来如果要做大规模分片规则调整升级依赖会牵扯所有应用服务排期和成本都不小。选代理还是选客户端取决于团队规模和架构风格。如果公司运维能力很强愿意把中间件当成基础设施来维护代理型会舒服很多。如果是小团队、业务节奏快我更推荐客户端型因为它接入简单、出了问题更好排查。不管选哪种我都建议把分片路由的计算规则独立成一层不要在业务代码里散落各种分片逻辑的硬编码。4.4 扩容思维一次性分到位还是动态扩容关于扩容的问题我一直提倡一个观点能提前多分就不要指望以后动态扩容。分库分表架构里的扩容永远是一块硬骨头。物理分片数的调整往往需要大量的数据迁移和双写校验整个过程中稍有不慎就会丢数据或者产生不一致。所以更务实的做法是在项目一开始就设计比当前需求大得多的逻辑分片数物理分片数量则按当前数据量来配置。比如逻辑分片拆成 1024 份每份在路由表里映射到物理库和物理表当前只有 16 个物理库每个库 64 张表。以后数据量翻了几倍只需要再增加物理库调整映射关系把一部分逻辑分片的流量引到新库上去。这个过程不需要对已有数据做完整重分布只需要做有限范围的数据搬迁。这种思路不是万能的它要求你从一开始就接受“路由映射多一层”的复杂度和元数据管理开销但从长远看是值得的。5. 从单库到分库分表的搬迁路线平滑切换不背锅架构设计是一回事真正的难关在搬迁。把线上一个几千万上亿行的单表平滑切到分库分表架构还不让业务感知这事比设计分片方案本身难十倍。我自己做过几次类似的搬迁方案基本是同一套路子这里完整梳理一遍。5.1 搬迁前的现状盘点与目标设计搬迁第一步不是写代码而是把现状彻底盘清楚。包括现有表的总行数、每日数据增量、读写比例、高峰期 QPS、最耗时的 SQL 清单、依赖这张表的下游服务清单。没有这些数据你根本没法设计搬迁节奏也没法判断切完后性能是否真的达标。目标设计这块要把分片键、分片数量、路由规则先确定下来并且做一遍全量数据的模拟路由。什么意思就是把现有数据的业务主键按你的分片规则跑一遍映射看看每个分片的数据分布是否均匀有没有哪个分片明显多出一截。这一步在迁移前做成本几乎为零但能提前发现分片键选型的问题避免数据都搬过去了才发现案板不对。5.2 双写方案的设计细节正式搬迁的核心是双写。所谓双写就是老的单表单库继续保持写入同时把同样的写入操作同步到新的分库分表结构中。业务上的每个写操作先写老库通过一个同步组件把数据复制到新库。这样新库会持续追上老库的数据直到两边基本对齐再选择低峰期完成切换。双写听起来简单细节里全是坑。第一个坑是双写的一致性问题。同一笔写操作老库成功了新库失败怎么办这需要设计同步的失败重试与补偿机制。我一般会用一个本地消息表来做削峰填谷——写操作在老库执行后同步往消息表里插一条记录后台任务消费这些记录写入新库写完就标记。如果新库写失败记录保留任务重试重试也不行就需要告警和人工介入。第二个坑是历史数据的回放。双写只能同步从开启时刻开始的新数据存量几个亿的历史数据得另外靠离线任务一次性灌入新库。所以搬迁的完整流程通常分三段先跑离线任务把存量数据灌入新库同时开启双写同步增量然后再做数据校验对比新库和老库的数据是否一致确认一致后把读流量也切过来完成整个切换。5.3 数据校验与灰度放量数据校验这块我见过太多人图省事只比总数或者抽查几条结果上线后暴雷。正确的做法是做字段级的哈希校验——按分片维度把每张分片表的主键和关键业务字段拼接后计算哈希值再和老库对应的数据计算哈希逐片比对。如果某些分片校验不一致要能定位到具体是哪些主键的记录出了问题。灰度放量也是一个不能跳的步骤。切流量的时候不要一下子把全部流量切到新架构而是先放 1% 的流量跑一段时间观察错误率和延迟指标确认稳定后再逐步提高到 5%、20%、50%、100%。每次调大放量比例之前都要重新看一眼新库的负载和数据一致性的校验结果。5.4 失败回滚最容易被忽略的退路最后说一下回滚。很多人做迁移时满脑子都是怎么切过去从来没想清楚万一切过去出了大问题怎么切回来。但恰恰是这个被忽略的问题决定了你整个迁移方案能不能被批准。我建议在做方案设计时先回答一个问题如果新架构在高峰期出现严重问题老架构还能不能在一分钟内恢复服务通常的做法是在切换后的观察期内老库继续保留接写的能力同步进程也不要立刻停。新架构跑得稳没问题再停老库的写入通道。不要一切过去就把老库的写入关掉那样等于自断后路。灰度放量的每个阶段都要能独立回滚这样风险才是可控的。最后说点个人的体会分库分表做了这么多次我最深的感受是它本质上是一个工程问题而不是一个技术问题。技术上无非是哈希路由、数据迁移、双写校验这些东西真正难的是什么时候该做、做到什么程度、怎么在业务不感知的情况下完成切换。你不需要成为一个分库分表专家才能做这个决策但你必须足够了解自己的数据特征和访问模式。如果让我给一个最朴素的建议那就是在动手之前先用最大努力去尝试不拆分也能解决问题的方案。索引优化、冷热分离、读写分离、缓存这些手段能把你分库分表的需求推后一到两年。等真正到了那一天你会发现自己对业务的认知已经足够清晰拆起来也会顺手很多。而如果从一开始就想着靠分库分表解决所有问题多半会收获一个比原来更复杂的系统。
RELATED READING

延伸阅读

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