单表到几千万行、写入开始排队时,分片是个合理选择。但它不是"把表拆开"这么简单:拆开的那一刻,JOIN、聚合、排序分页、事务、自增 ID 五件东西同时失效,而业务代码还按单库假设在写。
1. 分片之后丢掉的能力
| 能力 | 为什么丢 | 常见补法 |
|---|---|---|
| 跨分片 JOIN | 数据在不同库/不同实例,无法下推 | 冗余字段、宽表、应用层拼装 |
| 跨分片聚合 | sum/count 可加和,distinct/avg 不行 |
各分片预聚合再归并,或走 ES/OLAP |
| 跨分片排序分页 | 全局顺序不存在于任何单个分片 | 各分片取前 N 再归并(见第 3 节) |
| 跨分片事务 | 单库事务的 ACID 边界被切开 | 本地消息表、TCC、Saga(见第 5 节) |
| 全局唯一 ID | 各分片自增会撞号 | 号段、雪花、UUID(见第 6 节) |
最容易被低估的是排序分页:它能"看起来正常"很久,直到翻到第 100 页才发现顺序错乱。
2. 分片键决定"能不能落到单片"
| 分片键 | 落到单片的查询 | 必须全路由的查询 |
|---|---|---|
user_id |
where user_id=?、where user_id=? and status=? |
where order_no=?、按时间范围扫全表 |
order_id |
where order_id=? |
where user_id=?(除非用基因法把用户维度带进 ID) |
| 时间(按月分表) | where created_at between ?(落在对应月表) |
where id=?(不知道该去哪个表) |
// 取模分片:同一 user_id 恒定落一片,跨片查询必须广播
int shard = Math.floorMod(userId.hashCode(), 8);
// 基因法:把 user_id 的低位塞进 order_id,让订单也能按 user 定位
long orderId = (snowflake.nextId() & ~0xFFL) | (userId & 0xFFL);
判断标准只有一条:最高频的查询条件能不能落单片。业务 80% 的查询按 order_no、却按 user_id 分片,每次都是 8 片广播,收益被自己抵消。
3. 跨分片分页
正确做法是"各分片取前 N,再在应用层归并",而不是把 limit offset 下发——每个分片独立跳行,归并后的顺序与条数都不可信。
// 正确:单片取 offset+size 条,归并后截取;返回全局游标供下一页使用
int need = offset + size;
List<Row> merged = new ArrayList<>();
for (Shard s : shards) {
merged.addAll(s.query("SELECT * FROM t_order WHERE user_id=? " +
"ORDER BY created_at DESC, id DESC LIMIT ?", userId, need));
}
merged.sort(CMP); // created_at desc, id desc
List<Row> page = merged.subList(Math.min(offset, merged.size()),
Math.min(offset + size, merged.size()));
// 游标:下一页带上一页最后一行的 (created_at, id)
| 做法 | 代价 | 适用 |
|---|---|---|
各片取 offset+size 归并 |
内存与网络开销随 offset 线性增长 | 浅分页(前几十页) |
游标分页(where (created_at,id) < (?,?)) |
每页成本恒定 | 深分页、无限滚动 |
| 查询侧存 ES | 多一套存储与同步 | 复杂条件 + 任意跳页 |
深分页的代价是结构性的:offset 越大,每个分片都要多扫同样多的行,分片越多越亏。要么限制翻页深度,要么改游标分页。
4. 跨分片聚合
| 聚合 | 能否分片加和 | 处理方式 |
|---|---|---|
count(*) / sum(x) |
可以 | 各分片结果相加 |
min / max |
可以 | 各分片结果取极值 |
avg |
不可以 | 各片返回 sum 与 count,最后相除 |
count(distinct x) |
不可以 | 需全局去重:归并集合或走 ES 的 cardinality |
group by + having |
部分 | 各片分组后二次聚合,having 必须在归并后执行 |
-- 各分片先算 sum 与 count,应用层再除,避免"平均值再平均"的错误
SELECT SUM(amount) AS s, COUNT(*) AS c FROM t_order_0 WHERE status = 1;
SELECT SUM(amount) AS s, COUNT(*) AS c FROM t_order_1 WHERE status = 1;
-- 结果:total_s / total_c
内存归并简单、实时,受结果集大小限制;**异构存储(ES/ClickHouse)**适合多条件任意维度,代价是多一条同步链路。
5. 跨分片事务的四种方案
| 方案 | 一致性 | 性能 | 复杂度 | 适用 |
|---|---|---|---|---|
| 本地消息表 + 最终一致 | 最终一致 | 高 | 中 | 大多数业务写(下单、扣减后发消息) |
| TCC | 最终一致(强约束) | 中 | 高 | 资金、库存等需要显式补偿 |
| Saga | 最终一致 | 高 | 中高 | 长流程、多服务编排 |
| 尽量避免跨片写 | 强一致 | 最高 | 低 | 把关联数据放到同一分片键下 |
-- 本地消息表:业务表与消息表在同一分片、同一本地事务里写入
CREATE TABLE t_order_msg (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 待发 1 已发 2 失败
retry INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
KEY idx_status_created (status, created_at)
);
START TRANSACTION;
INSERT INTO t_order (order_no, user_id, amount, status) VALUES ('20261109001', 9, 100.00, 0);
INSERT INTO t_order_msg (order_no, payload, created_at) VALUES ('20261109001', '{"type":"ORDER_CREATED"}', NOW());
COMMIT;
-- 独立投递器轮询 status=0,发送成功后置 1;失败按退避重试并累计 retry
关键点:消息表必须与业务表在同分片、同一本地事务里,否则"业务成功、消息丢失"的窗口仍在。
XA/两阶段提交被刻意排除:锁持有到第二阶段结束,跨库时锁时间被网络往返放大,高峰期容易拖死连接池。
6. 分布式 ID 的取舍
| 方案 | 有序性 | 依赖 | 适用 |
|---|---|---|---|
| 数据库号段 | 单调递增 | 依赖一个号段表/中心服务 | 单点写不高的业务,一次取 1000 个降低压力 |
| 雪花(Snowflake) | 趋势递增 | 依赖时钟与 workerId 分配 | 高并发、需要按 ID 排序 |
| UUID / ULID | UUID 无序,ULID 近似有序 | 无 | 无需排序、可容忍索引碎片 |
用无序 UUID 会让 B+ 树不断页分裂,写入放大明显;用雪花则要处理时钟回拨与 workerId 重复,否则直接产生重复 ID 或乱序。
7. 全局二级索引
按 user_id 分片却要按 order_no 精确查,最省事的补法是维护索引表:
-- 索引表按 order_no 分片,只存路由信息,不存业务数据
CREATE TABLE t_order_idx (
order_no VARCHAR(32) PRIMARY KEY,
shard_id TINYINT NOT NULL,
user_id BIGINT NOT NULL,
created_at DATETIME NOT NULL
);
-- 查询:先查索引表拿到 shard_id,再定点查业务表(两次查询,一次跨片)
| 维护方式 | 一致性 | 代价 |
|---|---|---|
| 与业务写同事务(同一分片) | 强 | 只适用于索引表与业务表同片 |
| 订阅 binlog 异步写 | 最终一致 | 需要去重、幂等、失败重放 |
| 双写 + 对账补偿 | 最终一致 | 双写失败要能发现,靠定时对账兜底 |
引入索引表就要接受"索引短暂落后":查到 shard_id 后业务表查不到记录属于正常窗口,接口应返回"稍后重试"而不是 500。
8. 扩容迁移:双写、校验、切换
| 阶段 | 动作 | 风险点 |
|---|---|---|
| 1. 双写 | 新数据同时写旧分片与新分片 | 双写失败时以哪边为准 |
| 2. 存量迁移 | 按主键区间分批搬历史数据 | 迁移期间旧数据被改,搬过去是脏的 |
| 3. 校验 | 按分片抽样比对行数与关键字段 | 只比总数会漏掉字段错位 |
| 4. 读切换 | 灰度按用户/比例读新分片 | 读旧写新的窗口 |
-- 迁移校验:行数 + 关键聚合,两样都要比
SELECT COUNT(*) AS c, SUM(amount) AS s, MIN(id), MAX(id) FROM t_order_3;
SELECT COUNT(*) AS c, SUM(amount) AS s, MIN(id), MAX(id) FROM t_order_3_new;
批次大小按"单批秒级完成、失败可重试"来定,不要一次搬完一个分片:中途失败无法判断进度。
9. 什么时候该重新审视分片
| 现状 | 先做的 | 再考虑分片 |
|---|---|---|
| 单表几百万行、查询慢 | 索引、SQL 改写、覆盖索引 | 通常不需要 |
| 单表几千万行、写入排队 | 归档冷数据、分区表、读写分离 | 归档后往往回到可控区间 |
| 单表上亿行、单机容量到顶 | 分区 + 冷热分离 | 分片是最后手段 |
| 已有分片但 80% 查询全路由 | 重新选分片键或加索引表 | 而不是继续加机器 |
分片的真实成本在拆分之后的每次跨片查询与扩容。能在单库内解决时,别提前分。
10. 排错与常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 分页结果重复或漏行 | 各分片 limit offset 独立执行 |
改归并 + 游标分页 |
avg 结果明显偏小/偏大 |
平均值再平均 | 各片返回 sum 与 count |
distinct 计数偏大 |
各分片结果直接相加 | 归并去重或走 ES |
| 跨片事务偶发不一致 | 双写没有补偿 | 本地消息表 + 对账 |
| 扩容后 ID 冲突 | workerId 分配重复 | 集中分配 workerId,校验时钟 |
| 索引表查不到数据 | 异步同步延迟 | 接口容忍重试,加同步延迟监控 |
常见坑:
| 坑 | 表现 | 正确做法 |
|---|---|---|
| 按用户分片却常按订单号查 | 每次查询广播全部分片 | 基因法或全局索引表 |
跨分片 count 加和、distinct 也加和 |
统计数字虚高 | 只有可加和的聚合才能直接相加 |
| 跨片用两阶段提交 | 高峰期连接池耗尽 | 最终一致方案 + 幂等 |
| 分片键用雪花但没管时钟 | 重复 ID、乱序 | 时钟回拨检测 + workerId 唯一 |
| 迁移只对行数 | 字段错位查不出来 | 行数 + 关键聚合 + 抽样比对 |
| 分片后仍按单库写 JOIN | 上线才发现不支持 | 设计阶段就把跨片查询列清 |
11. 边界
| 场景 | 更合适的方案 | 理由 |
|---|---|---|
| 读多写少、查询维度多 | 读写分离 + ES 查询侧 | 分片解决写入与容量,不解决查询维度 |
| 数据有时效性 | 分区表 + 归档 | 运维成本远低于分片 |
| 强一致要求高 | 单库 + 垂直拆分 | 跨片强一致的代价通常不划算 |
| 报表与多维分析 | 数仓 / ClickHouse | 业务库不该承担分析负载 |
12. 自测
- 分片之后哪五项能力会丢失?分别对应什么补法?
- 按
user_id分片、却按order_no查询,除了广播还有哪两种解法? - 为什么跨分片分页不能把
limit offset直接下发给每个分片? avg和count(distinct)为什么不能在分片结果上直接合并?- 本地消息表为什么必须与业务表在同一个分片、同一个事务里?
- 迁移校验只比行数会漏掉什么,应该补哪些比对?