Skip to content

分库分表深度解析

面试官:你们项目有分库分表吗?是怎么做的?

:有,订单表按 user_id 取模分了 4 个库、每个库 16 张表(共 64 张分表),使用 ShardingSphere 做路由,雪花算法生成分布式主键。

面试官:为什么按 user_id 分而不是按 order_id 分?按 order_id 分不是也能路由吗?

这个追问把很多人问住了。能说清「分表键选择背后的查询模式」的候选人,才能真正脱颖而出。


链式追问一:拆分策略与分表键选择

Section titled “链式追问一:拆分策略与分表键选择”

Q1:垂直拆分和水平拆分的区别?什么时候该拆?必考

Section titled “Q1:垂直拆分和水平拆分的区别?什么时候该拆?”

垂直拆分(按列/业务拆)

垂直分表:将大表的列拆分
原始 user 表:
id, name, avatar, phone, address, bio, preferences, created_at...
拆分后:
user(热字段):id, name, avatar, phone
user_detail(冷字段):id, address, bio, preferences
→ 热数据和冷数据分离,减少 IO(热点行更容易进入 Buffer Pool)
→ 单行数据更小,一页存更多行,查询更快
垂直分库:按业务模块拆分数据库
用户库(user_db)→ 用户相关表
订单库(order_db)→ 订单相关表
商品库(product_db)→ 商品相关表
→ 按领域划分,与微服务对应
→ 故障隔离:订单库挂了不影响用户登录

水平拆分(按行拆)

水平分表:同一张表按某个维度分多张表
orders → orders_0, orders_1, ..., orders_15
规则:user_id % 16 → 决定存哪张表
→ 单表数据量减少,索引树高度降低,查询更快
水平分库:数据分散到多个库
db_0, db_1, db_2, db_3
每个库再分 orders_0 ~ orders_15(共 64 张表)
→ 单库连接数瓶颈解决(MySQL 默认 max_connections=151)
→ IO 并发能力提升(多个库的磁盘并发读写)

何时拆?拆分标准

指标单库单表阈值超过阈值影响
单表行数1000 万行B+树 3 层→4 层,IO 次数增加,查询变慢
单表数据量50GB索引占用内存大,Buffer Pool 命中率下降
单库 QPS2000-3000MySQL 单库处理能力上限(取决于硬件)
单库连接数2000+连接池压力大,可能耗尽资源

Q2:分表键(Sharding Key)如何选择?按 user_id 还是 order_id?必考

Section titled “Q2:分表键(Sharding Key)如何选择?按 user_id 还是 order_id?”

选择原则

1. 高频查询的过滤条件:用查询最多的字段作为分表键,确保路由到单一分片
2. 数据均匀分布:分表键的值分布均匀,避免热点
❌ 按 status 分(只有 3 种状态,不均匀)
❌ 按地区分(一线城市数据量大,四线城市少,不均匀)
✅ 按 user_id / order_id 分(分布均匀)
3. 业务聚合:同一业务实体的数据存在同一分片
用户的所有订单、订单项、物流信息在同一分片 → 避免 JOIN 跨库

订单表用 user_id 还是 order_id 分?对比分析

维度按 order_id 分按 user_id 分
按 order_id 查询✅ O(1) 精准路由❌ 需要先解析 user_id
按 user_id 查询❌ 全分片广播(性能差)✅ O(1) 精准路由
用户订单列表❌ 全分片查询 + 内存排序✅ 单分片查询 + 索引排序
数据均匀性✅ 均匀(雪花算法随机)✅ 均匀(用户分布均匀)
典型场景物流查询(按订单号)用户中心(查我的订单)

最佳实践根据查询模式选择分表键

电商订单场景:
核心查询:「查我的订单」→ 按 user_id 分
次要查询:「按订单号查」→ 从 order_id 中解析 user_id
物流场景:
核心查询:「按订单号查物流」→ 按 order_id 分
次要查询:「查用户物流」→ 接受全分片查询

