这些年做后端开发,凡是聊到数据量增长,几乎必然要碰“分表分库”这个话题。找我咨询的人通常一开口就问:“我们到底用不用做分库分表?分片策略选哪种最好?”但每次我的回答都是:先别急着选策略,你得先把容量账算清楚,再把业务查询模式画出来,否则无论选取模还是区间,后面都是给自己挖坑。这篇文章不是理论科普,而是把分表分库分片策略选型里真正要拍的几个决策点,整理成一份可以直接对着勾选的清单。适合正在规划数据架构、或者已经被慢查询搞得想上分片但不知道从哪下手的同学参考,也适合准备做系统改造复盘时拿出来对照一下。
1. 什么阶段值得做拆分:容量模型的先算账
1.1 单库单表的真实容量瓶颈
很多团队聊分库分表,是出于一种模糊的焦虑——“表里数据量到了几百万,是不是该拆了?”这个出发点其实不太对。MySQL单表能撑多少行,并没有一个“到了500万就必须拆”的硬指标,真正决定上限的是三个东西:B+树的索引深度、行宽度、以及写入并发模式。
先看索引深度。InnoDB默认页大小是16KB,主键如果是8字节的bigint,加6字节的行指针,一页大概能存1000多个索引条目。B+树三层能管理的数据行数,粗略估算在千万到亿级别,所以单表两千万行的点查询,只要索引用得对,磁盘IO也就三五次,根本不慢。真正让单表变慢的通常是两种情况:一是没有合适索引导致全表扫描,二是行宽度太大导致单页能容纳的行数很少,索引叶节点频繁分裂。
再说写入模式。单库的连接数、binlog写入、redo log刷盘,在高并发下都会成为瓶颈。我曾经接过一个后台系统,单表单表只有不到800万行,但每秒要扛2000多次插入,CPU和IO都顶不住了。这种情况下哪怕表只有100万行,该拆也得拆。所以判断要不要做分表分库,不能只看行数,要同时看数据总量、访问并发和SQL形态。
1.2 拆分前的容量估算方法
我个人习惯是先花半小时把容量模型算清楚,再决定动不动刀。估算公式不复杂:每日新增行数 × 保留周期 × 单行平均字节数,就是一张表的稳态数据量。
举个例子,一个订单系统日均产生50万条订单主记录,单行包含订单号、用户ID、金额、状态、时间等字段,平均按500字节算,一年就是50万 × 365 × 500字节,大概9GB。如果保留三年,存量就是27GB左右。点查询按主键或用户ID走,这个量级单表其实完全扛得住,不一定需要分片。
反过来,如果日均新增1000万条埋点日志,单行1KB,保留30天就是30TB,这种量级不仅得分表,大概率还得考虑归档和冷热分离。容量算完,再叠加QPS/TPS目标和慢查询占比,才能判断“是不是非拆不可”。我见过不少项目,数据量也就几百万行,结果为了“未来扩展”强行拆成32个分片,业务开发效率直线下降,运维也复杂了好几倍,这就是没算账的后果。
1.3 过度设计的成本账
分库分表不是免费的午餐,它带来的额外成本是实打实的:跨分片查询要改写、分布式事务要引入、全局主键要重新设计、扩容要迁移数据。这些成本最终都会转嫁给研发排期和线上故障率。
所以我的判断标准很简单:如果单表在合理索引下点查询和列表查询都能控制在几十毫秒,而且未来两三年容量增长也没有数量级变化,那就先不拆。先把慢查询优化、索引调整、缓存扛量这些事做掉,比上分片划算得多。分表分库应该是在“优化已经做到位但仍然扛不住数据量或并发”的阶段才启动的方案,而不是数据架构的默认选项。
2. 分片键是地基:选错后面全部白做
2.1 候选键的区分度与访问均衡性
如果确认要拆,第一个要拍板的就是分片键。分片键决定了每一条数据落到哪个分片,也决定了每次查询能不能精确路由到单个分片。选错分片键,后面所有分片策略、中间件配置都白搭。
判断一个字段适不适合做分片键,先看两件事:区分度和访问均衡性。区分度就是字段值的离散程度,user_id、order_id、流水号这类高基数字段天然适合;而status、type这种只有几个取值的字段坚决不能用,因为你会把所有数据压到极少数分片上,热点直接把节点打爆。
访问均衡性更隐蔽,它要求业务查询条件里经常带上这个字段,并且数据在这些值上均匀分布。比如C端App场景,绝大多数查询都是“查某个用户的订单”,那按user_id分片就非常理想;但如果是多租户后台的运营报表,按门店ID或租户ID分片可能更合适,关键看你的真实流量模型。
2.2 分片键与查询维度的矛盾怎么解决
最常见的坑是:明明业务主要按user_id查,但有一些重要查询是按order_id来的,而order_id是雪花算法生成的,不带任何用户信息,路由不到分片。这个矛盾不解决,系统上线后就会大量出现跨分片全扫描,性能直接退化成灾难。
业内常用的解法有三种。第一种是基因法,在生成order_id时把user_id的一部分二进制位拼进去,这样拿到order_id就能反向推导user_id,从而定位到正确分片。第二种是映射表,单独维护一张order_id到user_id的映射关系,查询时先查映射再路由,代价是多一次IO,适合低频查询。第三种是冗余宽表,把需要跨维度查询的数据同步到ES或ClickHouse,列表页、运营后台都走搜索库,RPC回源查明细。三种方案不冲突,实际项目里经常混用。
这里给一个选型经验:如果业务访问路径高度集中在“单一主体维度”(比如用户、商户、租户),优先按这个主体ID分片,然后围绕它设计其他维度的查询方案;如果业务本身就是多维度交叉查询特别多,那分库分表可能不是最佳手段,直接考虑分布式数据库或者上数仓宽表分析会省事得多。
2.3 分片键选型的核对清单
我每次评审分片键方案,都会用下面这张清单过一遍:
| 检查项 | 说明 | 反例 |
|---|---|---|
| 高区分度 | 字段取值是否足够离散 | 用status、channel等枚举字段 |
| 访问带键率 | 核心查询是否都会带该字段 | 高频查询按手机号但分片键用了ID |
| 分布均匀性 | 数据与请求是否均匀落到各分片 | 按时间取模导致写入集中在最新分片 |
| 值稳定性 | 字段值是否一成不变 | 用用户归属地做键,用户迁移后路由错乱 |
| 可路由性 | 是否有一条路径能从其他标识推导出分片键 | 订单号无法推导出用户ID且无映射 |
这几项全部满足,再进入下一步选分片策略。如果有一项不满足,就先把路由链路设计补齐了再动手,别指望上线后再补救。
3. 分片策略三派:取模、区间、混合切分怎么选
3.1 取模分片:实现简单但扩容是命门
取模分片是应用最广、理解成本最低的策略,公式就一句话:分片号 = hash(分片键) % 分片总数。这里有个细节:不一定直接拿ID数值取模,通常会先对ID做一次hash散列再取模,目的是让分布更均匀,避免自增ID低位数天然聚集的问题。
取模的优点非常明显——实现简单、数据分布均匀、单个查询路由计算快。缺点同样突出:一旦分片总数变化,比如从4个分片扩到5个,几乎所有数据的分片位置都会改变,原来在分片0的现在可能要跑到分片3,这就意味着必须对存量数据做全量rehash和迁移,在线扩容极其痛苦。
所以我一般建议:如果团队规模不大、又不确定未来容量增长曲线,取模分片最好配合“预留足够多分片数”使用。比如预估未来三年数据量会到3000万行,单分片扛500万,那就一开始定8个分片,而不是先4个再慢慢加。用一次性的物理分片数成本,换掉未来最危险的在线扩容操作。
3.2 区间分片:天然排序但热点集中
区间分片是按分片键的值域范围切分,比如按时间切成月表、周表,按ID区间切成[1, 100万)、[100万, 200万)。它的核心优势有两个:一是范围扫描非常友好,查某个月的数据直接落到对应分片,不需要跨分片聚合;二是扩容简单,加了新分片只需要在范围表里加一个区间,存量数据完全不用动。
致命缺点则是热点集中。拿订单按时间分片举例,每天的新订单几乎全部写到“今天”那个分片上,写入压力完全没被分摊,读写都集中在最新节点上,老节点反而闲着。这种不均匀分布对高并发写入系统来说是致命的。
区间分片更适合两类场景:第一类是日志、监控、流水等时间序列数据,天然按时间窗口归档;第二类是冷热数据差异极度明显的业务,比如订单超过一年后极少被访问,可以直接把老数据迁移到按时间切分的低频分片。如果是高并发在线交易类业务,纯区间分片要非常谨慎。
3.3 混合策略与一致性哈希的折中
混合策略是我在实际项目里用得最多的思路,核心做法是“时间分区 + 主体ID取模”两级切分。还是拿订单举例:第一级按月切分,保证冷热分离和归档方便;第二级在同一月份内按user_id取模,把同一个月的数据分散到多个分片,避免单月写入热点。这个方案同时解决了热点问题、扩容问题(月份切换天然是扩容窗口)和范围查询问题,代价是路由逻辑稍微复杂一点。
另一种折中是引入一致性哈希环。一致性哈希把分片节点映射到一个环上,数据按hash值找到最近的节点;扩容时只影响环上一个区间内的数据,迁移量远小于全量rehash。它的问题是实现复杂度高、虚拟节点分配不当会造成负载不均,而且数据分布天然不如取模均匀。
我的个人排序是:在线交易类业务优先“时间 + 主体ID”混合分片;数据量可预测、团队规模小就选预留分片数+取模;时序类、归档类选纯区间分片;不到万不得已不为了“灵活性”硬上一致性哈希,大多数场景下它带来的复杂度超过收益。
4. 中间件选型:集成型与代理型的取舍
4.1 ShardingSphere-JDBC:侵入式改造的省心与痛点
分片策略定好之后,要选一个执行层。目前Java生态里最主流的是ShardingSphere-JDBC,它的工作模式是嵌在应用进程内,通过重写SQL和数据源路由来实现分片,应用接入时通常只需要加一个starter、写一份配置。
它的优势很直接:不走额外网络,性能损耗极小;配置分片规则后开发模式跟操作普通JDBC基本一致;对Spring Boot工程非常友好。我接触过的中小团队多数是从它起步的,踩坑记录也最丰富。
需要注意的坑有几个。第一个是SQL兼容性,复杂子查询、多表关联、自定义函数等写法偶尔会解析失败,开发时要习惯把SQL写法往标准SQL上靠。第二个是连接数放大,每个应用实例都会对每个分片建立连接池,假设你有16个分片、应用部署10个节点,每个分片要扛的连接数就是原来的10倍,数据库连接数上限要提前调。第三个是事务边界,@Transactional一旦跨了分片,ShardingSphere-JDBC默认会退化成本地事务,只能保证单分片内原子性,跨分片强一致必须引入Seata这类分布式事务框架,而且性能代价很大。
4.2 代理层方案与老系统改造
如果你的系统里有多个语言栈,或者老系统完全不想改代码,另一个选择是ShardingSphere-Proxy。它部署为一个独立的代理服务,对外暴露数据库协议,应用层只需要把数据源指向Proxy,分片规则在代理层配置,跟普通数据库连接没有区别。
代理层的最大好处是“对应用透明”,多语言、老系统改造的改造成本极低;但坏处也明显——所有SQL都要多一跳网络,延迟和吞吐都有损耗,代理本身成了新的性能和可用性瓶颈点。我在实际项目里观察到的普遍做法是:新项目、Java技术栈统一,用JDBC集成型;存量老系统、多语言混杂、不想碰应用代码,才选代理型。两者也可以组合部署,官方支持同构混合。
MySQL生态里还有一类基于代理的历史方案,比如MyCat及其后继版本。早期它很流行,但我自己用过之后的感受是:复杂SQL支持、分布式事务、运维文档这些方面,和ShardingSphere生态相比差距比较明显,现在新项目里已经很少推荐了。
4.3 选型对照表
我把几种主流方案放在一起对比,方便直接选:
| 方案 | 改造方式 | 延迟 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| ShardingSphere-JDBC | 应用内集成,改代码+配置 | 最低 | 中 | Java新项目、已有Spring Boot体系 |
| ShardingSphere-Proxy | 独立代理层,应用透明 | 中(多一跳) | 高 | 多语言、老系统统一接入 |
| 原生分布式数据库 | 替换存储引擎,业务无分片概念 | 低~中 | 高(部署运维成本大) | 数据量极大、不想维护分片逻辑 |
这里多说一句,如果你的数据量已经大到需要几十上百个分片,团队又不想长期投入维护分片逻辑,那就应该认真评估TiDB、OceanBase这类原生分布式数据库。它们把分片、调度、扩缩容都收进了存储层,应用写起来跟单库差不多,代价是硬件投入和运维体系要重新建立。分库分表中间件是“自己管分片”,分布式数据库是“存储替你管分片”,两者不是替代关系,是不同阶段的取舍。
5. 补齐拆分后的三块短板:主键、跨分片、扩容
5.1 全局唯一ID的兜底设计
分库分表之后,数据库自增ID立刻失效,因为多个分片各自自增必然产生重复主键。全局唯一ID方案,业界基本收敛到雪花算法(Snowflake)这一条主线上:64位long,由时间戳、机器ID、序列号组成,既唯一又趋势递增,做聚簇索引时Page Cache友好度远高于UUID。
但雪花算法落地时有两个容易忽略的坑。第一个是机器ID分配,每个写节点必须有一个唯一ID,配置错了两个节点就可能生成重复ID;第二个是时钟回拨,系统时间往回跳会直接导致ID冲突,需要做等待回拨或备用ID方案。我见过直接拿mac地址碎片当机器ID的,也有没处理时钟回拨结果线上出现重复主键的,这些属于“看着简单、上线要命”的细节。
如果是订单这类需要基因路由的业务,建议在雪花ID的末尾预留十几个bit来拼接分片键的低位信息,这样订单ID既能全局唯一,又能同时反推出user_id做路由,省掉一次映射查询。设计ID前先想清楚路由需求,这是很多架构师容易漏掉的一步。
5.2 跨分片查询与全局分页的落地手法
分片之后最头疼的问题就是跨分片查询和分页。很多团队上线后才发现:连表join没法用了,order by + limit突然不准了。
原因不复杂。全局分页如果简单地“每个分片limit N、然后内存里再排序”,当偏移量很大时,内存里的数据量会变成 N×分片数,而且需要全局归并后再重新截取,数据量和开销都不可控。比如第100页,每页20条,分片数8个,正确做法需要每个分片取160条再归并,偏移量越大越灾难。
我在项目里长期使用的做法有三种。第一种是游标分页(Keyset Pagination),只记录上一页最后一条的排序键,下一页查询时每个分片都从该键位置往后取,稳定且高效;第二种是避免在分片库上做复杂查询,把列表页、运营后台的查询条件同步到ES或宽表里,用搜索引擎的分页能力,明细再回源;第三种是业务上引导用户用“时间范围 + 用户ID”这类条件来收敛,让查询尽可能落到单个分片。能用业务逻辑规避的问题,就别用技术硬扛。
5.3 扩容时怎么避免全量rehash
扩容是分库分表最恐怖的运维操作,尤其是取模分片。4个分片扩到5个,取模基数变了,所有数据的归属地全部重洗,在线迁移的工程量相当于重做一遍系统。
应对方法我推荐两条路。一条是翻倍扩容:从4个分片扩到8个,取模从%4改成%8,理论上旧数据只有一半需要迁移(精确说是每个旧分片按模2的结果决定是否搬移),配合双写和校验工具,能在可接受的时间窗口内完成。另一条是前面提到的“时间 + 主体ID”混合策略,扩容时直接按时间加新分区,存量不动,这也是我偏爱它的原因之一。
迁移过程的标准动作大概是:给旧分片开binlog、启动全量导出导入、做增量同步追平、校验数据差异、然后切流量。整个过程必须做充分的演练,我看到过太多把迁移脚本写完就直接上线的,最后不是丢数据就是停机时间远超预期。我的经验是,扩容方案要作为系统设计的一部分提前写进文档,而不是到了数据快满的那一天才现想。
6. 一次真实选型的复盘参考
最后用一个我经历过的订单中台项目来串一遍整个决策过程,方便你对照落位。
当时业务背景是:日活跃用户500万左右,日均订单约50万,订单表未来三年预估存量过亿,早晚要拆。业务查询路径非常清晰——C端用户只查自己的订单列表,后台运营需要按状态、时间、渠道检索。我们的决策过程是:
第一步做容量估算,算出三年订单主表加上明细表总量大约在80GB左右,单表索引再优化也接近极限,确认要拆。第二步分析路由维度,C端所有查询都带user_id,所以确定按user_id做分片键;后台复杂查询不落库,直接把订单数据异步同步到ES搭宽表,避免跨分片join。第三步定分片策略,用的是“月份 + user_id取模”的混合策略,物理分片一开始就是16个,一是预留容量,二是后期按月归档时节点数不尴不尬。第四步选中间件,因为团队技术栈就是Java + Spring Boot,直接用了ShardingSphere-JDBC,同一套配置里把读写分离也一起做了。
上线之后还是踩了几个印象很深的坑。第一个是代码里偶发出现不带user_id的订单查询,分片键缺失导致路由失败,SQL直接广播到了所有分片,慢查询立刻飙升。解决措施是在DAO层统一封装查询入口,强制传入分片键。第二个是事务边界问题,某个批量导入方法上挂了@Transactional,内部却要更新多个分片的数据,最终要么锁范围过大拖垮线上,要么事务部分生效。后来把所有跨分片写操作全部改成异步消息+本地事务补偿,才彻底稳住。第三个是连接数放大,16个分片 × 8个应用节点 = 128个连接池,单库连接数直接被占满,排查了半天才想起中间件的这个特性。
这段经验给我最大的体会是:分片策略选型这件事,方案的“技术含量”真不是最重要的部分,对业务访问模式的预判才是。先画出系统的核心查询路径,再确定分片键,再选策略和中间件,这个顺序一旦反了,后面每一步都在还债。
最后再分享一个小技巧。做选型评审时,别只看平均QPS,要去翻慢查询日志里那些“偶发但致命”的SQL,它们往往就是不带上分片键、需要跨分片扫描的那一批。把这些SQL挑出来,在方案评审时逐个问一句“这条查询落到哪个分片”,答不上来的场景,就是你的选型方案里最需要补的漏洞。