开发圈子里有一段很舒坦的时光:一个应用包打天下,一个数据库解决所有存储。那时候,聊起事务,就像在自家厨房里炒一盘菜,锅、铲、油、盐都在一只手够得着的范围里,火大了立刻关,盐放多了还能抢救一下。这种掌控感,就是单体架构在事务管理上给我们的安全感。但落回去想一想,当业务真变得复杂了,这份安全感还靠得住吗?这正是本文要聊透的问题。

一、先聊聊“容易”是怎么回事

1.1 为什么在单体里事务像个听话的小跟班

在单体架构里,最典型的配置是一个应用程序连接一个数据库。我们执行一个业务操作,比如下单,通常要写订单表、改库存表、可能还要记一条流水。这几个动作要么都成功,要么都失败。数据库本身提供一套叫做ACID的机制来保证这一点。简单说,它能把一组操作绑定成一个“要么全做,要么全不做”的原子操作。在单体里,因为所有的代码都在同一个进程里,所有的数据库访问都使用同一个数据源,所以事务的边界非常好划。你只需要在一个方法上标注“这是一个事务”,框架就会帮你开启、提交或回滚。这种“容易”,本质上是因为大部分复杂的分布协调问题被简化了。

1.2 一个最朴素的事务示例

我们用Java和Spring Boot来演示。假设有一个订单服务,需要同时向订单表插数据、扣库存。下面是核心代码。

// 技术栈:Java + Spring Boot
// 这里是一个典型的单体服务方法,使用 JdbcTemplate 操作 H2 内存库。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    // 注入 Spring 提供的 JDBC 操作模板
    private final JdbcTemplate jdbcTemplate;

    // 构造器注入,推荐这种方式
    public OrderService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * 创建订单
     * 这个方法被 @Transactional 标记后,两个数据库操作就是同一个事务。
     * 如果扣库存失败,插入的订单也会被回滚掉,不会留下一条“孤儿订单”。
     */
    @Transactional(rollbackFor = Exception.class)
    public void createOrder(String orderNo, int productId, int count) {
        // 1. 插入订单记录
        jdbcTemplate.update(
            "INSERT INTO orders(order_no, product_id, count) VALUES (?, ?, ?)",
            orderNo, productId, count
        );

        // 2. 扣减库存,注意 SQL 里的条件:库存必须大于等于扣减数量
        int affected = jdbcTemplate.update(
            "UPDATE stock SET count = count - ? WHERE product_id = ? AND count >= ?",
            count, productId, count
        );

        // 3. 如果库存不够,受影响行数是 0,说明没有扣成功,主动抛异常让事务回滚
        if (affected == 0) {
            throw new RuntimeException("库存不足,订单已回滚");
        }
    }
}

这个例子中,数据库操作都在同一个本地事务里,事务框架自动处理连接,无需关心提交回滚的细节。这就是“容易”的直观体现。

二、复杂业务场景到底复杂在哪

2.1 不省心的几种典型场景

随着业务发展,单一数据库、单一线程的模式开始被打破。会出现这些情况:

  • 数据库不止一个:订单数据放一个库,库存数据放另一个库,或者有读写分离。
  • 业务链路变长:下单时还要调用优惠券服务、通知系统、甚至支付网关。
  • 并发变高:同一件商品被多人同时抢,一个事务里要处理很多行数据。
  • 业务方法内部有异步处理:为了提升性能,把一部分操作丢给线程池。
  • 一个事务里调用了另一个本地方法,而那个方法本身也需要事务。

这些场景下,过去“一个方法一个事务”的简单玩法,开始出现裂缝。

2.2 多线程让事务“串台”

我们看一个具体例子。在方法上加了事务,然后在新线程里更新库存。代码如下:

// 技术栈:Java + Spring Boot
// 演示多线程场景下,事务边界被打破。
import org.springframework.transaction.annotation.Transactional;

@Transactional(rollbackFor = Exception.class)
public void createOrderAsync(String orderNo, int productId, int count) {
    // 主线程插入订单
    jdbcTemplate.update(
        "INSERT INTO orders(order_no, product_id, count) VALUES (?, ?, ?)",
        orderNo, productId, count
    );

    // 新开一个线程去扣库存,想“并行处理”提升性能
    Thread t = new Thread(() -> {
        // 这里用的 jdbcTemplate 每次都会从连接池拿一个新连接
        // 这个连接和主线程事务里用的连接完全不是同一个
        // 所以这个更新操作不会加入到主事务中,而是立刻自动提交
        jdbcTemplate.update(
            "UPDATE stock SET count = count - ? WHERE product_id = ?",
            count, productId
        );
    });
    t.start();

    // 模拟后续业务出错
    if (count > 5) {
        throw new RuntimeException("主线程出错了,但子线程已经扣了库存");
    }
}