进阶方案:将 user_id 编码进 order_id(雪花算法变种):

// 自定义雪花算法,将 user_id % 64 编码在 order_id 中
// order_id 结构:时间戳(41位) | 机器ID(10位) | user分片(6位) | 序列号(6位)
public long generateOrderId(long userId) {
long timestamp = System.currentTimeMillis() - EPOCH;
long machineId = getMachineId(); // 10位机器ID
long shardId = userId % 64; // 6位分片ID(user_id % 64)
long sequence = getNextSequence(); // 6位序列号
return (timestamp << 22) | (machineId << 12) | (shardId << 6) | sequence;
}
// 路由时从 orderId 中提取 shardId
public int getShardId(long orderId) {
return (int)((orderId >> 6) & 0x3F); // 提取第6~11位
}
// 按 order_id 查询时,无需知道 user_id,直接路由
SELECT * FROM orders WHERE order_id = 1234567890123456;
// → ShardingSphere 提取分片信息 → 定位到 db_2.orders_11

本质一句话:分表键选择的核心是「让最高频的查询能精准路由到单一分片」。


Q3:分库分表后如何生成全局唯一 ID?雪花算法原理?必考

Section titled “Q3:分库分表后如何生成全局唯一 ID?雪花算法原理?”

不能再用数据库自增 ID!多个分库的自增 ID 会重复。

方案对比

方案原理优点缺点性能
UUID128位随机数全局唯一;无依赖无序(影响B+树性能);36字符太长;无业务含义生成快,插入慢
数据库号段预分配ID段(1~1000)数据库生成;有业务含义强依赖DB;单点风险;DB挂了无法生成依赖DB性能
Redis INCRRedis原子自增趋势递增;简单依赖Redis;重启可能重复;单点风险5-10万 QPS
雪花算法时间戳+机器ID+序列号趋势递增;高性能;无依赖依赖时钟;时钟回拨有风险400万+ ID/s
百度UidGenerator改进雪花算法解决时钟回拨;高性能实现复杂600万+ ID/s

雪花算法(Snowflake)结构

64位 long 型整数:
┌──────────────────────────────────────────────────────────────┐
│ 0 │ 41位时间戳(ms) │ 10位机器ID │ 12位序列号 │
└──────────────────────────────────────────────────────────────┘
│ │ │ └─ 同一毫秒内 4096 个ID
│ │ └──────────────── 1024 台机器
│ └───────────────────────────────────── 约 69 年
└─────────────────────────────────────────────── 符号位(始终 0)
示例:
时间戳:2024-01-01 12:00:00.000 → 1704081600000 - 1288834974657 = 415246625343
机器ID:1
序列号:0
生成的 ID:415246625343 << 22 | 1 << 12 | 0 = 17365232559104

雪花算法的时钟回拨问题

问题场景:
系统时间被 NTP 同步回调(如从 12:00:01 调到 11:59:59)
→ 可能生成与之前重复的 ID
解决方案:
1. 检测到时钟回拨 → 抛出异常(简单但影响可用性)
if (currentTime < lastTime) throw new ClockBackwardsException();
2. 等待时钟追上(回拨时间短时可行)
if (currentTime < lastTime) {
Thread.sleep(lastTime - currentTime);
}
3. 使用扩展位存回拨次数(百度 UidGenerator 方案)
// 将机器ID从10位压缩到8位,空出2位存回拨次数
// 最多容忍 3 次回拨

ShardingSphere 配置雪花算法

shardingsphere:
rules:
sharding:
tables:
orders:
actual-data-nodes: db_${0..3}.orders_${0..15}
# 分布式主键配置
key-generate-strategy:
column: id # 哪个列生成分布式主键
key-generator-name: snowflake # 使用雪花算法
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: ${WORKER_ID:1} # 机器 ID(每个实例不同!)
max-tolerate-time-difference-milliseconds: 10 # 最大容忍时钟回拨 10ms

链式追问三:跨分片查询与性能问题

