【架构实战】分库分表实战:ShardingSphere与中间件选型
一、一张表8亿行,备份花了3天
2020年,我们订单表突破了8亿行,单表容量超过300GB。
问题一个个冒出来:
- 写入QPS到800就卡死,主库CPU满负荷
- 按月备份一次要3天,运维崩溃
- 一个COUNT(*)跑了15分钟
- 主从延迟超过30秒,读库数据严重滞后
架构团队开会讨论三个方案:
- 升级硬件,买更好的数据库服务器
- 迁移到分布式数据库(TiDB、OceanBase)
- 业务层做分库分表
最终选了方案3------成本最低,可控性最高。但这条路,全是坑。
二、分库分表核心概念
2.1 垂直拆分 vs 水平拆分
【垂直拆分(拆业务)】
单库
┌────────────────┬─────────────────┐
│ 用户表 │ 订单表 │
│ 商品表 │ 库存表 │
│ 支付表 │ 营销表 │
└────────────────┴─────────────────┘
↓ 垂直拆分
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 用户库 │ │ 订单库 │ │ 商品库 │
│ 用户表 │ │ 订单表 │ │ 商品表 │
│ 登录表 │ │ 支付表 │ │ 库存表 │
└──────────┘ └──────────┘ └──────────┘
【水平拆分(拆数据)】
订单表(8亿行)
┌───────────────────────────────────────┐
│ user_id=1到user_id=8亿 │
└───────────────────────────────────────┘
↓ 按 user_id % 4 水平拆分
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 订单表0 │ │ 订单表1 │ │ 订单表2 │ │ 订单表3 │
│id%4=0 │ │id%4=1 │ │id%4=2 │ │id%4=3 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
垂直拆分优先原则:先做业务拆分,数据量还是大再做水平拆分。
2.2 分片键的选择------最关键的决策
分片键决定数据分布的均匀性和查询的复杂度。
| 分片键策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 用户ID取模 | 数据均匀分布 | 扩容需要rehash | 用户维度查询为主 |
| 时间范围 | 扩展简单 | 热点数据集中 | 按时间归档 |
| 地理位置 | 就近访问 | 部分地区数据多 | 区域性业务 |
| 业务主键 | 业务直观 | 分布可能不均 | 特定业务对象 |
我们的选择 :使用 user_id % 16 作为分片键,将数据均匀分布到16个库。
三、ShardingSphere实战
3.1 为什么选ShardingSphere
选型对比:
| 中间件 | 架构 | 性能 | 侵入性 | 社区 |
|---|---|---|---|---|
| ShardingSphere-JDBC | 客户端SDK | 无额外网络开销 | 代码层侵入 | Apache,活跃 |
| MyCat | 独立代理 | 多一跳网络延迟 | 无侵入 | 较早,更新慢 |
| DBLE | 独立代理 | 多一跳 | 无侵入 | 爱可生维护 |
| Vitess | 独立代理 | 成熟可靠 | 无侵入 | Google背书 |
选择ShardingSphere-JDBC的核心原因:
- 性能最好,无代理层网络开销
- 与Spring Boot天然集成
- 社区活跃,问题响应快
- 支持分布式事务(Seata集成)
3.2 分库分表配置
yaml
# application-sharding.yml
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9,ds10,ds11,ds12,ds13,ds14,ds15
rules:
sharding:
tables:
t_order:
# 16库 * 8表 = 128个物理分片
actualDataNodes: ds$->{0..15}.t_order_$->{0..7}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: database-inline
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: table-inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 16}
table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 8}
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: ${worker.id}
3.3 分布式主键------Snowflake算法
java
// Snowflake ID 结构(64位)
// 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
// 符号│ 时间戳(41位) │ 数据中心│ 机器ID│ 序列号(12位)
// 1位 │ 2 │ 1 │ 2 │ 3
// 关键实现
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
// 起始时间戳 (2020-01-01)
private final long twepoch = 1577808000000L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨处理
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
Thread.sleep(offset * 2); // 等待追上
timestamp = System.currentTimeMillis();
} else {
throw new RuntimeException("时钟回拨超过5ms");
}
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
四、分库分表后的核心挑战
4.1 跨分片查询
问题 :按 user_id 分片后,按 order_status 查询需要遍历所有分片。
解决方案:Elasticsearch做二级索引。
java
// 订单创建时同步到ES
@Transactional
public void createOrder(Order order) {
// 1. 写入分库分表
orderMapper.insert(order);
// 2. 同步到ES(异步,允许短暂延迟)
CompletableFuture.runAsync(() -> {
elasticsearchRepository.save(order.toEsDoc());
});
}
// 按状态查询时走ES
public Page<Order> searchByStatus(String status, int page, int size) {
SearchHits<OrderDoc> hits = elasticsearchTemplate.search(
new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.termQuery("status", status))
.withPageable(PageRequest.of(page, size))
.build(),
OrderDoc.class
);
// 获取ID列表,从分库分表中批量加载详情
List<Long> orderIds = hits.stream()
.map(h -> h.getContent().getOrderId())
.collect(Collectors.toList());
return orderMapper.selectByIds(orderIds);
}
4.2 分布式事务
java
// 使用Seata AT模式
@GlobalTransactional(name = "create-order", timeoutMills = 300000)
public void createOrderWithTransaction(OrderRequest request) {
// 1. 创建订单(分库分表)
orderService.create(request);
// 2. 扣减库存(另一个服务)
inventoryService.deduct(request);
// 3. 扣减余额(另一个服务)
accountService.deduct(request);
}
4.3 分页的噩梦
跨分片分页需要用"全局排序+二次查询":
java
public List<Order> getOrdersByPage(int pageNo, int pageSize, String sortField) {
// 每个分片都取 pageNo * pageSize 条
int limit = pageNo * pageSize + pageSize;
List<Order> allOrders = new ArrayList<>();
for (int db = 0; db < 16; db++) {
List<Order> shardOrders = orderMapper.queryFromShard(
db, sortField, 0, limit);
allOrders.addAll(shardOrders);
}
// 全局排序后取分页
return allOrders.stream()
.sorted(Comparator.comparing(Order::getCreateTime).reversed())
.skip((long) (pageNo - 1) * pageSize)
.limit(pageSize)
.collect(Collectors.toList());
}
工程建议:业务上限制深分页,前端只允许查询前100页。
五、数据迁移方案
从单表迁移到分库分表的平滑方案:
阶段一:双写(1周)
应用 ──写──> 原单表
└──写──> 新分库分表(异步,失败不影响主流程)
阶段二:数据校验(3天)
对比原表和新分库分表的数据,修复差异
阶段三:灰度切读(1周)
5% → 20% → 50% → 100% 读流量逐步切到分库分表
阶段四:下线原表(1天)
确认无误后,停止双写,下线原表
六、总结
分库分表不是所有数据库性能问题的答案,先做好索引优化、读写分离、缓存策略,实在不行再考虑。
核心要点:
- 先垂直拆分,再水平拆分,顺序不能乱
- 分片键是灵魂,决定了后续所有查询的复杂度
- ShardingSphere-JDBC性能最好,但代码有侵入性
- 跨分片查询用ES做二级索引,跨分片分页要限制深度
- 分布式事务用Seata,别自己造轮子
- 迁移方案要稳妥,双写+灰度切读是标准操作
一个教训 :当年我们为了解决分库分表问题,引入了ES、Seata、Canal一堆中间件。后来发现一半的查询只需要 user_id 维度就能完成。分片键选对了,80%的问题自动消失。
个人观点,仅供参考