引言:当数据库成为瓶颈
想象一下,你的电商系统上线初期,订单表只有几万条数据,查询速度飞快。但随着业务发展,订单量突破千万、上亿,你发现:
-
单表查询从毫秒级变成秒级
-
写入 TPS 遇到天花板
-
数据库磁盘空间告急
-
备份恢复时间越来越长
这时候,你需要的不是更换更贵的服务器,而是分库分表——一种通过数据水平拆分来突破单机限制的架构演进策略。
今天我们聊聊这个让无数后端工程师又爱又恨的话题。
一、为什么需要分库分表?
1.1 单库单表的极限
MySQL 单表建议数据量在 1000-2000万 以内,超过这个量级:
-
B+树层级增加:索引查询需要更多次磁盘 I/O
-
缓冲池命中率下降:InnoDB Buffer Pool 无法缓存所有热点数据
-
锁竞争加剧:高并发下行锁、表锁冲突频繁
1.2 垂直拆分 vs 水平拆分
垂直拆分:按业务模块拆分
原来:user, order, product 都在一个库
拆分后:
- user_db: 用户相关表
- order_db: 订单相关表
- product_db: 商品相关表
-
优点:业务解耦,避免跨业务的锁竞争
-
缺点:单表数据量大的问题依然存在
水平拆分(今天的重点):按数据维度拆分
订单表 1亿数据 → 拆分成 10 个库,每库 10 张表
order_0.order_00 ~ order_0.order_09
order_1.order_00 ~ order_1.order_09
...
order_9.order_00 ~ order_9.order_09
二、分片策略:数据如何拆?
2.1 分片键的选择:灵魂问题
分片键决定了数据的分布,选择不当会导致:
-
数据倾斜:某些分片过热,某些分片空闲
-
跨分片查询:无法利用分片键查询时,需要扫描所有分片
常见选择:
-
订单表:user_id(按用户查询订单最常见)
-
支付流水:trade_id(交易号天然离散)
-
日志表:date(按时间范围分片)
反例:用 order_id 作为订单表分片键
-
虽然写入均匀,但查询"某用户的订单"时必须扫描所有分片!
2.2 分片算法
(1) 取模算法
// 假设分 100 个表
int tableIndex = userId % 100;
String tableName = "order_" + tableIndex;
-
优点:简单,数据分布均匀
-
缺点:扩容困难(从 100 扩到 200 需要迁移 50% 数据)
(2) 范围分片
// 按用户 ID 区间
if (userId < 1000000) return "order_0";
else if (userId < 2000000) return "order_1";
-
优点:扩容简单(新增区间即可)
-
缺点:容易数据倾斜(新用户集中在最新分片)
(3) 一致性哈希
// 使用一致性哈希环
long hash = hash(userId);
return consistentHash.getNode(hash); // 返回物理节点
-
优点:扩容时只需迁移少量数据(平均 K/N)
-
应用:ShardingSphere 支持此算法
(4) 雪花算法衍生
订单号:1234567890123456789
取中间 4 位 → 6789 % 100 = 89
路由到 order_89
-
优点:订单号本身包含分片信息,减少路由计算
三、分库分表的核心问题与解决方案
3.1 全局唯一 ID 生成
分库后自增 ID 会重复,需要全局 ID 生成器:
方案对比
|
方案 |
优点 |
缺点 |
适用场景 |
|
UUID |
简单,无依赖 |
36 字节,索引性能差 |
非核心业务 |
|
雪花算法 |
趋势递增,性能高 |
依赖时钟,机器 ID 管理 |
高并发订单号 |
|
数据库号段 |
简单可靠 |
存在单点,需要预分配 |
中小规模 |
|
Redis |
性能极高 |
依赖 Redis 可用性 |
高并发临时 ID |
雪花算法实现要点:
// 64 位组成:1 位符号 + 41 位时间戳 + 10 位机器 ID + 12 位序列号
public class SnowflakeIdGenerator {
private long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
// 时钟回拨处理
throw new RuntimeException("Clock moved backwards");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 4095 = 2^12 - 1
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - 1609459200000L) << 22)
| (workerId << 12)
| sequence;
}
}
关键点:
-
时钟回拨会导致 ID 重复,生产环境需监控 NTP 同步
-
机器 ID 可通过 ZooKeeper 自动分配
3.2 跨分片查询:最头疼的问题
场景 1:查询"最近 100 条订单"
-- 原 SQL
SELECT * FROM order WHERE user_id = 123 ORDER BY create_time DESC LIMIT 100;
分片后执行流程:
-
路由层根据 user_id 计算分片:order_23
-
在目标分片执行查询
-
返回结果
完美!单分片查询不受影响。
场景 2:运营查询"今天所有订单"
SELECT * FROM order WHERE DATE(create_time) = '2025-10-12';
问题来了:没有分片键,需要广播到所有分片!
优化方案:
-
冗余查询维度:订单表增加 date 字段做索引
-
异步同步到 OLAP:订单数据同步到 ClickHouse,走分析型数据库
-
限制查询时间范围:强制要求加上时间范围(最近 7 天)
场景 3:跨分片分页
-- 查询所有用户的订单,按时间倒序,第 2 页
SELECT * FROM order ORDER BY create_time DESC LIMIT 100, 10;
朴素方案:
-
每个分片查询 LIMIT 110(100 + 10)
-
内存中合并排序
-
取第 100-110 条
问题:翻页越深,性能越差(第 10000 页需要每个分片查 10010 条)
优化方案:
-
禁止深度分页:只允许翻前 100 页
-
游标分页:记住上次最后一条的 ID,下次查询 WHERE id > last_id LIMIT 10
-
搜索引擎:用 Elasticsearch 替代数据库做复杂查询
3.3 分布式事务:跨分片操作的噩梦
典型场景:订单 + 库存扣减
// 订单在 order_db_1,库存在 product_db_3
@Transactional
public void createOrder(Order order) {
orderDao.insert(order); // 操作 DB1
productDao.decreaseStock(...); // 操作 DB2
}
问题:@Transactional 只能保证单库事务!
解决方案
(1) 最终一致性方案(推荐)
// 1. 订单服务:先创建订单(状态=待支付)
orderService.create(order);
// 2. 发送 MQ 消息:扣减库存
mqProducer.send("stock-decrease", order.getItems());
// 3. 库存服务:消费消息扣减
@RocketMQListener(topic = "stock-decrease")
public void onMessage(StockDecreaseMsg msg) {
stockService.decrease(msg);
}
-
优点:性能高,服务解耦
-
缺点:业务需要容忍最终一致(短暂的库存不准确)
(2) Seata 分布式事务
@GlobalTransactional // Seata 注解
public void createOrder(Order order) {
orderDao.insert(order);
productDao.decreaseStock(...);
}
-
原理:两阶段提交(2PC),协调多个数据库提交/回滚
-
缺点:性能损耗大(需要记录 undo_log),不适合高并发
(3) 本地消息表
// 订单库中创建 local_message 表
@Transactional
public void createOrder(Order order) {
orderDao.insert(order);
// 同一个事务中插入待发送的消息
localMessageDao.insert(new LocalMessage("stock-decrease", ...));
}
// 定时任务扫描 local_message,发送到 MQ
@Scheduled(fixedDelay = 1000)
public void sendMessages() {
List<LocalMessage> messages = localMessageDao.selectPending();
for (LocalMessage msg : messages) {
mqProducer.send(msg);
localMessageDao.markSent(msg.getId());
}
}
-
优点:不依赖第三方事务框架,可靠性高
-
经典应用:支付宝的异步通知机制
3.4 数据扩容与迁移
场景:从 10 个分片扩容到 20 个
挑战:
-
取模算法变化:userId % 10 → userId % 20
-
数据需要重新分布(50% 数据要搬家)
方案 1:停机迁移(不推荐)
-
停止写入
-
全量数据重新计算分片
-
迁移数据
-
切换路由规则
-
恢复服务
缺点:停机时间长(TB 级数据需要数小时)
方案 2:双写迁移(生产推荐)
第 1 阶段:双写
应用同时写 old_shard 和 new_shard
读取走 old_shard
第 2 阶段:数据补齐
后台任务迁移历史数据到 new_shard
第 3 阶段:切换读
读取走 new_shard
第 4 阶段:下线 old_shard
核心代码:
public void insert(Order order) {
// 计算新旧分片
String oldTable = "order_" + (order.getUserId() % 10);
String newTable = "order_" + (order.getUserId() % 20);
// 双写
orderDao.insertToTable(oldTable, order);
orderDao.insertToTable(newTable, order);
}
经验教训:
-
迁移期间需要记录双写失败的数据,人工修复
-
使用 Binlog + Canal 同步数据更可靠
四、分库分表中间件选型
4.1 ShardingSphere(推荐)
特点:
-
支持 JDBC 代理(应用内集成)和 Proxy 代理(独立进程)
-
兼容 MySQL 协议,业务代码几乎无感知
-
支持读写分离、分布式事务
配置示例:
rules:
- !SHARDING
tables:
order:
actualDataNodes: ds_${0..1}.order_${0..9}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: order_mod
shardingAlgorithms:
order_mod:
type: MOD
props:
sharding-count: 10
4.2 MyCat(较老牌)
-
独立中间件,应用通过 MyCat 连接池访问
-
配置繁琐,社区活跃度下降
4.3 自研方案
大厂常见(如美团的 Zebra、阿里的 TDDL)
-
优点:深度定制,性能极致优化
-
缺点:开发维护成本高
五、实战经验与踩坑指南
5.1 不要过早分库分表
反模式:
-
系统上线就设计 256 张表
-
数据量 100 万就开始分表
正确姿势:
-
优先优化 SQL 和索引(90% 的慢查询可以通过索引解决)
-
上读写分离(应对读多写少)
-
单表突破 1000 万再考虑分表
5.2 分片键一旦确定,很难更改
血泪教训:
-
某电商用 order_id 分片,后来发现查"用户订单"性能差
-
改分片键需要全量数据迁移,耗时数月
建议:
-
前期充分调研业务查询场景
-
预留冗余字段(如订单表同时存 user_id 和 shop_id)
5.3 监控数据倾斜
案例: 某视频网站用 user_id 分片,结果头部大 V 的数据集中在某个分片
-
现象:某个分片 CPU 100%,其他分片空闲
-
解决:增加二级分片(先按 user_id % 10,再按 video_id % 10)
监控指标:
-
各分片的数据量差异
-
各分片的 QPS 分布
-
慢查询日志的分片分布
5.4 预留扩展位
设计表名时留扩展空间:
// 不好的设计
order_0, order_1, ..., order_9
// 好的设计
order_000, order_001, ..., order_099
这样从 100 扩到 1000 时,表名不冲突。
六、分库分表的替代方案
6.1 NewSQL(TiDB、OceanBase)
特点:
-
分布式架构,自动分片
-
兼容 MySQL 协议
-
强一致性,支持分布式事务
适用场景:
-
新系统,可以直接使用
-
数据量 PB 级
-
对一致性要求高(金融、支付)
案例:
-
小米用 TiDB 替代 MySQL 分库分表,开发效率提升 50%
6.2 时序数据库(InfluxDB、TimescaleDB)
适合日志、监控数据:
-
按时间自动分片
-
支持自动过期删除
6.3 NoSQL(MongoDB、Cassandra)
MongoDB 分片:
-
自动平衡数据
-
支持动态扩容
适用场景:
-
对一致性要求不高
-
数据模型灵活(非结构化)
七、总结:分库分表的哲学
分库分表不是银弹,而是一种权衡的艺术:
|
维度 |
收益 |
代价 |
|
性能 |
突破单机瓶颈 |
跨分片查询变慢 |
|
可扩展性 |
可无限水平扩展 |
扩容需要数据迁移 |
|
复杂度 |
- |
代码复杂度、运维复杂度飙升 |
|
一致性 |
- |
分布式事务难以保证 |
核心原则:
-
能不分就不分:优先垂直拆分、读写分离
-
分片键至关重要:要覆盖 80% 的查询场景
-
拥抱最终一致性:别执着于强一致
-
监控先行:提前发现数据倾斜、慢查询
演进路线:
单库单表
→ 主从读写分离
→ 垂直拆分(按业务)
→ 水平拆分(按数据)
→ NewSQL / 云原生数据库
分库分表是分布式系统中数据层的核心挑战,掌握它,你就掌握了应对海量数据的钥匙。
2915

被折叠的 条评论
为什么被折叠?



