引言
在微服务架构中,分布式事务是保证数据一致性的关键。Seata 作为一款流行的分布式事务解决方案,其 AT 模式通过 undo_log 表实现自动补偿。然而,当我们依赖框架的默认行为时,一些看似不起眼的细节------比如一个缺失的数据库索引------就可能导致严重的数据不一致。
本文将完整记录我们如何从一条 Feign 超时异常开始,层层剖析,最终定位到 Seata 防资源悬挂机制因缺少唯一索引而失效的全过程。希望能为遇到类似问题的开发者提供一份可参考的排查思路。
一、问题现象:医嘱撤销失败,数据出现"半删半留"
某日,业务人员反馈医生执行"医嘱明细撤销"操作时,系统报错,且部分数据未能正常恢复。具体表现为:
- 撤销操作返回异常,前端提示"操作失败"。
- 数据库检查发现:
charge_order_item(医嘱项目表)中对应的 16 条记录已被永久删除 ,而t_test_rec(检验记录表)中的数据却依然存在,导致业务数据不一致。
从业务角度,撤销操作本应要么全部成功(删除所有相关数据),要么全部失败(回滚所有变更),但当前状态显然违背了原子性。
二、初步排查:日志指向 Feign 调用超时
查看服务日志,发现了关键线索:
bash
feign.RetryableException: timeout executing POST http://charge-service/charge/cancel
- 调用方 :
doctor-service(医生服务) - 被调方 :
charge-service(收费服务) - 调用耗时:约 10 秒后超时
同时,Seata 全局事务日志显示:
sql
Begin new global transaction [xid=172.16.1.3:8091:1369875561409518336]
...
rollback status: Rollbacked
全局事务因 Feign 超时异常被回滚,但 charge 服务的本地事务已经提交,于是出现了部分数据被删、部分数据保留的局面。
三、为什么执行时间超过 10 秒?------ 慢 SQL 的发现
排查超时,我们首先需要回答一个问题:charge 服务处理 16 个项目的删除,为什么会耗时 10 秒之久?
检查 charge 服务日志,发现对每个 orderId 执行了如下操作:
sql
-- 对每个 orderId 执行
SELECT * FROM charge_order_item WHERE order_id = ?;
DELETE FROM charge_order_item WHERE order_id = ?;
16 个项目,每个都走一次 SELECT + DELETE,且 order_id 字段没有索引,导致每次查询都是全表扫描。16 次全表扫描累加,最终耗时超过 10 秒。
解决慢 SQL
添加索引后:
sql
CREATE INDEX idx_order_items_order_id ON charge_order_item(order_id);
同样的操作耗时从 10 秒+ 缩短到 1 秒以内 ,从根源上消除了超时的触发条件。此外,对其他涉及查询和删除的关联表(如 charge_other_presc_detail、charge_wait_charge_presc 等)也一并添加了索引。
四、检查超时配置:为何 60 秒不生效?
即使慢 SQL 是主因,配置的 60 秒超时为何没有生效?查看 doctor 服务的配置文件,发现:
yaml
feign:
okhttp:
enabled: true
client:
config:
default:
connectTimeout: 60000
readTimeout: 60000
理论上超时为 60 秒,为何 10 秒就超时了?
关键点 1:Feign 的 Request.Options 覆盖了 OkHttpClient
由于启用了 feign.okhttp.enabled=true,Feign 使用 OkHttp 作为底层客户端。但 Feign 的 Request.Options 会覆盖 OkHttpClient 自身的超时设置。我们注入 OkHttpClient Bean 并打印其超时值:
yaml
ConnectTimeout: 2000, ReadTimeout: 60000
OkHttp 读超时是 60 秒,但实际请求却依然在 10 秒超时。说明 Feign 的 Request.Options 才是真正的决策者 ,而非 OkHttpClient。
关键点 2:Feign 默认连接超时是 10 秒
Feign 的 Request.Options 默认值是:
- 连接超时:10 秒
- 读取超时:60 秒
由于我们的 YAML 配置未正确传递给 Request.Options(可能是因为特定客户端配置覆盖,或配置未加载),实际生效的连接超时是默认的 10 秒。
验证实际生效的 Request.Options
通过 FeignContext 获取实际的 Request.Options:
java
@Autowired
private FeignContext feignContext;
@GetMapping("/debug/feign-options")
public String debugFeignOptions() {
Request.Options options = feignContext.getInstances("charge-service", Request.Options.class)
.stream().findFirst().orElse(null);
return options == null ? "No options" :
"Connect: " + options.connectTimeoutMillis() + ", Read: " + options.readTimeoutMillis();
}
返回结果为 Connect: 10000, Read: 60000,确认实际生效的连接超时为 10 秒,读超时为 60 秒。
修复超时配置
java
@Configuration
public class FeignConfig {
@Bean
public Request.Options options() {
return new Request.Options(60000, TimeUnit.MILLISECONDS, 60000, TimeUnit.MILLISECONDS, false);
}
}
这样连接超时和读超时都被统一设置为 60 秒。再加上慢 SQL 的索引优化,Feign 超时问题被彻底解决。
五、深入挖掘:为何 Seata 回滚未能恢复数据?
超时问题解决了,但我们更关心的是:数据为什么会永久丢失? Seata 的 AT 模式通过 undo_log 表保存数据变更的前后镜像,回滚时根据镜像进行补偿。按理说,只要全局事务回滚,charge 服务的数据应该被恢复。但检查 charge 服务的 undo_log 表,我们有了新的发现:
异常:同一 xid + branchId 存在两条 undo_log
执行查询:
sql
SELECT * FROM undo_log WHERE xid = '172.xx.xx.xx:8091:1369875561409518336';
得到两条记录:
| ID | branchId | context | rollback_info | log_status | log_created |
|---|---|---|---|---|---|
| 1598573 | 1369875561409518337 | ZIP压缩 | 完整镜像(16条 beforeImage) |
0 | 2026-07-03 10:41:04 |
| 1598574 | 1369875561409518337 | NONE | 空 {} |
1 | 2026-07-03 10:41:04 |
两条记录的自增 ID 分别为 1598573 和 1598574,ID 连续且递增,这是关键线索。
- ID=1598573 :包含完整的
beforeImage,log_status=0,表示从未被回滚使用。 - ID=1598574 :内容为空,
log_status=1,表示已被标记为"全局事务已完成"。
正常情况,一个分支事务只应有一条 undo_log。这里却出现了两条,且 branchId 相同,违反唯一性。
六、定位根因:Seata 防悬挂机制 + 缺少唯一索引
在反复推敲后,我们将目光聚焦到了 Seata 的防资源悬挂(Anti-Suspension)机制,结合日志时序和自增 ID 的顺序,精确还原了问题过程。
什么是防资源悬挂?
Seata AT 模式中,存在一种并发场景:全局事务回滚指令可能先于本地事务提交到达 RM 。这种情况下,RM 收到回滚请求时,本地事务尚未提交,undo_log 还未写入,回滚无从执行。
Seata 的防悬挂机制通过以下方式解决:
- 回滚请求先到达时,RM 查询
undo_log发现无记录,插入一条log_status=1的空undo_log作为占位符。 - 稍后本地事务提交时,Seata 尝试插入完整的
undo_log。 - 如果
(xid, branch_id)有唯一索引,插入冲突,本地事务提交失败,事务回滚,避免资源悬挂。
log_status=1 即代表"该 undo_log 是防悬挂占位记录,表示回滚已预执行"。
时序还原(结合自增 ID)
根据 Seata 源码逻辑和两条记录的自增 ID(1598573 先,1598574 后),正确的时序是:
| 步骤 | 事件 |
|---|---|
| ① | charge 本地事务开始,Seata 获取 branchId=1369875561409518337,并在内存中准备写入 undo_log。此时 undo_log 的自增 ID=1598573 已被预分配(Oracle 序列先取值),但记录尚未真正插入。 |
| ② | doctor 侧 Feign 超时(10 秒),Seata TC 发起全局回滚。回滚指令先于 charge 本地事务提交到达 。RM 查询 undo_log,发现该 xid+branchId 下没有记录 ,执行防悬挂措施:插入一条 log_status=1 的空 undo_log,使用下一个自增 ID=1598574。 |
| ③ | charge 本地事务提交,Seata 尝试插入完整的 undo_log(ID=1598573 ,包含 16 条 beforeImage)。如果 (xid, branch_id) 有唯一索引,此时将因冲突而提交失败,事务回滚,数据不会删除,问题根本不会发生。 |
| ④ | 由于 (xid, branch_id) 没有唯一索引 ,ID=1598573 的插入成功,形成两条 undo_log 共存的异常状态。 |
根本原因
undo_log 表缺少 (xid, branch_id) 的唯一索引 ,导致:防悬挂机制失效,charge本地事务提交成功,未被阻止。
七、解决方案与反思
立即修复
-
创建唯一索引:
sqlCREATE UNIQUE INDEX idx_undo_log_xid_branch ON undo_log(xid, branch_id);确保防悬挂机制能正确工作,避免重复插入。
-
添加业务表查询/删除关键字段索引:
sqlCREATE INDEX idx_order_items_order_id ON charge_order_item(order_id); CREATE INDEX idx_presc_detail_presc_id ON charge_other_presc_detail(presc_id); CREATE INDEX idx_wait_charge_presc_no ON charge_wait_charge_presc(presc_no);将 charge 服务处理时间从 10 秒降到 1 秒以内。
-
显式配置 Feign 的 Request.Options:
java@Bean public Request.Options options() { return new Request.Options(60000, TimeUnit.MILLISECONDS, 60000, TimeUnit.MILLISECONDS, false); }
反思
本次问题的根本原因看似是"缺少一个索引",但暴露出的问题却是多层次的:
- 性能问题:慢 SQL 是超时的根源,索引缺失导致全表扫描,16 次循环累加超过 10 秒。
- 配置管理:Feign 超时配置未生效,说明对 Feign + OkHttp 的配置优先级理解不足。
- 框架认知:对 Seata 防悬挂机制的依赖条件(唯一索引)不够清楚,也未在生产环境进行前置校验。
- 时序推理 :两条
undo_log的时间戳相同曾一度误导我认为它们是同时写入的,但结合自增 ID(1598573 → 1598574)和 Seata 防悬挂机制,最终还原了正确的时序。