首页
学习
活动
专区
圈层
工具
发布

告别复杂分表逻辑!MyBatis-Plus 动态分表,一行注解全搞定

### 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 里。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OwsiOsnEjxOeH49GAKPkdeHQ0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券