技术指南
后台任务排障:用租约、幂等与死信阻止重复执行
沿接收、领取、副作用和完成四个阶段定位后台任务故障,用唯一约束、过期租约、受限重试和可审计死信避免重复执行。
后台任务出故障时,用户只看到“还在转”或“失败了”,系统内部却可能处在完全不同的状态:请求没有落库、消息排队、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
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、邮箱或访问令牌,可保存高熵随机值或受保护的业务标识及输入哈希。
用原子领取和过期租约支持接管
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 崩溃后长期无人接管。
副作用在确认消息前留下唯一证据
- 为每次外部创建、扣费、发信或通知派生稳定 effect_key,并在本地加唯一约束。
- 调用支持幂等的上游时传递同一键;响应到达后保存 provider request/job ID 与输入版本。
- 不能原子覆盖数据库与外部 API 时,使用可重放 outbox 或 reconciliation 扫描,而不是假装存在跨系统事务。
- Worker 重启或消息重投时先查已有 effect/result,再决定查询上游、补写本地状态或重新调用。
- 只有结果和后续事件都 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 | 修复凭据、权限或项目配置 | 权限探针恢复 |
| 持续未知错误 | 停止自动重试并进入死信 | 人工分类后受控重放 |
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 后快速返回;补偿扫描另行寻找未创建、租约过期或通知未发出的记录。
CREATE UNIQUE INDEX scheduled_window_once
ON jobs (job_type, scheduled_window)
WHERE scheduled_window IS NOT NULL;Webhook 先验签、去重、入队,再快速返回
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 或完整敏感输入。
死信是待处置证据,不是另一个自动循环
- 记录最终错误分类、attempts、代码版本、输入引用、provider ID 和首次/末次失败时间。
- 重放前确认根因、凭据、配额、目标版本和幂等证据仍有效,并显示将产生的副作用。
- 分小批、有限并发重放,生成新的执行记录但保留原 job 与失败历史。
- 对 400、权限或数据合规问题提供关闭/取消路径,不把不可恢复错误伪装成 pending。
- 监控死信新增速率和最老年龄;没有 DLQ 的队列也要在业务数据库保留终态。
队列健康看年龄、吞吐和恢复余量
| 信号 | 它回答的问题 | 常见误读 |
|---|---|---|
| oldest_job_age | 用户最长等了多久 | 只看 queue depth |
| arrival / completion rate | 积压是在增长还是收敛 | CPU 低就认为有余量 |
| lease expiry rate | Worker 是否卡死或租约不匹配 | 每次接管都算正常重试 |
| attempt distribution | 重试是否集中在一层/租户 | 只统计最终失败 |
| effect reconciliation lag | 外部成功与本地完成是否脱节 | 只看 provider 成功率 |
| dead-letter age | 人工恢复是否跟得上 | 只看死信总数 |
队列深度没有到达速率和完成速率就无法解释。恢复时也要计算上游配额与安全并发:若积压 10,000 个任务而净排空速度只有每分钟 20 个,恢复需要数小时。先暂停低优先级任务、控制重放和通知,再逐步放量;不要在上游刚恢复时让全部延迟任务同时醒来。
降级要保留真实状态和用户选择
- 入口仍可安全持久化时返回 job ID、状态和建议查询时间,不让浏览器保持长连接。
- 暂停批量摘要、刷新和低优先级任务,把有限容量留给明确的用户动作。
- 允许陈旧结果的场景显示最后成功时间,不把缓存结果冒充实时响应。
- 切换模型或提供商前验证输出语义、隐私、地区和成本,不静默改变关键业务结果。
- 无法可靠接收任务时明确拒绝并返回可重试语义,不先告诉用户“已提交”再丢失。
本次隔离演练验证了什么
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 与死信都有趋势告警。
- 降级、恢复和重放都有限流开关、停止条件、审计记录和用户可见状态。