ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

漫漫归途:一次数据链路丢失的排查与幂等治理

漫漫归途:一次数据链路丢失的排查与幂等治理 半夜收到一条告警工单编号是P31处理人填了个备注“漫漫归途”。第一眼看以为是谁随手起的名字等真正开始查才发现这个名字非常贴切。P31 描述的问题不是系统崩溃也不是数据库连接失败而是一条业务数据本该在处理完成后回到上游系统结果走到一半就消失了。没有报错没有死信没有失败日志就像一段旅程出发了但永远没有到站。这类问题的麻烦在于它不属于某一个明确的故障点而是发生在一条很长的链路上。排查的时候你不可能只盯一个日志文件或者只查一张表。真正有效的方法是把整条“归途”拆成可以独立验证的段落然后在每一段上确认输入、状态、输出。这篇文章不是讲某个开源工具也不是讲某类算法而是还原一次真实会遇到的数据链路排查过程以及我从中沉淀下来的一套通用方法。1. P31 不是文学项目是一条找不到归途的数据1.1 一次看似普通的同步任务失败场景本身很常规。上游业务系统产生一条待处理单据通过接口或消息队列发给下游处理系统下游完成处理之后再把结果通过回调接口或消息回写上游把单据状态从“处理中”改成“已完成”或“失败”。这是企业内部最常见的异步交互模式几乎所有涉及跨系统协作的业务都会用到。P31 的具体现象是某条单据在上游已经停留超过 30 分钟状态一直是“处理中”。正常情况下下游处理完应该马上回写。业务方反馈“数据丢了”但当你去下游系统查的时候会发现这条单据明明已经处理成功结果也有状态也是对的。这就形成了一个非常矛盾的现场上游说“我没有收到结果”。下游说“我已经处理完了结果已经返回”。数据库里没有报错。日志里没有异常。数据就是没有回到它该在的位置。我第一次处理这种问题的时候下意识地翻了下游回调接口的日志重点看有没有异常。结果日志干净得很既没有超时也没有失败。这是这类问题最容易让人卡住的地方你以为是接口返回了失败实际上接口根本连请求都没有收到。1.2 为什么这条“归途”特别难查难查的根本原因是这段归途不是一条直线而是由多个独立系统、多个通信协议、多个定时任务拼接起来的链条。每一段单独看都正常但连在一起就可能出现缝隙。我通常会把数据往返画成下面这样上游业务系统 │ │ ① 发起处理请求API / MQ ▼ 传输层接口超时、MQ 消费失败、网络抖动 │ ▼ 下游处理系统 │ │ ② 业务处理完成 ▼ 回调阶段回调接口、消息回写、定时对账 │ ▼ 上游状态落库P31 问题的关键在于整个过程里“没有返回”和“返回失败”是完全不同的两件事。返回失败通常会在日志里留下痕迹而“没有返回”可能意味着回调根本没有触发也可能意味着回调请求丢了还可能意味着回调响应已经到达但由于没有关联到原始请求被静默丢弃了。也就是说归途之所以“漫漫”不是路太长而是路上缺少必要的“路标”。你看到数据出发了也看到它在终点附近出现过但中间某一段没有任何可观测的痕迹。排查自然就变成了一件靠猜的事情。2. 排查前先画一张链路图而不是急着翻日志2.1 把数据往返拆成可以验证的六段接到 P31 之后我先做的不是打开日志文件而是把整条链路画出来并且给每段起了一个能验证的名字。这是一个很原始但很有效的方法如果你不能把问题定位到某一段就说明链路还没拆够细。我在这次排查中用了六段模型分段说明可验证的痕迹① 上游发起单据是否真的发出了处理请求请求表记录、消息发送记录、发送日志② 传输到达下游是否真的收到了这条请求网关日志、MQ 消费记录、下游入口日志③ 下游处理业务处理是否成功处理表状态、业务日志、结果数据④ 回调触发成功后是否触发了回写逻辑回调代码日志、任务调度记录⑤ 回传统达回调请求是否到达上游上游回调接口日志、统一入口日志⑥ 上游落库上游是否正确更新状态单据状态变更记录、数据库更新日志对一个具体问题来说并不是每一段都要查但每一段都必须有办法查。P31 里① 和②都没有问题③也没有问题真正可疑的是④⑤⑥。2.2 每一段都要回答三个问题分段之后每一段都要回答三个固定问题能回答上就说明这段路是通的它是否产生了输入它是否处理了这个输入它是否输出了可被下一段识别的结果这三句话看起来简单但很多排查走到一半会乱就是因为没有按段问而是同时被多个系统的信息干扰。比如当你看到下游处理表状态已经是“成功”就容易默认回调也一定执行了。但“处理成功”和“回调成功”是两回事必须分开验证。P31 里当我检查下游回调触发条件时发现这段代码是这样的逻辑if result.success: notify_upstream(result)这是非常典型的写法看起来没问题。但真正的问题在于notify_upstream内部可能有两类错误一是回调请求抛出了异常但被上层吞掉了二是请求没有抛出异常但返回值里的成功标志被忽略。如果没有针对回调本身做日志记录这段就是整个链路上的“盲区”。2.3 我的排查顺序先溯源再回放最后落库实际操作的时候我不会从前往后查而是从结果往前查。因为现象出现在最后一段上游状态没有更新。那我就先确认最后一段有没有收到回调再倒推到上一步。具体排查顺序是先在上游回调入口处用单据号或业务流水号搜索日志看有没有收到下游的回调。没收到就去下游回调发送处看有没有执行发送逻辑。如果发送了但在上游入口没有出现就要考虑网络、网关、负载均衡、序列化异常等传输层问题。如果上游收到了但状态没有更新就要查回调处理逻辑里的查询、幂等、锁和事务。P31 最终查到的结果很有意思上游回调入口根本没有收到请求因为下游回调发送前有一次save操作在处理结果落库时触发了异常异常被某个框架层面的拦截器吞掉了后续的notify_upstream代码根本没有执行。这就是“处理成功但回调从未发生”的典型例子。如果没有链路图和分段验证几乎不可能在短时间内想到问题出在“成功之后通知之前”。3. 真正让归途变漫长的是回写阶段的幂等和时序3.1 第一道坎幂等键缺失P31 的现场不只一个问题。即使我把第一个问题修好让下游在异常后不再吞掉回调后续还有两类更隐蔽的坑它们不会导致失败但会导致数据即使回到了上游也会被错误处理。这类问题比“丢了”更麻烦因为表面看已经恢复但实际上状态可能是错的。第一种坑是幂等键缺失。下游处理完一条单据可能会触发多次回调。比如接口超时后重试或者业务方手动触发了补发。如果回调接口只靠“单据号 结果状态”去更新数据就可能出现同一条单据被处理两次的情况。第一次回调到达时状态从“处理中”更新为“成功”。第二次回调到达时由于没有唯一性约束系统会再次执行一次状态更新。如果第二次回调带的是旧数据就可能把新状态覆盖掉。这在日志里是极难察觉的因为两次更新都成功了没有冲突没有报错。唯一让人起疑的是状态变更记录里出现了两个时间戳且后一个时间戳对应的内容反而更旧。判断一个阶段是否安全关键看它有没有幂等键。一个简单的验证方法是在回调落库时使用“业务流水号 回调类型”作为唯一键并在代码里处理冲突INSERT INTO callback_record (biz_no, callback_type, status) VALUES (P31-DEMO, SUCCESS, done) ON DUPLICATE KEY UPDATE status VALUES(status), updated_at VALUES(updated_at);当然具体数据库语法不同但这个思路是通用的回调记录必须有唯一标识并且重复写入时不能造成覆盖风险。3.2 第二道坎晚到的旧状态把新状态盖掉第二种坑是时序问题。异步系统里消息的到达顺序和发送顺序并不一定一致。下游可能有多个处理节点一个节点先处理完成发送了“成功”回调另一个节点因为网络延迟稍后又发送了一条更早的“失败”回调。如果上游回调逻辑没有做状态流转校验后到达的“失败”会把已经确定的“成功”覆盖成失败。这种问题叫“乱序覆盖”它让数据看起来“回来过”但回到的是一个错误的状态。处理办法不是强行保证消息有序而是给状态流转加一道闸门只允许合法方向的状态变更。比如“处理中 - 成功”合法。“成功 - 失败”不合法应当拒绝。“失败 - 成功”是否合法要按业务规则决定一般需要人工确认或明确的历史状态迁移表。实际落地时可以在更新语句里带上当前状态条件UPDATE biz_order SET status SUCCESS, updated_at now() WHERE biz_no P31-DEMO AND status PROCESSING;如果更新影响行数为 0说明状态已经不是“处理中”要么这条数据已经被处理过要么状态异常。此时不应该继续执行后续业务而应该记录冲突日志并报警。3.3 事件留痕没有过程就无法定位归途P31 这个工单还暴露了一个更深层的问题系统里只有“最终状态”没有“过程事件”。上游单据表里只有status字段从“待处理”变成“处理中”再变成“成功”。至于中间经历了哪几次回调、哪些请求被丢弃、哪些状态被覆盖全部没有记录。所以当问题发生的时候你只能靠日志去猜而日志又恰好不完整。这就是为什么我在这次优化中强调数据归途要可追溯必须把“状态”升级为“事件”。简单说就是每次状态变更都留下一行不可覆盖的记录。事件表结构可以非常简单CREATE TABLE t_biz_order_event ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_no VARCHAR(64) NOT NULL COMMENT 业务单据号, from_status VARCHAR(32) NOT NULL, to_status VARCHAR(32) NOT NULL, event_source VARCHAR(64) COMMENT 触发来源回调/重试/人工, callback_id VARCHAR(64) COMMENT 回调唯一标识, payload TEXT COMMENT 原始回调内容, created_at DATETIME NOT NULL ) DEFAULT CHARSETutf8mb4;有了事件表之后再遇到“数据没有归来”的问题就不需要拼凑日志了。直接按biz_no查事件表按时间排序就能看到这条数据的完整旅程。它在哪一段没有后续哪一段就是问题所在。4. 从“能查到问题”升级为“让归途可控”4.1 先补观测再改业务逻辑排查完 P31 的根因后我意识到一个比修 bug 更重要的问题这次能找到根因有运气成分如果链路再长一点日志再少一点可能就查不到了。所以后续的优化重点不是只修那一个异常而是让整条链路变得可观测。具体做三件事第一所有跨系统调用必须给请求带一个全局唯一的trace_id。这个trace_id要贯穿上游发起、下游处理、回调返回的全过程。日志里只要打出这个 ID排查时就能把所有相关记录串起来。第二回调发送和回调接收必须记录独立的日志。日志里要包含biz_no、trace_id、req_time、resp_time、http_status、result_code。尤其是回调发送点不能只依赖框架层的访问日志业务代码里也要有明确的日志哪怕只有一行。第三日志不能只记录异常。很多系统在正常路径上不打日志只在出错时打印。这导致排查“没报错但结果不对”时完全无从下手。对于回调这类关键操作我建议在发送前和发送后各打一条日志标记状态。4.2 再补重试和死信而不是靠人工补单P31 根因确认后第一版修复方式是“补一次人工处理”。这是应急手段不能长期依赖。更合理的做法是给回调发送加上重试机制并且把多次重试仍失败的任务放进一个可管理的“死信区域”。这里要区分两个概念重试解决的是临时性故障比如网络抖动、数据库连接池短暂打满。死信解决的是永久性故障比如数据结构错误、业务规则不满足。常见实践里重试可以放在本地消息表里。流程是下游处理成功后先写入一条“待回调任务”到本地表事务提交后再通过一个后台任务发送回调。只有本地表和业务处理在同一个事务里才能避免“业务处理成功但回调任务丢失”的情况。伪代码结构是def process_biz(data): with db.transaction(): result do_business(data) save_callback_task(biz_nodata.biz_no, payloadresult) # 事务提交后台发送 background_dispatch_callback(data.biz_no)如果后台发送失败任务保留在表里状态为“待发送”重试次数加一。超过最大重试次数状态变成“死信”由告警通知相关人处理。这样即使归途再长也不会出现“数据悄然消失”的情况。4.3 最后补幂等校验把状态机变成唯一真相能查到问题能让重试可靠接下来还要确保同一个回调即使被重复发送多次也不会造成错误。这就是前面提到的幂等键和状态机。幂等校验的核心是在处理回调请求时先检查这个回调是否已经处理过。判断标准不是业务单据号而是“业务单据号 回调类型 回调方生成的事件 ID”。只有三者组合唯一才是真正的一次独立回调。在更新状态之前还要加状态流转校验。这个校验可以内聚成一个小的状态机而不是散落在各处 if 判断里。一个简单的做法是维护一张状态迁移表PROCESSING - SUCCESS PROCESSING - FAILED FAILED - PROCESSING # 允许重推 SUCCESS - 不允许任何迁移每次从消息里拿到的目标状态先查这张表如果不合法直接拒绝并记录冲突日志。这会多写几行代码但它能保证无论消息乱序、重复、延迟最终落库的状态都是可控的。5. 一次 P31 沉淀下来的排查方法论5.1 五层递进排查法P31 这个工单处理完之后我把整个过程收敛成了一个可复用的排查框架叫“五层递进”。以后遇到类似“数据在链路上消失”的问题不再凭直觉而是按层往下走。第一层输入层。先确认数据是否被正确创建和发送原始字段是否完整发送动作是否真实发生。第二层链路层。确认发送和接收之间的传输是否可信有没有丢消息、延迟、网关拦截、序列化失败。第三层状态层。确认数据处理方是否真正完成了业务并且处理结果是否持久化持久化是否成功。第四层时序层。确认多个回调、重试、并发操作之间是否有乱序、覆盖、幂等缺失问题。第五层边界层。确认超时、重试上限、死信处理、人工补偿机制是否存在能否兜底。层级核心问题典型证据输入层数据是否真的发出了发送记录、接口入参日志链路层传输过程是否可靠网关日志、消息消费记录状态层处理结果是否持久化业务表状态、事务提交结果时序层状态更新是否被覆盖事件表、幂等键冲突记录边界层异常是否有兜底机制重试日志、死信表、告警记录P31 最初的排查之所以慢是因为我一直停在链路层和状态层忽略了“处理成功之后的通知动作”其实是一个独立环节它有自己独立的失败可能。五层递进法把“通知动作”归入边界层提醒自己只要这一步没有死信兜底它就是整条链路最脆弱的缝隙。5.2 什么时候不该过度设计把 P31 讲得这么细也容易让人产生一个误解是不是所有跨系统调用都必须上事件表、状态机、死信、重试、幂等当然不是。判断是否需要这套体系主要看两个指标一是失败成本。如果单据状态更新错了会直接影响资金、合同、生产流程那投入再多的可观测设计都值得。如果只是内部工具的一个辅助状态错了也能手动改回来那过度设计反而会增加维护成本。二是调用频率。高频、自动触发的跨系统回调一定要靠机制保障低频、人工触发的同步操作简单重试就够了。我一般会按这么几个档位来处理内部小系统直连失败就报错打日志 人工重试即可。跨部门接口失败需要对方配合加超时、重试、回调日志。核心业务链路失败影响用户可感知事件表、幂等、死信、告警全套。分布式跨团队消息中间件流转额外再补对账任务。P31 里的场景属于第三种因为它已经开始影响真实业务状态了所以从拆链路到补观察、补幂等都属于合理的工程投入。5.3 归途的尽头不是一条日志而是一套秩序写完这篇记录我回头看工单里那四个字“漫漫归途”觉得它其实点出了这类问题的本质。数据不会自己迷路。每一条看似无故消失的数据背后都是一个被跳过的环节、一个被忽略的状态、一个没有记录的事件。问题越诡异越说明系统在某个看不见的地方缺少秩序。我们排查问题表面上是找一条数据是怎么丢的实际上是在给系统建立一套完整的路径规则从哪里来经过哪里到哪里去每一步有没有留下凭证重复来了怎么处理错了有没有兜底。有了这套规则之后“漫漫归途”就不再是一个需要半夜爬起来猜测的悬案。它只是一条路径清晰、节点可查、异常可控的普通旅程。P31 的价值不在于我补了一个 bug而在于从这一次之后我每次设计跨系统调用都会先问一句话“如果数据在这段路上消失了我能在多长时间内定位到它”如果答案是“不能”那代码写得再漂亮也只是把问题推迟到了深夜。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进