分库分表
什么时候真的需要拆分、拆分的三条坐标轴、分片键与分布式 ID 设计、跨片查询与事务的解法、工具选型与在线迁移剧本,以及 MySQL 原生分区表的能力边界。
一、先泼冷水:单表多大才需要分库分表
先给一个被引用最多的经验值:单表千万级(1000 万 ~ 2000 万行)就该开始警惕。这个数字不是拍脑袋,它是 B+ 树算术的结果——04-索引原理与实战 里算过:InnoDB 页 16KB,非叶节点一条"键 + 页指针"大约 14 字节,扇出约一千出头;叶节点按平均行大小放几十到上百行。三层树能装两千万行左右,再往上就长出第四层——每次主键查询从 3 次页访问变成 4 次,而且多出来的这一层大概率不在缓冲池里,变成一次真实的磁盘随机 IO。第二个信号是缓冲池装不下热数据:当热点索引页频繁被换出,QPS 稍一抬升 RT 就出现长尾,这时候加缓存、换大内存机器之前,问题已经被树高放大了。
但经验值只是报警器,不是发令枪。真实的决策看两件事:SQL 形态——如果只是"数据多了但查询都走索引、都能秒回",你其实不缺分片;缺的是容量。反之,一条 LIKE '%xx%' 全表扫描的慢 SQL,拆成 64 片只是把灾难切成 64 份。并发与写入模型——单实例连接数打满、主库写 IOPS 到顶,这类问题拆库才能解决。
所以,在决定分库分表之前,请先把下面这份清单逐项打勾,它就是本章存在的意义——证明你还不需要读后面的内容:
- 索引与 SQL 优化做尽了吗。慢查询先走 08-EXPLAIN与查询优化 的路:该加的索引加了没有、该改写的 range/覆盖索引改了没有。大量"该分表了"的呼声,最后被一条 EXPLAIN 打消。
- 缓存层加了吗。读多一放 Redis(本库 redis 手册),把 QPS 挡在 MySQL 之前。分片键想不清楚时,缓存往往先续命两年。
- 读写分离做了吗。读压力独大时,10-主从复制与高可用 加从库的边际成本远低于拆库。
- 冷数据归档了吗。订单表里三年前的数据占了七成体积却无人问津——按时间归档到历史库/对象存储,热表往往直接回到千万以内。
- 规格还能加吗。NVMe 盘、更大内存、更多连接——垂直扩容便宜且无侵入,先用满。
- 原生分区表试过吗。如果只是"按时间清理旧数据"和"单分区索引变小"的需求,先看本章第九节——它是单机维护工具,够用时不必上分布式。
- 检索问题外包了吗。多条件模糊搜索、聚合报表的锅不该由 MySQL 背,异构到 ES/数仓(见第三节)解决的是"查询形态"问题,不是"容量"问题。
注意:七件事全部无效才轮到分库分表。因为它的本质是把数据库问题升级为分布式系统问题:跨片 JOIN、全局排序、分布式事务、扩容迁移、对账补偿——每一个都是长期税。行业里大量"拆分事故"复盘,根因都是"其实加索引和缓存就够了"。
二、三条拆分坐标轴:垂直拆库、垂直拆表、水平拆分
“分库分表"是四个字的统称,实际有三个正交的方向,解决三类不同的病:
垂直拆库(按业务域切):把订单库、用户库、商品库、营销库拆成独立实例。它解决的是单点容量与故障耦合——一个慢 SQL 拖垮整库连接数的经典事故,拆库后只伤自己域;同时它天然就是微服务边界(一个服务一个库),也是团队边界。代价:跨域 JOIN 消失(只能冗余字段或接口组装)、跨域事务消失(只能最终一致,见第六节)。
垂直拆表(按字段冷热切):把一张宽表里的 TEXT/BLOB 大字段(商品详情、富文本备注)剥到附属表,主表变瘦——同样 16KB 的页能装下更多主表行,树高和缓冲池效率直接受益。同理,把"极少查询的扩展属性"剥出去。它解决的是行太宽导致的 IO 放大,不解决行数。
水平拆分(按行分片):同构的表按分片键切成 N 份,t_order_0000 … t_order_0063。它解决的是行数与写并发,也是唯一真正"分布式"的一种,本章余下内容都围绕它。
三者的顺序也值得讲究:先垂直(便宜、语义清晰)、再水平(昂贵、牵一发动全身)。
三、分片键设计:本章的灵魂
水平拆分 80% 的成败在分片键(Sharding Key)选得对不对。铁律只有一条:绝大多数查询的条件里都带着它。分片键选错的系统,每条 SQL 都要扇出全部分片再归并,等于买了 64 台机器买了一堆广播。
以 user_id 哈希为例
电商订单场景,“我的订单"列表页占全站查询的九成以上,每条都带 user_id——于是按 user_id 哈希分片,同一用户的所有订单物理上落在同一个分片:
-- 逻辑表 t_order,分片键 user_id
-- 查询"我的订单":路由到唯一分片
SELECT id, amount, status, created_at
FROM t_order
WHERE user_id = 123456 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
-- 应用层实际下发(假设哈希后落在 0037 片):
-- SELECT ... FROM ds2.t_order_0037 WHERE user_id = ? ...卖家维度、时间维度的查询呢?带不上分片键,就"全片扇出”。少量低频查询可以接受扇出,高频的那一维必须走异构索引(见下文)——这不是 MySQL 能自己解决的事。
反例:按创建时间分片
“按月分表"看起来直观,实际是最常见的错误决策。写请求 100% 落在"当前月"这一个分片上——分片没有分摊写入,热点依旧热点,只是把单表瓶颈挪成了单分片瓶颈;而查询侧"近 7 天订单"还要跨两三张表归并。时间适合做归档维度(冷热分离、DROP 旧数据),不适合做分片维度。
注意:按时间分片还有一个隐性炸弹:所有分片键取"自然时间"的系统,都活在一个定时爆炸的悬崖上——某个整数位宽或字符串长度不够用时(如按天编号的第 999 天、36 进制编号溢出),往往在生产环境当场演示。预留位宽时按十年流量估算。
基因法:让订单号也能路由
按 user_id 分片后,“按订单号查单笔”(客服、支付回调场景,极高频)就麻烦了:订单号里没有 user_id,只能全片扫描。基因法的思路是把分片信息编码进发号:生成订单号时,取 user_id 的低若干位(即它在分片表里的下标)塞进订单号低位,之后按订单号查询先"解码"再路由:
-- 假设长期分片规划 1024 张表,需要 10 bit 存分片值
-- 发号时(应用层):order_id = (snowflake_id << 10) | (user_id & 0x3FF)
-- 路由时(应用层):table_index = order_id & 0x3FF
SELECT * FROM t_order WHERE order_id = 8723610238472381721;
-- 解码 table_index = 8723610238472381721 & 0x3FF 后直达该片位分配示例:64 bit 订单号 = 53 bit 雪花主体(时间戳+机器+序列)+ 10 bit 基因(user_id % 1024)+ 1 bit 校验/预留。优点是订单号自带路由、天然按发号时间递增(对 InnoDB 二级索引友好);缺点也很实在——基因容量把分片数锁死在 1024 以内(扩容到 2048 时旧订单号解出的下标对不上新布局,要靠"逻辑分片→物理分片"映射层兜住,设计时就要留这层);订单号规律外露(见第五节的对外脱敏);同一用户订单仍集中在同片,热点从"查询"转移到了"超大 V 用户”,需要单独白名单处理。
多维度查询困境与异构索引
订单永远有多个查询维度:买家、卖家、手机号、商家后台的多条件筛选、运营报表。分片键只能保一维,其余维度用一条binlog 异步链路换来(链路基础见 10-主从复制与高可用 与 07-日志体系):
分片 MySQL --binlog--> Canal --> MQ --> 消费者 --> Elasticsearch / HBase所有"带不上分片键"的查询统一打到 ES:多条件、聚合、模糊、翻页都从容。代价是秒级到分钟级的异步延迟和双份数据的存储,以及必须直面的一致性问题:
注意:binlog 链路不保证不丢不乱(Canal 假死、MQ 重投、下游写 ES 失败),所以异构索引之上必须配对账 + T+1 补偿:每日全量扫分片(走从库,按主键分块)比对 ES 文档,缺的补、错的纠;核心交易字段(金额、状态)另设实时校验口径。没有对账的异构索引,等于一颗不知道倒计时的定时炸弹。
一句话:分片键管"写与主查询”,异构索引管"其余一切"。这两件事都设计完,才算完成水平拆分方案。
四、路由算法与分片数规划
分片键决定"带什么条件查",路由算法决定"数据放哪"。两类:
哈希取模 hash(key) % N:分布均匀、无写热点,是 OLTP 主流。代价是范围查询必然扇出。生产上更常用一致性分片的变体:先对分片数取模得表号、表号对库数取模得库号,保证"同一用户的所有行落在同一库内"——第六节的本地消息表、跨表事务都依赖这一点。
范围分片 range:user_id ∈ [0,100万) 落片 0,区间递增。优点是范围查询只扫相邻片、ID 段可预测;缺点是天然写入倾斜(新号段全在最后一片)、分片间行数不可控(大 V 段爆炸)。实际很少纯用,多用于归档与编号治理。
为什么分片数选 2 的幂:hash & (N-1) 代替取模只是小事,关键是倍增扩容的迁移面最小。64 片扩到 128 片时,行 hash%64 == i 的数据在新布局下只会落到 i 或 i+64 两片——一半原地不动、一半按确定规则搬走,可以逐片、逐段灰度搬迁,随时可回退。若从 60 片扩到 90 片,几乎所有行都重排,等于全量迁移加全量停写窗口。
预分片(库表两级定位):拆分时表数按 5~10 年容量直接定死,库数慢慢加。典型如 4 库 × 每库 16 表 = 64 片:
库号 = hash(user_id) % 64 / 16 表号 = hash(user_id) % 64将来加到 8 库 128 片时,是"整表搬迁 + 路由翻倍",行级哈希的表号语义不变(老数据 %64 与未来 %128 通过映射层衔接),永远不要让"行级重新哈希"成为扩容动作。
扇出成本要会在白板上估算:一条不带分片键的查询 = N 条分片 SQL 并行,端到端 RT 约等于最慢那片加归并开销;如果"无分片键查询"的流量占比超过两成,或某个高频接口开始依赖扇出,就是分片键/异构索引出现了缺口——回到需求评审去修,而不是让中间件默默广播。
注意:单片行数控制在 500 万 ~ 1000 万以内(第一节的树高算术除以分片数即得分片上限),并且分片数按"五年后的峰值"定,宁大勿小——片多了只是管理成本,片少了扩容要动全网数据。
五、分布式 ID:自增失效之后
单表时代 AUTO_INCREMENT 免费给你有序 ID;水平拆分后它直接不可用——各分片各自从 1 开始,同一张逻辑表里 id=1001 出现在 64 个分片里,主键失去全局唯一性;AUTO_INCREMENT 的跨分片推进还依赖中心分配器,等于回到单点。全局 ID 必须满足:全局唯一、趋势递增(对 B+ 树写入友好,见 04-索引原理与实战)、高并发低延迟、不编造业务信息。四条主流方案:
号段模式(Leaf-segment 思路)
从数据库批量取一段 ID(步长 step),内存里慢慢发,用完再取下一段。单靠这个仍嫌 DB 压力大,工程版做双 buffer 预加载:
CREATE TABLE id_alloc (
biz VARCHAR(64) PRIMARY KEY,
max_id BIGINT NOT NULL DEFAULT 0,
step INT NOT NULL DEFAULT 1000
);
-- 每次取号段,在事务内:
UPDATE id_alloc SET max_id = max_id + step WHERE biz = 'order';
SELECT max_id FROM id_alloc WHERE biz = 'order'; -- 拿到 (max_id-step, max_id]// 当前号段消耗到 90% 时,异步线程提前加载下一号段
// 业务取号只走内存自增,DB QPS = 业务 QPS / step
class SegmentIdGen {
volatile Segment current, next; // 双 buffer
long nextId() {
if (current.exhausted()) { current = swapOrLoad(next); }
return current.incrementAndGet();
}
}DB 一次取 1000 个号、发完才回来——数据库压力降为千分之一,且短暂 DB 故障时 buffer 里的号还能顶着(容忍停机不掉号)。号段用完的边界会有一次取号毛刺,双 buffer 就是抹平它的。
雪花算法(Snowflake)
64 bit 纯本地生成,无中心依赖:
0 | 41 bit 毫秒时间戳 | 10 bit 机器号 | 12 bit 同毫秒序列号41 bit 毫秒可用约 69 年;10 bit 机器号(可拆成 5 数据中心 + 5 机器)上限 1024 节点;12 bit 单节点每毫秒 4096 个号。趋势递增,自带时间可反解。运维难点在机器号分配:1024 个 worker id 必须全局唯一且回收复用可控——常见做法是 K8s StatefulSet 序号(Pod 名带稳定序号)、etcd/ZooKeeper 临时顺序节点租约,或数据库租约表带心跳过期,任选其一,但要确保重启后不会撞上别的实例。
时钟回拨是雪花算法的命门:NTP 校时、闰秒、虚机迁移都可能让本机时钟倒走,倒走后同一毫秒可能发出重复 ID。三种对策(实际是分级策略):
- 回拨小(几毫秒):等待到追平再发——
while (now < last) park(),请求毛刺几毫秒可接受; - 回拨大(秒级以上):拒绝发号并报警,宁可业务报错也不产重复号,把决定权交给人;
- 扩展位回退:预留若干 bit 做"回拨计数"(每回拨一次扩展位 +1、机器位或时间线进入备用序列,如百度 UidGenerator 用 2 bit 扩展 + 历史时间环缓冲),机器位提前规划则容量减半——能不用尽量不用,属于兜底设计。
注意:容器化环境里"宿主机时钟漂移传给容器"是真实高发病。上雪花前,先确认监控里有
clock_drift指标与回拨报警通路,而不是等 ID 重复了去查。
UUID:明确反模式
uuid() 全局唯一且零协调,但无序。把它做 InnoDB 主键意味着:插入位置随机分布在整棵 B+ 树上,页分裂成为常态、缓冲池预读失效、二级索引叶子带着 16 字节的随机主键集体膨胀(04-索引原理与实战 的"随机写代价"在分片集群里乘以分片数)。36 字符的文本形态还浪费存储与网络。UUID 的正确用途是业务侧无索引场景的关联号(trace id、幂等 token),不是主键。折中的 v7/ULID(时间前缀 + 随机后缀)趋势递增可用,但字节长、位长仍需为基因/索引规划让路。
选型结论
订单/账单等对外单据:雪花或号段 + 基因位规划,对外展示时再做一层脱敏(不暴露体量与规律,自增/雪花裸号会被竞品按天差分推算单量;常见做法是加密/混淆、或前端展示号与存储号分离);纯内部流水(任务号、批次号):号段模式最省心——实现简单、依赖少、天然趋势递增;绝对不要用 UUID 做聚簇主键,不要在分片表上指望 AUTO_INCREMENT。
六、跨片难题逐个给解法
分片之后,单库时代理所当然的四类操作全部要重新设计。原则:能不跨片就不跨片,跨片了就用冗余/异步/异构把问题消解掉,而不是硬扛。
跨片 JOIN
t_order JOIN t_user ON user_id 在拆库后不存在——两张表不在一个实例上。三条出路:
- 冗余字段(首选):下单时把
user_name、merchant_name、商品快照写进订单行。订单本来就是"历史凭证",快照语义与业务天然契合;上游字段变更通过消息异步刷存量冗余。 - 广播表:字典、地区、汇率这类"全量小表"在每个库都放一份,JOIN 在库内完成。ShardingSphere 对广播表的写会同步到所有分片(见第七节)。成本是写放大——一行变更变 N 行变更,只适合低频变更的小表;伪装成字典的"大维表"(商家表)宁可走冗余,不要走广播。
- 应用层组装(三次查询法):先查
t_order得到本页 N 个user_id;按user_id分片键批量点查t_user(N 个 id 会按分片键自动聚成少数几条分片 SQL);内存里拼装。分页场景下 N=20,成本完全可控——这是微服务时代的标准做法,也是"跨片 JOIN 性能其实不差"的原因:IN 点查走主键,扇出条数 = 涉及分片数。
跨片聚合与分页
先记一个经典坑。需求"按金额倒序取第 10001~10020 条",直觉写法:
-- 错误:每个分片各自跳过 10000 条,合并后必然漏数据
SELECT ... FROM t_order ORDER BY amount DESC LIMIT 10000, 20; -- 下发到每一片offset 是分片内的局部量,不是全局量。 正确姿势是每个分片取前 offset + limit 条:
-- 正确:每片取 10020 条,归并排序后丢弃前 10000 条
-- 下发 64 条分片 SQL → 应用层归并(内存峰值 64×10020 行,大 offset 直接不可接受)于是两个约束随之而来:业务上禁深翻(只允许翻到前 N 页,电商列表都这么干);翻页改游标式——WHERE (amount, id) < (上一页末条值) ORDER BY amount DESC, id DESC LIMIT 20,每片取 20 归并 20,代价恒定。真正的聚合(GROUP BY、报表、多条件筛选统计)不属于分片系统,属于 ES/数仓——回到第三节异构索引。
分布式事务
第一原则:用设计避免它。路由规则保证"同一用户"的数据落同一库后,用户维度的下单扣库存之类操作大多可以收敛为单库本地事务;剩下跨库的部分,用最终一致,而不是让 XA 来兜底——XA/两阶段提交要预提交持锁、要协调者、性能与可用性代价在 05-事务与锁 与 07-日志体系 已详述,分片场景下只会更糟(协调者挂 = 全链路挂)。TCC(Try-Confirm-Cancel,资源预留两阶段业务补偿)与 Saga(正向操作序列 + 逆操作补偿)各记一句概念即可,落地成本都不低。
生产上最常用、最值得完整掌握的是本地消息表法——把"跨库写"转成"本地事务 + 可靠消息 + 幂等消费":
-- 与订单同库(路由到同一分片才能同库,这就是分片键设计的前置约束)
CREATE TABLE local_message (
id BIGINT PRIMARY KEY,
biz_type VARCHAR(32) NOT NULL, -- 'order_paid'
biz_key VARCHAR(128) NOT NULL, -- 订单号,消费端幂等键
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 待发送 1 已发送 2 已确认
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
KEY idx_status_created (status, created_at)
);// 1) 业务与消息同事务落库
@Transactional
public void payOrder(Order order) {
orderMapper.insert(order);
messageMapper.insert(Message.of("order_paid", order.getNo(), order.toJson()));
}
// 2) 后台任务扫表投递(只投 status=0,投递后置 1;带退避与最大重试)
// 3) 消费端按 biz_key 幂等消费,成功后回执/标记 status=2
// 4) 兜底:定时对账 + 人工死信处理可靠性来自"消息与业务同一个本地事务"这一招——事务 ACID 保证不会丢发;重复投递由消费端幂等消化(呼应 kafka 手册 08-可靠性与幂等消费 在消息语境的同一课题)。
跨片全局唯一约束
“手机号全局唯一"这类约束,在分片表上 UNIQUE(phone) 形同虚设——唯一键只在分片内生效。注册时怎么防重?软约束 + 异步清理:
- 前置校验走异构索引:Redis SET/BloomFilter 存全量手机号,注册先查(毫秒级);
- 落库后 binlog 链路把手机号写入一个按
phone % 64路由的"全局索引表/服务”(手机号 → user_id 映射),供后续验证; - 异步对账:定时全量扫分片手机号灌入校验集,发现重复立即告警 + 人工介入。
要"硬"唯一只有一条路:把该维度升格为分片键——那等于重写路由模型,一般不可行。接受"最终唯一 + 极小概率双发由对账兜底"是分布式世界的常态,关键是想清楚业务能否容忍(注册可以,支付账户不行——这类资源就该留在不分片的库或用分布式锁 + 唯一索引)。
注意:所有"用异构索引换查询能力/约束能力"的方案,可靠性上限都等于链路的可靠性。Canal 位点落后、MQ 堆积、ES mapping 冲突都会让"你以为的全局"变成"局部的"。上线第一天就把对账当一等公民建设,不要等事故补。
七、工具生态:JDBC、Proxy、云托管怎么选
ShardingSphere-JDBC:以客户端 jar 形式嵌入应用,接管 SQL 路由、改写、归并。优点:零额外部署、无网络多一跳、性能损耗小(本质是应用内一次路由计算);缺点:只服务 Java 生态,规则升级要跟应用发布,非 Java 语言只能旁路。新项目、应用全是 Java,默认选它。
ShardingSphere-Proxy:独立部署的数据库代理,应用把它当 MySQL 连。优点:跨语言、规则集中管理、可对临时工具(DMS/脚本)暴露统一入口;缺点:多一跳网络 + 一套要运维的高可用集群(它自己也得分片部署与扩缩容)。异构语言栈(Go/Python/PHP 混合)、或想把分片能力做成平台服务时选它。
配置的心智(Proxy 与 JDBC 的规则语义一致,示意):
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..3}.t_order_${0..63}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db_mod
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: tbl_mod
keyGenerateStrategy:
column: id
keyGeneratorName: snowflake
broadcastTables: [t_dict_region, t_currency]
bindingTables: [t_order, t_order_item] # 同分片键,JOIN 不展开笛卡尔积两个概念记牢:广播表(每库全量复制的小表,解跨片 JOIN);绑定表(分片键相同、分片规则一致的表组,t_order JOIN t_order_item 只在同序号分片间 JOIN,否则会退化成 64×64 笛卡尔积展开)。
MyCat:上一代开源 Proxy,功能路线被 ShardingSphere 吸收演进,社区活跃度已明显衰落——存量系统继续用没问题,新项目不建议起步。
云托管与 NewSQL:阿里云 PolarDB-X(DRDS 的演进版,水平拆分 + 透明分布式)、腾讯云 TDSQL、以及 TiDB(自动分片、SQL 层近 MySQL 兼容)。“透明扩容"的诱惑很实在——不用自己写路由、不用自己搞迁移。但要冷静做一道成本换算:应用改造与 SQL 兼容性审计(TiDB 的 DDL/事务边界/自增语义与 MySQL 并非 100% 一致,锁模型也不同)、运维模型切换(从 DBA 管 MySQL 变成管一套分布式系统)、以及迁移本身就是一次本章第八节的剧本——无论目标是不是云。同一家云内从单实例 RDS 平滑升到分布式版,比异构搬迁便宜得多;反之"为了未来可能的量"提前上 NewSQL,多数团队交的是学费。
注意:任何中间件都只解决"路由与归并”,不解决第三节、第六节的数据模型问题。中间件配得再漂亮,分片键选错照样全片扇出——先把模型做对,再让工具执行模型。
八、在线迁移剧本:双写到关旧写
拆分方案评审通过后,真正的硬仗是把线上流量从旧单表搬到新分片集群,且业务不能停。标准六步:
第一步:双写。上线写新链路:所有写请求先写旧表(主),成功后同步/异步写新分片。坑:新链路失败绝不能阻断主流程——同步写新失败要落补偿队列(本地消息表,第六节现成的)异步重试;双写期间两库数据有秒级差异窗口,任何"以新库为准"的读都还没开始,所以这个窗口无害。
第二步:存量回灌。把历史数据批量搬进新集群。坑与做法:按主键分块(每块几千行)扫旧表从库,写新库;限速与错峰(回灌的 IOPS 会把从库拖垮、把主从延迟拉到分钟级,呼应 10-主从复制与高可用);写入幂等(按业务主键 INSERT ... ON DUPLICATE KEY UPDATE),允许任何一块失败重跑;回灌期间新写入走双写,用时间戳/水位判断回灌块与增量重叠区,重叠以双写为准。
第三步:全量比对。确认"旧表数据 ≈ 新集群":分块校验(按主键段聚合 CRC/逐列比对),错峰跑,差异自动修复 + 人工复核报表。比对工具没有银弹,自建分块脚本 + 差异落表最常见;核心字段(金额、状态)单独出精度口径。比对不通过,后面所有步骤不许开始。
第四步:灰度切读。读流量按 1% → 5% → 50% → 100% 逐步从旧表切到新集群,每档观察:RT 分布(分片归并的尾延迟)、错误率、业务反馈(用户看到的数据对不对)。影子跑至少一周——覆盖工作日 + 周末 + 一次晚高峰,很多路由/归并 bug(跨月翻页、热点用户、空结果集归并)只有真实流量形态能暴露。
第五步:切写。低峰窗口把写主链路切到新集群。切前核对一件事:新集群发号器的起始水位必须高于旧表 MAX(id)(含所有分片),否则回滚或历史回流会撞主键。为保留回滚能力,切换后开启新→旧反向同步(binlog 回流,双写降级为单向复制即可),这样回滚 = 把写切回旧表 + 停反向同步,数据不丢。反向同步存在的时间 = 回滚安全窗口,一般保留 2~4 周。
第六步:关旧写与下线。安全窗口过后,旧表降级为只读归档,保留一个完整备份周期后下线。
注意:每一步都要有演练过的回滚开关(配置中心一键切,不依赖发版)。迁移事故复盘里最常见的句子是"当时想回滚,但开关没在真实流量上试过"。“先影子跑一周再切"的本质,就是把所有未知问题在"错了也不伤业务"的阶段暴露完。
九、MySQL 原生分区表:单机维护工具,不是扩容方案
第八、六节讲的分布式拆分很重。如果痛点其实只是"表大了不好管”,MySQL 自带的分区表值得单独认识——先明确它的能力边界,别把它当分库分表的平替。8.4 支持 RANGE / LIST / HASH / KEY 及其 COLUMNS 变体,上限 8192 个分区;一张逻辑表、一条 SQL、应用无感知,存储引擎按分区键把行物理切块。按月 RANGE 分区示例:
CREATE TABLE orders (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
amount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (id, created_at) -- 硬伤一:唯一键必须包含分区列
) ENGINE = InnoDB
PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p202608 VALUES LESS THAN (TO_DAYS('2026-09-01')),
PARTITION p202609 VALUES LESS THAN (TO_DAYS('2026-10-01')),
PARTITION p202610 VALUES LESS THAN (TO_DAYS('2026-11-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);分区类型选择的小规则:RANGE / LIST / HASH 的分区表达式必须求值为整数(日期列所以要用 TO_DAYS() / YEAR() 包一层);COLUMNS 变体(RANGE COLUMNS(created_at))与 KEY 分区可直接接受 DATE/字符串列,示例里的 TO_DAYS 写法换成 RANGE COLUMNS 也成立。
它擅长的是生命周期管理:清理一年前的数据,ALTER TABLE orders DROP PARTITION p202410——元数据级操作秒完成,对比 DELETE 五千万行(大事务、undo、碎片、主从延迟风暴,参见 09-Online-DDL与大表变更)是天壤之别;加新月份分区、重挂分区(EXCHANGE PARTITION)也都是轻量操作。每个分区各自一棵 B+ 树,单分区小意味着局部索引更小、树更矮,修剪后查询效率有真实收益。
修剪(partition pruning)要验证,查询条件带上分区键即可只扫目标分区:
EXPLAIN SELECT id, amount FROM orders
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-08'
AND user_id = 42;
-- 输出的 partitions 列显示 p202610(8.0 起普通 EXPLAIN 即含该列);
-- 不带 created_at 的查询,partitions 会是 ALL——全分区扫描硬伤逐条认清:没有全局索引——二级索引是每个分区各自的本地索引,不带分区键的查询要逐个分区走一遍索引进行归并(64 个分区 = 64 次独立索引下降),比不分区的全表更碎;唯一约束必须包含分区列(上文 PRIMARY KEY 那行)——“按 id 全局唯一"做不到,id 唯一性得靠上层方案(第五节)保证;查询必须带分区键才有修剪收益,否则是负优化;分区不改变单机天花板——连接数、缓冲池、写 IOPS、磁盘仍是一台实例,分区是"把一张大象皮裁成块好收拾”,不是"多养几头象";分区上的 DDL 也仍受 MySQL 限制(改列类型等依旧重建,参见 09 章)。
存量表转分区(ALTER TABLE orders PARTITION BY RANGE ...)是整表数据重写的重建操作,大表上并不"在线",要按 09-Online-DDL与大表变更 的窗口规划来做——“给 5000 万行的表补个分区"不是五分钟的事。
结论一句话:分区表解决"单机大表怎么管”,分库分表解决"单机装不下"。只要单机还装得下、连接与写 IOPS 还够,分区表优先——迁移成本近乎零,而分布式拆分没有回头路。
十、实战建议
最后用一套订单系统的决策记录收尾,把本章串成时间线复盘:
2023 Q2,单表 800 万。大促后"我的订单"偶发 800ms。复盘是缺联合索引 + 缓存击穿。加 (user_id, status, created_at) 索引、上 Redis,解决。没有拆——第七件事(检索)之外全部命中清单前五项。2024 Q3,单表 2600 万。树高到第四层,缓冲池命中率下滑,RT 长尾。做了冷热分离:完结超 12 个月的订单归档历史库,热表回到 1100 万;读写分离加了两从。没有拆。2025 Q2,5000 万且日均新增 30 万,写 IOPS 逼近上限,单实例主从结构不敢再扩量——启动水平拆分评审:确认查询形态 92% 带 user_id,分片键定 user_id;路由 4 库 × 每库 16 表共 64 片(单片峰值预计 120 万行,含五年余量,2 的幂便于倍增);订单号用雪花 + 10 bit 基因;卖家维度与运营检索走 Canal → ES 异构 + 每日对账;迁移按第八节剧本,双写 + 回灌 + 比对后灰度,影子跑满两周再切写。这次拆了。 2026 年 64 片均在 200 万以下,再无"装不下"的讨论——而团队共识反而是:如果哪天需要拆到 256 片以上,先停下来重新审视模型(按租户隔离?继续归档降热?),而不是加机器翻倍。
由此提炼几条硬建议:
- 先证伪,再拆分:第节清单七件事逐项排除并留下记录(EXPLAIN 截图、归档行数、扩容配置),拆分的正当性来自"前面的便宜方案都失败了"。
- 分片键是一次性决定:按"最高频查询 + 最同构的数据增长单元"选(用户/租户/账号维度),时间维度永远不当分片键;基因与异构索引是为第二、第三查询维度准备的,设计阶段就要画出来。
- 分片数按十年定,2 的幂,预分片;扩容永远做"整表搬迁/倍增映射",不做行级重哈希。
- 全局 ID 选号段或雪花,UUID 不做主键;时钟回拨对策、worker id 分配、对账补偿,写进上线清单而不是出事再补。
- 跨片能力全部外置:JOIN 靠冗余与广播、分页靠游标与禁深翻、事务靠本地消息表最终一致、全局唯一靠异构校验——接受并建设这些"软约束",比硬造分布式强一致便宜一个数量级。
- 分区表是平替不是备胎:单机装得下就用分区管生命周期,它的收益(DROP PARTITION 秒删)立等可取,风险为零。
- 迁移没有捷径:双写 → 回灌 → 比对 → 灰度读 → 切写 → 反向同步保回滚,每步开关必须演练过。
- 拆分后每月回顾一次"值不值":连接数、单片行数、异构链路延迟、对账差异率四个指标构成拆分红利是否还在的仪表盘;模型问题(热点用户、维度膨胀)出现时,修模型优先于加片数。
分库分表的最高境界不是拆得多快,而是让每一次拆分都晚一点发生、小一点发生。