运行后你会发现,主线程抛异常导致订单回滚了,但是库存已经被子线程扣掉了。在单体架构里,如果使用了多线程,原本“容易”的事务管理就会失效。原因在于Spring的事务是基于线程局部变量来绑定数据库连接的,新线程得不到这个绑定,自然无法共享事务。

2.3 远程调用让“事务”出了门就不听话

很多单体应用后来会“偷偷”调用其他系统,比如积分、短信、优惠券。这些调用如果放在事务方法里,问题就出来了。看代码:

// 技术栈:Java + Spring Boot
// 事务里夹杂一次 HTTP 远程调用,演示本地事务无法覆盖外部系统。
import org.springframework.web.client.RestTemplate;

@Transactional(rollbackFor = Exception.class)
public void createOrderWithCoupon(String orderNo, String couponCode) {
    // 第一步:本地数据库插入订单
    jdbcTemplate.update(
        "INSERT INTO orders(order_no, coupon_code) VALUES (?, ?)",
        orderNo, couponCode
    );

    // 第二步:调用远程优惠券服务,把这张券标记为“已使用”
    RestTemplate restTemplate = new RestTemplate();
    String result = restTemplate.postForObject(
        "http://coupon-service/api/useCoupon?code=" + couponCode,
        null, String.class
    );

    // 第三步:假设本地数据库紧接着出错,比如主键冲突
    if (result != null && result.contains("SUCCESS")) {
        throw new RuntimeException("本地数据库出错了,但远程优惠券已经被核销");
    }
}

这里的难点在于:本地事务只能管到自己的数据库。远程服务那边有自己做事务的方式,两边互不知情。即使本地回滚了,远程的状态也改不回来。这就是复杂业务场景下,单体架构的事务管理“失效”的经典缩影。

三、失效的那些常见坑

3.1 自调用:同一个类里“自己人”把事务给丢了

Spring的事务是通过AOP动态代理实现的。当外部类调用一个带事务注解的方法时,实际上是通过代理对象来调用,代理会开启事务。但如果一个类内部,某个普通方法直接调用了同类中另一个带事务注解的方法,这时调用的是原始对象的方法,不是代理对象的方法,所以事务拦截器根本没有机会执行。常见代码如下:

// 技术栈:Java + Spring Boot
// 演示同类自调用导致事务失效。
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    // 这个方法没有事务
    public void createOrderWithInnerCall(String orderNo, int productId, int count) {
        // 这里用了 this.createOrder(...) 是一个典型的自调用
        // 因为 this 指向原始对象,而不是 Spring 生成的代理对象
        // 所以 @Transactional 不会生效
        this.createOrder(orderNo, productId, count);
    }

    @Transactional(rollbackFor = Exception.class)
    public void createOrder(String orderNo, int productId, int count) {
        jdbcTemplate.update(
            "INSERT INTO orders(...) VALUES (...)",
            orderNo, productId, count
        );
        // 假装这里会抛异常,试图让事务回滚
        throw new RuntimeException("库存不足");
    }
}

如果把外部调用改成 orderService.createOrderWithInnerCall(...),你会惊讶地发现,订单被插入进去了,没有回滚。因为事务没开起来。

3.2 异常被吞:报错被“温柔”地接住了

另一个常见失效点,是事务方法里的异常被 try-catch 吃了。Spring事务默认只对RuntimeException回滚,遇到受检异常不会回滚。但如果你在方法内部捕获了异常不往外抛,事务框架根本不知道出过问题。比如:

// 技术栈:Java + Spring Boot
// 演示异常被捕获后,事务不会回滚。
@Transactional(rollbackFor = Exception.class)
public void createOrderWithCatch(String orderNo, int productId, int count) {
    try {
        jdbcTemplate.update("INSERT INTO orders(...) VALUES (...)", orderNo);
        // 这里执行扣库存,可能因为库存不足而失败
        int rows = jdbcTemplate.update("UPDATE stock ...");
        if (rows == 0) {
            // 故意把异常吞掉,不让它抛出去
            log.warn("库存不足,但事务还会继续提交");
        }
    } catch (Exception e) {
        // 这里也把异常拦截了
        log.error("SQL 执行出错,但不会影响事务提交", e);
    }
    // 方法正常返回,Spring 一看:没异常啊,那我就提交吧。
}

