node-java · 面试题库

Node / Java 后端

目录 · 16 题

#问题标记
1Node 事件循环的阶段和微任务时机🔴
2Stream 背压是什么,转发 LLM 流不做背压会怎样🔴
3Node 遇到 CPU 密集任务怎么办🟡
4Node 内存泄漏怎么定位🟡
5未捕获异常和未处理 Promise 该怎么处理🔴
6Node 做 LLM 网关,超时和连接池怎么配🟡
7JVM 内存分区和 GC 大致怎么工作🟡
8线程池参数怎么定,拒绝策略怎么选🔴
9synchronized 和 ReentrantLock 怎么选
10@Transactional 什么情况下会失效🔴
11ThreadLocal 为什么会内存泄漏🟡
12CompletableFuture 怎么编排多个 LLM 调用🟡
13幂等性怎么设计🔴
14限流算法怎么选,LLM 的双限流怎么做🔴
15分布式锁的正确性边界在哪🟡
16长耗时请求的架构怎么选🔴

题目与答案

1.Node 事件循环的阶段和微任务时机 🔴

展开答案

一轮 tick 依次经过六个阶段,业务上关心前四个和最后一个:

  1. timers —— 到期的 setTimeout/setInterval
  2. pending callbacks —— 上一轮延后的系统回调
  3. poll —— 取 I/O 事件并执行回调,大部分业务代码在这里
  4. check —— setImmediate
  5. close callbacks —— close 事件

微任务的插入时机是关键:每个宏任务执行完之后(不是每个阶段结束后)就清空微任务队列。清空顺序是 process.nextTick 队列先于 Promise 队列,且 nextTick 队列会被完全排干才轮到 Promise。

