1. 什么时候该分库分表:先算一笔“容量账”
我见过不少团队把分库分表当成“大厂标配”,数据量刚过百万就急着拆库拆表,结果引入了一大堆复杂度,收益却微乎其微。说实话,这件事什么时候做、做到什么程度,完全取决于你现在和可预期未来到底撞上了哪堵墙。我自己的经验是:分库分表是水平扩展的关键手段,但它不是一道开胃菜,而是一台手术——术前诊断比手术本身更重要。
1.1 单库单表的真实上限在哪儿
很多文章喜欢拿“单表几百万行就开始慢”说事,这个说法其实不够严谨。单表性能瓶颈不只看行数,更看你的访问模式和数据特征。
拿MySQL InnoDB举例,一张表能否扛住,核心看三个指标:
- B+树索引深度:主键索引的每个叶子节点默认16KB,假设一行数据1KB,一个叶子节点大约16行,三层B+树可以支撑约两千万行数据。所以“千万级以下不用慌”是有理论依据的,但前提是查询都能走主键索引。
- 写入吞吐:单库的写入能力受制于磁盘IO、binlog同步、锁竞争。即使数据量不大,只要写入QPS冲到几千,单库也可能先扛不住。
- 连接数:单实例默认最大连接数151,即使调到2000,也扛不住业务量增长带来的并发连接需求。
我接手过一个订单系统,数据量不到800万行,但每天凌晨的批量任务一跑,整个库的CPU直接飙到90%以上。原因就是一批复杂聚合查询全落在单库上,再加上业务高峰期的实时读写,资源互相挤占。所以判断要不要分库分表,看行数只是最粗的一条线,更关键的是评估:单库是否还能同时满足读写延迟、并发吞吐和数据容量三方面的要求。
1.2 四个判断指标,满足两条就该立项
我一般用下面四个指标给业务做体检,建议你也建立这样一套评估框架:
| 指标 | 警戒线 | 说明 |
|---|---|---|
| 数据容量 | 单表超过2000万行,或单库超过2TB | 索引效率下降,备份恢复时间不可接受 |
| 写入QPS | 持续超过2000,或峰值超过5000 | 单机磁盘IO和锁竞争成为瓶颈 |
| 连接数 | 应用连接池接近数据库max_connections的60% | 预留容量不足,突发流量会直接打满 |
| 慢查询比例 | 超过总查询数的1%,或p99延迟超过200ms | 索引优化已无法覆盖全部业务路径 |
满足一到两条,先做读写分离、缓存、归档冷数据这些“低风险改造”;如果满足三条以上,或者单条数据本身就很大导致单表容量激增,分库分表就提上日程。
1.3 别急着拆:先排除缓存、归档和读写分离
一段真实的对话经常是这样的:
- 你说:“这张表已经5000万行了,拆吧。”
- 业务方问:“拆了能解决什么问题?”
- 你说:“查询更快了……大概。”
- 业务方沉默。
如果回答不了“具体解决什么问题”,那就说明问题定义不清楚。很多“单表变大变慢”的场景,先用三个手段就能缓解:
- 冷热归档:把状态为“已完成”且超过90天的订单迁到历史库。这是成本最低、收益最明显的一步,很多业务做完这一步,数据量直接下降70%。
- 缓存兜底:热点读集中在最近订单和未读消息上,用Redis抗住这部分读流量,数据库压力会小很多。
- 读写分离:报表查询、后台管理类查询全部走从库,主库只服务线上交易路径。
这套组合拳打完,如果单库仍然长期处于高水位,再考虑分库分表。分库分表解决的是“容量上限”和“写入水平扩展”问题,它不是查询优化的替代品。
提示:做技术选型前先量化现状,没有监控数据支撑的架构升级都是拍脑袋。至少要有数据库CPU、磁盘IO、慢查询、连接数这四类监控曲线,再说拆不拆。
2. 分片键选不好,后面全是坑
分库分表方案里最重要、也最不可逆的决策就是分片键(Sharding Key)。分布式系统里有一个著名的说法:分片键决定了这个系统能做什么、不能做什么。选对了,大部分查询仍然像单库一样简单;选错了,后面每一个需求都是在给架构还债。
2.1 为什么说分片键是生死决策
分片键决定了数据如何被路由到不同的物理库表。如果业务查询条件里带上了分片键,路由层可以精确计算出目标分片,一次查询只打一个库,效率和单库一样高;如果查询条件里没有分片键,路由层只能把请求广播到所有分片,再对结果做聚合,这种“全分片扫描”的代价会随着分片数量线性增长。
我见过一个典型的反例:某团队把订单表按“订单状态”分表——已支付一张表、未支付一张表、已退款一张表。听起来很符合业务直觉,但订单状态是高频变化的字段,每笔订单从创建到完成要跨好几张表,事务没法在一个分片内完成,还得引入分布式事务。更麻烦的是,按状态分表的数据分布极不均匀,未支付订单可能只有几千条,历史已完成订单却有上亿条。这不是水平扩展,这是给自己做了一个无法运维的“垂直切碎”。
2.2 候选分片键的取舍:用户ID、订单ID还是时间
在大多数业务里,真正值得考虑的候选分片键其实只有两三个。以电商订单系统为例:
- 用户ID分片:非常适合“用户视角”的业务。用户查自己的订单列表,天然带上用户ID,路由精准,体验最好。而且用户维度天然隔离,单个用户的数据量不会超过单分片承载能力。
- 订单ID分片:适合后台运营、客服查询场景。客服需要按订单号查详情,订单ID作为分片键可以让这类查询精准路由。但用户查询自己的订单列表时,没有订单ID,只能广播。常见的折中方案是:以订单ID分片,同时维护一份“用户ID到订单ID”的映射关系,或者生成订单ID时把用户ID编码进去(如雪花ID的bit位设计)。
- 时间分片:适合日志、流水、事件类数据,按天/按月分表。优点是归档方便,过期数据直接DROP表;缺点是热点永远落在最新分片上,写入压力没有分散,本质上只是解决了“容量无限增长”问题,没有解决“热点写入”问题。
2.3 我的选择原则:谁最核心,就优先服务谁
没有完美的分片键,只有最适合业务核心路径的权衡。我给自己定过三条原则:
第一,分片键必须能覆盖业务最高频、最核心的查询路径。电商里最高频的是“用户查自己的订单”,那就优先保证用户维度查询是精准路由。
第二,分片键必须具备稳定的“终态”属性。也就是说,这个字段一旦确定基本不会改变。用户ID天然终态;手机号可能换绑,不适合;订单状态会变,绝对不行。
第三,分片键的值域必须足够离散。枚举值很少的字段(如性别、类型、渠道)都会造成数据倾斜,切分后某些分片负载远高于其他分片。
如果核心查询有两个以上维度,我的做法是:选一个主分片键,其他的维度靠“索引表”或“冗余表”来支撑。例如订单表按用户ID分片,同时维护一张“订单号到用户ID”的全局映射表。虽然多了一次查询开销,但保证了主路径的简单高效。
3. 容量规划与分片算法:动手之前先算账
分库分表的老手和新手之间,最明显的差别就是:新手上来就定“分10个库100张表”,老手会先拿一年的业务数据做推演。分片数的确定不是拍脑袋,它直接影响未来扩容的难度和数据分布均匀度。
3.1 分片数怎么算:一个可复用的推演过程
我用一个实际案例来说明推演过程。假设业务是B端零售系统的订单表:
- 当前每天新增订单量:50万行
- 订单数据保留策略:永久保留
- 两年后预估日均新增:200万行(按年增长100%算)
- 两年后总数据量:200万 × 730 ≈ 14.6亿行
单分片(单库单表)建议上限按2000万行算,那么所需分片总数:
14.6亿 ÷ 2000万 ≈ 73个分片
综合考虑单库承载能力和运维成本,我习惯每个物理库放8~16张分片表。如果每个库放8张表,那么需要约10个物理库。所以最终方案定为:10个库 × 8张表 = 80个分片。
注意,这只是容量维度的下限。还要再算一下写入QPS:
- 峰值写入:按日均200万的5倍峰值系数算,大约每秒11500行写入。
- 单分片承受峰值写入:11500 ÷ 80 ≈ 144 QPS。
这个量级对单库来说非常轻松,所以瓶颈在容量而非并发。如果峰值QPS超过单库承受力,就需要增加分片数来分摊写入压力。总之,分片数 = max(按容量计算所需分片数, 按并发计算所需分片数),还要留出30%~50%的buffer。
3.2 三种主流分片算法:Hash取模、一致性哈希、Range
算法选型直接决定数据分布和后续扩容方式,三种方案各有适应场景。
Hash取模是最常见的方案:分片号 = hash(分片键) % 分片总数。优点是实现简单、数据分布均匀;缺点是一旦分片总数变化,几乎所有数据的映射关系都会变化,扩容时要做全量数据迁移。
一致性哈希把分片键映射到一个环形哈希空间,每个物理分片负责环上的一段区间。扩容时只影响相邻分片的数据,迁移量大幅减少。缺点是实现复杂度高,而且需要处理节点不均匀问题(一般引入虚拟节点)。
Range分片按分片键的连续区间划分,比如用户ID 1~1000万在分片1,1000万~2000万在分片2。它的优势是范围查询友好,扩容可以按区间分段追加;风险是数据可能倾斜,某些热门区间负载过高。
我的建议很直接:绝大多数业务场景,用最简单的Hash取模就够了,配合“2倍扩容”策略来规避迁移痛点。一致性哈希适合分片节点频繁变动的场景,但大部分业务一年也扩容不了几次,引入一致性哈希的复杂度远大于它节省的迁移成本。Range分片则适合时序类数据或明确能按业务维度切分的场景。
3.3 一个容易被忽略的设计:提前预留分片位数
一个让后面少吃苦的决定是:把分片总数设计为2的N次方,并且取模时用位运算替代取模运算。比如分片数定为64,路由时直接shard = hash(userId) & 63。这不是纯粹为了性能,而是为了扩容时能“只迁移一半数据”而不是“全量迁移”。
具体的做法是:初始只用64个分片里的前32个(或者前64个,但未来扩容到128个)。当需要从64扩容到128时,数据迁移可以按位运算的规律只搬一半:根据hash值的第7位是0还是1,决定数据留在原分片还是迁移到新分片。这个技巧在ShardingSphere等中间件里已经有现成实现,但你自己理解原理后,才能合理评估它的迁移方案是否可靠。
提示:任何分片方案都要写清楚“扩容SOP”,并且在大促前至少演练一次。我见过太多系统在扩容演练时才发现中间件版本不支持在线迁移,只能停机扩容。
4. 路由层选型:中介软件与自研Proxy的取舍
分库分表的落地形态,核心是路由层怎么做。目前行业里有三类主流方案:客户端式中间件(如ShardingSphere-JDBC)、代理式中间件(如MyCAT、ShardingSphere-Proxy、Atlas)、以及自研Proxy。选型要考虑团队维护能力、业务复杂度和响应要求。
4.1 三类方案的定位和取舍
- 客户端式(ShardingSphere-JDBC):以jar包形式嵌入应用,应用直连数据库。优点是性能损耗极小,功能丰富,支持分布式事务、读写分离、数据加密等。缺点是语言绑定Java,多语言团队适配成本高。
- 代理式(ShardingSphere-Proxy / MyCAT):独立部署的代理服务,应用通过数据库协议连代理,代理再转发到后端分片。优点是语言无关,业务侵入性小,适合多语言团队。缺点是多了一层网络开销,延迟增加;代理节点本身也需要高可用部署。
- 自研Proxy:可控性最高,但开发量极大,需要处理协议解析、连接管理、分布式事务、高可用等一整套问题,只建议大厂或有充足中间件人力的团队考虑。
以我们当时的情况为例:后端是统一的Java技术栈,团队对ShardingSphere比较熟,流量还没到需要独立代理层支撑的水平,所以选了ShardingSphere-JDBC。它直接嵌入业务服务,路由和SQL改写都在应用内完成,性能几乎无损。后续如果某个独立业务线想接非Java应用,再单独部署一个ShardingSphere-Proxy接入同一个注册中心,两种模式可以混用。
4.2 ShardingSphere-JDBC核心配置解读
ShardingSphere-JDBC的配置逻辑核心就一句话:告诉它“哪个逻辑表对应哪些物理表,用哪个分片键、哪个算法路由”。下面是一个典型订单表配置示例(YAML格式):
rules: - !SHARDING tables: t_order: actualDataNodes: ds${0..9}.t_order_${0..7} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_table_hash_mod keyGenerateStrategy: column: order_id keyGeneratorName: snowflake defaultDatabaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_db_hash_mod shardingAlgorithms: order_db_hash_mod: type: HASH_MOD props: sharding-count: 10 order_table_hash_mod: type: HASH_MOD props: sharding-count: 8这段配置里有几个容易忽略的点:
actualDataNodes里库和表的数量必须和分片算法里的sharding-count完全匹配,否则路由会落空。- 库策略和表策略用的是同一个分片键
user_id,保证同一个用户的所有订单落在同一个库、同一张表里——这样按用户维度的查询连跨分片聚合都省了。 - 分片键和主键生成策略可以分开:分片用
user_id,主键用雪花算法生成order_id,两者互不干扰。
4.3 自研Proxy的风险评估
如果你的团队确实考虑自研Proxy,我有一个很现实的提醒:不要低估“兼容全部SQL”的难度。业务侧的SQL千奇百怪,JOIN、子查询、GROUP BY、ORDER BY、分页、聚合函数组合起来,SQL改写规则极其复杂。自研Proxy很容易陷入“支持了90%的SQL,剩下10%的SQL要靠开发改代码”的窘境。
我的建议是:除非现有方案完全无法满足业务需求(比如需要跨语言、需要强一致事务、需要定制路由协议),否则优先选择成熟中间件。自研Proxy的时间成本在人力密集的大厂或许可以接受,但对大多数团队来说,投入产出比太低,还会成为长期维护负担。
5. 存量迁移与双写,线上发布的全流程
方案设计再好,最后都要落到“怎么从单库平滑切换到分库分表”。这个过程的难点不在于“写代码”,而在于“切流量”的时机和风险控制。我推荐一套经过多次验证的流程:全量同步 + 增量回放 + 双写校验 + 灰度切换。
5.1 第一步:全量同步和增量Binlog回放
迁移前先把老库的表结构转换成分表后的结构。比如老订单表t_order要变成t_order_0到t_order_7共8张表,每张表除了原有字段外,需要保证user_id是分片键并且建立好索引。
全量同步阶段,我用的是自研的迁移工具加Binlog订阅的组合:
- 在源库开启Binlog,记录当前位点。
- 用DataX或自研导出工具把存量数据按分片规则写入目标分表。
- 启动Binlog消费任务,从记录的位点开始回放增量数据到目标分表。
- 并行启动数据校验任务,定时对比源库和目标库的行数、关键字段checksum。
这个阶段的核心是:全量先跑,增量持续追,校验不放过。如果源库数据量太大,全量同步会跑很久,增量回放会持续追位点,Binlog消费任务要保证它在全量完成前不追丢。
5.2 第二步:双写切换
增量回放追平后,进入双写阶段。双写的意思是:业务写入时,同时写老库和新库,老库作为生产主库,新库作为影子库。
ShardingSphere下双写一般借助ShardingSphereDataSource的dual数据源功能,或者自己在业务代码里封装一层“写老库+写分片库”的DAO。我偏向前者,因为它对业务代码透明。配置上需要注意:双写不能影响主链路延迟,所以对影子库的写入要走异步队列,或者直接使用带超时控制的附加数据源,避免影子库抖动拖垮主流程。
双写持续期间要反复做数据一致性校验。我一般每天跑一次全量校验,同时抽查最近一小时变更数据的实时一致性。只有校验连续3天通过,才敢进入下一步。
5.3 第三步:灰度切换与回滚预案
灰度切换的关键是“能随时切回来”。顺序建议如下:
- 读流量灰度:把5%的读流量切到新库,对比延迟和错误率,同时校验数据准确性。
- 写流量灰度:把1%的写流量切到新库(通过开关或按用户ID白名单)。此时新库和旧库的角色对调:新库成为“影子主库”,写入同时异步回放到老库。
- 全量切换:确认稳定后,把所有读写流量切到新库,老库转为只读备份。
回滚预案一定要提前准备好。我的做法是:在整个灰度期间,保留从新库到老库的增量回放任务,也就是“反向双写”。一旦新库出现严重问题,立即把读写流量切回老库,不会丢数据。
提示:灰度期间不要只监控接口错误率,还要监控数据延迟、分片数据倾斜、慢查询等指标。很多问题不是“切了就崩”,而是“切了一天后越来越慢”。
6. 跨分片查询、分布式事务与全局主键:四个躲不掉的痛点
分库分表真正让人难受的地方,不是迁移时的体力活,而是上线后每天都在面对的那些“单库时代根本不算事”的问题。
6.1 跨分片Join与聚合:别硬撑,改造查询
分片后,一条SQL如果涉及多个分片,ShardingSphere会把SQL下发到每个分片执行,再把结果汇聚。这个过程对应用透明,但有两个代价:一是性能损失,二是有SQL兼容限制。
实际业务里,跨分片Join查询我见过四种解法,按推荐程度排序:
- 数据冗余:在业务写入时就把需要Join的字段冗余到主表中,查询时单表搞定。比如订单表冗余了商品名称和商品快照价格,查订单列表就不用再去Join商品表。
- 全局表:数据量小、更新不频繁的表(如地区表、字典表)在每个分片都放一份全量副本,Join只在分片内发生。
- 搜索引擎/OLAP引擎:复杂的多条件检索、聚合统计,把数据同步到ES或ClickHouse,查询走搜索引擎,业务库只承担OLTP。
- 应用层组装:分两次查询,在代码里做内存组装。适合数据量小的关联场景,但要注意避免N+1查询。
6.2 全局主键:雪花ID的生成细节和坑
分库分表后,数据库自增主键不能用了。主键生成必须全局唯一且尽量有序,目前最常用的就是雪花算法(Snowflake)。
雪花ID的本质是一个64位整数:1位符号位 + 41位毫秒时间戳 + 10位机器ID + 12位序列号,一毫秒内单机可以生成4096个ID。几个容易踩的坑:
- 时钟回拨:服务器NTP时间回拨后,可能生成重复ID。保险做法是保留“上次生成时间戳”,发现当前时间小于上次时间就拒绝生成或等待时钟追上。
- 机器ID分配:如果机器ID没有全局唯一管理,两台机器可能生成相同ID。建议用配置中心或注册中心统一分配机器ID。
- 分片友好性:订单ID里可以编码分片信息,例如把用户ID的一部分放进订单ID的末几位,后端的“订单ID查详情”就能从ID里直接解析出分片键,省掉一次映射表查询。
6.3 分布式事务:最终一致性比强一致更现实
分库后,一个业务操作可能跨多个分片更新数据。最典型的是“创建订单 + 扣减库存 + 增加积分”,各写各的分片。
业界对分布式事务有几种方案,别一上来就TCC,成本和收益要算清楚:
- 事务消息(本地消息表/RocketMQ事务消息):把核心业务操作和“发消息”放在同一个本地事务里,消息消费者再去执行其他分片的更新。缺点是最终一致,存在短暂不一致窗口。适合大多数交易场景。
- TCC(Try-Confirm-Cancel):每个参与方都要实现Try、Confirm、Cancel三个接口,开发量大,适合对一致性要求极高且并发量可控的场景。
- Seata AT模式:通过undo_log实现自动回滚,业务侵入比TCC小很多,但性能开销和运维复杂度都需要评估。
我的建议很直接:优先把业务设计成“单分片内完成”,把需要跨分片更新的动作通过消息异步化。与其追求一套漂亮的分布式事务框架,不如从业务划分上减少跨分片事务。
6.4 深度分页:LIMIT的大坑
分库分表后,ORDER BY create_time LIMIT 100000, 20这样的SQL会被改写成每个分片都执行LIMIT 100000, 20,然后内存汇总,再取第100020条。随着页码增大,性能急剧恶化,因为每个分片都要捞出前10万条数据来做排序。
解决深度分页的方案有三个:
- 游标分页(推荐):把“页码”改成“上次最后一条记录的排序值”,查询条件变成
WHERE create_time < ? ORDER BY create_time DESC LIMIT 20。这个方案性能稳定,且不受分片数影响。 - 禁止深翻页:产品层面限制最多翻到100页。
- 加辅助列:如果业务必须支持翻页,可以在各分片维护一张“排序值到ID的索引表”,减少排序数据量。
这三种方案里,游标分页是唯一能长期抗住并发压力的。如果产品经理非要“跳转第500页”,把搜索需求丢给ES就对了。
7. 复盘与避坑清单:那些文档里没写的细节
分库分表的方案设计和容量规划,前面几部分已经把主线讲清楚了。这一节我专门整理一份踩坑清单,都是真实项目中卡过壳的细节。
7.1 五个回看时才明白的教训
教训一:路由字段的隐式类型转换会让分片失效。有一次排查线上慢查询,发现某个分片上的数据量明显比其他分片大。最后定位到原因是:分片键在数据库里的类型是varchar,但应用传入的是Long型。ShardingSphere在做路由计算时拿到的hash值不一致,导致一部分数据路由到分片,查询时却算到了另一个分片,结果被迫广播。后来统一了分片键的传入类型,并加了一组监控报警来发现分片不均衡。
教训二:分片键允许更新,看起来很美好,实际上是大坑。有一版方案支持用户更换分片键(比如手机号分片改ID分片),结果每次更新都要处理跨分片数据搬迁,还会在搬迁过程中出现短暂查不到数据的情况。后来的设计方案强制分片键不可更新,如果有修改需求,通过“新增记录 + 废弃旧记录”的方式处理。
教训三:中间件版本升级要单独走压测。从ShardingSphere 4.x升级到5.x,很多配置项和SQL改写逻辑都变了。我们就是因为升级后没有做全量回归,导致某些带GROUP BY的复杂查询结果集计算错误。升级中间件版本,必须准备一套完整的SQL回归用例集。
教训四:分片表的DDL变更要在所有分片上同时执行,否则路由报错。这听起来是常识,但自动化运维平台没覆盖到位时,少执行一个分片的ALTER TABLE,线上查询就可能随机失败。建议把DDL变更纳入统一的自动化平台,遍历所有分片执行并校验结果。
教训五:连接池大小要重新计算,不能沿用单库配置。分片后,一个事务如果涉及多个分片,应用会同时持有多个分片连接。如果连接池最大连接数仍然是原来的值,高并发下很容易把数据库连接打满。需要根据“单事务同时打开的连接数 × 并发事务数”重新推导连接池配置。
7.2 上线前后我建议你准备的一份自检清单
每次做分库分表项目,我都会在方案评审和上线前对照下面这份清单检查一遍:
- [ ] 分片键是否覆盖了核心查询路径?是否保证最高频查询能精准路由?
- [ ] 分片算法是否有明确的扩容SOP?是否已经在测试环境演练过?
- [ ] 主键生成方案是否全局唯一?时钟回拨是否有兜底策略?
- [ ] 所有跨分片查询是否都有替代方案(冗余、全局表、搜索引掊)?
- [ ] 分布式事务方案是否符合业务一致性要求?是否有降级预案?
- [ ] 深度分页是否已经改造为游标分页?
- [ ] 数据迁移工具是否支持断点续跑?校验任务是否自动告警?
- [ ] 灰度切换的开关和回滚脚本是否经过演练?
- [ ] 连接池大小是否按分片数重新核算?
- [ ] 监控看板是否覆盖分片数据倾斜、各分片延迟和慢查询?
这套清单看着琐碎,但每一条背后都有真实的事故支撑。分库分表这种改动,一旦上线运行,回头的成本非常高昂,宁可多花一周准备,也不要上线后日夜救火。
最后再分享一个个人体会:分库分表做得好的团队,通常不是算法最先进的团队,而是“敬重数据”的团队——每一步都算过账、每一条SQL都验证过、每一次切换都有回滚。这套严谨的工作方式,比分库分表本身更能决定系统的长期稳定性。希望这篇来自实战的拆解,能让你在规划自己的水平扩展方案时少走几步弯路。