分片上线那天最轻松:数据还少,SQL 还都能带分片键。真正的账在半年后开始收——后台导出突然扫全表,按时间排序的列表页要归并四个库,一个"查这个用户所有订单"的接口加了 IN 却忘了带分片键。分片不是数据库的事,是应用层的事:路由、聚合、分页、排序、事务、ID 六处全在你的代码里,每一处都补不回来。
这里不算"该不该分",只算分片之后要还的账:哪些改动躲不掉、哪些改法看似省事实则把复杂度转嫁到别处。
1. 三个真实动机与一个伪动机
| 动机 | 触发证据 | 分片能否解决 | 更便宜的手段 |
|---|---|---|---|
| 写入瓶颈 | 主库写 TPS 或 fsync 触顶,从库延迟持续扩大 | 能(写分散到多主) | 异步化、合并写、冷热分离 |
| 单表过大 | 单表行数使索引难以全部常驻,DDL 与备份窗口不可接受 | 能(表变小) | 分区表、归档、历史表 |
| 隔离需求 | 大客户与普通客户争抢同一资源池 | 能(按租户分片) | 独立库、资源组、限流 |
| "数据量大" | 只有总量,说不出哪条链路触顶 | 不能 | 先定位慢查询 |
伪动机的识别很简单:让对方指出具体哪条语句、哪个指标已经触顶。答不出来,问题就在索引或查询形态。用"数据量大"当理由分片,等于用不可逆的改造掩盖一条缺失的复合索引。
2. 分片键:四类后果,选完就定了
| 分片键选法 | 直接后果 | 典型症状 |
|---|---|---|
| 用户 ID | 单用户查询天然单库 | 后台按商家维度查询全跨片 |
| 订单 ID(雪花) | 写入均匀,按用户查需冗余 | 主键快,业务查到处 IN |
| 时间(月/日) | 冷热分离天然 | 最新片成热点,历史片空转 |
| 租户 ID | 隔离清晰,大租户倾斜 | 一个租户压满一个库 |
-- 选键前必须回答:每个候选键下,Top 查询里有几条能收敛到单片
SELECT DIGEST_TEXT, COUNT_STAR,
SUM_ROWS_EXAMINED/COUNT_STAR AS rows_per_call
FROM performance_schema.events_statements_summary_by_digest
ORDER BY COUNT_STAR DESC LIMIT 20; -- 按调用次数排,而不是按耗时排
判据不是"哪个键分布均匀",而是**"按它切,能收敛到单片的查询占多大比例"**——这个比例决定后面要建几张冗余表。且分片键一旦写进数据,改它成本接近重建:选键阶段值得花两周,不是两小时。
3. 应用层要改的六处
| 位置 | 改什么 | 躲得掉吗 | 代价形态 |
|---|---|---|---|
| 路由 | 所有 SQL 必须能从上下文推出分片键 | 否 | 分片键要能一路传到 DAO |
| 聚合 | COUNT/SUM/GROUP BY 要归并 |
否 | 中间件做或自己写归并器 |
| 分页 | 跨片 LIMIT 语义改变 |
否 | 归并排序 + 内存放大 |
| 排序 | 全局 ORDER BY 需要各片都排再合并 |
否 | 索引设计要按分片重做 |
| 事务 | 跨片写不再原子 | 否 | 消息表 / Saga / 补偿 |
| ID | 自增主键在多库冲突 | 否(若用自增) | 号段、雪花、UUID 取舍 |
// 路由的前提:分片键在进入 DAO 之前就已确定,而不是在 SQL 里猜
public OrderPage listByUser(long userId, int page, int size) {
int shard = shardRouter.shardOf(userId); // 唯一事实来源
try (var scope = ShardContext.bind(shard)) { // 让下层不必再算
return orderMapper.page(userId, page * size, size);
}
}
六处里最易低估的是路由:它要求分片键在调用链上始终可得。HTTP 层能拿到用户 ID,但定时任务、消息消费、批处理往往拿不到,只能反查——反查就是扫全片。所以分片键的可用性要在入口层解决,不能在 DAO 层兜底。
4. 跨片查询的三种解法
| 解法 | 延迟 | 一致性 | 维护成本 |
|---|---|---|---|
| 冗余表(按第二维再存一份) | 单库级 | 写时双写,或短暂不一致 | 双写 + 对账任务 |
| 异构索引(ES 类) | 毫秒到几十毫秒 | 最终一致,滞后可观测 | 同步链路 + 重建 |
| 离线汇总(数仓/定时) | 分钟到小时级 | 最终一致,跨天数据 | 调度 + 口径 |
选择只看一句:查询要实时精确就付双写代价,要模糊检索就付同步链路代价,要统计口径就付调度代价。三者同时都要,说明分片键选错了,回到第 2 节。
三种解法的共同点是它们都不是查询优化,而是数据复制。复制就要付同步与对账成本;不接受对账,就得接受"某些查询偶尔少一行"——这个决定该由业务方签字,而不是由架构师默认。
5. 跨片分页:LIMIT 语义为什么会变
单片 LIMIT 20 OFFSET 40 是"跳过 40 行取 20 行"。跨四片的正确语义是"四片各取 60 行、归并排序、取第 41–60 行",于是偏移量放大 N 倍的排序开销,页越深越糟,且各片返回行数必须 ≥ offset+size,否则缺行。
-- 错误做法:各片独立分页后拼接(会漏行、会乱序)
SELECT * FROM orders_0 ORDER BY created_at DESC LIMIT 20 OFFSET 40;
SELECT * FROM orders_1 ORDER BY created_at DESC LIMIT 20 OFFSET 40; -- 拼起来不是全序
-- 正确做法:各片取前 offset+size,归并后截断;或改用游标
SELECT * FROM orders_0 WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20;
根治是把偏移分页换成游标分页(WHERE (sort_key, id) < (last)),每片只取 size 行,归并成本与页深无关。代价是接口契约变化:前端不能再"跳到第 500 页"——这要在需求阶段谈掉,不能上线后打补丁。
6. 跨片事务:三种替代与它们的账
| 替代 | 一致性窗口 | 实现复杂度 | 适合 |
|---|---|---|---|
| 本地消息表(outbox) | 秒级,可重试 | 中(投递器+幂等) | 同库多表 + 跨片通知 |
| Saga(正向+补偿) | 取决于补偿延迟 | 高(每步可逆) | 长流程、跨服务 |
| 业务补偿(对账) | 分钟到小时 | 低(需人值守) | 一致性宽松的场景 |
-- outbox:业务写入与消息落库在同一本地事务里,保证"要么都有、要么都没有"
BEGIN;
INSERT INTO orders(id, user_id, amount, status) VALUES (?, ?, ?, 'CREATED'); -- 落在 user_id 对应片
INSERT INTO outbox(id, event_type, payload, status) VALUES (?, 'ORDER_CREATED', ?, 'PENDING');
COMMIT; -- 投递器随后扫 PENDING 并重试,消费端靠幂等键去重
判据是能容忍多久的不一致:零 → 分片方案不成立;秒级 → outbox;分钟级且有值守 → 对账。若有人主张"用分布式事务框架保强一致",追问协调器在跨机房故障下的行为——那通常是把可用性换成了理论原子性。
7. 再分片:四条路与停机成本
| 方式 | 机制 | 停机 | 风险 |
|---|---|---|---|
| 双写迁移 | 新旧并行写,读逐步切换 | 近零 | 双写不一致,需回放 |
| 扩倍(4→8) | 每片对半拆,路由可推导 | 短 | 只支持整数倍扩,热点翻倍 |
| 一致性哈希 | 虚拟节点,迁移量最小 | 近零 | 路由不可预测,排障难 |
| 按新键重建 | 全量导出重灌 | 长(小时级) | 需冻结写入或双跑 |
再分片的前置条件有四条,缺一条就别动:全量校验(行数 + 抽样比对 + 校验和)、可切回开关(路由层支持按白名单走新规则)、回放通道(双写差异能补齐)、同屏观测(新旧两侧写入成功率、延迟、差异条数)。
扩倍最省事,但它把热点从一个片复制成两个片:原热点若是单个大租户,扩倍后仍是单点。再分片解决分布,不解决倾斜,倾斜要靠拆租户或单独隔离。
8. 中间件与读写分离的叠加
| 叠加项 | 带来 | 拿走 |
|---|---|---|
| 中间件代理(ShardingSphere 类) | 路由、归并、主键透明 | 执行计划不可见、EXPLAIN 失真 |
| 客户端分片 | 无额外跳数、易调试 | 每个语言栈都要实现一遍 |
| 读写分离 | 读吞吐线性扩展 | 复制延迟下的读己之写、走主标记 |
| 分片+读写分离 | 组合容量最大 | 故障域=分片数×副本数,链路更长 |
# 透明性的代价:分片规则藏在配置里,慢查询归因要跨过这一层
rules:
- !SHARDING
tables:
orders: { actualDataNodes: ds_${0..3}.orders_${0..7}, tableStrategy: { standard: { shardingColumn: user_id, shardingAlgorithmName: mod8 } } }
# 关键:把 hint 路由留作运维手段,否则批量修数据只能全片扫
中间件最大的隐性成本是可观测性下降:一条慢 SQL 在中间件里被改写成 N 条,数据库侧的慢日志看到的是 N 条互不相关的记录,而应用侧看到的是 1 个接口耗时。要在中间件层补上"逻辑 SQL → 实际片 → 各片耗时"的映射,否则排障时间会成倍增长。
9. 排错:五种典型现象
| 现象 | 常见原因 | 方向 |
|---|---|---|
| 某接口偶发变慢 | 全片扫描(未带分片键) | 打开路由日志,看实际访问片数 |
| 列表页第 N 页缺行 | 各片独立分页后拼接 | 改归并或游标分页 |
| 统计数与明细对不上 | 只查了单片或漏了一片 | 校验片数,补归并器 |
| 主键冲突 | 自增 + 多库 | 换号段/雪花,检查步长 |
| 双写后数据不一致 | 无对账、无幂等 | 加校验任务与幂等键 |
10. 边界:这些情况不要分
| 场景 | 判断 | 理由 |
|---|---|---|
| 单表千万级以内、增长可控 | 不分 | 索引 + 归档足够,分片只会增加路由成本 |
| 能靠分区表解决 | 不分 | 分区对应用透明,代价低一个数量级 |
| 瓶颈在读写比 | 先读写分离 | 读扩展不需要动数据模型 |
| 团队无中间件运维能力 | 不分 | 分片故障的定位门槛高于收益 |
| 查询形态高度不确定 | 不分 | 分片键依赖查询形态,形态未定就先分等于赌 |
一句话:分片是把数据库的复杂度搬到应用层,并且搬到的是最难测的那部分(路由、归并、补偿)。收益是容量,代价是每一条新查询都要过一次"带没带分片键"的检查。
11. 自测
- 有人说"数据量太大所以要分片",你会要求他给出什么证据?
- 分片键选定的核心判据是什么?为什么不是"分布均匀"?
- 应用层六处改动中,哪一处的失败最容易被误判为"数据库慢"?
- 跨片分页为什么不能用各片独立分页再拼接?根治办法是什么?
- 扩倍再分片能解决热点问题吗?为什么?
- 中间件让分片"透明"之后,排障能力具体损失在哪一环?