事务边界设计实战:从单库原子性到跨服务可靠协作

很多业务代码把事务理解成一段被 BEGIN 和 COMMIT 包起来的 SQL。单库场景下,这种理解通常足够完成入账、扣库存或更新订单;但当一个操作同时涉及订单库、库存服务和消息队列时,真正困难的问题变成了:哪些操作必须一起成功?哪些操作可以最终一致?失败后如何重试而不重复扣款?

例如,创建订单通常包含以下步骤:

  1. 写入订单记录。
  2. 扣减库存。
  3. 发送支付或履约事件。

如果三个步骤都放在一个数据库事务中,跨服务调用会长时间占用连接和锁;如果每一步独立提交,又可能出现订单已创建但库存未扣减、库存已扣减但事件没有发送等中间状态。因此,事务设计的核心不是盲目扩大事务范围,而是明确一致性要求,并为不同边界选择合适的失败处理方式。

三个基础边界

原子性不等于全局原子性

原子性表示一个事务内的操作要么全部生效,要么全部回滚。它的边界通常由数据库连接和数据源决定。两个不同数据库连接上的提交,并不会因为业务代码位于同一个方法中就自动具备原子性。

Spring 的 @Transactional 默认只管理当前事务管理器能够识别的数据源。下面的代码可以保证订单表和订单明细表在同一个数据源中保持一致,但不能自动回滚已经调用的远程库存服务:

scss 复制代码
@Transactional
public Long createOrder(CreateOrderCommand command) {
    Order order = orderRepository.insert(command);
    orderItemRepository.batchInsert(order.getId(), command.items());
    inventoryClient.reserve(command.items());
    return order.getId();
}

如果 inventoryClient.reserve 已经成功,随后本地事务因为异常回滚,库存服务并不知道这次回滚。远程调用不应被误认为本地事务的一部分。

隔离性需要和业务语义匹配

隔离级别解决的是并发事务之间能够看到什么。常见级别包括读未提交、读已提交、可重复读和串行化。隔离级别越高,通常意味着更强的并发约束,但具体锁行为还取决于数据库、存储引擎、索引和 SQL 形态,不能只凭名称推断性能。

库存扣减是一个典型例子。与其先查询库存再在应用层判断,不如让更新语句直接表达约束:

ini 复制代码
UPDATE inventory
SET available = available - :quantity
WHERE sku = :sku
  AND available >= :quantity;

随后检查受影响行数是否为 1。如果为 0,说明库存不足,或者目标商品不存在。这个写法把"不能扣成负数"的规则放进了数据库条件中,减少了查询和更新之间的竞态窗口。

持久性有明确前提

提交成功通常表示数据库已经接受这次提交,但最终数据能否在故障后恢复,还受到日志落盘策略、存储设备、复制方式和故障类型影响。应用不能把"收到成功响应"简单扩展成"所有副本都已持久化"。在设计高价值操作时,应确认数据库和部署环境对提交确认的实际保证,并将备份、复制和恢复演练作为独立能力建设。

单库事务的实现步骤

以订单和库存位于同一个 MySQL 数据库为例,可以按以下顺序实现:

  1. 确认相关表使用支持事务的存储引擎,并为更新条件建立合适索引。
  2. 在应用服务层定义事务边界,不要把事务注解随意放在远程调用封装层。
  3. 先写入订单,再执行带条件的库存扣减。
  4. 库存扣减失败时抛出异常,让本地事务回滚。
  5. 提交后再处理不要求与订单严格同时成功的通知或异步任务。

一个简化的 Spring Boot 服务如下:

java 复制代码
@Service
public class OrderService {
    private final OrderRepository orders;
    private final InventoryRepository inventory;

    @Transactional
    public long place(PlaceOrderCommand command) {
        long orderId = orders.insertPending(command.userId(), command.total());

        int affected = inventory.decrease(
            command.sku(), command.quantity()
        );
        if (affected != 1) {
            throw new InsufficientStockException(command.sku());
        }

        orders.markReserved(orderId);
        return orderId;
    }
}

异常类型应当是运行时异常,或者在事务配置中明确指定需要回滚的异常类型。还要注意自调用问题:同一个类内部直接调用另一个带 @Transactional 的方法时,调用可能绕过代理,导致事务注解不按预期生效。实际项目应通过服务边界、事务测试或框架文档确认行为。

跨服务场景:本地事务加事件表

当订单和库存分属不同服务时,常见的稳妥方案是采用本地事务加可靠事件投递。订单服务只在自己的数据库事务中完成订单状态和事件记录的写入,后台投递器再把事件发送到消息系统。这样即使进程在提交后立刻崩溃,事件仍然保存在事件表中,可以在恢复后继续投递。

事件表可以包含以下字段:

sql 复制代码
CREATE TABLE order_event (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  event_id VARCHAR(64) NOT NULL,
  aggregate_id BIGINT NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  status VARCHAR(16) NOT NULL,
  attempts INT NOT NULL DEFAULT 0,
  next_attempt_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL,
  UNIQUE KEY uk_event_id (event_id),
  KEY idx_event_dispatch (status, next_attempt_at)
);

