1. 分表业务层改造的核心挑战
最近在重构一个日订单量突破50万单的电商系统,遇到了单表数据量过大的性能瓶颈。订单表已经增长到接近2亿条记录,查询响应时间从最初的200ms飙升到3秒以上。经过压力测试和性能分析,我们决定对订单表进行水平拆分,但在业务层改造过程中踩了不少坑。
关键提示:分表改造不是简单的技术方案选型,而是涉及数据分布、查询路由、事务一致性的系统工程,需要从业务场景出发设计拆分策略。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分表方案设计与选型
2.1 分片键的选择考量
我们最终选择user_id作为分片键,主要基于以下业务特征:
- 80%的查询都带有user_id条件
- 单个用户的订单数量平均在300单左右
- 用户订单之间没有强事务需求
测试对比了三种分片策略的性能表现:
| 分片策略 | QPS | 跨分片查询比例 | 热点问题 |
|---|---|---|---|
| 按user_id哈希 | 2850 | 5% | 无 |
| 按订单ID范围 | 1200 | 40% | 严重 |
| 按创建时间 | 1800 | 65% | 明显 |
2.2 MyBatis-Plus动态表名方案
采用MyBatis-Plus 3.5.17的动态表名插件实现路由逻辑:
java复制public class OrderTableNameHandler implements TableNameHandler {
@Override
public String dynamicTableName(String sql, String tableName) {
Long userId = RequestDataHolder.getCurrentUserId();
return "order_" + (userId % 16); // 16个分表
}
}
配置拦截器时需要注意:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件必须放在动态表名插件之前
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
interceptor.addInnerInterceptor(new DynamicTableNameInnerInterceptor());
return interceptor;
}
3. 业务层改造关键实现
3.1 Service层的适配改造
原始订单服务接口:
java复制public interface OrderService {
Order getById(Long orderId);
List<Order> listByUser(Long userId);
}
改造后的实现要点:
- 所有查询方法必须显式传递userId
- 批量查询需要先按userId分组
- 跨分片操作需要特殊处理
java复制@Override
public Order getById(Long orderId, Long userId) {
// 通过ThreadLocal传递分片信息
RequestDataHolder.setCurrentUserId(userId);
return orderMapper.selectById(orderId);
}
3.2 分布式ID生成方案
对比测试了三种ID生成方案:
| 方案 | TPS | 冲突概率 | 趋势递增性 |
|---|---|---|---|
| 雪花算法 | 12万 | 0 | 是 |
| UUID | 8万 | 0 | 否 |
| 数据库序列 | 6000 | 0 | 是 |
最终采用改良版雪花算法:
java复制public class OrderIdGenerator {
private static final long USER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
public static long generateId(long userId) {
long timestamp = System.currentTimeMillis();
long sequence = ... // 序列号生成逻辑
return (timestamp << (USER_ID_BITS + SEQUENCE_BITS))
| ((userId % 1024) << SEQUENCE_BITS)
| sequence;
}
}
4. 典型问题与解决方案
4.1 跨分页查询难题
用户订单列表分页查询的解决方案:
- 内存分页:适合小数据量(<1万)
java复制public Page<Order> listByUser(Long userId, PageParam param) {
List<Order> all = orderMapper.selectListByUser(userId);
return new Page<Order>()
.setRecords(all.stream()
.skip((param.getPageNum()-1)*param.getPageSize())
.limit(param.getPageSize())
.collect(Collectors.toList()))
.setTotal(all.size());
}
- ES二级索引:大数据量下的解决方案
- 建立user_id + create_time的联合索引
- 先查询ES获取主键,再精确查询分表
4.2 分布式事务处理
订单创建时的典型事务场景:
java复制@Transactional
public void createOrder(Order order) {
// 1. 扣减库存(库存服务)
inventoryService.deduct(order.getSkuId(), order.getCount());
// 2. 创建订单(订单服务)
orderMapper.insert(order); // 分表插入
// 3. 增加积分(积分服务)
pointsService.add(order.getUserId(), order.getAmount());
}
采用Seata的AT模式解决方案:
- 配置全局事务注解
java复制@GlobalTransactional
public void createOrder(Order order) {
// ...
}
- 每个微服务需要:
- 创建undo_log表
- 配置Seata数据源代理
- 注入GlobalTransactionScanner
5. 性能优化实践
5.1 热点分片处理
监控发现user_id=12345的用户产生了20万订单,导致单个分片过大。解决方案:
- 二次分片策略:
java复制public String dynamicTableName(String sql, String tableName) {
Long userId = RequestDataHolder.getCurrentUserId();
if(isHotUser(userId)) {
return "order_hot_" + (userId % 4); // 热点用户再分4表
}
return "order_normal_" + (userId % 16);
}
- 历史数据归档:
- 建立order_archive表存储6个月前的数据
- 查询时自动路由到当前表或归档表
5.2 缓存策略优化
多级缓存设计方案:
- 本地缓存:Caffeine存储热点订单(TTL=5s)
- 分布式缓存:Redis集群存储完整订单数据(TTL=30s)
- 缓存key设计:order:{userId}:
缓存击穿解决方案:
java复制public Order getByIdWithCache(Long orderId, Long userId) {
String cacheKey = "order:" + userId + ":" + orderId;
return cacheTemplate.opsForValue().get(cacheKey,
() -> orderMapper.selectById(orderId, userId),
5, TimeUnit.SECONDS);
}
6. 监控与治理
6.1 分片健康度监控
通过Prometheus监控关键指标:
yaml复制- name: order_table_stats
metrics:
- table_size: gauge
labels: [table_name]
- query_latency: histogram
labels: [table_name, operation]
配置告警规则:
- 单个分表数据量 > 1000万
- 跨分片查询比例 > 10%
- 热点分片QPS > 5000
6.2 灰度迁移方案
采用双写方案保证迁移安全:
- 新订单同时写入旧表和新分表
- 通过定时任务补全历史数据
- 校验程序对比新旧数据一致性
- 流量逐步切到新分表
校验脚本示例:
sql复制SELECT COUNT(*) FROM order_old o
LEFT JOIN order_new_${shard} n ON o.id = n.id
WHERE n.id IS NULL AND o.user_id % 16 = ${shard}
7. 经验总结
- 字段设计避坑:
- 避免在分片键上使用自动递增ID
- 所有分表必须包含完整索引结构
- 谨慎使用JOIN操作
- 开发规范:
- 所有DAO方法必须显式传递分片键
- 禁止不带分片条件的全表扫描
- 批量操作需要先按分片键分组
- 我特别推荐的两个工具:
- MyBatis-Plus的SQL分析插件:
java复制@Bean
public SqlExplainInterceptor sqlExplainInterceptor() {
return new SqlExplainInterceptor()
.setStopProceed(true); // 拦截全表扫描
}
- 分片数据校验工具ShardingSphere-Scaling:
bash复制./bin/start.sh --config=config.yaml
