先聊个背景。前两年做订单中心重构,单日订单量冲上百万级别之后,单表数据很快逼近数亿行。CPU还稳得住,但问题开始连环冒头:锁等待越来越频繁,备份和恢复的时间拉长到难以接受,加个索引都得提前几周安排窗口。你几乎能预判到,下一次发版之后某条核心SQL又会爬上慢查询榜单。也是在那个阶段,我认真把分库分表从概念到落地捋了一遍。分库分表这个概念在后端圈流传太久了,但多数讨论停留在“用取模路由数据”“拆成多库多表”这种口号上,至于为什么拆、拆完之后查询Join事务怎么办、扩容时数据怎么动,很多人反而是边踩坑边补课。这篇内容我会把分库分表的完整知识链讲清楚,包括单库到底何时到极限、垂直和水平拆分的选择逻辑、分片键与算法的取舍,以及真实项目里从评估到迁移的完整路径。适合正被单表容量和写并发困扰的后端开发,也适合想系统建立认知、以便看懂架构评审会上那些术语的读者。
1. 先想清楚:单库扛不住时,问题到底出在哪儿
1.1 数据库变慢的三种典型信号
很多人判断数据库是否需要拆分,只看“表里有多少行”,这是最朴素但也最容易误判的指标。实际业务里,单库性能恶化几乎都绕不开三种信号。
第一种是写入路径上的锁竞争加剧。单机数据库的写入能力瓶颈往往不是CPU,而是锁和日志落盘的速度。一张热点订单表如果所有写操作都集中在几个页面上,行锁、间隙锁、自增锁会互相等待,TPS一高就会出现大量Lock wait timeout。这时候你去看表行数,可能只有两三千万,但应用层已经能感受到明显的写入毛刺。
第二种是存储与IO的放大效应。InnoDB的B+树为了维持有序性,写入会触发页分裂,二级索引越多写放大越明显。数据量达到数亿行后,索引体积可能超过内存缓冲池的数倍,随机读开始大量落盘,缓存命中率断崖式下降。有些查询明明执行计划没问题,实际却慢在物理IO上。
第三种是备份、DDL、复制链路的边际成本失控。单表超过一定规模后ALTER TABLE会长时间占用元数据锁,即使用了在线DDL工具,主从延迟也可能被推到秒级以上。备份恢复更头疼,一个200GB的库做全量恢复,在故障场景下意味着几个小时不可用,这已经不只是性能问题,而是容灾能力失效。
这三个信号之间没有严格的先后顺序,但绝大多数需要分库分表的系统,至少同时踩中两个。如果只是偶发慢查询,先排查索引和SQL,别急着往分库分表上靠。
1.2 单表行数不是压死骆驼的最后一根稻草
关于单表多少行就该拆,网上有说500万、2000万、1亿的,观点互相矛盾。其实这些数字背后都藏着一个访问模型问题。
数据库真正怕的不是“存得多”,而是“活跃数据集”过大。一张10亿行的流水表,如果业务永远只查最近三天的数据,并且冷热数据被分区或归档打理得很好,单表照样能跑得很稳。反过来,一张2000万行的表,如果查询字段上缺少合适索引,或者业务侧经常做无过滤条件的统计,30GB的数据也可能把内存缓冲池直接击穿。
所以做容量评估时,我习惯先问三个问题:这个表的访问是否高度依赖某一个业务维度?冷数据能不能通过时间或者其他状态位归档出去?核心查询能不能用有限的索引覆盖?如果答案都偏向“可以”,那优先做数据生命周期管理,而不是拆表。分库分表解决的是单机物理资源的天花板问题,不是糟糕SQL和缺失索引的药方。
曾经有个项目,线上核心表5000万行,P95查询延迟到了800毫秒,团队喊了一个月要水平拆分。我接手后先开慢查询日志,发现所有高延迟SQL都来自一个后台报表查询,它用状态字段过滤时没走上索引,一次扫描几百万行。加完联合索引之后,P95直接掉到80毫秒,拆分的事自然就搁置了。这个案例想说明的是:分库分表的技术成本极高,启动之前必须确认你已经把单机优化这条路走到了头。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 垂直拆分与水平拆分:两个方向解决的是完全不同的两类问题
2.1 垂直拆分重划数据域
垂直拆分通常被理解成“按业务拆库”,比如把用户、订单、商品分到不同的库。这个理解没错,但更准确的表述是:数据所有权需要重新划归给不同服务。
单体应用时代,所有表都在一个库里,服务之间通过数据库Join就能拿全数据。一旦业务拆成微服务,每个服务应该有自己独立的数据域,再通过API或消息与其他服务协作,而不是共享一个库。垂直拆分的价值在于隔离故障、独立扩容、控制爆炸半径,但它不解决单表行数增长的问题。用户库可能仍然有一张2亿行的用户表在膨胀,订单库也照样会有大表。
垂直拆分还有一种表级做法,把一张宽表按列的访问频率拆成“高频字段表”和“低频扩展表”。例如商品表拆成商品基础表和商品详情表,基础表承载列表页高频查询,详情表只在详情页用到,更新频率低。这种拆法减少了单行的IO读取量,但属于表结构优化的一部分,大部分场景用不上,对于一张行数仍在疯涨的表,拆列救不了命。
不要混淆的是,垂直拆分往往发生在架构演进早期,由业务模块化驱动;水平拆分则发生在单库单表出现物理瓶颈的阶段,由容量驱动。把它们当同一个动作会导致方案错配。
2.2 水平拆分是容量扩容的终极手段
水平拆分的核心逻辑很直接:把同一张表的数据按照某个规则打散到多个结构完全相同的表中,每个表只持有全量数据的一部分。目标是通过增加物理节点来扩展总容量。
订单表按用户维度拆分之后,单个用户的订单会完整落在同一张分表里,“查这个用户最近订单”的路由只需要一次定位,响应速度很快。从这个角度看,水平拆分的难点不在于“多建几台机器”,而在于你必须找到一个稳定的黄金拆分维度,让高频查询尽可能只在单分片上完成。
分库分表的实践里,绝大多数中间件是先确定逻辑分片数,再通过分片键计算出物理分片位置。常见的计算规则是取模或范围映射。例如用order_id取模,中间件就知道该去哪个库哪张表执行SQL。
水平拆分也不是零成本扩容。拆完之后,原来单库天然支持的跨行事务、Join、唯一索引都会被削弱甚至消失。更关键的是,一旦分片键选错,后续想换一个维度重新分,等于再做一次全量数据迁移,代价等同于对线上系统动一次大手术。
2.3 分库不是为了消灭Join,而是把高耦合业务留在同一个数据域
很多人一听说分库分表就担心“以后不能Join了”,然后试图用宽表、冗余字段去模拟Join。这个思路有点本末倒置。
正确做法是在设计阶段让高耦合的表进入同一个分片域。比如订单系统和支付系统在业务上天然强关联,如果都按同一位用户维度进行分库分表,并且分片数与路由规则一致,用户维度的订单和支付记录就会落在同一个分片内,仍然可以安全地做本地Join。这也是中间件里面常说的“库表分组”或“绑定表”,属于设计层面的能力,而不是事后补救。
反例是那些不同业务域的表虽然也有Join需求,但没有相同分片维度。比如电商订单要关联物流轨迹,两者如果分别按用户和运单维度拆分,跨片关联就不可避免。这种场景下,比较务实的方案是让微服务通过接口批量聚合,让订单服务先查出订单,再一次性把订单号传给物流服务查出轨迹,最后在应用层用Map做内存拼接。
对应用层拼接的担忧通常集中在内存占用上,其实批量聚合每次只处理一个页面的数据,比如二三十条,内存开销完全可控。怕的是有人仍按单库思维写一次Join几十万行的SQL,那不管分不分库都早晚要出问题。
3. 分片键与分片算法:看起来是数学题,其实是访问模型题
3.1 取模分片:均匀与扩容之痛
取模分片是最常见的数据分布方案。逻辑上就是分片号 = 分片键 % 分片总数。好处是计算简单、数据分布相对均匀、实现几乎没有学习成本。
可取模分片有一个绕不开的痛:分片总数一变,历史数据的映射关系就全部失效。原来分8片,数据都按% 8路由,现在要扩到16片,所有数据必须重新计算目标位置并搬迁,而且搬迁期间新旧路由规则并存很容易出现读写错位。为此我在项目里见过两种主流缓解手段。
第一种是提前把逻辑分片数定得很大,例如固定1024个逻辑分片,再通过一张路由表把逻辑分片映射到物理库。扩容时只增加物理库并调整映射关系,逻辑分片不动,数据搬迁范围被控制在部分分片内。这种方案把物理扩容从“全量数据迁移”降级成“部分分片迁移”,代价是多了一张路由表,而且所有查询要多一次路由查找,热点高时路由表自身也可能成为瓶颈。
第二种是使用“分片数设计成相对素数且结合一致性哈希”的做法,本质上已经不完全是取模。单纯取模适合稳定场景,如果你预期未来一年数据量会持续增长,就需要把分片数设计得足够大,或者直接考虑其他算法。
3.2 范围分片:时间序列和冷热分离
范围分片是按某个字段的区间划分数据,最典型的是按时间分月表或分日表。订单表按创建时间范围拆分成order_202501、order_202502这样一系列表,新增数据永远落在当前时间对应的最新表里。
这种方案与业务访问模型很契合,用户查历史订单时通常也会带时间范围,存储和查询都有明显的冷热边界。更难得的是,扩展几乎不迁移历史数据,跨月后新建一张表即可。因此日志、流水、行为上报这类时间序列数据,用范围分片几乎是天然最优解。
缺点是容易出现“尾部热点”,所有写入都集中在当前时间段的那张表,如果业务量足够大,单张月表本身可能很快撞上容量瓶颈。解决思路是时间范围拆得更细,或者把范围维度与其他维度组合使用,例如日期加用户尾号二次路由。但组合路由会让系统复杂度明显上升。
还有一点值得注意,纯按时间范围拆分后,跨时间段查询变成“查多张表再合并”,如果业务方喜欢不带时间条件地按用户拉取全部订单,性能会非常难看。所以范围分片不太适合作为核心业务表唯一的分片策略,除非你能接受查询必须带有效的时间窗口。
3.3 一致性哈希:平滑扩缩容的另一面
一致性哈希通过哈希环把分片键映射到环上的某个节点,再用单向查找找到顺时钟最近的目标节点。好处众所周知:增加或摘除一个节点,只会影响该节点在环上的前后一小段数据,迁移范围远小于普通取模。
但它有被低估的短板。第一个问题是数据分布天然可能不均,业务分片键经过哈希后如果集中在环的某个区间,就会出现所谓“数据倾斜”。虚拟节点技术可以在一定程度上缓解,但每多加一层虚拟映射,实现复杂度也会上升。第二个问题是范围查询等于作废,因为哈希破坏了原始字段的连续性。
我接触到的真实订单系统里,纯粹用一致性哈希做核心数据拆分的反而不多。多数团队会自己实现一套结合范围映射和一致性哈希的规则,比如时间维度用范围、用户维度用一致性哈希。概述阶段不需要把实现细节写死,但一定要形成判断:算法不是越高级越好,越高级的路由规则在排查问题时越难直觉化。
3.4 选键先于选法
比算法更需要认真决策的是分片键。如果只能记住一句话,那就是:分片键必须来自业务里最高频、最稳定、分布最均匀的访问维度。
对于C端交易系统,user_id是比order_id更好的分片键,因为用户端绝大多数查询都带着当前登录用户ID,天然单分片定位。相反,如果按order_id分片,端上想看“我的订单列表”就要跨所有分片扫描,系统直接没法用。
user_id也不是万能的。后台运营系统经常按商户、按区域、按活动查询订单,这些条件如果都不包含用户ID,就只能引入离线数仓或者ES同步一份副本,无法依赖在线订单库直接解决。另一个常见反例是按地区分片,上海广东这类热点区域瞬间把单分片打满,其他分片却在闲着,等于把容量问题换了一种形式继续存在。
选键本质上是在分析业务访问模型,而不是做纯数学选择。在动手拆分前,我会要求团队先做一个查询条件覆盖分析,把高频SQL列出来,逐个确认它们是否携带候选分片键,同时统计候选键的基数分布。只有覆盖率和均匀度都达标,这个键才能进入实施方案。
4. 拆完之后,SQL能力直接回到石器时代
4.1 分布式ID要先想明白,而不是临时拼一个
分库分表后,数据库自增主键就不能当全局唯一主键用了。两个分片各自从1开始自增,必然出现重复。替代方案里,最容易实现的UUID生成简单,但作为字符串主键会浪费大量存储空间,乱序插入还会引发页分裂,在线交易系统不太建议直接用。
业内常用的是雪花算法Snowflake。它由时间戳、机器ID、序列号组成64位整数,趋势递增,生成性能非常高。但它依赖机器时钟,如果系统发生时钟回拨,就可能生成本地重复ID,所以落地时要加入时钟回拨处理:小幅度回拨可以短暂等待或拒绝,大幅回拨则需要通过数据库顺序号重新校准。
还有一类业务友好型ID方案,将分片信息和业务含义编码进ID。例如订单号由“日期+分片号+自增段”组成,运维人员只看订单号就能判断它落在哪个分片,排障效率大幅提升。这一点在概述层面常被忽略,但真实项目中非常重要,因为一套不可读的分布式ID方案会让全链路日志排查变得很痛苦。分布式ID的技术选型要和分片键联动考虑,不要等表结构建完了再补。
4.2 跨分片查询、排序与分页要重新设计
数据打散之后,单条SQL的事务边界和查询能力同时收缩。最常见的阵痛来自三处:
第一是Join,前面提到过,优先用数据域绑定让关联数据留在同一分片,做不到就应用层聚合。这一条如果事前没有设计好,后期每个接口都要返工。
第二是全局排序与分页。单表里的ORDER BY在分片场景会变成“各分片先各自排序,中间件再归并”。如果只查前20条,各分片返回各自前20条再归并,结果没问题。可怕的是深分页,业务传LIMIT 100000, 20,每个分片都必须先把前100000条取出来排序,再交到中间件层丢弃,性能灾难几乎是必然的。
替代方案是把分页方式改成游标模式,用WHERE id > last_id ORDER BY id LIMIT 20往下翻页。它能保证性能稳定,因为每页只扫描很小的数据范围。移动端“下拉加载更多”天然适合这种模式,但产品侧如果还停留在“一键跳到第5000页”需求,就要和技术方案来回拉锯,最好在产品设计早期就沟通好。
第三是聚合统计,比如COUNT、SUM,中间件可以对各分片的结果再做一次合并,但精确性没问题,只是扫全片的成本和延迟会很高。频繁的全分片聚合会拖垮系统,应该通过异步任务把统计结果同步到统计库或缓存,不要用在线查询扛报表压力。
4.3 分布式事务向实用性妥协
单库时代,一笔订单涉及订单表、支付表、库存表时,一个数据库事务就能保证原子性。分片之后,这些表如果落在了不同分片甚至不同物理库,跨库事务就变成了标准的分布式事务问题。
很多教材喜欢直接搬出两阶段提交、三阶段提交,但工程上强一致事务的代价极大,对交易链路的吞吐影响非常明显。实际项目里我看到的主流做法是做一个取舍:
- 允许最终一致性的业务,使用本地消息表加消息队列做异步补偿。
- 对一致性要求更高但步骤可控的业务,使用TCC或者基于事务消息的Saga模式。
- 除非万不得已,不要在一个用户请求内同步跨两个以上的分片做强一致事务。
订单创建链路是经典场景:写订单成功、扣库存成功、后续还要异步更新积分。如果都硬塞进一个分布式事务,任何一环抖动都会导致整个订单失败率上升。设计上通常把订单主状态先写成功,其他业务通过可靠消息异步解耦,即使某个环节失败也能用对账任务补偿。
事务方案的变化才是分库分表最容易被低估的成本,因为这不是改个中间件配置就能完成的事,它会影响每一个写接口的编码模型。
4.4 中间件接入方式对比:没有银弹,只有取舍
关于分库分表如何实现,业界总结下来无非是三种接入方式。
客户端模式以ShardingSphere-JDBC为代表。它在应用内完成SQL解析、路由和结果合并,部署简单,性能损耗低,没有独立服务节点要维护,强路由规则可以在代码层直接强制。缺点是语言绑定较重,只有Java项目能天然嵌入,对团队的SQL编写规范要求高,每次框架升级都要跟着回归。
代理模式以ShardingSphere-Proxy、MyCat、Vitess这类中间件为代表,独立于应用部署,对应用透明,可跨语言接入。但链路加长后会引入额外网络延迟,代理层一旦成为瓶颈会影响所有数据库访问,运维需要多养一套有状态服务,连接管理和权限体系也要一起迁移。
云数据库的分布式版本则是把分库分表能力内嵌到数据库内核,在控制台调整分片策略即可,适合不想维护额外中间件的团队。缺点是成本高、有一定的供应商绑定,并且可用调优参数可能不如开源方案灵活。
没有哪一种方案能同时满足“低侵入、高性能、好运维、可扩展”,做选型时应该结合团队的技术栈和维护能力来定,而不是只对比官网的功能清单。
5. 项目实践:平滑迁移不是一行配置的事
5.1 拆分前先做一次“不拆体检”
拿到一个想分库分表的项目,我通常要求团队按下面的清单先过一遍,全都没问题再考虑拆:
- 慢查询里有多少是因为索引缺失或SQL写得差造成的?
- 能不能通过加缓存把热点读扛住?
- 冷数据能否做归档,把表的热活数据集降到50GB以下?
- 能不能用读写分离解决读多写少问题?
- 单条大事务能否拆成小事务?
- 当前CPU、磁盘、连接数是否真的摸到了单机物理极限?
- 未来一年数据量增长预期是否明确,有没有量化数字?
这不是为了劝退,而是因为一次分库分表的改造,应用层要动、中间件要上、数据要搬、运维体系要改,整体周期往往以月为单位。如果缓存加索引能解决80%的问题,那这笔账怎么算都是亏的。只有这些问题已经解决了,系统仍然因为容量和并发在痛苦挣扎,才真正值得进入拆分流程。
5.2 双写、校验与灰度切换的具体节奏
从旧单库迁移到新分片集群,最忌讳的做法是“某天夜里写一个离线脚本全量导入,第二天直接切换”。分库分表涉及路由规则验证、存量数据映射、增量数据一致性三件事,任何一步出错都可能导致线上数据错乱,所以要设计一个可灰度、可回滚的迁移链路。
整体节奏通常是四步走。第一步,历史数据全量导入新集群,用离线任务把旧库数据按新分片规则搬过去,迁移后做行数和关键字段的抽样比对。第二步,启动增量同步,通过解析Binlog把旧库的新增写入实时转发到新集群,同时应用层开启双写,每次写入旧库后按新规则也写一份到新库,两者形成互备。第三步,开启影子校验任务,周期比对新旧两边数据,把差异数据捞出来反查根因,而不是等业务方报障。第四步,按流量比例灰度切读,从5%、20%、50%逐步放大,每一档都观察慢查询比例、错误率、数据一致性。
我特别想提醒的是切换前要约定回滚条件。如果切到新集群后,某个核心接口的错误率超过阈值,应该能在几秒内把读流量切回旧库。回滚不是把数据再反向搬一遍,而是路由开关的快速切换,因此旧库在回滚窗口内必须继续维持增量同步,不能一切换就拆掉。
5.3 用容量模型反推分片数量
分片数量不是拍脑袋定的,它是容量模型和查询模型共同作用的结果。我常用的推演方式是三步:
先估算单分片可承载的数据上限。假设单表1000万行、单行存储加索引平均2KB,一个分片大约20GB。以当前MySQL单机物理库存放10个分片表计算,单实例能承载的数据规模约200GB,这已经超过了很多通用机器加内存缓冲池的舒适区,实际部署通常会进一步压缩到每实例5到8个分片。
再根据业务总量倒推分片数。假设预期三年订单总量10亿行,单分片承载力上限3000万行,分片数至少是34片。分片表设计往往不是等分片数算出来后就直接排Number,还要考虑库的数量。例如规划4个物理库,每个库里预留8到12张分片表,总分片数在32到48之间,就与刚才的业务预期匹配了。
最后再纠偏一次:分片键的取值分布和中间件并发连接数会不会成为新瓶颈。有些团队把表拆到几百片,看起来数据分布很均匀,但物理库连接数是固定的,每个库持有的连接池会被一堆分片摊薄,任何一次未带分片键的查询都可能触发大量并发连接,把连接数打满。这提醒我们,分片数量更多不等于更稳定,它必须与业务查询模型和运维支撑能力一起收敛。
6. 哪些场景别硬拆:拆分成本与长期维护
6.1 数据能归档就不拆表
开发团队对分库分表往往有一种“技术升级”的冲动,但很多时候大表问题完全可以通过数据归档解决。
所谓归档,是把已经超过业务有效期、不再被高频访问的数据从在线表中搬出,转入历史库或冷存储。比如订单流水经常只需要在线保留一年,超过一年的订单直接进入历史库。通过归档把在线订单表从3亿行压到3000万行,业务查询响应会显著改善,数据库压力也大幅下降,而应用侧几乎不需要改动。
归档和分库分表不是互斥方案,而是演进顺序问题。比较常见的合理路径是先做归档和缓存,再做读写分离;垂直分库解决故障隔离;最后才轮到水平拆分解决终极容量。很多团队跳过了归档直接上水平拆分,结果是把大量早已不访问的历史数据继续分散到新分片里,白白浪费存储资源和分片容量。先把冷数据清出去,你会发现需要拆的数据量少了一大截。
6.2 无法携带分片键的查询就是定时炸弹
分库分表上线后,任何一个不带分片键的查询都会演变成全分片扫描。接口偶发调用一次可能不明显,但如果某个后台页面每次刷新都触发这种查询,整个集群的分片会被瞬间拖垮。
团队在制度建设上要做两件事。一是在开发规范里明确规定,所有在线SQL必须携带分片键,并在代码评审环节人工把关;二是利用中间件配置强制禁止全路由或无分片键查询,宁可在发现扫描的第一时间报错,也不要让它默默跑完。ShardingSphere-JDBC等组件提供了相关的解析与拦截能力,需要在项目启动时就打开,而不是出问题后再补。
某次我处理过一个线上故障,新同事写了一个按订单号查详情的接口,但订单号作为分片键时他漏传了用户ID,又没有带分片键解析,结果每次查询都扫描了全部分片表。一开始并发量低看不出问题,大促预压测时数据库连接池直接被打爆。后来在DAO层引入了基于订单号到用户ID的映射缓存,才把这类漏网之鱼兜住。
6.3 冗余、报表与异构存储的收尾方案
分库分表方案里还有一个很少写在PPT上的问题:报表和搜索怎么办。
在线订单库按用户维度拆分之后,运营侧想按日、按商品、按区域统计订单,几乎每一个维度都不是user_id,跨分片聚合的代价会高到无法接受。比较常见的做法是把订单表的Binlog实时同步到数据仓库、ES或ClickHouse,在异构存储中重建多维索引和聚合模型,由数据处理任务提供报表查询。在线库只保留按分片键访问的实时能力,分析任务全部走下云数据链路。
时序数据、全文检索、复杂报表这些领域,各自有比关系型数据库更擅长的系统,不用硬把一个能力塞给分库分表后的OLTP库。数据副本多了确实要关心一致性问题,但相比让在线库承载所有查询,让专业存储各司其职才是可维护的长期结构。
回到分库分表本身,它是一个能解决单机容量瓶颈但也同时引入无数新约束的架构决策。拆完之后系统的复杂度和运维成本会成倍上升,这也是为什么我始终建议开发团队先用慢查询分析、归档、缓存、索引优化把单机的价值压榨到极限,再在数据量增长曲线已经明确可见时动手拆分。分库分表带来的核心能力是水平扩展,而一个靠谱的分片键、一个可平滑扩容的路由设计、一套能兜底的双写校验机制,才是这个能力能不能真正落地的关键。如果你正在为某个系统评估分库分表,我最后的小建议就是:先把所有高频SQL和对应的访问维度画在一张表上,找到那个能覆盖绝大多数查询的稳定业务键,再决定要不要往下走。分片键对了,后面的事情才有意义;分片键错了,所有算法、中间件、迁移方案都会变成在错误地基上修修补补。
