ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring只读事务写操作延迟检测之谜:MyBatis批处理与连接池的真相

Spring只读事务写操作延迟检测之谜:MyBatis批处理与连接池的真相 1. 只读事务里写了东西为什么错误总是来得那么慢我先说结论Spring 的只读事务从来不会主动帮你拦截写 SQL它只是把readOnly标志传递给事务管理器真正做检测的是数据库驱动和连接状态。在 Spring Boot 3.x MyBatis 这套组合里因为连接池复用、批处理执行器延迟刷新、以及事务代理的提交时序写操作经常要等到 commit 前后才被数据库发现这就是大家常说的“检测延迟”。我之前接手过一个基于 Spring Boot 3 的老项目权限模块里有一个查询菜单的接口方法上清清楚楚标着Transactional(readOnly true)。某次发布后这个接口偶发出现几秒超时日志里没有大段报错只有一条很不起眼的 MySQL 异常——说当前连接是只读的不允许写操作。我第一反应是有人把写逻辑塞进了查询接口一查确实如此有人在方法后面顺手加了两行 MyBatis 的insert用来记录菜单访问日志。问题在于这个异常不是在那两行 insert 执行时立刻抛出来的而是等整个事务提交阶段才被数据库识别导致接口在极端流量下才暴露。这个案例非常典型。它揭示了一个很容易被忽略的事实只读事务里的写操作大多数情况下不是“立刻被检测”而是“延迟被检测”。你在本地跑一次可能连异常都看不到一旦上了生产数据库用户权限、连接池分配、批量 SQL 刷新时机等因素全部叠加问题才会以各种奇形怪状的方式冒出来。1.1 先说清楚“检测”到底指的是什么很多人对只读事务有一个错误预期以为只要写了Transactional(readOnly true)框架就会在代码里拦住所有 insert、update、delete。实际上 Spring 并没有这么强的能力也不打算这么干。它的职责更接近“传话”告诉底层事务管理器这个事务需要以只读模式运行然后由事务管理器去设置 JDBC Connection 的readOnly状态。真正的“检测”通常有两层框架层检测Spring 在事务提交时检查事务状态、同步器回调、以及各种边界条件。如果 MyBatis 或 JPA 的 flush 机制被触发写操作才真正落到数据库。数据库层检测MySQL、PostgreSQL、Oracle 这些数据库收到写 SQL 时会根据当前会话或事务的只读属性决定是否放行。如果你的应用里出现了“写操作没在第一时间被拦截”的现象很可能是这两层检测之间的时间差导致的。批处理执行器会把多条 update/insert 先攒在内存里到 flush 时才真正发出去这个“攒”的过程就会产生明显的延迟窗口。1.2 为什么 Spring Boot 3.x 场景下更容易踩坑Spring Boot 3.x 默认使用 Spring Framework 6.x底层对事务连接的管理比 2.x 更倾向于“延迟获取”。简单说框架会尽量让应用在真正需要数据库连接之前不提前占用连接这对性能是有利的但也带来了一个副作用事务状态、只读属性、连接绑定这些信息不一定在你执行写操作的那一刻就已经完整地作用到物理连接上。再叠加 HikariCP 连接池的代理机制情况会更复杂。HikariCP 会返回一个经过代理增强的 Connection 对象很多状态并不是直接设置到 MySQL 原生连接上而是要经过一层层转发。如果你的数据库驱动版本、连接池版本、Spring 版本之间配合不好readOnly标志就可能出现“设置晚了”“被覆盖了”“在另一个连接上生效”等奇怪现象。我建议所有做 Spring Boot 3.x 维护的同学在项目一开始就给数据库层加上足够的可观测性否则这类延迟问题排查起来会很痛苦。2. 从根子上看只读事务为什么会延迟生效2.1 Transactional(readOnly true) 到底做了什么先看一段非常典型的代码Service public class OrderService { private final OrderMapper orderMapper; Transactional(readOnly true) public void finishOrder(String orderId) { Order order orderMapper.selectById(orderId); // 这里不该出现更新操作但项目里确实有人写漏了 orderMapper.updateStatus(orderId, FINISHED); // 再写一条审计日志 orderLogMapper.insert(new OrderLog(order_finish, orderId)); } }Spring 在处理这个 Bean 时会用代理包一层。进入方法前事务拦截器会执行DataSourceTransactionManager.doBegin()它做的主要事情包括从DataSource获取一个 Connection并加入当前线程的事务资源。调用connection.setReadOnly(true)。根据事务定义设置隔离级别比如READ_COMMITTED。关闭自动提交setAutoCommit(false)。把事务状态记录到TransactionSynchronizationManager。注意第 2 步Spring 只是调用了 JDBC 的setReadOnly。至于这个方法在特定数据库驱动里具体怎么实现全看驱动脸色。MVCC 系的数据库和传统数据库的处理方式差别非常大。再看 MyBatis 这一侧。MyBatis-Spring 的SqlSessionTemplate会自动感知当前是否存在 Spring 管理的事务。如果有事务它不会新开独立连接而是从TransactionSynchronizationManager里把当前线程绑定的 Connection 拿出来用。这种方式保证了 MyBatis 的写操作和 Spring 的事务在同一个数据库连接上执行但在批量模式下也导致了写 SQL 不会立刻发出。2.2 数据库和驱动对只读属性的处理时机完全不同我自己在多个数据库上做过实验可以用一张表来概括不同数据库对Connection.setReadOnly(true)的行为差异数据库setReadOnly(true) 的实际效果写操作被发现的时机MySQL 8.x修改会话级变量transaction_read_only对后续事务生效同一事务内的写 SQL 通常会较快被发现但基于会话连接池时不同连接状态之间可能不一致Oracle对应SET TRANSACTION READ ONLY需要在一个事务开始时设置延迟比较明显有些场景要等提交阶段才报错PostgreSQL驱动维护一个 read-only hint是否真正阻止写入取决于驱动版本和参数多数情况语句执行时报错但存在连接状态残留导致的不稳定SQL Server驱动和连接属性不同行为差异较大可能延迟到下一条语句或提交阶段这张表想说明的核心问题就是只读检测的时机并不统一。如果你在开发环境用的 MySQL生产环境换成 Oracle同样的代码表现会完全不同。很多团队会忽略这种差异等到生产告警才发现。对比一个更直观的例子JDBC 里setAutoCommit(true)和setReadOnly(true)的执行顺序也会影响最终效果。比如 MySQL Connector/J 在开启自动提交时设置 readOnly可能会在下一个事务才真正校验。如果你的代码在事务边界处理时顺序不对那么只读属性往往“慢半拍”才生效。2.3 Spring 的延迟获取连接机制把窗口拉得更长Spring Framework 6.x 引入了一些优化比如在可以使用“无连接线程绑定”的场景下尽量延迟获取物理连接。Spring Boot 3.x 开启事务后DataSourceTransactionManager在doBegin阶段仍然需要拿到连接但要分情况如果事务只是持有DataSource且真正使用连接的时间点靠后Spring 会尽量复用池里的连接。如果连接池需要验证连接有效性比如执行connectionTestQuery那么状态切换可能发生在这些额外查询之后。这些优化对正常业务是透明的但一旦事务里不小心写了 SQL问题就会表现得非常诡异。我遇过一种情况接口先是很快地完成了一些内存操作然后不紧不慢地调用 MyBatis 去执行 update居然过了好几百毫秒才抛只读异常。原因就是连接是在 update 前一刻才真正从池子里拿出来并完成只读设置的。3. 复现与定位MyBatis 批量写操作是最容易踩的坑3.1 先还原一个最典型的最小复现场景很多项目为了方便会在配置里直接设置 MyBatis 的默认执行器为 BATCHmybatis: executor-type: batch这个配置对性能有好处尤其是批量 insert 场景。但它会改变 MyBatis 执行 SQL 的方式SimpleExecutor是一条 SQL 发一次BatchExecutor则会把同一条 SQL 攒进Statement的 batch 里直到被显式刷新或事务提交时才真正发送。对应的 Service 代码可能是这样Transactional(readOnly true) public void batchUpdateTag(ListString orderIds) { // 这里看起来只是在批量更新但方法外层的只读事务会把整个操作都绑进只读事务里 for (String orderId : orderIds) { orderMapper.updateTag(orderId); } }如果此时 Mapper 里的 update 语句只是普通 update在 BATCH 模式下它不会立即执行而是进入批处理队列。真正触发的时机有三个下一次需要从数据库读取数据时MyBatis 为了保持数据一致性会先 flush 已排队的语句。手动调用SqlSession.flushStatements()。事务提交前SqlSessionSynchronization.beforeCompletion()关闭会话时刷新。这意味着你以为执行orderMapper.updateTag(orderId)的时候就会触发只读检测实际上它可能一直拖到接口返回之后、Spring 提交事务时才把 SQL 批量发给数据库。异常自然也就延迟出现了。3.2 通过日志和调用时间线看延迟我在复现环境开启调试日志后得到了非常直观的时间线# application.yml logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource: DEBUG com.example.order.mapper: DEBUG日志大致像这样10:00:00.100 [http-nio-8080-exec-1] DEBUG o.s.j.d.DataSourceTransactionManager - Acquired Connection [HikariProxyConnection123] for JDBC transaction 10:00:00.101 [http-nio-8080-exec-1] DEBUG o.s.j.d.DataSourceTransactionManager - Setting JDBC Connection [HikariProxyConnection123] read-only 10:00:00.105 [http-nio-8080-exec-1] DEBUG c.e.order.mapper.OrderMapper.updateTag - Parameters: A001(String) 10:00:00.106 [http-nio-8080-exec-1] DEBUG c.e.order.mapper.OrderMapper.updateTag - Updates: 0 10:00:00.107 [http-nio-8080-exec-1] DEBUG c.e.order.mapper.OrderMapper.updateTag - Parameters: A002(String) 10:00:00.108 [http-nio-8080-exec-1] DEBUG c.e.order.mapper.OrderMapper.updateTag - Updates: 0 ... 10:00:00.120 [http-nio-8080-exec-1] DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction commit 10:00:00.121 [http-nio-8080-exec-1] DEBUG o.s.j.d.DataSourceTransactionManager - Committing JDBC transaction on Connection [HikariProxyConnection123] 10:00:00.300 [http-nio-8080-exec-1] ERROR c.e.order.service.OrderService - Unexpected error: Connection is read-only...注意看MyBatis 的日志里其实“看起来”已经执行了 update Updates: 0并不代表数据库真的执行了。BatchExecutor 在日志层面显示的是“加入批处理”但Updates: 0表示还没有真正影响到数据库。等到 commit 时SQL 才真正发出去只读异常才弹出。这个时间差你可以自己算一下从 10:00:00.108 到 10:00:00.300接近 200ms 的延迟已经足够把很多高并发接口拖垮。如果把日志级别调成 TRACE还能看到 MyBatis 在 flush 时重新组织了 Statement。结论很清楚你在应用代码里看到异常的位置不等于实际发生 SQL 的位置。排障时如果只看业务日志很容易误判。3.3 快速定位问题出在哪个环节这里分享一个我自己常用的排查套路。遇到“写操作在只读事务里没有立刻报错”的情况我按以下顺序确认确认事务是否真的生效。在 Service 方法入口打印TransactionSynchronizationManager.isCurrentTransactionReadOnly()如果返回 true说明只读标志已经绑到当前线程。确认 MyBatis 执行器类型。在配置里临时加一个Bean去获取Configuration的defaultExecutorType或者在 SQL 日志里观察是否有“BATCH”相关特征。确认连接是否来自连接池。打印DataSource的实际实现类如果是 HikariProxy说明走了代理只读属性是否真正传递到物理连接需要用数据库端命令验证。用数据库端监控看 SQL 到达时间。开启 MySQL 的 general log观察相同的 update 语句到底是在方法执行时到达还是在 commit 阶段到达。确认异常出现的位置。异常栈是在 Service 方法内部还是由transaction interceptor抛出的两种位置对应的排障方向完全不同。这套流程我用了很多次基本能在十分钟内定位出检测延迟发生在哪一环。4. 修复与加固把“延迟检测”变成“立刻检测”4.1 第一步把业务逻辑中的写操作挪出只读事务最稳的方案永远是结构上正确而不是靠框架兜底。如果你有一个方法前期需要读一些配置后期需要写数据那么不要把整个方法都标成Transactional(readOnly true)。应该拆成两个方法// 读操作可以放心只读 Transactional(readOnly true) public OrderDetail getOrderDetail(String orderId) { return doQuery(orderId); } // 写操作不要再标 readOnly Transactional public void updateOrderStatus(String orderId, String status) { orderMapper.updateStatus(orderId, status); }这个方法虽然简单但真能解决 90% 的问题。我见过太多项目把“只读”当成一种性能优化手段到处乱标结果查询接口和写操作混在一个事务里要么连接长时间被占用要么读写状态互相污染。4.2 第二步如果一定要用 MyBatis BATCH就把刷新时机提前有些场景确实必须用批量模式比如定时任务里批量更新大量记录。这时不要把整个任务挂在只读事务上。更合理的做法是Transactional public void batchUpdateInTx(ListOrder orders) { try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { OrderMapper mapper sqlSession.getMapper(OrderMapper.class); for (Order order : orders) { mapper.update(order); } sqlSession.flushStatements(); // 显式刷新 } }如果你已经在用 Spring 的Transactional同时 MyBatis 的SqlSessionTemplate也参与了这个事务那么显式调用flushStatements可以避免批量 SQL 拖到提交阶段。但要注意不是在每个循环里都调否则批量优化就白做了。正确做法是攒够一批再刷或者唯一一次刷在所有数据准备好之后。此外SpringSqlSessionTemplate在事务提交时会自动执行beforeCompletion回调并关闭绑定会话所以如果你在事务方法里没有显式 flush最终还是会刷新。关键不是不刷新而是要在你预期的时间和位置刷新让问题早点暴露。4.3 第三步用拦截器做最后一层防线如果团队成员水平参差不齐代码 review 拦不住所有手滑操作我建议在基础设施层加一道防线通过 MyBatis 拦截器检测当前事务是否为只读同时 SQL 命令类型是写操作则直接抛出异常。示例代码如下import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.SqlCommandType; import org.apache.ibatis.plugin.*; import org.springframework.transaction.support.TransactionSynchronizationManager; Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class ReadOnlyWriteGuardInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { if (TransactionSynchronizationManager.isActualTransactionActive() TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; SqlCommandType commandType ms.getSqlCommandType(); if (commandType SqlCommandType.INSERT || commandType SqlCommandType.UPDATE || commandType SqlCommandType.DELETE) { throw new IllegalStateException(拒绝在只读事务中执行写操作 ms.getId()); } } return invocation.proceed(); } }然后在 MyBatis 配置里注册Configuration public class MybatisConfig { Bean public ConfigurationCustomizer readOnlyGuardCustomizer() { return configuration - { configuration.addInterceptor(new ReadOnlyWriteGuardInterceptor()); }; } }这段代码有两个细节要注意TransactionSynchronizationManager.isCurrentTransactionReadOnly()只有在 Spring 管理的事务里才有意义如果 MyBatis 自己开着事务这个方法可能拿不到准确值。拦截器只能拦截 MyBatis 层面的 SQL如果你直接用NamedParameterJdbcTemplate或原始 JDBC 写库MyBatis 拦截器就管不到。所以它适合作为一种兜底手段而不是完全依赖它。4.4 第四步从数据源层面强制只读另一个思路是在连接池分两个数据源一个写库一个只读库。读接口只使用只读数据源写接口使用写数据源。Spring Boot 4.x 里关于DataSourceAutoConfiguration的讨论已经很多核心还是要在多数据源场景下把事务管理器与 SqlSessionFactory 绑定正确。用抽象一些的话说只读事务的“检测延迟”本质上是状态在不同连接之间漂移。如果你让读写连接彻底分离那么即使在代码里不小心写了 update也会在连接池获取阶段就拿到明确状态检测延迟自然消失。5. 线上问题排查清单与经验手记5.1 从现象倒推问题类型我把实际工作中遇到的只读事务写操作问题整理成了排查表很多看起来完全不同的现象最终都指向同一个根因线上现象可能的根因处理建议接口偶发超时日志里提交阶段才报 Connection is read-onlyMyBatis BATCH 或 Hibernate 延迟 flush把写操作拆出只读事务显式 flush接口正常返回但数据没有写入只读事务提交被回滚写操作在提交时失败但外层 catch 吞了异常检查异常处理链尽量不要在事务边界吞异常只有生产环境报错开发环境正常生产库用户权限更严格或连接池配置不同对比数据库账号权限和连接池参数查询接口连接池被打满方法被错误标记为只读但里面其实做了耗时写操作分解事务降低事务粒度调用 Mapper 后立刻查库看不到刚更新的数据批量 SQL 还没 flush查询又触发了 flush但事务回滚导致看不到检查是否在同一个事务中确保 flush 后提交这张表不一定覆盖所有情况但每次排查我都会先对照一遍能省下不少时间。5.2 几个容易被忽视的细节第一个细节是事务代理自调用问题。在同一个类内部通过this.method()调用的带Transactional的方法并不会被 Spring 代理拦截。也就是说只看一个方法上写着readOnly true但内部通过this去调用另一个没有标事务的方法结果可能就是读写状态混乱异常检测的位置更加扑朔迷离。第二个细节是 MyBatis 的SqlCommandType判断。有些开发会在 Mapper 方法上使用Delete或Update注解但 SQL 里写的是select这种情况命令类型和 SQL 内容不一致拦截器判断容易误判。所以如果要做拦截最好再结合BoundSql里的 SQL 前缀来判断。第三个细节是连接池验证。HikariCP 默认会启用一些连接活性检测如果你配置了connection-test-query那么连接从池里拿出来时可能会额外执行一次查询。这个查询可能会提前触发事务状态同步也可能因为顺序问题把只读标志覆盖掉。我建议只读事务相关的连接池参数尽量显式配置不要完全依赖默认值。5.3 一些值得长期遵守的实践建议结合我踩过的坑这里给几条非常朴素但有效的建议事务注解不要顺手就写。只读就意味着这个事务里真的不应该出现写操作。如果代码里存在readOnly true同时还调用 insert/update那是设计问题不是配置问题。MyBatis 批量操作尽量独立事务。不要和查询逻辑绑在同一个事务里尤其不要放进只读事务。批量操作的失败率其实不低一旦发生延迟检测你连 rollback 的范围都很难控制。日志里保留事务边界信息。把org.springframework.transaction的 debug 日志档位保留在某个可用的 profile 配置里出问题再动态打开能极大缩短定位时间。引入数据库连接状态可视化。不一定非得上复杂监控至少在测试环境留一个入口能查看当前连接、当前事务的 only 状态排障效率会高很多。在读接口的数据库账号上限制 DML 权限。如果技术上允许这是最彻底的办法。让数据库自己拒绝写操作比在 Spring 层拦截更硬核也不会存在什么延迟检测问题。我在实际使用中发现真正让团队头疼的不是“只读事务里不能写”这个规则本身而是“为什么我写了却没立刻报错”。如果能早一点把事务边界、执行器类型、连接池状态这些信息暴露出来很多问题根本熬不到上线。希望大家在开发阶段就把这些点检查清楚不要等生产环境帮我们做检测那时候付出的代价往往就大了。
返回列表