最近回头整理一条资产推理流程时,我发现自己花心思补的,更多落在“出了最脏的异常以后,系统最后那份状态还能不能信”上面。

这条流程本身不算复杂:先判断资产是否在线,再查漏洞,必要时补查威胁信息,如果确认有高危漏洞,就立刻发通知。

难点不在 happy path,而在这些很烦但又真实会发生的场景:

  • 同一个资产的任务会重试。
  • 整个流程里有好几个外部调用,耗时不稳定。
  • 服务可能中途挂掉,新的执行者要能接着跑。
  • 最危险的是,旧线程卡死一阵以后又活过来,继续拿着过期上下文往下写。

前面几件事,很多系统都知道要防。最后这件事,反而特别容易被低估。

问题不在并发本身,在过期执行流还在写

一开始我也走的是很常见的思路:

  • 用 Redis 分布式锁保证同一资产同一时刻只有一个执行者。
  • 用 Redis 记流程状态,给幂等和断点续作兜底。
  • 用看门狗续期,避免长任务把锁拿着拿着拿丢了。

这套东西已经比“写一段串行逻辑直接跑”强很多了,不过往坏一点的场景推,就会发现还有个口子没补上。

比如这样:

  1. 线程 A 开始处理某个资产,查下来发现设备离线。
  2. 它正准备把状态写成 OFFLINE,结果进程卡了很久,比如长时间 GC。
  3. 锁因为续不上失效了。
  4. 线程 B 接管任务,重新拿到锁,查到资产已经恢复在线,还扫出了高危漏洞,于是发了通知,并把状态更新成类似 SCANNED_VULNERABLE
  5. 这时候线程 A 又醒了,继续按自己那套过期上下文执行,把状态写回 OFFLINE

这时候最恶心的地方就在于:通知其实已经发出去了,但系统里最后那份记录却像什么都没发生过。

这不是普通覆盖,事实和记录直接裂开了。对外部世界来说,高危漏洞已经被发现;对内部系统来说,这台机器又成了“离线,未发现漏洞”。

告警流程碰上这种事,后面审计、排障、补偿都会变得很别扭。你会发现流程表面跑完了,但最后那笔状态已经不可信了。

如果把这个问题压成一张图,大概就是下面这样:

flowchart LR
  start["一次资产推理任务"] --> lock["拿锁"]
  lock --> ticket["读取票据状态"]
  ticket --> step["继续在线检查 / 漏洞查询 / 威胁补查"]
  step --> risk{"发现高危漏洞?"}
  risk -- "否" --> finish["更新最终状态"]
  risk -- "是" --> notify["发送通知"]
  notify --> valid["带当前 token 写票据"]
  valid --> finish

  takeover["新线程接管"] --> newer["拿到更大的 token"]
  newer --> valid

  zombie["旧线程恢复"] --> stale["带旧 token 写旧状态"]
  stale -. "Redis Lua 原子拒绝" .-> reject["拒绝脏写"]

图里最关键的不是“有两条执行流”,而是后面这半句:旧线程恢复以后,到底还有没有资格继续写。

为什么普通分布式锁和看门狗还不够

问题的根子在于,普通锁能解决的是“现在谁能进来”,但它不保证“旧执行流以后就再也不能写”。

看门狗也一样。它很有用,能解决长任务没法提前准确估 TTL 的问题。线程正常活着,它就帮你续;线程真挂了,它也会停,锁能尽快释放给后来者。

但它解决到这里就结束了。

它并不能阻止这样一件事:

  • 旧线程在逻辑上已经失去执行资格。
  • 新线程已经接手并写出了更新的结果。
  • 旧线程恢复以后,还拿着老上下文继续写。

所以关键不在“锁有没有”,而在“写入资格到底怎么判”。

如果一个线程只是曾经拿过锁,就还能继续改状态,那 correctness 其实还没立住。

我后来把这条链路拆成了两层

想清楚这一点以后,我先把“锁”和“状态”拆开了,不让它们混在一个东西里。

锁只负责互斥

锁的职责很单纯:

  • 同一资产同一时刻只允许一个执行者进入核心逻辑。
  • 任务很慢也没关系,交给 Redisson 的看门狗自动续期。
  • 如果实例真的挂了,锁能自动释放,后面的线程可以接上。

票据负责记录流程事实

具体的流程状态,我单独放在 Redis 里的票据对象里。里面会记:

  • 当前跑到哪个状态。
  • 哪些步骤已经完成。
  • 上一次失败发生在哪一步。
  • 漏洞和通知相关的摘要结果。
  • 当前允许写入的版本号。

这个票据独立于锁,它才是整个流程的事实源。后面的恢复、幂等、审计,最终都看它。

这样拆开以后,逻辑会清楚很多:锁管互斥,票据管事实。新线程接手时,直接读票据就知道做到哪儿了,不用猜。

清楚多了。

状态机先把幂等和断点续作补齐

再往下,我没有把这条流程写成一段从头跑到尾的长逻辑,而是明确定义成状态机。

状态大致会经历这些阶段:

INIT -> ASSET_ONLINE / ASSET_OFFLINE -> VULN_CHECK_DONE -> HIGH_RISK_FOUND -> NOTIFYING -> NOTIFIED -> FINISHED / FAILED

这里状态名不是重点,重点是每做完一步,都要把“已经确认发生的事实”写进票据。

这样做有两个直接好处。

第一,重试不会再默认从头跑。新线程接手时,看到票据已经做到 VULN_CHECK_DONE,就不会又从在线检查重新来一遍。

