第 08 站展开探险地图
可靠任务调度器
先用一门语言守住容量与失败,再把双语协作当作扩展。
怎样构建有界任务系统,在队列满、worker 崩溃、timeout 与节点断线时守住容量和重试上限,并诚实说明跨重启还缺哪种保证?
一个持续失败的任务触发重试风暴
失败任务没有重试上限,也没有幂等键。它一次次回到队列,容量计数逐渐失真,外部动作还可能重复发生。
- 故障前后 queued、running 与 retry 数量
- 幂等键,以及外部副作用是否只发生一次的记录
- reject、recover、give-up 指标与注入故障测试
它要解决什么
终点作品把前面的工具连起来。我们会制造队列满、任务失败、消息迟到和节点断开,再检查状态、重试、恢复与拒绝规则。
你会做到
能先写下必须始终成立的规则,再据此安排进程、队列和监督树
能先交付一门语言的核心版,再选择是否增加 Erlang/Elixir adapter
能主动制造小故障(故障注入),检查系统怎样恢复、拒绝或留下线索
- 完成前面的 BEAM 主线,并保留队列和监督树练习
- 核心版只要求会一门语言;双语扩展再要求完成共享 term 与互操作两站
先看做什么,再看怎么写
Elixir 负责 API 与验证,Erlang 保管队列和调度状态。队列满时明确拒绝。
# Elixir 层负责输入验证和对外返回格式
defmodule Scheduler.API do
def submit(payload, opts \\ []) do
# 只有验证与入队都成功,任务才算被接收
with :ok <- validate(payload),
{:ok, id} <- :scheduler_core.enqueue(payload, opts) do
{:accepted, id}
else
# 队列满时明确拒绝,不继续占用内存
{:error, :queue_full} -> {:rejected, :busy}
{:error, reason} -> {:rejected, reason}
end
end
end为什么这样写
为什么先写保证清单,再画进程树?
容量、ack、持久性和幂等决定系统真正能承诺什么,进程结构只是实现这些承诺的工具。
只用内存队列时,应诚实写成“运行中有限重试”,不能声称跨 VM 重启的至少一次。
从 Java、Python、JavaScript 看过来
Java
- 熟悉的起点
- executor、持久队列与 Spring Batch。
- BEAM 在意什么
- 监督树恢复进程;ack、持久化与幂等另外决定任务保证。
- 别带错直觉
- 进程重启不等于任务已经持久化。
Python
- 熟悉的起点
- Celery/RQ 的 ack、retry 与 worker。
- BEAM 在意什么
- BEAM 能在应用内表达 worker 生命周期,但投递保证仍取决于存储和协议。
- 别带错直觉
- 至少一次意味着业务可能看见重复。
JavaScript
- 熟悉的起点
- BullMQ、云队列与 Promise worker。
- BEAM 在意什么
- OTP 负责恢复边界,队列协议负责任务状态。
- 别带错直觉
- retry 必须和幂等、退避、死信去向一起设计。
做四次故障演练
主动制造问题叫故障注入。先为 worker 退出、非法消息、timeout 和断连写下期望结果。
- 01
让正在工作的 worker 主动 exit,检查 retry 次数和容量计数
- 02
发送一条不符合约定的消息,确认服务仍能继续,也留下日志或 Telemetry 事件
- 03
让任务超过 timeout,检查隔离、取消约定和迟到结果
- 04
断开远程节点,检查 stale 标记以及重新连接后的状态
# 只运行标记为 fault_injection 的测试,并显示执行过程
mix test --only fault_injection --trace- 需要长期工作的进程最后仍由监督树照看
- 失败任务只在上限内重试,不会永远循环
- 队列长度和正在工作的数量最终回到一致状态
- 每次拒绝、恢复或放弃都能在结构化日志或指标中找到证据
删除 retry 上限,再让任务持续失败。观察重试风暴如何放大问题。
在这四种指定意外下,系统能按照已经写下的规则恢复、拒绝或停止重试。
四场演练不能覆盖所有故障,也不能证明恰好一次、跨节点一致或外部操作不重复。
这段代码里的关键词
at-least-once
任务至少尝试一次,也可能执行多次。要跨整台 VM 重启守住这项保证,还需要持久队列或日志、ack 与幂等记录;只有内存队列时不能这样承诺。
bounded queue
容量固定的等待队列。队列满时,系统要等待、拒绝或降级。
runbook
运行说明:看哪些日志与指标,怎样判断问题,可以执行什么安全动作。
这里用了哪些设计模式
Bounded Buffer
使用有限队列,并在队列满时明确拒绝新任务。
Idempotent Consumer
让至少一次投递产生的重复执行保持安全。
Retry with Exponential Backoff
限制重试次数并逐步拉开间隔,避免重试风暴。
准备让失败任务自动 retry 前,最先要想清楚什么?
交付核心版,再选双语扩展
先用 Elixir 或 Erlang 任一门完成核心调度器,整理监督树、容量计划、故障记录、release 和一页 runbook。若已学互操作,再增加另一门语言的 adapter/worker。
提示 1先迈一步
主动说出至少一个这版作品还没有保证的事情,这不是扣分,而是诚实的工程判断。
提示 2再缩小一点
除了成功提交,也演示 queue_full,让观众看到系统怎样承认自己忙不过来。
提示 3离答案很近了
写 runbook 时先想用户会看到什么,再反推值守者需要哪些信号。
提示 4从终点往回想
先挑一条“过关信号”,为它写一个最小测试。如果电脑看不出结果,就把这句话改成一个真正能观察到的现象。
并发、队列、timeout 和 retry 都有明确上限
能区分内存队列的运行中重试,与依赖持久化和 ack 的跨重启保证
另一位同学能启动 release,并按 runbook 完成一次安全检查
可选扩展:两门语言各自承担真实职责,并用双向测试穿过 adapter
记住三句话
- 1
可靠作品先写清必须守住的规则、容量和失败后的选择,再安排进程结构。
- 2
retry 可能放大外部影响,因此幂等、退避和次数上限要一起出现。
- 3
好维护不是最后多打印几行日志,而是从消息约定开始留下足够证据,并写进 runbook。