3110 字
约 10 分钟
22
S08-A07 · 分库分表的应用层代价

分片上线那天最轻松:数据还少,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. 自测

  1. 有人说"数据量太大所以要分片",你会要求他给出什么证据?
  2. 分片键选定的核心判据是什么?为什么不是"分布均匀"?
  3. 应用层六处改动中,哪一处的失败最容易被误判为"数据库慢"?
  4. 跨片分页为什么不能用各片独立分页再拼接?根治办法是什么?
  5. 扩倍再分片能解决热点问题吗?为什么?
  6. 中间件让分片"透明"之后,排障能力具体损失在哪一环?
S08-A07 · 分库分表的应用层代价
https://www.clxhxhhr.top/posts/6181/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。