记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧

引言

在微服务架构中,分布式事务是保证数据一致性的关键。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_detailcharge_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 :包含完整的 beforeImagelog_status=0,表示从未被回滚使用。
  • ID=1598574 :内容为空,log_status=1,表示已被标记为"全局事务已完成"。

正常情况,一个分支事务只应有一条 undo_log。这里却出现了两条,且 branchId 相同,违反唯一性。


六、定位根因:Seata 防悬挂机制 + 缺少唯一索引

在反复推敲后,我们将目光聚焦到了 Seata 的防资源悬挂(Anti-Suspension)机制,结合日志时序和自增 ID 的顺序,精确还原了问题过程。

什么是防资源悬挂?

Seata AT 模式中,存在一种并发场景:全局事务回滚指令可能先于本地事务提交到达 RM 。这种情况下,RM 收到回滚请求时,本地事务尚未提交,undo_log 还未写入,回滚无从执行。

Seata 的防悬挂机制通过以下方式解决:

  1. 回滚请求先到达时,RM 查询 undo_log 发现无记录,插入一条 log_status=1 的空 undo_log 作为占位符
  2. 稍后本地事务提交时,Seata 尝试插入完整的 undo_log
  3. 如果 (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本地事务提交成功,未被阻止。


七、解决方案与反思

立即修复

  1. 创建唯一索引

    sql 复制代码
    CREATE UNIQUE INDEX idx_undo_log_xid_branch ON undo_log(xid, branch_id);

    确保防悬挂机制能正确工作,避免重复插入。

  2. 添加业务表查询/删除关键字段索引

    sql 复制代码
    CREATE 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 秒以内。

  3. 显式配置 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 防悬挂机制,最终还原了正确的时序。
相关推荐
小短腿的代码世界1 小时前
Qt Bluetooth源码深度解析:从HCI协议到跨平台API的完整架构
开发语言·qt·架构
掉鱼的猫1 小时前
Agent Harness 实战指南:构建生产级 AI Agent 的"马具"框架
java·llm·aigc
带刺的坐椅1 小时前
Agent Harness 实战指南:构建生产级 AI Agent 的"马具"框架
java·ai·llm·agent·solon-ai
c238562 小时前
互斥锁高频面试题全解:从基础概念到底层实现,一文通关
java·c++·面试·职场和发展
北冥you鱼2 小时前
Go 语言读取链上数据:从基础到实战
开发语言·后端·golang
程序员cxuan2 小时前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员
郡杰2 小时前
Git基础与开发平台搭建
git·后端
小短腿的代码世界2 小时前
Qt WebEngine多进程架构与IPC通信:从Chromium多进程到Qt信号槽融合
qt·架构·系统架构
error:(2 小时前
【系统与实战双精通】VS Code 调试 ROS2 Python 节点与 Launch 系统指南
android·java·python