资讯动态

微服务下MyBatis-Plus实战调优:多租户隔离到缓存一致性

发布时间:2026/10/20 2:29:54 来源:尧图企业网站定制
说实话MyBatis-Plus在微服务架构里用久了你会发现真正让人头疼的从来不是CRUD写不出来而是框架那些“默认帮你做了的事”在分布式环境下反而变成了坑。单机单体时代一个分页插件走天下到了微服务里多租户隔离、跨服务调用、连接池水位、缓存一致性每一个环节都能把原本顺滑的数据库操作拖成事故现场。这篇文章基于我个人在某跨平台系统开发中的实战经历梳理MyBatis-Plus在微服务场景下的高级玩法与性能调优思路。从多租户自动隔离、数据权限拦截到分页插件、批量写入、SQL治理、缓存链路再到那些真正踩过的线上事故和修复过程。适合正在做微服务拆分或者已经用上MyBatis-Plus但总觉得哪里不对劲的团队读完后你会发现很多问题不是框架本身不行而是我们对它的理解还停在单机时代的那层。1. 微服务环境下的数据边界问题1.1 多租户自动隔离不是拼SQL那么简单多租户隔离在微服务里几乎是刚需。同一套服务、同一张业务表几十个租户的数据混在一起靠每个业务开发自己写条件基本等于在数据安全上裸奔。MyBatis-Plus的TenantLineInnerInterceptor就是干这个的它的原理是在SQL解析阶段自动给SELECT、UPDATE、DELETE语句拼接租户字段条件比如你的查询原本是SELECT * FROM order经过插件处理后变成SELECT * FROM order WHERE tenant_id ?。配置其实很简单但你得注意几个关键点。第一是插件顺序多租户插件必须排在分页插件前面否则分页插件生成的count查询可能不带租户条件数据直接多算甚至越权。第二是ignoreTable的判定逻辑字典表、配置表、系统表这些共享表必须放行我的建议是白名单模式而不是黑名单只对前缀匹配的业务表生效宁可漏配也不让不该加条件的表被误伤。第三点也是我实际开发中踩得最深的一处上下文里没有租户ID时怎么办我当时在某个多租户项目中采用了强制模式——如果TenantContext里取不到租户ID直接抛出异常绝对不允许不带租户条件去查业务表。这个决策当时被不少同事吐槽太严格但后来发生了一次安全事故某个定时任务漏传租户ID导致查询结果串了租户大家才意识到严格模式是真能保命的。很多框架默认允许null值并忽略租户条件这在微服务环境下就是个定时炸弹。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { Long tenantId TenantContextHolder.getTenantId(); if (tenantId null) { throw new RuntimeException(租户上下文缺失强制终止查询); } return new LongValue(tenantId); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { // 只对业务表生效系统表跳过 return !tableName.startsWith(t_); } })); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }租户ID在微服务调用链中的传递也是个容易忽略的细节。网关层在请求头带上租户信息内部服务通过HandlerInterceptor或Feign请求拦截器统一解析并写入ThreadLocal但是异步线程、定时任务、MQ消费这些场景不会自动继承ThreadLocal必须显式传递。我用的方案是封装了一个TenantContextHolder配合线程池的TaskDecorator在提交任务时拷贝租户上下文执行完再清理这样异步情况下也不会丢租户条件。1.2 数据权限拦截器的另一种打开方式多租户解决的是租户级别的隔离但微服务里还有一类更细粒度的需求同一个租户下的不同用户、不同部门能看到的数据范围不一样。比如销售只能看自己的订单部门主管可以看到整个部门的数据老板看全部。这类需求如果你在每一个Mapper接口上写条件代码会膨胀到难以维护而且容易漏。我当时做这个需求时没有直接上现成的数据权限框架而是基于MyBatis拦截器手写了一个DataScopeInterceptor思路其实不复杂拦截StatementHandler解析SQL的where条件根据当前用户的角色和注解配置动态拼接过滤条件。实现上主要解决几个问题一是从Costomized的参数对象上识别数据范围类型二是拿到当前登录人所属部门和用户ID三是在执行前用BoundSql改写SQL。用这种方式业务代码里的Mapper接口保持干净只需要加一个自定义注解标注数据范围策略拦截器会自动把人员ID或部门ID拼到SQL上。这种做法的好处是无论后续加多少张需要数据权限的表都不需要改动业务SQL扩展成本极低。需要注意一点数据权限条件拼接用的是逻辑AND必须确保和租户条件、普通业务条件兼容否则可能出现条件互斥导致数据为空。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 获取原始SQL通过JSqlParser解析AST // 根据注解和当前用户角色拼接过滤条件 return invocation.proceed(); } }如果你不想自己造轮子也可以用MyBatis-Plus的DynamicTableNameInnerInterceptor或开源数据权限框架但无论如何权限逻辑一定要收敛到公共层不能散落在各个业务模块里。微服务里权限模型本身已经够复杂了数据权限再分散的话后期排查起来简直是灾难。2. 分页与批量操作的进阶玩法2.1 分页插件不是Silver Bullet深分页问题MyBatis-Plus的分页插件确实好用selectPage一下就能拿到分页结果但它在微服务里最容易踩到的性能问题就是深分页。当offset越来越大比如翻到第1万条记录MySQL底层要将前面的1万条全部扫描一遍再丢弃查询耗时会明显上升。这个问题不是分页插件的锅而是所有基于OFFSET分页方案的物理特性。有一种解决思路是用游标分页替代传统的页码分页也就是基于上次查询的最后一条记录的ID来翻页比如WHERE id 100000 ORDER BY id DESC LIMIT 20。但游标分页对用户交互有要求不能跳页只适合“加载更多”这种场景。你的产品到底需不需要跳页这是架构师要在设计初期就跟业务方对齐的事情。另外一个分页相关的性能隐患是count查询。MyBatis-Plus分页插件默认会执行一条COUNT(*)统计总条数但在大表、多表Join场景下count查询本身可能比数据查询还慢。官方提供了optimizeCountSql开关可以在多表Join时优化掉无用的left join但这不是银弹。我遇到过几个实践案例业务上对总条数精度要求不高我们直接在接口层把分页改成不分页只返回下拉加载的列表数据性能提升非常明显。我在实际项目中还用了PaginationInnerInterceptor的另一个小技巧如果明确知道某个列表不返回总条数可以自己解析SQL并跳过count语句。做法是在Mapper方法上不返回IPage类型而是用Page只做limit拼接配合自定义返回类型。这样能省掉一次数据库往返在热点接口上效果显著。2.2 批量写入提速一个参数解决大问题微服务里经常有初始化数据、批量导入、订单批量生成之类的场景很多人直接用saveBatch但这些批量操作总是很慢为什么因为默认情况下JDBC并没有开启真正的批量执行模式每次addBatch都是单独发送SQL到数据库服务器性能提升聊胜于无。核心开关是MySQL连接URL上的rewriteBatchedStatementstrue。这个参数开启后驱动才会把一批INSERT语句重写成一条多VALUES语句发送给数据库于是批量插入的性能从逐条执行变成了一次网络往返处理几百条记录提升幅度经常是数量级的。我做过一组对比测试同样插入5000条订单数据未开参数耗时约3.5秒开启后耗时约600毫秒这个优化几乎零成本。# application.yml 配置示例 spring: datasource: url: jdbc:mysql://your-host:3306/db?rewriteBatchedStatementstrueuseServerPrepStmtstruesaveBatch的batchSize也值得调。默认1000在大多数场景下是合理的但在微服务接口里一次性处理1000条事务时间会比较长。我的建议是根据实际数据条数和单条记录的字段数量调整通常200到500之间比较稳定既能保证批处理效率又不会因为单条数据过大而撑爆MySQL的max_allowed_packet。还有一点很多人不知道saveBatch底层是逐条UPDATE还是批量INSERT取决于主子表关系。如果是带有主键的表MyBatis-Plus的saveOrUpdate批量模式实际上会在判断存在后才更新而这种逐条判断在超大批次下会引发N条SQL的逻辑性能极差。更推荐的做法是提前按业务规则区分哪些是新增、哪些是更新分别走insertBatch或自定义的优化SQL。大批量更新也值得单独说。MyBatis-Plus的updateBatchById其实是一条一条UPDATE性能不算好。我处理大批量状态更新时的做法是在XML里写一个foreach拼接CASE WHEN id ? THEN ? END的方式一次性更新多条记录的不同值。看起来SQL很丑但性能差距极大尤其是在数据库主库压力本来就高的时候。3. 性能优化第一刀从SQL本身开刀3.1 索引设计与慢SQL治理别急着加Redis性能问题出现的时候很多人第一反应是上缓存、加机器但微服务里数据库层面的慢SQL才是根源。MyBatis-Plus生成的SQL本身不算复杂但它在多租户条件下会自动拼接tenant_id在逻辑删除条件下会自动加deleted 0这些自动生成的过滤条件如果没吃到索引整表扫描就会很频繁。多租户表我建议在创建索引时把tenant_id作为最左列因为几乎所有的查询都会带这个条件。而逻辑删除的deleted字段如果你用0/1进行标记区分度很差单独建索引意义不大但可以和tenant_id组成联合索引。这个设计看起来不起眼实际上能帮你规避掉大量非预期全表扫描。慢SQL治理方面我的建议是在每个服务里都加上一层SQL自动计时与告警。MyBatis-Plus本身没有内置慢SQL统计功能常见做法是用p6spy或者自定义一个MyBatis拦截器统计执行耗时。我项目里用的是自定义Interceptor方式对超过500毫秒的SQL输出warning日志并记录SQL原文。微服务日志本来就很分散我还会把这些慢SQL日志统一采集到日志平台按服务、表名维度聚合哪张表出问题一目了然。关于EXPLAIN很多人不会看。其实select type、type、key、rows这几列是最关键的。type字段出现了ALL或index基本就可以判定没走索引ref和const才是正常状态。我处理过一次用户订单查询慢的问题排查下来就是user_id字段上存在隐式类型转换导致索引失效——Java侧传的是Long但表字段是varcharMySQL会在每一行执行时做类型转换索引根本用不上。这个问题光看SQL看不出毛病必须看EXPLAIN才能发现。3.2 循环查库与N1问题微服务里的经典杀手N1问题在微服务里被放大了。单体应用查一次数据库本地网花费时可以忽略不计但到了微服务里一个订单列表接口可能需要拼用户信息、商品信息、店铺信息如果每个循环里都查一次库接口RT直接从几十毫秒飙到几秒。常见的反面示例拿到订单列表后在for循环里调用userService.getById(userId)。这种代码在功能上完全没问题数据量小的时候也没人察觉但只要单页数据量上到50条伴随50次单条查询数据库压力立刻上来了。后来又叠加了跨服务调用每次循环还发一次HTTP请求找用户中心那接口性能直接没法看。正确的做法是先批量收集ID再一次性批量查询最后在内存中组装成Map。MyBatis-Plus的listByIds就是为此提供的。拿到用户List后转成MapLong, User循环订单时一次map.get(userId)查内存即可。跨服务调用同理把多家ID聚合成一次请求发给用户中心接口性能至少快一个数量级。// 反面循环内单条查询 for (Order order : orderList) { User user userMapper.selectById(order.getUserId()); } // 正面批量查询 Map组装 ListLong userIds orderList.stream() .map(Order::getUserId) .distinct() .collect(Collectors.toList()); MapLong, User userMap userService.listByIds(userIds).stream() .collect(Collectors.toMap(User::getId, Function.identity()));还有一类场景是报表导出、数据同步这种读取全量大结果集的场景不适合一次性selectList否则内存根本扛不住。我一般用MyBatis的游标查询返回Cursor类型配合try-with-resources逐条消费。但有一个坑Cursor依赖SqlSession存活如果用MyBatis-Plus的默认Mapper必须把读取放在事务范围内否则会报SqlSession关闭的错误。所以游标查询我的习惯是单独写一个Mapper方法并在Service层加上Transactional确保游标生命周期内Session不关闭。4. 缓存链路设计二级缓存还是Redis4.1 本地缓存与二级缓存的边界微服务里谈论MyBatis的二级缓存我的态度一直很保守。MyBatis自带的二级缓存是JVM本地缓存服务节点之间完全隔离。在微服务多实例部署下某个节点更新了数据另一个节点的缓存不会感知进而读到旧数据这往往比不缓存更危险。如果单机性能测试好但在多副本环境下数据出现不一致那八九不离十就是本地二级缓存闯的祸。我的建议是微服务环境下默认关闭MyBatis的二级缓存配置把缓存外置到Redis。Redis缓存虽然也有网络开销但胜在多个节点共享一致性控制在一个统一的地方。Service层的本地缓存比如Caffeine也不是完全不能用但要明确边界。像字典表、配置项这种几乎不变化的数据用Caffeine做本地缓存是合理的更新时通过发布订阅或者版本号机制主动刷新即可。但业务数据尤其是订单、库存这类高频更新且实时性要求高的数据绝对不要做本地缓存。4.2 Redis缓存一致性我的个人实践既然缓存以Redis为主那就要解决缓存与数据库的一致性问题。我经历过两种主流方案的大讨论先更新数据库再删除缓存还是先删缓存再更新数据库。实际生产环境里我的选择是更新数据库后删除缓存配合过期时间兜底。为什么因为先删缓存再更新数据库在并发读请求下容易出现缓存穿透大量请求打到数据库上而先更新库再删缓存虽然存在极小概率的“删缓存失败”但这个概率可以通过可靠消息或延时双删来进一步兜底。MyBatis-Plus在写操作时能拿到updateById的返回结果可以利用这个特性在更新成功后主动清理相关缓存key。如果不想在业务代码里到处写清理逻辑可以基于Spring的事件机制在Service层发布一个领域事件由监听器统一执行Redis删除。这样业务方不用关心缓存细节架构上更干净。还有一个微服务容易踩的坑是事务和缓存的边界。如果你用的是Transactional大方法方法里更新数据库又删除缓存那么缓存删除应该放在事务提交之后否则事务还没提交但缓存已经被删了读请求查到的是Redis空缓存然后去数据库读到了未提交的快照照样数据不一致。我实际项目里用一个TransactionSynchronizationManager.registerSynchronization在事务提交后执行缓存清理这个细节很多人忽略。5. 那些年踩过的坑线上事故与排查实录5.1 逻辑删除与唯一索引冲突字段删除标记之争这是一个几乎每个用逻辑删除的团队都会撞上的问题。业务表上有唯一索引比如用户账号account唯一你把记录逻辑删除了deleted 1但下次再插入相同账号时唯一索引校验还是会失败因为那条被“删除”的记录还物理存在。这在微服务里造成的后果就是用户注销后无法重新注册报错信息看起来像灵异事件。我当时面对这个问题第一反应是修改唯一索引把deleted字段纳入联合唯一索引也就是uk_account_deleted(account, deleted)。这个方案在逻辑删除只表示0和1的场景下依然有问题删除第二条相同账号数据时联合索引中(account, 1)已经存在还是冲突。唯一的办法是让deleted字段存一条记录的业务主键ID即删除时更新deleted id这样每条被删记录在唯一索引中都是不同的值。当你真的要修改线上唯一索引时注意MyBatis-Plus的TableLogic配置里有一个value属性和delval属性delval可以直接设为id字段的引用吗实际上delval支持文本值但你不能动态写当前ID。所以我是用自定义SQL的UPDATE ... SET deleted id WHERE id ?来替代默认的逻辑删除动作保留了自动填充审计字段的能力。5.2 事务内RPC导致的连接池耗尽这是我见过最典型的微服务数据库事故。某核心服务的Transactional方法里调用了第三方系统的HTTP接口对方超时时间设的10秒但数据库连接在这个期间一直被持有不释放。高峰期100个并发请求进来连接池瞬间被占满其他所有查询都排队等待连接接口全线超时。事故的根因就一句话不要在事务里做外部RPC调用。数据库事务是为了保护本地数据的一致性但RPC的耗时是不可控的你不能让数据库连接去给网络超时买单。修复方式有两种一是把RPC调用移出事务比如先执行业务逻辑并提交再异步发送通知二是如果必须先RPC再写库就手动控制事务边界用编程式事务替代Transactional注解在方法上的应用。排查连接池耗尽时有个小技巧看HikariCP的日志和线程dump。如果线程dump里大量线程阻塞在Connection is not available等待队列上下一步就是看这些线程的业务调用栈90%都是事务方法里卡了什么外部依赖。我给所有服务的数据源都开启了leakDetectionThreshold60000连接泄漏时会输出完整堆栈这比事后猜要高效得多。5.3 插件顺序错误导致的分页数据异常这个坑比较隐蔽。MyBatis-Plus的多个拦截器之间是有顺序依赖的特别是TenantLineInnerInterceptor、DynamicTableNameInnerInterceptor、PaginationInnerInterceptor这三个混在一起时顺序错了会引发两种典型问题分页count语句没带租户条件或者分页查询的LIMIT子句被多租户插件误改导致翻页错乱。我遇到一次真实案例新增动态表名插件后分页数据总是少一条排查了半天才发现是插件顺序问题。动态表名插件置换了表名但分页插件已经生成了count语句两个SQL里的表名一个带后缀一个不带统计结果对不上。解决办法就是参考官方文档把顺序调整为多租户→动态表名→分页。不同版本可能有差异升级MyBatis-Plus版本后一定要重新验证插件行为因为底层解析器偶尔有更新。另外一个和分页插件有关的实际问题当单表数据量特别大时分页插件默认自动生成的count其实是SELECT COUNT(*) FROM ...如果业务表本身有上千行的子查询count语句可能会执行得非常慢。这时候我更倾向于自定义count方法精确控制统计SQL避免分页插件自作主张的优化策略干扰判断。5.4 乐观锁在微服务下的重试方案Version乐观锁是MyBatis-Plus很受欢迎的一项功能但在微服务场景下乐观锁冲突的概率比单体应用高得多。多个服务并发操作同一条数据时后执行的更新会因为版本号不匹配而直接失败用户会看到“操作失败请重试”这种不友好提示。我的做法分两层底层更新SQL照常使用Version做乐观锁保护但在上层封装一个重试机制。重试时先重新查询最新数据把需要更新的字段重新赋值再发起一次新的更新操作。需要考虑重试次数和退避策略我一般是3次重试重试间隔采用指数退避。同时还要在重试过程中时刻关注幂等性如果一个更新实际上已经成功只是响应超时重试就要避免重复处理。还要留意MyBatis-Plus的乐观锁插件和批量更新之间的坑。updateBatchById虽然带了版本号判断但批量模式下如果第一条更新失败后面的记录会被逐个跳过日志里很难发现。我后来对关键业务表的批量更新改成了自己写SQL在WHERE条件中带上version并判断返回值affected rows如果等于零就明确报错这样问题才能被及时暴露出来而不是静默吞掉。结尾给正在折腾团队的一点经验写了这么多最后分享一点我自己的心得体会。MyBatis-Plus在微服务里的定位始终是数据访问层的加速器而不是一个可以无脑依赖的框架。多租户、数据权限、分页、批量操作、缓存每一个环节看起来都有现成的插件和注解但生产环境跑起来之后真正决定系统稳不稳定的是你对底层SQL执行过程的理解程度。我在做某跨平台系统时最大的教训就是不要等到线上出现慢SQL才去研究索引不要等连接池耗尽才去审视事务边界不要等数据权限漏了才想起严格校验。框架能帮你生成SQL但补不了架构上的窟窿。多花时间在压测、日志监控、执行计划分析上比多引入几个中间件实在得多。最后再分享一个小技巧给所有Mapper接口的执行时间加上一个统一的指标埋点无论有没有慢SQL告警先把数据量积累起来。等到系统优化的时候这些数据能直接告诉你哪张表、哪个接口才是真正的瓶颈省下的排查时间远超当初埋点的那点工作量。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