
几年前我接手过一个订单系统单表数据量破了亿高峰期一个简单的 count 查询就要跑好几秒慢日志把监控面板刷得通红。那时候摆在团队面前的方案就是今天要聊的分库分表。分完之后单表压力确实下来了但随之而来的问题也一个接一个冒出来全局ID、跨库Join、分布式事务、深分页还有最头疼的扩容迁移。这篇文章我会按自己在生产环境里的踩坑顺序把分库分表后暴露出来的典型问题逐个拆开讲清楚底层原因和可行的解决方案。适合正在准备分库分表的团队、被分片问题折磨的后端开发以及想系统理清这套架构思路的人。有一些方案属于行业通用做法我会额外标注哪些是来自我的实践经验供你参考。1. 分库分表前必须想清楚的那笔账1.1 它到底解决了什么问题先别急着追求“用了分库分表就高级”。分库分表本质上解决的是三个现实瓶颈。第一是连接数问题。MySQL 这类数据库的连接是有限资源一个实例的连接数一旦被占满新请求只能在连接池里排队等。很多系统的数据量不大但应用实例多、连接池配得又大单库连接数很容易被打爆。分库之后连接压力被分散到多个实例上这个问题会立刻缓解。第二是单表数据量膨胀后索引性能下降。这里有个容易误解的地方B树索引本身能支撑千万级数据3到4层深度其实还挺能打。但真实生产环境不是教科书大量历史数据堆积、页碎片增加、随机回表变多几亿行的单表会让很多简单查询变得很慢尤其带 order by、group by 的时候慢查询几乎躲不掉。分表把数据拆成多份每张物理表的数据量降下来索引效率自然恢复。第三是单库的写入吞吐天花板。一个主库的磁盘IO、binlog写入、刷盘能力都有上限。日增百万行以上的业务单库写入很容易成为瓶颈。分库分表后写入被分散到多个节点这个天花板就被抬高了。我经手的那个订单系统就是这样单表日均新增几十万行大促期间一个简单写操作排队几百毫秒库的连接数和CPU都顶不住。分完立刻立竿见影但也只是“开始”。1.2 分完之后你失去了什么可以这么理解分库分表前你的数据库是一个大仓库你想怎么盘点都行分完之后仓库被切成几十个小隔间找特定商品很快但跨隔间盘点就得挨个进去数再在外面汇总。这是分库分表最核心的代价用牺牲“全局视图”换“伸缩能力”。具体来说你至少会失去以下东西跨库 Join 能力原本一条 SQL 关联三张表分库后如果数据不在同一个库这条 SQL 基本废了。本地事务边界原本一个事务里同时更新订单、库存、余额分库后它们可能在不同库ACID 不再由单库保证。唯一索引约束user_name 这类全局唯一字段在分片后的数据库里无法靠单表唯一索引兜底。全局排序、分页、聚合count、sum、avg 这些操作都要先分散查询再归并深分页更是灾难。DDL 与索引管理原来对一张表加个索引现在要对几十张表操作窗口期成倍拉长。所以分库分表是一个“确定性有损方案”。它不是优化技巧是为了可用性和容量做出的取舍。动手之前最该做的事情就是把现有 SQL 全部梳理一遍按“能带路由键的查询”“不带路由键的查询”“跨分片查询”分类。很多团队跳过这一步拆分之后才发现核心列表页全是跨分片查询等于把单表的慢查询换成了更复杂的跨分片慢查询。1.3 分库还是分表垂直还是水平不少人把“分库分表”当成一个词其实它们是不同的手段解决的问题也不一样。分库是把不同业务域的数据放到不同数据库实例比如把订单库、用户库、支付库拆开。它主要解决单库连接、IO、CPU 瓶颈。很多时候一个实例扛不住只是因为所有业务挤在一起垂直分库就够了根本不用水平分表。分表是把一张大表按行拆成多张结构相同的物理表。它主要解决单表数据量过大、索引效率下降的问题。比如订单表按 user_id 哈希拆成 64 张每张表只存原来的 1/64 数据。垂直拆分是按列拆把热点字段和冷字段分开减少单行数据体积提高缓存命中率。水平拆分才是通常说的“分表”也是这里要讨论的重点。判断标准我一般这样简化单库连接或 CPU 扛不住优先分库单表数据量过大、索引性能明显退化优先分表一张表字段特别多且冷热访问差异极大考虑垂直拆分。拆键的选择则是最关键的决定。理想路由键要覆盖 80% 以上的查询。比如订单高频查询是“查某个用户的订单列表”那就按 user_id 拆如果是“按订单号查订单详情”那就按 order_id 拆。注意这两个键往往只能二选一选了 user_id所有按 order_id 的查询就会退化成全分片扫描。如果你两种查询都很高频就得考虑冗余表或者异构存储来兜住另一种查询方式。2. 分布式主键、跨库Join与唯一约束第一个痛感来源2.1 全局唯一ID自增主键的替代方案分库分表后单表自增主键立刻失效。原因很简单两个分片各自从 1 开始自增会出现重复 ID。你说我可以设置不同的初始值和步长比如库1生成 1,3,5库2生成 2,4,6这确实能撑一阵子但后期扩容要重新调整步长历史数据还得重新计算维护成本很高不推荐。实际生产里常用的方案有三类我做过对比方案特点适用场景业务规则ID用日期用户ID序列拼出订单号天然唯一且可读订单号、流水号等业务强相关场景号段模式集中发号服务每次取一批号本地内存分配需要高性能、实现简单的场景雪花算法时间戳机器标识序列号趋势递增分布式节点多、需要本地生成ID的场景UUID全局唯一但随机无序不适合做数据库主键只适合做链路追踪等UUID 不建议当主键这是我在实际中踩过的坑。UUID 没有顺序性作为聚簇索引主键时新数据会随机插入索引页导致页分裂频繁写入性能和磁盘碎片都很难看。雪花算法是当前最主流的方案核心结构是 1 位符号位 41 位毫秒时间戳 10 位机器标识 12 位序列号。它能保证全局唯一并且趋势递增对索引比较友好。但有一个经典问题时钟回拨。如果机器 NTP 校时后时间往回跳就可能生成重复 ID。我实践中的做法是记录上一次生成 ID 的时间戳如果当前时间小于等于上次时间就拒绝生成或者阻塞等待时钟恢复同时配合机器标识位做兜底。号段模式也很值得提一下它的思路是集中发号服务在数据库里维护一个“当前最大号段”每次申请取一批号比如一次取 1000 个应用本地分配。这样发号服务压力小性能高实现也简单。缺点是发号服务仍然是个单点需要做好高可用取号窗口也不能太小否则会造成频繁访问发号库。我自己在中等规模系统里很喜欢用号段模式因为维护成本远低于雪花算法。2.2 跨库Join习惯了SQL的人最先懵掉的地方分库之后一条 SQL 关联多张表还能不能跑取决于你的拆键设计。最理想的情况是经常一起 Join 的父子表使用同一个路由键。比如订单表和订单明细表都按 order_id 分片那么同一笔订单的数据天然落在同一个分片单库 Join 依然可用。这一点应该在设计拆键时优先考虑能帮你省掉大量后续麻烦。但现实往往是用户表和订单表没法用同一个键拆。订单按 user_id 分片用户表又按 user_id 分片这其实还能对得上。就怕订单按 order_id、用户按 user_id两套路由体系那么“查询订单并显示买家昵称”就必然跨分片。跨分片 Join 的常规解法有四种冗余字段。把查询列表需要的买家昵称、商品标题等字段直接冗余到订单表写入时多写一份查询时无需关联。这是最简单也最有效的方案绝大多数订单列表展示场景都够用。应用层组装。先在分片里查出订单数据再根据 user_id 集合批量调用用户服务查询昵称最后在服务内存里拼装。要注意控制批量查询的 IN 条件长度分片查询也尽量并发。宽表/汇总表。把高频查询需要的维度都同步到一张宽表里这张表可以单独存储查询走它。本质上是建立一个面向查询的模型。异构存储。复杂关联和检索直接交给搜索引擎或者分析型存储业务库只承担在线交易写入。我的建议是优先冗余字段其次应用层组装别在业务代码里硬撑复杂 Join。分库分表之后SQL 不再是万能胶水这条认知越早建立越好。2.3 唯一约束失效唯一索引在分片下不灵了分库分表后一个容易被忽略的问题是唯一约束失效。比如用户表的用户名需要全局唯一但表按 user_id 分片之后每个分片只能检查自己分片内有没有重名无法判断其他分片的同名情况。数据库层的唯一索引在这里形同虚设。解决思路有三条。第一条让业务规则保证唯一。比如订单号本身由全局发号器生成规则上就不会重复这时数据库层不需要唯一索引兜底。适合有明确业务编号的场景。第二条建立“唯一键 → 路由键”的映射表。以用户名注册为例先在一张独立的全局映射表里插入用户名和 user_id 的对应关系映射表本身可以做唯一索引插入成功后再把用户数据写入 user_id 对应的分片。并发重复注册时映射表的唯一索引会把冲突挡在外面。代价是多一次查询和一次额外写入。第三条路由键本身如果是唯一字段比如 user_id 本身全局唯一那就不存在额外约束问题直接用路由键作为唯一条件即可。这套机制需要额外引入一张映射表增加一次网络交互很多人会觉得麻烦。但这是分库分表的固有复杂度躲是躲不掉的。不做映射表的话就得自己做分布式锁而且要处理锁粒度、锁超时、锁释放复杂度只会更高。3. 跨库分页、排序与聚合业务代码里最隐蔽的坑3.1 深分页为什么是灾难分页这个功能看起来人畜无害但分库分表之后它是隐藏极深的性能杀手。先看单库分页select * from order where user_id 100 order by create_time desc limit 100000, 20。MySQL 会扫描前 100020 行丢弃前 100000 行再返回 20 行。单库这个操作已经很重了但还能咬牙撑住。分库之后就完全不同了。假设拆了 16 个分片你要取全局第 100000 到 100020 条不能只查询每个分片的后 20 条因为全局排完序之后第 100000 条可能分布在任何分片里。正确做法是每个分片各取前 100020 条然后合并排序再截取第 100001 到 100020 条。每个分片都得传输十万级数据到聚合层内存和 CPU 开销呈线性放大offset 越大代价越高。这还不算最惨的。如果分页查询没带路由键比如管理员后台按状态查订单所有分片都得参与那就是 16 倍的扫描量。解决方案我实践下来最有效的是这三种禁止深分页。前端页码限制到 100 页以内超出就提示用户缩小查询范围。很多业务场景其实根本不需要翻到很深。键集分页也叫 keyset pagination / 游标分页。不依赖 offset而是记住上一页最后一条数据的排序字段值。例如 order by create_time desc, id desc下一页就查询 where create_time ? or (create_time ? and id ?) order by create_time desc, id desc limit 20。这样每页查询都能利用索引数据量再大也能保持稳定的查询耗时。跨分片排序分页交给搜索引擎。业务库只负责写入搜索引擎负责维护倒排索引列表页和后台查询全部走搜索服务。这是大型系统里最稳妥的路线。方案优点缺点传统 offset 分页实现简单前端友好深分页性能灾难跨分片必放大游标/键集分页性能稳定适合列表不能跳页需要知道上一页锚点搜索引擎分页支持复杂条件排序引入额外组件有数据同步延迟3.2 全局排序、count与avg的归并陷阱排序和分页是连在一起的。跨分片排序的基本思路是每个分片先按排序字段排序取出前 N 条聚合层做归并排序再取出最终结果。但如果排序字段不是路由键这个“每个分片取前 N 条”并不等于全局前 N 条需要每个分片取的量足够大最后结果才准确。所以跨分片排序的复杂度不仅来自排序本身还来自“取多少才够”的不确定性。生产上最简单的做法就是限制查询范围或者直接把排序场景迁移到搜索引擎。再说 count 和 avg这两个函数都有考核点。count(*) 在单库是一条索引扫描分库后就必须“各分片 count 再累加”。如果业务量每小时都要做一次精确全量计数对分片的压力很大。更合理的做法是维护一张汇总表或者直接由分析引擎提供计数能力。avg 有一个非常经典的坑不能直接把各分片的 avg 加起来再求平均。正确的计算方式是 avg sum(所有分片的总和) / count(所有分片的总数)。因为各分片的数据量可能不均匀简单平均结果会偏。这个我在代码评审里见过不止一次看起来是小问题一旦数据分布倾斜报表数据直接失真。sum 也一样需要先各分片 sum再汇总。min、max 反而简单各分片取自身最大最小再取极端值即可。3.3 分组统计在分片下的正确姿势group by 的问题比单值聚合更隐蔽。比如要统计“每个城市的订单量”但订单表是按 user_id 分片的同一个城市的用户分散在各个分片每个分片只能算出自己的城市分组要得到全局分组必须把所有分片的小组结果合并然后按城市维度重新聚合。如果数据量不大这种归并还能接受。但一旦分片多、基数大应用层需要做一次完整的 MapReduce非常难受。在线上交易库里做分组统计我强烈建议三条路只允许按路由键分组。例如按 user_id 分片就只做 user 维度的分组这样每个分片独立算结果直接汇总即可。把分组键作为二级分片键建设维度表或汇总表。异步预聚合。写入时同步到分析型数据库或搜索引擎报表和统计全部从这边出。这是大型系统的通行做法也是我实际最喜欢的方案。分库分表永远是为在线事务服务的不是为了数据分析更好。分析类的复杂聚合应该尽早导出到专用引擎而不是在业务库上硬扛。4. 分布式事务从本地ACID到最终一致性的思维切换4.1 单库事务为什么不能直接搬到分库分库之前一个下单操作通常是这样开启事务插入订单表扣减库存表更新账户余额然后提交。如果中间任何一步失败本地事务回滚数据保持一致。分库分表之后订单、库存、余额很可能在不同的库甚至不同的物理机。你无法再用一个数据库事务把它们包起来——每个库的事务是独立的Commit 的时候可能订单库提交成功、库存库提交失败系统立即处于不一致状态。这就是分布式事务要解决的问题。很多人一开始试图找一种“像本地事务一样”的分布式事务框架但分布式环境下没有免费的午餐你必须接受一个事实跨库操作的原子性要么依赖协调协议要么靠最终一致性补偿。一个非常实用的建议在拆键设计阶段尽量把强一致相关的数据放到同一个分片。比如“订单和订单明细”本来就在同一个库里关联那就保持同一路由键“用户余额变动”和“订单创建”如果无法避免跨分片则需要认真评估业务上是否真的需要强一致。4.2 几种主流的分布式事务方案怎么选我把实际工程里见得最多的方案拉个表给你参考方案一致性强度开发成本适用场景XA 两阶段提交强一致低一致性要求极高、并发量不大TCC 补偿最终一致高跨服务资金操作需自定义补偿本地消息表/事务消息最终一致中订单、异步通知最通用SAGA 长事务最终一致中高流程长、有明确正向/补偿步骤XA 是数据库原生支持的强一致方案逻辑简单但两阶段提交的锁持有时间长并发能力差在高频写入场景下基本扛不住。用在低并发、强一致的管理类操作还可以。TCC 需要业务方自己实现 Try、Confirm、Cancel 三个动作。Try 阶段预留资源Confirm 提交Cancel 回滚。这套方案灵活、性能比 XA 好但代码量很大还要处理协调者的状态和超时。我见过不少团队用 TCC 处理支付扣款最后维护补偿逻辑的成本非常高。本地消息表和事务消息是最终一致方案里性价比最高的。核心思路是在业务本地事务里同时写入业务数据和一条待发送消息事务提交后再把消息发出去消费者异步处理后续步骤。由于业务数据和消息同库提交不会出现“业务做了但消息没发”的问题。很多消息中间件的事务消息做得更彻底把本地表都省了原理是一样的。SAGA 把长事务拆成多个本地事务每一步有正向操作和补偿操作。和 TCC 的区别是SAGA 没有预留资源这一说直接执行失败时逆序补偿。适合订单流程长、步骤边界清晰的场景。我的个人倾向是能用单库事务解决就不做分布式事务必须跨库时优先事务消息和最终一致不要一上来就上 TCC 或 XA。真正的高并发系统核心诉求是“最终把事办成且不重复”而不是“每一步都强一致”。4.3 兜底补偿与对账生产环境真正的保命手段再好的分布式事务方案在真实网络环境下也会出现消息丢失、重复消费、超时无响应。所以生产环境里真正靠得住的是兜底机制。第一是对账。定期扫描所有跨库业务的状态数据找出不一致的记录并触发补偿。比如订单表和支付流水表对不上对账任务自动补单或者告警人工介入。这是最终一致性的守护神比任何事务框架都重要。第二是幂等。下游消费方必须能幂等处理否则一条消息重复投递就会造成重复扣款、重复发货。幂等键一般用业务主键加事件类型处理前先查询是否已处理。我分享一个真实教训有段时间我们上线了本地消息表机制以为万事大吉结果忘了给消息表配置定期清理任务消息表膨胀到几千万行查询越来越慢消费端延迟飙升。后来加上“消息表状态扫描 定时补偿”才稳定下来。分布式事务只能降低不一致概率不能消灭不一致运维侧的兜底永远不能省。5. 扩容与数据迁移分库分表真正的第二次考验5.1 取模扩容的代价与一致性哈希很多人以为分库分表是一劳永逸等容量不够了才发现扩容才是最难的一步。最早期的方法是按 user_id % 16 取模拆成 16 个分片。这种方案在数据量增长到临界点后想扩到 32 个分片问题就来了因为取模的模数变了原来大部分数据的路由结果都会变几乎所有的数据都得重新搬迁。扩容不再是“加几台机器”的事而是一次接近全量迁移的大工程。一致性哈希是这里面的经典解法。把哈希值空间组织成环机器落在环上数据按顺时针归属到最近的节点。扩容时只需要把一部分数据从相邻节点迁移到新节点迁移量比取模小得多。但一致性哈希也有自己的问题节点分布不均会导致数据倾斜需要引入虚拟节点来缓解同时迁移期间新旧映射要保证一致性否则会出现读写走不同节点的情况。另外按范围分片比如按时间范围、按ID区间在扩容时更灵活只需要切分某个区间的数据但缺点也很明显最新数据往往集中在某个区段容易形成热点。如果业务本来就是冷热交替比如订单按月份归档范围分片反而很合适。所以没有哪种分片算法是完美的只有适不适合当前业务。5.2 逻辑分片与预分片平滑扩容的工程化思路这里介绍一个我在实践中认为最值得推广的方案逻辑分片与预分片。核心思想是把“分片编号”和“物理存储位置”解耦。预先定义一批固定数量的逻辑分片比如 1024 个槽位每个槽位是一个逻辑概念它当前映射到哪张物理表、哪个物理库由一个路由表维护。写入数据时先用路由键算出槽位编号再查路由表找到实际物理存储位置。扩容时不需要改变槽位数量和路由算法只需要把一部分槽位从旧节点迁移到新节点并更新路由表即可。迁移粒度从“全量数据”变成“局部槽位”范围可控影响面小。预分片的槽位数在设计时就要预留未来好几年的增长空间比如先定 1024 个槽位映射到 16 个物理库每库 64 个槽位。将来扩到 32 个库每库分到的槽位变成 32 个数据只搬移那些槽位对应的数据其他槽位纹丝不动。工程实现上路由表可以放到配置中心或本地进程缓存。这里有个小细节路由表变更期间要保证一致性最好引入版本号所有请求在同一个版本下解析路由等迁移完成再切换版本。否则迁移中途出现路由不一致数据就会读到一半新一半旧的。预分片的实现比直接取模多一层映射初期确实有点多余但它是长期最省心的方案。如果团队有中长期扩容预期建议一步到位。5.3 数据迁移的完整节奏从双写到灰度切换不管用哪种方案数据迁移都不是“停服拷贝”而是线上平滑切换。我自己跑过多次迁移基本固定成下面五步节奏。第一步建表与双写。在目标库建好新表结构业务写入时同时写旧表和按新规则映射的新表双写逻辑要能容忍重复数据最好以幂等写入方式保证两侧一致。第二步全量迁移。用一致性快照或者离线任务把存量数据导入目标库。如果数据量很大按槽位或按ID区间分批处理。第三步增量追平。通过数据库的变更日志订阅或者消息回放把双写期间产生的增量数据持续同步到目标库直到目标库和旧库的数据延迟降到秒级以内。第四步数据校验。抽样或者全量对比主键、版本号、排序字段。不一致的记录要重新回放。这一步不要省我见过直接跳过校验上线结果漏了部分分片的数据用户反馈问题层出不穷。第五步灰度切换。先把少量读写流量切到新路由观察指标逐步放大比例1% → 10% → 50% → 100%。每次放大后至少观察一个业务周期确认无异常再继续。窗口选择低峰期出错时还能快速回切。双写与增量同步期间还有一个常见坑新旧路由并存会导致部分数据重复。这个只能靠全链路幂等去兜比如每次写入带业务主键目标库按主键去重。总之迁移本身不可怕可怕的是迁移方案里没有回退预案和校验机制。6. 反向经验有些表根本不该分库分表6.1 什么情况下分库分表是负优化聊了这么多问题与解法我得泼一盆冷水很大一部分系统其实根本不需要分库分表。一个典型场景是单表只有几百万行数据查询几十毫秒仅仅因为听说“大厂都这么干”或者为了面试好聊就引入了分库分表。结果每个查询都要带路由键不带就全分片扫描跨分片查询还引入额外的组装逻辑性能比单库更差。这种属于典型的负优化。另一个反例是业务 SQL 本身特别复杂、关联五六个表、查询维度又多又杂的表。这种表一旦水平拆分几乎所有查询都会退化成跨分片扫描加上 MapReduce 式的归并复杂度直接爆炸。对于这种表分库分表只会让系统更慢、更不稳定。一个简单判断是如果分库分表之后你的核心查询依然需要频繁跨分片且跨分片率超过 30%那大概率是拆错了。正确做法是在拆分前就把查询模式摸清而不是拆完再补。6.2 更轻的替代方案缓存、冷热分离与异构存储很多以为需要分库分表的问题其实用更轻的方案就能解决。读多写少、查询热点集中优先上缓存。把高频查询结果缓存起来数据库压力立刻降下来。缓存都搞不定的高频复杂查询再考虑别的。历史数据不参与在线查询做冷热分离或归档。以订单系统为例三个月前的订单查询频率骤降可以直接迁移到归档库或大数据存储在线库只保留热数据。这种方案比无脑水平分表更简单效果也更直接。单库连接和IO扛不住优先垂直分库。把用户、订单、商品放到不同实例互不影响。很多时候没有水平拆分的必要只是多个业务域挤在一起了。复杂检索、报表聚合这类需求最好直接交给搜索引擎或者分析型数据库。让在线事务库承担极少的高权重查询其他分析类需求全部导出到专用引擎。这套组合拳下来真正需要水平分表的场景会少很多。6.3 我的选型判断标准最后分享一套我自己的选型判断框架纯经验之谈不一定绝对但值得参考单表数据量是否接近 5000 万行以上并且持续增长单库写入 QPS 是否持续打到上千甚至更高连接数和 CPU 是否长期高水位核心业务是否已经无法靠索引优化、缓存、归档、读写分离来缓解最频繁的查询是否能以某一个固定键作为路由键收敛跨分片查询比例是否足够低团队是否有人能长期维护路由、双写、对账、迁移这套体系如果这五条里有两条以上不满足我都建议先不要分。因为分库分表是用复杂度换容量收益必须明显大于复杂度才有意义。我以前接手过一个“号称已分库分表”的老系统不少表才几百万行却因为路由键设计不合理每个查询都在全分片扫描比单库还慢最后又把那些表合并回去了。这个内容后续还可以这样扩展把路由表、双写同步、对账任务做成可观测的治理平台或者进一步思考冷热混合存储让分片策略随着数据生命周期动态调整。不过这些都是后话了先把上面这些问题想清楚分库分表这条路才算真的走稳了。