订单写入和事件写入必须在同一个本地事务中:

java 复制代码
@Transactional
public long submit(PlaceOrderCommand command) {
    long orderId = orders.insertPending(command.userId(), command.total());
    String eventId = UUID.randomUUID().toString();
    eventRepository.insert(
        eventId,
        orderId,
        "OrderCreated",
        jsonEncoder.encode(new OrderCreated(orderId)),
        "PENDING",
        Instant.now()
    );
    return orderId;
}

投递器读取 PENDING 或到达重试时间的事件,发送成功后再将状态更新为 SENT。发送和状态更新之间仍可能发生进程崩溃,因此消费者必须支持重复消息。不要把"生产者只发送一次"当作可靠性前提。

幂等消费与状态机

幂等不是简单地忽略所有重复请求,而是让同一个业务操作重复执行后,结果与执行一次一致。常用做法包括:

  • 为业务请求生成稳定的幂等键,并建立唯一约束。
  • 消费者处理事件前记录 event_id,重复到达时直接返回已处理结果。
  • 使用订单状态机限制非法状态跳转,例如只允许 PENDING 变为 RESERVED。
  • 对外部支付、物流等系统保存请求号,重试时复用同一个请求号。

消费者可以用唯一键配合本地事务完成去重:

sql 复制代码
CREATE TABLE consumed_event (
  consumer_name VARCHAR(64) NOT NULL,
  event_id VARCHAR(64) NOT NULL,
  consumed_at DATETIME NOT NULL,
  PRIMARY KEY (consumer_name, event_id)
);

处理流程应当是:在本地事务中插入消费记录、执行业务更新;如果插入因唯一键冲突失败,则认为该事件已经处理过。这里的前提是消费记录和业务数据位于同一个事务资源内。若不满足这个前提,需要采用其他协调方式或接受更明确的最终一致性边界。

重试、补偿与监控

重试只适合暂时性错误,例如网络超时、消息系统短暂不可用。参数错误、权限错误和数据约束错误通常不应无限重试。可采用指数退避,并设置最大次数:

ini 复制代码
delay = min(baseDelay * 2^attempts, maxDelay)

超过最大次数后将事件转入待处理状态,由人工或运维任务检查。补偿操作必须是新的业务动作,而不是假设数据库可以回到过去。例如库存预留失败,可以把订单标记为待处理并释放已成功预留的资源;释放动作也必须具备幂等键。

生产环境至少应记录以下信息:事务业务号、事件号、当前状态、重试次数、最后错误、首次创建时间和最后处理时间。监控重点不是单纯统计异常数量,还要关注长时间停留在中间状态的订单、事件表积压和重复消费比例。

常见问题

把所有操作放进一个大事务是否更安全?

不一定。大事务会延长锁持有时间,增加连接占用和回滚成本;如果其中包含远程调用,还会引入不可回滚的副作用。只有确实共享同一事务资源且需要强一致的操作,才适合放在同一事务中。

事务提交后立即发送消息可以吗?

可以,但需要接受进程在提交和发送之间崩溃的丢消息窗口。事件表或数据库事务消息模式可以缩小这个窗口。无论采用哪种方式,消费者仍应设计为幂等。

增加重试次数能解决一致性问题吗?

不能。重试只能提高暂时性故障下的成功概率,还可能放大重复扣款、重复发货等问题。重试必须与稳定业务号、状态机、去重表和死信处理配套。

为什么测试环境没有问题,线上却出现死锁?

并发量、访问顺序、索引选择和事务持续时间都可能不同。应在接近生产的数据规模和并发条件下观察锁等待,并保持事务内 SQL 的访问顺序一致。遇到死锁时可对明确的瞬时失败进行有限重试,但不能用重试掩盖长期的锁设计问题。

总结

事务设计首先要回答一致性边界,而不是先选择某个注解或中间件。单库内使用短事务、条件更新和正确的隔离级别;跨服务时,将强一致范围限制在本地事务内,再通过事件表、幂等消费、状态机和补偿动作建立可恢复的最终一致性。最后,用业务号和事件号串联日志、指标与人工处理流程,才能在故障发生时知道系统处于什么状态,以及下一步应该执行什么操作。

相关推荐
imDwAaY1 小时前
Redo Log 和 Binlog 为什么需要两阶段提交?
后端·mysql
xyLJ1 小时前
Redis 常见的数据类型及底层结构
后端
我的div丢了肿么办1 小时前
go语言中的时间time
后端·go
136096757231 小时前
同一套 DeepSeek,三种框架六个坑
后端
IT_陈寒1 小时前
Vue这个响应式陷阱我竟然踩了3次
前端·人工智能·后端
IT_陈寒1 小时前
Redis雪崩把我坑惨了,三招教你躲过去
前端·人工智能·后端
ikoala1 小时前
同样叫 Harness,DeepSeek Harness 和 Pi 根本不在同一层
前端·后端·ai编程
大白801 小时前
多模态大模型能干什么?图文音视频统一理解,正在重画 AI 的边界
后端
花卷持续成长1 小时前
“码上面试”项目学习02
后端