Startlux-Decision 做多步决策 / 先盯住状态传递和回滚条件

文章导读
单步调用返回成功,只说明这一步本身通;多步决策串起来之后出现状态丢失或重复执行,通常是每一步各自持有内存态、状态落点没有指定归属层、失败后没人负责回退造成的。排查顺序建议固定下来:先把调用链和状态落点画成表,再补状态传递骨架,然后才谈幂等、超时和回滚。跳过链路表直接调重试参数,容易把问题掩盖成偶发。
📋 目录
  1. 一 画出多步决策调用链和状态落点
  2. 二 用伪代码实现状态传递骨架
  3. 三 设计重复执行和超时防护
  4. 四 注入失败并验证回滚条件
  5. 五 输出可追踪的多步日志字段
A A

单步调用返回成功,只说明这一步本身通;多步决策串起来之后出现状态丢失或重复执行,通常是每一步各自持有内存态、状态落点没有指定归属层、失败后没人负责回退造成的。排查顺序建议固定下来:先把调用链和状态落点画成表,再补状态传递骨架,然后才谈幂等、超时和回滚。跳过链路表直接调重试参数,容易把问题掩盖成偶发。

多步决策的故障点多数不在单步逻辑,而在状态归谁写、写在哪一层、失败后回到哪个点。建议先落一张链路表,标出每步的状态变更和失败影响;再按 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,说明那一步没有落盘,多步串联时的状态丢失往往就出在这里。

Startlux-Decision 做多步决策 / 先盯住状态传递和回滚条件

设计重复执行和超时防护

重复执行通常来自客户端重试、上游超时后重发、调度任务重复触发。防护放在写状态的那一层最有效,因为只有那一层知道这次决策是否已经推进过。

  • 幂等键:建议用 trace_id + step_name + input_hash 组合,写状态前先查该键是否存在,存在则返回上次结果,不再执行副作用。
  • 超时时间:给每步单独设超时占位常量并集中配置,不要全局共用一个值;具体数值需要结合下游响应情况和环境确认后再定。
  • 重试次数:建议默认 2 次,只对可重试错误码重试,退避按 base 乘以 2 的 n 次方递增;已经产生外部副作用的步骤不允许自动重试。

验证方式:用同一个 trace_id 和同一份输入连发两次请求,观察第二次是否命中幂等记录、配额或扣减这类变更是否只生效一次。如果两次都生成了新凭证,说明幂等判断放在了副作用之后,需要前移。

注入失败并验证回滚条件

回滚条件要在动手改代码前定下来:哪一步失败必须回滚,哪一步只需要记录。常用边界是,产生外部副作用的步骤失败要回滚,纯计算步骤失败只需重试或记录。

Startlux-Decision 做多步决策 / 先盯住状态传递和回滚条件
失败场景期望动作人工介入条件必须打的日志字段
规则计算超时重试后仍失败,整个 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 排序即可还原一次决策的完整路径,遇到状态跳变,就能定位到具体是哪一步没有落盘或重复落盘。