Java 后端常见坑:Spring 事务、异步与 MyBatis
框架帮我们少写了很多代码,也把一些错误悄悄藏了起来。Spring 事务和 MyBatis 动态 SQL 的坑,多数不是“不会用”,而是“看起来生效,实际没生效”。
Spring 事务
同类自调用会绕过事务代理
现象
orderService.create() 调用了同一个类里的 saveOrder(),但 saveOrder() 抛异常后数据库没有回滚。
1 |
|
根因
Spring 事务基于 AOP 代理。外部调用 create() 时,走的是代理对象;但 create() 内部通过 this.saveOrder() 调用时,是原始对象直接调用,不会经过代理,@Transactional 因此失效。
正确写法
把事务方法放到另一个 Bean,让外部调用经过代理:
1 |
|
如果必须保留同类调用,可以注入自身代理,或者通过 AopContext.currentProxy() 获取代理对象,但这类写法会降低可读性,能拆 Bean 就先拆 Bean。
捕获异常后事务不会回滚
现象
事务方法里主动捕获了异常,日志正常,但数据库却保留了错误数据。
1 |
|
根因
方法内部捕获异常后,异常没有抛给事务拦截器,Spring 不知道事务应该回滚,于是正常提交。
正确写法
不要在事务边界内吞掉业务异常。要么让异常向上抛,要么在真正需要降级时,明确标记回滚:
1 |
|
只在“外层捕获但确实要回滚”时使用:
1 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); |
但这属于较底层的用法,优先让异常自然传播。
默认只回滚 RuntimeException
现象
方法声明了受检异常,抛出去后事务没有回滚。
1 |
|
根因
Spring 默认对 RuntimeException 和 Error 回滚,对受检异常不回滚。IOException 是受检异常,所以事务仍然提交。
正确写法
业务明确需要受检异常也回滚时:
1 |
|
更推荐统一约定“所有业务事务都回滚 Exception”,并在团队规范中固定下来,避免每个人按默认行为猜。
Spring 异步
@Async 同类自调用不生效
现象
在同一个 Service 里调用自己的 @Async 方法,代码还是同步执行。
1 |
|
根因
和事务一样,@Async 也依赖代理。同类内部直接调用不会经过代理,所以异步增强没有执行。
正确写法
把异步方法放到独立的 Bean:
1 |
|
异步方法还需要是 public,否则代理无法拦截。
不要依赖默认异步线程池
现象
异步任务偶尔丢失,或者每次调用都创建新线程,线程数持续增长。
根因
如果没有配置线程池,Spring Boot 的 @Async 行为取决于版本和配置,可能使用 SimpleAsyncTaskExecutor,它默认每次任务都新建线程,没有复用和上限。对于生产系统,这既不可控,也不容易观测。
正确写法
显式定义线程池 Bean,并在 @Async 中指定:
1 |
|
同时补齐队列长度、拒绝策略和线程数监控,避免异步线程池变成黑盒。
MyBatis
<if> 判断数字 0 容易失效
现象
状态字段值为 0 时,动态 SQL 没有拼上这个条件。
1 | <if test="status != null and status != ''"> |
根因
MyBatis 的 OGNL 表达式中,数值 0 和空字符串比较时会被视为相等,因此 status != '' 在 status = 0 时不成立,条件被跳过。
正确写法
数字字段只判断是否为 null:
1 | <if test="status != null"> |
只有字符串字段才需要同时排除空串。不要把所有字段都复制成同一套 != null and != '' 模板。
#{} 和 ${} 不要混用
现象
某个搜索接口出现 SQL 注入,或者数据库报语法错误。原因是动态 SQL 里直接写了:
1 | ORDER BY ${sortField} |
根因
#{} 是预编译参数占位符,参数会作为值绑定;${} 是字符串替换,直接把内容拼进 SQL。如果 ${} 的内容来自用户输入,就可能被注入。排序字段、表名这类无法用占位符的场景才需要 ${},但必须做白名单校验。
正确写法
用户输入走 #{}:
1 | WHERE user_name = #{userName} |
排序字段如果必须用 ${},先校验:
1 | private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "created_at", "amount"); |
模糊查询也不要拼 '%' 到 ${},用:
1 | AND name LIKE CONCAT('%', #{keyword}, '%') |
事务边界与外部调用
长事务里不要夹着远程调用
现象
事务方法里先改数据库,再调用 HTTP 或 MQ,外部服务一慢,数据库连接长时间不释放,锁等待和连接池耗尽。
根因
Spring 事务通常绑定数据库连接。只要事务没提交,连接就不会释放。远程调用耗时不确定,放在事务中间会让持有连接的时间变得不可控。
正确写法
先完成必要的本地事务,再发消息或调用外部服务:
1 | public void handleOrder() { |
如果外部调用失败需要回滚,要使用最终一致性、重试、补偿任务或本地消息表,而不是简单地把远程调用包进一个超长事务里。
排查清单
遇到 Spring 或 MyBatis 的“没生效”问题,先检查:
- 是不是同类自调用绕过了代理。
- 异常是否被方法内捕获,或者属于默认不回滚的受检异常。
@Async是否在独立 Bean 的 public 方法上,是否配置了明确线程池。- MyBatis 动态 SQL 是否把数字 0 当成了空值。
- 用户输入是否进入了
${}。 - 事务里是否混入了远程调用、睡眠或大循环。
