问题引入
2022 年,某社交平台用户表突破 10 亿行,单表 SELECT * FROM users WHERE user_id = ? 即使走了主键索引,平均响应时间也从 5ms 涨到了 200ms。DBA 团队紧急加索引、扩内存、升 SSD,但提升很快触顶——单机的 CPU、IO、内存和连接数都有物理上限。
更致命的是,INSERT 操作的锁竞争导致高峰期大量连接等待,Threads_running 飙到 5000,MySQL 几近瘫痪。团队最终决定分库分表:128 个分片,每个分片约 800 万行。
但分库分表不是"切完就完",面试追问下去:
- 分片键怎么选?
user_id做 Hash 取模,那按手机号查询怎么办? - 雪花算法的时钟回拨问题怎么解决?
- 跨分片 JOIN 和分页怎么做?第 10000 页的深度分页性能有多差?
- 分布式事务选 2PC、TCC 还是本地消息表?Seata 的 AT 模式底层怎么实现?
- 分库分表后,外键、触发器、自增 ID 还能用吗?
本文从拆分策略的选型开始,穿透 Sharding 的数学本质,揭示分库分表的暗面与分布式事务的工程实践。
核心概念
1. 拆分策略:垂直拆分 vs 水平拆分
垂直拆分(Vertical Sharding):按业务模块拆库。
┌─────────────────────────────────────────────┐
│ 单机数据库(拆分前) │
│ ┌─────────┬─────────┬─────────┬─────────┐ │
│ │ users │ orders │ products│ payments│ │
│ │ 10亿行 │ 5亿行 │ 1亿行 │ 3亿行 │ │
│ └─────────┴─────────┴─────────┴─────────┘ │
└─────────────────────────────────────────────┘
垂直拆分
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户库 │ │ 订单库 │ │ 商品库 │
│ ┌─────────┐│ │ ┌─────────┐│ │ ┌─────────┐│
│ │ users ││ │ │ orders ││ │ │products ││
│ │ profiles││ │ │payments ││ │ │inventory││
│ └─────────┘│ │ └─────────┘│ │ └─────────┘│
└─────────────┘ └─────────────┘ └─────────────┘
读图导引:垂直拆分的核心是"按业务解耦"——用户相关的表去用户库,订单相关的表去订单库。解决的是不同业务争抢单库资源的问题,但单表行数没有减少。
水平拆分(Horizontal Sharding):按数据行拆分到多个表/库。
┌─────────────────────────────────────────────┐
│ users 表(拆分前) │
│ ┌─────────────────────────────────────┐ │
│ │ user_id | name | phone | created_at │ │
│ │ 1-10亿 │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
水平拆分
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ users_0000 │ │ users_0001 │ ... │ users_0127 │
│ user_id │ │ user_id │ │ user_id │
│ 1, 129... │ │ 2, 130... │ │ 128, 256.. │
│ ~800万行 │ │ ~800万行 │ │ ~800万行 │
└─────────────┘ └─────────────┘ └─────────────┘
读图导引:水平拆分的核心是"按行分散"——同一张表的数据分散到多个物理表中。每个分片的数据结构和表名相同,只是存储的数据行不同。
两者的关系和优先级:
┌─────────────────────────────────────────┐
│ 数据库拆分策略演进 │
├─────────────────────────────────────────┤
│ │
│ 阶段1:垂直拆分(按业务拆库) │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │用户库│ │订单库│ │商品库│ │
│ └─────┘ └─────┘ └─────┘ │
│ ↓ 单库内某表仍过大 │
│ │
│ 阶段2:水平拆分(按数据行拆表) │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │user0│ │user1│ │userN│ │
│ └─────┘ └─────┘ └─────┘ │
│ ↓ 单机资源不够 │
│ │
│ 阶段3:分库+分表(二维拆分) │
│ ┌────┐┌────┐ ┌────┐┌────┐ │
│ │db0 ││db0 │... │dbN ││dbN │ │
│ │_t0 ││_t1│ │_t0 ││_t1 │ │
│ └────┘└────┘ └────┘└────┘ │
│ │
└─────────────────────────────────────────┘
读图导引:演进路径是垂直拆分 → 水平拆分 → 分库分表。先按业务解耦,再在业务内部按数据量拆分。不要一上来就二维拆分,复杂度会爆炸。
2. Sharding 策略:数据如何路由到分片
水平拆分的关键是分片键(Sharding Key)和路由算法。
Hash 取模
分片号 = hash(user_id) % 128
user_id = 10001 → hash(10001) % 128 = 17 → users_0017
user_id = 10002 → hash(10002) % 128 = 93 → users_0093
优点:数据分布均匀,不会有热点分片
缺点:扩容时需要迁移绝大多数数据(从 128 扩容到 256,约 50% 的数据要迁移)
范围分片
user_id 范围 分片
0 - 10,000,000 → users_0000
10,000,001 - 20M → users_0001
... → ...
优点:扩容简单,新增分片即可;范围查询效率高(如 WHERE user_id BETWEEN 100 AND 10000)
缺点:新注册用户总是落在最新分片,造成写热点("尾部热点")
一致性 Hash + 虚拟节点
┌───────────────────────────────────────────────────────┐
│ Hash 环(0 ~ 2^32-1) │
│ │
│ N1 (虚拟×150) │
│ ○ ○ ○ ○ ○ │
│ \ │
│ \ N2 (虚拟×150) │
│ ○ ○ ○ ○ ○○ ○ ○ ○ ○ │
│ \ / │
│ \/ │
│ ○ ○ ○ ○ ○ /\ ○ ○ ○ ○ ○ │
│ N3 / \ N4 (新增) │
│ / \ │
│ Key 顺时针找到第一个虚拟节点,映射到真实节点 │
│ │
└───────────────────────────────────────────────────────┘
读图导引:一致性 Hash 将节点和数据都映射到一个环上。增加 N4 时,只有 N3 到 N4 之间的数据需要迁移(约 1/N)。虚拟节点(每个真实节点对应 150 个虚拟节点)让分布更均匀。
三种策略对比:
| 策略 | 数据均匀性 | 扩容迁移量 | 范围查询 | 热点问题 |
|---|---|---|---|---|
| Hash 取模 | 优 | 大(~N-1/N) | 差(需全分片扫描) | 无 |
| 范围分片 | 良 | 小(仅新增分片) | 优 | 尾部写热点 |
| 一致性 Hash | 优(加虚拟节点) | 小(~1/N) | 差 | 无 |
3. 分布式 ID:分库分表后自增 ID 失效了
分库分表后,MySQL 的自增 ID 会冲突——users_0000 和 users_0001 都可能生成 id=1。
常见方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 自增 ID 偏移 | 分片 0 从 0 开始,步长 128;分片 1 从 1 开始,步长 128 | 简单 | 依赖分片数固定,扩容需调整步长 |
| UUID | 128 位随机字符串 | 全局唯一、去中心化 | 无序、索引效率低、占用空间大 |
| 雪花算法 | 64 位 = 时间戳(41) + 机器 ID(10) + 序列号(12) | 趋势递增、高性能 | 时钟回拨问题 |
| 号段模式(Leaf) | 从中央服务批量获取 ID 号段(如 1-1000),本地分配 | 高性能、趋势递增 | 依赖中央服务 |
雪花算法详解
┌─────────────────────────────────────────────────────────────────┐
│ 64 位 Snowflake ID │
├───────────┬────────────────────────┬───────────┬────────────────┤
│ 符号位 │ 时间戳(41位) │ 机器ID │ 序列号 │
│ 1 bit │ 69年(毫秒级) │ 10 bit │ 12 bit │
│ 0 │ 自定义纪元以来的毫秒数 │ 0-1023 │ 0-4095 │
└───────────┴────────────────────────┴───────────┴────────────────┘
读图导引:雪花算法的 64 位结构——符号位固定为 0(保证正数),时间戳提供趋势递增(有利于 B+树索引),机器 ID 区分不同节点,序列号解决同一毫秒的并发冲突。
时钟回拨问题:如果服务器 NTP 同步导致时钟回退,可能出现"当前时间戳 < 上次生成 ID 的时间戳",导致 ID 重复。
解决方案:
- 短期回拨(< 5ms):等待时钟追上
- 长期回拨:报错或使用"备用机器 ID"切换
- 百度 UidGenerator:用
CachedUidGenerator预生成 ID 缓冲池 - 美团 Leaf:号段模式 + Snowflake 双模式
原理分析
1. ShardingSphere 执行流程:SQL 是怎么被拆分的
ShardingSphere 是 Apache 顶级项目,代表了分库分表的中间件思路。一条 SQL 的执行流程如下:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ SQL解析 │───▶│ 路由 │───▶│ SQL改写 │───▶│ 执行 │───▶│ 结果归并 │
│ │ │ │ │ │ │ │ │ │
│ 词法分析 │ │ 分片键提取│ │ 补充分片名│ │ 并行下发 │ │ 排序聚合 │
│ 语法分析 │ │ 路由策略 │ │ IN 展开 │ │ 连接池 │ │ 分页重写 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
读图导引:五阶段流水线——解析、路由、改写、执行、归并。重点关注"路由"(决定 SQL 去哪些分片)和"归并"(多片结果如何合并)。
SQL 改写示例:
sql
-- 原始 SQL
SELECT * FROM users WHERE user_id IN (10001, 10002, 10129);
-- ShardingSphere 改写后(假设 128 分片,Hash 取模)
-- user_id=10001 → 分片 17
-- user_id=10002 → 分片 93
-- user_id=10129 → 分片 17
SELECT * FROM users_0017 WHERE user_id IN (10001, 10129);
SELECT * FROM users_0093 WHERE user_id = 10002;
跨分片查询的代价:如果 SQL 没有带分片键条件,就会变成全分片广播查询:
sql
-- 没有分片键条件,所有 128 个分片都要查
SELECT * FROM users WHERE phone = '13800138000';
-- 128 个分片并行查询 → 结果归并
-- 即使只有 1 条数据,也扫描了 128 张表
2. 跨分片 JOIN:为什么分库分表后 JOIN 变难了
单库时:
sql
SELECT u.*, o.*
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = 10001;
分库分表后,如果 users 和 orders 的分片键都是 user_id,且路由策略相同(都按 user_id Hash 取模),则同一 user_id 的数据一定在同一个分片,JOIN 可以在分片内完成——这叫绑定表(Binding Table)。
但如果分片策略不同,或 JOIN 条件不是分片键:
sql
-- users 按 user_id 分片,orders 按 order_id 分片
-- JOIN 条件 user_id 不是 orders 的分片键
-- 需要将所有 orders 数据拉到内存再 JOIN——性能灾难
解决方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 绑定表 | 关联表使用相同分片键和路由策略 | 用户+订单(同 user_id) |
| 全局表 | 小表在每个分片存一份完整数据 | 字典表、配置表 |
| 应用层组装 | 先查主表,再批量 IN 查询关联表 | 一对多查询 |
| 宽表冗余 | 反范式设计,把关联字段冗余到主表 | 读多写少 |
3. 跨分片分页:深度分页的灾难
单库分页:
sql
SELECT * FROM orders WHERE user_id = 10001 ORDER BY create_time DESC LIMIT 100000, 10;
分 128 个分片后,LIMIT 100000, 10 的查询需要:
- 从每个分片取
LIMIT 0, 100010(排序后取前 100010 条) - 在内存中归并 128 个分片的结果,再取第 100000-100010 条
总数据量:128 × 100010 ≈ 1280 万行要拉到中间件内存中排序!
分片 0: 取前 100010 条 ──┐
分片 1: 取前 100010 条 ──┤
... ├──▶ 中间件内存归并排序 ──▶ 取第 100000-100010 条
分片 127: 取前 100010 条─┘
总数据量: 128 × 100010 = 12,801,280 行
读图导引:跨分片 LIMIT M, N 的查询,每个分片都需要取前 M+N 条,中间件归并后再取第 M 条开始。深度分页时 M 很大,数据量爆炸。
解决方案:
- 禁止深度分页:业务上不允许直接跳到第 10000 页,改用"上一页/下一页"(记录上一页的
max(create_time)) - 双维分片:如果同时按
user_id和create_time范围分片,可以缩小查询范围 - 宽表 + ES:将需要分页的数据同步到 Elasticsearch,用 ES 做分页,MySQL 做精确查询
4. 分布式事务:从 ACID 到最终一致
分库分表后,单机事务变成跨库事务。BEGIN; UPDATE db0.users ...; UPDATE db1.orders ...; COMMIT; 无法保证原子性。
2PC(Two-Phase Commit)
协调者(Coordinator) 参与者(Participant A) 参与者(Participant B)
│ │ │
│ Phase 1: PREPARE │ │
│ ───────────────────────────────▶│ │
│ ────────────────────────────────────────────────────────▶│
│ │ 写本地 undo/redo │
│ │ 锁定资源 │
│ ◀───────────────────────────────│ 返回 YES/NO │
│ ◀───────────────────────────────────────────────────────│
│ │ │
│ Phase 2: COMMIT/ABORT │ │
│ (所有参与者返回 YES) │ │
│ ───────────────────────────────▶│ │
│ ────────────────────────────────────────────────────────▶│
│ │ 真正提交 │
│ │ 释放锁 │
│ ◀───────────────────────────────│ ACK │
│ ◀───────────────────────────────────────────────────────│
读图导引:2PC 分为投票阶段(Prepare)和执行阶段(Commit)。协调者询问所有参与者是否能提交,参与者锁定资源并回复;如果都回复 YES,协调者发送 Commit;如果有 NO,发送 Rollback。
2PC 的问题:
- 同步阻塞:Prepare 阶段参与者锁定资源,其他事务无法修改
- 单点故障:协调者宕机,参与者一直持有锁等待
- 脑裂:协调者发送 Commit 后宕机,部分参与者收到 Commit,部分没收到——数据不一致
TCC(Try-Confirm-Cancel)
TCC 是业务层面的两阶段提交,将事务拆分为三个操作:
java
public interface PaymentService {
// Try:预留资源(如冻结账户余额)
boolean tryPayment(String userId, BigDecimal amount);
// Confirm:真正执行(扣减冻结的余额)
boolean confirmPayment(String userId, BigDecimal amount);
// Cancel:释放预留资源(解冻余额)
boolean cancelPayment(String userId, BigDecimal amount);
}
Try 阶段:
账户服务: 余额 1000 → 冻结 100,可用 900
积分服务: 预增加 10 积分(写待确认记录)
↓ 全部 Try 成功
Confirm 阶段:
账户服务: 真正扣减 100(余额 900)
积分服务: 确认增加 10 积分
↓ 任一 Try 失败
Cancel 阶段:
账户服务: 解冻 100(余额恢复 1000)
积分服务: 删除待确认记录
读图导引:TCC 的核心是"业务层面的补偿"——Try 预留资源,Confirm 确认执行,Cancel 回滚释放。每个操作都是独立的本地事务,通过业务逻辑保证最终一致。
TCC 的问题:
- 业务侵入性大:每个操作都要实现三个接口
- 幂等性要求高:Confirm/Cancel 可能被重试,必须幂等
- 空回滚:Try 还没执行,Cancel 先被执行(网络超时导致),需要处理空回滚
- 悬挂:Try 因为网络延迟后到达,此时事务已结束,需要拒绝悬挂的 Try
本地消息表(最终一致性)
┌─────────────┐ 写入业务表+消息表(同一本地事务) ┌─────────────┐
│ 业务服务 │ ───────────────────────────────────────▶ │ 数据库 │
│ (订单服务) │ INSERT INTO orders ... │ (订单库) │
│ │ INSERT INTO msg_queue ... │ │
└──────┬──────┘ └──────┬──────┘
│ │
│ 定时扫描未发送消息 │
│ │
▼ │
┌─────────────┐ │
│ 消息投递 │ ───────────────────────────────────────────────▶│
│ (定时任务) │ 调用下游服务(如库存扣减) │
│ │ │
└──────┬──────┘ │
│ 下游处理成功 │
│ │
▼ │
┌─────────────┐ │
│ 更新消息状态 │ ───────────────────────────────────────────────▶│
│ (标记为已消费)│ UPDATE msg_queue SET status='DONE' │
└─────────────┘ │
读图导引:本地消息表的核心思路——将分布式事务拆分为"本地事务 + 异步投递"。业务操作和消息记录在同一个本地事务中保证原子性,然后通过定时任务异步投递消息,实现最终一致。
本地消息表的优点:实现简单、不依赖外部框架、性能高
缺点:有延迟(最终一致)、需要定时任务、消费方需要幂等
Seata 的 AT 模式
Seata 是阿里开源的分布式事务框架,AT 模式对业务零侵入:
业务应用 Seata Server (TC) 资源管理器 (RM)
│ │ │
│ 1. BEGIN (全局事务) │ │
│ ──────────────────────────▶│ │
│ ◀──────────────────────────│ 返回 XID │
│ │ │
│ 2. 执行业务 SQL │ │
│ ────────────────────────────────────────────────────────▶│
│ │ │
│ │ │ 2.1 解析 SQL,
│ │ │ 查询修改前数据
│ │ │ 2.2 执行业务 SQL
│ │ │ 2.3 记录 undo_log
│ │ │ (反向 SQL)
│ │ │
│ 3. COMMIT (全局事务) │ │
│ ──────────────────────────▶│ │
│ │ 4. 异步驱动二阶段提交 │
│ │ ────────────────────────────▶│
│ │ │ 4.1 成功:删除 undo_log
│ │ │ 4.2 失败:用 undo_log
│ │ │ 反向补偿
读图导引:Seata AT 的魔法在 RM 端——业务 SQL 执行时,Seata 代理数据源自动解析 SQL、生成反向补偿 SQL(undo_log)并记录在本地。全局提交时异步删除 undo_log;全局回滚时用 undo_log 做反向补偿。
AT 模式的边界:
- 需要 Seata 代理数据源(对 JDBC 操作有侵入)
- 不支持复杂 SQL(如子查询、存储过程)
- 全局锁竞争:一阶段会锁定修改的行,直到全局事务结束
5. 分库分表的暗面:那些"再也用不了"的功能
分库分表后,以下 MySQL 特性会失效或受限:
| 特性 | 单库 | 分库分表后 |
|---|---|---|
| 自增 ID | AUTO_INCREMENT |
冲突,需改用分布式 ID |
| 外键约束 | FOREIGN KEY |
跨分片无法检查,需应用层保证 |
| 唯一约束 | UNIQUE |
跨分片无法保证全局唯一(除分片键外) |
| 事务 | BEGIN/COMMIT |
单分片事务可用,跨分片需分布式事务 |
| JOIN | 任意表 JOIN | 仅绑定表可 JOIN,其他需应用层组装 |
| 子查询 | 支持 | 复杂子查询可能无法正确路由 |
| 聚合函数 | SUM/AVG/COUNT |
跨分片需归并,结果可能不准确 |
| 存储过程/触发器 | 支持 | 中间件通常不支持透传 |
唯一约束的 workaround:
- 分片键 + 业务字段联合唯一:如
UNIQUE(sharding_key, phone),保证同一分片内唯一 - 全局唯一检查:用 Redis
SETNX或分布式锁检查,但性能和一致性有 trade-off
实战/源码
1. ShardingSphere-JDBC 配置示例
yaml
# application-sharding.yml
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db0:3306/db0
username: root
password: xxx
ds1:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db1:3306/db1
username: root
password: xxx
rules:
sharding:
tables:
users:
actual-data-nodes: ds${0..1}.users_${0..63} # 2 库 × 64 表 = 128 分片
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-hash
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-hash
sharding-algorithms:
user-hash:
type: INLINE
props:
algorithm-expression: users_${user_id % 64}
db-hash:
type: INLINE
props:
algorithm-expression: ds${user_id % 2}
binding-tables:
- users,orders # 绑定表,同分片键同路由策略
broadcast-tables:
- config # 全局表,每个分片一份
props:
sql-show: true # 开发环境打印真实 SQL
2. 雪花算法实现(Java)
java
public class SnowflakeIdWorker {
// 起始时间戳(2024-01-01)
private final long twepoch = 1704067200000L;
// 位数分配
private final long workerIdBits = 10L; // 机器 ID 占 10 位
private final long sequenceBits = 12L; // 序列号占 12 位
// 最大值
private final long maxWorkerId = ~(-1L << workerIdBits); // 1023
private final long sequenceMask = ~(-1L << sequenceBits); // 4095
// 位移
private final long workerIdShift = sequenceBits;
private final long timestampShift = sequenceBits + workerIdBits;
private long workerId; // 机器 ID (0-1023)
private long sequence = 0L; // 序列号
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨: " + (lastTimestamp - timestamp) + "ms");
}
if (timestamp == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 序列号溢出,等待下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0; // 新毫秒,序列号归零
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampShift)
| (workerId << workerIdShift)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
}
时钟回拨处理增强版:
java
public class SafeSnowflakeWorker extends SnowflakeIdWorker {
private final long maxBackwardMs = 5; // 允许最大回拨 5ms
@Override
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= maxBackwardMs) {
// 短期回拨:等待
try {
Thread.sleep(offset);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨超出阈值");
}
} else {
throw new RuntimeException("时钟严重回拨: " + offset + "ms");
}
}
// ... 正常生成逻辑
}
}
3. 本地消息表实现
sql
-- 消息表结构
CREATE TABLE msg_queue (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50) NOT NULL COMMENT '业务类型',
biz_id VARCHAR(100) NOT NULL COMMENT '业务单号',
payload TEXT NOT NULL COMMENT '消息内容(JSON)',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0:待发送 1:发送中 2:成功 3:失败',
retry_count INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_type, biz_id)
) ENGINE=InnoDB COMMENT='本地消息表';
java
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
// 1. 保存订单
orderMapper.insert(order);
// 2. 写入消息表(同一本地事务)
MsgQueue msg = new MsgQueue();
msg.setBizType("ORDER_CREATED");
msg.setBizId(order.getOrderId());
msg.setPayload(JsonUtils.toJson(order));
msg.setStatus(0);
msgQueueMapper.insert(msg);
}
}
@Component
public class MsgDeliveryJob {
@Scheduled(fixedRate = 5000) // 每 5 秒扫描一次
public void deliver() {
List<MsgQueue> pendingMsgs = msgQueueMapper.selectByStatus(0, 100);
for (MsgQueue msg : pendingMsgs) {
try {
// 更新为发送中(CAS 防重)
int updated = msgQueueMapper.casUpdateStatus(
msg.getId(), 0, 1, msg.getVersion()
);
if (updated == 0) continue; // 被其他线程处理
// 调用下游服务
boolean success = inventoryClient.deduct(
JsonUtils.fromJson(msg.getPayload(), Order.class)
);
if (success) {
msgQueueMapper.updateStatus(msg.getId(), 2);
} else {
retryOrFail(msg);
}
} catch (Exception e) {
retryOrFail(msg);
}
}
}
private void retryOrFail(MsgQueue msg) {
if (msg.getRetryCount() >= 3) {
msgQueueMapper.updateStatus(msg.getId(), 3); // 死信
// 告警通知人工介入
} else {
msgQueueMapper.increaseRetry(msg.getId());
}
}
}
4. 跨分片分页优化
方案一:游标分页(推荐)
java
// 不使用 OFFSET,使用上一页最后一条记录的时间戳
public List<Order> getOrders(Long userId, Long lastCreateTime, int pageSize) {
return orderMapper.selectByCursor(userId, lastCreateTime, pageSize);
}
// SQL
// SELECT * FROM orders WHERE user_id = ? AND create_time < ? ORDER BY create_time DESC LIMIT ?
方案二:宽表 + ES
java
// 订单数据同步到 Elasticsearch
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
OrderDoc doc = new OrderDoc(event.getOrder());
elasticsearchRestTemplate.save(doc);
}
// 分页查询走 ES
public Page<OrderDoc> searchOrders(OrderSearchRequest request) {
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(buildQuery(request))
.withPageable(PageRequest.of(request.getPage(), request.getSize()))
.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
.build();
return elasticsearchRestTemplate.searchForPage(query, OrderDoc.class);
}
// 详情查询再走 MySQL
public Order getOrderDetail(String orderId) {
return orderMapper.selectById(orderId);
}
常见问题
Q1:分片键怎么选?选错了有什么后果?
答:分片键的选择是 Sharding 的第一性原理,一旦选定很难变更。
好的分片键标准:
- 查询频率高:80% 以上的查询都带这个条件
- 基数大:值的种类多,避免热点(如性别只有男女,不能当分片键)
- 不可变:分片键值变更意味着数据要迁移到另一个分片
常见错误:
| 分片键选择 | 问题 |
|---|---|
create_time |
新数据集中在最新分片,写热点 |
status(0/1/2) |
基数太低,数据分布极不均匀 |
user_id 但按手机号查询 |
不带 user_id 的查询变成全分片广播 |
多维度查询的 workaround:
- 建立"映射表":
phone_to_user_id表,先查映射表拿到user_id,再按user_id查主表 - 异构索引表:用 Elasticsearch 或 Redis 维护二级索引
- 基因法:将
user_id的基因信息编码到订单 ID 中,让订单按用户维度分片
Q2:扩容时数据怎么迁移?
答:取决于 Sharding 策略:
Hash 取模扩容(128 → 256):
- 约 50% 的数据需要迁移
- 步骤:
- 双写:新数据同时写入旧分片和新分片
- 迁移历史数据:按
user_id % 256重新路由旧数据到新分片 - 校验一致性
- 切读流量到新分片
- 停双写
一致性 Hash 扩容(增加节点):
- 约 1/N 的数据需要迁移
- 通过虚拟节点,迁移范围可控
范围分片扩容(新增区间):
- 无需迁移历史数据
- 新数据落入新分片即可
- 但需要提前规划区间,避免区间用尽
最佳实践:
- 初始分片数要"过量设计",如预估 10 个分片够用,初始就分 64 或 128 个
- 使用逻辑分片映射到物理分片,扩容时只需调整映射关系
Q3:分布式事务到底选哪个方案?
答:没有银弹,按场景选:
| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 差(阻塞) | 低 | 金融转账(强一致刚需) |
| TCC | 最终一致 | 好 | 高(三个接口) | 电商下单(库存+订单+积分) |
| 本地消息表 | 最终一致 | 好 | 中 | 异步场景(通知、积分) |
| Seata AT | 最终一致 | 较好 | 低 | 已有 Spring 项目快速接入 |
| Saga | 最终一致 | 好 | 中 | 长事务(旅游预订多步骤) |
选型决策树:
是否需要强一致?
├── 是 → 能否接受阻塞?
│ ├── 能 → 2PC/XA
│ └── 不能 → reconsider 需求(真需要强一致吗?)
└── 否 → 事务长吗?
├── 长(多步骤) → Saga
└── 短 → 已有框架?
├── Spring Cloud → Seata AT
└── 无框架 / 追求简单 → 本地消息表
Q4:分库分表后怎么保证全局唯一索引?
答:分片键本身天然全局唯一(路由到唯一分片),但非分片键的唯一约束无法跨分片保证。
解决方案:
-
分片键 + 业务字段联合唯一
sql-- phone 不是分片键,但 user_id 是 -- 如果查询总是带 user_id,可以在分片内保证唯一 UNIQUE KEY uk_user_phone (user_id, phone) -
全局唯一索引表
sql-- 单独的库维护全局唯一约束 CREATE TABLE global_unique_phone ( phone VARCHAR(20) PRIMARY KEY, user_id BIGINT NOT NULL ); -- 注册时先插入全局表,成功再继续 -
分布式锁 + 前置校验
javaString lockKey = "register:phone:" + phone; if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) { try { // 二次校验 if (userMapper.countByPhone(phone) > 0) { throw new DuplicateException(); } // 真正注册 } finally { redisTemplate.delete(lockKey); } }
Q5:分库分表的终极判断——到底什么时候必须拆?
答:分库分表是最后的手段,在此之前,试试这些方案:
| 方案 | 效果 | 复杂度 |
|---|---|---|
| 读写分离 | 扩展读 QPS 3-10 倍 | 低 |
| 索引优化 | 减少慢查询 | 低 |
| 缓存(Redis) | 减少 90% 读请求 | 中 |
| 归档历史数据 | 单表数据量减少 80% | 中 |
| TiDB/OceanBase | 分布式数据库自动分片 | 中(迁移成本) |
| 分库分表 | 读写都扩展 | 极高 |
分库分表的必要条件:
- 单表数据量 > 5000 万行(InnoDB 的 B+树高度达到 4-5 层)
- 单库写 QPS > 5000(InnoDB 的 redo log 和锁竞争成为瓶颈)
- 上述优化手段已用尽
不要轻易分库分表的原因:
- JOIN 变难、子查询受限、聚合不准
- 分布式事务复杂度指数级上升
- 运维成本:128 个分片的备份、监控、DDL 变更都是噩梦
- 扩容和数据迁移风险高
如果业务还能通过读写分离 + 缓存 + 归档缓解,尽量不要走到分库分表。或者考虑云原生分布式数据库(TiDB、OceanBase、PolarDB-X),它们提供透明的分布式能力,让应用像访问单机 MySQL 一样访问分布式集群。
总结
分库分表不是架构的终点,而是痛苦的开始。本文从拆分策略到分布式事务,穿透了每一个环节的核心原理和暗面:
- 垂直拆分解耦业务,水平拆分分散数据:先垂直再水平,不要一上来就二维拆分
- Sharding 策略的数学本质决定扩容代价:Hash 取模分布均匀但扩容痛苦,范围分片扩容简单但有尾部热点,一致性 Hash + 虚拟节点是两者的较好平衡
- 分片键是 Sharding 的第一性原理:选错了,80% 的查询会变成全分片广播,性能比单表还差
- 跨分片查询是性能灾难:JOIN 需绑定表或应用层组装,深度分页需游标或异构索引,聚合需归并且可能不准确
- 分布式事务没有银弹:2PC 强一致但阻塞,TCC 性能好但侵入大,本地消息表简单但最终一致,Seata AT 零侵入但全局锁有竞争
- 分库分表的终极边界:外键、自增 ID、全局唯一约束、跨分片事务——这些单机 MySQL 的"理所当然"在分库分表后都变成了"奢侈品"
最后提醒:如果业务还能通过读写分离、缓存、归档、甚至升级硬件来缓解,尽量不要走到分库分表。如果必须走,优先考虑云原生分布式数据库(TiDB、OceanBase),让专业的基础设施解决分片、路由、分布式事务的问题,团队 focus 在业务上。