1. 动态表名为什么我们需要它如果你做过数据量稍微大一点的项目尤其是那种用户行为日志、交易流水、或者物联网设备上报数据的场景肯定会遇到一个头疼的问题单张表的数据量增长得太快了。查询越来越慢备份越来越费劲一不小心手滑删错了数据影响范围还特别大。这时候有经验的开发者就会想到“分表”。把数据按照某种规则分散到多张结构相同但名字不同的表里去。最常见的规则之一就是“按天分表”。比如一个用户操作日志表今天的日志存到t_log_20231027明天的就存到t_log_20231028。这样做的好处显而易见单表数据量可控历史数据清理方便直接删表就行查询性能也能得到优化特别是按时间范围查询时可以精准定位到某几张表。但是问题来了。我们的代码是写死的DAO层通常映射到一个固定的实体类和表名。难道我们要为每一天都写一个Log20231027Mapper、Log20231028Mapper吗这显然不现实。我们需要的是“动态表名”能力让程序在运行时根据当前的日期或者其他业务规则自动决定去操作哪张具体的物理表。这就是 Mybatis-Plus 动态表名功能大显身手的地方。它提供了一套优雅的机制让你几乎不用改动业务逻辑代码就能实现透明的分表访问。我在这几年的项目里三种主流的实现方案都用过也踩过不少坑。今天我就把这三种方案掰开揉碎了讲给你听对比一下它们的优缺点和适用场景帮你找到最适合自己项目的那把“瑞士军刀”。2. 方案一动态传参最直接最灵活这种方案的核心思想很简单我不依赖任何框架的高级特性就在 Mybatis 的 Mapper 方法里把表名作为一个参数传进去。这是最原始但也是最直接、控制力最强的方法。2.1 具体怎么操作首先你的实体类映射的还是那个基础表名不带日期后缀。Data TableName(t_user) // 这里还是 t_user不是 t_user_20231027 public class User { TableId(type IdType.AUTO) private Integer id; private String name; }关键在 Mapper 接口的定义上。你有两种写法第一种使用Select注解直接写 SQLMapper public interface UserMapper extends BaseMapperUser { Select(SELECT * FROM ${tableName}) ListUser selectByTableName(Param(tableName) String tableName); }第二种使用 XML 映射文件在UserMapper.xml文件里select idselectByTableNameInXml resultTypecom.yourpackage.entity.User SELECT * FROM ${tableName} /select然后在 Mapper 接口里声明对应的方法ListUser selectByTableNameInXml(Param(tableName) String tableName);在业务层调用的时候你需要自己拼装出完整的表名Service public class UserServiceImpl { Autowired private UserMapper userMapper; public ListUser getTodayUsers() { // 格式化日期拼装表名 String dateSuffix LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // 输出 20231027 String dynamicTableName t_user_ dateSuffix; // 调用Mapper传入动态表名 return userMapper.selectByTableName(dynamicTableName); // 或者 return userMapper.selectByTableNameInXml(dynamicTableName); } }2.2 优点与坑点分析优点极其简单直观不需要理解复杂的插件机制就是传个参数符合最基础的编程思维。灵活性超高表名后缀的生成规则完全由你掌控。你可以按天、按月、按用户ID哈希甚至根据业务状态动态计算没有任何限制。兼容性最好无论 Mybatis-Plus 版本如何升级这种基于原生 Mybatis 参数替换的方式永远有效。坑点务必注意SQL注入风险注意看我上面代码里SQL 中是${tableName}而不是#{tableName}。${}是字符串直接替换#{}是预编译参数占位。这里我们必须用${}因为表名不能作为预编译参数。但这意味着如果tableName这个参数来自不可信的用户输入就会导致严重的 SQL 注入漏洞。所以动态表名参数必须在服务端可靠地生成绝不能由前端直接传递。代码侵入性每个需要分表查询的方法你都要额外添加一个tableName参数。如果你的业务逻辑复杂到处都需要这个参数代码会显得有点啰嗦和重复。无法使用通用方法BaseMapper提供的selectById、insert、update等便捷方法统统用不了因为它们内部使用的表名是实体类上TableName注解定义的固定值。你必须为每个分表操作都编写自定义的 SQL。适用场景适合分表规则特别复杂、需要高度定制化的场景或者项目历史包袱重不方便引入新插件的遗留系统。也适合作为快速验证分表思路的临时方案。3. 方案二DynamicTableNameInnerInterceptor 插件 自定义 Handler最优雅最推荐这是 Mybatis-Plus 官方推荐的、也是我认为在大多数生产环境下最优雅的方案。它利用插件机制在 SQL 执行前进行拦截动态替换其中的表名对业务代码的侵入性降到最低。3.1 核心组件与配置这个方案包含三个部分插件配置、自定义表名处理器Handler、以及用于传递参数的 ThreadLocal。第一步写一个自定义的 TableNameHandler这个类是灵魂所在它决定了如何替换表名。import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler; import java.util.Arrays; import java.util.List; /** * 按天分表的表名处理器 */ public class DayTableNameHandler implements TableNameHandler { // 记录哪些表需要由我这个处理器来管理比如 t_user, t_order private ListString targetTables; // 用 ThreadLocal 来保存当前线程需要的日期后缀。为什么用 ThreadLocal因为要保证线程安全每个请求互不干扰。 private static final ThreadLocalString DATE_SUFFIX new ThreadLocal(); // 构造方法传入需要动态处理的表名基础名 public DayTableNameHandler(String... targetTables) { this.targetTables Arrays.asList(targetTables); } // 提供给业务代码设置后缀的方法 public static void setDateSuffix(String suffix) { DATE_SUFFIX.set(suffix); } // 清理 ThreadLocal防止内存泄漏非常重要 public static void clearDateSuffix() { DATE_SUFFIX.remove(); } Override public String dynamicTableName(String sql, String tableName) { // 如果当前执行的 SQL 中的表名是我需要处理的表 if (targetTables.contains(tableName)) { String suffix DATE_SUFFIX.get(); if (suffix ! null !suffix.trim().isEmpty()) { // 返回拼接后的真实表名例如 t_user - t_user_20231027 return tableName _ suffix; } } // 如果不是目标表或者没设置后缀则返回原表名 return tableName; } }第二步配置插件将这个 Handler 注册进去Configuration MapperScan(com.yourpackage.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 创建动态表名内部拦截器 DynamicTableNameInnerInterceptor dynamicTableNameInterceptor new DynamicTableNameInnerInterceptor(); // 2. 创建我们的处理器实例并指定它负责 t_user 表 DayTableNameHandler dayTableNameHandler new DayTableNameHandler(t_user); // 3. 将处理器设置给拦截器 dynamicTableNameInterceptor.setTableNameHandler(dayTableNameHandler); // 注意setTableNameHandler 方法也可以接收一个 Map用于配置多个表的不同处理器这里用单个处理器演示。 // 4. 将动态表名拦截器添加到总拦截器链中 interceptor.addInnerInterceptor(dynamicTableNameInterceptor); // 还可以添加其他拦截器比如分页插件 // interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }3.2 在业务中如何使用配置好之后业务层的使用就非常清爽了Service public class UserService { Autowired private UserMapper userMapper; // 这个Mapper就是普通的继承BaseMapper的接口 public ListUser getTodayUsers() { // 1. 在操作数据库前设置当前线程所需的表名后缀 String suffix LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); DayTableNameHandler.setDateSuffix(suffix); try { // 2. 像平常一样使用Mybatis-Plus的方法插件会自动帮你改表名。 ListUser list userMapper.selectList(null); // 查询 t_user_20231027 // userMapper.insert(user); // 插入 t_user_20231027 // User user userMapper.selectById(1); // 从 t_user_20231027 查ID为1的记录 return list; } finally { // 3. 【关键】一定要在finally块中清理ThreadLocal DayTableNameHandler.clearDateSuffix(); } } }看到没业务代码里除了设置和清理后缀的那几行其他的数据库操作和操作单表时一模一样。BaseMapper的所有方法都可以正常使用开发效率极高。3.3 优点与深度解析优点对业务代码侵入性极低业务逻辑几乎不受分表影响可以继续使用 Mybatis-Plus 强大的 CRUD 接口。集中管理维护方便分表逻辑集中在 Handler 和配置类中要修改分表策略比如从按天改为按月只需改动一两处。安全表名替换由插件在框架层完成业务层接触不到 SQL 字符串拼接避免了 SQL 注入风险。功能强大一个 Handler 可以管理多张表构造时传入多个表名也可以定义多个不同的 Handler 来处理不同的分表规则如按天分表 Handler、按月分表 Handler通过 Map 配置给拦截器。需要特别注意的细节ThreadLocal 的内存泄漏这是最容易踩坑的地方。Web 应用服务器如 Tomcat通常使用线程池。一个请求处理完线程会被放回池子供下一个请求使用。如果上一个请求没有清理ThreadLocal它设置的值会被下一个毫不相干的请求读到导致数据错乱。所以try...finally模式是标准做法。Handler 的作用范围在dynamicTableName方法里一定要做好判断。只有属于你负责的表你才去修改它的名字。否则框架中其他不需要分表的表如系统配置表会被错误地加上后缀。插件顺序MybatisPlusInterceptor可以添加多个内部拦截器它们的执行顺序就是添加的顺序。虽然动态表名插件一般不影响其他插件如分页但知道有这个机制总没坏处。适用场景绝大多数需要进行透明分表尤其是按时间分表的生产项目。这是平衡了优雅性、功能性和可控性的最佳选择。4. 方案三简化版插件配置Lambda表达式方案二已经很好但有些人会觉得为了一个简单的后缀拼接还要单独写一个 Handler 类有点“重”。Mybatis-Plus 的插件也支持直接用 Lambda 表达式来定义表名替换规则这让配置变得更紧凑。4.1 如何配置这种方案把 ThreadLocal 直接放在配置类里替换规则通过 Lambda 表达式内联完成。Configuration MapperScan(com.yourpackage.mapper) public class MybatisPlusConfig { // 将ThreadLocal提升为配置类的静态变量 public static final ThreadLocalString DYNAMIC_SUFFIX new ThreadLocal(); Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); DynamicTableNameInnerInterceptor dynamicInterceptor new DynamicTableNameInnerInterceptor(); // 使用一个Map来配置表名处理规则 MapString, TableNameHandler handlerMap new HashMap(2); handlerMap.put(t_user, (sql, tableName) - { // Lambda表达式实现获取当前线程的后缀直接返回完整表名 String suffix DYNAMIC_SUFFIX.get(); return t_user_ (suffix ! null ? suffix : ); // 注意这里如果suffix为null会返回t_user_可能有问题。最好判断一下。 // 更健壮的写法 return suffix ! null !suffix.isEmpty() ? tableName _ suffix : tableName; }); // 可以继续为其他表添加规则 // handlerMap.put(t_order, (sql, tableName) - ... ); dynamicInterceptor.setTableNameHandlerMap(handlerMap); interceptor.addInnerInterceptor(dynamicInterceptor); return interceptor; } }4.2 业务层使用使用方式和方案二类似只是设置和清理后缀的对象变了Service public class UserService { Autowired private UserMapper userMapper; public ListUser getTodayUsers() { String suffix LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); MybatisPlusConfig.DYNAMIC_SUFFIX.set(suffix); // 使用配置类里的ThreadLocal try { return userMapper.selectList(null); } finally { MybatisPlusConfig.DYNAMIC_SUFFIX.remove(); // 清理 } } }4.3 方案对比与取舍这种方案可以看作是方案二的“快捷方式”。它把 Handler 的逻辑简化并内联到了配置里。优点代码更少对于简单的后缀拼接场景省去了创建一个独立 Handler 类的步骤。一目了然配置和规则在一个地方看起来更紧凑。缺点和局限逻辑复杂时难以维护如果分表规则不只是简单拼接后缀比如需要根据某个业务编码查字典决定表名Lambda 表达式里会塞满业务代码可读性和可维护性会变差。复用性差这个 Lambda 是专门为t_user表写的。如果t_order表也需要同样的按天分表逻辑你得把 Lambda 表达式再抄一遍或者把拼接逻辑抽成一个方法。而独立的 Handler 类天然就是可复用的。职责模糊配置类本应只负责 Bean 的装配现在却包含了具体的业务规则如何修改表名违反了单一职责原则。所以我的个人建议是除非你的分表规则极其简单就是固定前缀后缀并且确定只有一两张表需要否则优先使用方案二独立的 Handler 类。写一个类多花不了5分钟但它带来的清晰度、可维护性和复用性在项目迭代过程中会给你带来巨大的回报。方案三更适合在快速原型开发或者小型工具脚本中使用。5. 三种方案实战对比与选型指南光讲原理和代码还不够我们拉个表格结合具体场景看看怎么选。特性维度方案一动态传参方案二插件自定义Handler方案三插件Lambda实现复杂度低只需写SQL中需理解插件和ThreadLocal中低需理解插件和ThreadLocal业务代码侵入性高每个方法都需改参数极低仅设置/清理ThreadLocal极低同方案二功能灵活性极高SQL完全自定义高Handler内可编复杂逻辑中Lambda内不宜写复杂逻辑使用便捷性低无法用通用CRUD高可使用全部MP方法高同方案二安全性需自行防范SQL注入高框架层替换安全高同方案二可维护性差逻辑散落在各SQL中好逻辑集中易于管理中规则混在配置中性能影响无额外开销轻微插件拦截开销轻微同方案二推荐适用场景1. 分表规则极其复杂多变。2. 遗留系统改造避免引入新插件。3. 个别特殊查询。1. 标准的按时间天/月分表。2. 需要透明访问大量使用CRUD接口。3. 中大型项目追求架构清晰。1. 小型项目或临时功能。2. 分表规则简单固定仅拼接后缀。3. 追求极简配置表数量少。再分享两个我实际踩过的坑和应对经验坑一跨线程传递问题。如果你在业务中使用了Async异步方法或者在代码中手动创建了新线程去执行数据库操作那么父线程设置的ThreadLocal值子线程是获取不到的。这会直接导致动态表名失效。解决方案是使用TransmittableThreadLocal阿里开源的TTL来替代ThreadLocal它可以解决线程池间的值传递问题。坑二同一事务内操作多张分表。假设你有一个业务需要先查t_user_20231027再往t_log_20231027里插入一条记录。你需要在方法开始时设置后缀然后执行两个服务方法。这时要确保这两个DAO操作都在同一个设置了正确后缀的线程上下文中执行。如果其中一个服务方法内部有Transactional并且配了PROPAGATION_REQUIRES_NEW这样的事务传播行为可能会切换到新线程也要特别注意。说到底技术选型没有银弹。对于刚接触 Mybatis-Plus 动态表名的新手我强烈建议你从方案二开始实践。它既能让你体会到框架带来的便利又能让你深入理解插件、拦截器、ThreadLocal这些核心概念。等你对这套机制了然于胸之后再根据项目的特殊需求决定是否退回到更原始的方案一或者采用更轻量的方案三。记住清晰和可控的代码远比一时偷懒写下的“简洁”配置要重要得多。