最近总有人问我:"你们项目用过MySQL的分库分表吗?"这句话看着简单,背后信息量其实很大。有人是想确认你有没有扛过大数据量的场景,有人是想打听一整套方案怎么落地,还有人就是单纯想听拆分之后踩了哪些坑。这篇文章我把这几年做MySQL分库分表的真实经验从头捋一遍:什么时候该拆、怎么拆、拆完怎么兜住,以及那些文档里不会写的教训。全程不打算从B+树底层原理讲起,而是直接围绕工程落地展开,适合正准备拆库拆表、或者已经被分库分表折磨过一阵子的朋友参考。
1. 先搞清楚:分库分表到底在解决什么问题
1.1 单库单表撑不住的三个典型信号
先说一个最常被问的点:MySQL单表到底能存多少行?网上答案五花八门,有人说两千万、有人说一个亿,其实都不准。能不能撑住,跟机器磁盘能力、缓冲池大小、表的行宽、索引设计、业务读写比例都有直接关系。我见过三千万行还能跑得飞快的订单表,也见过几百万行就把库拖垮的日志表,差距就在这些细节里。
真正值得关注的不是"数据总量"这个数字,而是下面三个信号有没有同时出现。
第一个信号是写入瓶颈。单主库的写入TPS接近上限,连接一多就开始排队,主从延迟从几十毫秒慢慢拉大。这时候你会发现,加索引、调参数都救不了写入路径,因为瓶颈已经落在了单机顺序写能力上,再优化SQL也压不出空间。
第二个信号是查询瓶颈。B+树层级随数据量增加慢慢变深,缓冲池命中率往下掉,慢查询开始隔三差五冒出来。你把现有慢SQL一条条优化掉之后,新的慢SQL又出现,说明问题不在某一条SQL本身,而是整体容量已经接近边界。
第三个信号是容量瓶颈。单库磁盘撑不住历史数据,归档做完一遍又一遍,备份时间越来越长,一个DDL在线执行要跑几个小时。容量问题是最容易被忽视的,因为它是一点点逼近的,等你看清楚的时候往往已经很被动了。
这三个信号单独出现一个,可能还不用拆库;两个以上同时出现,并且你已经把索引、缓存、归档这些常规手段用尽了,那才轮到分库分表登场。
1.2 分库分表不是万能药,先判断要不要拆
我见过不少项目,数据量刚过百万就喊着要分库分表,最后把架构复杂度搞上去,过两年又费劲拆回来。分库分表是重型武器,不是常规弹药,乱用的代价远超收益。
以下几类情况不要急着拆:
- 查询慢但数据量不大:优先看索引。联合索引、覆盖索引、最左前缀这些基础工作没做好就分库,纯属掩耳盗铃。
- 读多写少但没到极限:先上主从读写分离和缓存层,把读压力打掉,往往就够用两三年。
- 未来增量预期不明显:有些系统数据总量很快就收敛,没必要为了一时的峰值去做永久性的结构改造。
- 没有稳定的业务维度:找不到一个长期稳定的分片键,硬拆只会让查询逻辑变成灾难。
判断要不要拆,我习惯列一份简单清单:数据总量是不是已经超出单库存储和备份的可承受范围;写入TPS是不是逼近单主上限且难以水平扩展;归档、备份、DDL这些运维动作是不是已经失控;业务上是不是有一个天然稳定的拆分维度,比如用户ID、订单ID或者租户ID。清单上至少有两项打勾,并且常规优化手段已经用尽,才考虑动手。
我印象里最典型的一个反面案例,是某只读报表系统在单表两千万行的时候天天慢查询告警,大家上来就讨论分库分表。结果仔细一看,核心查询根本没匹配上索引,一条SQL扫了上百万行。把索引调整完,大部分慢查询当场消失。这就是典型的"该优化却去拆库"。
2. 拆分策略:垂直切与水平切的取舍
2.1 垂直拆分:先按业务模块和字段特性切开
垂直拆分分两层意思。第一层是按业务模块分库,比如把用户、订单、商品三个域拆成三个独立数据库。这样做的好处是职责清晰、故障隔离,订单库挂了不会拖垮用户登录。代价是从单一数据库变成多个数据源,应用层要按业务打散连接,跨库的Join和事务必须重新设计。
第二层是表级别的垂直拆分,也就是把一张宽表拆成多张窄表。常见做法是冷热字段分离,比如把content、remark这类大字段拆到扩展表,主表只保留高频查询字段。原因很简单:InnoDB按行存储,行越宽,一个数据页容纳的行数越少,相同数据量下缓冲池命中率自然越低。把大字段挪走后,主表查询的IO成本往往能降一大截。
垂直拆分实施起来相对简单,但它解决的是"单库太杂、单表太宽"的问题,解决不了"单表数据量特别大"的问题。业务模块再多、字段再干净,一张表几亿行的时候,该水平拆还是得水平拆。
2.2 水平拆分:按数据行把压力摊平
水平拆分才是真正意义上的"把蛋糕切开分着吃"。核心思想是选择某个分片键,把一张逻辑大表的数据按路由规则分布到多张结构相同的物理表中,甚至可以分布到多个数据库实例上。
最常见的例子是订单表按用户ID拆分。假设表数量是32,路由规则就是 hash(user_id) % 32,每一条订单落到编号从0到31之间的某一张物理表上。这样单表数据量降到原来的三十二分之一,写入和查询的压力顺带分摊到32张表上。
水平拆分里有三个关键决策点,缺一不可:
- 分片键怎么选。这是整个方案的地基。分片键必须满足两个条件:分布足够均匀,避免某几个键把数据压在一个分片上;业务上被高频使用,查询条件里能稳定带上它。最理想的就是用户ID、订单ID、租户ID这类天然业务主键。
- 分片数量定多少。定少了,一两年后又要扩容;定多了,管理成本和连接开销上来,单表数据量太小反而浪费。我实际项目中比较常见的做法是按未来三到五年的数据增量预估来定,初始宁愿多拆一点。
- 路由规则怎么定。这直接关系到分布均匀度、查询效率和扩容难度,下面单独说。
水平拆分对应用的影响是全方位的:SQL写法要变、事务边界要重画、数据迁移要有方案,设计阶段一定要把长期成本想清楚。
2.3 分片算法:取模、范围、时间戳各有利弊
路由规则直接决定数据分布均匀度和后续扩容难度,不同场景差别很大,下面把常见算法逐一说明。
取模分片是最经典的做法:key % N 得到分片编号。优点是实现简单、分布均匀,尤其配合用户ID这类散列值很高的字段。缺点是扩容很讲究:从32扩容到64是完美的翻倍拆分,迁移路径清晰;从32扩容到48,绝大多数数据都要重分布,迁移成本极高。一般来说,取模分片只考虑翻倍扩容这一条路。
范围分片是按区间划分,比如用户ID 1到1000放0号表,1001到2000放1号表。好处是范围扫描友好,扩容时能只增加新区间,不动老数据。坏处是热点问题明显,新增用户都往区间尾部跑,尾部表容易先被打爆。适合数据天然有区间属性、且各区间热度差不多的场景。
一致性哈希把哈希值空间组织成一个环,每个节点落到环上,数据归属它顺时针遇到的第一个节点。最大优势是节点增删只影响环上一小部分数据,扩容迁移量远小于取模。代价是实现复杂度高,需要引入虚拟节点解决分布不均,维护起来更考验团队功底。
时间分片适合日志、流水、账单这类只往尾部追加的数据。按天或按月分表,查询天然带时间范围,归档和删除直接drop旧表。问题也很明显:回表永远是当月热点,一旦当月数据量爆了,单个分片照样撑不住。
| 算法 | 数据分布 | 扩容难度 | 热点风险 | 场景建议 |
|---|---|---|---|---|
| 取模 | 均匀 | 翻倍扩容成本低,非翻倍成本高 | 低 | 用户、订单等高频业务 |
| 范围 | 依赖区间划分 | 新增区间容易 | 高 | 数值区间天然均匀的场景 |
| 一致性哈希 | 均匀(配合虚拟节点) | 迁移量小 | 低 | 节点频繁变化的场景 |
| 时间 | 按时间单调 | 新增分片容易 | 高(当月热点) | 日志、流水、账单 |
3. 中间件选型与核心实现方案
3.1 应用内SDK和独立代理,两条路线选哪条
确定了拆分策略之后,下一步就是怎么把路由规则落地。目前主流方案分成两大类:一类是侵入应用的SDK模式,一类是独立部署的代理模式。
SDK模式的典型形态是把路由逻辑打进应用内部,数据源在应用层被重写。请求带着分片键进来,SDK算好该访问哪个库哪张表,直接建立连接执行SQL。好处是几乎没什么额外网络开销,性能损耗很小,应用启动时就拿到逻辑数据源。坏处是每个语言栈都要有对应SDK,修改拆分配置往往要重新发布应用。
代理模式则部署一个中间层服务,应用仍然像连普通MySQL一样连代理,代理解析SQL后帮你去各分片执行并聚合结果。对应用来说几乎透明,业务代码改动小,DBA维护也顺手。代价是多一跳网络,时延有损耗,而且代理本身的SQL兼容性、连接管理、高可用会变成新的一层运维负担。
我的选型倾向是:团队规模不大、语言栈集中、追求性能的时候优先SDK模式;团队有专门DBA、应用语言多样、希望把路由逻辑收口的时候,可以选代理模式。很多项目实际是"SDK为主、代理为辅"的混合形态,核心业务走SDK,运维管理平台走代理。
3.2 分布式ID生成:分库分表必须先解决的第一件事
拆表之后,数据库自增主键就不能用了:多张表各自从1开始自增,一定会撞车。就算给每张表设置不同的初始值和步长,也只是绕开局部冲突,ID的全局唯一性、趋势递增性很难兼顾。所以分库分表的第一个前置任务,是换一套分布式ID生成方案。
数据库号段模式是工程化程度最高的一种:设计一张专门的ID生成表,存业务类型、当前最大ID、步长。应用每次申请时按步长取一段号区间缓存在内存里,用完再向数据库申请下一段。比如某次申请拿到1000到1099这一段,应用内存里直接分配即可。这样数据库写入压力极小,ID天然趋势递增,对MySQL索引很友好。
雪花算法是另一种常见方案,64位ID由时间戳、机器编号、序列号拼接而成。它纯算法实现,不依赖数据库,生成速度极快,适合高并发场景。它有个著名的坑:时钟回拨会生成重复ID,必须做偏差纠正或熔断保护。我实际排查过一起ID重复事故,折腾半天才发现是NTP校准导致的。
UUID也有团队在用,但最核心的问题是随机性太强导致索引局部性极差。InnoDB二级索引场景下,随机无序的ID会让索引页频繁分裂,写入性能明显下降,所以尽量不要在核心表用它。
号段模式里"号段缓存"这个点值得多说一句:应用拿了号段没用完就重启,这段号就浪费了。步长设置要结合实际并发量和重启频率,太小了会频繁请求生成表,太大了会有号段浪费。订单场景512、1024都算常见值。
3.3 路由与执行细节:全路由是大忌
路由是分库分表最核心的执行环节。应用发来一条SQL,中间件要做两件事:解析SQL提取分片键的值,然后按路由规则计算目标分片。带分片键的查询能直接定位到单个分片,这是最理想的情况;不带分片键的查询没有定位依据,只能把所有分片都扫一遍再归并,专业说法叫"全路由"。
全路由是分库分表里的大忌。每一次全路由都意味着N倍的查询放大,线上只要有一条高频全路由SQL,整个集群的性能都会被拖崩。所以在设计阶段就要对业务SQL做盘点:哪些查询必带分片键,哪些偶尔不带可以接受,哪些完全不能接受全路由。后者就需要做映射表或者走汇总层。
这里有两个概念值得展开。
绑定表解决的是多表关联的定位问题。两张表如果分片键一致、分片数量一致,关联查询时中间件就能保证关联数据落在同一分片,直接在本地做Join,不需要跨库取数。比如订单表和订单明细表都按order_id分片,查某张订单的明细就永远是单分片操作。这是我做分库分表最推荐的设计约束:有父子关系的表,分片键必须对齐。
广播表解决的是字典数据的分发问题。像商品分类、国家地区、状态码这类数据量小但到处要Join的表,把它们复制到每一个分片中,查询时本地Join,不需要跨库。代价是写操作要广播到全部分片,但字典表写入频率极低,这点成本完全可接受。
4. 分库分表之后绕不开的五大难题
4.1 跨库Join与聚合查询怎么破
分库分表之后,原本一条SQL就能完成的Join,现在数据散落在多个库里,数据库层没法直接做。处理思路一般有三层。
第一层是字段冗余。查询需要的关联字段直接冗余到主表里。比如订单列表要展示用户昵称,那就把昵称冗余进订单表,下单时写入或者异步更新。这样既不用Join也不用跨库,查询性能反而更好。代价是字段一致性问题需要业务侧做补偿。
第二层是应用层组装。先按主表分片查出主数据,再根据关联ID列表去第二张表查询,在内存里合并。这种方式实现简单,适合关联量可控的场景,但要注意避免循环查询,批量处理关联ID才能控制请求次数。
第三层是引入汇总层。把需要跨库聚合的数据通过异步任务汇总到一个专门的宽表或OLAP存储,查询直接走汇总层。这在报表统计类场景几乎是标配。分库分表之后,在线库负责高并发明细查询,汇总层负责统计分析,各司其职。
4.2 分布式事务的取舍
分库分表之后,一个业务操作可能会跨多个库写入,原来的本地事务保护不到了。严格意义上的分布式事务非常昂贵,两阶段提交的性能损耗和协调复杂度都很大,实际工程里我更倾向用最终一致性方案兜底。
最基本的做法是避免跨库事务:重构业务逻辑,把一个完整流程尽量约束在同一个分片内。订单创建时,把订单主表和明细表按同一分片键写入,一次本地事务就搞定,这是最优解。
确实无法避免跨分片写入时,常见落地方案是本地消息表或事务消息。核心逻辑是:主流程先写业务表和消息表,同一个本地事务;异步任务轮询消息表,把状态变更推送给下游分片;下游处理成功后回执,消息表里的记录标记为已完成。整个链路是典型的最终一致性,可以接受短暂的中间状态。
TCC、SAGA这类模式在特定场景也有价值,但工程复杂度太高,投入产出比要仔细算。我的经验是,能缩事务边界就缩,缩不了再上消息,直接上TCC的项目大部分都在为过度设计买单。
4.3 数据扩容与迁移:翻倍迁移和在线双写
分库分表最怕的不是拆的时候费劲,而是拆完没多久就扩容。取模分片要做扩容,经典做法是翻倍扩容:分片数从N变成2N,每个旧分片只需把自己一半数据搬到新分片,路由规则规律,迁移路径清晰。如果分片数不是翻倍增加,比如从3变成4,重分布数据量会急剧上升,操作成本直接翻几倍。
在线迁移的通用流程大致如下:
- 新建扩容后的分片集群,建好表结构。
- 对旧分片开启binlog采集,持续捕获增量变更。
- 把历史存量数据按新路由规则导入新集群。
- 增量数据实时同步到新集群,用校验工具对比两边的数据量和抽样数据。
- 确认一致后修改应用的路由配置,把流量切到新集群。
- 先放少量灰度流量观察,确认没问题再全量放开,全程保留回滚预案。
这套流程里最容易翻车的是校验环节。只对比总数远远不够,要按分片维度对比,还要抽样校验关键字段。另一个容易忽略的是切换期间的新老链路并存问题,切流一定要有灰度过程,不要一把梭。
4.4 全局主键切换与分页排序的坑
全局主键的问题在前面分布式ID章节已经展开,这里提一个容易被忽略的关联点:分片键和主键经常不是同一个字段。比如用user_id做分片键,但表主键是order_id。分库之后order_id必须换成全局唯一ID,很多团队只分了表、忘了换主键生成方式,上线不久就撞主键,这种事故我见过不止一次。
分页排序的坑更隐蔽。单表时一句limit 100000, 20就完了,分库分表之后,这句SQL会变成每个分片都取100020条,然后中间件把所有分片结果合并、排序、再截取20条。越翻越慢,翻到后面,查询成本高得吓人。
深分页的正确解法是Keyset分页,也就是用上次返回的最大ID作为游标。比如where user_id = ? and id > last_id order by id limit 20,每个分片哪怕只取20条也能归并出正确结果。前提是排序字段稳定,通常用主键ID。如果产品一定要支持随机深翻,建议单独上汇总层或搜索引擎,不要硬怼在线库。
4.5 跨分片查询的兜底:映射表与汇总层
分片键设计得再好,业务上总会有一些场景是"带不上分片键"的。最常见的解法是映射表:建一张"业务键-分片键"的薄表,先用业务键查到分片键,再定位分片。比如订单表按order_id分片,但业务要按user_id查订单,那就维护一张 user_id 到 order_id 的关系表,查询时先走映射再走分片。
映射表的代价是写路径会多一次IO,而且映射表本身也可能增长得很快,需要考虑它的存储和分片方式。另一个思路是汇总层:通过binlog异步把在线库的数据同步到宽表或OLAP存储,统计和即席查询全部走汇总层,在线库保持轻量、只承接在线事务。这能把大量"查不了分片键"的问题整体消化掉。
兜底方案的顺序我一般这样排:先约束业务查询必须带分片键;做不到的用映射表;报表统计类的需求直接丢给汇总层。别把希望寄托在"跑得慢归并一下也行"上,全路由的放大效应在线上一旦爆发,压制很难。
5. 实操避坑经验:那些文档里不会写的事
5.1 分片键选错,后面全是债
分片键是整个分库分表方案里唯一一个"上线后改不了"的决策,一旦选错,后续所有补救都是打补丁。
最常见的问题是只考虑了写入分布均匀,没考虑查询路径。比如某团队把订单表按order_id分片,写入确实均匀了,但业务方高频查询是"某个用户的所有订单",而user_id和order_id之间没有映射关系。结果就是查询全部变成全路由,每条SQL都要扫所有分片再归并,性能直接崩盘。
正确做法是把查询路径梳理清楚再定分片键:先把核心业务的高频查询清单列出来,看哪个字段出现频率最高,再评估这个字段的取值分布。如果user_id和order_id都能作为分片键,优先选择高频查询稳定携带的那个。实在要支持另一种查询,再建映射表或汇总层,算折中方案。
5.2 翻倍扩容听着简单,迁移流程没那么美好
取模分片天然支持翻倍扩容,这是它的最大优点。但"支持"不等于"容易",真做起翻倍迁移,还是要走完整流程:新集群搭建、全量导入、增量订阅、校验、切流、回滚预案,一个环节都不能少。而且翻倍扩容意味着整个集群规模翻倍,成本是实打实翻上去的,不是改个数字就完事。
这里有个常被忽略的细节:翻倍扩容之后,如果未来还要再一次翻倍,旧节点上的数据会再往新建节点迁移,迁移期间对磁盘IO和网络带宽的占用都会影响线上。所以我对扩容的建议是"宁多勿少":初期分片多了只是管理麻烦一点,分片少了后面扩容才是大工程。
另外,如果用的是一致性哈希,扩容不要求翻倍,迁移量小很多;但虚节点和环重建带来的数据重新平衡同样要盯着。别指望算法本身能替你解决运维复杂度,监控和演练不能省。
5.3 运维配套不跟上,分库分表就是给自己挖坑
分库分表拆完之后,运维视角的变化往往被低估。原来一个库、一组监控、一套备份策略,现在几十个库表,每个都要有监控、告警、备份、恢复演练。连接数更是直接翻倍,应用连接池配置如果不调整,很容易在高峰期把数据库连接打满。
我的习惯是拆库之后马上做三件事:第一,把慢查询监控覆盖到全部分片,聚合展示TopN,不要等业务方反馈才去翻日志;第二,把分片维度的容量监控建起来,看到某张表数据量增长异常就能及时排查路由倾斜;第三,备份和恢复演练按分片维度做过一轮,别在恢复的时候才发现某个分片的备份链路是断的。
还有一点是连接池参数。分库之后,每个分片都需要连接,应用连接池的总连接数是原来乘以分片数,上限一定要算好。不然等流量上来,应用侧报"连接获取超时",数据库侧明明还有空闲连接,两边一对账才知道是池子配小了。
6. 常见问题速查表
6.1 高频问题与排查建议
我在前面各个章节里零零散散提到的坑,整理成一张速查表,方便遇到问题时直接对照:
| 现象 | 根本原因 | 处理建议 |
|---|---|---|
| 某张分片表数据量明显偏大 | 分片键取值不均匀,路由倾斜 | 换散列值高的字段,或改用一致性哈希 |
| 不带分片键的查询超时 | 全路由导致查询放大 | 加映射表、汇总层,或要求调用方必须带分片键 |
| 跨分片关联查询特别慢 | 没配置绑定表或广播表 | 对齐分片键,字典表做成广播表 |
| 插入数据时主键冲突 | 还在用自增主键 | 切换为号段模式或雪花算法 |
| 深分页翻页越来越慢 | 分片内limit大偏移量 | 改成Keyset游标分页 |
| 扩容后大量数据重新分布 | 分片数非翻倍变化 | 只能翻倍扩,或提前用一致性哈希 |
| 应用报连接超时但DB空闲 | 连接池总连接数没按分片倍数算 | 调整连接池最大连接数 |
| 调用链偶尔出现数据不一致 | 跨分片写入没有最终一致性方案 | 引入本地消息表或事务消息 |
6.2 决策顺序:什么情况下先别拆
最后给一个直接的决策顺序,是我做技术方案时反复用的套路:优先把索引、缓存、归档、读写分离做到位;数据量持续增长、常规手段都试过,才考虑分库分表;分库分表前先确认三件事,有稳定分片键、核心查询能带分片键、业务能接受最终一致性;拆分时把广播表和绑定表清单一起定下来;分片数量按三到五年增量预估,宁多勿少;扩容只走翻倍路径,有条件就上一致性哈希;拆完立刻把监控、备份、连接池、恢复演练补上。
这套流程不一定适合所有团队,但方向不会错。走完一遍之后,你大概率会发现,分库分表的难点从来不在"拆"本身,而在于拆完之后整个系统还能不能稳定运行。
最后说点个人体会。分库分表这件事,最难的从来不是中间件怎么配、路由怎么写,而是你能不能忍住不拆。我见过太多项目,数据量刚过百万就急着上分库分表,最后架构复杂度上去了,业务没跑多久又悄悄拆回来。真正稳的做法永远是先把单机红利吃干净,等信号真切到眼前再动手。真要拆的时候也别追求一步到位,分片方案一定要给扩容留好路,宁可初始分片多一点,也不要卡着上线之后被迫搬数据。这些都是真金白银踩出来的经验,希望你能少走几步弯路。