这种情况下,即使数据没写对,事务也不会回滚。复杂业务里的异常处理稍不注意,就会踩中这个坑。

3.3 跨数据源:本地事务根本管不着第二个库

单体架构如果为了性能或团队职责拆了数据库,比如订单库和库存库分开,那么一个事务要操作两个库。此时,Spring的@Transactional只能作用于默认数据源上的那个事务,第二个数据源的操作完全不受同一个事务控制。你可以想象成两个厨师各管一灶,但都不承认对方在用同一口锅。除非引入两阶段提交等分布式事务方案,不然无法保证原子性。

3.4 长事务和并发冲突

有时候事务方法里做了很多事,比如调第三方接口等待响应,这会让事务一直持有数据库连接和锁。在高并发下,其他操作就会排队等着,系统响应越来越慢。如果连接池被占满,新的请求直接失败。这种“失效”不是事务本身的问题,而是长时间占用连接导致的整体不可用。

四、到底会不会失效?

4.1 什么时候它依然可靠

如果业务仍然停留在单一数据库、单一应用、不跨线程、不调用远程的范围内,单体架构的事务管理完全可靠。比如一个内容管理后台,发布文章时更新文章表和操作日志表,这两步在同一个事务里,非常顺滑。这也是大量系统仍然选择单体架构的原因:简单、清晰、可控。

4.2 什么时候必须换思路

一旦业务场景出现下面这些信号,就该重新评估“一个事务包一切”的做法:

  • 代码里出现了多个 DataSource。
  • 事务方法内部新开了线程。
  • 事务方法里调用了外部 HTTP 接口或者消息队列。
  • 不同微服务需要共享同一个事务边界。
  • 一个事务要执行几秒钟甚至更久。

在这些场景下,单体架构的本地事务确实“管不住”了。但我们不能武断地说事务管理失效,而是原来的简单模型不匹配现在的复杂度。你需要调整事务边界,或者引入更高阶的解决方案,比如事件驱动、最终一致性、分布式事务框架(如Seata)等。

五、优缺点与注意事项

5.1 单体架构事务管理的优点

  • 简单直接:一个注解就能开启事务,学习成本低。
  • 一致性强:在单库内,ACID特性被数据库和框架完美处理,几乎不用关心细节。
  • 调试方便:所有代码都在同一个进程,日志和堆栈能看到完整的调用链。
  • 性能可控:本地事务没有网络开销,提交回滚很快。

5.2 单体架构事务管理的缺点

  • 扩展受限:不能轻易拆分数据库或者引入多数据源。
  • 边界脆弱:方法内一旦出现自调用、异常捕获、多线程,事务就会悄悄失效。
  • 长事务害人:容易锁表、堆连接,拖垮性能。
  • 与外部系统难协同:远程调用的事务一致性几乎无法保障。

5.3 注意事项

要避开前面提到的那些失效坑,你可以这样做:

  • 不要在事务方法里调用同类方法,需要事务的方法尽量暴露给外部调用,或者使用 AopContext.currentProxy() 来获取代理对象。
  • 异常一定要抛出去,而且要让事务框架能识别,统一使用 RuntimeException 并配置 rollbackFor。
  • 事务里不要做 RPC、消息发送、文件上传等耗时操作。它们应该放在事务提交之后执行。
  • 如果必须用多线程,请把数据库操作拆出去,用独立事务,最终通过对账或补偿来保证一致性。
  • 对于跨库跨系统的场景,用分布式事务中间件,或者干脆放弃强一致,改用最终一致性的方案。

六、总结

回到开头那个问题:单体架构的事务管理,在复杂业务场景下会不会失效?答案是:它不会凭空消失,但它确实有边界。在它管辖的“单机、单库、单线程”范围内,它依然高效可靠。一旦你越过了这些边界,比如引入多线程、远程调用、多数据源,原本的“容易”就会变成“容易出错”。这时候最关键的,不是质疑事务本身,而是修正你对事务的使用方式。该收缩事务边界就收缩,该引入分布式协调就引入,该拥抱最终一致性就拥抱。理解了失效的场景,才能让事务管理继续为你所用,而不是反过来拖垮你的系统。