Node / Java 后端
目录 · 16 题
题目与答案
1.Node 事件循环的阶段和微任务时机 🔴
展开答案
一轮 tick 依次经过六个阶段,业务上关心前四个和最后一个:
- timers —— 到期的
setTimeout/setInterval - pending callbacks —— 上一轮延后的系统回调
- poll —— 取 I/O 事件并执行回调,大部分业务代码在这里
- check ——
setImmediate - close callbacks ——
close事件
微任务的插入时机是关键:每个宏任务执行完之后(不是每个阶段结束后)就清空微任务队列。清空顺序是 process.nextTick 队列先于 Promise 队列,且 nextTick 队列会被完全排干才轮到 Promise。
所以 setImmediate 和 setTimeout(fn, 0) 的差别是:setImmediate 在 check 阶段(本轮 poll 之后立即执行),setTimeout(0) 要等下一轮 timers。在 I/O 回调里注册两者,setImmediate 先执行;在主模块顶层注册,顺序不确定(取决于启动耗时是否跨过了 1 毫秒的定时器精度)。
追问「process.nextTick 里递归调用自己会怎样」:事件循环彻底饿死。nextTick 队列会被完全排干才继续,递归注册意味着它永远排不干——I/O 回调、定时器全部得不到执行,进程表现为「活着但不响应」。Promise.then 递归也有类似问题但稍轻(Promise 队列同样在微任务阶段清空)。生产上要用 setImmediate 做需要让出的递归,它会把控制权交回事件循环。这是「知道有 nextTick」和「知道 nextTick 危险」的分界。
2.Stream 背压是什么,转发 LLM 流不做背压会怎样 🔴
展开答案
背压是下游消费不过来时反向通知上游减速的机制。writable.write() 返回 false 就是在说「缓冲区满了,停一下」,等 drain 事件再继续。
不做背压的后果:数据在进程内存里堆积。上游快、下游慢的差额全变成 RSS 增长,最后 OOM。
这在 LLM 网关场景是真实且高频的问题:上游模型 API 吐得很快,下游是用户的浏览器——移动网络、弱网、或者用户干脆切后台了。这时候如果代码是 for await (chunk of upstream) res.write(chunk) 而不检查返回值,几百个并发连接就能把内存打满。
正确写法是用 pipeline,它自带背压和错误传播:
import { pipeline } from 'node:stream/promises'
await pipeline(upstreamStream, transformSSE, res)
手写的话必须处理 write() 返回 false:
if (!res.write(chunk)) {
await once(res, 'drain')
}
追问「LLM 场景里,下游慢就让上游等,这样对吗」:不完全对,这里有个 LLM 特有的取舍。你没法真正让模型 API 减速——它按自己的速度生成,你不读它的数据也只是堆在你和它之间的 socket 缓冲里。所以实际策略是:优先保证读完上游并计费/落库,对下游做有上限的缓冲,缓冲超限就判定这个客户端跟不上,主动断开并把完整结果存起来让它重连拉取。因为上游的 token 已经花钱了,丢掉是纯损失,而下游连接是可以重建的。这个权衡答出来会明显区别于纯理论回答。
3.Node 遇到 CPU 密集任务怎么办 🟡
展开答案
前提:Node 的 I/O 是异步的,但JS 执行是单线程的。一个跑 500 毫秒的同步计算会让这个进程上所有请求都等 500 毫秒。
三个层次的解法:
worker_threads—— 进程内多线程,适合单个任务耗时明显(几十毫秒以上)且需要共享内存的场景。要配合 worker 池复用,别每次新建(创建一个 worker 有几十毫秒开销)cluster/ 多进程 —— 按 CPU 核数起多个进程,主要解决吞吐(多核利用率),不解决单个请求的长阻塞- 拆出去 —— 真正重的计算(视频转码、大批量数据处理)交给专门的服务或任务队列,Node 只做调度
AI 应用里的典型 CPU 热点:大 JSON 的序列化/反序列化、token 计数(tiktoken 之类)、本地 embedding 推理、大文档解析。前两个容易被忽略——一个几 MB 的 JSON parse 就能阻塞几十毫秒。
追问「怎么知道是不是被 CPU 阻塞了,而不是下游慢」:监控事件循环延迟(event loop lag)。用 perf_hooks 的 monitorEventLoopDelay 或者简单的 setInterval 打点测偏差。下游慢时事件循环是空闲的(lag 正常),CPU 阻塞时 lag 会飙升。这两种「慢」的处理方向完全相反,混淆了会朝错误方向优化。这个指标是 Node 服务必备监控项,说得出来很能证明真运维过。
4.Node 内存泄漏怎么定位 🟡
展开答案
先确认是不是真泄漏:观察 RSS 和 heapUsed 在多个 GC 周期后的趋势。锯齿状但基线不涨是正常的,基线持续单调上涨才是泄漏。
定位流程:
- 抓堆快照 ——
node --inspect后用 Chrome DevTools,或者代码里用v8.writeHeapSnapshot() - 取两个时间点的快照做 diff —— 看哪类对象的数量只增不减。这一步是核心,单个快照很难看出问题
- 看 retainer 链 —— 找到是谁在持有这些对象不放
常见来源,按出现频率:
- 全局 Map/数组当缓存但没有淘汰 —— 最常见。任何无界的
Map都是泄漏候选 - 事件监听没移除 —— 特别是给长生命周期对象(连接池、全局 emitter)反复
on而不off,MaxListenersExceededWarning是个信号 - 闭包意外持有大对象 —— 回调里引用了整个请求上下文
- 未清理的定时器
- 未关闭的流和连接 —— AI 场景里的中断请求如果没清理,SSE 连接和上游流都会挂着
追问「生产环境不敢开 --inspect,怎么办」:三条路。一是用信号触发堆快照(监听 SIGUSR2 写快照到磁盘,用完即走,不长期开调试端口);二是先用便宜的指标缩小范围——按路由/任务类型打点内存增量,看是哪条链路涨;三是在预发环境用相同流量回放复现。不要在生产长期开 inspect 端口,它等于一个远程代码执行入口。这个安全意识本身也是加分项。
5.未捕获异常和未处理 Promise 该怎么处理 🔴
展开答案
两个事件,处理原则不同但结论相似:
uncaughtException—— 同步抛出没人接。此时进程状态已不可信,可能有资源没释放、状态改了一半unhandledRejection—— Promise reject 没有 catch。Node 15 之后默认直接终止进程
正确处理是「记录 + 优雅退出 + 由外部重启」:
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaughtException')
server.close(() => process.exit(1)) // 停止接新请求
setTimeout(() => process.exit(1), 5000).unref() // 兜底强退
})
不要 catch 住然后继续跑。这是最常见的错误做法——它把一次明确的崩溃变成了长期的、状态不一致的服务,后面产生的错误会更难查。
配套要有的东西:进程管理器(systemd / PM2 / K8s)负责重启、健康检查探针、以及优雅关闭(收到 SIGTERM 后停止接新请求、等在途请求完成、关闭连接池)。
追问「优雅关闭时,正在生成的 LLM 流式响应怎么处理」:这是 AI 服务特有的问题,因为一个请求可能持续几十秒甚至几分钟,普通的 30 秒 drain 窗口不够。做法:收到 SIGTERM 后立刻从负载均衡摘掉(停止接新请求),在途的流式请求给一个更长的宽限期(按 P99 生成时长设,比如 120 秒),同时给客户端发一个「服务即将重启,请重连」的事件让它主动收尾。超过宽限期还没结束的,把已生成内容落库后再断,让用户重连能拿到结果——不要静默切断,那等于用户付了钱什么也没得到。
6.Node 做 LLM 网关,超时和连接池怎么配 🟡
展开答案
LLM 网关的参数和普通 API 网关差别很大,照抄普通配置一定出问题。
超时要分层,不能只设一个总超时:
| 超时项 | 建议 | 理由 |
|---|---|---|
| 连接超时 | 3~5 秒 | 连不上要快速失败 |
| TTFT 超时 | 15~30 秒 | 首 token 迟迟不来说明上游有问题 |
| 空闲超时(流间隔) | 30~60 秒 | 两个 chunk 之间超过这个时长判定为卡死 |
| 总超时 | 按最长任务定,5~10 分钟 | Agent 场景很长 |
关键是空闲超时而不是总超时才是流式场景的主要保护。总超时设得很长的话,一条卡死的连接会占住资源好几分钟;用空闲超时能在 30 秒内识别出来。
连接池:
- Node 18+ 用
undici,配Agent({ connections: N, pipelining: 0 }) - 长连接要开 keep-alive,LLM 请求的 TLS 握手成本相对可观
- 连接数按「并发请求数」而不是 CPU 核数来定——这些请求几乎全是等待,不吃 CPU
- 不要用 pipelining,流式响应下会乱序
还要有的:并发信号量(防止把上游打到限流)、按 key 的配额、以及上游错误的分类重试(429/5xx 退避重试,4xx 不重试)。
追问「上游限流了,是让请求排队还是直接拒绝」:两者都要,看队列长度。短队列(比如等待预估在用户可接受范围内)就排队并告知用户排队位置;队列超限就直接拒绝并返回明确的重试建议,不要让请求在队列里等到超时——那是最差的结果(用户等了很久最后还是失败,而且期间占着连接)。另外要按优先级分队列,付费用户和后台批量任务不该抢同一个槽位。
Java 与 JVM
7.JVM 内存分区和 GC 大致怎么工作 🟡
展开答案
内存分区(JDK 8 之后):
- 堆 —— 对象实例。分年轻代(Eden + 两个 Survivor)和老年代
- 元空间 —— 类元数据,在本地内存,替代了永久代
- 虚拟机栈 —— 每线程一个,存栈帧和局部变量
- 本地方法栈、程序计数器 —— 每线程
- 直接内存 —— NIO 的
DirectByteBuffer,不受堆大小限制但受MaxDirectMemorySize限制
GC 的基本思路是分代收集:绝大多数对象朝生夕死,所以年轻代用复制算法(Minor GC,快且频繁),存活下来的晋升到老年代,老年代用标记整理(Major/Full GC,慢)。
现代收集器只要知道选型即可:G1 是默认(分区、可控停顿目标)、ZGC 适合大堆低延迟(停顿不随堆大小增长)、Serial/Parallel 适合小服务或吞吐优先。
调优的第一步永远是看 GC 日志(-Xlog:gc*),而不是凭感觉调参数。
追问「Full GC 频繁怎么排查」:先分清是「堆确实不够」还是「有对象该死没死」。看 GC 日志里 Full GC 之后的老年代占用:回收后仍然居高不下说明有大量存活对象(内存泄漏或堆确实小了),回收后降得很多说明是晋升太快(年轻代太小或有大对象直接进老年代)。前者要 dump 堆分析对象;后者调整年轻代比例或找出大对象来源。不要一上来就加 -Xmx——如果是泄漏,加内存只是让崩溃来得晚一点。
8.线程池参数怎么定,拒绝策略怎么选 🔴
展开答案
核心线程数按任务类型算:
- CPU 密集 ——
核数 + 1。多了只是增加上下文切换 - IO 密集 ——
核数 × (1 + 等待时间/计算时间)。等待占比 90% 的话就是核数的 10 倍左右
但这个公式只给起点,实际值要压测。
队列选择比线程数更容易出事:
LinkedBlockingQueue不传容量是无界的,队列永远不满 → 最大线程数形同虚设 → 请求无限堆积 → OOM。这是最经典的线程池事故ArrayBlockingQueue有界,会触发扩容到 max 和拒绝策略,是生产上该用的SynchronousQueue不存储,直接交给线程,配合大 max 用于短任务
拒绝策略四种,选择依据是这个任务丢了会怎样:
| 策略 | 行为 | 适用 |
|---|---|---|
| AbortPolicy | 抛异常 | 默认,调用方需要知道失败 |
| CallerRunsPolicy | 调用方线程执行 | 想要天然的反压(提交速度自动被拖慢) |
| DiscardPolicy | 静默丢弃 | 几乎不该用,丢了都不知道 |
| DiscardOldestPolicy | 丢最老的 | 只关心最新数据(实时指标) |
追问「为什么阿里的规范不建议用 Executors 的工厂方法」:因为它们的默认值有坑。newFixedThreadPool 和 newSingleThreadExecutor 用无界队列(堆积到 OOM),newCachedThreadPool 的最大线程数是 Integer.MAX_VALUE(能创建到系统崩)。规范要求手写 new ThreadPoolExecutor(...) 就是为了强制你显式想清楚队列容量和拒绝策略——这两个值恰恰是决定服务在过载时是优雅降级还是雪崩的关键。
9.synchronized 和 ReentrantLock 怎么选 ⚪
展开答案
synchronized 是关键字,JVM 层面实现,会自动释放(异常退出也释放)。ReentrantLock 是 API,要手动 unlock,必须放 finally。
选择依据看你是否需要这四种能力,需要就用 ReentrantLock,不需要就用 synchronized:
- 可中断等待 ——
lockInterruptibly(),等锁时能响应中断 - 超时获取 ——
tryLock(timeout),拿不到就走降级路径而不是死等 - 公平锁 —— 按等待顺序给锁(有性能代价,慎用)
- 多个条件变量 —— 一把锁配多个
Condition,比wait/notify的单一等待集精细
性能上现在差别不大——synchronized 从 JDK 6 起有偏向锁、轻量级锁、自旋等一系列优化,无竞争或低竞争时开销很低。所以默认用 synchronized,代码更简洁、不会漏 unlock。
lock.lock();
try { /* ... */ } finally { lock.unlock(); } // finally 是硬要求
追问「用了 tryLock 超时拿不到锁,业务该怎么办」:这题在考你是不是只会用 API 不想业务。tryLock 的价值就在于逼你设计降级路径:返回「系统繁忙请重试」、走无锁的只读路径、或者把任务丢进队列异步处理。如果拿不到锁之后你只能再死等,那用 tryLock 就没有意义。超时机制的价值是把不确定的等待变成确定的失败,前提是失败有处理方案。
10.@Transactional 什么情况下会失效 🔴
展开答案
失效的根本原因几乎都是一个:Spring 的事务靠代理实现,没走到代理就没有事务。
具体场景:
- 自调用 —— 同一个类里
this.methodB()直接调用带@Transactional的方法。this是原始对象不是代理对象,注解完全不生效。这是最高频的一个 - 方法不是 public —— 默认只对 public 方法生效
- 异常类型不匹配 —— 默认只对
RuntimeException和Error回滚,抛受检异常(Exception)不回滚。要用rollbackFor = Exception.class - 异常被自己吞了 —— 方法里 try-catch 住没有再抛出,事务管理器不知道出错了
- 类没被 Spring 管理 —— 自己
new出来的对象 - 传播行为选错 —— 用了
NOT_SUPPORTED或NEVER - 数据库引擎不支持 —— MyISAM 不支持事务
- 多线程 —— 事务绑定在
ThreadLocal上,子线程里的操作不在同一个事务里
自调用的修法:注入自己(@Lazy 防循环依赖)、用 AopContext.currentProxy()、或者把方法拆到另一个 Bean——拆到另一个 Bean 是最干净的做法,因为它同时改善了职责划分。
传播行为常用三个:REQUIRED(默认,有就加入没就新建)、REQUIRES_NEW(总是新建独立事务,用于「主流程失败也要保留的记录」比如操作日志)、NESTED(保存点,可部分回滚)。
追问「一个方法里既要写业务数据又要调外部 LLM API,事务该怎么划」:不能把外部调用包在事务里——LLM 调用可能几十秒,事务持有数据库连接和锁那么久会拖垮连接池并造成锁等待。正确做法是把事务边界缩到只包住数据库操作:先在短事务里落一条「处理中」记录并提交,然后在事务外调 LLM,拿到结果再开一个短事务更新状态。这样任何一步失败都有明确的中间态可查、可重试。长事务包外部调用是 AI 应用后端最典型的架构错误,答出来会明显加分。
11.ThreadLocal 为什么会内存泄漏 🟡
展开答案
结构上:每个 Thread 有一个 ThreadLocalMap,它的 Entry 是 WeakReference<ThreadLocal> 作为 key,value 是强引用。
所以当外部对 ThreadLocal 对象的强引用消失后:
- key 因为是弱引用被 GC 回收 → 变成
null - value 仍被 Entry 强引用着,回收不掉
- 结果是一个 key 为 null 但 value 还在的 Entry,永远无法通过正常途径访问,也不会被释放
在线程池场景这个问题被放大:线程是复用的、长期存活的,它身上的 ThreadLocalMap 也就长期存活,泄漏的 value 一直累积。请求级别的上下文(用户信息、trace id)如果不清理,跑一段时间就能积出可观的内存。
解法只有一条:用完必须 remove(),放在 finally 里。
try {
ctx.set(userInfo);
doWork();
} finally {
ctx.remove(); // 硬要求,不是可选优化
}
框架里通常用拦截器/过滤器在请求结束时统一 remove。
追问「子线程拿不到父线程的 ThreadLocal,怎么传递」:InheritableThreadLocal 能在创建子线程时复制父线程的值。但它在线程池里不work——池里的线程是提前创建好的、被复用的,创建时刻的父线程和实际提交任务的线程不是一个。线程池场景要用 TransmittableThreadLocal(阿里 TTL),它在任务提交时捕获上下文、在执行时还原。这个区别很重要,因为 trace id 在异步链路里丢失基本都是这个原因。
12.CompletableFuture 怎么编排多个 LLM 调用 🟡
展开答案
三种典型编排:
并行独立调用 —— 多路检索、多模型投票:
var f1 = CompletableFuture.supplyAsync(() -> retrieve(q), pool);
var f2 = CompletableFuture.supplyAsync(() -> searchWeb(q), pool);
CompletableFuture.allOf(f1, f2).join();
var merged = merge(f1.join(), f2.join());
串行依赖 —— 改写 → 检索 → 生成,用 thenCompose(返回 Future 时用它,用 thenApply 会套成 Future<Future<T>>)。
竞速取第一个 —— anyOf,用于多个供应商谁先返回用谁。
四个必须注意的点:
- 一定要指定线程池。默认用
ForkJoinPool.commonPool(),它的并行度是核数-1,而且是全 JVM 共享的——把 LLM 这种长等待任务放进去会把公共池占满,连带影响其他并行流 - 超时要显式 ——
orTimeout(30, SECONDS)或completeOnTimeout(fallback, ...)。没有超时的编排在上游卡住时会一直挂着 - 异常处理 ——
exceptionally/handle。allOf里任一失败会导致整体失败,需要部分成功语义的话要在每个子任务里各自exceptionally兜住 join()会阻塞 —— 在 Web 请求线程里 join 就等于同步调用,只是把等待挪了个位置
追问「三路并行检索,其中一路超时了,是整体失败还是用剩下两路的结果」:应该降级用剩下两路,这是 RAG 场景的正确取舍——少一路材料可能答得差一点,但整体失败是零分。实现上给每个子任务单独包 completeOnTimeout(emptyResult, ...),让它超时后返回空结果而不是异常,这样 allOf 仍然成功。同时要记录降级事件,因为「经常降级」本身是需要修的问题,不能静默地让质量长期打折。
服务工程与并发
13.幂等性怎么设计 🔴
展开答案
幂等就是同一个请求执行多次,结果和执行一次相同。需要它是因为重试是必然的——网络超时、客户端重复点击、消息队列的至少一次投递。
关键是幂等键从哪来:
- 客户端生成并传入(
Idempotency-Keyheader,UUID),服务端负责去重。这是最通用的做法 - 或者由业务字段派生(订单号、
用户ID + 业务类型 + 时间窗)
实现三种,按强度:
- 唯一索引 —— 最可靠。幂等键做唯一约束,重复插入直接冲突,用数据库保证。能用这个就用这个
- 状态机 —— 只允许特定状态流转,
update ... where status = '待支付'靠影响行数判断是否是重复操作 - Redis 记录键 —— 快,但要处理「先写 Redis 后写库失败」的不一致,只适合弱一致场景
必须处理的细节:并发同键请求。两个请求同时到达,都查「不存在」然后都执行了。唯一索引方案天然免疫(第二个插入失败),其他方案要加锁或用 SETNX。
追问「LLM 调用的幂等怎么做,同样的输入输出还不一样」:这题是 AI 场景的重点。分两层看:
- 副作用要幂等 —— 扣费、发消息、写数据库这些必须靠幂等键去重,跟 LLM 输出无关
- 生成结果本身不追求幂等,但要可复现 —— 做法是把「请求 → 结果」缓存起来(键包含完整输入 + 模型 + prompt 版本 + 参数)。重试时命中缓存就返回同一个结果,既省钱又保证了用户看到的一致性
关键判断是区分「重试」和「重新生成」:网络失败导致的重试应该返回原结果(走缓存),用户主动点「换个答案」才该真的重新调用。这两者用不同的幂等键处理——前者用请求 ID,后者带一个递增的 attempt 序号。
14.限流算法怎么选,LLM 的双限流怎么做 🔴
展开答案
四种算法,实践中主要用后两种:
| 算法 | 特点 | 问题 |
|---|---|---|
| 固定窗口 | 简单计数 | 窗口边界能打进两倍流量 |
| 滑动窗口 | 精确 | 要存每个请求的时间戳,内存开销大 |
| 漏桶 | 匀速流出 | 不允许突发 |
| 令牌桶 | 匀速发令牌,允许突发 | 需要设桶容量 |
默认选令牌桶,因为真实流量本来就是有突发的,桶容量给了一个可控的突发额度。要严格平滑输出(比如保护一个脆弱的下游)才用漏桶。
LLM 的特殊性在于要同时限两个维度:
- RPM(请求数/分钟)—— 常规限流
- TPM(token 数/分钟)—— 这个才是主要瓶颈,因为一个长上下文请求相当于几十个短请求
TPM 的难点是发请求时还不知道会消耗多少 token(输出长度未知)。做法是:
- 预估:
输入 token(可精确算)+ max_tokens(上限)先扣 - 请求完成后按实际用量返还差额
- 两个令牌桶都要有令牌才放行,缺一个就排队
if (rpmBucket.tryAcquire(1) && tpmBucket.tryAcquire(estimatedTokens)) {
// 放行,完成后 tpmBucket.release(estimated - actual)
}
追问「多实例部署时,令牌桶怎么共享」:本地桶会导致实际限额是 单实例限额 × 实例数,超过上游配额就会被上游 429。两种解法:一是分配式——把总配额按实例数均分(简单但资源利用不均,某个实例闲着的额度别人用不了);二是集中式——Redis + Lua 脚本原子扣减令牌(准确但每次限流判断多一次网络往返)。实践上常用混合:本地桶拿一批令牌(比如够用 1 秒的量)缓存着用,用完再去 Redis 批量领取,这样把网络开销摊薄到几十分之一,同时全局配额基本准确。
15.分布式锁的正确性边界在哪 🟡
展开答案
Redis 实现的基本要求(缺一个就有 bug):
- 原子获取 ——
SET key value NX PX ttl,不能拆成两条命令 - 必须有 TTL —— 否则持有者崩溃后死锁
- value 是唯一标识 —— 释放时用 Lua 脚本比对再删,防止删掉别人的锁
- 可重入和续期 —— 业务执行超过 TTL 时要有看门狗自动续期(Redisson 提供)
但正确性边界必须说清楚,这是这题的重点:
Redis 分布式锁在下面这些情况下不保证互斥:
- 主从架构下主节点挂了,锁还没同步到从节点,故障转移后新主上没有这把锁 → 两个客户端同时持有
- 持有者发生长时间 GC 停顿或网络分区,TTL 到期锁被别人拿走,但它自己不知道,醒来后继续操作
所以结论是:分布式锁只能用来做效率优化(避免重复劳动),不能用来保证正确性(防止数据错乱)。真正的正确性必须靠底层存储的原子性——数据库唯一索引、乐观锁版本号、update ... where 的条件更新。
需要更强保证的话有 fencing token 方案:锁附带一个单调递增的序号,下游写入时检查序号,拒绝比已见过的更小的序号。这需要下游支持,成本高,通常只在关键路径上做。
追问「那 Redlock 呢,多节点投票不是更安全吗」:Redlock 用多个独立 Redis 节点,多数派成功才算获锁。它降低了单点故障的影响,但学界对它有明确的批评:它依赖时钟假设——如果某个节点的时钟跳变(NTP 调整、虚拟机挂起恢复),锁的过期判断就会出错,互斥性仍然可能被打破。所以 Redlock 提高了可用性,但没有改变「不能依赖它做正确性」这个结论。知道这个批评的存在比会背 Redlock 步骤更有价值。
16.长耗时请求的架构怎么选 🔴
展开答案
AI 应用最重要的一个架构决策。 一个 Agent 任务可能跑几分钟到几十分钟,同步等着必然出问题。
三种模式,按耗时选:
| 耗时 | 模式 | 说明 |
|---|---|---|
| < 30 秒 | 同步 + 流式 | HTTP 保持连接,SSE 边算边推。用户体验最好 |
| 30 秒 ~ 几分钟 | 异步任务 + 推送 | 立即返回 task_id,完成后 WebSocket/SSE 通知或轮询 |
| > 几分钟 | 任务队列 + 断点续跑 | 持久化任务状态,支持中断恢复、跨进程重启 |
同步硬等的问题是叠加的:网关默认超时通常 30~60 秒(Nginx、ALB、Cloudflare 都是这个量级)、移动网络容易断、服务重启会丢掉所有在途请求、而且一个请求占住一个连接和线程。
异步方案的关键设计点:
- 任务状态持久化 —— 存数据库不是内存,进程重启后任务还在
- 进度可查 —— 状态、当前步骤、已用时长、部分结果
- 结果有保留期 —— 客户端可能过一会儿才来取
- 可取消 —— 取消要级联到正在执行的上游调用
- 可重试且幂等 —— 重试不能产生重复副作用
- 超时兜底 —— 任务本身要有最大执行时长,卡住的任务要能被判死
推送优于轮询,但轮询要作为兜底——推送连接可能建不起来(企业代理、弱网),这时候降级到轮询能保证功能可用。
追问「异步之后,用户体验反而变差了(要等通知),怎么平衡」:用混合模式:请求进来先按同步流式处理,如果在一个阈值(比如 20 秒)内没完成,就转成异步——返回 task_id 并告知「任务较长,完成后通知你」,同时前端保留一个进度视图。这样短任务保持最好的即时体验,长任务不占连接。实现上要求执行层从一开始就把状态写进持久化存储(不管最终走哪条路),这样切换时不丢上下文。这个设计的关键是「执行和交付解耦」:任务怎么跑和结果怎么给用户是两件事,一开始就分开设计,后面才能灵活切换。