优秀的编程知识分享平台

网站首页 > 技术文章 正文

搞懂了,这种情形也要用事务_哪些事务需要重做

nanyue 2025-09-01 10:27:26 技术文章 3 ℃

咱们今天聊聊事务这事儿。其实事务本身的技术实现真不算难,不管是本地事务还是分布式事务,业内都有成熟的方案。但真正让人栽跟头的,往往是你压根没意识到 “这儿得用事务”,或者处理方式不对,最后导致数据出问题。

就像设计模式一样,很多人能把 23 种模式背得滚瓜烂熟,但真到写代码的时候,该用 if-else 堆逻辑还是堆,根本判断不准该用哪种模式。事务也是这个道理,有时候不是你不会用 @Transactional,而是没搞明白什么时候必须用,怎么用才对。

就说电商里常见的「订单支付后发货」流程吧,这个场景里也藏着类似的事务问题,咱们一点点看。

先看一段核心代码,这是支付成功后触发的发货准备逻辑:

// 支付成功后,准备发货(简化版)
public void prepareDelivery(String orderId) {
if (StringUtil.isEmpty(orderId)) {
return ;
}

List taskList;

try {
// 1. 查询订单详情,确认商品、收货地址等
OrderDetailDO order = orderService.getDetail(orderId);
// 2. 生成发货任务(可能包含多个步骤:仓库分配、物流单生成、库存锁定等)
taskList = deliveryService.generateTasks(order);
log.info("订单{}生成发货任务: {}", orderId, taskList);

} catch (Exception e) {
log.error("订单{}准备发货失败", orderId, e);
}

// 3. 执行发货任务(比如通知仓库、扣减库存等)
if (taskList != null && !taskList.isEmpty()) {
deliveryExecutor.execute(taskList);
}
}

// 生成发货任务的具体逻辑
public List generateTasks(OrderDetailDO order) {
List tasks = new ArrayList<>();

// 步骤1:分配仓库(根据收货地址匹配最近的仓库)
WarehouseDO warehouse = warehouseSelector.selectNearby(order.getReceiverAddress());
tasks.add(new DeliveryTaskDO("ALLOCATE_WAREHOUSE", warehouse.getId(), order.getId()));

// 步骤2:生成物流单(调用第三方物流接口)
LogisticsOrderDO logistics = logisticsService.createOrder(order, warehouse);
tasks.add(new DeliveryTaskDO("CREATE_LOGISTICS", logistics.getLogisticsNo(), order.getId()));

// 步骤3:锁定库存(仓库中锁定对应商品数量)
boolean lockSuccess = inventoryService.lockStock(warehouse.getId(), order.getItems());
if (!lockSuccess) {
throw new RuntimeException("库存不足,锁定失败");
}
tasks.add(new DeliveryTaskDO("LOCK_INVENTORY", order.getItems().toString(), order.getId()));

return tasks;
}

这段代码的逻辑很简单:支付成功后,先查询订单详情,然后生成一系列发货任务(分配仓库、生成物流单、锁定库存),最后执行这些任务(通知仓库、扣减库存等)。

但这里藏着个大问题: 发货任务必须“全成或全不成” 。比如,仓库分配好了、物流单也生成了,但最后一步库存锁定失败了——这时候前面的两个任务已经加到taskList里了,而异常被 prepareDelivery 里的try-catch接住,只是打了个日志。接下来,代码会判断taskList不为空,直接执行已生成的任务。

结果就是:仓库已经分配了、物流单也创建了,但库存没锁定——后续发货时发现没库存,物流单作废、仓库白分配,还得人工去清理这些无效数据,麻烦得很。

为啥会出这问题?本质上和之前的例子一样: 没意识到“这一串操作是一个整体,需要一致性保障” 。这里的“一致性”不是数据库事务那种强约束,而是业务上的逻辑完整性——要么三个任务都生成成功,一起执行;要么只要有一个失败,就一个都不执行。

正确的做法应该是:在 generateTasks 里任何一步失败时,直接让异常抛出去,并且在 prepareDelivery 的catch块里把taskList设为null或空列表。这样即使前面生成了部分任务,只要有一步失败,最终也不会执行任何任务。后续可以通过重试机制重新触发,总比生成一堆无效任务强。或者整个方法加上事务,一旦报错,全部回滚。

如果是 分布式系统 ,比如库存系统、物流系统是独立的微服务(各自有自己的数据库),本地事务就失效了(跨库操作无法用单库事务保证一致性)。这时候得用分布式事务方案。

比如用 「可靠消息最终一致性」 方案,核心思路是:

