给出何时异步何时同步的判断公式:比较操作p99时长与最短路径超时(常为60秒),差距两倍内则异步,避免为等待用户而徒增复杂度。
不要因为一次调用看起来慢就想到队列。只有当某个特定的不等式成立时,才考虑使用。取出该操作的 p99 耗时(包含重试和所有兜底方案),然后与前端路径上最短的超时时间做比较。这个最短超时很少是你自己的服务器设置的:通常是负载均衡器或 CDN 的空闲超时,在托管平台上往往不超过六十秒。然后:
p99 远低于最短跃点超时,且用户仍在等待 — 保持同步并使用流式响应。队列会额外增加一个跃点、一次轮询和一个状态机,如果用户无论如何都会停留在页面上,这些开销毫无意义。
p99 处于最短跃点超时两倍以内 — 异步处理。你离一类故障只差一个"坏日子",这类故障在用户看来像 504,但在服务商那边却是一次已完成且已计费的调用。
工作会超越会话生命周期 — 异步处理,无论耗时多长。批量导入、文档处理,以及任何需要向多个条目分散执行的任务,都应该上队列,即使每个条目本身很快,因为工作的原子单位是批次。
结果需要但不需即时获取 — 异步处理。创建记录后做 enrichment、上传时生成 embedding、为了后续搜索写入摘要。没有人盯着看,不要让请求路径为此买单。
注意列表里没有的是什么:成本。队列不会让调用更便宜。它改变的是谁在等待以及调用失败后发生什么,并让你能够集中控制并发,这是另一个独立存在的收益。
队列只是基础设施,你应该尽量少去想它。任务记录是你自己的,它存在你的数据库里,也是用户界面的数据来源。给它设置显式状态,而不是用一对布尔值。
create table ai_job (
id uuid primary key,
request_key text not null unique, -- caller-supplied idempotency key
state text not null, -- queued|running|succeeded|failed|abandoned
attempts int not null default 0,
lease_until timestamptz, -- who owns it, until when
input_hash text not null, -- detects a reused key with new input
result jsonb,
error_class text, -- normalised, not the vendor string
cost_cents numeric not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
这三列都是人们在事故发生之后才会加上去的。attempts 让你能够停下来;没有它,毒消息会永远运行下去,而且每次运行都会计费。cost_cents 会跨多次尝试累加,这是你发现失败路径比成功路径更昂贵的途径。error_class 是一个标准化的错误码,而不是厂商返回的原始消息,因为按原始厂商字符串分组的仪表盘实际上等于没做任何分组。
abandoned 值得作为一个独立于 failed 的状态存在。failed 意味着工作被尝试过但没有成功。abandoned 意味着在值得重试之前你就放弃了——截止时间已过、预算耗尽、用户取消了。对队列来说它们是一样的,但对一个在读表的人来说它们完全不同。
这是一种特定于这个依赖项的故障。几乎所有值得使用的队列都是至少一次投递(at-least-once)的: worker 拿到一条消息后,该消息会在某个租约期内变为不可见,如果 worker 在该期限结束前没有确认,消息就会回到队列中被其他人捡走。这是一个优秀的设计,也是队列可靠的原因。
现在在 worker 内部放一个模型调用。这个调用耗时足够长才值得上队列——这正是它上队列的原因——而租约是用平台默认值设置的,那是为调整图片大小的任务选择的。租约在调用中途过期了。队列把消息又发给了另一个 worker。现在两个 worker 都在运行同一个生成任务。两个都会被计费。如果任务在完成时写入结果,第二次写入会覆盖,或者两者交织在一起,你不仅浪费了钱还面临正确性问题。
三种防御手段,你最好三个都用:
根据截止时间设置租约,而不是用默认值。 租约必须超过整个尝试过程的最坏情况,包括重试和兜底。如果你的队列支持在工作中延长租约,按心跳信号去延长,而不是一开始就猜一个很大的数字。
调用前先争抢。 用条件更新获取任务行——只在当前租约已过期时才设置 state='running' 和 lease_until——然后仅在更新成功时才发起模型调用。这样重复投递只会多一次数据库往返,而不是多一次生成。这个的一般形式是幂等性页面的主题。
按花费而非次数来限制总尝试次数。 五次廉价调用的重试和五次长文档摘要的重试不是同等的风险。
一旦工作脱离了请求路径,用户需要知道它完成了。对任务端点进行轮询是无聊的答案,但它正确的次数比人们给它的赞誉要多:它能在重连后存活、能穿透所有代理、不需要服务器端会话状态,而且客户端可以随着任务老化而退避。返回状态、一个单调递增的 updated_at,以及如果有的话,一个进度百分比。
当等待时间足够长、轮询要么浪费要么滞后时,推送机制更好。它们之间的权衡——服务器发送事件、WebSocket 和做得好的轮询——各有各的取舍。这里重要的是,无论你选哪种,任务记录始终是真相来源。一次被遗漏的推送必须能够通过读行数据来恢复,否则一次断开的连接就会变成一个完成的任务和一个永远不知道结果的用户。
一个没人看的死信队列是把事故转换成沉默的方式。有两个习惯让它变得有用。第一,监控死信队列的到达率而不是它的深度来做告警:深度告警在一次糟糕的一小时后就会永远触发,然后被静音。第二,把重放做成一个一等公民的操作,走的是和正常运行相同的争抢路径,这样重放两千个任务的批次时不会重复那些在写入前实际已成功运行的任务。
还要保留失败的任务。在一次糟糕的部署之后最有用的产物是一张包含任务的标准错误类、输入哈希和累计花费的表,因为它一个查询就回答了"这花了多少钱"和"哪些输入导致了它"。