第二,幂等从“接口别重复进”细化到了步骤级。哪些步骤可以跳过,哪些步骤必须继续,哪些状态已经不允许回退,票据里都能看出来。

我还做了一个无锁快筛:先读一次票据,如果任务已经完成,就直接返回,不用每次都去抢锁。这个不是 correctness 机制,只是为了过滤很多无意义的重复请求,顺手把票据当了一层结果缓存。

真正把口子补上的,是 Fencing Token

上面这些东西补完以后,系统已经比较像样了,但最早那个“僵尸线程恢复后继续写旧状态”的问题,还是没有消掉。

最后还得再加一层:Fencing Token。

做法不复杂,关键在于把“我拿到了锁”升级成“我拿到的是当前最新的一次写入资格”。

比如:

  • 线程 A 第一次拿锁,拿到 token = 41
  • 后来它卡住了,线程 B 接管,拿到 token = 42

从这一刻开始,系统里真正有资格改票据的,就只能是持有最新 token 的执行流。

也就是说,状态更新不再是简单的:

SET status = ...

而要变成:

只有当调用方 token 仍然等于当前合法 token 时,才允许写;
否则直接拒绝。

下面这段并非线上原脚本,是按当时机制复原后的最小示意,重点是说明“校验 token + 写状态”为什么必须放在 Redis 服务端一次做完:

local raw = redis.call("GET", KEYS[1])
if not raw then
  return { err = "TICKET_NOT_FOUND" }
end

local ticket = cjson.decode(raw)
local callerToken = tonumber(ARGV[1])
local validToken = tonumber(ticket.fencing_token or 0)

if callerToken ~= validToken then
  return { err = "STALE_TOKEN" }
end

ticket.status = ARGV[2]
ticket.updated_at = ARGV[3]
ticket.last_error = ARGV[4]

redis.call("SET", KEYS[1], cjson.encode(ticket))
return "OK"

这段示意代码具体证明的是:旧线程有没有资格继续写,不应该由客户端自己判断,而应该在 Redis 服务端用原子脚本拦掉。它不能证明的是“线上脚本一字不差就是这样”,那不是这段代码要承担的职责。

这样前面那个最脏的场景就变了:

  • 线程 B 接管以后,拿着更大的 token 写入新状态。
  • 线程 A 后来恢复,试图继续拿旧 token 写 OFFLINE
  • Lua 脚本发现这个 token 早就过期了,直接拒绝。

于是旧线程可以继续醒着,但它已经没有资格污染结果了。

口子算是补上了。

我觉得这一层才是整个流程里最关键的 correctness 补丁。前面的锁、状态机、看门狗都重要,但它们更多是在解决“流程能不能继续跑”。Fencing Token 补的是另一件事:最后这份状态到底还能不能信。

它解决了什么,没解决什么

我很在意这类方案的一点,就是别把边界说大了。

Fencing Token 能解决的是:

  • 旧执行流覆盖新执行流结果。
  • 僵尸线程恢复后继续脏写。
  • “谁有资格写状态”这件事长期靠默认约定,没有显式校验。

但它没有直接解决另外一些事,比如:

  • 微信通知这类外部副作用会不会重复发。
  • 下游 RPC 自己是不是幂等。
  • “通知已经发出,但本地状态还没来得及落盘”这种跨系统一致性问题。

所以我没有指望一个 token 把所有问题都包掉。外部副作用那层,我还是单独做了分层处理:

  • 票据 ID 往下游传,作为 request id / 幂等键。
  • 通知过程单独过 NOTIFYING -> NOTIFIED 这类中间状态。
  • 下游自己也要按这个 id 做幂等。

这样分层以后,边界就比较清楚了:

  • 锁保证互斥。
  • 状态机保证断点续作和步骤级幂等。
  • Fencing Token 保证旧线程别回来乱写。
  • 幂等键保证外部副作用别重复执行。

为什么这里我没有拿 MySQL 做主控

这个问题后来也有人问过我。

不是说 MySQL 做不了。你当然也可以建状态表、做唯一键、跑事务、再补一层补偿任务。

但在这个场景里,我更想把数据库从“过程控制中心”里摘出来。

原因主要有三点。

第一,这里大量读写的是过程态数据,不是最终业务数据。它们更新频繁,时效强,更适合放 Redis。

第二,我不想让这类慢调用、重试、恢复,把数据库连接和写压力一起拖高。尤其在重复请求多的时候,数据库很容易被这些过程控制流量顺手带着受伤。

第三,把“流程控制”和“最终持久化”拆开以后,边界更清楚。Redis 负责锁、状态、恢复;需要长期留存的业务数据,还是回到持久化系统。

对我来说,这个场景里两者更适合分工,跟谁更高级没关系。

这条链路最后让我更放心的,不是它更复杂了

回头看,这套东西让我放心的点,不是它堆了多少机制,是几个判断终于站住了。

第一,锁和状态机是两回事。谁能进来,和流程做到哪了,不该混在一起。

第二,看门狗管的是锁生命周期,但它不替你证明旧线程不会脏写。

第三,最终状态要可信,写入资格就必须可比较、可拒绝,而不是只靠“我感觉我刚才拿过锁”。

第四,内部状态正确,和外部副作用幂等,也不是同一个问题,别硬拿一个机制全包。

所以这篇东西如果非要收成一句话,大概就是:

这条高危告警流程里,我最后补上的,不是又一把更稳的锁,而是系统在异常场景下还能守住哪一份状态算数。