“先确保核心操作(比如订单状态更新)成功,再通过消息队列异步触发其他操作,失败了重试,直到所有操作都完成”。

再延伸一个分布式场景的坑。比如很多系统会封装一个“用户账户查询”的Manager,调用远程的账户服务:

@Component
public class UserAccountManager {
@Resource
private RemoteAccountService remoteAccountService;

// 查询用户账户余额
public BigDecimal getBalance(Long userId) {
if (userId == null || userId <= 0) {
return BigDecimal.ZERO;
}
try {
// 调用远程服务查询余额
Result result = remoteAccountService.queryBalance(userId);
if (result == null || !result.isSuccess()) {
return BigDecimal.ZERO;
}
return result.getData();
} catch (Exception e) {
log.error("查询用户{}余额失败", userId, e);
}
return BigDecimal.ZERO;
}
}

这段代码的问题在于:如果远程服务调用失败(比如网络超时、服务宕机),它会直接返回0。这时候下游业务拿到底层0,根本分不清是“用户真的没余额”还是“调用失败了”。

比如下游有个逻辑:

BigDecimal balance = userAccountManager.getBalance(userId);
if (balance.compareTo(BigDecimal.ZERO) > 0) {
// 有余额,执行提现
withdrawService.withdraw(userId, balance);
}

如果用户实际有余额,但远程调用失败导致返回0,这时候该执行的提现没执行,用户就会投诉“为啥我的钱提不出来”。这本质上也是“事务意识缺失”——远程调用的结果和本地业务操作需要保持逻辑一致,不能因为异常就随便返回一个“安全值”,否则会掩盖问题,导致数据或业务流程不一致。

所以你看,事务的难点真不在技术实现,而在时刻提醒自己: 当前这串操作是不是一个“整体”?中间任何一步失败,会不会导致部分操作生效、部分无效? 尤其是处理异常时,不能只图“不报错”,得想清楚异常后怎么保证业务逻辑的完整性——有时候“宁可不做,也别做错一半”,才是对业务负责的态度。

这个问题的核心在于**“模糊了失败与正常业务结果的边界” ——把“调用失败”和“余额为0”这两种完全不同的情况,用同一个返回值(0)来表示,导致下游业务无法正确决策。解决的关键是 让调用方清晰区分“操作成功”“调用失败”“业务异常”三种状态**,而不是用默认值掩盖问题。

具体解决方案可以分三步:

第一步:定义清晰的返回结构,区分“成功/失败”状态

不要直接返回 BigDecimal 这种原始类型,而是用一个包含“状态标识”和“数据”的包装类。比如定义一个通用的 Result 类:

// 通用返回结果类
@Data
public class Result<T> {
// 是否成功
private boolean success;
// 业务数据(成功时返回)
private T data;
// 错误码(失败时返回,如:REMOTE_CALL_FAILED、PARAM_ERROR等)
private String errorCode;
// 错误信息(失败时返回)
private String errorMsg;

// 成功时的静态工厂方法
public static Result success(T data) {
Result result = new Result<>();
result.setSuccess(true);
result.setData(data);
return result;
}

// 失败时的静态工厂方法
public static Result fail(String errorCode, String errorMsg) {
Result result = new Result<>();
result.setSuccess(false);
result.setErrorCode(errorCode);
result.setErrorMsg(errorMsg);
return result;
}
}

第二步:改造Manager层,如实返回“调用状态”

不再捕获异常后返回默认值,而是把“调用成功”“调用失败”“远程服务返回失败”这三种情况,通过 Result 类明确传递给下游:

@Component
public class UserAccountManager {
@Resource
private RemoteAccountService remoteAccountService;

// 改造后:返回Result,明确状态
public Result getBalance(Long userId) {
// 参数校验失败:明确标记为业务参数错误
if (userId == null || userId <= 0) {
return Result.fail("PARAM_ERROR", "用户ID无效");
}

try {
// 调用远程服务
Result remoteResult = remoteAccountService.queryBalance(userId);

// 远程服务返回失败(如:用户不存在)
if (remoteResult == null || !remoteResult.isSuccess()) {
String errorMsg = remoteResult != null ? remoteResult.getErrorMsg() : "远程服务返回空结果";
return Result.fail("REMOTE_SERVICE_FAIL", "查询余额失败:" + errorMsg);
}

// 远程调用成功:返回真实余额
return Result.success(remoteResult.getData());

} catch (Exception e) {
// 调用过程抛异常(如:网络超时、服务宕机)
log.error("查询用户{}余额时远程调用异常", userId, e);
return Result.fail("REMOTE_CALL_EXCEPTION", "调用账户服务失败:" + e.getMessage());
}
}
}

