网站首页 > 技术文章 正文
咱们今天聊聊事务这事儿。其实事务本身的技术实现真不算难,不管是本地事务还是分布式事务,业内都有成熟的方案。但真正让人栽跟头的,往往是你压根没意识到 “这儿得用事务”,或者处理方式不对,最后导致数据出问题。
就像设计模式一样,很多人能把 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");
}
核心原则总结
不掩盖失败 :永远不要用“默认值”(如0、null)来替代“失败状态”,这会丢失关键信息; 明确状态传递 :用结构化的返回值(如 Result
类)清晰区分“成功”“调用失败”“业务异常”,让下游能精准决策;失败要处理 :调用失败时,要么重试(针对临时故障),要么记录并告警(针对永久故障),不能直接跳过; 责任边界清晰 :通过错误码区分“本地问题”“远程服务问题”“网络问题”,方便排查和追责。
本质上,这还是“事务意识”的延伸——分布式场景中,“远程调用的可靠性”和“本地业务逻辑的正确性”需要绑定在一起,任何一环的模糊处理,都可能导致整个流程的数据或逻辑不一致。
- 上一篇: 代码静态检查Findbugs使用说明_静态代码测试
- 下一篇: 优秀程序员需要掌握的代码整洁之道
猜你喜欢
- 2025-09-01 Python定时任务,三步实现自动化_python定时任务,三步实现自动化
- 2025-09-01 (三)Java基础知识复习(异常处理)_java异常处理规则(新手必看)
- 2025-09-01 情人节脱单秘诀,程序员表白的情话大盘点!| CSDN 博文精选
- 2025-09-01 这几道面试题,可以考考自己_面试考什么题
- 2025-09-01 教程3 猜数字游戏_猜数字游戏玩法
- 2025-09-01 Go 学习:那些不一样的知识点(下)_不一样的知识的英文
- 2025-09-01 execute和submit的区别_execute和implement的区别
- 2025-09-01 愤怒的TryCatch_愤怒的英文
- 2025-09-01 异常处理_异常处理培训心得怎么写
- 2025-09-01 catch at和catch up with的区别是什么
- 最近发表
-
- C语言重要吗?_c语言重要吗计算机专业
- 2024 年顶级 C# 面试问题和答案_c#面试题及答案2020
- C#调用C++编写的DLL需要通过P/Invoke机制实现
- 掌握 C# 和 .NET:常用术语与概念_c#.net教程
- C++ .NET与C# .NET:谁才是.NET开发的“真香”选择?
- 是C++好,带GC的Java、C#好?还是Rust好?
- 为什么有人一直喜欢 C 而不喜欢 C++?
- Python定时任务,三步实现自动化_python定时任务,三步实现自动化
- (三)Java基础知识复习(异常处理)_java异常处理规则(新手必看)
- 情人节脱单秘诀,程序员表白的情话大盘点!| CSDN 博文精选
- 标签列表
-
- cmd/c (90)
- c++中::是什么意思 (84)
- 标签用于 (71)
- 主键只能有一个吗 (77)
- c#console.writeline不显示 (95)
- pythoncase语句 (88)
- es6includes (74)
- sqlset (76)
- windowsscripthost (69)
- apt-getinstall-y (100)
- node_modules怎么生成 (87)
- chromepost (71)
- flexdirection (73)
- c++int转char (80)
- mysqlany_value (79)
- static函数和普通函数 (84)
- el-date-picker开始日期早于结束日期 (76)
- asynccallback (71)
- localstorage.removeitem (74)
- vector线程安全吗 (70)
- java (73)
- js数组插入 (83)
- mac安装java (72)
- 查看mysql是否启动 (70)
- 无效的列索引 (74)