Administrator
发布于 2018-11-17 / 1798 阅读
46

@Transactional 事务不生效的 7 种场景

扣了库存没生成订单,@Transactional 一个字没生效

十一月中旬那次上线后,客服陆续接到投诉:用户付款成功,但订单列表里查不到。我翻数据库,发现 t_stock 的库存扣了,t_order 里却没有对应记录。同一个 @Transactional 方法里的两个操作,一个成了一个没成。

这种"事务没生效"的问题我后来陆陆续续遇到了好几种,整理成 7 个场景,按我们项目里实际踩到的顺序排。

@Service
public class OrderServiceImpl implements OrderService {

    @Transactional
    public void createOrder(OrderDTO dto) {
        stockMapper.deduct(dto.getSkuId(), dto.getQty());     // 执行了
        orderMapper.insert(buildOrder(dto));                   // 没执行
    }
}

场景一:异常被 catch 吞掉了(就是这次)

完整代码是这样的:

@Transactional
public void createOrder(OrderDTO dto) {
    try {
        stockMapper.deduct(dto.getSkuId(), dto.getQty());
        orderMapper.insert(buildOrder(dto));
    } catch (Exception e) {
        log.error("下单失败", e);        // 我以为是好习惯,结果把异常吞了
    }
}

我写的 try-catch 把异常消化掉了,方法正常返回。Spring 的事务拦截器是在目标方法抛出异常时才决定回滚的,它看不到异常,就认为一切正常,直接提交。

正确做法是 catch 之后要么重新抛出,要么手动标记回滚:

@Transactional
public void createOrder(OrderDTO dto) {
    try {
        stockMapper.deduct(dto.getSkuId(), dto.getQty());
        orderMapper.insert(buildOrder(dto));
    } catch (Exception e) {
        log.error("下单失败", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();   // 显式标记
    }
}

setRollbackOnly() 的好处是不会再抛异常出去,接口能返回自定义的错误信息。但要注意,它只是标记,不会中断方法执行,标记之后的代码如果还有数据库操作,那些操作同样会被回滚

场景二:异常类型不对,默认不回滚

就算异常没被吞,也不是所有异常都会触发回滚。Spring 的默认规则是:只对 RuntimeException 和 Error 回滚,受检异常(Exception)不回滚

看源码 DefaultTransactionAttribute

public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}

所以下面这段代码,抛出 IOException 之后事务照样提交:

@Transactional
public void createOrder(OrderDTO dto) throws IOException {
    orderMapper.insert(buildOrder(dto));
    notifyDownstream(dto);        // 抛 IOException,事务不回滚!
}

这个默认行为其实是照抄 EJB 的,但业务里经常需要回滚受检异常。两种改法:

// 方式一:显式声明
@Transactional(rollbackFor = Exception.class)

// 方式二:把受检异常包成 RuntimeException
try {
    notifyDownstream(dto);
} catch (IOException e) {
    throw new BizException("下游通知失败", e);
}

我们项目统一用了第一种,并且写了个自定义注解把 rollbackFor = Exception.class 固化下来,避免每个人漏配:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface BizTransactional {
}

场景三:同一个类里的方法自调用

这个是我遇到次数最多的。看代码:

@Service
public class OrderServiceImpl {

    public void batchCreate(List<OrderDTO> list) {
        for (OrderDTO dto : list) {
            this.createOrder(dto);        // this 自调用,事务失效
        }
    }

    @Transactional
    public void createOrder(OrderDTO dto) {
        // ...
    }
}

Spring 的声明式事务是通过 AOP 代理实现的。调用 orderService.createOrder() 时,实际调用的是代理对象的方法,代理在前后织入了开启事务、提交/回滚的逻辑。而 this.createOrder() 里的 this目标对象本身,不是代理,所以绕过了增强。

三种解决办法:

// 1. 注入自己(Spring 支持自注入,但看着别扭)
@Autowired
private OrderService self;
self.createOrder(dto);

// 2. 从 AopContext 拿当前代理,需要开启 exposeProxy
((OrderService) AopContext.currentProxy()).createOrder(dto);
// 配置:@EnableAspectJAutoProxy(exposeProxy = true)

// 3. 拆成两个类(我最终用的,结构最清晰)
@Service
public class OrderBatchService {
    @Autowired
    private OrderService orderService;

    public void batchCreate(List<OrderDTO> list) {
        for (OrderDTO dto : list) {
            orderService.createOrder(dto);
        }
    }
}

场景四:方法不是 public

@Transactional
private void createOrder(OrderDTO dto) {      // private,事务失效
    // ...
}

@Transactional
protected void createOrder(OrderDTO dto) {    // protected,同样失效
    // ...
}

Spring 的 AbstractFallbackTransactionAttributeSource 里有这么一段:

protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
    // 只处理 public 方法
    if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
        return null;
    }
    // ...
}

返回 null 意味着没有事务属性,注解被忽略。而且它不会报错,默默失效,特别坑。

需要说明的是,这个限制只对基于代理的 AOP 成立(CGLIB 理论上能代理 protected 方法,但 Spring 事务这块统一做了 public 检查)。用 AspectJ 织入的话没这个限制,不过我们项目没用。

