### SQL: SELECT id, buyer_id, amount FROM biz_order WHERE buyer_id = ?
### Cause: java.sql.SQLSyntaxErrorException: Table 'mall.biz_order' doesn't exist
这类报错我见过不少。
库里明明有订单表,只不过名字叫biz_order_202607。代码生成的 SQL 却还在查biz_order,最后只能在 Mapper 里拼表名,或者复制一堆 XML:
SELECT * FROM ${tableName}
WHERE buyer_id = #{buyerId}
这种写法我第一眼就不太信。
${tableName}没有预编译保护,表名从接口参数一路传进来,稍微少一道校验,风险就跟着进数据库了。更麻烦的是,每个查询都要带表名,Mapper、Service、定时任务全被分表逻辑污染。
MyBatis-Plus 已经提供了DynamicTableNameInnerInterceptor,它会在 SQL 执行前替换逻辑表名。官方提供的是拦截器,不是现成的“分表注解”;@TableName也只是给实体绑定一个固定表名,不能自己计算月份后缀。
所以我一般在它上面再包一层,把路由动作收进一个注解。
实体还是绑定逻辑表:
@TableName("biz_order")
public class OrderEntity {
private Long id;
private Long buyerId;
private BigDecimal amount;
private LocalDateTime createdAt;
}
然后定义一个按月路由的注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MonthShard {
String table();
String key();
}
table是逻辑表名,key用 SpEL 指定分表时间。真正执行查询时,只需要这么写:
@MonthShard(table = "biz_order", key = "#p0")
public List<OrderEntity> listByMonth(
LocalDateTime orderTime,
Long buyerId) {
return orderMapper.selectList(
Wrappers.<OrderEntity>lambdaQuery()
.eq(OrderEntity::getBuyerId, buyerId)
);
}
业务代码里没有表名拼接,也不需要给 Mapper 增加额外参数。
切面负责算出真实表名:
@Aspect
@Component
public class MonthShardAspect {
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer names =
new DefaultParameterNameDiscoverer();
@Around("@annotation(rule)")
public Object switchTable(
ProceedingJoinPoint point,
MonthShard rule) throws Throwable {
Method method =
((MethodSignature) point.getSignature()).getMethod();
EvaluationContext context = new MethodBasedEvaluationContext(
null, method, point.getArgs(), names
);
LocalDateTime time = parser
.parseExpression(rule.key())
.getValue(context, LocalDateTime.class);
if (time == null) {
throw new IllegalArgumentException("分表时间不能为空");
}
String actualTable = rule.table() + "_"
+ time.format(DateTimeFormatter.ofPattern("yyyyMM"));
ShardTableContext.bind(rule.table(), actualTable);
try {
return point.proceed();
} finally {
ShardTableContext.clear();
}
}
}
这里的finally不能省。
Web 请求线程通常会被线程池复用。上一次请求把表名放进ThreadLocal,执行结束却没清掉,下一次请求就可能查到上个月的表。这种问题本地不一定复现,线上一旦出现,日志看起来还特别像脏数据。
上下文只保存当前路由:
public final class ShardTableContext {
private static final ThreadLocal<Route> HOLDER =
new ThreadLocal<>();
public static void bind(String logicTable, String actualTable) {
HOLDER.set(new Route(logicTable, actualTable));
}
public static String route(String tableName) {
Route route = HOLDER.get();
if (route == null || !route.logicTable().equals(tableName)) {
return tableName;
}
return route.actualTable();
}
public static void clear() {
HOLDER.remove();
}
private record Route(String logicTable, String actualTable) {
}
}
最后把它接到 MyBatis-Plus 插件里:
@Configuration
public class MybatisPluginConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor chain = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor shard =
new DynamicTableNameInnerInterceptor();
shard.setTableNameHandler(
(sql, tableName) -> ShardTableContext.route(tableName)
);
chain.addInnerInterceptor(shard);
return chain;
}
}
此时 Mapper 生成的原始 SQL 还是:
SELECT id, buyer_id, amount
FROM biz_order
WHERE buyer_id = ?
进入拦截器后,才会被替换成:
SELECT id, buyer_id, amount
FROM biz_order_202607
WHERE buyer_id = ?
表名后缀必须由服务端根据时间生成,别让前端直接传biz_order_202607。官方也反复提醒,进入 SQL 的动态片段需要做安全检查,不能把外部字符串不加限制地拼进去。
这套写法还有两个地方容易踩。
一个是同类内部调用。Spring AOP 走的是代理,this.listByMonth()不会触发切面,注解等于没写。另一个是异步任务,普通ThreadLocal不会自动传到新线程,到了@Async或自建线程池里,需要在任务内部重新绑定路由。
跨月查询也别硬塞给一个注解。查询条件从六月跨到七月,就拆成两个分片分别查,再合并结果。分片很多、还要分页排序时,已经不是一个轻量拦截器该扛的活了,该上分库分表中间件就上,别把半套框架继续往业务代码里补。
单表按月拆分、路由规则固定、查询明确命中某个月,这种场景用注解收口正合适。业务层只留下时间和查询条件,表名怎么算、什么时候清理上下文,都关在基础设施里。
分表可以复杂,但复杂不该散落在每一个 Mapper 里。