适用于分表分库、按月分表、多租户独立表等场景,实现运行时动态替换表名。


一、核心概念

  1. 动态表名
    运行时根据规则动态生成表名(如 order_2023order_2024)。
  2. 表名处理器(TableNameHandler)
    自定义表名生成规则的核心接口。
  3. 动态表名拦截器(DynamicTableNameInnerInterceptor)
    在 SQL 执行前替换原始表名。

二、详细操作步骤

1. 添加依赖(确认版本 ≥ 3.4.0)
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version> <!-- 使用最新版本 -->
</dependency>
2. 实现表名处理器
import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;

public class DynamicTableNameHandler implements TableNameHandler {
    // ThreadLocal 保存动态表名参数
    private static final ThreadLocal<String> TABLE_SUFFIX = new ThreadLocal<>();

    public static void setSuffix(String suffix) {
        TABLE_SUFFIX.set(suffix);
    }

    public static void clear() {
        TABLE_SUFFIX.remove();
    }

    @Override
    public String dynamicTableName(String sql, String originalTable) {
        // 动态拼接表后缀 (e.g. order -> order_2024)
        String suffix = TABLE_SUFFIX.get();
        return suffix != null ? originalTable + "_" + suffix : originalTable;
    }
}
3. 配置动态表名拦截器
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 创建动态表名拦截器
        DynamicTableNameInnerInterceptor dynamicInterceptor = new DynamicTableNameInnerInterceptor();
        
        // 2. 配置表名处理规则 (key=逻辑表名, value=处理器)
        Map<String, TableNameHandler> handlerMap = new HashMap<>();
        handlerMap.put("order", new DynamicTableNameHandler()); // 所有order表动态替换
        handlerMap.put("user", new UserTableNameHandler());     // 自定义其他处理器
        
        dynamicInterceptor.setTableNameHandlerMap(handlerMap);
        
        // 3. 添加到拦截器链
        interceptor.addInnerInterceptor(dynamicInterceptor);
        return interceptor;
    }
}
4. 业务层动态设置表名参数
@Service
public class OrderService {
    
    public List<Order> get2024Orders() {
        try {
            // 关键:设置当前线程表名后缀
            DynamicTableNameHandler.setSuffix("2024");
            
            // 执行查询 (自动使用 order_2024 表)
            return orderMapper.selectList(new QueryWrapper<>());
        } finally {
            // 必须清理ThreadLocal,防止内存泄漏
            DynamicTableNameHandler.clear();
        }
    }
}
5. 处理分表字段(可选)

在实体类中添加分表字段(非数据库字段):

public class Order {
    private Long id;
    private String orderNo;
    
    @TableField(exist = false) // 非数据库字段
    private String tableSuffix; // 分表标识字段
}

三、常见错误与解决

  1. 表名未动态替换

    • 检查拦截器配置顺序(动态表名拦截器需在分页插件前添加)
    • 确认 handlerMap 中的逻辑表名与 SQL 中的表名大小写一致
  2. ThreadLocal 污染

    • 必须在 finally 块中执行 clear()
    • 异步场景使用 TransmittableThreadLocal
  3. 复杂 SQL 解析失败

    • 避免在 SQL 中使用保留字作为表别名
    • 升级 MyBatis-Plus 到最新版
  4. NPE 异常

    • dynamicTableName() 方法中处理空指针:
      public String dynamicTableName(String sql, String originalTable) {
          return Optional.ofNullable(TABLE_SUFFIX.get())
                  .map(suffix -> originalTable + "_" + suffix)
                  .orElse(originalTable);
      }
      

四、注意事项

  1. 拦截器顺序
    动态表名拦截器必须在分页插件(PaginationInnerInterceptor)之前添加:

    interceptor.addInnerInterceptor(dynamicInterceptor); // 动态表名第一
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页第二
    
  2. 多表关联处理
    多表 JOIN 时需手动指定动态表名:

    SELECT * FROM order_${year} o 
    JOIN user_${year} u ON o.user_id = u.id
    
  3. DDL 语句跳过
    自动建表语句需跳过动态表名替换:

    public String dynamicTableName(String sql, String originalTable) {
        if (sql.toUpperCase().startsWith("CREATE TABLE")) {
            return originalTable; // 跳过DDL
        }
        // ...动态替换逻辑
    }
    

