在某些业务场景中,需要根据运行时条件动态选择数据库表名,例如:分表(按时间、租户、用户ID等)。MyBatis-Plus 提供了 DynamicTableNameInnerInterceptor 插件,配合 TableNameHandler 接口实现,可在 SQL 执行时动态替换表名。


一、核心概念

1. 什么是 TableNameHandler

  • TableNameHandler 是 MyBatis-Plus 动态表名功能的核心接口,用于在 SQL 执行时动态决定实际使用的表名。
  • 它由 DynamicTableNameInnerInterceptor 调用,根据当前上下文(如参数、时间、租户等)返回目标表名。
  • 常用于:
    • 日志表按月分表(如 log_202508, log_202509
    • 用户行为表按用户 ID 哈希分表
    • 多租户系统中按租户分库分表(配合多租户插件)

2. 核心方法

public interface TableNameHandler {
    /**
     * 根据当前 SQL 上下文动态返回实际表名
     *
     * @param sql  当前 SQL(可用于解析)
     * @param tableName 原始表名(如 log_table)
     * @return 实际要使用的表名(如 log_table_202508)
     */
    String dynamicTableName(String sql, String tableName);
}

⚠️ 注意:该接口已从旧版的 Executor 模型迁移至基于 InnerInterceptor 的新模型(3.4.0+)。


二、操作步骤(非常详细)

步骤 1:引入 MyBatis-Plus 依赖

确保使用 MyBatis-Plus 3.4.0 及以上版本(推荐 3.5.5):

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.5</version>
</dependency>

步骤 2:定义实体类与 Mapper

假设有一个日志表,按月分表。

@TableName("log_record") // 这是逻辑表名,将被动态替换
@Data
public class LogRecord {
    private Long id;
    private String content;
    private LocalDateTime createTime;
}
@Mapper
public interface LogRecordMapper extends BaseMapper<LogRecord> {}

🔔 注意:@TableName("log_record") 是一个逻辑表名,实际表名为 log_record_202508 等。


步骤 3:实现 TableNameHandler 接口

创建处理器,根据时间动态返回表名。

package com.example.handler;

import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
public class LogTableNameHandler implements TableNameHandler {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");

    /**
     * 根据当前时间动态返回日志表名
     *
     * @param sql         当前执行的 SQL
     * @param tableName   原始表名(如 log_record)
     * @return 实际表名(如 log_record_202508)
     */
    @Override
    public String dynamicTableName(String sql, String tableName) {
        // 判断是否为日志相关表
        if ("log_record".equalsIgnoreCase(tableName)) {
            // 获取当前时间对应的年月
            String suffix = LocalDateTime.now().format(FORMATTER);
            return tableName + "_" + suffix; // 如 log_record_202508
        }
        // 其他表不处理,返回原表名
        return tableName;
    }
}

步骤 4:配置动态表名拦截器

创建 MyBatis-Plus 配置类,注册 DynamicTableNameInnerInterceptor

package com.example.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.example.handler.LogTableNameHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(LogTableNameHandler logTableNameHandler) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 创建动态表名拦截器
        DynamicTableNameInnerInterceptor dynamicInterceptor = new DynamicTableNameInnerInterceptor();

        // 方式一:注册单个处理器(推荐)
        Map<String, TableNameHandler> map = new HashMap<>();
        map.put("log_record", logTableNameHandler); // 逻辑表名 -> 处理器
        dynamicInterceptor.setTableNameHandlerMap(map);

        // 方式二:注册全局处理器(可选)
        // dynamicInterceptor.setTableNameHandler((sql, tableName) -> {
        //     if ("log_record".equals(tableName)) {
        //         return "log_record_" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
        //     }
        //     return tableName;
        // });

        interceptor.addInnerInterceptor(dynamicInterceptor);
        return interceptor;
    }
}

✅ 推荐使用 Map<String, TableNameHandler> 方式,便于扩展和管理。


步骤 5:在业务中使用(无需特殊处理)

@Service
public class LogService {

    @Autowired
    private LogRecordMapper logRecordMapper;

    public void saveLog(String content) {
        LogRecord log = new LogRecord();
        log.setContent(content);
        log.setCreateTime(LocalDateTime.now());

        // 插入操作将自动路由到 log_record_202508 表
        logRecordMapper.insert(log);
    }

    public List<LogRecord> getLogs() {
        // 查询也自动使用当前月表
        return logRecordMapper.selectList(null);
    }
}

