MySQL 分库分表实战——ShardingSphere 接入、数据迁移、ID 问题解决方案
MySQL 分库分表实战——ShardingSphere 接入、数据迁移、ID 问题解决方案
适读人群:面临数据库容量瓶颈、正在或准备做分库分表的后端工程师 | 阅读时长:约18分钟 | 核心价值:分库分表的完整方案,从选型到迁移到踩坑全覆盖
单表 3 亿行的教训
2020 年底,我接了一个紧急咨询,对方是一家做物流跟踪的公司,技术负责人小贾。他们的 tracking_events 表已经有 3 亿多行数据了,每天新增 200 万行,数据库已经在 3 台高规格服务器上了,但查询还是很慢。
"已经加了所有该加的索引,服务器也升级了,但就是慢。"小贾说。
我看了一下表结构:tracking_events 是一张宽表,60 多个字段,主键是 BIGINT AUTO_INCREMENT。EXPLAIN 跑一条按 order_id 查的 SQL,rows 达到 1200 万——索引区分度太低,一个订单平均有 60 多条追踪事件。
到了这个体量,单表优化已经到了极限。分库分表是唯一的出路。
这个项目最终花了两个月落地了 ShardingSphere 的分库分表方案。今天把这个过程里的关键决策和踩坑系统整理出来。
一、什么时候需要分库分表?
先明确一个认知:分库分表是最后的手段,不是第一选项。在考虑分库分表之前,先把这些做了再说:
- 索引优化(最高性价比)
- 读写分离(主库写,从库读,线性扩展读能力)
- 归档历史数据(把老数据移到冷存储,减小热表体积)
- 分区表(MySQL 原生支持,对应用透明)
当单表数据量超过 5000 万行,或者 B+树高度超过 4 层,或者即使做了上面这些优化,响应时间还是不满足要求,才考虑分库分表。
二、ShardingSphere 接入方案
2.1 分片键选择:最关键的决策
分片键(Sharding Key)选择错了,后面所有的优化都是白做。核心原则:
- 选择查询频率最高的过滤字段:如果 90% 的查询都带
user_id,那user_id是最好的分片键 - 数据分布要均匀:分片键的值域要足够分散,避免某个分片过热
- 避免跨分片查询:同一业务实体的数据尽量落在同一分片(比如同一个用户的所有订单在同一分片)
- 不要用时间字段做分片键:时间是单调递增的,会导致最新的数据全堆在最后几个分片
# ShardingSphere 配置(JDBC 模式,不需要中间件服务器)
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://mysql-0:3306/order_db_0
username: root
password: ${DB_PASSWORD}
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://mysql-1:3306/order_db_1
username: root
password: ${DB_PASSWORD}
rules:
sharding:
tables:
orders:
actual-data-nodes: ds${0..1}.orders_${0..3} # 2库 x 4表 = 8张分表
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: table-inline
key-generate-strategy:
column: id
key-generator-name: snowflake
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds${user_id % 2}
table-inline:
type: INLINE
props:
algorithm-expression: orders_${user_id % 4}
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: ${WORKER_ID:1}
props:
sql-show: true # 开发环境显示路由后的 SQL,生产关闭三、分布式 ID:不能用 AUTO_INCREMENT 了
分库分表后,不同分片的表各自 AUTO_INCREMENT,会产生重复 ID。必须用分布式 ID 方案。
3.1 雪花算法(Snowflake)
ShardingSphere 内置了 Snowflake 实现,64 位长整型,结构:
符号位(1) | 时间戳(41) | 机器ID(10) | 序列号(12)- 41 位时间戳,精度毫秒,可用约 69 年
- 10 位机器 ID,支持 1024 个节点
- 12 位序列号,每毫秒每节点最多 4096 个 ID
踩坑一:Snowflake 的机器 ID 配置不当导致 ID 冲突
现象:两个应用实例生成的 ID 出现重复,数据库报主键冲突。
原因:两个实例的 worker-id 都配置为 1,时间戳和序列号碰巧相同时,生成的 ID 完全一样。
解法:生产环境中,worker-id 必须保证每个实例唯一。可以通过 Kubernetes downward API 注入 Pod 序号、Nacos 注册时获取序号、ZooKeeper 分配序号等方式实现。
// 动态分配 workerId 的示例(基于 Redis 原子计数)
@Configuration
public class SnowflakeConfig {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Bean
public long workerId() {
// 应用启动时从 Redis 获取唯一的 workerId
// 使用 INCR 保证原子性,模 1024 保证在范围内
Long workerId = redisTemplate.opsForValue().increment("snowflake:worker-id");
if (workerId == null) {
throw new RuntimeException("无法获取 workerId");
}
long id = workerId % 1024;
log.info("Snowflake workerId 分配成功: {}", id);
return id;
}
}四、数据迁移:最难的部分
4.1 迁移原则:停机 vs 不停机
停机迁移:暂停服务,把数据从旧表迁移到新的分库分表结构,验证后重新开服。适合允许短暂停机的系统。
不停机迁移(双写方案):这是大多数互联网系统必须走的路,步骤如下:
阶段1:老库正常读写,双写启动,新库开始接收写入
旧库 ←→ 应用 → 新库(双写)
读操作:全部读旧库
阶段2:数据追平,切读到新库(灰度切流)
旧库 ← 应用 → 新库(双写)
读操作:灰度流量读新库,大部分还读旧库
阶段3:全量读新库,停止写旧库
旧库(只读备份)← 应用 → 新库
读操作:全部读新库
阶段4:下线旧库// 双写实现示例
@Service
public class OrderServiceWithMigration {
@Autowired
private OldOrderMapper oldOrderMapper;
@Autowired
private NewOrderMapper newOrderMapper;
@Value("${migration.phase:1}") // 从配置中心动态控制迁移阶段
private int migrationPhase;
@Value("${migration.read.new.percentage:0}") // 读新库的流量比例
private int readNewPercentage;
public void createOrder(Order order) {
// 始终写旧库(保证数据不丢)
oldOrderMapper.insert(order);
// 阶段 >= 1:双写到新库
if (migrationPhase >= 1) {
try {
newOrderMapper.insert(order);
} catch (Exception e) {
// 写新库失败不影响主流程,只记录日志
log.error("双写新库失败,orderId={}", order.getId(), e);
// 可以发 MQ 消息触发异步补偿
}
}
}
public Order getOrder(Long orderId) {
// 根据迁移阶段决定读哪个库
if (migrationPhase >= 2 && shouldReadNew()) {
Order order = newOrderMapper.selectById(orderId);
if (order != null) {
return order;
}
// 新库没有则回源到旧库(防止数据迁移未完成时的数据空洞)
log.warn("新库未找到数据,回源旧库,orderId={}", orderId);
}
return oldOrderMapper.selectById(orderId);
}
private boolean shouldReadNew() {
return ThreadLocalRandom.current().nextInt(100) < readNewPercentage;
}
}踩坑二:数据迁移期间的数据一致性校验
现象:切读到新库后,发现部分查询结果和旧库不一致,有些数据在新库里少了几条。
原因:批量迁移历史数据时,并发写入的数据被遗漏了(迁移脚本在扫描旧库时,新的数据还在不断写入)。
解法:历史数据迁移分两步:
- 全量迁移(可能需要数天,期间业务不停)
- 增量补偿:记录迁移开始时间,全量迁移完成后,再补迁这段时间内的增量数据(可以通过 binlog 解析实现)
- 切流前做数据比对:抽样检查新旧库数据一致性,差异率小于设定阈值才切流
五、分库分表的限制与应对
限制一:跨分片 JOIN
分库分表后,JOIN 查询如果涉及多个分片,ShardingSphere 会拉取所有相关分片的数据到内存进行归并计算,性能较差。
应对:把 JOIN 拆成多次单表查询,在应用层做关联;或者把关联频繁的小表设置为"广播表"(每个分片都保存完整数据)。
限制二:跨分片聚合和排序
GROUP BY、ORDER BY、COUNT 在跨分片时需要归并计算,效率低。
应对:把统计分析类查询独立出来,走数据仓库(ClickHouse/Elasticsearch)而不是 MySQL。
踩坑三:分片扩容(翻倍扩展)
从 4 个分片扩展到 8 个分片时,几乎所有的数据都需要重新迁移(原来落在分片 0 的数据,现在一半要去分片 4)。这是分库分表最痛苦的地方。
应对:初始分片数要预留空间,比如业务量在 1 亿级时,分 16 或 32 个分片,为未来 10 倍增长留余地。一次分多,好过将来频繁迁移。
分库分表是大厂标配,但也是一把双刃剑。在享受水平扩展能力的同时,也要承担查询能力受限、迁移复杂、运维成本高的代价。在做决策时,要对这些代价有清醒的认识。
