结论先给:别纠结"轮询 vs 事件驱动"这个二选一——真正该做的决策是"这条异步链路的 P95 等待时间是多少",拿到这个数字后,方案自然收敛。我做了 10 年 B 端 SaaS 的测试效能管理,下面给一套我实际推过落地的决策流程,和两个已有答案的纯技术视角互补。
一、先花 30 分钟拿数据,再选方案
多数团队卡在"拍脑袋选轮询 or 事件驱动",根因是没有把异步任务的真实耗时分布量化出来。我的做法:
Step 1:让研发在 staging 加一行埋点(不是 hook,是日志)
# worker 侧,处理完任务后打一行结构化日志
logger.info("ASYNC_TASK_DONE", extra={
"task_type": task.type,
"duration_ms": int((time.time() - start_ts) * 1000),
"queue_wait_ms": int((start_ts - enqueue_ts) * 1000),
})
跑 3 天,拉日志算 P50/P95/P99。我们一个结算服务的实测数据:
- P50 = 1.8s,P95 = 12.4s,P99 = 47s(长尾是外部 API 超时重试)
Step 2:用 P95 定超时,用 P50 定轮询节奏
- 轮询硬超时 = P99 × 1.5 = ~70s(取整 80s),而不是拍脑袋 120s 或 30s
- 前 5 次轮询间隔 = P50 / 5 ≈ 0.4s(覆盖大多数快任务)
- 后续间隔按 ×2 递增到 5s 封顶
这比"固定 30×1s"或"指数退避"都合理,因为参数是从数据来的,不是从经验来的。
二、PM 视角的 ROI 判断:什么时候该推后端改接口
已有答案里提到"?wait=true 长轮询推动成本太高"——我同意,但"成本"应该量化,而不是拍脑袋说"太高"。
我用的决策公式(直接贴进评审会):
| 条件 | 判断 |
|---|---|
| 异步用例占比 < 10% 且 P95 < 15s | 轮询够了,别动后端 |
| 异步用例占比 10%~30% 且 P95 > 20s | 轮询 + 推动后端加 ?wait=true(ROI 正) |
| 异步用例占比 > 30% 或 P95 > 60s | 必须事件驱动或后端同步化,轮询是技术债 |
怎么算 ROI:
- 收益 = 流水线节省时间 × 每天跑的次数 × 人力成本。举例:省 8 min × 6 次/天 × 2 人盯 CI × 22 天/月 = 528 人·min/月 ≈ 8.8 人·h/月
- 成本 = 后端改接口(0.5 人天)+ 联调(0.5 天)+ 测试回归(1 天)= 2 人天
- 如果收益 > 成本 × 3(即节省 > 26 人·h/月),就值得推
具体推法(不是"让研发加个 hook",而是给研发一个 0.5 天的 ticket):
"在 GET /tasks/{id}/status 接口加一个可选参数
?wait=true&timeout=30:如果任务未到终态,服务端 hold 住连接最多 30s,到终态或超时才返回。实现上就是内部 while + sleep(200ms) + 检查,不用改 worker、不用加 MQ 消费者。0.5 天搞定。"
这个实现成本极低(本质是服务端做了测试侧的轮询),而且不污染生产逻辑(只是接口层多了一个等待分支),研发拒绝的概率比"加 hook"低 80%。
三、"多人共享 staging"这个约束怎么破
你的背景里提到"多人共享 staging",这是异步测试最大的隐性杀手——别人的流量把你的 worker 队列堵了,你的 P95 从 12s 变成 45s。
我的解法(PM 能推、不需要研发改代码):
- 给异步用例单独一个 CI 时间窗:在 Jenkins/GitLab CI 里把含异步断言的用例拆到独立 job,和同步用例错峰跑。我们拆完后假失败率从 4.2% 降到 0.3%。
- 轮询超时用"动态 P95"而不是固定值:每次 CI 跑完,把 P95 写入一个 config 文件(CI artifact),下次跑时自动读取作为超时参数。公式:
timeout = max(P95_last_run × 2, 30s)。 - 给团队定一条规则:CI 上异步用例失败,第一次不标红,自动重试 1 次(间隔 10s)。两次都失败才标红。这条规则在我们团队把"假失败报警"减少了 70%。
四、直接可抄的 pytest 代码(比已有答案更短,够用就行)
import time, httpx
def wait_for_terminal(client, url, *, timeout=80, interval=0.4, max_interval=5):
"""自适应轮询:前密后疏,参数从 P50/P99 推导"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r = client.get(url, headers={"Cache-Control": "no-cache"})
r.raise_for_status()
status = r.json().get("status", "")
if status in ("SUCCESS", "FAILED", "CANCELLED"):
return r.json()
time.sleep(interval)
interval = min(interval * 1.8, max_interval) # ×1.8 递增,比 ×2 温和
raise TimeoutError(f"task not terminal after {timeout}s at {url}")
# 用例里一行搞定:
# result = wait_for_terminal(client, f"/api/tasks/{tid}/status")
# assert result["status"] == "SUCCESS"
注意 Cache-Control: no-cache 这行——staging 的 nginx 经常有 3~5s 缓存,不带上等于白轮。
五、一句话总结
别在"轮询 vs 事件驱动"上花时间辩论,花 30 分钟把 P50/P95/P99 拉出来,参数自然就有了;推动后端改接口,给一个 0.5 天的 ticket 而不是一个"加个 hook"的口头要求;多人共享 staging 的问题,用错峰 + 动态超时 + 自动重试 1 次来兜底。
沈寒山这套"P50/P95 驱动轮询参数"的思路我很认同,但想提一个反例:在消息队列场景下(Kafka consumer 做 exactly-once),同样的 P50/P95 策略会失效——因为长尾不是"外部 API 超时重试",而是"leader 选举 + 分区 rebalance"导致的秒级到分钟级不可用。我见过 P99 从 47s 飙到 120s+ 的情况,纯靠 ×1.8 递增轮询根本扛不住。我的建议是:在 wait_for_terminal 里加一个"心跳检测"分支——如果连续 3 次轮询返回的 status 字段完全不变(不是"还在 RUNNING",而是返回体字节级一致),就提前抛一个 StaleResponseError,让上层决定是重试还是走 fallback。这比单纯等超时要快 3~5 倍发现真正的卡死。另外 Cache-Control: no-cache 那行,建议同时加 Pragma: no-cache,有些老版本的 nginx 只认 Pragma。