搞后端的同学迟早会遇到这么一关线上业务跑得好好的突然某天监控告警订单表已经七八千万行单表查询动不动就几百毫秒写库的时候行锁还打架。这时候很多人脑子里会冒出四个字——分库分表。但你真去搜资料又会发现一堆名词砸过来垂直分库、水平分表、分片键、广播表、中间件、分布式事务……每个字都认识连在一起就懵。今天我把这些名词和背后的中间件原理摊开讲清楚。这里不聊玄学直接说一个分库分表系统里一条SQL从发出来到拿到结果中间到底发生了什么拆分方案到底怎么定以及上线后最容易踩的那些坑。这篇文章适合正在做系统架构评估、数据量大到开始睡不着觉的后端研发也适合刚接手高并发系统的初级工程师。1. 先把名词理顺分库分表的基础概念很多新人一上来就问“我该分库还是分表”这个问题其实问早了。分库和分表拆的东西完全不一样对应的瓶颈也不一样。只有先把概念理清后面选方案才不会跑偏。1.1 分库和分表先分清拆的是什么分库拆分的是数据库实例。原来是所有业务表都在一个数据库里现在拆到多个数据库可能在同一台机器也可能在不同机器。它的核心目的是降低单库的连接数压力、磁盘IO压力、CPU压力和锁竞争压力。举个例子订单库和用户库拆开订单高峰写入订单库就不会把用户库的查询拖死。分表拆分的是物理表。原来一张表几千万行索引层级深查询要扫很多数据页写入还需要维护索引拆成多张结构相同的物理表每张表的数据量降下来单表查询和写入压力都会小很多。它解决的是单表数据量过大导致的性能问题。所以分库和分表并不是二选一而是分别解决“实例压力大”和“单表数据量大”两个问题。生产环境里最典型的做法是“分库又分表”比如8个库每个库里同一张逻辑表拆成8张物理表总共64个物理分片。这样既分散了数据库连接也控制了单表行数。1.2 垂直拆分和水平拆分别再傻傻分不清垂直拆分的核心是“按字段或按业务模块拆”。垂直分库就是把用户、订单、支付这些业务模块放到不同库垂直分表是把一张表里的字段拆开比如把商品表的基础信息放到商品主表把详情、图片、扩展属性放到商品详情表。垂直拆分的价值在于隔离不同类型的数据访问避免一个大表的行宽过宽、索引过大让高频查询和低频大字段互相干扰。水平拆分则是“按数据行拆”。同样是订单表把订单数据按照某个规则分散到多张结构相同的表里。它不改变表结构只改变数据的存放位置。水平分库和水平分表可以组合使用比如按用户ID取模决定数据进哪个库再进哪个表。这里有一个很常见的设计混淆新手容易把“按月分区表”当成水平分表。数据库分区表确实也是把数据按规则放到不同分区但它对应用透明还在一张逻辑表里由数据库自己管理分库分表则是把数据放到独立的物理表或独立的库中中间件需要参与路由。分区表解决不了单实例的资源瓶颈水平分表能配合分库一起把压力分散出去。1.3 其它高频名词分片键、广播表、读写分离、分布式ID分片键也叫分库分表字段是决定一行数据去哪个分片的关键字段。比如order_id % 16order_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_id123时123对12取模等于3所以数据只去分片3查找其他分片完全不用碰。这种算法简单直观、分布均匀也是新手最容易理解的一种。第二种是哈希路由。取模不适用于字符串分片键比如用手机号或设备号分片可以先算哈希值再做取模或范围映射。哈希路由的问题在于如果直接对哈希值取模扩容时需要重新计算所有数据的位置迁移成本很高。所以很多中间件采用一致性哈希目的就是把扩容时受影响的数据量降到最低。第三种是范围路由。按照ID区间或者时间区间分片例如订单ID在1到1000万进分片01000万到2000万进分片1。日期分表更是典型order_202401、order_202402。范围路由的优点是区间查询友好缺点是数据容易集中在一个分片比如只查当月的表热点很明显。路由判断的结果不是唯一的如果SQL条件里带上了分片键并且是等值条件可以精确路由到一个分片如果分片键条件是范围查询可能路由到多个分片如果SQL里压根没有分片键那中间件只能把请求广播到所有分片这种情况叫全路由后面排查问题时会重点遇到。2.3 SQL改写逻辑表和物理表之间切换路由计算出来以后中间件要把原始的SQL改写成能在真实物理表上执行的SQL。这一步对应用透明应用还是写逻辑表名真正执行的是改写的版本。还是上面那条SQL假设规则是user_id % 16123对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再看索引最后再谈分库分表。一旦决定上中间件分片键的确认、扩容预案和数据校验体系这三件事必须在第一天就想清楚否则后面每个迭代都会还债。