一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

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

时间:2026-07-07 09:29:05 编辑:袖梨 来源:一聚教程网

引言

在微服务架构中,分布式事务是保证数据一致性的关键。Seata 作为一款流行的分布式事务解决方案,其 AT 模式通过 undo_log 表实现自动补偿。然而,当我们依赖框架的默认行为时,一些看似不起眼的细节——比如一个缺失的数据库索引——就可能导致严重的数据不一致。

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

本文将完整记录我们如何从一条 Feign 超时异常开始,层层剖析,最终定位到 Seata 防资源悬挂机制因缺少唯一索引而失效的全过程。希望能为遇到类似问题的开发者提供一份可参考的排查思路。


一、问题现象:医嘱撤销失败,数据出现“半删半留”

某日,业务人员反馈医生执行“医嘱明细撤销”操作时,系统报错,且部分数据未能正常恢复。具体表现为:

  • 撤销操作返回异常,前端提示“操作失败”。
  • 数据库检查发现:charge_order_item(医嘱项目表)中对应的 16 条记录已被永久删除,而 t_test_rec(检验记录表)中的数据却依然存在,导致业务数据不一致。

从业务角度,撤销操作本应要么全部成功(删除所有相关数据),要么全部失败(回滚所有变更),但当前状态显然违背了原子性。


二、初步排查:日志指向 Feign 调用超时

查看服务日志,发现了关键线索:

 复制代码feign.RetryableException: timeout executing POST 
  • 调用方doctor-service(医生服务)
  • 被调方charge-service(收费服务)
  • 调用耗时:约 10 秒后超时

同时,Seata 全局事务日志显示:

 复制代码Begin new global transaction [xid=172.16.1.3:8091:1369875561409518336]
...
rollback status: Rollbacked

全局事务因 Feign 超时异常被回滚,但 charge 服务的本地事务已经提交,于是出现了部分数据被删、部分数据保留的局面。


三、为什么执行时间超过 10 秒?—— 慢 SQL 的发现

排查超时,我们首先需要回答一个问题:charge 服务处理 16 个项目的删除,为什么会耗时 10 秒之久?

检查 charge 服务日志,发现对每个 orderId 执行了如下操作:

 复制代码-- 对每个 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

添加索引后:

 复制代码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 服务的配置文件,发现:

 复制代码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 并打印其超时值:

 复制代码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

 复制代码@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 秒。

修复超时配置

 复制代码@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

执行查询:

 复制代码SELECT * FROM undo_log WHERE xid = '172.xx.xx.xx:8091:1369875561409518336';

得到两条记录:

IDbranchIdcontextrollback_infolog_statuslog_created
15985731369875561409518337ZIP压缩完整镜像(16条 beforeImage02026-07-03 10:41:04
15985741369875561409518337NONE{}12026-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. 创建唯一索引

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

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

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

     复制代码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

     复制代码@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 防悬挂机制,最终还原了正确的时序。

热门栏目