你有没有遇到过这种场景:前端点下“生成报表”,页面开始转圈,后端接口其实早就返回了 200,但业务状态一直是“处理中”;运维看了监控说 CPU、内存都正常,研发看了日志说任务在跑,可用户就是在等。更麻烦的是,如果是系统之间互相调用,A 服务调 B 服务,B 要 30 秒才能算完,A 的连接池就在这 30 秒里被一直占着,最后不是业务出错,而是线程被打满,整条链路一起挂掉。
这些问题的表面形态不一样,本质上是同一件事:有个东西要等你。等你算完、等你回调、等你落库、等你确认。在单机、单请求、数据量小的阶段,等待不是问题,一个Thread.sleep或者一个同步 HTTP 调用就能糊过去;一旦并发上来、链路变长,“怎么等”就变成了架构问题。
这篇文章想给出的判断是:“等你”这个需求的技术难点,从来不是“等待”这个动作本身,而是三个更实际的问题——谁来等、等多久、等不到怎么办。这三个问题的答案,直接决定了你该用短轮询、长轮询、回调通知还是消息队列,也直接决定了你线上故障时是业务超时还是一片线程被打满。
下面从等待的成本开始拆,然后给出一套可以直接跑通的异步任务 + 状态查询 + 超时退避的最小实现,最后聊选型、排错和工程上真正容易踩的坑。读完你应该能判断自己手上的“等你”场景该落在哪种方案上,也能直接照着代码把流程跑起来。
1. “等你”背后的真实工程问题
1.1 一次等待的成本被严重低估
很多人对“等待”的直觉成本是零,因为它不消耗 CPU。这个直觉在单线程脚本里成立,在服务端完全不成立。一次阻塞式等待,至少会占住三样东西:一个线程、一份连接(数据库连接、HTTP 连接或 Redis 连接)、一段堆内存(请求上下文、响应缓冲)。这三样在 JVM 里都是有限资源,不是无限的。
我们可以做一个很朴素的推算,不涉及任何具体产品参数:假设你的 Web 容器线程池是 200 个线程,某个接口因为下游慢,平均要等 5 秒才返回。那么在不做任何异步化改造的前提下,这个接口的理论吞吐上限就是 200 ÷ 5 = 40 QPS。注意,这还是理想值,因为线程调度、GC、网络抖动都会吃掉一部分。这个数字很吓人:40 QPS 意味着它连一次正常的运营活动都扛不住,而且更糟的是,这 200 个线程被占满之后,同一容器里的其他接口也会一起不可用,因为它们共享同一个线程池。
这就是为什么很多线上事故的表现是“整个服务挂了”,但根因只是“某个不重要的慢接口把线程池打满了”。等待本身不消耗 CPU,但它消耗的是并发能力,而并发能力才是服务端最贵的资源。理解了这一点,后面的所有方案都是在回答同一个问题:怎么把“等”这件事,从占着资源等,变成不占资源等。
1.2 三个必须先回答的问题
在动手写代码之前,先把三个问题回答清楚,方案基本就定了。第一个问题是谁来等:是调用方线程一直挂着,还是把等待的责任转移出去,让调用方先拿到一个凭据然后去做别的事。前者实现简单但成本高,后者实现复杂但可扩展。
第二个问题是等多久。这里最常见的错误是只设一个超时,比如统一 30 秒。实际上一次请求的等待时间应该是分层的:客户端等接口多久、接口等下游多久、下游等数据库多久,每一层都必须小于上一层,否则外层已经超时返回了,内层还在傻等,最后内层的结果没人要,白算一遍。
第三个问题是等不到怎么办。超时之后,任务是被取消、还是继续跑只是结果丢弃、还是转成异步任务留个记录让用户稍后查?这三种处理方式对用户体验和系统负载的影响完全不同。很多系统在超时后既不取消任务也不记录状态,导致用户重试一次就多算一遍,重复数据、重复扣款、重复发消息全从这个缝里出来。
1.3 什么样的读者最需要这篇文章
如果你正在写一个耗时超过 3 秒的接口,并且它是要给用户直接点的,那这篇内容对你最直接。如果你的系统里有“订单支付结果回调”“文件解析进度”“大模型推理结果”“批量导入结果”这类场景,你也一定绕不开。还有一类读者容易被忽略:做前端或者做客户端的同学。轮询策略、退避间隔、超时提示文案,其实都发生在你这一侧,后端只能提供一个查询接口,用户体验好不好,很大一部分取决于你怎么“等”。
2. 五种“等你”的实现形态与核心原理
2.1 同步阻塞等待
这是最原始的形态:调用方发起请求,然后线程挂在那里,直到结果返回或者超时。它的实现成本最低,HTTP 客户端一个execute()就完事了,代码可读性也最好,从上到下一条直线。
它的问题在于耦合了业务耗时和请求生命周期。业务要 30 秒,请求就得挂 30 秒,中间不能有任何风吹草动——网关不能超时、负载均衡不能断连、客户端不能手抖取消。链路上一共有 5 个组件,那这 5 个组件的超时都必须大于 30 秒,任何一个配小了,等待就变成了失败。而且这个方案没有重试的空间:超时之后你不知道任务是没开始、跑了一半还是已经完成了,重试一下可能就是重复执行。
所以同步阻塞并不是“错”,它真正的适用范围很明确:耗时可控在 1 到 3 秒以内、调用链短、失败可以整体重试且天然幂等的场景。超过了这个边界,就该考虑换方案了。
2.2 短轮询
短轮询的思路是:提交任务立刻返回一个任务 ID,客户端拿着这个 ID 每隔一段时间来问一次“好了没”。服务端的请求变成两个:一个负责创建任务,一个负责查询状态,两个都是快速返回的短接口,线程占用时间从 30 秒降到几十毫秒。
这是工程上性价比最高的方案,没有之一。它不需要长连接、不需要消息中间件、不需要服务端主动推送,用最普通的 HTTP 就能实现,前端一个setInterval就能跑。但它引入了一个新问题:轮询频率怎么定。间隔太短,1 秒一次,一万个用户同时在等就是一万 QPS 打在一个查询接口上,而这个接口本身可能还要查数据库;间隔太长,用户体感就差了,明明 2 秒就算完了,用户要等 5 秒才看到结果。
实际项目里更推荐的做法是退避式轮询:第一次等 200 毫秒,第二次 400 毫秒,第三次 800 毫秒,逐步翻倍到一个上限(比如 2 秒),然后保持这个上限直到超时。这样既保证了快任务的体感,又限制了慢任务对服务端的压力。这个策略在本文第 5 节会有可运行的实现。
2.3 长轮询
长轮询是短轮询的变种:客户端发一个查询请求,如果服务端发现任务还没完成,就不立刻返回,而是把请求挂住一段时间,比如挂 20 秒,期间如果任务完成就马上返回结果,如果 20 秒到了还没完成就返回一个“仍在处理中”,客户端再发起下一次请求。
它的好处是大幅减少了无效请求数量。一次挂 20 秒,相比 1 秒轮询 20 次,请求量降了一个数量级,体感还更好,因为任务一完成就能立刻返回。代价是它重新引入了“占资源”的问题——只不过占的不是业务线程,而是异步容器里的连接。用 Servlet 3 的异步特性或者响应式框架能做到不占线程只占连接,但如果直接用阻塞式写法实现长轮询,那你就是把线程池的问题原封不动搬过来了。
长轮询适合消息量不大、实时性要求中等、又不想引入消息中间件的场景。一旦并发连接数上到几万,你就得认真评估服务端的连接承载能力,别把长轮询写成了“把线程挂住 20 秒”的同步等待。
2.4 回调通知
回调(Callback / Webhook)是把等待责任彻底转移出去的做法:A 提交任务时顺便告诉 B “算完了请通知我这个地址”,然后 A 立刻返回,不再等待。B 算完之后主动发起一次 HTTP 请求打到 A 指定的地址,A 收到通知再更新状态。
这是系统间集成的标准做法,资源占用最低,实时性也很好。但它把复杂度转移到了可靠性上:如果回调的时候 A 正好在发布,请求打不通怎么办?如果 B 回调了但 A 处理失败了怎么办?如果网络抖动导致 A 收到了两次回调怎么办?所以回调方案必须配套三样东西:回调失败的重试机制(通常是退避重试若干次)、接收端的幂等处理(用业务单号或者回调 ID 去重)、以及一个兜底的主动查询(叫“回调优先、轮询兜底”)。
很多团队在这里踩坑,是因为只做了回调没做兜底。回调丢一次,订单状态就永远停在“处理中”,用户反复刷新也没用,只能靠人工捞数据修复。这个坑的代价非常高,因为它是静默失败的——不报错、不告警,就是数据不对。
2.5 消息队列 + 状态表
规模再大一点,通常会把“任务执行”和“任务通知”拆到消息队列里。提交任务时写一条记录到任务表(状态为 PENDING),同时发一条消息到队列;消费者拿到消息开始执行,执行过程中更新状态表为 RUNNING,结束后更新为 SUCCESS 或 FAILED,再发一条结果通知消息。
这个方案的核心价值不是“快”,而是削峰和解耦。任务提交的速率和执行速率彻底分开了,高峰期的任务在队列里排队,消费者按自己的能力慢慢消费,不会把下游打垮。状态表则给整个流程提供了一个可观测的真相来源:任何时候你都能查到某个任务现在到底在哪一步。
代价是组件变多了,链路变长了,排查问题也更难——一个任务卡住,可能卡在生产端、卡在 broker、卡在消费端、卡在状态更新,得有一套完整的日志和追踪才能定位。所以这个方案不适合小项目,也不适合任务量很小的场景,杀鸡用牛刀反而会增加故障面。
2.6 五种方案的对比与选型
把上面五种形态放在一起,选型逻辑其实很清楚:
| 方案 | 资源占用 | 实现复杂度 | 实时性 | 适用场景 | 主要风险 |
|---|---|---|---|---|---|
| 同步阻塞 | 高 | 低 | 最高 | 耗时 1-3 秒、链路短 | 线程池被打满、链路超时不一致 |
| 短轮询 | 低 | 低 | 中 | 通用场景、前后端分离 | 轮询频率失控、查询接口被打爆 |
| 长轮询 | 中 | 中 | 较高 | 消息量中等、不想引入中间件 | 连接数承载、阻塞式实现退化 |
| 回调通知 | 最低 | 中 | 高 | 系统间集成、跨团队调用 | 回调丢失、重复回调 |
| 消息队列 | 低 | 高 | 中 | 高并发、需要削峰解耦 | 链路长、排查难、组件运维成本 |
选型时不要追求“最先进”,而要问自己:这个场景的并发量有多大、能接受多长的端到端延迟、团队有没有能力维护消息队列。大部分业务系统用“短轮询 + 退避”就能解决 80% 的问题,剩下的 20% 再考虑回调和队列。
3. 环境准备与前置条件
3.1 运行环境
本文的示例以 Java 和 Python 两条线展开。Java 侧用来演示服务端的异步任务接口和带退避的等待工具,Python 侧用来演示客户端侧如何并发等待多个结果。两个示例互相独立,你可以只挑自己需要的那部分跑。
Java 侧建议使用 JDK 17 或更高版本,因为 Spring Boot 3.x 的最低要求就是 JDK 17。如果你所在的项目还停在 JDK 8,用 Spring Boot 2.7 系列同样可以跑通本文全部代码,示例里没有用到任何高版本特有的语法。构建工具用 Maven 或 Gradle 都行,本文用 Maven 演示。Python 侧用 3.8 以上即可,示例只用到asyncio的基础 API,不需要安装任何第三方包,标准库就够。
具体版本请以你实际项目的依赖管理规范为准,本文重点演示通用思路和代码结构,不锁定任何特定版本号。
3.2 项目依赖
Java 侧只需要一个 Web starter,用来提供 HTTP 接口。示例中任务状态存在内存里,所以不需要引入数据库或 Redis;如果你要上生产,把内存 Map 换成 Redis 或者数据库表即可,接口形态不用变。
<!-- 文件路径:pom.xml --> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>wait-demo</artifactId> <version>1.0.0</version> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> </project>这里的spring-boot-starter-parent版本只是一个可运行的示例值,实际项目请用你们统一管理的版本,避免和团队其他服务不一致。依赖只有 Web starter 一个,是为了让示例足够干净——真实项目里你还需要加上数据库、缓存、监控这些,但那属于业务基建,不影响本文要讲的等待模型。
Python 侧不需要任何依赖文件,asyncio、random、time都是标准库。如果你打算把这个脚本接到真实的 HTTP 接口上,再按需引入httpx或requests,但本文为了可复现,用的是模拟的远程调用。
4. 核心流程拆解
4.1 第一步:提交任务并立刻返回
异步化的第一刀就切在这里。原来的接口是“收请求 → 干活 → 返回结果”,现在要改成“收请求 → 登记任务 → 投递执行 → 立刻返回任务标识”。关键在于这一步要尽可能快,不能在里面做任何耗时操作,否则异步化的意义就打了一半折扣。
具体来说,提交接口里应该只做三件事:生成一个全局唯一的任务 ID、把任务状态以 PENDING 写进去、把真正的执行逻辑丢到线程池或者消息队列。参数校验、权限校验这类必须前置的逻辑可以保留,但任何 IO 密集的调用都应该挪到执行阶段。
这一步做错最常见的表现是:提交接口本身就很慢,因为开发者顺手在里面做了数据库写入 + 文件上传 + 下游校验。结果客户端还是得等,异步化只改了形式没改实质。
4.2 第二步:设计一个稳定的任务标识
任务 ID 是整个流程的主键,它必须满足两个条件:全局唯一、不暴露业务含义。用自增 ID 做任务 ID 是个很常见的错误,因为客户端拿去轮询的时候,别人只要改一下数字就能查到别人的任务状态,这是典型的越权漏洞。
更稳妥的做法是用 UUID 去掉横线,或者用“业务前缀 + 时间戳 + 随机串”的组合。另外,如果你有多个服务实例,任务 ID 还要考虑是否需要在分布式环境下唯一——UUID 天然满足,自增 ID 就不满足。
任务 ID 还需要能被日志系统追踪。建议在提交时就把这个 ID 打进 MDC 或者日志上下文,后面执行、查询、回调各个环节都带上它,出问题时一个 grep 就能串起整条链路。这个小习惯的价值,在你第一次排查“任务到底跑没跑”的时候就会体现出来。
4.3 第三步:状态查询接口的幂等设计
查询接口看起来最简单,其实最容易埋坑。它必须是纯读、幂等、无副作用的:同一个任务 ID 查一百次,结果应该一致(除非任务状态推进了),不能因为查询动作本身改变任何状态。
有个很隐蔽的错误是:把“查询状态”和“推进状态”混在一起,比如查询时发现任务卡住了就顺手重试一下。这会导致客户端轮询得越勤,重复执行越多,最后数据全乱。状态查询就是查询,推进状态的逻辑应该由执行方或者一个独立的调度器负责。
返回结构上建议统一成固定的几个字段:任务状态、结果数据、错误信息、创建时间、更新时间。状态值不要用魔法字符串散落在各处,用枚举固定下来:PENDING、RUNNING、SUCCESS、FAILED、CANCELLED。前端只认这五个值,任何非法状态都当成未知处理,避免后端加了个新状态前端直接白屏。
4.4 第四步:结果落地与通知
任务执行完成后,需要一个明确的落地点。最直接的是把结果写进任务表,让查询接口读出来;如果结果很大,写文件存储或者对象存储,任务表里只存一个引用地址。这个选择的关键是别把大对象塞进状态表,否则一次查询就把几百 KB 的 JSON 拉出来,轮询几次带宽就爆了。
如果用了回调通知,那么执行完成后要先更新状态,再发通知,顺序不能反。反过来做的话,会出现“通知已经发出去了,但状态还没更新”的窗口期,接收方收到通知回来查状态,查到的还是 RUNNING,会产生误判。稳妥的顺序是:更新状态为 SUCCESS → 提交通知任务 → 通知失败进入重试队列。即使通知最终丢失,状态表里也是对的,兜底查询能救回来。
4.5 第五步:超时、回收与清理
没有清理机制的异步任务系统,迟早会变成垃圾场。至少要处理三种情况:一是任务提交后一直没人消费,停留在 PENDING;二是任务执行中进程挂了,停留在 RUNNING 再也不会变;三是任务已经完成很久,记录还在占空间。
第一种和第二种需要靠“超时回收”来解决:给每个任务一个预期的最大执行时间,超过这个时间的 PENDING 或 RUNNING 任务,由一个定时任务扫描出来标记为 FAILED 或者重新投递。扫描周期不用太短,分钟级即可,但必须有,否则数据会一直错下去。
第三种靠归档或者 TTL。任务表只保留最近一段时间的记录,更早的迁到历史表或者直接删除。这个策略要按业务实际需要来定,比如对账类任务可能要保留很久,而临时的导出任务保留几天就够了。
5. 完整示例代码实现
5.1 任务提交与状态查询接口
先写服务端的核心接口。为了不引入数据库依赖,这里用ConcurrentHashMap存任务状态,用固定线程池模拟执行。这段代码可以直接复制到 Spring Boot 项目里跑。
// 文件路径:src/main/java/com/example/waitdemo/controller/TaskController.java package com.example.waitdemo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @RestController @RequestMapping("/api/task") public class TaskController { /** 任务状态存储,生产环境请替换为 Redis 或数据库 */ private final Map<String, TaskState> store = new ConcurrentHashMap<>(); /** 执行线程池,生产环境请按实际负载调整或换成消息队列 */ private final ExecutorService worker = Executors.newFixedThreadPool(8); /** * 提交任务:立刻返回任务 ID,不等待执行结果 */ @PostMapping("/submit") public Map<String, Object> submit(@RequestParam(defaultValue = "3000") long costMillis) { String taskId = UUID.randomUUID().toString().replace("-", ""); store.put(taskId, TaskState.pending()); worker.submit(() -> { store.put(taskId, TaskState.running()); try { Thread.sleep(costMillis); store.put(taskId, TaskState.success("result-of-" + taskId)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); store.put(taskId, TaskState.failed("interrupted")); } catch (Exception e) { store.put(taskId, TaskState.failed(e.getMessage())); } }); Map<String, Object> resp = new HashMap<>(); resp.put("code", 0); resp.put("taskId", taskId); resp.put("status", "PENDING"); return resp; } /** * 查询任务状态:纯读接口,幂等,无副作用 */ @GetMapping("/{taskId}") public Map<String, Object> query(@PathVariable String taskId) { Map<String, Object> resp = new HashMap<>(); TaskState state = store.get(taskId); if (state == null) { resp.put("code", 404); resp.put("message", "task not found"); return resp; } resp.put("code", 0); resp.put("taskId", taskId); resp.put("status", state.status); resp.put("data", state.data == null ? "" : state.data); return resp; } /** * 任务状态载体,生产环境对应一张任务表 */ static class TaskState { String status; String data; static TaskState pending() { TaskState s = new TaskState(); s.status = "PENDING"; return s; } static TaskState running() { TaskState s = new TaskState(); s.status = "RUNNING"; return s; } static TaskState success(String data) { TaskState s = new TaskState(); s.status = "SUCCESS"; s.data = data; return s; } static TaskState failed(String data) { TaskState s = new TaskState(); s.status = "FAILED"; s.data = data; return s; } } }这段代码有几个地方值得单独说明。第一,submit方法里没有任何阻塞逻辑,Thread.sleep被放进了线程池,所以提交接口的响应时间基本就是一次 Map 写入的时间。第二,返回的是HashMap而不是Map.of,因为Map.of不接受 null 值,而data字段在任务未完成时是 null,用Map.of会直接抛空指针异常,这个坑非常常见。第三,查询接口对不存在的任务返回 404 而不是空对象,这样客户端能区分“任务不存在”和“任务存在但没完成”这两种完全不同的情况。
5.2 客户端带退避的等待工具
服务端提供了查询接口,客户端怎么等就很关键了。下面这个工具类实现了指数退避 + 总超时,是整个方案里最值得复用的部分。
// 文件路径:src/main/java/com/example/waitdemo/client/WaitUtil.java package com.example.waitdemo.client; import java.time.Duration; import java.util.function.Predicate; import java.util.function.Supplier; public final class WaitUtil { private WaitUtil() { } /** * 按指数退避轮询,直到满足结束条件或超时 * * @param poller 单次查询动作 * @param done 判断是否结束的条件 * @param timeout 总超时时间 * @param initialInterval 首次间隔 */ public static <T> T waitUntil(Supplier<T> poller, Predicate<T> done, Duration timeout, Duration initialInterval) throws InterruptedException { long deadline = System.nanoTime() + timeout.toNanos(); long interval = Math.max(initialInterval.toMillis(), 10L); while (true) { T value = poller.get(); if (done.test(value)) { return value; } long remainNanos = deadline - System.nanoTime(); if (remainNanos <= 0) { throw new IllegalStateException("wait timeout after " + timeout.toMillis() + "ms"); } long sleepMillis = Math.min(interval, Duration.ofNanos(remainNanos).toMillis()); if (sleepMillis > 0) { Thread.sleep(sleepMillis); } // 间隔翻倍,但不超过 2000ms,避免后期轮询过疏 interval = Math.min(interval * 2, 2000L); } } }这个工具类做了三件事。第一,用System.nanoTime()而不是System.currentTimeMillis()来计算截止时间,因为前者是单调时钟,不受系统时间调整影响,后者在 NTP 校时的时候可能突然跳变。第二,每次 sleep 的时间取“当前间隔”和“剩余时间”的较小值,保证不会睡过头。第三,间隔翻倍但封顶 2000 毫秒,既照顾了快任务的体感,又不会让慢任务等到天荒地老。调用方式大概是这样:
Map<String, Object> result = WaitUtil.waitUntil( () -> queryTask(taskId), r -> !"PENDING".equals(r.get("status")) && !"RUNNING".equals(r.get("status")), Duration.ofSeconds(15), Duration.ofMillis(200) );5.3 超时与线程池配置
超时值不要散落在代码里,统一收到配置文件中,这样不同环境可以给不同的值,改的时候也不用动代码。
# 文件路径:src/main/resources/application.yml server: port: 8080 tomcat: threads: max: 200 connection-timeout: 5s task: poll: initial-interval-ms: 200 max-interval-ms: 2000 default-timeout-ms: 15000这里有三层的超时要配套设置,顺序上必须是外层大于内层:客户端等待总超时 15 秒,服务端查询接口自身响应要在几百毫秒内返回,Tomcat 的连接超时 5 秒是兜底防止连接挂着不放。如果把客户端超时配成 15 秒,而网关的超时只有 3 秒,那客户端还没等到,网关已经返回 504 了,用户看到的是错误页,但后端任务其实还在跑,这就是典型的超时不一致。
5.4 Python 侧并发等待多个结果
如果你需要在脚本或者数据处理流程里同时等多个耗时任务,顺序等待会很浪费时间。Python 的asyncio可以用gather并发等待,用wait_for给每个任务单独设超时。
# 文件路径:scripts/wait_demo.py import asyncio import random async def remote_call(name: str, cost: float) -> str: """模拟一次耗时调用""" await asyncio.sleep(cost) return f"{name} done in {cost:.2f}s" async def wait_with_timeout(name: str, cost: float, timeout: float) -> str: """单个任务的等待,超时不抛异常而是返回明确结果""" try: return await asyncio.wait_for(remote_call(name, cost), timeout=timeout) except asyncio.TimeoutError: return f"{name} timeout after {timeout}s" async def main() -> None: random.seed(42) tasks = [ wait_with_timeout("report-A", random.uniform(0.5, 2.0), 1.5), wait_with_timeout("report-B", random.uniform(0.5, 2.0), 1.5), wait_with_timeout("report-C", random.uniform(0.5, 2.0), 1.5), ] # gather 让三个任务并发等待,总耗时约等于最慢的那个 results = await asyncio.gather(*tasks) for line in results: print(line) if __name__ == "__main__": asyncio.run(main())这里的关键点是asyncio.wait_for内部会取消超时的协程,不会留下悬空任务。如果你用asyncio.gather而不加wait_for,那么只要有一个任务卡住,整个gather就永远不返回,这在线上是一个非常危险的写法。另外seed(42)是为了让输出可复现,方便你验证结果,真实场景当然不需要。
5.5 用命令行验证整个流程
代码写完了,用两条 curl 命令就能验证最核心的行为:提交立刻返回,查询能拿到状态。
# 1. 提交任务,指定执行耗时 3 秒 curl -s -X POST "http://localhost:8080/api/task/submit?costMillis=3000" # 预期返回:{"code":0,"taskId":"xxxx","status":"PENDING"} # 2. 用上一步拿到的 taskId 立即查询,应该是 PENDING 或 RUNNING curl -s "http://localhost:8080/api/task/6f1c8a2b9d3e4f5a8b7c6d5e4f3a2b1c" # 预期返回:{"code":0,"taskId":"xxxx","status":"RUNNING","data":""} # 3. 等待 4 秒后再查,应该是 SUCCESS 并带上结果 sleep 4 curl -s "http://localhost:8080/api/task/6f1c8a2b9d3e4f5a8b7c6d5e4f3a2b1c" # 预期返回:{"code":0,"taskId":"xxxx","status":"SUCCESS","data":"result-of-xxxx"}6. 运行结果与效果验证
服务端启动命令很直接:
mvn spring-boot:run启动成功的标志是日志里出现Tomcat started on port(s): 8080和Started WaitDemoApplication这两行。如果只看到第一行没有第二行,通常是某个 Bean 初始化失败,往上翻日志找Caused by即可。
判断整个方案是否真的生效,不能只看“接口有没有返回 200”,要看三个指标。第一个是提交接口的响应时间:如果带着costMillis=3000提交,响应时间应该稳定在几十毫秒量级,如果也是 3 秒左右,说明你的执行逻辑根本没进线程池,还是同步跑的。第二个是线程占用情况:用jstack抓一下线程栈,能看到pool-1-thread-*在执行 sleep,而 Tomcat 的http-nio-8080-exec-*线程已经释放了,这才说明等待被转移出去了。第三个是状态推进是否符合预期:PENDING → RUNNING → SUCCESS,三个状态应该都能被查到,如果直接从 PENDING 跳到 SUCCESS,说明你的 RUNNING 状态写入被覆盖或者根本没写。
如果验证失败,排查顺序建议是:先看提交接口的耗时,确认异步是否生效;再看线程池的队列是否有积压,Executors.newFixedThreadPool(8)用的是无界队列,任务提交太快会导致队列无限增长,这在生产环境是内存泄漏的隐患;最后看查询接口是不是真的读到了最新状态,如果是多实例部署但状态存在本地 Map 里,那么轮询到别的实例就会查不到任务,这是新手最容易忽略的问题。
7. 常见问题与排查思路
异步等待这类问题,现象往往很迷惑,因为“失败”和“慢”很难区分。下面整理了几种高频情况:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 提交接口本身很慢 | 提交逻辑里混入了耗时 IO | 看接口耗时分布、打点日志 | 把耗时逻辑移到执行阶段 |
| 状态永远停在 PENDING | 线程池被占满或队列积压 | 看线程池活跃数和队列长度 | 换有界队列 + 拒绝策略,或改用消息队列 |
| 状态永远停在 RUNNING | 执行进程崩溃未更新状态 | 查实例重启记录、任务表更新时间 | 增加超时扫描任务,超时标记失败 |
| 多实例部署查不到任务 | 状态存在本地内存 | 检查是否用了本地 Map | 换成 Redis 或数据库共享存储 |
| 客户端轮询打爆服务 | 轮询间隔过短、无退避 | 看查询接口 QPS 曲线 | 加指数退避并设置最小间隔 |
| 超时后出现重复数据 | 超时未取消任务且重试不幂等 | 对比任务 ID 和业务单号 | 业务侧幂等 + 任务可取消 |
| 回调丢失导致状态不更新 | 只做回调没做兜底查询 | 查回调失败日志和重试记录 | 回调优先 + 定时主动查询 |
| 查询接口返回 null 报错 | 用了不接受 null 的集合构造 | 看异常堆栈 | 改用 HashMap 并显式处理空值 |
关于“状态永远停在 RUNNING”,再补充一点实际经验:这个现象在容器化环境里特别常见,因为 Pod 被驱逐或者 OOM Kill 的时候,进程是直接没的,不会执行任何finally块。所以不能依赖应用自己来保证状态最终一致,必须有一个独立于业务进程的扫描者,从外部把超时任务捞出来处理。这个扫描者可以是定时任务,也可以是消费端的心跳检测,但一定不能放在业务进程内部。
另一个容易被忽略的问题是轮询的客户端没做页面可见性判断。用户把浏览器标签页切走了,setInterval还在继续跑,一万个后台标签页就是一规模不小的无效请求。前端侧应该在页面不可见时暂停轮询,重新可见时立即恢复一次查询,这个改动能省下相当一部分查询压力。
8. 最佳实践与工程建议
8.1 超时一定要分层且单调递减
这是全文最想强调的一条。一次请求涉及的超时至少有四层:客户端等待超时、网关超时、服务接口超时、下游依赖超时。它们的值必须满足客户端 > 网关 > 服务接口 > 下游依赖,否则就会出现“外层已经放弃,内层还在苦干”的资源浪费。更糟的是,如果外层超时后用户重试,内层还在处理第一次的请求,两次请求并发跑,不幂等的业务直接就出错了。
实践上建议把超时集中配置,在启动时做一次校验,发现配置顺序不对就直接启动失败。这个校验花不了多少代码,但能挡住一类很难排查的线上问题。
8.2 任务 ID 和业务单号的分离
任务 ID 是技术标识,业务单号是业务标识,两者不要混用。原因很实际:一个业务单号可能对应多次任务执行(比如失败了重跑),用业务单号做主键就没法区分历史记录;反过来,用任务 ID 做业务查询又需要额外索引。正确的做法是任务表里两个字段都有,任务 ID 做主键,业务单号建索引,查询时按业务单号查出所有关联任务。
8.3 状态机要闭环,不要留悬空状态
任务状态必须是一个闭环的状态机:PENDING 只能到 RUNNING 或 CANCELLED,RUNNING 只能到 SUCCESS 或 FAILED,SUCCESS 和 FAILED 是终态,不能再变。任何不在这个图里的状态流转都应该被拒绝并记录告警。这条约束看起来只是“规范”,实际价值在于它让“卡住的任务”变得可发现——如果一个任务在 RUNNING 状态停留时间超过了预期上限,那它一定出了问题,扫描器可以据此捞出来。
8.4 可观测性比功能本身更重要
异步系统最怕的不是慢,而是“不知道现在什么情况”。至少要有四个观测点:提交量、各状态的实时任务数、执行耗时的分位数、超时和失败的任务数。有了这四个,绝大部分问题在用户投诉之前就能发现。日志上建议统一带上任务 ID,并且把提交、开始执行、执行结束、状态更新这四个时刻都打一条,这样任何一个任务的生命周期都能被完整还原。
8.5 安全边界不能忘
任务查询接口必须做权限校验。用 UUID 做任务 ID 只能降低被猜中的概率,不能替代鉴权。正确的做法是查询时校验“当前登录用户是否是该任务的创建者”,不匹配就返回 404 而不是 403,避免泄露任务是否存在。另外,任务结果里如果包含敏感数据,不要把完整内容塞进查询响应,只返回一个下载地址,并且这个地址也要带鉴权和有效期。
8.6 生产环境的取舍
内存 Map 存状态只适合演示和单机工具,生产环境一律换成共享存储。Redis 适合任务量中等、需要快速读写的场景,但要注意设置合理的 TTL 和内存上限;数据库适合需要长期保留和审计的场景,但要注意索引设计和归档策略。线程池一定要用有界队列配合明确的拒绝策略,无界队列在高峰期会变成内存炸弹。
9. 下一步可以往哪走
如果你已经把最小版本跑通了,接下来最值得做的一件事,是把“任务状态”从内存搬到 Redis,并且把轮询改造成“轮询 + 回调”双通道。这一步做完,你的方案就从“能跑”变成了“能上生产”,因为多实例部署和回调丢失这两个最现实的问题被同时解决了。
再往后,可以思考任务的可取消性。现在提交出去的任务是没法中途停掉的,但如果用户关掉页面、或者业务发现参数填错了,能取消就很有价值。实现上需要在执行逻辑里定期检查一个取消标记,把大任务拆成可中断的小段,每段之间检查一次。这个改造不难,但要提前设计,事后补会很痛苦。
最后一个方向是观测。你可以先不加任何监控系统,只做一件事:每天统计一次“处于 RUNNING 状态超过 10 分钟的任务数量”。这个数字如果长期是 0,说明你的状态机是健康的;一旦开始非零并且持续上涨,那就意味着有任务在静默失败,这时候再去补扫描和告警也不迟。用最小的成本拿到最关键的信号,比一上来就搭一整套可观测平台要务实得多。