文章目录
- 审计日志被外层事务回滚问题分析与修复
- 一、问题现象
- 二、定位过程与误区
- 三、根本原因
- 3.1 事务边界与 @Async 的关系
- 3.2 失败分支为何丢日志
- 3.3 为什么 catch 看不到异常
- 四、修复方案
- 4.1 修复后的事务时序
- 4.2 为什么不去修 @Async
- 五、原理小结:Spring 事务传播行为
审计日志被外层事务回滚问题分析与修复
一、问题现象
在调用快递100下单接口失败时,本应记录到trade_delivery_kd100_api_log表的失败日志没有落库;而调用成功时同一段日志记录代码却能正常落库。
失败分支构造的日志对象示例:
DeliveryKd100ApiLogDO(orderId=019d22f3-1dd2-7c61-8508-d15a2bf27958,apiType=B_ORDER_OFFICIAL,requestResult=FAIL,responseBody=null,errorMessage=接口异常:[404NotFound]during[POST]to[...],executeTime=165)调用代码:
apiLogMapper.insert(logDO);代码没有报错(catch 被吞了 log),但数据库里查不到这条记录。
二、定位过程与误区
最初怀疑过两个方向,都被证据排除:
怀疑租户上下文丢失:
@Async切线程后TenantContextHolder取不到tenantId,导致多租户拦截器拒绝插入。- 反例:成功分支走的是同一个方法、同一种线程模型,如果上下文丢失,成功也应该插不进去。所以不是。
怀疑字段超长:
errorMessage中包含 Tomcat 整页 HTML(977 字节),可能超过VARCHAR(n)限制。- 查 DDL:
error_message是TEXT,长度不是问题。
- 查 DDL:
继续聚焦"成功能落、失败落不进"这唯一差异点,发现失败分支调用logApiCall之后还做了一件事:
}else{apiLogService.logApiCall(orderId,...,false);throwexception(newErrorCode(1_011_008_015,response.getMessage()));}throw exception(...)抛了运行时异常,而外层deliveryOrder()方法上挂着@Transactional——回滚发生了。这才是真正的根因。
三、根本原因
3.1 事务边界与 @Async 的关系
@Async标注的方法在 Spring 默认情况下会被代理到TaskExecutor上的另一个线程,新线程默认开启自己的事务上下文,不会跟外层共享事务。
但前提是@Async真正生效。任何一个原因都会让@Async静默退化为同步调用:
- 主启动类(或任一
@Configuration)没有加@EnableAsync - 方法不是
public - 同类自调用(
this.xxx())绕过了代理 - 方法返回了非
void/ 非Future类型且被同步使用
一旦@Async没生效,apiLogService.logApiCall(...)就是同步在外层事务里执行的,insert只是把 SQL 发到当前事务的 connection 上,commit 还得等外层事务来决定。
3.2 失败分支为何丢日志
将本案的执行时序展开:
deliveryOrder() 开启事务 TX ├─ kd100OrderFeignClient.border() ← 远程返回 result=false └─ else 分支 ├─ apiLogService.logApiCall(..., FAIL) ← @Async 未生效,SQL 进入 TX └─ throw exception(...) ← TX 标记 rollback-only deliveryOrder() 退出 → Spring 检测到异常 → TX 回滚成功分支没有throw,TX 正常提交,所以同一行apiLogMapper.insert能落库。
失败分支抛异常触发回滚,日志 insert 被一起撤销,于是"看起来没保存"。
这是审计日志最经典的事故:审计日志的命运不应当跟着业务事务走,但默认情况下它就是跟着走的。
3.3 为什么 catch 看不到异常
try{apiLogMapper.insert(logDO);}catch(Exceptione){log.error("记录API日志失败",e);}insert发送 SQL 到 connection 后立即返回成功(PostgreSQL 在事务里不会立刻校验外键以外的约束),异常不在insert这一行抛出,而是在外层事务 commit/rollback 阶段才决定结局。catch 自然抓不到,从调用方视角看就是"静默失败"。
四、修复方案
给logApiCall加@Transactional(propagation = Propagation.REQUIRES_NEW),让审计日志在一个独立的新事务里提交,与外层业务事务完全解耦。
修改后的代码:
@Slf4j@Service@RequiredArgsConstructorpublicclassKd100ApiLogServiceImplimplementsKd100ApiLogService{privatefinalDeliveryKd100ApiLogMapperapiLogMapper;/** * 异步记录 API 日志。 * REQUIRES_NEW 保证日志在独立事务中提交, * 即使外层业务事务回滚,日志也不会丢——审计日志的标准做法。 */@Override@Async@Transactional(propagation=Propagation.REQUIRES_NEW)publicvoidlogApiCall(UUIDorderId,Kd100ApiTypeEnumapiType,StringrequestUrl,StringrequestParams,StringresponseBody,StringerrorMessage,longexecuteTime,booleansuccess){try{DeliveryKd100ApiLogDOlogDO=newDeliveryKd100ApiLogDO();apiLogMapper.insert(logDO);}catch(Exceptione){log.error("记录API日志失败",e);}}}4.1 修复后的事务时序
deliveryOrder() 开启事务 TX1 ├─ kd100OrderFeignClient.border() ← result=false └─ else 分支 ├─ logApiCall(...) │ └─ REQUIRES_NEW → 挂起 TX1,开启 TX2 │ ├─ apiLogMapper.insert(logDO) │ └─ TX2 commit ✓ 日志已落库 │ ← 恢复 TX1 └─ throw exception(...) ← TX1 标记 rollback-only deliveryOrder() 退出 → TX1 回滚(与 TX2 无关)业务回滚 = TX1 回滚;审计落库 = TX2 已提交。两者互不影响。
4.2 为什么不去修 @Async
直觉上"把 @Async 修好"也能切到新线程、新事务。但只靠 @Async 有两个隐患:
@EnableAsync配置漂移、同类自调用等仍可能让它退化为同步;- 异步线程脱离了租户上下文(
TenantContextHolder是ThreadLocal),需要额外手工传递。
REQUIRES_NEW是事务层面的强保证,无论是否异步、是否同线程,新事务一定独立。两者叠加最稳。
五、原理小结:Spring 事务传播行为
@Transactional的propagation决定方法被调用时如何使用事务:
| 传播行为 | 行为 | 适用场景 |
|---|---|---|
REQUIRED(默认) | 有事务就加入,没有就新建 | 普通业务方法 |
REQUIRES_NEW | 挂起外层事务,开启全新事务,新事务独立提交/回滚 | 审计日志、操作记录、消息发送等"无论业务成败都要落"的旁路操作 |
NESTED | 在外层事务中开 savepoint,可独立回滚但跟随外层提交 | 局部容错 |
SUPPORTS/NOT_SUPPORTED/MANDATORY/NEVER | 较少用,按需选择 | — |
判断要点:一段逻辑能不能跟主业务"同生共死"?
- 能 →
REQUIRED - 不能(日志/审计/告警/对外通知) →
REQUIRES_NEW