五、使用技巧

  1. 多维度分表策略

    public String dynamicTableName(String sql, String table) {
        // 根据用户ID分表:user_uid123
        if ("user".equals(table)) {
            return table + "_" + UserContext.getUserId();
        }
        // 根据月份分表:order_202405
        if ("order".equals(table)) {
            return table + "_" + YearMonth.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
        }
        return table;
    }
    
  2. 注解驱动动态表名
    自定义注解实现更精细控制:

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface DynamicTable {
        String value(); // 表后缀
    }
    
    // AOP切面
    @Around("@annotation(dynamicTable)")
    public Object setDynamicTable(ProceedingJoinPoint pjp, DynamicTable dynamicTable) {
        DynamicTableNameHandler.setSuffix(dynamicTable.value());
        try {
            return pjp.proceed();
        } finally {
            DynamicTableNameHandler.clear();
        }
    }
    
  3. 分表路由配置化
    通过配置中心动态更新路由规则:

    public class ConfigurableTableHandler implements TableNameHandler {
        private final TableRouter router; // 从配置中心读取规则
    
        @Override
        public String dynamicTableName(String sql, String table) {
            return router.route(table); // 例: order -> order_${shardKey}
        }
    }
    

六、最佳实践与性能优化

  1. 分表键设计原则

    • 选择高基数字段(如 user_id)
    • 避免热点数据倾斜(如按时间分表需加随机后缀)
  2. SQL 预编译优化

    • 动态表名应在拦截器阶段完成替换
    • 避免在 XML 中使用 ${tableName} 破坏预编译
  3. 跨表查询处理

    • 并行查询+内存聚合:
      List<CompletableFuture<List<Order>>> futures = tables.stream()
          .map(table -> CompletableFuture.supplyAsync(() -> queryByTable(table)))
          .toList();
      
      List<Order> result = futures.stream()
          .flatMap(future -> future.join().stream())
          .collect(Collectors.toList());
      
    • 使用 UNION ALL 合并查询(需相同表结构)
  4. 元数据缓存
    缓存表结构信息避免每次解析:

    public class CachedTableHandler implements TableNameHandler {
        private final Map<String, String> tableCache = new ConcurrentHashMap<>();
    
        @Override
        public String dynamicTableName(String sql, String table) {
            return tableCache.computeIfAbsent(table, this::calculateRealTable);
        }
    
        private String calculateRealTable(String logicTable) {
            // 复杂计算逻辑...
        }
    }
    
  5. 影子表压测支持
    动态切换到压测表:

    public String dynamicTableName(String sql, String table) {
        if (isStressTesting()) {
            return table + "_stress"; // order -> order_stress
        }
        // ...正常逻辑
    }
    

七、完整流程图

graph TD
    A[业务调用] --> B{设置ThreadLocal<br>表名参数}
    B --> C[执行Mapper方法]
    C --> D[MyBatis-Plus拦截器链]
    D --> E[DynamicTableNameInnerInterceptor]
    E --> F{查询表名处理器<br>Map}
    F -->|存在处理器| G[调用dynamicTableName()]
    G --> H[拼接真实表名]
    H --> I[替换原始SQL表名]
    I --> J[执行修改后SQL]
    J --> K[返回结果]
    F -->|无处理器| L[使用原始表名]
    L --> J
    K --> M[清除ThreadLocal]

通过以上配置,可轻松实现:

  • 按时间分表(日志/订单)
  • 按租户分表(SaaS系统)
  • 按业务分片(大数据表)
  • 动态压测影子表

关键点

  1. 正确使用 ThreadLocal 传递参数并确保清理
  2. 复杂 SQL 做好兼容性测试
  3. 分表策略避免热点问题