2958 字
约 9 分钟
14
S11-X09 · 分库分表后的跨库查询与事务

单表到几千万行、写入开始排队时,分片是个合理选择。但它不是"把表拆开"这么简单:拆开的那一刻,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. 自测

  1. 分片之后哪五项能力会丢失?分别对应什么补法?
  2. 按 user_id 分片、却按 order_no 查询,除了广播还有哪两种解法?
  3. 为什么跨分片分页不能把 limit offset 直接下发给每个分片?
  4. avg 和 count(distinct) 为什么不能在分片结果上直接合并?
  5. 本地消息表为什么必须与业务表在同一个分片、同一个事务里?
  6. 迁移校验只比行数会漏掉什么,应该补哪些比对?
S11-X09 · 分库分表后的跨库查询与事务
https://www.clxhxhhr.top/posts/5831/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。