步骤 6:验证效果

启动应用并调用 saveLog() 方法,查看实际执行 SQL:

INSERT INTO log_record_202508 (id, content, create_time) VALUES (?, ?, ?)

✅ 成功实现按月动态分表。


三、常见错误与解决方案

错误现象 原因 解决方案
表名未替换,仍使用 log_record TableNameHandler 未注册或表名不匹配 检查 setTableNameHandlerMap 中的 key 是否与 @TableName 一致
启动报错找不到 Bean LogTableNameHandler 未加 @Component 添加注解或手动 @Bean 注册
多线程下表名错乱 使用了静态变量或非线程安全逻辑 确保 dynamicTableName 方法无状态
查询历史数据失败 只能查当前月 需手动指定表名或扩展逻辑(见技巧)

四、注意事项

  1. @TableName 必须是逻辑表名
    实际表名由 TableNameHandler 动态生成。

  2. 不支持跨表查询
    SELECT * FROM log_record WHERE id IN (SELECT log_id FROM ...),若子查询涉及不同表名,可能出错。

  3. DDL 操作需提前建表
    MyBatis-Plus 不负责建表,需通过脚本或工具提前创建 log_record_202508 等表。

  4. 缓存失效
    不同表名视为不同 SQL,不影响一级/二级缓存。

  5. TableNameHandler 应无副作用
    方法内不要修改全局状态。


五、使用技巧

技巧 1:支持查询历史表

// 在 Service 中手动设置上下文
public List<LogRecord> getLogsByMonth(int year, int month) {
    String table = "log_record_" + String.format("%d%02d", year, month);
    // 使用原生 SQL 或自定义 SQL 显式指定表名
    return logRecordMapper.selectByTableName(table);
}

或通过 ThreadLocal 传递目标表名:

public class TableContext {
    private static final ThreadLocal<String> TABLE = new ThreadLocal<>();
    public static void setTable(String table) { TABLE.set(table); }
    public static String getTable() { return TABLE.get(); }
    public static void clear() { TABLE.remove(); }
}

TableNameHandler 中优先读取上下文:

String targetTable = TableContext.getTable();
if (targetTable != null) {
    return targetTable;
}
// 否则走默认逻辑

技巧 2:按用户 ID 哈希分表

@Override
public String dynamicTableName(String sql, String tableName) {
    if ("user_behavior".equals(tableName)) {
        Long userId = UserContext.getUserId();
        int shard = (userId.hashCode() & Integer.MAX_VALUE) % 4; // 4 个分表
        return tableName + "_shard" + shard;
    }
    return tableName;
}

技巧 3:结合租户分表

可与 TenantLineHandler 配合,实现“租户 + 时间”二级分表:

return "log_" + tenantId + "_" + suffix;

六、最佳实践

实践 说明
✅ 明确分表策略 按时间、哈希、范围等,避免随意分表
✅ 提前建表或自动建表 使用定时任务每月初创建新表
✅ 监控与告警 监控分表写入、查询性能
✅ 文档化表结构 明确各分表结构一致
✅ 避免频繁跨分表查询 必要时使用 ES 或数仓
✅ 使用连接池 分表不增加连接数,但高并发需优化池配置

七、性能优化

  • 性能影响小:仅在 SQL 解析阶段替换表名,无额外开销。
  • 避免反射或复杂计算dynamicTableName 方法应轻量。
  • 缓存常用表名:如按月分表,可缓存最近 12 个月的表名映射。
  • 批量操作支持:MyBatis-Plus 批量插入也支持动态表名(同一 SQL 使用同一表)。
  • 索引优化:每个分表应有合理索引,避免全表扫描。

八、总结

项目 内容
核心接口 TableNameHandler
拦截器 DynamicTableNameInnerInterceptor
配置方式 setTableNameHandlerMap
适用场景 按时间分表、哈希分表、多租户分表
优势 透明化分表,业务无感知
限制 不支持跨表复杂查询

一句话总结
通过实现 TableNameHandler 并配置 DynamicTableNameInnerInterceptor,MyBatis-Plus 可实现动态表名路由,是轻量级分表的优雅解决方案。


📌 立即实践建议

  1. 定义逻辑表名实体。
  2. 实现 TableNameHandler
  3. 配置拦截器映射。
  4. 测试插入/查询是否自动路由到正确分表。

⚠️ 重要:分表策略一旦确定,不易变更,务必提前设计好!