Section titled “链式追问三:跨分片查询与性能问题”

Q4:跨分片查询如何处理?ORDER BY、GROUP BY、分页怎么办?高频

Section titled “Q4:跨分片查询如何处理?ORDER BY、GROUP BY、分页怎么办?”

跨分片查询(全分片广播)

查询「所有未支付订单」(不含 user_id 条件):
SELECT * FROM orders WHERE status='PENDING' LIMIT 100
ShardingSphere 处理流程:
1. 解析 SQL:发现没有分表键(user_id)
2. 路由决策:广播到所有 64 个分片
3. 并行执行:每个分片执行 SELECT ... WHERE status='PENDING' LIMIT 100
4. 结果归并:合并 64 个结果集(可能 64×100=6400 行)
5. 返回应用:返回 100 行
性能对比:
单表查询:1 次查询,扫描 100 行
全分片查询:64 次查询,扫描 6400 行,性能 = 单表 / 10 ~ 单表 / 100

跨分片 ORDER BY + LIMIT(深分页问题)

-- 查询全局按时间排序的第 10000 页
SELECT * FROM orders ORDER BY create_time DESC LIMIT 10 OFFSET 100000
ShardingSphere 的处理:
1. 向每个分片发送:
SELECT * FROM orders ORDER BY create_time DESC LIMIT 100010
-- 注意:OFFSET + LIMIT,每个分片都要查 100010 条!
2. 每个分片返回 100010 条
3. ShardingSphere 内存合并 64 × 100010 = 6,400,640 条记录
4. 全局排序后,取第 100001~100010 条
内存暴涨:
单表:只查 100010 条
64 分片:内存中 640 万条!
→ 深分页时内存暴涨,可能 OOM

解决方案

方案一:禁止深分页(推荐)

业务层限制:
- 最多查看前 100 页(大多数用户不会翻 100 页)
- 搜索场景用 Elasticsearch,不用 MySQL 分页

方案二:游标分页(基于最后一条记录的 ID)

-- 记住上一页最后一条记录的 create_time 和 id
-- 下一页:
SELECT * FROM orders
WHERE create_time < '2024-01-01 12:00:00'
OR (create_time = '2024-01-01 12:00:00' AND id < 123456)
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 性能:
-- 每个分片只需扫描 create_time < '...' 的数据
-- 避免 OFFSET 扫描大量数据

方案三:按分表键分页(最优)

-- 查询某个用户的订单(带分表键 user_id)
SELECT * FROM orders
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 10 OFFSET 10000;
-- 路由到单一分片,性能等同于单表

跨分片 GROUP BY

-- 统计每个状态的订单数
SELECT status, COUNT(*) FROM orders GROUP BY status;
ShardingSphere 处理:
1. 向每个分片发送:SELECT status, COUNT(*) FROM orders GROUP BY status
2. 每个分片返回聚合结果:
分片0: PENDING(1000), PAID(2000), SHIPPED(1500)
分片1: PENDING(1200), PAID(1800), SHIPPED(1600)
...
3. ShardingSphere 合并:
PENDING = 1000 + 1200 + ... = 15000
PAID = 2000 + 1800 + ... = 25000
...
性能:比单表慢,但可接受(传输的是聚合结果,数据量小)

Q5:分库分表后的分布式事务如何处理?实战

Section titled “Q5:分库分表后的分布式事务如何处理?”

分库后,一个操作可能跨多个数据库,ACID 事务失效。

场景示例

// 用户下单:扣减库存 + 创建订单
@Transactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 1. 扣减库存(商品库)
productMapper.decreaseStock(productId, quantity); // → db_product
// 2. 创建订单(订单库)
orderMapper.insertOrder(userId, productId, quantity); // → db_order
// 问题:两个操作在不同数据库,@Transactional 无法保证原子性
// 如果订单创建失败,库存已扣减 → 数据不一致
}

分布式事务方案对比

