技术指南

后台任务排障:用租约、幂等与死信阻止重复执行

沿接收、领取、副作用和完成四个阶段定位后台任务故障,用唯一约束、过期租约、受限重试和可审计死信避免重复执行。

后台任务出故障时,用户只看到“还在转”或“失败了”,系统内部却可能处在完全不同的状态:请求没有落库、消息排队、Worker 持有租约、上游已经产生结果、回调重复到达,或结果已保存但通知未发出。可靠排障先保存一条跨层时间线,再决定是重试、接管、降级还是人工重放;延长函数超时不会补回缺失的状态证据。

用四个时间点确定故障层

时间点最低证据缺失时先查什么
接收job_id、幂等键哈希、created_at入口是否在返回前完成持久化
领取worker_id、attempt、locked_until消息是否可见、租约是否已过期
副作用provider_request_id 或 effect_key外部调用是否已发生但本地未确认
完成结果版本、completed_at、通知状态结果与通知是否分属不同事务
先查询单个用户可见故障的完整状态;不要从总队列长度直接猜根因
SELECT id, status, attempts, available_at, locked_until, owner,
       provider_request_id, result_version, last_error_code, updated_at
FROM jobs
WHERE id = :job_id;

浏览器或网关超时只说明等待连接结束,不说明任务未执行。若入口已经返回 job ID,继续沿 job、队列交付和上游请求查;若没有 durable job,则先修接收事务。所有日志使用同一 UTC 时间基准,并记录队列等待与执行耗时,避免把排队时间误算为模型或 Worker 延迟。

把一次任务拆成四份可恢复契约

阶段成功条件失败后的恢复依据
Admission同一业务意图只创建一个 durable job数据库唯一约束与已存在 job ID
Claim一个 Worker 在有限租约内拥有本次执行locked_until、owner 与 attempt
Effect每个外部副作用有稳定的 effect/idempotency key上游请求 ID 或本地唯一记录
Complete结果、状态与后续通知可独立追踪结果版本、outbox/通知记录

入口先持久化,再返回 job ID

冲突后查询并返回已有 job;同一个键必须绑定相同的业务意图和输入摘要
CREATE UNIQUE INDEX jobs_intent_once
  ON jobs (tenant_id, job_type, idempotency_key);

INSERT INTO jobs (id, tenant_id, job_type, idempotency_key, status)
VALUES (:id, :tenant, :type, :key, 'pending')
ON CONFLICT (tenant_id, job_type, idempotency_key)
DO NOTHING;

幂等键表示一次业务意图,而不是一次 HTTP 尝试。前端重复点击、网关重试和客户端断线重连应复用同一个键;用户明确发起新任务才生成新键。键本身不要包含 Prompt、邮箱或访问令牌,可保存高熵随机值或受保护的业务标识及输入哈希。

用原子领取和过期租约支持接管

PostgreSQL 示例必须放在事务中;目标数据库、隔离级别和公平性要单独验证
WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending' AND available_at <= now()
  ORDER BY priority DESC, available_at, id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE jobs j
SET status = 'processing',
    owner = :worker_id,
    locked_until = now() + :lease,
    attempts = attempts + 1
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

PostgreSQL 手册明确说明 `SKIP LOCKED` 会跳过不能立即锁定的行,得到的是不一致视图,因此适合多个消费者访问队列表,不适合一般查询。领取还要有确定排序、短事务和索引;租约必须长于正常一次处理并可续租,但不能长到 Worker 崩溃后长期无人接管。

副作用在确认消息前留下唯一证据

  1. 为每次外部创建、扣费、发信或通知派生稳定 effect_key,并在本地加唯一约束。
  2. 调用支持幂等的上游时传递同一键;响应到达后保存 provider request/job ID 与输入版本。
  3. 不能原子覆盖数据库与外部 API 时,使用可重放 outbox 或 reconciliation 扫描,而不是假装存在跨系统事务。
  4. Worker 重启或消息重投时先查已有 effect/result,再决定查询上游、补写本地状态或重新调用。
  5. 只有结果和后续事件都 durable 后才确认队列消息;通知失败不能把已完成任务改回未执行。
为每个可崩溃点写明重复执行时读取哪条 durable 证据
external call succeeded
        |
        v
store provider/effect ID -- crash here --> redelivery
        |                                  |
        v                                  v
store result + outbox              reconcile existing effect
        |                                  |
        +---------------+------------------+
                        v
                 acknowledge message

重试要有分类、预算和唯一责任层

故障默认动作退出条件
连接中断、408、部分 5xx在幂等前提下有限退避总截止时间或最大 attempts
429遵循有效 Retry-After,降低并发配额/截止时间耗尽后排队或降级
400修正输入或代码新输入/版本通过验证
401/403修复凭据、权限或项目配置权限探针恢复
持续未知错误停止自动重试并进入死信人工分类后受控重放
随机等待值只是算法结构;实际错误分类和预算必须来自所调用 API 的当前契约
function fullJitter(attempt, baseMs = 1000, capMs = 60000) {
  const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
  return Math.floor(Math.random() * ceiling);
}

// 同时限制 attempts、elapsed time、单租户并发和全局并发。
// SDK 已有重试时,不要再在每一层叠加同样的循环。

OpenAI 当前速率限制文档要求在有效时遵循 `Retry-After`,否则使用带 jitter 的指数退避,并同时限制次数和总耗时;官方 SDK 已对符合条件的速率限制错误执行重试。AWS 也提醒,多层重试会相乘并放大压力。记录实际总 attempts,才能发现 SDK、HTTP 客户端、Worker 与队列同时重试。

