搞后端的同学,迟早会遇到这么一关:线上业务跑得好好的,突然某天监控告警,订单表已经七八千万行,单表查询动不动就几百毫秒,写库的时候行锁还打架。这时候,很多人脑子里会冒出四个字——分库分表。但你真去搜资料,又会发现一堆名词砸过来:垂直分库、水平分表、分片键、广播表、中间件、分布式事务……每个字都认识,连在一起就懵。
今天我把这些名词和背后的中间件原理摊开讲清楚。这里不聊玄学,直接说一个分库分表系统里,一条SQL从发出来到拿到结果,中间到底发生了什么,拆分方案到底怎么定,以及上线后最容易踩的那些坑。这篇文章适合正在做系统架构评估、数据量大到开始睡不着觉的后端研发,也适合刚接手高并发系统的初级工程师。
1. 先把名词理顺:分库分表的基础概念
很多新人一上来就问“我该分库还是分表”,这个问题其实问早了。分库和分表拆的东西完全不一样,对应的瓶颈也不一样。只有先把概念理清,后面选方案才不会跑偏。
1.1 分库和分表,先分清拆的是什么
分库,拆分的是数据库实例。原来是所有业务表都在一个数据库里,现在拆到多个数据库,可能在同一台机器,也可能在不同机器。它的核心目的是降低单库的连接数压力、磁盘IO压力、CPU压力和锁竞争压力。举个例子,订单库和用户库拆开,订单高峰写入订单库,就不会把用户库的查询拖死。
分表,拆分的是物理表。原来一张表几千万行,索引层级深,查询要扫很多数据页,写入还需要维护索引;拆成多张结构相同的物理表,每张表的数据量降下来,单表查询和写入压力都会小很多。它解决的是单表数据量过大导致的性能问题。
所以分库和分表并不是二选一,而是分别解决“实例压力大”和“单表数据量大”两个问题。生产环境里最典型的做法是“分库又分表”,比如8个库,每个库里同一张逻辑表拆成8张物理表,总共64个物理分片。这样既分散了数据库连接,也控制了单表行数。
1.2 垂直拆分和水平拆分,别再傻傻分不清
垂直拆分的核心是“按字段或按业务模块拆”。垂直分库,就是把用户、订单、支付这些业务模块放到不同库;垂直分表,是把一张表里的字段拆开,比如把商品表的基础信息放到商品主表,把详情、图片、扩展属性放到商品详情表。垂直拆分的价值在于隔离不同类型的数据访问,避免一个大表的行宽过宽、索引过大,让高频查询和低频大字段互相干扰。
水平拆分则是“按数据行拆”。同样是订单表,把订单数据按照某个规则分散到多张结构相同的表里。它不改变表结构,只改变数据的存放位置。水平分库和水平分表可以组合使用,比如按用户ID取模,决定数据进哪个库,再进哪个表。
这里有一个很常见的设计混淆:新手容易把“按月分区表”当成水平分表。数据库分区表确实也是把数据按规则放到不同分区,但它对应用透明,还在一张逻辑表里,由数据库自己管理;分库分表则是把数据放到独立的物理表或独立的库中,中间件需要参与路由。分区表解决不了单实例的资源瓶颈,水平分表能配合分库一起把压力分散出去。
1.3 其它高频名词:分片键、广播表、读写分离、分布式ID
分片键也叫分库分表字段,是决定一行数据去哪个分片的关键字段。比如order_id % 16,order_id就是分片键。选错分片键,整个拆分方案基本白做。
广播表又叫全局表,是那种数据量不大、几乎所有分片都要用到的表,比如商品分类表、地区表。中间件会把这个表的全量数据同步到每个分片,这样在任意分片执行关联查询时,都能拿到完整数据,避免了跨库JOIN。
读写分离解决的是“读多写少”的压力问题:主库承担写入,从库复制主库数据后承担查询。它和分库分表不是一回事,但经常会一起用。分库分表解决数据规模和写入并发,读写分离解决单机读并发,两者互补。
分布式ID是分库分表绕不开的配套方案。原来数据库自增主键在单库里没问题,一旦拆成多库多表,每个分片都自增,全局就重复了。所以需要一套全局唯一ID生成方案,常见的是雪花算法或者中间件自带的ID生成器。这部分后面讲原理时会细说。
2. 中间件原理:从一条SQL看分库分表怎么工作
分库分表之后,应用不能直接面向几十张物理表。比如逻辑上是一张order_info表,实际上它分散在order_info_0到order_info_15,如果让业务自己判断去哪张表查,代码会写得非常痛苦。这时候就需要中间件在中间做一层“翻译”。
分库分表中间件的基本工作流程可以概括为四步:解析SQL、计算路由、改写SQL、执行并归并结果。下面我按一条实际SQL的执行路径讲。
2.1 SQL解析:先搞懂字符串原来是棵树
中间件拿到应用发来的SQL时,第一件事不是去连数据库,而是把SQL字符串解析成结构化的语法树。这是一切后续操作的基础。
比如你发来一条查询:
SELECT * FROM order_info WHERE user_id = 123 AND order_status = 1;中间件会拆出几个关键信息:逻辑表名order_info、查询条件user_id = 123、order_status = 1。这些信息在语法树里被分开存储,后面路由和改写才能拿它们做计算。
解析要支持多种数据库方言,还要能处理子查询、JOIN、UNION这种复杂语法。很多中间件对复杂SQL支持有限,原因就在这个环节:解析不了,后面全免谈。所以使用中间件时要特别注意SQL写法,能拆分复杂查询、能避免多表JOIN就尽量避免。
2.2 路由计算:数据到底落在哪个分片
解析之后要回答的问题是:这条SQL应该去哪些分片执行?答案由路由算法决定。
最常见的是取模路由。例如分片键是user_id,总共12个分片,规则是user_id % 12。user_id=123时,123对12取模等于3,所以数据只去分片3查找,其他分片完全不用碰。这种算法简单直观、分布均匀,也是新手最容易理解的一种。
第二种是哈希路由。取模不适用于字符串分片键,比如用手机号或设备号分片,可以先算哈希值,再做取模或范围映射。哈希路由的问题在于:如果直接对哈希值取模,扩容时需要重新计算所有数据的位置,迁移成本很高。所以很多中间件采用一致性哈希,目的就是把扩容时受影响的数据量降到最低。
第三种是范围路由。按照ID区间或者时间区间分片,例如订单ID在1到1000万进分片0,1000万到2000万进分片1。日期分表更是典型,order_202401、order_202402。范围路由的优点是区间查询友好,缺点是数据容易集中在一个分片,比如只查当月的表,热点很明显。
路由判断的结果不是唯一的:如果SQL条件里带上了分片键并且是等值条件,可以精确路由到一个分片;如果分片键条件是范围查询,可能路由到多个分片;如果SQL里压根没有分片键,那中间件只能把请求广播到所有分片,这种情况叫全路由,后面排查问题时会重点遇到。
2.3 SQL改写:逻辑表和物理表之间切换
路由计算出来以后,中间件要把原始的SQL改写成能在真实物理表上执行的SQL。这一步对应用透明,应用还是写逻辑表名,真正执行的是改写的版本。
还是上面那条SQL,假设规则是user_id % 16,123对16取模等于11,中间件改写的结果类似:
SELECT * FROM order_info_11 WHERE user_id = 123 AND order_status = 1;除了替换表名,改写还会处理很多细节。比如分页查询,LIMIT 10发到每个分片之前,中间件要改成LIMIT 10,同时把所有分片返回的数据汇总后在内存里再做一次归并。更复杂的聚合函数也要改写:SELECT COUNT(*) FROM order_info在各分片执行后,中间件要把各自的COUNT(*)相加才是最终结果。
写入场景的改写更严格。插入语句如果没写分片键,中间件甚至可能直接报错,因为它没法计算该写入哪个分片。这是分库分表之后对开发最大的约束:所有写操作必须带分片键。
2.4 结果归并:多分片的数据怎么“合起来”
SQL被改写到多个分片执行后,中间件会拿到多份结果集,不能直接原样返回给应用,必须合并成一个结果集。这个合并动作就是结果归并。
最简单的是遍历归并,所有分片结果直接拼起来,适合COUNT以外的无顺序要求查询。排序归并就麻烦一些:SELECT * FROM order_info WHERE user_id = 123 ORDER BY create_time DESC LIMIT 5,每个分片都只能先取前5条,中间件拿到所有分片的前5条后,在内存里做一次排序,才能最终取出前5条。如果LIMIT后面跟了大的OFFSET,比如第10000条开始取,每个分片都要取10000+条,性能消耗会成倍放大,这也是分页深度翻页在分库分表场景特别慢的根本原因。
聚合归并是指SUM、MIN、MAX这类函数:SUM需要把所有分片的和再加总,MAX需要比较所有分片的最大值。GROUP BY场景更复杂,各分片先各自分组聚合,中间件再做一次二次归并。有些中间件在聚合优化上做得很深,能把部分聚合计算下推到数据库,只传少量结果到内存,但仍有大量场景依赖内存归并,所以不能迷信“中间件包办”。
2.5 分布式事务与分布式ID:保证分片后不出乱子
单个分片内的数据库事务,中间件是可以保证ACID的,因为底层就是普通数据库事务。但一条业务操作要更新多个分片时,问题就来了:分片A提交成功,分片B提交失败,整个操作到底算成功还是失败?
行业里的方案主要有两派。一派是强一致的分布式事务,比如XA两阶段提交:先让所有分片进入待提交状态,都准备好了再统一提交。好处是保证一致,代价是性能损失明显,现在大部分业务系统已经很少在核心链路上用XA。
另一派是柔性事务,基于最终一致性。中间件内部协调各分片事务,配合重试机制、事务日志、状态机,允许短暂不一致,但通过后台补偿最终对齐。实际落地时,很多团队会采用本地消息表加定时任务,或者改造业务流程避免一次跨多个分片。
分布式ID在这里承担的任务,是让每个分片生成的主键都全局唯一。典型的雪花算法生成一个64位整数,由时间戳位、机器标识位和序列号位组成。我在多个项目里的感受是:用自己的序列服务或者用一套统一的ID生成服务,比在业务代码里自己拼一个“时间戳加随机数”要靠谱得多,至少能避免并发冲突和时钟回拨问题。
3. 实操落地:方案选型、分片键确定、接入步骤
名词和原理都过了一遍,接下来是大家最关心的:真实项目里到底怎么分、怎么接。这一章我会按我自己的推进顺序来写,基本都是可以直接抄作业的流程。
3.1 什么时候分,怎么分,先做容量估算
很多团队把分库分表当成一种“荣誉证书”,系统才几百万数据就急着拆,结果运维成本和开发复杂度都上去了,性能没有任何收益。分库分表是最后手段,不是第一选择。
我的建议是分之前先做三件事。第一,看单表数据量和单行大小。单表超过一千万行,或者表容量达到几十GB,索引效率和查询体验明显下降,这时候才需要认真考虑分表。第二,看每秒QPS和TPS。如果单库连接数被打满、CPU长期在80%以上,单纯加索引已经救不回来,可以考虑分库。第三,看核心查询是否能用现有索引覆盖。很多“慢SQL”其实是索引缺失导致的,加一个联合索引能解决的事,不值得上一套中间件。
容量估算可以用一个简单模型:假设每天新增订单200万行,单行平均1KB,一天就是2GB左右,一个月60GB,三个月就接近200GB。再叠加读取放大和备份恢复时间,单库单表必然撑不住。这种增长模型下,分库分表才是合理的。
拆分的数量也讲究个度。初期我见过不少团队直接64个分片,实际业务量根本跑不满,反而让很多管理面的事变成体力活。建议从16个分片起步,也就是4库乘4表,后面根据增长再加。分片数尽量取2的幂,后续做扩容时更好规划。
3.2 分片键怎么选:三个核心原则
分片键是整个分库分表方案的命门。它选得好不好,直接决定这个系统未来一年是省心还是天天救火。
原则一是高频查询条件必须覆盖分片键。最简单的判断方法:打开慢日志和监控,看核心查询里WHERE条件常带哪个字段。如果订单查询一律带着user_id,那user_id就是分片键;如果查询入口总是按order_id查,就改为按order_id分片。最怕的是两头都高频,哪个都舍不得,最后只能做全路由。
原则二是值分布要均匀。选一个只有几个固定值的字段做分片键就是灾难,比如按“省份”分片,广东的数据量是其他省份好几倍,会产生严重的数据倾斜。常见的均匀字段是订单号、用户ID、手机号,它们的枚举空间够大,取模后分布相对均衡。
原则三是一旦确定几乎不改。更新分片键意味着要把这一行数据从旧分片搬到新分片,跨分片操作非常麻烦。所以建议选那些“创建后基本不变”的字段。
还有一种常见场景是“订单既按用户查,又按商家查”。这种多维查询没法靠单一分片键通吃,我的做法通常是:主分片键按用户ID,订单表冗余一份按商家ID拆分的索引表,或者干脆建一张商家维度的汇总表,用异步任务同步数据。不要在同一个表上强求两个分片键,中间件处理这种双路由的复杂度和代价都比想象中大。
3.3 接入中间件的典型流程
分库分表中间件在部署形态上有两类:一类以Proxy方式独立部署,应用通过数据库协议连接中间件,对业务代码侵入小,但多一层网络转发;另一类是嵌入应用的方式,通过数据源封装来路由,性能好一些,但需要改造代码。选型时没有绝对好坏,得看团队是偏DBA运维还是偏应用开发。
我常用的接入流程大致如下:
第一步,梳理系统里所有核心表,找出真正需要拆分的表和它的关联表。可以把这个阶段看成“只分必须分的表”,不要顺手把不需要拆的表也塞进中间件,那只会增加配置复杂度。
第二步,确定分片键和路由算法。等值查询为主就取模或哈希,范围查询为主就按区间。这个决定后续很难改,要反复推演一下未来两三年的数据增长。
第三步,配置数据源和逻辑表。先准备独立的测试环境,把主库、从库、分片规则、广播表都配好,应用只连中间件,连不上真实表。
第四步,改造SQL和事务边界。去掉中间件不支持的写法,把不带分片键的查询尽量调整成带分片键。事务里如果可能跨分片,提前做好最终一致性方案和幂等控制。
第五步,灰度发布。先切部分读流量到中间件,对比结果和耗时;确认无异常后,再切写流量。整个过程不要一天做完,留足观察窗口。
接入过程中最大的隐藏成本其实是“把原来SQL里隐含的假设全部翻出来”。比如有人写SELECT * FROM user WHERE name = ?,单表时没问题,分片后如果name不是分片键,这条SQL就会全路由,跑到中间件层变成压垮系统的慢查询。
3.4 数据迁移与双写方案
如果系统是从单表直接演进到分库分表,存量数据必须先迁到分片结构里。如果已经用中间件但想扩容,也要面对存量数据重分布。数据迁移不是导出导入那么简单,关键是别丢数据、别重复。
在可停机场景下,方案最简单:写维护公告,停写,导出单表数据,按分片键计算后导入各分片,校验记录数和关键字段对账,最后切换读写流量。优点是控制力强,缺点是要牺牲一段时间的可用性。
在不可停机场景下,就得做平滑迁移,通常分三步走。第一步全量复制存量数据到分片,这一步可以后台慢慢跑;第二步开启增量同步,把源表新增和变更的数据实时追到分片,保证源和目标不断接近;第三步进行双写。应用写入时同时写旧表和新分片,读流量逐步从旧库切到新库,同时用对账任务对比新旧数据。等数据校验通过,再停掉旧表的写流量,整个迁移就结束了。
这个过程中我犯过一个印象很深的错:直接开启双写,但没有把两边写入做成幂等,导致重试时旧表写入两次,新表也写入两次,最后对账差了一大批数据。正确做法是双写前先给每行数据设计一个全局唯一的业务主键或者唯一索引,并且在写入时由服务端做幂等判断。
4. 常见问题与排查技巧实录
不管方案设计得多周全,上线后大概率还是会遇到问题。这一章我整理几个出现频率最高的问题,每一个都是我亲眼见过或者亲手处理过的典型场景,可以直接当排查手册用。
4.1 全路由问题:一条SQL拖垮整个集群
现象是中间件监控显示某条SQL被路由到所有分片执行,单次查询的耗时从几毫秒暴涨到几百毫秒,数据库连接数瞬间飙升,最终拖垮整个集群。
根因几乎都是SQL里缺少分片键。比如订单表按user_id分片,但业务代码用order_no去查订单详情,中间件没有order_no的分片规则,只好全片扫描。这种问题在分库分表后非常常见,因为应用里总有千奇百怪的查询条件。
排查思路是先看中间件的SQL审计日志,找到路由到全部分片的SQL;再看这条SQL的调用来源是哪个服务、哪个接口;最后到代码里确认是否在入口处把分片键传下来了。如果确实需要支持“非分片键查询”,最稳妥的办法是建立分片键与业务字段的映射关系,比如单独建一张索引表存order_no到user_id的对应关系,查询两次。
4.2 数据倾斜:哪个分片都不少,就它特别多
有些系统拆完之后,某个分片的数据量明显超过其他分片,查询热点都压在一个库上,其他库却很闲。比如按用户ID取模,但系统里大部分订单都来自十几个大客户,这些大客户的ID取模后恰好集中到同一个分片。
解决倾斜要从源头做:分片键尽量选择分布均匀的字段,而不是业务意义上的“重要字段”。如果已经偏了,有两个补救思路。一是给分片键加盐,在计算路由前对原始值拼一个后缀,把大客户的数据打散到多个分片,代价是查询时需要同时查多个分片再合并。二是在应用层对大客户做单独的冷热分类,把大客户的订单单独存放一套表结构。
4.3 扩容难题:从4片扩到8片,数据全乱了
这是个非常经典的焦虑点。原来规则是id % 4,数据分在4个分片里;业务增长后想把4个分片变成8个,如果直接改规则为id % 8,几乎每一条数据的归属分片都会发生变化,等于把所有数据重新导一遍。
提前预防比事后补救重要得多。我见过一个比较好的做法:分片规则不直接% 4,而是先% 1024,把1024个虚拟桶映射到物理分片上。扩容时只调整“虚拟桶到物理分片”的映射关系,大部分数据不用动,只有少部分桶需要迁移。这个方案牺牲了一点复杂度,换来了扩容时的从容。
如果已经用了直接取模,扩容就只剩一条路:用双写迁移。把读写流量保持在新旧两套分片方案之上,后台做全量数据迁移和增量追平,最后切换规则。这个过程非常耗时间,所以我在做新拆分方案时,基本都会要求预留虚拟桶或者一致性哈希,避免下一代人再踩一次坑。
4.4 跨分片JOIN和分布式事务的坑
分库分表之后业务最不适应的就是跨分片JOIN。原来一张订单表可以JOIN用户表、商品表,分片后如果两张表的分片键不一致,数据不在同一个物理分片,JOIN就无法直接在数据库完成。
我的解决顺序是:能不做JOIN就不做JOIN,应用层查两次,内存里拼数据。必须做JOIN时,优先把维度表做成广播表,每个分片都放一份;关联的明细表尽量使用相同的分片键,让关联数据落在同一个分片,中间件能直接在同库完成JOIN。都不满足,最后才考虑应用层合并。
分布式事务的坑则更难察觉。跨分片写库时,一条业务链路涉及多个本地事务,中间任何一个成功提交了,后面一个失败了,数据就处于中间状态。排查这类问题不能只依赖数据库日志,要建立对账系统,定期对比各分片的关键数据和业务状态,发现不一致后通过补偿任务修复。这个对账脚本在我维护的系统里,比中间件自带的事务机制可靠得多。
这里我还想额外提一个坑:中间件的事务上下文。如果事务里一条SQL路由到分片A,紧接着另一条SQL路由到分片B,事务是在多个分片上分别开启的,任何一节提交失败,整份结果都和预期不一致。所以业务代码里要尽量避免“大事务”,拆成小事务,每个事务尽量只操作一个分片。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 优先排查方向 | 解决思路 |
|---|---|---|---|
| 某条SQL非常慢 | 缺少分片键导致全路由 | 中间件路由日志,慢SQL监控 | 加映射表,确保入口带分片键 |
| 某个分片数据明显多 | 分片键分布不均 | 统计各分片行数,分析数据分布 | 加盐分片、冷热分离 |
| 扩容后大量数据迁移 | 直接取模规则无虚拟桶 | 检查分片规则演进方案 | 虚拟桶映射或一致性哈希 |
| 跨分片JOIN报错/性能差 | 关联表分片键不一致 | 查中间件是否支持该JOIN | 广播表、应用层合并 |
| 数据不一致 | 分布式事务未完全处理 | 对账脚本比对分片数据 | 幂等重试、补偿任务 |
| 分页深翻页卡顿 | 每个分片都取全量offset | 查分页改写逻辑 | 禁用深分页,改用游标或key-set分页 |
| 分布式ID重复 | 时钟回拨或生成策略问题 | 查ID生成服务日志 | 更换时钟源、缓存加速、统一ID服务 |
最后说一个我踩过好几次坑之后的体会:分库分表中间件不是越复杂越好,也不是越透明越好。它在帮应用屏蔽数据分布的同时,也把很多原来数据库层面的约束变得不那么透明。你越了解它底层怎么做解析、路由、归并,就越知道哪些SQL能写、哪些不能写。真的遇到性能瓶颈时,先排查慢SQL,再看索引,最后再谈分库分表。一旦决定上中间件,分片键的确认、扩容预案和数据校验体系这三件事,必须在第一天就想清楚,否则后面每个迭代都会还债。