MySQL 实战精通系列 · 第10篇:分库分表与分布式事务实战
本篇目标:单机扛不住、数据量太大、跨库怎么保证一致------从分片策略到分布式事务,系统化掌握 MySQL 水平扩展方案。
文章目录
- [MySQL 实战精通系列 · 第10篇:分库分表与分布式事务实战](#MySQL 实战精通系列 · 第10篇:分库分表与分布式事务实战)
-
- 一、什么时候需要分库分表
-
- [1.1 单机瓶颈的三个信号](#1.1 单机瓶颈的三个信号)
- [1.2 一张图看懂决策顺序](#1.2 一张图看懂决策顺序)
- [二、分片策略:垂直 vs 水平](#二、分片策略:垂直 vs 水平)
-
- [2.1 两种拆分方式](#2.1 两种拆分方式)
- [2.2 垂直拆分:按业务分库](#2.2 垂直拆分:按业务分库)
- [2.3 水平拆分:按数据行分表](#2.3 水平拆分:按数据行分表)
- 三、分片算法
-
- [3.1 常用分片算法对比](#3.1 常用分片算法对比)
- [3.2 Range 分片](#3.2 Range 分片)
- [3.3 Hash 分片](#3.3 Hash 分片)
- [3.4 雪花算法:全局唯一 ID](#3.4 雪花算法:全局唯一 ID)
- 四、分库分表中间件
-
- [4.1 两种技术路线](#4.1 两种技术路线)
- [4.2 ShardingSphere-JDBC 配置示例](#4.2 ShardingSphere-JDBC 配置示例)
- [4.3 分片后的 SQL 限制](#4.3 分片后的 SQL 限制)
- 五、分库分表实战:订单表拆分
-
- [5.1 需求分析](#5.1 需求分析)
- [5.2 分片键选择](#5.2 分片键选择)
- [5.3 建表 SQL](#5.3 建表 SQL)
- [5.4 路由计算](#5.4 路由计算)
- [5.5 跨分片分页查询](#5.5 跨分片分页查询)
- 六、分布式事务
-
- [6.1 为什么需要分布式事务](#6.1 为什么需要分布式事务)
- [6.2 分布式事务方案对比](#6.2 分布式事务方案对比)
- [6.3 Seata AT 模式实战](#6.3 Seata AT 模式实战)
- [6.4 本地消息表:最终一致方案](#6.4 本地消息表:最终一致方案)
- 七、扩容方案
-
- [7.1 为什么要考虑扩容](#7.1 为什么要考虑扩容)
- [7.2 扩容方案对比](#7.2 扩容方案对比)
- [7.3 双写迁移实战](#7.3 双写迁移实战)
- 八、实战任务
- 九、本篇小结
一、什么时候需要分库分表
1.1 单机瓶颈的三个信号
单机 MySQL 扛不住的信号
│
├── ① 数据量太大 ← 单表 > 2000万行,B+树层高增加,查询变慢
├── ② 写入压力太大 ← 单机 TPS 到瓶颈,主库 CPU 打满
├── ③ 磁盘空间不够 ← 单机磁盘容量到上限
原则:能不拆就不拆。分库分表是最后手段,优先考虑读写分离、索引优化、归档冷数据。
1.2 一张图看懂决策顺序
数据库扛不住了
│
↓
① SQL 和索引优化了吗?
│ 没有 → 先优化 SQL 和索引
│
↓ 优化了
② 读写分离做了吗?
│ 没有 → 先做读写分离,读压力分摊
│
↓ 做了
③ 冷数据归档了吗?
│ 没有 → 先归档历史数据,减少单表体积
│
↓ 归档了
④ 还是扛不住?
│ 是 → 分库分表
二、分片策略:垂直 vs 水平
2.1 两种拆分方式
| 维度 | 垂直拆分 | 水平拆分 |
|---|---|---|
| 拆分对象 | 按业务/字段拆 | 按数据行拆 |
| 典型场景 | 用户库、订单库、商品库分离 | 订单表拆成 orders_0~orders_9 |
| 优点 | 业务解耦、专库专用 | 单表数据量可控 |
| 缺点 | 跨库 JOIN 困难 | 分片键选择困难 |
| 复杂度 | 低 | 高 |
2.2 垂直拆分:按业务分库
拆分前:
shop 库(所有表在一起)
拆分后:
shop_user 库 → user, address
shop_order 库 → orders, order_items, payment
shop_product 库 → product, category
垂直拆分原则:
① 按业务关联度拆:强关联的表放一起
② 按访问频率拆:高频表和低频表分离
③ 按数据量拆:大表独立
④ 避免跨库 JOIN:能冗余就冗余
2.3 水平拆分:按数据行分表
拆分前:
orders 表(5000万行)
拆分后:
orders_0 表(500万行)
orders_1 表(500万行)
...
orders_9 表(500万行)
分片键选择:
| 分片键 | 优点 | 缺点 |
|---|---|---|
| user_id | 同一用户订单集中 | 商家维度查询跨片 |
| order_id | 分布均匀 | 用户维度查询跨片 |
| create_time | 便于归档 | 热点数据集中 |
| 基因法 | 兼顾多维度 | 实现复杂 |
基因法示例:订单 ID 中嵌入 user_id 的后几位,使 order_id 和 user_id 都能定位到同一分片。
三、分片算法
3.1 常用分片算法对比
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Range | 按范围分(id 1-100万 → 分片0) | 扩容方便 | 热点集中 |
| Hash | hash(key) % N | 分布均匀 | 扩容需 rehash |
| 一致性 Hash | 环上取模 | 扩容影响小 | 实现复杂 |
| 雪花算法 | 生成全局唯一 ID | 趋势递增 | 依赖时钟 |
3.2 Range 分片
sql
-- 按 order_id 范围分片
orders_0: order_id 1 ~ 10000000
orders_1: order_id 10000001 ~ 20000000
orders_2: order_id 20000001 ~ 30000000
适用 :订单、日志等有时间顺序的数据。
问题:新数据集中写入最后一个分片,造成热点。
3.3 Hash 分片
sql
-- 按 user_id 取模分片
分片号 = user_id % 10
适用 :用户维度查询为主的场景。
问题:扩容时需要数据迁移(10 片扩到 20 片,几乎所有数据都要动)。
3.4 雪花算法:全局唯一 ID
Snowflake ID 结构(64 位)
│
├── 1 位符号位(固定 0)
├── 41 位时间戳(毫秒级,可用 69 年)
├── 10 位机器 ID(1024 台机器)
└── 12 位序列号(每毫秒 4096 个 ID)
特点:
① 全局唯一
② 趋势递增(利于 B+ 树索引)
③ 本地生成,无网络开销
④ 每秒可生成 400 万+ ID
为什么不用自增 ID:
单库自增:分库后 ID 会冲突
UUID:无序,作为主键会导致 B+ 树频繁分裂
雪花算法:有序 + 唯一 + 高性能
四、分库分表中间件
4.1 两种技术路线
| 路线 | 代表 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| 客户端分片 | ShardingSphere-JDBC | 应用层计算路由 | 性能好、无额外部署 | 应用侵入、多语言支持差 |
| 代理分片 | ShardingSphere-Proxy、MyCat | 独立代理层 | 应用透明、多语言 | 多一跳、代理需高可用 |
4.2 ShardingSphere-JDBC 配置示例
yaml
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://127.0.0.1:3306/shop_0
username: root
password: root123
ds1:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://127.0.0.1:3306/shop_1
username: root
password: root123
rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..1}.orders_$->{0..1}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: orders-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
orders-inline:
type: INLINE
props:
algorithm-expression: orders_$->{user_id % 2}
4.3 分片后的 SQL 限制
分库分表后不能用的 SQL:
✗ 跨库 JOIN
✗ 跨库事务
✗ 不包含分片键的查询(会广播到所有分片)
✗ 跨库 ORDER BY + LIMIT
✗ 跨库 COUNT(*)、GROUP BY
✗ 跨库 UPDATE/DELETE
解决方案:
| 问题 | 方案 |
|---|---|
| 跨库 JOIN | 冗余字段、宽表、ES 检索 |
| 跨库分页 | 各分片查 N 条,内存归并 |
| 跨库 COUNT | 各分片求和、维护计数表 |
| 跨库事务 | 分布式事务(见第六节) |
五、分库分表实战:订单表拆分
5.1 需求分析
订单表 orders
├── 当前数据量:5000 万行
├── 日增:50 万行
├── 查询模式:
│ ├── 用户查自己的订单(user_id 维度)80%
│ ├── 商家查店铺订单(shop_id 维度)15%
│ └── 运营查全量订单(跨维度)5%
└── 目标:拆成 4 库 × 4 表 = 16 片
5.2 分片键选择
主分片键:user_id(覆盖 80% 查询)
基因法:将 shop_id 后 4 位嵌入 order_id
→ 商家查询时,通过 shop_id 反推分片
5.3 建表 SQL
sql
-- 在每个分片库中执行
CREATE TABLE orders_0 (
order_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
shop_id BIGINT NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (order_id),
KEY idx_user_created (user_id, created_at),
KEY idx_shop_created (shop_id, created_at)
) ENGINE=InnoDB;
-- orders_1 ~ orders_15 结构相同
5.4 路由计算
java
// 用户维度查询
int dbIndex = userId % 4;
int tableIndex = userId % 4;
// 商家维度查询(基因法)
long gene = shopId & 0xF; // 取后4位
int shopDbIndex = (int) (gene % 4);
int shopTableIndex = (int) (gene % 4);
5.5 跨分片分页查询
sql
-- 需求:查询第 100 页,每页 20 条
-- 错误做法:每个分片查 LIMIT 2000, 20 → 结果不对
-- 正确做法:每个分片查 LIMIT 0, 2020,内存归并后取 2000-2020
-- 第1步:各分片查询
SELECT * FROM orders_0 ORDER BY created_at DESC LIMIT 2020;
SELECT * FROM orders_1 ORDER BY created_at DESC LIMIT 2020;
-- ... 所有分片
-- 第2步:内存归并排序,取第 2000-2020 条
优化:使用游标分页(记录上一页最后一条的 created_at + order_id),避免深度分页。
六、分布式事务
6.1 为什么需要分布式事务
场景:用户下单
① 订单库:创建订单
② 库存库:扣减库存
③ 账户库:扣减余额
问题:步骤 ② 失败,步骤 ① 已提交,数据不一致
6.2 分布式事务方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 差 | 中 | 传统金融 |
| TCC | 强一致 | 中 | 高 | 支付、交易 |
| 本地消息表 | 最终一致 | 好 | 中 | 订单、通知 |
| 事务消息 | 最终一致 | 好 | 中 | 异步解耦 |
| Seata AT | 强一致 | 中 | 低 | 通用场景 |
| Saga | 最终一致 | 好 | 中 | 长流程 |
6.3 Seata AT 模式实战
核心角色:
Seata 三大角色
│
├── TC (Transaction Coordinator) ← 事务协调器,独立部署
├── TM (Transaction Manager) ← 事务管理器,发起方
└── RM (Resource Manager) ← 资源管理器,各数据库
AT 模式原理:
① 业务 SQL 执行前,记录 before image
② 执行业务 SQL
③ 执行后,记录 after image
④ 注册分支事务到 TC
⑤ 全局提交 → 删除 undo log
⑥ 全局回滚 → 用 undo log 反向补偿
代码示例:
java
@GlobalTransactional
public void createOrder(OrderDTO order) {
// ① 订单库:创建订单
orderMapper.insert(order);
// ② 库存库:扣减库存
inventoryService.deduct(order.getProductId(), order.getQuantity());
// ③ 账户库:扣减余额
accountService.deduct(order.getUserId(), order.getTotalAmount());
}
6.4 本地消息表:最终一致方案
本地消息表方案
│
├── ① 业务库:订单创建 + 消息写入(同一本地事务)
├── ② 定时任务:扫描未发送消息
├── ③ 发送消息到 MQ
├── ④ 下游消费消息,执行库存扣减
└── ⑤ 消费成功,更新消息状态
建表 SQL:
sql
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50) NOT NULL,
biz_id BIGINT NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 2已消费
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_status_created (status, created_at)
);
七、扩容方案
7.1 为什么要考虑扩容
初始:4 库 × 4 表 = 16 片
增长:数据量翻倍,需要扩到 8 库 × 8 表 = 64 片
问题:hash(user_id) % 4 变成 % 8,几乎所有数据都要迁移
7.2 扩容方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 停机扩容 | 停服迁移 | 简单 | 业务中断 |
| 双写迁移 | 新旧库同时写 | 平滑 | 实现复杂 |
| 一致性 Hash | 环上迁移 | 影响小 | 实现复杂 |
| 倍增扩容 | 4→8,只迁一半 | 迁移量小 | 需规划 |
7.3 双写迁移实战
双写迁移步骤
│
├── ① 新库建好,结构一致
├── ② 应用双写:同时写旧库和新库
├── ③ 数据同步:将旧库存量数据迁移到新库
├── ④ 数据校验:对比新旧库数据一致性
├── ⑤ 读切换:读流量逐步切到新库
├── ⑥ 停写旧库:确认无误后,停止双写
└── ⑦ 下线旧库
八、实战任务
任务清单
- 设计订单表的分片方案(分片键 + 算法)
- 搭建 ShardingSphere-JDBC 环境
- 实现订单表的水平分片
- 实现跨分片分页查询
- 用 Seata 实现下单分布式事务
- 用本地消息表实现最终一致
- 模拟扩容:4 库扩到 8 库
自检问题
- 什么时候需要分库分表?决策顺序是什么?
- 垂直拆分和水平拆分的区别?
- 常用分片算法有哪些?各自的优缺点?
- 雪花算法的结构是什么?为什么不用自增 ID?
- 分库分表后哪些 SQL 不能用?怎么解决?
- 分布式事务有哪些方案?各自适用什么场景?
- Seata AT 模式的原理是什么?
- 扩容有哪些方案?双写迁移的步骤?
九、本篇小结
第10篇 核心收获
│
├── 分片决策
│ ├── 先优化 SQL 和索引
│ ├── 再读写分离
│ ├── 再冷数据归档
│ └── 最后才分库分表
│
├── 分片方式
│ ├── 垂直拆分:按业务分库
│ └── 水平拆分:按数据行分表
│
├── 分片算法
│ ├── Range:范围分片,扩容方便,热点集中
│ ├── Hash:分布均匀,扩容需 rehash
│ └── 雪花算法:全局唯一 ID,趋势递增
│
├── 中间件
│ ├── ShardingSphere-JDBC:客户端分片
│ └── ShardingSphere-Proxy:代理分片
│
├── 分片后限制
│ ├── 跨库 JOIN → 冗余字段/宽表
│ ├── 跨库分页 → 内存归并
│ ├── 跨库 COUNT → 分片求和
│ └── 跨库事务 → 分布式事务
│
├── 分布式事务
│ ├── 2PC/XA:强一致,性能差
│ ├── TCC:强一致,复杂度高
│ ├── Seata AT:强一致,通用
│ ├── 本地消息表:最终一致
│ └── 事务消息:最终一致
│
└── 扩容方案
├── 双写迁移:平滑扩容
├── 一致性 Hash:影响小
└── 倍增扩容:迁移量小
下一篇:第11篇《Redis 缓存与 MySQL 一致性实战》