所以 setImmediatesetTimeout(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 毫秒。

三个层次的解法:

  1. worker_threads —— 进程内多线程,适合单个任务耗时明显(几十毫秒以上)且需要共享内存的场景。要配合 worker 池复用,别每次新建(创建一个 worker 有几十毫秒开销)
  2. cluster / 多进程 —— 按 CPU 核数起多个进程,主要解决吞吐(多核利用率),不解决单个请求的长阻塞
  3. 拆出去 —— 真正重的计算(视频转码、大批量数据处理)交给专门的服务或任务队列,Node 只做调度

AI 应用里的典型 CPU 热点:大 JSON 的序列化/反序列化、token 计数(tiktoken 之类)、本地 embedding 推理、大文档解析。前两个容易被忽略——一个几 MB 的 JSON parse 就能阻塞几十毫秒。

追问「怎么知道是不是被 CPU 阻塞了,而不是下游慢」:监控事件循环延迟(event loop lag)。用 perf_hooksmonitorEventLoopDelay 或者简单的 setInterval 打点测偏差。下游慢时事件循环是空闲的(lag 正常),CPU 阻塞时 lag 会飙升。这两种「慢」的处理方向完全相反,混淆了会朝错误方向优化。这个指标是 Node 服务必备监控项,说得出来很能证明真运维过。

4.Node 内存泄漏怎么定位 🟡

展开答案

先确认是不是真泄漏:观察 RSS 和 heapUsed 在多个 GC 周期后的趋势。锯齿状但基线不涨是正常的,基线持续单调上涨才是泄漏。

定位流程:

  1. 抓堆快照 —— node --inspect 后用 Chrome DevTools,或者代码里用 v8.writeHeapSnapshot()
  2. 取两个时间点的快照做 diff —— 看哪类对象的数量只增不减。这一步是核心,单个快照很难看出问题
  3. 看 retainer 链 —— 找到是谁在持有这些对象不放

常见来源,按出现频率:

  • 全局 Map/数组当缓存但没有淘汰 —— 最常见。任何无界的 Map 都是泄漏候选
  • 事件监听没移除 —— 特别是给长生命周期对象(连接池、全局 emitter)反复 on 而不 offMaxListenersExceededWarning 是个信号
  • 闭包意外持有大对象 —— 回调里引用了整个请求上下文
  • 未清理的定时器
  • 未关闭的流和连接 —— 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 的工厂方法」:因为它们的默认值有坑。newFixedThreadPoolnewSingleThreadExecutor无界队列(堆积到 OOM),newCachedThreadPool 的最大线程数是 Integer.MAX_VALUE(能创建到系统崩)。规范要求手写 new ThreadPoolExecutor(...) 就是为了强制你显式想清楚队列容量和拒绝策略——这两个值恰恰是决定服务在过载时是优雅降级还是雪崩的关键。

9.synchronized 和 ReentrantLock 怎么选

展开答案

synchronized 是关键字,JVM 层面实现,会自动释放(异常退出也释放)。ReentrantLock 是 API,要手动 unlock,必须放 finally

选择依据看你是否需要这四种能力,需要就用 ReentrantLock,不需要就用 synchronized

  1. 可中断等待 —— lockInterruptibly(),等锁时能响应中断
  2. 超时获取 —— tryLock(timeout),拿不到就走降级路径而不是死等
  3. 公平锁 —— 按等待顺序给锁(有性能代价,慎用)
  4. 多个条件变量 —— 一把锁配多个 Condition,比 wait/notify 的单一等待集精细

性能上现在差别不大——synchronized 从 JDK 6 起有偏向锁、轻量级锁、自旋等一系列优化,无竞争或低竞争时开销很低。所以默认用 synchronized,代码更简洁、不会漏 unlock。

lock.lock();
try { /* ... */ } finally { lock.unlock(); }   // finally 是硬要求

追问「用了 tryLock 超时拿不到锁,业务该怎么办」:这题在考你是不是只会用 API 不想业务。tryLock 的价值就在于逼你设计降级路径:返回「系统繁忙请重试」、走无锁的只读路径、或者把任务丢进队列异步处理。如果拿不到锁之后你只能再死等,那用 tryLock 就没有意义。超时机制的价值是把不确定的等待变成确定的失败,前提是失败有处理方案。

10.@Transactional 什么情况下会失效 🔴

展开答案

失效的根本原因几乎都是一个:Spring 的事务靠代理实现,没走到代理就没有事务

具体场景:

  1. 自调用 —— 同一个类里 this.methodB() 直接调用带 @Transactional 的方法。this 是原始对象不是代理对象,注解完全不生效。这是最高频的一个
  2. 方法不是 public —— 默认只对 public 方法生效
  3. 异常类型不匹配 —— 默认只对 RuntimeExceptionError 回滚,抛受检异常(Exception)不回滚。要用 rollbackFor = Exception.class
  4. 异常被自己吞了 —— 方法里 try-catch 住没有再抛出,事务管理器不知道出错了
  5. 类没被 Spring 管理 —— 自己 new 出来的对象
  6. 传播行为选错 —— 用了 NOT_SUPPORTEDNEVER
  7. 数据库引擎不支持 —— MyISAM 不支持事务
  8. 多线程 —— 事务绑定在 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,用于多个供应商谁先返回用谁。

四个必须注意的点:

  1. 一定要指定线程池。默认用 ForkJoinPool.commonPool(),它的并行度是 核数-1,而且是全 JVM 共享的——把 LLM 这种长等待任务放进去会把公共池占满,连带影响其他并行流
  2. 超时要显式 —— orTimeout(30, SECONDS)completeOnTimeout(fallback, ...)。没有超时的编排在上游卡住时会一直挂着
  3. 异常处理 —— exceptionally / handleallOf 里任一失败会导致整体失败,需要部分成功语义的话要在每个子任务里各自 exceptionally 兜住
  4. join() 会阻塞 —— 在 Web 请求线程里 join 就等于同步调用,只是把等待挪了个位置

追问「三路并行检索,其中一路超时了,是整体失败还是用剩下两路的结果」:应该降级用剩下两路,这是 RAG 场景的正确取舍——少一路材料可能答得差一点,但整体失败是零分。实现上给每个子任务单独包 completeOnTimeout(emptyResult, ...),让它超时后返回空结果而不是异常,这样 allOf 仍然成功。同时要记录降级事件,因为「经常降级」本身是需要修的问题,不能静默地让质量长期打折。


服务工程与并发

13.幂等性怎么设计 🔴

展开答案

幂等就是同一个请求执行多次,结果和执行一次相同。需要它是因为重试是必然的——网络超时、客户端重复点击、消息队列的至少一次投递。

关键是幂等键从哪来:

  • 客户端生成并传入(Idempotency-Key header,UUID),服务端负责去重。这是最通用的做法
  • 或者由业务字段派生(订单号、用户ID + 业务类型 + 时间窗

实现三种,按强度:

  1. 唯一索引 —— 最可靠。幂等键做唯一约束,重复插入直接冲突,用数据库保证。能用这个就用这个
  2. 状态机 —— 只允许特定状态流转,update ... where status = '待支付' 靠影响行数判断是否是重复操作
  3. Redis 记录键 —— 快,但要处理「先写 Redis 后写库失败」的不一致,只适合弱一致场景

必须处理的细节:并发同键请求。两个请求同时到达,都查「不存在」然后都执行了。唯一索引方案天然免疫(第二个插入失败),其他方案要加锁或用 SETNX

追问「LLM 调用的幂等怎么做,同样的输入输出还不一样」:这题是 AI 场景的重点。分两层看:

  • 副作用要幂等 —— 扣费、发消息、写数据库这些必须靠幂等键去重,跟 LLM 输出无关
  • 生成结果本身不追求幂等,但要可复现 —— 做法是把「请求 → 结果」缓存起来(键包含完整输入 + 模型 + prompt 版本 + 参数)。重试时命中缓存就返回同一个结果,既省钱又保证了用户看到的一致性

关键判断是区分「重试」和「重新生成」:网络失败导致的重试应该返回原结果(走缓存),用户主动点「换个答案」才该真的重新调用。这两者用不同的幂等键处理——前者用请求 ID,后者带一个递增的 attempt 序号。

14.限流算法怎么选,LLM 的双限流怎么做 🔴

展开答案

四种算法,实践中主要用后两种:

算法 特点 问题
固定窗口 简单计数 窗口边界能打进两倍流量
滑动窗口 精确 要存每个请求的时间戳,内存开销大
漏桶 匀速流出 不允许突发
令牌桶 匀速发令牌,允许突发 需要设桶容量

默认选令牌桶,因为真实流量本来就是有突发的,桶容量给了一个可控的突发额度。要严格平滑输出(比如保护一个脆弱的下游)才用漏桶。

LLM 的特殊性在于要同时限两个维度:

  • RPM(请求数/分钟)—— 常规限流
  • TPM(token 数/分钟)—— 这个才是主要瓶颈,因为一个长上下文请求相当于几十个短请求

TPM 的难点是发请求时还不知道会消耗多少 token(输出长度未知)。做法是:

  1. 预估:输入 token(可精确算)+ max_tokens(上限) 先扣
  2. 请求完成后按实际用量返还差额
  3. 两个令牌桶都要有令牌才放行,缺一个就排队
if (rpmBucket.tryAcquire(1) && tpmBucket.tryAcquire(estimatedTokens)) {
    // 放行,完成后 tpmBucket.release(estimated - actual)
}

追问「多实例部署时,令牌桶怎么共享」:本地桶会导致实际限额是 单实例限额 × 实例数,超过上游配额就会被上游 429。两种解法:一是分配式——把总配额按实例数均分(简单但资源利用不均,某个实例闲着的额度别人用不了);二是集中式——Redis + Lua 脚本原子扣减令牌(准确但每次限流判断多一次网络往返)。实践上常用混合:本地桶拿一批令牌(比如够用 1 秒的量)缓存着用,用完再去 Redis 批量领取,这样把网络开销摊薄到几十分之一,同时全局配额基本准确。

15.分布式锁的正确性边界在哪 🟡

展开答案

Redis 实现的基本要求(缺一个就有 bug):

  1. 原子获取 —— SET key value NX PX ttl,不能拆成两条命令
  2. 必须有 TTL —— 否则持有者崩溃后死锁
  3. value 是唯一标识 —— 释放时用 Lua 脚本比对再删,防止删掉别人的锁
  4. 可重入和续期 —— 业务执行超过 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 都是这个量级)、移动网络容易断、服务重启会丢掉所有在途请求、而且一个请求占住一个连接和线程。

异步方案的关键设计点:

  1. 任务状态持久化 —— 存数据库不是内存,进程重启后任务还在
  2. 进度可查 —— 状态、当前步骤、已用时长、部分结果
  3. 结果有保留期 —— 客户端可能过一会儿才来取
  4. 可取消 —— 取消要级联到正在执行的上游调用
  5. 可重试且幂等 —— 重试不能产生重复副作用
  6. 超时兜底 —— 任务本身要有最大执行时长,卡住的任务要能被判死

推送优于轮询,但轮询要作为兜底——推送连接可能建不起来(企业代理、弱网),这时候降级到轮询能保证功能可用。

追问「异步之后,用户体验反而变差了(要等通知),怎么平衡」:用混合模式:请求进来先按同步流式处理,如果在一个阈值(比如 20 秒)内没完成,就转成异步——返回 task_id 并告知「任务较长,完成后通知你」,同时前端保留一个进度视图。这样短任务保持最好的即时体验,长任务不占连接。实现上要求执行层从一开始就把状态写进持久化存储(不管最终走哪条路),这样切换时不丢上下文。这个设计的关键是「执行和交付解耦」:任务怎么跑和结果怎么给用户是两件事,一开始就分开设计,后面才能灵活切换。