Cron 只生成唯一时间窗,不执行长链路

调度触发不等于业务恰好执行一次。Vercel 当前文档说明失败的 Cron invocation 不会自动重试,长运行还可能与下一次触发重叠,事件也可能重复。Handler 应验证请求,以 `job_type + scheduled_window` 建立唯一键,创建 job 后快速返回;补偿扫描另行寻找未创建、租约过期或通知未发出的记录。

时间窗使用明确 UTC 边界;业务时区和夏令时规则另存为调度配置
CREATE UNIQUE INDEX scheduled_window_once
  ON jobs (job_type, scheduled_window)
  WHERE scheduled_window IS NOT NULL;

Webhook 先验签、去重、入队,再快速返回

使用官方 SDK 和原始请求体验签;存储接口是架构占位,需在目标数据库实现原子去重与入队
const body = await request.text();
const event = await client.webhooks.unwrap(body, request.headers);
const deliveryId = request.headers.get('webhook-id');

await db.transaction(async (tx) => {
  const fresh = await tx.insertDeliveryOnce(deliveryId, event.type);
  if (fresh) await tx.enqueueEvent(event);
});
return new Response('ok', { status: 200 });

OpenAI 当前文档建议 endpoint 尽快返回 2xx,把非必要处理移到后台;失败或数秒内未响应会重试,少数事件可能重复,可用 `webhook-id` 去重。签名 secret 与 API key 分开管理,日志只保留 delivery ID、事件类型、关联 job 和处理结果,不记录 Authorization、Cookie 或完整敏感输入。

死信是待处置证据,不是另一个自动循环

  1. 记录最终错误分类、attempts、代码版本、输入引用、provider ID 和首次/末次失败时间。
  2. 重放前确认根因、凭据、配额、目标版本和幂等证据仍有效,并显示将产生的副作用。
  3. 分小批、有限并发重放,生成新的执行记录但保留原 job 与失败历史。
  4. 对 400、权限或数据合规问题提供关闭/取消路径,不把不可恢复错误伪装成 pending。
  5. 监控死信新增速率和最老年龄;没有 DLQ 的队列也要在业务数据库保留终态。

队列健康看年龄、吞吐和恢复余量

信号它回答的问题常见误读
oldest_job_age用户最长等了多久只看 queue depth
arrival / completion rate积压是在增长还是收敛CPU 低就认为有余量
lease expiry rateWorker 是否卡死或租约不匹配每次接管都算正常重试
attempt distribution重试是否集中在一层/租户只统计最终失败
effect reconciliation lag外部成功与本地完成是否脱节只看 provider 成功率
dead-letter age人工恢复是否跟得上只看死信总数

队列深度没有到达速率和完成速率就无法解释。恢复时也要计算上游配额与安全并发:若积压 10,000 个任务而净排空速度只有每分钟 20 个,恢复需要数小时。先暂停低优先级任务、控制重放和通知,再逐步放量;不要在上游刚恢复时让全部延迟任务同时醒来。

降级要保留真实状态和用户选择

  1. 入口仍可安全持久化时返回 job ID、状态和建议查询时间,不让浏览器保持长连接。
  2. 暂停批量摘要、刷新和低优先级任务,把有限容量留给明确的用户动作。
  3. 允许陈旧结果的场景显示最后成功时间,不把缓存结果冒充实时响应。
  4. 切换模型或提供商前验证输出语义、隐私、地区和成本,不静默改变关键业务结果。
  5. 无法可靠接收任务时明确拒绝并返回可重试语义,不先告诉用户“已提交”再丢失。

本次隔离演练验证了什么

VPScope 使用 Python 3 标准库与一次性 SQLite 数据库模拟任务表、租约、effect 和 delivery 记录。两个线程同时竞争一个 eligible job,只有一个成功领取;模拟 Worker 在写入唯一 effect 后、确认完成前崩溃,租约过期后由另一 Worker 接管,重复 effect 被唯一约束拒绝,任务最终完成。

同一业务幂等键和同一 Cron 时间窗都只创建一个 job;同一 webhook delivery 只记录一次。一个合成 503 任务经过三次带上限 full-jitter 的尝试后进入 dead_letter,取消任务没有被领取,`PRAGMA integrity_check` 返回 ok,临时目录完整删除。演练证明的是本地约束、事务和状态转换,不证明 PostgreSQL 隔离级别、托管队列投递、真实 Webhook 签名、上游幂等或生产并发容量。

上线前完成一次可重复的故障演练

  • 重复提交同一业务意图只返回一个 durable job,输入变化会被拒绝或生成新键。
  • 两个 Worker 竞争时只有一个获得当前租约;租约过期后可接管且旧 Worker 不能覆盖新版本。
  • 外部调用成功后立即崩溃,恢复仍能查到 provider/effect 证据且不重复副作用。
  • 429/暂时错误遵循服务端提示或 bounded jitter;400/权限错误不会无限重试。
  • 达到最大 attempts 后进入可观察死信,受控重放保留原失败历史。
  • Cron 重复与漏触发分别由窗口唯一约束和补偿扫描处理。
  • Webhook 使用原始请求体验签,重复 delivery 不会重复更新状态。
  • 队列年龄、到达/完成速率、租约过期、attempts、reconciliation 与死信都有趋势告警。
  • 降级、恢复和重放都有限流开关、停止条件、审计记录和用户可见状态。

返回知识库