第 08 站展开探险地图
00装好工具前置准备01起跑线前置准备02进程与信箱R1Elixir 数据流水线可选复习R2读懂 Erlang可选复习03消息与超时04OTP 消息章法05会重启的监督树06给并发设上限07BEAM 节点失联X1两种语言对暗号双语扩展X2双语搭档双语扩展08可靠任务调度器
首页/BEAM 主线/第 08 站
08
作品综合创作ElixirErlangOTP

可靠任务调度器

先用一门语言守住容量与失败,再把双语协作当作扩展。

4小关卡
约 10 小时 · 建议分 6 次可以分几次
QUESTION · 本站只追这一问

怎样构建有界任务系统,在队列满、worker 崩溃、timeout 与节点断线时守住容量和重试上限,并诚实说明跨重启还缺哪种保证?

先看现场

一个持续失败的任务触发重试风暴

失败任务没有重试上限,也没有幂等键。它一次次回到队列,容量计数逐渐失真,外部动作还可能重复发生。

能够观察到
  • 故障前后 queued、running 与 retry 数量
  • 幂等键,以及外部副作用是否只发生一次的记录
  • reject、recover、give-up 指标与注入故障测试
为什么学这一站

它要解决什么

终点作品把前面的工具连起来。我们会制造队列满、任务失败、消息迟到和节点断开,再检查状态、重试、恢复与拒绝规则。

走完这一站

你会做到

  • 能先写下必须始终成立的规则,再据此安排进程、队列和监督树

  • 能先交付一门语言的核心版,再选择是否增加 Erlang/Elixir adapter

  • 能主动制造小故障(故障注入),检查系统怎样恢复、拒绝或留下线索

出发前
  • 完成前面的 BEAM 主线,并保留队列和监督树练习
  • 核心版只要求会一门语言;双语扩展再要求完成共享 term 与互操作两站
同一件事,两种写法

先看做什么,再看怎么写

Elixir 负责 API 与验证,Erlang 保管队列和调度状态。队列满时明确拒绝。

当前代码
Elixir
# 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 必须和幂等、退避、死信去向一起设计。
LAB
动手

做四次故障演练

约 15–25 分钟

主动制造问题叫故障注入。先为 worker 退出、非法消息、timeout 和断连写下期望结果。

  1. 01

    让正在工作的 worker 主动 exit,检查 retry 次数和容量计数

  2. 02

    发送一条不符合约定的消息,确认服务仍能继续,也留下日志或 Telemetry 事件

  3. 03

    让任务超过 timeout,检查隔离、取消约定和迟到结果

  4. 04

    断开远程节点,检查 stale 标记以及重新连接后的状态

复制到终端,按回车
# 只运行标记为 fault_injection 的测试,并显示执行过程
mix test --only fault_injection --trace
你会看到
  • 需要长期工作的进程最后仍由监督树照看
  • 失败任务只在上限内重试,不会永远循环
  • 队列长度和正在工作的数量最终回到一致状态
  • 每次拒绝、恢复或放弃都能在结构化日志或指标中找到证据
故意弄坏

删除 retry 上限,再让任务持续失败。观察重试风暴如何放大问题。

这次能看清

在这四种指定意外下,系统能按照已经写下的规则恢复、拒绝或停止重试。

这次还不能说明

四场演练不能覆盖所有故障,也不能证明恰好一次、跨节点一致或外部操作不重复。

先认词

这段代码里的关键词

01

at-least-once

任务至少尝试一次,也可能执行多次。要跨整台 VM 重启守住这项保证,还需要持久队列或日志、ack 与幂等记录;只有内存队列时不能这样承诺。

02

bounded queue

容量固定的等待队列。队列满时,系统要等待、拒绝或降级。

03

runbook

运行说明:看哪些日志与指标,怎样判断问题,可以执行什么安全动作。

从代码里认出章法

这里用了哪些设计模式

01

Bounded Buffer

使用有限队列,并在队列满时明确拒绝新任务。

02

Idempotent Consumer

让至少一次投递产生的重复执行保持安全。

03

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. 1

    可靠作品先写清必须守住的规则、容量和失败后的选择,再安排进程结构。

  2. 2

    retry 可能放大外部影响,因此幂等、退避和次数上限要一起出现。

  3. 3

    好维护不是最后多打印几行日志,而是从消息约定开始留下足够证据,并写进 runbook。

再读一点

去看原版资料

Mix and OTPErlang ApplicationsElixir Releases
本站结束实验做过,答案也想过,就把这一站收好。