问题引入

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_0000users_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;

分库分表后,如果 usersorders 的分片键都是 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 的查询需要:

  1. 从每个分片取 LIMIT 0, 100010(排序后取前 100010 条)
  2. 在内存中归并 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 很大,数据量爆炸。

解决方案

  1. 禁止深度分页:业务上不允许直接跳到第 10000 页,改用"上一页/下一页"(记录上一页的 max(create_time)
  2. 双维分片:如果同时按 user_idcreate_time 范围分片,可以缩小查询范围
  3. 宽表 + 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 的第一性原理,一旦选定很难变更。

好的分片键标准

  1. 查询频率高:80% 以上的查询都带这个条件
  2. 基数大:值的种类多,避免热点(如性别只有男女,不能当分片键)
  3. 不可变:分片键值变更意味着数据要迁移到另一个分片

常见错误

分片键选择 问题
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% 的数据需要迁移
  • 步骤:
    1. 双写:新数据同时写入旧分片和新分片
    2. 迁移历史数据:按 user_id % 256 重新路由旧数据到新分片
    3. 校验一致性
    4. 切读流量到新分片
    5. 停双写

一致性 Hash 扩容(增加节点)

  • 约 1/N 的数据需要迁移
  • 通过虚拟节点,迁移范围可控

范围分片扩容(新增区间)

  • 无需迁移历史数据
  • 新数据落入新分片即可
  • 但需要提前规划区间,避免区间用尽

最佳实践

  • 初始分片数要"过量设计",如预估 10 个分片够用,初始就分 64 或 128 个
  • 使用逻辑分片映射到物理分片,扩容时只需调整映射关系

Q3:分布式事务到底选哪个方案?

:没有银弹,按场景选:

方案 一致性 性能 侵入性 适用场景
2PC/XA 强一致 差(阻塞) 金融转账(强一致刚需)
TCC 最终一致 高(三个接口) 电商下单(库存+订单+积分)
本地消息表 最终一致 异步场景(通知、积分)
Seata AT 最终一致 较好 已有 Spring 项目快速接入
Saga 最终一致 长事务(旅游预订多步骤)

选型决策树

复制代码
是否需要强一致?
    ├── 是 → 能否接受阻塞?
    │           ├── 能 → 2PC/XA
    │           └── 不能 →  reconsider 需求(真需要强一致吗?)
    └── 否 → 事务长吗?
                ├── 长(多步骤) → Saga
                └── 短 → 已有框架?
                            ├── Spring Cloud → Seata AT
                            └── 无框架 / 追求简单 → 本地消息表

Q4:分库分表后怎么保证全局唯一索引?

:分片键本身天然全局唯一(路由到唯一分片),但非分片键的唯一约束无法跨分片保证。

解决方案

  1. 分片键 + 业务字段联合唯一

    sql 复制代码
    -- phone 不是分片键,但 user_id 是
    -- 如果查询总是带 user_id,可以在分片内保证唯一
    UNIQUE KEY uk_user_phone (user_id, phone)
  2. 全局唯一索引表

    sql 复制代码
    -- 单独的库维护全局唯一约束
    CREATE TABLE global_unique_phone (
        phone VARCHAR(20) PRIMARY KEY,
        user_id BIGINT NOT NULL
    );
    -- 注册时先插入全局表,成功再继续
  3. 分布式锁 + 前置校验

    java 复制代码
    String 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 一样访问分布式集群。

总结

分库分表不是架构的终点,而是痛苦的开始。本文从拆分策略到分布式事务,穿透了每一个环节的核心原理和暗面:

  1. 垂直拆分解耦业务,水平拆分分散数据:先垂直再水平,不要一上来就二维拆分
  2. Sharding 策略的数学本质决定扩容代价:Hash 取模分布均匀但扩容痛苦,范围分片扩容简单但有尾部热点,一致性 Hash + 虚拟节点是两者的较好平衡
  3. 分片键是 Sharding 的第一性原理:选错了,80% 的查询会变成全分片广播,性能比单表还差
  4. 跨分片查询是性能灾难:JOIN 需绑定表或应用层组装,深度分页需游标或异构索引,聚合需归并且可能不准确
  5. 分布式事务没有银弹:2PC 强一致但阻塞,TCC 性能好但侵入大,本地消息表简单但最终一致,Seata AT 零侵入但全局锁有竞争
  6. 分库分表的终极边界:外键、自增 ID、全局唯一约束、跨分片事务——这些单机 MySQL 的"理所当然"在分库分表后都变成了"奢侈品"

最后提醒:如果业务还能通过读写分离、缓存、归档、甚至升级硬件来缓解,尽量不要走到分库分表。如果必须走,优先考虑云原生分布式数据库(TiDB、OceanBase),让专业的基础设施解决分片、路由、分布式事务的问题,团队 focus 在业务上。

参考资料