框架帮我们少写了很多代码,也把一些错误悄悄藏了起来。Spring 事务和 MyBatis 动态 SQL 的坑,多数不是“不会用”,而是“看起来生效,实际没生效”。

Spring 事务

同类自调用会绕过事务代理

现象

orderService.create() 调用了同一个类里的 saveOrder(),但 saveOrder() 抛异常后数据库没有回滚。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class OrderService {

public void create(Order order) {
saveOrder(order);
}

@Transactional
public void saveOrder(Order order) {
// 数据库操作
}
}

根因

Spring 事务基于 AOP 代理。外部调用 create() 时,走的是代理对象;但 create() 内部通过 this.saveOrder() 调用时,是原始对象直接调用,不会经过代理,@Transactional 因此失效。

正确写法

把事务方法放到另一个 Bean,让外部调用经过代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class OrderService {

private final OrderRepository orderRepository;

public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}

public void create(Order order) {
orderRepository.save(order);
}
}

@Repository
public class OrderRepository {

@Transactional
public void save(Order order) {
// 数据库操作
}
}

如果必须保留同类调用,可以注入自身代理,或者通过 AopContext.currentProxy() 获取代理对象,但这类写法会降低可读性,能拆 Bean 就先拆 Bean。

捕获异常后事务不会回滚

现象

事务方法里主动捕获了异常,日志正常,但数据库却保留了错误数据。

1
2
3
4
5
6
7
8
9
@Transactional
public void createOrder() {
try {
orderMapper.insert(order);
inventoryService.deductStock();
} catch (Exception e) {
log.error("create order failed", e);
}
}

根因

方法内部捕获异常后,异常没有抛给事务拦截器,Spring 不知道事务应该回滚,于是正常提交。

正确写法

不要在事务边界内吞掉业务异常。要么让异常向上抛,要么在真正需要降级时,明确标记回滚:

1
2
3
4
5
6
7
8
9
10
@Transactional
public void createOrder() {
try {
orderMapper.insert(order);
inventoryService.deductStock();
} catch (Exception e) {
log.error("create order failed", e);
throw new OrderCreateException("订单创建失败", e);
}
}

只在“外层捕获但确实要回滚”时使用:

1
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

但这属于较底层的用法,优先让异常自然传播。

默认只回滚 RuntimeException

现象

方法声明了受检异常,抛出去后事务没有回滚。

1
2
3
4
5
@Transactional
public void createOrder() throws IOException {
orderMapper.insert(order);
throw new IOException("file error");
}

根因

Spring 默认对 RuntimeException 和 Error 回滚,对受检异常不回滚。IOException 是受检异常,所以事务仍然提交。

正确写法

业务明确需要受检异常也回滚时:

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)
public void createOrder() throws IOException {
orderMapper.insert(order);
throw new IOException("file error");
}

更推荐统一约定“所有业务事务都回滚 Exception”,并在团队规范中固定下来,避免每个人按默认行为猜。

Spring 异步

@Async 同类自调用不生效

现象

在同一个 Service 里调用自己的 @Async 方法,代码还是同步执行。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class NotificationService {

public void handle() {
sendMessage();
}

@Async
public void sendMessage() {
// 发送消息
}
}

根因

和事务一样,@Async 也依赖代理。同类内部直接调用不会经过代理,所以异步增强没有执行。

正确写法

把异步方法放到独立的 Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class NotificationService {

private final MessageSender messageSender;

public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}

public void handle() {
messageSender.send();
}
}

@Component
public class MessageSender {

@Async("messageExecutor")
public void send() {
// 发送消息
}
}

异步方法还需要是 public,否则代理无法拦截。

不要依赖默认异步线程池

现象

异步任务偶尔丢失,或者每次调用都创建新线程,线程数持续增长。

根因

如果没有配置线程池,Spring Boot 的 @Async 行为取决于版本和配置,可能使用 SimpleAsyncTaskExecutor,它默认每次任务都新建线程,没有复用和上限。对于生产系统,这既不可控,也不容易观测。

正确写法

显式定义线程池 Bean,并在 @Async 中指定:

1
2
3
4
5
6
7
8
9
10
11
@Bean("messageExecutor")
public Executor messageExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("message-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}

同时补齐队列长度、拒绝策略和线程数监控,避免异步线程池变成黑盒。

MyBatis

<if> 判断数字 0 容易失效

现象

状态字段值为 0 时,动态 SQL 没有拼上这个条件。

1
2
3
<if test="status != null and status != ''">
AND status = #{status}
</if>

根因

MyBatis 的 OGNL 表达式中,数值 0 和空字符串比较时会被视为相等,因此 status != '' 在 status = 0 时不成立,条件被跳过。

正确写法

数字字段只判断是否为 null:

1
2
3
<if test="status != null">
AND status = #{status}
</if>

只有字符串字段才需要同时排除空串。不要把所有字段都复制成同一套 != null and != '' 模板。

#{} 和 ${} 不要混用

现象

某个搜索接口出现 SQL 注入,或者数据库报语法错误。原因是动态 SQL 里直接写了:

1
ORDER BY ${sortField}

根因

#{} 是预编译参数占位符,参数会作为值绑定;${} 是字符串替换,直接把内容拼进 SQL。如果 ${} 的内容来自用户输入,就可能被注入。排序字段、表名这类无法用占位符的场景才需要 ${},但必须做白名单校验。

正确写法

用户输入走 #{}:

1
WHERE user_name = #{userName}

排序字段如果必须用 ${},先校验:

1
2
3
4
5
6
7
8
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "created_at", "amount");

public String normalizeSortField(String sortField) {
if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
throw new IllegalArgumentException("invalid sort field");
}
return sortField;
}

模糊查询也不要拼 '%' 到 ${},用:

1
AND name LIKE CONCAT('%', #{keyword}, '%')

事务边界与外部调用

长事务里不要夹着远程调用

现象

事务方法里先改数据库,再调用 HTTP 或 MQ,外部服务一慢,数据库连接长时间不释放,锁等待和连接池耗尽。

根因

Spring 事务通常绑定数据库连接。只要事务没提交,连接就不会释放。远程调用耗时不确定,放在事务中间会让持有连接的时间变得不可控。

正确写法

先完成必要的本地事务,再发消息或调用外部服务:

1
2
3
4
public void handleOrder() {
Order order = transactionalService.createAndSaveOrder();
messageProducer.send(order.toMessage());
}

如果外部调用失败需要回滚,要使用最终一致性、重试、补偿任务或本地消息表,而不是简单地把远程调用包进一个超长事务里。

排查清单

遇到 Spring 或 MyBatis 的“没生效”问题,先检查:

  1. 是不是同类自调用绕过了代理。
  2. 异常是否被方法内捕获,或者属于默认不回滚的受检异常。
  3. @Async 是否在独立 Bean 的 public 方法上,是否配置了明确线程池。
  4. MyBatis 动态 SQL 是否把数字 0 当成了空值。
  5. 用户输入是否进入了 ${}。
  6. 事务里是否混入了远程调用、睡眠或大循环。