1. 分表业务层改造概述
在业务系统发展到一定规模后,单表数据量膨胀带来的性能问题会逐渐显现。我最近刚完成一个日订单量超过50万笔的电商系统改造,其中最关键的就是对订单表进行水平拆分。分表本质上是通过将单张大表拆分为多张小表来分散I/O压力,但真正考验技术功底的在于如何让业务层无感知地适配这种拆分。
传统做法是在DAO层硬编码分表逻辑,但这会导致业务代码与分表策略高度耦合。我们的方案是采用MyBatis-Plus 3.5.17 + SpringBoot 2.7.12组合,在Service层实现动态路由。实测下来,查询性能提升3倍的同时,代码改动量控制在原有业务的5%以内。特别值得注意的是,分表改造必须与现有分页查询、事务管理等特性无缝兼容,这也是选择MyBatis-Plus的重要原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与核心设计
2.1 框架版本匹配
在技术预研阶段,我们发现版本兼容性是个大坑。MyBatis-Plus 3.5.17官方文档标明需要SpringBoot 2.7.x支持,但实际测试中发现:
- SpringBoot 2.7.0-2.7.4与MP的自动填充功能存在冲突
- 分页插件PaginationInterceptor在2.7.10后有行为变更
最终选定SpringBoot 2.7.12 + MyBatis-Plus 3.5.17的组合,这个搭配经过200+小时的压测验证稳定。
2.2 分表策略设计
电商订单表我们采用"用户ID尾号分表法",将订单表拆分为order_0到order_9共10张表。这种设计的考虑点包括:
- 离散度:保证各分表数据量均衡
- 查询友好:80%的查询都带user_id条件
- 扩展性:未来可平滑升级到分库
关键路由逻辑用ThreadLocal实现:
java复制public class TableRouterContext {
private static final ThreadLocal<String> suffix = new ThreadLocal<>();
public static void setSuffix(String tableSuffix) {
suffix.set(tableSuffix);
}
public static String getSuffix() {
return suffix.get();
}
public static void clear() {
suffix.remove();
}
}
3. Service层改造实战
3.1 基础CRUD改造
在Service方法入口处通过AOP自动计算分表后缀:
java复制@Around("execution(* com..service.*.*(..))")
public Object routeTable(ProceedingJoinPoint pjp) {
Object[] args = pjp.getArgs();
Long userId = extractUserId(args); // 从参数提取用户ID
String suffix = String.valueOf(userId % 10);
try {
TableRouterContext.setSuffix(suffix);
return pjp.proceed();
} finally {
TableRouterContext.clear();
}
}
Mapper层使用MyBatis-Plus的动态表名插件:
java复制public class DynamicTableNameInterceptor implements ITableNameHandler {
@Override
public String dynamicTableName(String sql, String tableName) {
String suffix = TableRouterContext.getSuffix();
return suffix != null ? tableName + "_" + suffix : tableName;
}
}
3.2 分页查询特殊处理
分页查询需要先获取各分表数据再合并排序。我们采用并行查询+内存分页方案:
java复制public Page<Order> pageOrders(PageQuery query) {
// 并行查询各分表
List<CompletableFuture<List<Order>>> futures = IntStream.range(0, 10)
.mapToObj(i -> CompletableFuture.supplyAsync(() -> {
TableRouterContext.setSuffix(String.valueOf(i));
return baseMapper.selectList(query.toQueryWrapper());
}))
.collect(Collectors.toList());
// 合并结果
List<Order> all = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.sorted(comparing(Order::getCreateTime).reversed())
.skip(query.offset())
.limit(query.getSize())
.collect(Collectors.toList());
return new Page<>(query.getCurrent(), query.getSize(), all);
}
4. 关键问题与解决方案
4.1 分布式事务一致性
跨分表的本地事务可以通过Spring的@Transactional保证,但涉及其他微服务时需要特殊处理。我们采用TCC模式:
- Try阶段:预占资源
- Confirm阶段:实际扣减
- Cancel阶段:释放预留
关键代码结构:
java复制@Transactional
public void placeOrder(OrderDTO dto) {
// 1. 路由到主分表
TableRouterContext.setSuffix(dto.getUserId() % 10);
// 2. TCC第一阶段
inventoryTccService.tryReduce(dto.getSku(), dto.getCount());
// 3. 主业务逻辑
Order order = convert(dto);
orderMapper.insert(order);
// 4. 异步Confirm
tccConfirmExecutor.execute(() -> {
inventoryTccService.confirmReduce(dto.getSku(), dto.getCount());
});
}
4.2 历史数据迁移
采用双写方案过渡一个月:
- 新写入操作同时写入新老表
- 后台任务逐步迁移历史数据
- 校验程序对比新旧表数据一致性
迁移脚本关键逻辑:
sql复制INSERT INTO order_${suffix}
SELECT * FROM orders
WHERE user_id % 10 = ${suffix}
AND create_time < '2023-01-01';
5. 性能优化实践
5.1 热点分表处理
监控发现order_5分表QPS是其他表的3倍,采用二级拆分方案:
- 将order_5拆分为order_5a和order_5b
- 路由规则升级为:user_id % 10 == 5 && user_id % 2 == 0 → 5a
5.2 缓存策略调整
原Redis缓存键未考虑分表因素导致缓存击穿。改进方案:
java复制public String buildCacheKey(Long userId, String bizType) {
return String.format("order:%d:%s:%d",
userId % 10, // 分表后缀
bizType,
userId);
}
6. 监控与运维
6.1 埋点设计
在AOP切面中添加监控指标:
java复制@Around("execution(* com..mapper.*.*(..))")
public Object monitorTableAccess(ProceedingJoinPoint pjp) {
String table = ((MappedStatement)pjp.getArgs()[0])
.getSqlCommandType().name();
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
Metrics.timer("db.access")
.tag("table", table)
.record(System.currentTimeMillis() - start, MILLISECONDS);
}
}
6.2 应急方案
当某个分表故障时,可动态修改路由策略:
java复制// 在配置中心修改路由规则
@RefreshScope
public class TableRouter {
@Value("${route.rule.order:userId%10}")
private String rule;
public String route(Long userId) {
// 动态解析路由规则
return ScriptUtils.eval(rule, Map.of("userId", userId));
}
}
7. 经验总结
- 分表字段选择要同时考虑写分布和查询模式,我们曾因选择不当中途调整过分表键
- MyBatis-Plus的LambdaQueryWrapper在动态表名场景下需要特殊处理别名问题
- 分布式ID生成器需要保证跨分表的全局唯一性,我们最终采用Leaf的segment模式
- 聚合查询尽量用预计算+缓存替代实时统计,比如订单总数通过定时任务刷新
整个改造过程中最大的教训是:不要在业务高峰期进行全量数据迁移。我们曾因此导致数据库连接池耗尽,最终采用限流迁移方案,将迁移速度控制在每秒1000条左右。
