异步任务设计指南:轮询、WebSocket、回调三方案对比与选择
1. 异步任务设计先想清楚一件事用户等不等得起做后台系统最常遇到的一类需求就是点个按钮后台跑一个耗时任务然后告诉用户结果。比如导出上千行报表、批量给用户发消息、视频转码、生成图片、跑一段算法模型。这些任务不是几十毫秒能完成的事短则几秒长则几分钟甚至更久。如果请求发出后页面一直转圈用户能等三秒就已经很有耐心了等十秒就会开始疯狂刷新等三十秒基本就要找客服了。所以异步任务设计不是后端自嗨它直接决定用户体感。这里面的核心问题只有一个任务在后台执行的时候前端怎么知道任务做完了目前主流做法逃不开三种——轮询、WebSocket、回调Webhook。很多人在设计接口的时候会纠结选哪种其实每个方案都有它的适用边界也有它对应的接盘成本。这篇文章把三种方式掰开揉碎讲清楚再附上一段可以直接抄进PRD的写法帮你在做技术方案或者写需求文档的时候少踩几个坑。1.1 为什么不能同步等结果先看一个反例。有人设计导出接口时这么写前端调 POST /export后端同步生成文件生成完直接返回文件下载地址。听起来很简单但任务一慢问题全出来了。第一HTTP连接是有超时时间的。无论是网关、负载均衡还是浏览器都会在几十秒到几分钟之间断开空闲连接。同步接口只要超过这个时间前端得到一个连接错误但后端的任务可能还在跑任务本身丢了还是继续跑前端完全不知道。第二同步等待会占用大量服务端资源。一个任务慢它挂起的线程、数据库连接、内存都要一直占着系统并发一高无辜的请求也被拖死。第三用户体感极差页面一直转圈没有进度信息没有取消按钮。所以凡是有可能超过两三秒的任务基本都要改造成异步接口先接收请求、创建任务、立刻返回 taskId让用户该干嘛干嘛后台悄悄干活干完之后再想办法通知。这里的想办法通知就是我们要讨论的三种方案。1.2 三种方案的本质区别轮询、WebSocket、回调这三种方式本质区别在于谁主动、谁被动。轮询是客户端主动每隔一段时间去问服务端一次任务好了吗服务端不需要额外连接能力只需要提供查询接口。WebSocket 是服务端主动推客户端和服务端之间建立一条长连接任务状态一变服务端直接往这条连接里发消息。这个方案解决轮询实时性不够和轮询浪费请求的问题。回调是服务端到服务端的主动通知。客户端在创建任务的时候把自己的一个接口地址告诉服务端任务完成后服务端反过来请求这个地址把结果推送过去。这个方案适用于没有人的页面参与、纯后台系统之间联动。打个比方你去餐厅点菜菜没好。轮询是你隔三差五跑去问服务员菜好了没WebSocket 是服务员手里有个对讲机菜一好直接喊你xx桌菜好了回调是你留了个电话给前台菜好了前台打电话通知你而你自己不用一直在店里守着。搞清楚这个区别之后再往下看具体实现和坑。2. 轮询方案最稳妥的兜底但请把频率控制好轮询是三种方案里实现成本最低、兼容性最强的一个。后端只需要提供一个查询任务状态的接口前端起一个定时器不断去查查到终态以后停止。没有特殊网络环境要求也不涉及长连接所以它一直是很多系统的默认方案。但轮询也是最容易被做坏的方案。最常见的问题就是前端开发者图省事设置了 1 秒一次页面一开好几个任务同时轮询后端接口被打到报警。技术方案评审时一定要把轮询频率当成一个正式设计项去聊而不是随口定个数。2.1 短轮询与长轮询平时团队里说的轮询通常默认是短轮询客户端定时发起请求服务端立即返回当前状态不管任务是不是完成响应都直接返回。短轮询实现简单一个 setInterval 加一个状态查询接口就完事。问题在于大量请求是无意义的例如任务需要 5 分钟前端每 2 秒查一次前 4 分 58 秒的查询全都是还没好。长轮询是对短轮询的一次优化。客户端发起请求后服务端不立即返回而是把请求挂起来直到这个任务有状态变化或者达到超时时间再返回。这样前端一次请求能等到结果整个过程中无效请求变少。但长轮询对服务端有一定要求挂起的连接需要占着线程如果同时挂几百个请求线程池容易被打满。现在很多用 HTTP 长轮询的实现会搭配异步 Servlet 或者协程来做但复杂度自然比短轮询高一截。我的建议是如果不是对实时性特别敏感先做短轮询。等量上来了再考虑长轮询或者换 WebSocket不要一上来就自嗨。2.2 轮询参数怎么定间隔、超时、退避轮询参数不是拍脑袋定的它至少要受到三个因素的约束任务平均耗时、客户端数量、服务端 QPS 承受能力。先说轮询间隔。一个经验性的起点是任务平均耗时 30 秒以上轮询间隔可以设为 5 秒任务平均耗时 5 到 30 秒间隔 2 到 3 秒只有任务平均耗时停留在秒级且用户实时性要求极高时才建议用 1 秒或者更短。注意间隔必须加随机抖动例如 5 秒的间隔实际请求在 4.5 到 5.5 秒之间随机偏移。否则全平台用户在同一时间发请求会产生明显的毛刺效应服务端监控图上会出现规律性的尖峰。再说超时设置。轮询接口本身要设置一个合理超时一般 3 到 5 秒。如果超过这个时间没响应前端不能傻等要做下一次重试但重试次数要有上限。连续失败 5 次后应该把轮询暂停提示用户网络异常请稍后刷新而不是无限打下去。还有指数退避。当出现连续错误时轮询间隔按 1 倍、2 倍、4 倍、8 倍递增最多不要超过 1 分钟。这能有效防止后端抖动恢复期间前端集中重试造成二次故障。注意轮询状态接口只返回轻量数据别把业务详情全塞进去。前端每轮询一次就拉一遍完整结果等于把接口性能压在查询上。只返回 state、progress、message 三个字段查询会轻松很多。2.3 什么场景选轮询轮询不是不能用而是要放在合适的场景里。最典型的适合场景是后台管理平台、内部工具系统。用户量不大任务状态变化不频繁实时性要求最终能看到就行。比如运营后台导出 Excel、企业后台批量导入数据用户本来就预期要等一会儿页面显示一个进度条用户点完就去干别的了轮询完全够用。另一种必须用轮询的情况是外部系统不支持 WebSocket 也不支持回调的时候。比如对接老的第三方开放平台它只提供查询接口那你就只能定时去拉状态。此时轮询不是选型问题而是唯一解。还有一点轮询可以当兜底。即使在主流程里用了 WebSocket 或回调前端也可以保留一个低频轮询作为状态校准的手段。比如 WebSocket 偶发断连回调偶发丢失前端每小时轮询一次把任务状态纠正过来这比纯粹依赖单一通道稳妥得多。3. WebSocket想实时看进度的时候再用它如果任务执行过程中需要展示实时进度比如大文件上传转码、模型训练、代码部署轮询的准实时 高浪费就不太够了。这时候 WebSocket 是更自然的选择。WebSocket 建立一条全双工长连接服务端可以主动把任务的状态、进度、日志推给客户端。用户体验上非常顺滑不需要客户端不停发请求理论上消息实时性也能做到毫秒级。但代价也很明显连接管理、鉴权、断线重连、消息协议这些全是额外的工程复杂度。很多第一次做 WebSocket 的团队在连接数一多服务端被拖垮这件事上栽过跟头。3.1 连接管理一条全局连接还是按任务建连接设计 WebSocket 方案时第一个要决定的问题是前端到底建几条连接比较推荐的做法是全平台只保留一条全局 WebSocket 连接通过消息体里的业务字段标识具体是哪个任务。比如服务端推送一条消息里面的 payload 带有 taskId 和 taskType前端收到后根据自己的页面状态决定要不要处理。这样连接数恒定服务端压力可控也不容易出现一个页面开十个任务建十条连接的局面。不要按任务建连接。每次创建任务都要经历 WebSocket 握手、鉴权、建立连接开销大还可能遇到浏览器对同一域名并发连接数的限制。任务频率一旦上来连接风暴会把服务器打蒙。连接建立之后心跳是必须的。WebSocket 连接很容易被中间的网络设备静默断开而连接双方一时感觉不到。标准做法是客户端每 30 秒发一个 ping服务端回一个 pong服务端也可以定期检查空闲连接并主动关闭僵死连接。心跳丢失连续三次客户端就要主动释放连接并重连。3.2 消息协议设计一定要有事件类型和状态机很多团队做 WebSocket 只推送一句话例如任务完成结果前端拿到消息不知道这个任务属于哪个模块也不知道接下来该刷新哪块页面。这属于协议设计偷懒早晚要返工。建议消息统一成一个 JSON 结构至少包含这些字段event事件名例如 task.progress、task.completed、task.failedtaskId任务唯一标识status任务状态progress0 到 100 的进度数字payload业务需要的扩展内容timestamp服务端时间戳任务状态建议做成一个固定的状态机PENDING、RUNNING、COMPLETED、FAILED、CANCELED。所有端都用同一套枚举定义不要在某个接口里用字符串成功另一个接口里用success。注意进度推送不要每次都发全量 JSON 大对象尽量只推关键字段。进度走得太频繁时可以做节流例如每 2 秒推送一次避免高频消息把浏览器和网络都打满。3.3 鉴权和断线重连怎么做WebSocket 的鉴权不能丢。常见做法是客户端在连接 URL 上带一个短期有效的 token服务端在握手阶段校验 token 合法后才允许建立连接。这个 token 可以和登录态或任务权限绑定有效时长不要太长比如 30 分钟到 1 小时过期后客户端需要重新鉴权。断线重连要考虑到消息断点。客户端重连之后怎么把断线期间漏掉的消息补回来我的做法是在 WebSocket 消息里带一个单调递增的 sequence 或者服务端时间戳客户端重连后带着最后收到的消息序号重新订阅服务端把缺失的消息补发一遍。如果消息量太大就直接返回当前任务状态快照前端按快照重新渲染即可。WebSocket 适合的场景总结下来就是实时性要求高、用户要看到过程、任务执行时间较长或进度变化频繁。典型的有模型训练、大规模数据处理、代码构建、客服实时聊天等。如果只是最终结果重要对过程不敏感选 WebSocket 会增加不少无谓成本。4. 回调Webhook异步任务完成后的标准动作轮询和 WebSocket 解决的是人要看到结果的问题。还有一种典型场景是创建任务的并不是用户本人而是另一个后台服务它不需要实时展示进度只需要在任务结束后拿到结果去触发后续流程。比如订单支付后更新库存、内容审核完成后发通知、批量任务跑完后通知数据仓库入库。这种情况下回调是最合适的。回调的核心思想是服务端在任务结束后主动请求调用方预先提供的 HTTP 接口把结果数据送过去。调用方不用一直挂着连接也不用反复查询一切交给任务服务端来做。4.1 回调地址怎么传回调地址一般有两种来源一种是调用方在请求创建任务时在请求体里带一个 callbackUrl 字段另一种是调用方先在平台后台配置一个默认回调地址创建任务时不带则使用默认地址。第一种更灵活适合同一个调用方对接多个不同下游的情况第二种更安全因为回调地址不是由请求参数随意决定的不容易被人乱传地址打别人接口。创建任务的接口建议长这样请求体任务参数 callbackUrl可选 notifyRelatedId业务幂等ID响应体taskId、初始状态、预估等待时间这里有个细节不要在请求体里传一个没法验证的 URL 就直接回调。你要在创建任务时就校验这个 URL 是否属于合法域名或者是否在白名单内否则攻击者可以让你的服务器去请求内网地址造成 SSRF 风险。4.2 回调请求要带签名和时间戳回调本质上是两个系统之间的 HTTP 请求而且这个请求是从你的业务服务器发出去的如果中途被劫持或者被伪造后果很严重。所以回调请求必须做签名验证这是老生常谈但总有人忽略。常见签名方案是双方事先约定一个密钥回调方在推送时把参数按照一定规则拼接加上时间戳、随机数用 HMAC-SHA256 计算签名放在 Header 里。接收方收到请求后先校验时间戳是否在合理时间范围内一般允许 5 分钟偏移再用同样的密钥重算签名签名一致才处理。为什么一定要带时间戳因为如果回调请求被截获攻击者原样重放接收端收到多次一模一样的请求没有校验时间的话很难识别。时间戳加上随机数可以防范重放攻击。回调时还要约定一个业务幂等 ID。接收方可能连续收到多条相同任务的回调它不能重复处理业务。所以回调体里必须包含一个唯一键接收方用它去重。4.3 失败重试与超时降级回调请求发出去之后接收方不一定在线可能是接口超时、返回 5xx、或者因为发布运维阶段暂时不可用。为了让任务结果不丢回调一定要有重试机制。重试策略可以简单定义为首次调用超时 5 秒返回异常后分别延迟 1 分钟、5 分钟、30 分钟、2 小时各重试一次最多重试 4 次。最终失败后要有一个死信处理机制把失败回调记录下来提供人工查询或者重新推送的能力。这里需要特别提醒回调如果一直失败任务的最终状态不能一直挂在 RUNNING。任务本身已经完成了只是通知不到调用方。所以状态机里要有两类状态执行状态和通知状态。任务可能执行成功 通知失败这对系统是正常状态而不是异常。PRD 里一定要把这个区分写清楚否则开发会把这场景当成 bug 处理前端也无法正确提示用户。5. 三种方案怎么选决策表 混合模式每次做技术评审都会有人问到底用哪个其实答案不用争论太久拉一张决策表出来过一遍就清楚了。5.1 一张表帮你做选择判断维度轮询WebSocket回调实时性取决于轮询间隔实时推送实时推送服务端压力高尤其时高频率轮询连接数管理有压力较低只需在完成任务时发起请求客户端复杂度最低高需要管理连接无客户端不需要参与网络兼容性最好普通 HTTP 即可需要支持 WebSocket需要公网可达的接收接口适合场景管理后台、低频任务、兜底需要实时过程展示的场景系统间自动触发后续流程常见风险请求浪费、频率失控连接风暴、断线丢消息回调失败、接收方不可用从这张表可以直接得出结论如果任务是内部工具、低频、不要求秒级实时用轮询如果用户盯着页面等进度、要看实时日志或百分比用 WebSocket如果任务结果要触发另一个系统做事用回调。5.2 复杂任务可以混合使用很多人以为三种方式互斥实际上一个大型系统经常混着用。我自己做过的项目里比较典型的组合是创建任务时如果调用方带了 callbackUrl就走回调通知前端页面如果在线同时建立一个 WebSocket 订阅实时展示进度为了兜底前端还会以 30 秒一次的频率做轻量轮询在 WebSocket 断连时补状态。这三个通道之间不冲突反而互相备份。关键是要在消息里带上 taskId各通道之间用 taskId 对齐。成本是消息发送逻辑要封装一层通知中心统一决定一个任务的状态变化要推给哪些通道。如果团队资源有限我建议先把回调做好再考虑 WebSocket。因为回调是服务端到服务端的可靠通知能解决大部分自动化的链路问题WebSocket 只是锦上添花优化体验用。轮询可以做兜底实现成本最低。6. PRD写法把这几种机制写清楚开发才不会翻车异步任务设计最后落到文档上很多人只写一句任务完成后通知前端然后甩给开发自己去商量。这个文档交出去开发大概率会来来回回问你细节是主动推还是前端查通知失败怎么办状态怎么定义推送内容有哪些字段一个一个解释下来比写清楚文档还累。所以这里给一份可以直接借鉴的 PRD 写法覆盖状态定义、接口说明、通知机制、异常场景拿去对照着改就能用。6.1 先定义任务状态和流转规则PRD 里第一个要写的是任务状态机不能含糊。建议用一张表定义状态枚举和触发条件。字段定义taskId全局唯一服务端生成status任务状态枚举值为 PENDING、RUNNING、COMPLETED、FAILED、CANCELEDprogress0 到 100非终态时表示进度errorMessageFAILED 时的错误原因createdAt、startedAt、finishedAt关键时间点callbackUrl调用方传入的回调地址可为空notifyStatus通知状态枚举为 NOTIFY_PENDING、NOTIFY_SUCCESS、NOTIFY_FAILED、NOTIFY_RETRYING状态流转规则要明确PENDING 到 RUNNING任务被调度器获取开始执行RUNNING 到 COMPLETED业务执行成功RUNNING 到 FAILED业务执行异常PENDING 或 RUNNING 到 CANCELED用户主动取消且任务支持取消COMPLETED 之后进入通知流程通知成功才结束通知失败则进入 NOTIFY_RETRYING状态之间不能随便乱跳比如 FAILED 不能回到 RUNNING除非有明确的重新执行接口。6.2 轮询、WebSocket、回调在 PRD 里怎么写轮询部分建议写成前端在收到任务创建成功响应后开启定时轮询。轮询间隔默认 5 秒并增加 1 到 2 秒随机偏移。轮询接口返回 taskId、status、progress、errorMessage。前端在收到 COMPLETED、FAILED、CANCELED 三个终态后停止轮询。连续 5 次请求失败时暂停轮询提示网络异常并提供手动重试按钮。WebSocket 部分建议写成平台提供全局 WebSocket 通道客户端在登录后建立连接。任务状态变化时服务端通过该连接推送消息。消息格式包含 event、taskId、status、progress、timestamp。客户端收到 COMPLETED 或 FAILED 事件后刷新对应页面。连接断开后客户端自动重连重连成功后上传最后收到消息的 offset服务端补发缺失消息或状态快照。回调部分建议写成当创建任务请求中携带 callbackUrl 时任务完成后服务端以 HTTP POST 方式回调该地址。请求体包含 taskId、status、businessId、result 数据。回调请求头部携带签名和时间戳接收方需校验签名。回调超时 5 秒失败后按 1 分钟、5 分钟、30 分钟、2 小时间隔重试最多重试 4 次全部失败后在管理后台标记为通知失败支持人工重新推送。把这三种机制都在 PRD 里写明开发拿到文档就能直接评估工作量不会再来来回回猜需求。6.3 验收标准不要只写能通知PRD 最后一定要有可验收的标准。异步任务这块建议至少覆盖以下场景创建任务返回 taskId状态为 PENDING系统在 1 秒内产生任务记录。任务执行完成后轮询接口能在下一个周期内返回终态。WebSocket 在线时任务完成消息在 2 秒内推送到前端。回调请求失败 4 次后任务标记为通知失败并提供手动重推入口。回调重复推送时接收方根据 businessId 去重不重复处理业务。回调请求签名错误时接收方返回 401 并拒绝处理。任务执行异常时状态为 FAILEDerrorMessage 有具体原因并记录日志。取消任务后状态为 CANCELED轮询/WebSocket/回调任一通道均能正确推送该状态。这些验收标准写出来测试同学才有方向开发同学也才会把边界情况真正当回事。很多项目表面上功能上线了实际上通知丢失、重复回调、状态卡死这些问题都埋着雷等到线上出了事故才发现 PRD 根本没写。我在实际项目中体会最深的一点是异步任务设计里技术选型并不是最难的最难的是把状态变化和通知策略抽象成一套稳定且可解释的规则。无论你们最后选轮询、WebSocket 还是回调都先把状态机、重试次数、超时时间、失败兜底这几件事钉死。只要这些核心规则定了换通道只是改一层适配代码的事系统不会乱。