1. ShardingSphere分库分表实战:电商订单系统完整实现
作为一位经历过多次数据库架构升级的老兵,我深知单库单表架构在业务量暴增时的痛苦。记得去年双十一大促期间,我们的订单系统因为单表数据量突破3000万条,查询响应时间从毫秒级直接飙升到秒级,差点酿成重大事故。这次经历让我下定决心引入ShardingSphere进行分库分表改造,今天就把这套经过实战检验的方案完整分享给大家。
1.1 为什么选择ShardingSphere?
在评估了多种分库分表方案后,我们最终选择ShardingSphere主要基于以下考量:
技术适配性:
- 对现有代码零侵入:只需修改配置即可接入,无需重写DAO层代码
- 支持多种分片策略:能满足我们按用户ID分库、按订单ID分表的复杂需求
- 完善的分布式事务:提供XA和Seata两种方案,我们最终选择了性能更好的Seata
性能表现:
- 在压测环境中,单表3000万数据时查询平均响应时间达1.2秒
- 分库分表后(2库×4表),相同查询降至200毫秒以内
- 并发能力从原来的500QPS提升到3000QPS
运维成本:
- 配置变更可通过YAML文件热更新
- 提供SQL日志追踪功能,方便排查路由问题
- 社区活跃,遇到问题能快速找到解决方案
2. 核心架构设计
2.1 分片策略设计
我们的电商平台每天产生约50万笔订单,按照二八法则,80%的查询都是通过用户ID进行的。基于这个特点,设计了如下分片方案:
java复制// 分库路由算法示例
public class UserIdShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
long userId = shardingValue.getValue();
return "ds" + (userId % 2); // 对用户ID取模决定库名
}
}
// 分表路由算法示例
public class OrderIdShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
long orderId = shardingValue.getValue();
return "t_order_" + (orderId % 4); // 对订单ID取模决定表名
}
}
2.2 数据分布验证
为确保数据均匀分布,我们开发了数据巡检工具:
sql复制-- 检查各分片数据量
SELECT
SUBSTRING_INDEX(TABLE_NAME,'_',-1) as shard,
TABLE_ROWS as count
FROM
information_schema.TABLES
WHERE
TABLE_SCHEMA in ('ds0','ds1')
AND TABLE_NAME LIKE 't_order_%';
执行结果示例:
| shard | count |
|---|---|
| 0 | 1,245,678 |
| 1 | 1,256,432 |
| 2 | 1,238,765 |
| 3 | 1,251,987 |
数据分布差异控制在±1%以内,符合预期。
3. 关键实现细节
3.1 分布式ID生成
为避免分库分表后的ID冲突,我们采用改良版雪花算法:
java复制public class DistributedIdGenerator {
private static final long START_TIMESTAMP = 1672531200000L; // 2023-01-01
private static final long WORKER_ID_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨异常");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - START_TIMESTAMP) << timestampLeftShift)
| (workerId << workerIdShift)
| sequence;
}
}
3.2 绑定表配置
订单表与订单明细表配置为绑定表,确保关联查询效率:
yaml复制spring:
shardingsphere:
rules:
sharding:
binding-tables:
- t_order,t_order_item
3.3 分布式事务处理
采用Seata的AT模式实现分布式事务:
java复制@GlobalTransactional
public boolean createOrder(Order order, List<OrderItem> items) {
orderMapper.insert(order); // 主事务
items.forEach(item -> {
item.setOrderId(order.getOrderId());
orderItemMapper.insert(item); // 分支事务
});
return true;
}
4. 性能优化实践
4.1 索引设计规范
每个分片表都必须包含以下索引:
- 主键索引:order_id(聚簇索引)
- 用户查询索引:user_id
- 订单号索引:order_no(唯一索引)
sql复制CREATE TABLE t_order_0 (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
...
INDEX idx_user_id (user_id),
UNIQUE INDEX idx_order_no (order_no)
);
4.2 慢查询监控
配置ShardingSphere的SQL日志分析:
yaml复制spring:
shardingsphere:
props:
sql-show: true
sql-simple: false # 显示详细执行信息
通过ELK收集日志,设置报警规则:
- 执行时间 > 500ms
- 扫描行数 > 10,000
- 返回行数 > 1,000
5. 踩坑经验分享
5.1 分片键选择误区
初期我们尝试用订单创建时间作为分片键,导致两个严重问题:
- 热点数据集中:新订单都写入同一个分片
- 历史数据迁移困难:按时间范围查询需要跨多个分片
解决方案:
- 改用用户ID作为分库键
- 增加订单ID哈希作为分表键
- 对时间范围查询建立单独的历史库
5.2 分布式事务超时
在秒杀场景下,曾出现事务锁等待超时:
code复制io.seata.core.exception.TransactionException: acquire global lock fail
优化措施:
- 将默认全局锁超时时间从60秒调整为3秒
- 对库存扣减采用乐观锁替代分布式锁
- 引入本地消息表实现最终一致性
5.3 结果归并内存溢出
当执行SELECT * FROM t_order WHERE status=1这类全分片查询时,曾导致OOM。
改进方案:
- 增加分页查询:
LIMIT 1000 - 使用流式归并:
yaml复制spring:
shardingsphere:
props:
max.connections.size.per.query: 5 # 控制每个查询的最大连接数
6. 监控与运维体系
6.1 关键监控指标
我们通过Prometheus监控以下核心指标:
-
SQL执行统计:
- 分片SQL执行次数
- 平均响应时间
- 错误率
-
连接池状态:
- 活跃连接数
- 等待连接数
- 连接获取时间
-
事务监控:
- 全局事务数
- 二阶段提交耗时
- 事务回滚率
6.2 弹性扩缩容方案
当需要增加分片数量时,采用以下平滑迁移方案:
-
双写阶段:
- 配置新分片数据源
- 同时写入新旧分片
- 通过日志对比验证数据一致性
-
迁移阶段:
- 使用ShardingSphere的弹性伸缩功能
sql复制CREATE MIGRATION RULE ( READ_RESOURCE=ds_0,ds_1, WRITE_RESOURCE=ds_0,ds_1,ds_2,ds_3 ); -
切换阶段:
- 停写旧分片
- 验证新分片数据完整性
- 更新ShardingSphere配置
7. 典型业务场景实现
7.1 用户订单查询
java复制public Page<OrderVO> queryUserOrders(Long userId, int pageNo, int pageSize) {
// 使用HintManager强制路由到指定分片
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.addDatabaseShardingValue("t_order", userId);
Page<Order> page = new Page<>(pageNo, pageSize);
LambdaQueryWrapper<Order> query = new LambdaQueryWrapper<>();
query.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime);
return orderMapper.selectPage(page, query)
.convert(this::convertToVO);
}
}
7.2 订单详情获取
java复制public OrderDetailVO getOrderDetail(Long orderId) {
// 1. 获取订单主信息
Order order = orderMapper.selectById(orderId);
// 2. 获取订单明细(自动路由到相同分片)
LambdaQueryWrapper<OrderItem> query = new LambdaQueryWrapper<>();
query.eq(OrderItem::getOrderId, orderId);
List<OrderItem> items = orderItemMapper.selectList(query);
// 3. 组装VO对象
OrderDetailVO vo = new OrderDetailVO();
vo.setOrder(order);
vo.setItems(items);
return vo;
}
7.3 跨分片统计报表
对于需要跨分片聚合的统计场景,我们采用以下方案:
- 并行查询+内存聚合(适合小数据量)
java复制public Map<String, BigDecimal> getSalesByCategory() {
List<Future<Map<String, BigDecimal>>> futures = shardingExecutor.executeQuery(
() -> orderItemMapper.sumAmountByCategory()
);
return futures.stream()
.map(f -> {
try {
return f.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.flatMap(map -> map.entrySet().stream())
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
BigDecimal::add
));
}
- 预聚合表(适合大数据量)
- 创建定时任务计算各分片汇总数据
- 将结果写入Redis或单独统计库
8. 未来演进方向
随着业务发展,我们规划了以下优化路径:
-
混合分片策略:
- 热数据:采用更细粒度分片(如4库×8表)
- 冷数据:合并到大分片(如1库×2表)
-
智能路由:
- 基于历史查询模式分析
- 自动优化分片键选择
-
多租户支持:
- 租户级分片策略
- 资源隔离与配额管理
这套ShardingSphere分库分表方案已在生产环境稳定运行9个月,支撑了日均百万级订单处理。最大的体会是:分库分表不是银弹,必须根据业务特点精心设计分片策略,配合完善的监控体系,才能发挥最大价值。