分库分表深度解析
面试官:你们项目有分库分表吗?是怎么做的?
你:有,订单表按 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 命中率下降 |
| 单库 QPS | 2000-3000 | MySQL 单库处理能力上限(取决于硬件) |
| 单库连接数 | 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 中提取 shardIdpublic 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本质一句话:分表键选择的核心是「让最高频的查询能精准路由到单一分片」。
链式追问二:分布式主键生成
Section titled “链式追问二:分布式主键生成”Q3:分库分表后如何生成全局唯一 ID?雪花算法原理?必考
Section titled “Q3:分库分表后如何生成全局唯一 ID?雪花算法原理?”不能再用数据库自增 ID!多个分库的自增 ID 会重复。
方案对比:
| 方案 | 原理 | 优点 | 缺点 | 性能 |
|---|---|---|---|---|
| UUID | 128位随机数 | 全局唯一;无依赖 | 无序(影响B+树性能);36字符太长;无业务含义 | 生成快,插入慢 |
| 数据库号段 | 预分配ID段(1~1000) | 数据库生成;有业务含义 | 强依赖DB;单点风险;DB挂了无法生成 | 依赖DB性能 |
| Redis INCR | Redis原子自增 | 趋势递增;简单 | 依赖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 100000ShardingSphere 的处理: 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 ordersWHERE 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 DESCLIMIT 10;
-- 性能:-- 每个分片只需扫描 create_time < '...' 的数据-- 避免 OFFSET 扫描大量数据方案三:按分表键分页(最优)
-- 查询某个用户的订单(带分表键 user_id)SELECT * FROM ordersWHERE user_id = 123ORDER BY create_time DESCLIMIT 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 事务失效。
场景示例:
// 用户下单:扣减库存 + 创建订单@Transactionalpublic 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 TCC | Try/Confirm/Cancel | 强一致性;性能好 | 业务侵入强(需写3个方法) | 金融场景;强一致性 |
| 消息事务 | 本地事务 + 消息最终一致 | 高性能;解耦 | 接受短暂不一致;实现复杂 | 异步场景;最终一致性 |
| 避免跨库事务 | 业务重新设计 | 最简单;性能最好 | 需要业务妥协 | 最优解,设计时考虑 |
最佳实践:避免跨库事务:
// 方案:将同一事务内的操作,路由到同一个分库
// 设计:订单表和库存表都按 user_id 分库(而非按各自主键)// 用户下单时,订单和库存在同一分库 → 本地事务即可
@Transactionalpublic 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
// 两个操作在同一数据库 → 本地事务保证一致性}消息事务方案(最终一致性):
// 场景:下单后给用户发积分(允许异步)
@Transactionalpublic 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); }}本质一句话:分布式事务的最佳方案是「设计时避免跨库」,其次是「接受最终一致性(消息事务)」。
链式追问四:数据迁移与扩容
Section titled “链式追问四:数据迁移与扩容”Q6:如何不停机迁移到分库分表?实战
Section titled “Q6:如何不停机迁移到分库分表?”经典方案:双写 + 数据同步:
阶段一:双写(新旧库同时写) 应用 → 写旧库(主流程) → 写新库(异步,允许失败) → 数据不一致(可接受)
阶段二:历史数据同步 旧库 → 数据迁移工具(DataX/Canal)→ 新库 按时间分批迁移(如按月份)
阶段三:数据校验 对比新旧库数据(抽样或全量) 修复不一致数据
阶段四:切读(灰度) 1% 流量读新库 → 观察 → 10% → 50% → 100%
阶段五:停双写,下线旧库 只写新库 → 旧库归档双写代码示例:
@Servicepublic 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. 下线旧库数据(保留旧库,只清空已迁移数据)