可恢复的任务执行系统(Recoverable Task Execution)
所属:01-fundamentals / 07-distributed-systems
面试权重:★★★
来源:本篇是收束篇,把同目录六篇缝成一个系统 ——process-thread-async.md(执行体)、rpc-http-grpc.md(通信)、queue-pubsub.md(投递)、state-machine.md(状态)、timeout-retry-idempotency.md(重试与幂等)、transaction-wal.md(原子提交)。其中「做完了但确认丢了」的处理流程、持久性语义的分级、保存频率的权衡,引《深入理解 AI Infra:量化分析与系统设计》第 11 章 §11.5(李博杰);事务性发件箱、消费端幂等等模式命名属通用工程实践(System Design 101 / 微服务模式等公开资料),未做一手考证
相关知识点:../06-agent-infra/10-agent-runtime.md(分工:那篇是 Agent 工具执行层的资源与恢复账 —— 并发容量、沙箱生命周期、抢占代价;本篇是通用机制层的最小完整系统,机器换成人也一样讲得通)、../../02-interview/design/08-agent-platform.md(把这一套做成一道系统设计题)、../04-agent-architecture/14-agent-state.md(Agent 视角的状态建模)
一句话#
「重启进程后未完成的任务还能继续」不是靠重启时读一遍内存,而是靠三件事在架构里各就各位:状态落到持久存储(且知道落到哪一级)、恢复到「连续完成到哪一步」而不是「最后写了什么」、所有外部副作用都能被判重。这条链上任何一环缺失,系统平时看着都对,只在故障那一刻露馅 —— 所以本篇的交付物不是架构图,而是一张崩溃点矩阵。
为什么重要(面试角度)#
- 它是一道「看起来都会、一问就散」的题。 几乎所有人都能画出「接入 → 队列 → worker → 数据库」,但追问「在写状态之后、入队之前崩了会怎样」,答得上来的人立刻少一大半。
- 它把同目录六篇串成一条线。 单独问「RPC 的超时」「队列的投递语义」「WAL 是什么」,是知识题;问「实现一个可恢复的任务系统」,考的是你能不能把六块同时摆对位置 —— 这正是资深与初级的真实分界。
- 它有一个必须能脱口而出的判据:连续保存位置 ≠ 最后写入位置。 异步上传失败一轮再成功一轮,云端就会有一个空洞;只会背概念的人不会想到这一层(详见
../06-agent-infra/10-agent-runtime.md§6)。 - 它有真实可讲的落点:QueWI 网关的超时 / 重试 / 熔断是你做过的那一层;这套任务运行时是你要学的那一层。面试里能说清这两层是两回事,就是加分。
- 诚实划界:这一篇是设计,不是你的生产经验。你没有在线上运维过任务队列与工作进程集群。用「这套我会这么搭、这么验」的口径讲,不要说成「我们线上就是这么做的」。
核心内容#
1. 先定义「可恢复」:三条判据,缺一不可#
「重启后能继续」这句话要先拆成三个可检验的问题:
| # | 判据 | 检验问法 | 对应同目录篇章 |
|---|---|---|---|
| ① | 状态存哪、落到哪一级 | 进程被杀后,任务进度还在吗?在内存 / 本地页缓存 / 已 fsync / 云端确认? |
state-machine.md、transaction-wal.md |
| ② | 恢复到哪个位置 | 恢复点由「保存调用返回」决定,还是由「提交完成」决定? | transaction-wal.md、../06-agent-infra/10-agent-runtime.md §6 |
| ③ | 副作用如何不重复 | 重放那一步时,外部系统会收到两次请求吗? | timeout-retry-idempotency.md、rpc-http-grpc.md |
三条是独立的。 状态存好了但恢复点选错(回到「最后一次写入」而不是「连续完整到的位置」),照样丢进度;恢复点选对了但副作用不幂等,照样重复扣款。面试里先把这三条摆出来,比直接画架构图更显功力 —— 因为它证明你知道自己在防什么。
2. 端到端架构:五个部件,各自的责任边界#
┌──────────────────────────────┐
客户端 ──提交──▶ │ 接入层(无状态,多副本) │
│ 生成 task_id / 幂等键去重 │
└───────┬──────────────────────┘
│ 同一个事务里写两行
┌───────▼───────────────┐ ┌─────────────────┐
│ 状态存储(唯一事实源) │◀──│ 事务性发件箱 │
│ 任务状态机 + 步骤记录 │ │ outbox 表 │
└───────▲───────────────┘ └───────┬─────────┘
│ 读写 │ 投递器
│ ┌───────▼─────────┐
┌───────┴───────────────┐ │ 队列(持久化) │
│ 工作进程(无状态、可重启)│◀────│ 至少一次 + 可见性 │
│ 取任务 → 执行 → 提交一步 │ │ 超时 + 死信 │
└───────┬───────────────┘ └─────────────────┘
│ 带操作标识调用
┌───────▼───────────────┐
│ 外部系统(副作用边界) │ ← 这一侧只能靠幂等/补偿
└───────────────────────┘四条设计纪律,逐条都有理由:
- 工作进程必须无状态。 进度只在状态存储里,不在进程内存里 —— 这样进程可以随时被杀、随时换一台机器,恢复不需要「把那台机器找回来」。这也是
process-thread-async.md里「等什么和算什么放不同执行体」在架构层的延伸。 - 队列必须持久化。 用语言内置的内存队列(进程内
Queue、内存里的 Redis List 但没开 AOF/RDB)等于把「未完成的任务」放在最容易被重启清掉的地方。队列是任务的第一份副本,它本身要能扛重启。 - 状态存储是新的单点,必须比别处更可用。 引入状态存储换来的是可恢复性,代价是「它挂了整个平台不知道任务跑到哪了」—— 这条与
../../02-interview/design/08-agent-platform.md§4.5 的三处高可用是同一件事。 - 外部系统永远在信任边界之外。 本地怎么记「已提交」,都保证不了对方恰好执行一次 —— 那一侧只能靠操作标识 / 幂等接口 / 补偿。这一句在
transaction-wal.md与timeout-retry-idempotency.md里各出现一次,这里是被架构逼出来的第三次。
3. 一次任务的生命周期#
PENDING ──入队──▶ QUEUED ──worker 取走──▶ RUNNING(attempt=n)
│
每一轮:读状态 → 执行一步 → 原子提交这一步
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
SUCCEEDED RETRYABLE FAILED
(终态,不可重入) (回 QUEUED,n+1) (进死信,人工介入)两个容易忽略的点:
- 终态必须不可重入。
SUCCEEDED之后再收到同一条消息,正确动作是直接 ack 丢掉,不是再跑一遍。 - 重试次数要有上限,且上限之后要有去处。 无限重投会把一条坏消息变成堵住整个队列的塞子 —— 这就是
queue-pubsub.md里死信队列存在的理由。
容量侧一句话:消费者要开多少,用 Little 定律看 (在办任务数 = 到达率 × 平均处理时长),再用「净消化能力」 判断积压是收敛还是发散。排队时间不由总工作量决定,而由到达方式决定 —— 十项各 1 秒的工作,每隔 1 秒到达则零排队;十项同时到达则平均等 4.5 秒(推演见 ../06-agent-infra/10-agent-runtime.md §4)。
4. 崩溃点矩阵:本篇的核心交付物#
不要把「能不能恢复」当成一个笼统的属性去感觉。正确做法是穷举崩溃点,逐个问「重启后会发生什么」。 这张表就是设计评审时要能当场画出来的东西:
| # | 崩溃时机 | 系统留下的痕迹 | 重启后会发生什么 | 少了哪个机制就会错 |
|---|---|---|---|---|
| 1 | 收到请求、写状态之前 | 什么都没有 | 客户端收不到响应,重试即可 | 客户端侧要有幂等键,否则变成两次提交 |
| 2 | 写完状态、入队之前 | 有 PENDING,队列里没有消息 |
任务永远卡住 ❌ | 事务性发件箱(状态与待投递记录同一事务),或定时清扫陈旧 PENDING |
| 3 | 入队之后、worker 取走之前 | 消息在持久队列里 | 正常被取走 | 队列本身要持久化 |
| 4 | worker 取走、执行中崩溃 | 消息不可见(可见性超时窗口),状态 RUNNING |
超时后消息重新可见 → 被另一个 worker 取走 | 执行要幂等;状态要能判定「这一步到底做完没有」 |
| 5 | 外部副作用完成、写状态之前 | 外部已生效,本地不知道 | 重试 → 副作用发生两次 ❌ | 操作标识 + 先查询外部再决定是否重试 |
| 6 | 写状态之后、ack 之前 | 状态已更新,消息未确认 | 消息重投递 → 同一个任务被再取一次 | 消费端先查状态(做过了就直接 ack) |
| 7 | 一轮的多条更新提交到一半 | 状态是半成品 | 可能停在自相矛盾的中间态 | 把一轮做成原子提交(transaction-wal.md) |
第 2 行和第 5 行是这张表的全部价值所在 —— 它们是「平时永远不出错、只在故障时出错」的两类缺陷,也是面试官判断你是否真设计过这类系统的地方。
- 第 2 行的标准解法叫事务性发件箱(transactional outbox):不要「先写库、再发消息」两个独立动作,而是在同一个本地事务里写业务状态 + 写一条待投递记录,再由一个投递器把记录发出。这样「状态已写但消息没发」和「消息发了但状态没写」都不会出现。
- 第 5 行没有便宜的解法,只能靠操作标识(幂等键)+ 目标系统的幂等接口或补偿。
5. 「做完了但确认丢了」:处理流程#
这是第 4、5、6 行的共同根源,也是 ../06-agent-infra/10-agent-runtime.md §6 讲的第一件事:没收到确认,无法区分「没做」和「做完了但确认丢了」。
正确顺序不是重试,而是:
- 先查询已持久化的结果,核对操作标识与输入版本;
- 查到结果 → 直接接续,不重试;
- 查不到 → 才判断这个操作是否幂等、目标系统是否支持操作标识 / 事务 / 补偿;
- 两者都不满足 → 停下来交人工,不要赌。
必须点破的一条:消息层去重与「实际执行两次」是两回事。 同一份结果被传送两次,去重就够了;同一项操作实际执行两次,已经烧了两份资源 / 扣了两次钱 —— 后者只能靠持久化结果 + 幂等协议避免,消息层帮不上忙。
6. 幂等要落在三个不同的边界上#
「把系统做成幂等的」是一句没有信息量的话。要按边界分别说:
| 边界 | 重复的来源 | 做法 |
|---|---|---|
| 入口(客户端 → 接入层) | 客户端超时后重试提交 | 幂等键 + 去重表 + 唯一约束;重复提交返回同一个 task_id |
| 投递(队列 → worker) | 至少一次投递、可见性超时重投 | 取到任务后先查状态:已完成就 ack,未完成才继续 |
| 步骤(worker → 外部系统) | 上一步成功但没记下来 | 每步带操作标识(如 task_id:step_id),目标侧支持幂等接口;或做可补偿设计 |
注意第三行的措辞:「目标侧支持」不是你能单方面决定的。对方不支持幂等接口时,你能做的只有在自己这一侧做可补偿设计(延迟发送、状态可回退、留人工撤销通道)—— 这正是 transaction-wal.md 里「补偿不总是可行」的实际含义。
7. 最小实现骨架#
伪代码,只保留每一块存在的理由:
# ── 接入:状态与待投递记录在同一个事务里 ──────────────────
def submit(payload, idem_key):
task_id = upsert_task(payload, idem_key) # 唯一约束保证重复提交返回同一 id
with transaction(): # 关键:两行同一个事务
insert_task(task_id, status="PENDING")
insert_outbox(task_id) # 待投递记录
return task_id
# ── 投递器:把发件箱里的记录送进队列(可能重复送,没关系)──
def relay():
for row in select_outbox_unsent(limit=100):
queue.publish(row.task_id) # 至少一次投递
mark_outbox_sent(row.id)
# ── 工作进程:无状态,随时可被杀 ──────────────────────────
def handle(task_id, attempt):
task = load(task_id)
if task.status in ("SUCCEEDED", "FAILED"):
return ack() # 边界二:重复投递直接丢
mark_running(task_id, attempt)
for step in remaining_steps(task): # step 自带 done 标记
if step.done:
continue # 边界二:跳过已完成的步骤
result = call_external(step, idem_key=f"{task_id}:{step.id}") # 边界三
commit_step(task_id, step.id, result) # 关键:这一步原子提交
mark_succeeded(task_id)
return ack()三处 关键 各自对应矩阵里的一行:同一个事务解决第 2 行,step.done + 状态检查解决第 6 行,原子 commit_step 解决第 7 行,idem_key 把第 5 行的伤害降到最低(前提是对方认这个键)。
8. 怎么验证它真的能恢复#
「跑一遍成功了」证明不了任何可恢复性。 正确的验证方式是崩溃注入(crash injection):
- 用计数器式的外部 stub 替掉真实外部系统 —— 它能数出「这个操作被真正调用了多少次」;
- 在崩溃点矩阵的每一行各注入一次
kill -9(或进程内抛异常),然后重启; - 断言两件事:最终状态与不崩溃的对照组一致,且每个外部操作恰好被真正执行一次。
第 3 步是这套测试的全部意义。只断言「任务最后成功了」会漏掉重复副作用 —— 而重复副作用恰恰是最贵的失败(扣两次钱)。这也把 timeout-retry-idempotency.md 里那条判据变成了可执行的东西。
9. 与既有两篇的分工#
| 篇 | 尺度 | 回答什么 |
|---|---|---|
| 本篇 | 通用机制层 | 一台机器重启后,未完成的任务怎么继续(机器换成人也一样讲得通) |
../06-agent-infra/10-agent-runtime.md |
Agent 工具执行层 | 并发容量先撞内存还是 CPU、沙箱留还是重建、抢占要算几笔账 |
../../02-interview/design/08-agent-platform.md |
系统设计题 | 把上面两套压成一道 45 分钟能讲完的题(澄清 → 架构 → 取舍) |
常见面试问法#
Q:让你实现一个「重启后未完成的任务还能继续」的系统,你会怎么设计?
A:先定义「可恢复」,再画架构。 可恢复 = 三条独立判据同时满足:① 状态落到持久存储,且说得清落到哪一级(本地页缓存 / 已 fsync / 云端确认,能扛的故障完全不同);② 恢复点由「提交完成」决定,不是由「保存调用返回」决定,且要记的是连续完整到哪一步,不是「最后写了什么」;③ 外部副作用可判重(操作标识 / 幂等接口 / 补偿)。架构上是无状态工作进程 + 持久化队列 + 状态存储作为唯一事实来源 + 外部系统作为信任边界之外的一侧。关键设计动作是穷举崩溃点:在「写完状态未入队」「外部副作用完成未记状态」「写状态未 ack」这几处,分别用什么机制兜住。
Q:为什么不能「先写数据库,再发队列消息」? A:因为那是两个独立动作,中间崩溃就会留下「状态说任务已提交,但队列里没有消息」——任务永远卡住,而且不会报错,只会静默堆积。正解是事务性发件箱:在同一个本地事务里写业务状态 + 写一条待投递记录,再由投递器负责送出。这样两个方向的不一致都被消除:状态与待投递记录要么都在、要么都不在。代价是队列消息可能重复投递(投递器重试),所以消费端仍要幂等。
Q:任务做完了一半,进程被杀了。重启后怎么知道从哪继续? A:看状态里记的是「连续完成到哪」,而不是「最后写了什么」。 恢复点必须由提交完成决定 —— 一次保存调用返回不代表这份记录已可用于恢复(异步写入尤其如此:每轮先写本地、后台异步上传,某轮上传失败而下一轮成功,云端会留下一个空洞,连续位置停在空洞之前)。所以状态设计里要有一个显式的步骤记录(每步带 done 标记与产物引用),恢复时从第一个未完成的步骤继续;已经完成的步骤跳过而不重做,未完成但可能已产生外部副作用的那一步要走「先查询、再决定」。
Q:任务被重复投递了(至少一次投递),会不会跑两遍?
A:会,而且这是常态,不是异常。 投递语义上端到端「恰好一次」做不到,能做的是「至少一次投递 + 幂等消费」。具体三道闸:① worker 取到任务后先查状态 —— 已 SUCCEEDED / FAILED 就直接 ack;② 步骤级别带 done 标记,已完成步骤跳过;③ 对外部系统的每一步带操作标识,让对方能判重。要主动区分:消息层去重只防「同一份结果传两次」,防不住「同一项操作实际执行两次」 —— 后者已经烧了两份资源,只能靠持久化结果 + 幂等协议。
Q:这套系统你怎么验证它真的能恢复? A:崩溃注入,而不是跑一遍看看。 ① 把外部系统换成会计数的 stub;② 在崩溃点矩阵的每一行各注入一次进程被杀,重启;③ 断言最终状态与对照组一致,且每个外部操作恰好真正执行一次。第三点是重点 —— 只断言「任务最后成功了」会漏掉重复副作用,而重复副作用是最贵的失败。平时再补一个对账任务:定期比对本地状态与外部系统的实际结果,把不一致捞出来。
易错点#
| 易错说法 | 问题 | 应该说 |
|---|---|---|
| 「先写库再发消息就行」 | 两个独立动作之间有崩溃窗口 | 用事务性发件箱:状态与待投递记录同一事务 |
| 「保存返回了,进度就保住了」 | 返回 ≠ 已可用于恢复 | 恢复点由提交完成决定;异步写入尤其如此 |
| 「记最后保存的那一轮就够了」 | 会留下空洞 | 要记从起点连续保存到哪一轮 |
| 「消息去重了就防住重复执行」 | 方向错 | 去重只防「同一结果传两次」;实际执行两次要靠持久化结果 + 幂等协议 |
| 「重试就能恢复」 | 混淆两种失败 | 先查持久化结果与操作标识;「没做完」和「做完了但确认丢了」处理不同 |
| 「状态存本地文件就够了」 | 换机器就没了,且落盘分级不同 | 状态要进持久存储,并说清是页缓存 / fsync / 云端确认 |
| 「工作进程把进度记在内存里」 | 进程一重启进度就没了 | 工作进程无状态,进度只在状态存储 |
| 「用进程内队列顶着先」 | 队列是任务的第一份副本 | 队列必须持久化,能扛重启 |
| 「重试次数不限,总能成功」 | 坏消息会堵住队列 | 重试有上限,上限之后进死信队列交人工 |
| 「本地记了已提交,外部就一定只执行一次」 | 越过信任边界 | 外部那一侧只能靠操作标识 / 幂等接口 / 补偿 |
| 「跑通一遍就说明能恢复」 | 没测故障路径 | 崩溃注入 + 计数 stub,断言恰好执行一次 |
| 把本篇讲成「我们线上就是这么搭的」 | 触碰仓库硬规矩 | 这是设计推演,非一手经验;用「这套我会这么搭、这么验」的口径讲 |
自测#
1. 你的设计是「API 收到请求 → 写 tasks 表 status=PENDING → 发一条消息到 Redis 队列 → worker 消费」。请指出一个会让任务永久卡住的崩溃点,并给出修法。
答案
崩溃点:写完 tasks 表之后、消息发出去之前。
- 这一刻的状态是:数据库说有个
PENDING任务,队列里没有任何消息。重启后没有任何东西会去处理它 —— 它永远停在那里,而且不报错。日志干净、监控正常,只有用户那边「任务一直没动静」。这类缺陷比崩溃本身更难查,因为它不产生异常。 - 同类问题的镜像:如果先发消息再写库,就会得到「worker 去处理一个数据库里还不存在的任务」。两个方向都不行 —— 这正是「先写库再发消息」这个说法的根本毛病。
修法(二选一,前者更稳):
- 事务性发件箱:在同一个本地事务里写
tasks状态 + 写一条outbox待投递记录。投递器独立地把outbox里未发送的记录送进队列并标记已发送。「状态已写但消息没发」和「消息发了但状态没写」都不会出现;代价是投递可能重复,所以消费端要幂等。 - 清扫器(兜底):定时扫描「
PENDING且超过阈值时间仍未入队」的任务,重新投递。它能兜住漏发,但依赖时间去猜,不如方案 1 精确 —— 适合作为方案 1 之外的第二道保险。
2. worker 正在处理一个任务时被 kill -9。请分别说明:队列侧会发生什么、状态存储侧留下什么、重启后系统怎么继续?
答案
三边各说一句:
- 队列侧:消息被取走时通常进入不可见窗口(可见性超时),worker 崩溃后来不及 ack。窗口到期后消息重新可见,被重新投递 —— 所以「至少一次投递」的意思就是「这条消息还会再来一遍」。
- 状态存储侧:留下的是最后一个已提交的步骤,加上一个
RUNNING(attempt=n)的标记。关键:留下的是「提交过什么」,不是「执行到哪」 —— 正在执行但还没提交的那一步,在状态里等于没发生。 - 重启后:新的 worker 取到这条消息,先查状态 → 不是终态 → 从第一个未完成的步骤继续;已完成的步骤跳过不重做。唯一危险的是「正在执行、尚未提交」的那一步:如果它已经产生了外部副作用,就会重复 —— 所以这一步必须先查询外部(带操作标识与输入版本),查到结果就接续,查不到才判断能否重试。
要主动补一句边界:attempt 计数要有限制,超限进死信队列;否则一个每次都在同一处崩溃的任务会无限重投,把队列堵死。
3. 有同事说:「我们加了消息去重,所以同一个操作不会执行两次。」这句话哪里不对?
答案
错在把「同一份结果被传送两次」和「同一项操作被实际执行两次」当成了同一件事。
- 消息去重能防的:同一条消息因为重投递而被消费者收到两次。这种情况下,消费者做一次幂等检查(「这个 id 处理过了」)就够了。
- 消息去重防不住的:消费者第一次已经把外部操作做掉了(付款、创建资源、发消息),只是没来得及 ack 或没来得及记状态;消息重投后,外层去重认为这是新的一次(或去重表本身还没写成功),于是再执行一次。此时资源已经烧掉两份、钱已经扣了两次。
- 正确的分工:消息层负责「不重复投递」,业务层负责「不重复生效」—— 后者靠的是持久化结果 + 幂等协议(操作标识、目标系统的幂等接口、或补偿)。两者都要有,缺一不可,因为它们防的是不同层的事。
4. 你要说服团队「跑通一遍」不能作为可恢复性的验收标准。你会怎么设计这个验收?
答案
核心论点:可恢复性是故障路径的性质,跑通的是正常路径 —— 正常路径无论实现得多糟都能通过。
验收设计三步:
- 把外部系统换成会计数的 stub。 它要能回答一个正常运行时看不出来的问题:「这个操作真正被执行了几次」。没有这个计数器,重复副作用就是不可见的。
- 在崩溃点矩阵的每一行各注入一次故障,然后重启:写状态前 / 写状态未入队 / 入队未取走 / 执行中 / 外部副作用完成后未写状态 / 写状态未 ack / 一轮提交到一半。逐行验证,而不是只测一两个「看起来最危险」的。
- 断言两件事:① 最终状态与不崩溃的对照组一致;② 每个外部操作恰好真正执行一次。第二件是重点 —— 只断言「任务最后成功了」会放过重复副作用,而重复副作用恰好是最贵的失败(扣两次钱比慢十秒严重得多)。
再补一句工程习惯:上线后加一个对账任务,定期比对本地状态与外部系统的真实结果,把不一致捞出来 —— 崩溃注入覆盖的是你想到的故障,对账覆盖的是你没想到的。
5. 这套系统里,哪一部分最容易成为新的单点,你打算怎么办?
答案
状态存储。 它比队列和 worker 更关键,理由是:worker 无状态、可以随便重启和扩缩;队列可以多副本;但状态存储挂了,整个平台就「不知道任务跑到哪了」 —— 既不能安全地重试(不知道做过没有),也不能回答用户的进度查询。可恢复性是靠它换来的,所以它成了代价所在。
做法(与 ../../02-interview/design/08-agent-platform.md §4.5 的三处高可用同源):
- 多副本 + 持久化 + 定期备份,把它当成真正的有状态依赖来运维,而不是当成一个「顺便存进度的地方」;
- 写路径尽量短且幂等:状态更新带版本号/操作标识,重复写不产生二次效果;
- 故障时要能区分「不确定」和「没做」:状态存储不可用时,宁可停机也不要盲目重试 —— 盲目重试会把「结果未知」当成「没执行」,正是重复副作用的来源;
- 给读路径留降级:进度查询可以接受陈旧数据,写路径不能。
顺带要点出的取舍:把所有状态都塞进一个存储会让它更重,所以分级是必要的 —— 关键状态(任务状态机、步骤提交记录)在强一致的存储里;体积大、只用于展示的轨迹与产物放对象存储,状态里只留引用。