LLM 面试备考
首页/分布式与可靠性

可恢复的任务执行系统(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 定律看 L=λWL=\lambda W(在办任务数 = 到达率 × 平均处理时长),再用「净消化能力」μ−λ\mu-\lambda 判断积压是收敛还是发散。排队时间不由总工作量决定,而由到达方式决定 —— 十项各 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 讲的第一件事:没收到确认,无法区分「没做」和「做完了但确认丢了」。

正确顺序不是重试,而是:

  1. 先查询已持久化的结果,核对操作标识与输入版本;
  2. 查到结果 → 直接接续,不重试;
  3. 查不到 → 才判断这个操作是否幂等、目标系统是否支持操作标识 / 事务 / 补偿;
  4. 两者都不满足 → 停下来交人工,不要赌。

必须点破的一条:消息层去重与「实际执行两次」是两回事。 同一份结果被传送两次,去重就够了;同一项操作实际执行两次,已经烧了两份资源 / 扣了两次钱 —— 后者只能靠持久化结果 + 幂等协议避免,消息层帮不上忙。

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):

  1. 用计数器式的外部 stub 替掉真实外部系统 —— 它能数出「这个操作被真正调用了多少次」;
  2. 在崩溃点矩阵的每一行各注入一次 kill -9(或进程内抛异常),然后重启;
  3. 断言两件事:最终状态与不崩溃的对照组一致,且每个外部操作恰好被真正执行一次。

第 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 去处理一个数据库里还不存在的任务」。两个方向都不行 —— 这正是「先写库再发消息」这个说法的根本毛病。

修法(二选一,前者更稳):

  1. 事务性发件箱:在同一个本地事务里写 tasks 状态 + 写一条 outbox 待投递记录。投递器独立地把 outbox 里未发送的记录送进队列并标记已发送。「状态已写但消息没发」和「消息发了但状态没写」都不会出现;代价是投递可能重复,所以消费端要幂等。
  2. 清扫器(兜底):定时扫描「PENDING 且超过阈值时间仍未入队」的任务,重新投递。它能兜住漏发,但依赖时间去猜,不如方案 1 精确 —— 适合作为方案 1 之外的第二道保险。

2. worker 正在处理一个任务时被 kill -9。请分别说明:队列侧会发生什么、状态存储侧留下什么、重启后系统怎么继续?

答案

三边各说一句:

  • 队列侧:消息被取走时通常进入不可见窗口(可见性超时),worker 崩溃后来不及 ack。窗口到期后消息重新可见,被重新投递 —— 所以「至少一次投递」的意思就是「这条消息还会再来一遍」。
  • 状态存储侧:留下的是最后一个已提交的步骤,加上一个 RUNNING(attempt=n) 的标记。关键:留下的是「提交过什么」,不是「执行到哪」 —— 正在执行但还没提交的那一步,在状态里等于没发生。
  • 重启后:新的 worker 取到这条消息,先查状态 → 不是终态 → 从第一个未完成的步骤继续;已完成的步骤跳过不重做。唯一危险的是「正在执行、尚未提交」的那一步:如果它已经产生了外部副作用,就会重复 —— 所以这一步必须先查询外部(带操作标识与输入版本),查到结果就接续,查不到才判断能否重试。

要主动补一句边界:attempt 计数要有限制,超限进死信队列;否则一个每次都在同一处崩溃的任务会无限重投,把队列堵死。

3. 有同事说:「我们加了消息去重,所以同一个操作不会执行两次。」这句话哪里不对?

答案

错在把「同一份结果被传送两次」和「同一项操作被实际执行两次」当成了同一件事。

  • 消息去重能防的:同一条消息因为重投递而被消费者收到两次。这种情况下,消费者做一次幂等检查(「这个 id 处理过了」)就够了。
  • 消息去重防不住的:消费者第一次已经把外部操作做掉了(付款、创建资源、发消息),只是没来得及 ack 或没来得及记状态;消息重投后,外层去重认为这是新的一次(或去重表本身还没写成功),于是再执行一次。此时资源已经烧掉两份、钱已经扣了两次。
  • 正确的分工:消息层负责「不重复投递」,业务层负责「不重复生效」—— 后者靠的是持久化结果 + 幂等协议(操作标识、目标系统的幂等接口、或补偿)。两者都要有,缺一不可,因为它们防的是不同层的事。

4. 你要说服团队「跑通一遍」不能作为可恢复性的验收标准。你会怎么设计这个验收?

答案

核心论点:可恢复性是故障路径的性质,跑通的是正常路径 —— 正常路径无论实现得多糟都能通过。

验收设计三步:

  1. 把外部系统换成会计数的 stub。 它要能回答一个正常运行时看不出来的问题:「这个操作真正被执行了几次」。没有这个计数器,重复副作用就是不可见的。
  2. 在崩溃点矩阵的每一行各注入一次故障,然后重启:写状态前 / 写状态未入队 / 入队未取走 / 执行中 / 外部副作用完成后未写状态 / 写状态未 ack / 一轮提交到一半。逐行验证,而不是只测一两个「看起来最危险」的。
  3. 断言两件事:① 最终状态与不崩溃的对照组一致;② 每个外部操作恰好真正执行一次。第二件是重点 —— 只断言「任务最后成功了」会放过重复副作用,而重复副作用恰好是最贵的失败(扣两次钱比慢十秒严重得多)。

再补一句工程习惯:上线后加一个对账任务,定期比对本地状态与外部系统的真实结果,把不一致捞出来 —— 崩溃注入覆盖的是你想到的故障,对账覆盖的是你没想到的。

5. 这套系统里,哪一部分最容易成为新的单点,你打算怎么办?

答案

状态存储。 它比队列和 worker 更关键,理由是:worker 无状态、可以随便重启和扩缩;队列可以多副本;但状态存储挂了,整个平台就「不知道任务跑到哪了」 —— 既不能安全地重试(不知道做过没有),也不能回答用户的进度查询。可恢复性是靠它换来的,所以它成了代价所在。

做法(与 ../../02-interview/design/08-agent-platform.md §4.5 的三处高可用同源):

  • 多副本 + 持久化 + 定期备份,把它当成真正的有状态依赖来运维,而不是当成一个「顺便存进度的地方」;
  • 写路径尽量短且幂等:状态更新带版本号/操作标识,重复写不产生二次效果;
  • 故障时要能区分「不确定」和「没做」:状态存储不可用时,宁可停机也不要盲目重试 —— 盲目重试会把「结果未知」当成「没执行」,正是重复副作用的来源;
  • 给读路径留降级:进度查询可以接受陈旧数据,写路径不能。

顺带要点出的取舍:把所有状态都塞进一个存储会让它更重,所以分级是必要的 —— 关键状态(任务状态机、步骤提交记录)在强一致的存储里;体积大、只用于展示的轨迹与产物放对象存储,状态里只留引用。