介绍一下分库分表
为什么需要分库分表? 🤔
当 MySQL 单表数据量超过500 万行或2GB,单库 QPS 超过1000时,查询性能会急剧下降。分库分表就是为了解决以下三个核心瓶颈:
瓶颈类型 具体表现 临界点
存储瓶颈 单库磁盘空间不足,备份恢复慢 单库 > 100GB
性能瓶颈 查询、写入延迟飙升,索引失效 单表 > 500 万行
并发瓶颈 连接数耗尽,数据库锁竞争激烈 单库 QPS>1000
分库分表的两种核心方式 📊
- 垂直拆分(按业务维度)
垂直分库:将一个大库按业务模块拆分为多个独立的库,比如用户库、订单库、商品库。
垂直分表:将一个大表按字段冷热程度拆分为多个表,比如订单主表(常用字段)和订单详情表(大字段)。
image
优点:业务清晰,便于团队维护,冷热数据分离。
缺点:无法解决单表数据量过大的问题。
2. 水平拆分(按数据维度)
水平分库:将同一个表的数据按规则分散到多个库中。
水平分表:将同一个表的数据按规则分散到同一个库的多个表中。
image
优点:彻底解决单表数据量和并发问题。
缺点:引入了分布式事务、跨库关联等复杂问题。
核心分片策略 🎲
最常用的是哈希分片和范围分片,各有优劣:
分片策略 实现方式 优点 缺点 适用场景
哈希分片 库索引=user_id%库数量``表索引=user_id%表数量 数据分布均匀,热点分散 扩容麻烦,需要数据迁移 用户中心、订单系统
范围分片 按时间、ID 范围分片 扩容简单,便于按范围查询 容易产生热点数据 日志系统、历史数据
一致性哈希 虚拟节点环 扩容时只迁移少量数据 实现复杂 缓存系统、分布式存储
核心代码示例(哈希分片路由):
// 计算分片索引
public ShardingResult sharding(Long userId, int dbCount, int tableCount) {
// 先分库
long dbIndex = userId % dbCount;
// 再分表
long tableIndex = (userId / dbCount) % tableCount;
return ShardingResult.builder() .dbName("order_db_" + dbIndex) .tableName("order_" + tableIndex) .build();}
分库分表带来的问题与解决方案 ⚠️
这是面试官最关心的部分,也是分库分表的核心难点:
问题 解决方案 最佳实践
分布式事务 1. 最终一致性(MQ + 本地事务表)2. Seata AT 模式 优先使用最终一致性,强一致性场景用 Seata
跨库关联查询 1. 字段冗余2. 数据同步到 ES 做聚合查询3. 应用层多次查询 避免跨库 JOIN,用 ES 做复杂查询
分页排序 1. 业务层禁止深分页2. 用游标分页代替 offset 分页 SELECT * FROM order WHERE id > ? LIMIT 10
全局唯一 ID 1. 雪花算法(Snowflake)2. 美团 Leaf3. 数据库自增序列 优先使用雪花算法,注意时钟回拨问题
扩容问题 1. 提前规划分片数量(2 的幂次)2. 双倍扩容法 初始分 16 个库 64 个表,足够支撑百亿级数据
主流中间件对比 🛠️
中间件 架构 优点 缺点 推荐指数
Sharding-JDBC 客户端层 轻量,无代理,性能高 不支持跨库事务 ⭐⭐⭐⭐⭐
MyCat 服务端代理 支持多种数据库,功能全 性能损耗大,运维复杂 ⭐⭐⭐
Sharding-Proxy 服务端代理 透明化接入,支持多语言 有性能损耗 ⭐⭐⭐⭐
面试加分项 ✨
先优化再拆分:先做 SQL 优化、索引优化、读写分离,实在不行再分库分表
避免过度拆分:拆分越细,复杂度越高,维护成本越大
数据迁移方案:双写 + 数据校验 + 流量切换的平滑迁移方案
监控告警:对各分片的数据量、QPS、延迟进行实时监控
核心代码实现(技术亮点)💻
- 改进版雪花算法(解决时钟回拨问题)✅
技术亮点:增加时钟回拨检测与等待机制,支持自定义机器 ID,生成 64 位有序唯一 ID
public class SnowflakeIdGenerator {
// 起始时间戳 (2020-01-01)
private static final long START_TIMESTAMP = 1577808000000L;
// 各部分位数 private static final long WORKER_ID_BITS = 5L; private static final long DATA_CENTER_ID_BITS = 5L; private static final long SEQUENCE_BITS = 12L; // 最大值 private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS); // 偏移量 private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; private final long workerId; private final long dataCenterId; private long sequence = 0L; private long lastTimestamp = -1L; public SnowflakeIdGenerator(long workerId, long dataCenterId) { if (workerId > MAX_WORKER_ID || workerId < 0) { throw new IllegalArgumentException("Worker ID超出范围"); } if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) { throw new IllegalArgumentException("DataCenter ID超出范围"); } this.workerId = workerId; this.dataCenterId = dataCenterId; } public synchronized long nextId() { long currentTimestamp = System.currentTimeMillis(); // 技术亮点:时钟回拨处理 if (currentTimestamp < lastTimestamp) { long offset = lastTimestamp - currentTimestamp; // 回拨小于5ms,等待 if (offset <= 5) { try { wait(offset << 1); currentTimestamp = System.currentTimeMillis(); if (currentTimestamp < lastTimestamp) { throw new RuntimeException("时钟回拨严重,无法生成ID"); } } catch (InterruptedException e) { throw new RuntimeException("线程中断", e); } } else { throw new RuntimeException("时钟回拨超过5ms,拒绝生成ID"); } } // 同一毫秒内,序列号自增 if (currentTimestamp == lastTimestamp) { sequence = (sequence + 1) & ~(-1L << SEQUENCE_BITS); // 序列号溢出,等待下一毫秒 if (sequence == 0) { currentTimestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = currentTimestamp; // 拼接ID return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT) | (dataCenterId << DATA_CENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } private long waitNextMillis(long lastTimestamp) { long timestamp = System.currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = System.currentTimeMillis(); } return timestamp; }}
2. 双写数据迁移核心逻辑(平滑无感知)✅
技术亮点:状态机控制迁移流程,保证数据一致性,支持回滚
@Service
public class DataMigrationService {
// 迁移状态枚举
public enum MigrationStatus {
INIT, // 初始状态
DOUBLE_WRITE, // 双写阶段
DATA_CHECK, // 数据校验阶段
SWITCH_READ, // 切换读流量
FINISH // 迁移完成
}
@Autowired private OldOrderMapper oldOrderMapper; @Autowired private NewOrderMapper newOrderMapper; @Autowired private RedisTemplate<String, String> redisTemplate; // 技术亮点:根据状态自动路由写入 public void createOrder(Order order) { MigrationStatus status = getMigrationStatus(); // 先写旧库 oldOrderMapper.insert(order); // 双写阶段:再写新库 if (status == MigrationStatus.DOUBLE_WRITE || status == MigrationStatus.DATA_CHECK || status == MigrationStatus.SWITCH_READ) { try { newOrderMapper.insert(order); } catch (Exception e) { // 记录失败日志,后续补偿 log.error("双写新库失败,orderId:{}", order.getId(), e); compensateWrite(order); } } } // 技术亮点:根据状态自动路由查询 public Order getOrderById(Long orderId) { MigrationStatus status = getMigrationStatus(); if (status == MigrationStatus.SWITCH_READ || status == MigrationStatus.FINISH) { return newOrderMapper.selectById(orderId); } else { return oldOrderMapper.selectById(orderId); } } // 数据校验:对比新旧库数据一致性 public boolean checkDataConsistency(Long startId, Long endId) { List<Order> oldOrders = oldOrderMapper.selectByIdRange(startId, endId); List<Order> newOrders = newOrderMapper.selectByIdRange(startId, endId); // 技术亮点:用MD5对比数据内容,避免逐字段比较 Map<Long, String> oldOrderMap = oldOrders.stream() .collect(Collectors.toMap(Order::getId, this::calculateOrderMd5)); Map<Long, String> newOrderMap = newOrders.stream() .collect(Collectors.toMap(Order::getId, this::calculateOrderMd5)); return oldOrderMap.equals(newOrderMap); } private String calculateOrderMd5(Order order) { // 将订单所有字段拼接后计算MD5 String content = JSON.toJSONString(order); return DigestUtils.md5Hex(content); } private MigrationStatus getMigrationStatus() { String status = redisTemplate.opsForValue().get("migration:status"); return status == null ? MigrationStatus.INIT : MigrationStatus.valueOf(status); }}
3. 游标分页通用实现(解决深分页问题)✅
技术亮点:避免 offset 深分页性能问题,支持任意字段排序
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
// 技术亮点:游标分页,性能稳定,不受数据量影响 public PageResult<Order> queryOrdersByCursor(Long lastId, int pageSize, String status) { // 查询条件:id > lastId(游标) List<Order> orders = orderMapper.selectByCursor(lastId, pageSize, status); // 计算是否有下一页 boolean hasNext = orders.size() == pageSize; // 获取下一页的游标 Long nextCursor = hasNext ? orders.get(orders.size() - 1).getId() : null; return PageResult.<Order>builder() .data(orders) .hasNext(hasNext) .nextCursor(nextCursor) .build(); }}
// 对应的SQL
@Select("SELECT * FROM order_${tableIndex} " +
"WHERE id > #{lastId} AND status = #{status} " +
“ORDER BY id ASC LIMIT #{pageSize}”)
List selectByCursor(@Param(“lastId”) Long lastId,
@Param(“pageSize”) int pageSize,
@Param(“status”) String status);
核心技术难点深度解析 ⚠️
这是面试官最关心的部分,也是分库分表的核心价值所在:
技术难点 问题本质 常见坑 解决方案 最佳实践
全局唯一 ID 生成 分布式环境下无法使用数据库自增 ID 时钟回拨导致 ID 重复;机器 ID 冲突 1. 雪花算法(推荐)2. 美团 Leaf3. 数据库号段模式 优先使用改进版雪花算法;提前规划机器 ID 分配;监控时钟偏移
数据平滑迁移 业务不能停服,数据不能丢失 双写顺序错误导致数据不一致;数据校验不全面 1. 双写 + 数据校验 + 流量切换2. 全量迁移 + 增量同步 严格按照 “双写→校验→切读→停写” 四步走;灰度切换流量;保留回滚能力
分布式事务 跨库操作无法保证 ACID 强一致性方案性能差;最终一致性数据不一致 1. 最终一致性(MQ + 本地事务表)2. Seata AT 模式3. TCC 模式 90% 场景用最终一致性;强一致性场景用 Seata AT;避免使用 TCC
跨库关联查询 无法执行跨库 JOIN 操作 应用层多次查询导致 N+1 问题;数据冗余导致不一致 1. 字段冗余2. 数据同步到 ES 做聚合3. 应用层组装 绝对禁止跨库 JOIN;用 ES 做复杂查询和统计;冗余字段只保留不变的
分页排序 深分页 offset 过大导致全表扫描 传统分页在大数据量下超时;跨库排序不准确 1. 游标分页(推荐)2. 禁止深分页3. 业务层限制分页深度 所有列表接口必须用游标分页;前端禁止跳页;最大分页深度不超过 100 页
热点数据问题 数据倾斜导致部分分片压力过大 某个用户 / 商家订单量占比超过 50% 1. 热点数据单独分片2. 读写分离3. 缓存热点数据 提前识别热点数据;对超级用户做特殊处理;热点数据优先走缓存
扩容问题 分片数量不足需要扩容 数据重分布导致业务中断;扩容复杂度高 1. 提前规划分片数量(2 的幂次)2. 双倍扩容法3. 一致性哈希 初始分 16 个库 64 个表;用双倍扩容法减少数据迁移量;扩容前做好备份
分布式主键查询 非分片键查询需要遍历所有分片 非分片键查询性能差;全表扫描 1. 建立映射表2. 数据同步到 ES3. 业务层避免非分片键查询 所有查询必须带分片键;非分片键查询走 ES;映射表只存主键和分片键
真实现场模拟面试
真实现场模拟面试
面试官 😊:
看你简历里写了海量数据优化经验,那咱先聊个场景:假设现在有个电商订单表,数据量干到 2 亿了,查询越来越慢,数据库连接池动不动就满了。你会怎么办?
候选人 🧑💻:
这个问题其实很典型。单表到 2 亿,光加索引、上缓存已经很难压住了。因为 InnoDB 的 B+ 树可能已经 4~5 层高,磁盘 IO 剧增,再加上主从延迟,很容易拖垮整个库。所以这种量级,我肯定会考虑分库分表。
简单说,就是把原本一个库一张表的数据,按一定规则拆分到多个库多张表里,把存储和访问压力分散开。
面试官 👍:
嗯,方向对。那具体怎么拆?你能说说你理解的拆分策略吗?
候选人 🗂️:
好,我画个图可能更清楚点:
image
核心就两大维度:
垂直拆分:按业务模块拆库(用户库、订单库),或按字段冷热拆表(把大字段拆到扩展表)。
水平拆分:按数据行拆分,比如把订单表按用户 ID 拆成 16 个库,每个库 16 张表。
生产上一般先垂直拆分,再把核心业务的大表做水平拆分,组合着来。
面试官 👏:
思路很清晰。那水平拆分时,最关键的是什么?分片键和路由算法怎么选?
候选人 🎯:
最关键的是分片键,后续所有查询都得带着它,否则就得全分片扫描,直接禁用。
常见的路由算法我对比一下:
算法 特点 典型用法
哈希取模 分布均匀,但扩容迁移痛苦 db_idx = user_id % 4
一致性哈希 扩容平滑,可用虚拟节点避免数据倾斜 环形哈希空间
范围路由 易归档,但易产生写热点 order_db_2025
前期业务量不大时哈希取模够用,但后期扩容一般会配合双写 + 历史数据迁移,或者直接用像 ShardingSphere 这种支持在线扩缩容的中间件。
面试官 🤔:
分完表之后,原来单库的主键自增不能用了,分布式 ID 你怎么设计?
候选人 🔑:
对,这是个经典坑。我会这么选:
雪花算法:趋势递增,纯内存生成,性能高,但依赖机器时钟,可能回拨。
号段模式:比如美团 Leaf,从 DB 批量拉号段,解决时钟回拨,且能支撑更高并发。
Redis 自增:简单但持久化有风险,一般不作为主方案。
目前我们用雪花算法比较多,做好时钟回拨兜底(比如记录上一次时间戳,拒绝回拨并稍等重试)。
面试官 ⚠️:
继续深挖,分完后如果有个查询要关联用户表查用户名,怎么办?跨库 JOIN 怎么处理?
候选人 🚫:
原则就一条:严禁跨库 JOIN。我的解法:
数据异构:通过 Canal 监听 binlog,把订单和用户数据异构到 Elasticsearch 构建宽表,查询走 ES。
应用层拼接:先查用户服务拿到 user_id 列表,再带 id 去订单分片查,但要注意 in 元素不要太多。
全局字典表:变动极少的基础数据,可以在每个分片冗余一份。
面试官 📄:
那分页呢?比如订单列表按时间倒序,还要翻页,分片后怎么处理?
候选人 📑:
分片下每个表都是局部有序,全局分页就变成“归并排序”。