方案原理优点缺点适用场景
Seata AT自动补偿,无业务侵入接入简单;业务代码无感知性能差(全局锁);有脏读风险对性能要求不高;快速接入
Seata TCCTry/Confirm/Cancel强一致性;性能好业务侵入强(需写3个方法)金融场景;强一致性
消息事务本地事务 + 消息最终一致高性能;解耦接受短暂不一致;实现复杂异步场景;最终一致性
避免跨库事务业务重新设计最简单;性能最好需要业务妥协最优解,设计时考虑

最佳实践:避免跨库事务

// 方案:将同一事务内的操作,路由到同一个分库
// 设计:订单表和库存表都按 user_id 分库(而非按各自主键)
// 用户下单时,订单和库存在同一分库 → 本地事务即可
@Transactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 订单表按 user_id 分库
orderMapper.insertOrder(userId, productId, quantity); // → db_order_user123
// 库存表也按 user_id 分库(冗余存储,用空间换一致性)
productMapper.decreaseStock(userId, productId, quantity); // → db_order_user123
// 两个操作在同一数据库 → 本地事务保证一致性
}

消息事务方案(最终一致性)

// 场景:下单后给用户发积分(允许异步)
@Transactional
public void placeOrder(Long userId, Long productId, int quantity) {
// 1. 创建订单(本地事务)
Order order = orderMapper.insertOrder(userId, productId, quantity);
// 2. 发送消息到 MQ(事务消息)
// RocketMQ 事务消息:确保本地事务和消息发送的原子性
rocketMQTemplate.sendMessageInTransaction(
"order-topic",
MessageBuilder.withPayload(order).build(),
null
);
}
// 消费者:监听订单消息,增加积分
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "points-group")
public class PointsConsumer implements RocketMQListener<Order> {
@Override
public void onMessage(Order order) {
// 增加积分(重试直到成功)
pointsService.addPoints(order.getUserId(), order.getAmount() / 10);
}
}

本质一句话:分布式事务的最佳方案是「设计时避免跨库」,其次是「接受最终一致性(消息事务)」。


Q6:如何不停机迁移到分库分表?实战

Section titled “Q6:如何不停机迁移到分库分表?”

经典方案:双写 + 数据同步

阶段一:双写(新旧库同时写)
应用 → 写旧库(主流程)
→ 写新库(异步,允许失败)
→ 数据不一致(可接受)
阶段二:历史数据同步
旧库 → 数据迁移工具(DataX/Canal)→ 新库
按时间分批迁移(如按月份)
阶段三:数据校验
对比新旧库数据(抽样或全量)
修复不一致数据
阶段四:切读(灰度)
1% 流量读新库 → 观察 → 10% → 50% → 100%
阶段五:停双写,下线旧库
只写新库 → 旧库归档

双写代码示例

@Service
public class OrderService {
@Autowired
private OrderMapper oldOrderMapper; // 旧库
@Autowired
private ShardingOrderMapper newOrderMapper; // 新分库分表
@Transactional
public void placeOrder(Order order) {
// 1. 写旧库(主流程,失败抛异常)
oldOrderMapper.insert(order);
// 2. 写新库(异步,允许失败)
CompletableFuture.runAsync(() -> {
try {
newOrderMapper.insert(order);
} catch (Exception e) {
log.error("写入新库失败", e);
// 记录日志,后续补偿
}
});
}
}

扩容方案:从 4 库扩到 8 库

问题:扩容后路由规则变化,数据需要迁移
方案一:停机迁移
- 停服 → 迁移数据 → 改路由规则 → 启动
- 简单但停服时间长
方案二:在线迁移(推荐)
1. 新增 4 个库(db_4, db_5, db_6, db_7)
2. 双写:写旧库 + 写新库
3. 数据迁移:将需要迁移的数据从旧库复制到新库
user_id % 8 = 4,5,6,7 的数据迁移到新库
4. 切读:逐步切换读流量到新库
5. 停旧库写:只写新库
6. 下线旧库数据(保留旧库,只清空已迁移数据)