改造后的数据流向

  • 如果用户真实余额是0, remoteResult.getData() 会返回0, Result.success(0) 告诉下游“成功查询到余额为0”;
  • 如果远程调用超时,返回 Result.fail("REMOTE_CALL_EXCEPTION", ...) ,明确告诉下游“调用失败了,不是余额为0”;
  • 如果远程服务返回“用户不存在”,返回 Result.fail("REMOTE_SERVICE_FAIL", ...) ,明确是业务层面的失败。

第三步:下游业务根据“状态”做差异化处理

下游不再依赖“余额是否大于0”这一个条件,而是先判断“操作是否成功”,再决定如何处理:

// 下游提现逻辑
public void processWithdraw(Long userId) {
// 1. 调用Manager获取余额(带状态)
Result balanceResult = userAccountManager.getBalance(userId);

// 2. 先判断是否查询成功
if (!balanceResult.isSuccess()) {
// 2.1 查询失败:记录日志+告警,后续人工介入或重试
log.error("用户{}提现前查询余额失败,错误码:{},原因:{}",
userId, balanceResult.getErrorCode(), balanceResult.getErrorMsg());
// 可以触发重试机制(如:用定时任务重试,或返回给前端“系统繁忙,请稍后再试”)
return ;
}

// 3. 查询成功:再判断余额是否足够
BigDecimal balance = balanceResult.getData();
if (balance.compareTo(BigDecimal.ZERO) > 0) {
// 3.1 余额足够:执行提现
withdrawService.withdraw(userId, balance);
} else {
// 3.2 余额不足:正常业务逻辑(如:返回给用户“余额不足”)
log.info("用户{}余额不足,无法提现,当前余额:{}", userId, balance);
}
}

这样处理的好处

  • 调用失败时,下游能明确知道“不是用户没余额,而是系统出了问题”,可以触发重试或告警,避免漏执行提现;
  • 余额真的为0时,才执行“余额不足”的业务逻辑,不会和“调用失败”混淆;
  • 错误责任清晰: PARAM_ERROR 是本地参数问题, REMOTE_CALL_EXCEPTION 是网络或服务问题, REMOTE_SERVICE_FAIL 是远程业务问题,方便排查。

额外优化:增加重试机制(针对可恢复的失败)

对于“网络超时”这类临时故障(可重试异常),可以在Manager层增加有限次数的重试,减少失败概率:

public  Result getBalance(Long userId) {
if (userId == null || userId <= 0) {
return Result.fail("PARAM_ERROR", "用户ID无效");
}

// 最多重试2次(加上第一次,共3次)
int maxRetries = 2;
int retryCount = 0;

while (retryCount <= maxRetries) {
try {
Result remoteResult = remoteAccountService.queryBalance(userId);
// ... 省略判断逻辑(同上文)
return Result.success(remoteResult.getData());

} catch (Exception e) {
// 判断是否是可重试异常(如:超时、连接拒绝等)
if (isRetryable(e) && retryCount < maxRetries) {
retryCount++;
log.warn("用户{}余额查询第{}次失败,将重试,原因:{}", userId, retryCount, e.getMessage());
// 重试前休眠一小段时间(避免频繁重试)
Thread.sleep(100);
continue;
}
// 不可重试或重试次数用完:返回失败
return Result.fail("REMOTE_CALL_EXCEPTION", "调用失败:" + e.getMessage());
}
}
return Result.fail("REMOTE_CALL_EXCEPTION", "超过最大重试次数");
}

// 判断是否是可重试异常(如:超时、网络异常等)
private boolean isRetryable(Exception e) {
return e instanceof SocketTimeoutException
|| e instanceof ConnectException
|| e.getMessage().contains("timeout");
}

核心原则总结

  1. 不掩盖失败 :永远不要用“默认值”(如0、null)来替代“失败状态”,这会丢失关键信息;
  2. 明确状态传递 :用结构化的返回值(如 Result 类)清晰区分“成功”“调用失败”“业务异常”,让下游能精准决策;
  3. 失败要处理 :调用失败时,要么重试(针对临时故障),要么记录并告警(针对永久故障),不能直接跳过;
  4. 责任边界清晰 :通过错误码区分“本地问题”“远程服务问题”“网络问题”,方便排查和追责。

本质上,这还是“事务意识”的延伸——分布式场景中,“远程调用的可靠性”和“本地业务逻辑的正确性”需要绑定在一起,任何一环的模糊处理,都可能导致整个流程的数据或逻辑不一致。





最近发表
标签列表