单步调用返回成功,只说明这一步本身通;多步决策串起来之后出现状态丢失或重复执行,通常是每一步各自持有内存态、状态落点没有指定归属层、失败后没人负责回退造成的。排查顺序建议固定下来:先把调用链和状态落点画成表,再补状态传递骨架,然后才谈幂等、超时和回滚。跳过链路表直接调重试参数,容易把问题掩盖成偶发。
多步决策的故障点多数不在单步逻辑,而在状态归谁写、写在哪一层、失败后回到哪个点。建议先落一张链路表,标出每步的状态变更和失败影响;再按 trace_id 串起状态写入与日志,用同一请求发两次验证幂等,用失败注入确认回滚边界。适用面是可改代码或可加中间层的自建链路;纯黑盒外部接口只能做记录与人工兜底,回滚能力受下游是否提供撤销接口限制。
画出多步决策调用链和状态落点
先把决策拆成有限几步,每一步写清输入从哪来、输出给谁、改了哪块状态、失败会影响后面哪几步。状态落点通常只有三种选择:调用方内存、决策上下文存储(库或带持久化的缓存)、下游业务库。同一个 trace 的状态建议只落在一处,其他层只读或只留快照,避免两边都写导致对账困难。
| 步骤 | 输入 | 输出 | 状态变更 | 失败影响 |
|---|---|---|---|---|
| 上下文组装 | 请求参数、用户标识 | 绑定 trace_id 的决策上下文 | 写入初始状态 pending | 链路不启动,无需回滚 |
| 规则或模型计算 | 上下文 | 分数、命中规则 | 更新为 scored | 可重试;重试耗尽则停在 pending,不占资源 |
| 资源预占 | 分数、配额标识 | 预占凭证 | 写入 held 并记录凭证号 | 不释放会泄漏配额,需要补偿 |
| 决策落库并响应 | 预占凭证 | 决策结果 ID | 变为 committed | 响应丢失靠幂等读回,不重复落库 |
用伪代码实现状态传递骨架
下面结构只表达状态怎么传、什么时候写,函数名和存储层按自家实现替换,不绑定任何官方接口。要点是每步开始先读状态、成功后再写状态,中间不留内存直传。
# 仅示意,不可直接运行
STEP_HANDLERS = {} # step_name -> callable
def run_step(name, payload, ctx):
state_before = load_state(ctx['trace_id']) # 状态从存储读,不从内存读
result = STEP_HANDLERS[name](payload, state_before)
state_after = merge_state(state_before, result)
save_state(ctx['trace_id'], name, state_after) # 每步成功后才落盘
print('trace=%s step=%s before=%s after=%s' % (
ctx['trace_id'], name, state_before, state_after))
return state_after
def save_state(trace_id, step, state):
DB.upsert('decision_state', trace_id, step, state) # 仅示意
def load_state(trace_id):
return DB.get('decision_state', trace_id) or {}
验证方式很直接:每步打印 state_before 与 state_after,跑完一次完整链路后按 trace_id 查库,确认库里的最终状态与日志最后一条 after 一致。若中途某步只有 before 没有 after,说明那一步没有落盘,多步串联时的状态丢失往往就出在这里。
设计重复执行和超时防护
重复执行通常来自客户端重试、上游超时后重发、调度任务重复触发。防护放在写状态的那一层最有效,因为只有那一层知道这次决策是否已经推进过。
- 幂等键:建议用 trace_id + step_name + input_hash 组合,写状态前先查该键是否存在,存在则返回上次结果,不再执行副作用。
- 超时时间:给每步单独设超时占位常量并集中配置,不要全局共用一个值;具体数值需要结合下游响应情况和环境确认后再定。
- 重试次数:建议默认 2 次,只对可重试错误码重试,退避按 base 乘以 2 的 n 次方递增;已经产生外部副作用的步骤不允许自动重试。
验证方式:用同一个 trace_id 和同一份输入连发两次请求,观察第二次是否命中幂等记录、配额或扣减这类变更是否只生效一次。如果两次都生成了新凭证,说明幂等判断放在了副作用之后,需要前移。
注入失败并验证回滚条件
回滚条件要在动手改代码前定下来:哪一步失败必须回滚,哪一步只需要记录。常用边界是,产生外部副作用的步骤失败要回滚,纯计算步骤失败只需重试或记录。
| 失败场景 | 期望动作 | 人工介入条件 | 必须打的日志字段 |
|---|---|---|---|
| 规则计算超时 | 重试后仍失败,整个 trace 停在 pending | 同一 trace 重试耗尽 | trace_id、step_name、error_code、attempt |
| 预占成功但落库失败 | 调用释放接口退回预占,状态回到 pending | 释放接口同样失败 | trace_id、pre_hold_id、state_before、state_after |
| 决策已落库、响应丢失 | 不回滚,靠幂等键让重发请求读回同一结果 | 对端要求重新决策 | trace_id、idem_key、decision_id |
| 下游撤销接口不可用 | 只记录 held,交给补偿任务 | 超过约定时长仍未补偿 | trace_id、state_before、error_code |
注入方式建议在测试环境用开关或替身实现:把某一步强制返回超时或异常,跑一次完整链路,再查库和日志确认状态是否回到了预期点。不要在无法回滚的下游上做这类注入。
输出可追踪的多步日志字段
线上排查多步决策问题,能把日志按一次决策链路串起来,比日志写得多更重要。建议每一步至少输出一行结构化日志,字段先固定下来:
- trace_id:整条链路唯一,贯穿所有步骤与下游调用。
- step_name:步骤名与链路表保持一致,便于直接对照。
- input_hash:该步输入的摘要值,用来判断两次请求是否同一输入。
- state_before 与 state_after:状态变更前后,建议只记关键字段,避免整个对象落日志。
- error_code:错误码占位;同时带上 attempt、ts 用于判断重试次数和时间。
字段命名先定再写代码,中途改名会让历史日志和查询语句对不上。查询时按 trace_id 排序即可还原一次决策的完整路径,遇到状态跳变,就能定位到具体是哪一步没有落盘或重复落盘。