另外,注解加在 final 方法或者 final 类上也会失效——CGLIB 靠生成子类做代理,final 方法没法重写。

场景五:数据库引擎不支持事务

这个是测试同事发现的。我们在测试环境跑得好好的,上线到预发环境事务就不生效。查下来是建表语句的问题:

SHOW TABLE STATUS WHERE Name = 't_order'\G
*************************** 1. row ***************************
           Name: t_order
         Engine: MyISAM        ← 罪魁祸首
        Version: 10

MySQL 5.7 默认引擎是 InnoDB,但预发那套库是用老脚本建的,指定了 MyISAM。MyISAM 完全不支持事务,所有 SQL 都是自动提交的。

-- 查看当前默认引擎
SHOW VARIABLES LIKE 'default_storage_engine';

-- 转换引擎
ALTER TABLE t_order ENGINE = InnoDB;

顺便,MySQL 里有些语句会隐式提交事务,比如 ALTER TABLETRUNCATEDROP。这些 DDL 执行时会先把当前事务提交掉,所以别在事务方法里做 DDL。

场景六:传播行为用错

默认情况下 @Transactional 的传播行为是 REQUIRED,即"有就加入,没有就新建"。这个行为导致了我们另一个 bug:

@Service
public class JobService {

    @Transactional
    public void syncJob() {
        for (Long orderId : fetchFailedOrders()) {
            try {
                orderService.retry(orderId);       // 内部抛异常
            } catch (Exception e) {
                log.error("订单 {} 重试失败", orderId, e);     // 以为只影响这一条
            }
        }
    }
}

@Service
public class OrderService {
    @Transactional
    public void retry(Long orderId) {
        // ...
        throw new RuntimeException("重试失败");
    }
}

retry 用的是默认的 REQUIRED,它加入了外层 syncJob 的事务。内层方法抛异常时,Spring 会把整个事务标记为 rollback-only。等外层方法正常结束准备提交时,发现事务已被标记回滚,直接抛:

org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only

这个报错信息我第一次看到完全懵了。正确的做法是让内层方法独立开事务:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void retry(Long orderId) {
    // 挂起外层事务,新建独立事务,异常只回滚自己
}

REQUIRES_NEW 会挂起当前事务并新建一个,内层回滚不影响外层。代价是每次都要新建连接,1000 条重试就是 1000 次事务提交,性能要评估。

传播行为有 7 种,我常用的就三个:

传播行为含义使用场景
REQUIRED(默认)有则加入,无则新建绝大多数场景
REQUIRES_NEW挂起当前,新建独立事务批量任务里单条失败不影响整体
SUPPORTS有则加入,无则非事务运行查询方法,不强制开事务
NOT_SUPPORTED挂起事务,非事务执行大批量查询,避免长事务
NESTED嵌套事务,靠 savepoint 实现需要部分回滚,用得少

场景七:多线程环境下事务不传播

最后一个。我做过一个批量导入,为了快用了线程池:

@Transactional
public void batchImport(List<OrderDTO> list) {
    // 主线程插入一条批次记录
    batchMapper.insert(newBatchRecord());

    list.parallelStream().forEach(dto -> {
        orderMapper.insert(buildOrder(dto));       // 子线程,不在事务里
    });

    throw new RuntimeException("测试回滚");        // 只有批次记录被回滚
}

Spring 的事务信息是绑定在 ThreadLocal 上的(TransactionSynchronizationManager),子线程拿不到父线程的数据库连接,自然也就不在同一个事务里。结果就是:批次记录回滚了,1000 条订单记录全部留在库里。

这个没有优雅的解法。我的处理方式是:多线程只做数据校验和转换,数据库操作统一收回到主线程批量执行。

@Transactional
public void batchImport(List<OrderDTO> list) {
    List<Order> orders = list.parallelStream()
            .map(this::validateAndBuild)          // 只做计算
            .collect(Collectors.toList());

    orderMapper.batchInsert(orders);              // 主线程批量入库
}

1000 条数据用 MyBatis 的 foreach 批量插入,实测耗时 340ms,比并行插入慢不了多少,但事务是完整的。

排查清单

遇到事务不生效,我现在按顺序检查:

  1. 方法是不是 public;
  2. 有没有 try-catch 吞掉异常;
  3. 抛的是不是受检异常(看要不要加 rollbackFor);
  4. 是不是 this. 自调用;
  5. 表引擎是不是 InnoDB;
  6. 传播行为对不对;
  7. 有没有跨线程。

另外有个快速验证的办法,开 SQL 日志看有没有 SET autocommit=0commit

logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
logging.level.org.mybatis.spring.SqlSessionUtils=DEBUG
DEBUG o.s.j.d.DataSourceTransactionManager - Creating new transaction with name [...]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG o.s.j.d.DataSourceTransactionManager - Acquired Connection [...] from DataSource
DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [...] to manual commit
DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction rollback

如果日志里连 Creating new transaction 都没有,那就是注解压根没被解析到,直接按上面 1-4 条查。

那天排查到根因是 try-catch 之后,师傅说了句我一直记着的话:写 catch 之前先想清楚这个异常该由谁处理,能处理就处理,处理不了就抛出去,别打一行日志就当解决了。

参考