SSE 双队列架构进化之路:从 OOM 到网络漂移,一个后端项目的八次蜕变

写在前面

这是一个 LLM 流式对话后端的故事。

项目核心很简单------一条 SSE 连接(Server-Sent Events),把大模型的流式输出实时推给用户。听起来不值一提:一个队列存 token,一个 SseEmitter 推出去,完事。

但线上教做人。真实场景远比玩具 demo 残酷:

  • LLM 生成太快,下游消费太慢 → OOM
  • 网络抖了抖,几个 token 丢了 → 用户看到一篇残缺回答
  • 用户从 WiFi 切到 4G,三秒断连 → 全量重跑 LLM,账单翻倍

每一个问题都不是"加个 if-else"能解决的。它们逼迫你一次次重新审视架构,把"能用"打磨成"抗打"。

这篇文章不讲八股理论,只讲我们亲手踩过的坑,和每一次迭代背后,架构思维的进化


目录

yaml 复制代码
Level 1: 起步 --- 双队列解决基本的生产消费矛盾
Level 2: 防崩溃 --- 有界队列 + offer() 非阻塞入队
Level 3: 降级 --- 快速失败 → 100ms 超时等待
Level 4: 语义 --- 丢旧保新淘汰策略
Level 5: 并发 --- while 循环的竞态陷阱
Level 6: 容错 --- 丢包检测与重传(TCP 方案为什么不行)
Level 7: 漂移 --- WiFi 切 4G 场景下的会话存活
Level 8: 成本 --- 网络抖动导致 LLM 调用翻倍怎么办

Level 1:为什么是"双"队列,不是"三"队列

问题

LLM 生成 token 的速度和 SSE 推送的消费能力不匹配。生成快、推送慢,如果共用一个队列,生产端阻塞、消费端积压,两边互相拖累。

方案:一分为二

css 复制代码
生成源 → [接收队列 1000] → Reorderer → [推送队列 100] → SSE 推送

两个队列各司其职,互不干扰:

组件 容量 角色 职责
接收队列 1000 生产者专属车道 上游 LLM 产生的 token 快速写入,不做任何处理,保证 LLM 线程不被阻塞
推送队列 100 消费者缓存池 流式下推的有序内容,容量严格控制,防止下游消费堆积
Reorderer --- 逻辑处理器 消费接收队列,做乱序排序 + 超时检测(注意:这是处理逻辑,不是物理队列

一个容易踩的坑

"接收队列 + Reorderer + 推送队列"------乍看像三个队列。实际上 Reorderer 是逻辑处理器,不是物理存储。 如果面试被问到,说"三队列"会被纠正的。

架构思维:分离关注点。 接收队列面向生产者,追求吞吐;推送队列面向消费者,保障有序。一个组件解决一个问题,而不是一个组件解决所有问题。


Level 2:有界队列 --- 你不设上限,JVM 就给你设上限

问题

如果接收队列无界 ------Reorderer 一旦跟不上生产速度,队列无限堆积,结果只有一个:OOM,进程被 Kill

方案:1000 的上限从哪来

java 复制代码
BlockingQueue<StreamMessage> receiveQueue = new LinkedBlockingQueue<>(1000);

1000 这个数字不是拍脑袋------压测数据显示,正常场景下队列水位在 200~500 之间。1000 留了一倍余量,又不会吃掉太多堆内存。在生产力和安全性之间取了平衡。

关键决策:offer() 而不是 put()

java 复制代码
// ❌ 会阻塞 LLM 生成线程------生产停了,整个对话卡死
queue.put(message);

// ✅ 不阻塞,满了直接返回 false,让上游自己决定怎么处理
boolean ok = queue.offer(message);

put() 在队列满时会阻塞,这一阻塞会逆向上溯到 LLM 生成线程。LLM 生成是整条流水线的源头,源头一停,整条线就断了。 所以设计原则很明确:宁可丢消息,不能让 LLM 断流。

架构思维:识别关键依赖。 在这套系统里,LLM 生成是唯一生产源,优先级最高。所有设计都要优先保证它不被阻塞------哪怕是牺牲推送的完整性。


Level 3:100ms 的进化 --- 从"宁丢不堵"到"等一等再丢"

问题

offer() 不阻塞、不等待,返回 false 就直接把消息丢了。但网络抖动几十毫秒是家常便饭------只是为了躲一个几十毫秒的抖动就永久丢失一个 token,值得吗?在流式场景下,丢掉一个 token 可能导致整句话不通顺,甚至代码逻辑断裂。

V1:快速失败(零等待,零保障)

java 复制代码
if (!queue.offer(message)) {
    // 丢消息,无补偿,用户看到了残缺回答
}

好处是绝对不阻塞 LLM。坏处是网络一抖就丢------太敏感了。

V2:限时等待 + 降级淘汰

java 复制代码
boolean ok = queue.offer(message, 100, TimeUnit.MILLISECONDS);
if (!ok) {
    // 等了 100ms 还不行?说明真满了,启动降级
    queue.poll();
    queue.offer(message);
}

为什么不多不少,偏偏是 100ms?

  1. 覆盖网络抖动:SSE 场景下几十毫秒的抖动是常态,100ms 能兜住 99% 的正常波动
  2. 不拖垮 LLM:LLM 超时通常是 30s,100ms 只有它的 1/300------九牛一毛
  3. 用户无感知:100ms 的延迟人类根本感觉不到,但丢一个 token 的感知很强

架构思维:阶段性优化 --- 先解决"崩不崩",再解决"好不好"。 第一次迭代只求不阻塞(V1),线上跑了一段时间发现丢消息太频繁,才进化到 V2。不是设计时就想到了 100ms------是问题驱动出来的。工程上一步到位是理想,持续迭代才是现实。


Level 4:丢旧保新 --- 当队列满时,让谁出局?

问题

队列真的满了,必须有人出去才能让新消息进来。丢谁?

直觉可能是"丢最新进来的"------毕竟它还没被处理。但在流式场景下正好相反。

方案

java 复制代码
if (!queue.offer(message, 100, TimeUnit.MILLISECONDS)) {
    queue.poll();       // 弹出队头(最旧的消息)
    queue.offer(message); // 新消息入队
}

为什么"丢旧"不是"丢新"?

流式场景和传统消息队列有个关键区别:用户的眼睛在屏幕最下方,盯着最新的输出。

10 秒前已经发送到前端的旧 token,现在从内存里丢掉,用户毫无感觉------它已经在屏幕上渲染好了。但如果丢掉最新到的那一批 token,用户会看到内容突然截断、代码缺一半、逻辑断在中间。

LinkedBlockingQueue 的 FIFO 特性在这里恰好踩中了语义:队头是最早入队的(大概率已经推送完毕),队尾是最新的(正要处理)。poll() 丢队头,offer() 加队尾------天然就是"丢旧保新"。

架构思维:业务语义驱动技术选型。 不是"FIFO 好"所以用它,而是"我们要丢弃最旧的消息"这个需求,恰好被 FIFO 完美满足。选型的理由应该在业务侧,而不是技术侧。


Level 5:并发竞态 --- 一段 while 循环引发的血案

问题

上面的降级逻辑看起来没问题,但一上并发就出事。

当多个线程同时走到降级分支时,"poll + offer"这个操作不是原子的:

css 复制代码
线程 A: poll() → 腾出 1 个空位
               ← 切到线程 B 抢了这个空位,offer() 入队成功
线程 A: offer() → 又满了 → 再次 poll()

最终效果是:一个入队操作触发了多次 poll,过度丢弃旧消息,而且 CPU 白白空转。

解决方案:Session 级锁,把 poll+offer 捆成原子

思路很直接------把"检查容量 → poll → offer"三个步骤锁在一起,不让其他线程插队。但锁的粒度要控制好:不能锁全局,只锁当前 Session。

java 复制代码
private final ConcurrentHashMap<String, Object> queueLocks = new ConcurrentHashMap<>();

public boolean enqueue(String sessionId, StreamMessage message) {
    BlockingQueue<StreamMessage> queue = receiveQueues.get(sessionId);
    if (queue == null) return false;
    Object lock = queueLocks.get(sessionId);

    // 1. 先尝试正常入队(无锁通路------99% 的请求走这里)
    boolean ok = queue.offer(message, 100, TimeUnit.MILLISECONDS);
    if (ok) return true;

    // 2. 降级路径:加锁保证 poll+offer 原子执行
    synchronized (lock) {
        // 二次 offer:排队等锁期间,别的线程可能已经腾出空位了
        if (queue.offer(message)) return true;
        // 锁内只 poll 一次,保证腾出的空位不会被抢走
        queue.poll();
        return queue.offer(message);
    }
}

优化要点总结

优化 解决了什么
Session 级锁粒度 不同 Session 完全不互斥,不会一处降级拖慢全局
不锁正常路径 99% 的时候队列不满,走无锁路径,性能不受影响
二次 offer 避免排队等锁期间别人已经腾出空位,防止重复 poll
单次 poll 不需要 while 循环,杜绝多次丢弃和 CPU 空转

架构思维:并发设计不是"加锁/不加锁"的二分选择。 99% 的请求走无锁路径,只有那 1% 的极端情况才走锁------而且锁的是 Session 级,不是全局。无锁保性能,有锁保正确,两条路并存。


Level 6:丢包检测 --- TCP 那一套,搬到 LLM 上行不通

问题

Reorderer 按 sequence 序号消费接收队列时,发现序号不连续------seq=45,seq=46,seq=48......seq=47 去哪了?

直觉反应是学 TCP:检测到丢包 → 请求重传。但这恰恰是一个危险的类比陷阱

为什么 TCP 重传模型不能用

ini 复制代码
TCP 场景:    没收到 seq=47 → 告诉发送方重传 seq=47 → 发送方从缓冲区取出重发 ✅
LLM 场景:    没收到 seq=47 → 告诉 LLM"再发一次 seq=47" → LLM:❌ 一次性的,无法局部重发

LLM 生成是单向流,一次性的。 它不是 KV 存储,不能说"再给我生成 seq=47"。产生的 token 从它的内存里流过即消失,根本没有持久化。你的"重传请求"根本不知道该发给谁。

正确思路:放弃精确重传,分层兜底

第一层:源头预防(队列流控)

控制 LLM 推送到接收队列的速率,匹配下游 Reorderer 和推送队列的消费能力------从源头减少丢包概率。

第二层:区分"乱序"和"永久丢失"

java 复制代码
// 连续超时判定:不是乱序,是真丢了
if (currentTime - buffer.firstEntry().getValue().timestamp > LOSS_THRESHOLD_MS) {
    // 发缺失标记给前端------不是重传请求
    StreamMessage lossMarker = StreamMessage.builder()
        .sequence(expectedSeq)
        .content("[内容片段丢失]")
        .build();
    sendToEmitter(lossMarker);
}

第三层:前端兜底

  • 灰色提示文案:"内容片段丢失" + 手动重试按钮
  • 代码/SQL 残缺处标红,降低误导风险

第四层:会话熔断

单会话丢弃量超过阈值(如 10 条/10s)→ 自动关闭 SseEmitter,释放服务端资源。

架构思维:适配业务模型,而非套用通用方案。 TCP 的重传假设"发送方有缓存、可以重发",但这个假设在 LLM 场景不成立。强行套用只能把自己绕进去。分层兜底代替精确修复------不是最优解,却是这个场景下最务实的选择。


Level 7:网络漂移 --- WiFi 切 4G 的 3-5 秒,架构崩了

问题

用户正看着 AI 回答,WiFi 信号弱了,手机自动切换到 4G。看似平常的举动,在后端引发了一场灾难。

这 3-5 秒叫 IP 漂移期:服务端那端的 TCP 连接看起来还活着(没有 FIN 包),但实际上数据已经送不到用户了。心跳超时了------按照原始设计,直接删除 sessionId 关联的所有资源。

等用户 4G 稳定下来重新连上时,服务端已经把他的会话上下文清空了:队列没了、序号没了、LLM 生成结果也没了。只能从头重新跑一次 LLM。

一个用户切一次网,LLM 就多付一次钱。100 个用户反复切,账单直接翻倍

原始设计的致命缺陷

markdown 复制代码
心跳超时 → 删除 sessionId 所有资源(SseEmitter、队列、序号)
        → 用户重连 → 找不到上下文 → 全量重跑 LLM → 重复计费

改造方案:资源两级隔离

核心思路:不是所有资源都该跟着连接一起死。

bash 复制代码
┌─────────────────────────────────────────────────────┐
│  瞬时资源(绑定 TCP 连接,心跳超时直接销毁)            │
│  ├── SseEmitter(连接句柄)                          │
│  ├── 发送线程                                        │
│  └── sendQueues(发送队列)                          │
├─────────────────────────────────────────────────────┤
│  持久会话上下文(绑定 sessionId,8s 宽限期)           │
│  ├── receiveQueues(未推送的 token 缓存)             │
│  ├── reorderBuffers(乱序重排缓冲区)                  │
│  ├── expectedSequence / sentMaxSequence(序号游标)   │
│  ├── 会话快照(prompt、已渲染文本最大 seq)            │
│  ├── 任务版本号                                      │
│  └── LLM 生成中的 Future 任务                    │
└─────────────────────────────────────────────────────┘

心跳超时不再等于"全部清除"

markdown 复制代码
1. 业务心跳连续超时 → 判定当前 TCP 链路失效
2. 关闭 SseEmitter,销毁链路级瞬时资源(连接、发送线程)
3. 保留 sessionId 对应的持久会话上下文(队列不删、序号不删、快照不删)
4. 启动 8s 宽限期倒计时,标记会话状态为「链路断开,等待重连」
5. 不取消正在运行的 LLM 生成任务,继续生产消息写入接收队列
6. 如果用户在 8s 内重连 → 复用上下文,从当前 expectedSequence 继续推送
7. 如果 8s 后仍无重连 → 判定永久断开 → cancel LLM → 清理所有持久资源

为什么是 8s?

IP 漂移通常持续 3-5s,8s 给了 60%~160% 的余量。太长(比如 30s)会导致堆积过多内存;太短(比如 5s)刚好卡在漂移窗口边缘,漂移稍长一点就误杀。

架构思维:识别不同生命周期的资源。 不是所有资源都该随着心跳超时一起陪葬。把"连接"和"会话"解耦,让断连成为可逆事件------从"要么活着要么死"进化到"先观察再决定"。


Level 8:成本防护 --- 不解决这个问题,账单翻倍是代码的锅

问题

到了这一步,大部分技术问题都解决了。但还有一个更现实的问题:网络抖动导致的重复 LLM 调用,直接体现为云账单。

100 个用户同时切网,每个都触发全量 LLM 重试------LLM 是按 token 计费的。而且这不是合理的业务成本,这是架构缺陷导致的浪费。

四道防线,从根上掐住成本

防线 1:宽限期内复用上下文(最核心)

只要用户在 8s 内重连,完全复用原有的 LLM 流,不产生二次调用、不产生额外计费。这不是"优化",而是"从根源消除浪费"------零额外成本。

防线 2:会话级重试熔断

java 复制代码
// 一个 session 在 30s 内最多触发 1 次静默全量重试
if (sessionRetryCount.incrementAndGet() > MAX_RETRY_PER_30S) {
    return; // 超出阈值,降级为用户手动重试
}

防线 3:任务版本号抢占取消

java 复制代码
// 每次新消息或重连,递增版本号
int currentVersion = sessionTaskVersion.incrementAndGet();

// LLM 生成任务每隔一段时间检查版本号
if (this.version != sessionTaskVersion.get()) {
    // 版本不匹配 → 自己已经被取代了,停止生成
    throw new TaskVersionObsoletedException();
}

已经过时的生成任务自动中断,不会继续生成无用的 token,不会浪费计费。

防线 4:全局并发限流

全局限制同一时刻运行的 LLM 生成并发数,超出阈值直接拒绝自动重试,降级为用户手动触发。

成本归属:哪些重调是合理的,哪些是代码背锅

场景 是否额外计费 定性
8s 内重连(WiFi→4G 正常切换) ❌ 零额外计费 架构正确,零浪费
永久断连后用户手动点击重试 产生费用 用户主动行为,合理
极端批量切网、超并发 ❌ 全局限流挡住 熔断生效,防止失控
代码缺陷导致无限循环重试 ❌ 频率熔断挡住 熔断兜底,不给账单爆炸机会

架构思维:成本意识是后端架构的能力项,不是加分项。 区分"合理的用户行为成本"和"代码缺陷导致的不必要成本",并且从架构设计上消除后者------这是从"把功能做出来"到"把系统做健康"的分水岭。


进化全景图

回顾这八个阶段,每一次进化的驱动力都很明确------线上出了状况,或者面试官指出了盲点。

复制代码
Level 1  双队列分离          → 解耦生产消费 ------ 基础架构选型
Level 2  有界队列 + offer     → 防 OOM       ------ 容量安全意识
Level 3  快速失败→100ms 超时  → 抖动容忍    ------ 从极端到务实
Level 4  丢旧保新             → 业务语义淘汰 ------ 业务驱动技术选型
Level 5  Session 级锁        → 并发原子性   ------ 从单线程思维到并发思维
Level 6  分层兜底重传         → LLM 语义适配 ------ 打破通用方案的惯性
Level 7  资源两级隔离         → 断连可逆化   ------ 从资源粒度思考
Level 8  四道成本防线         → 成本可控     ------ 从代码质量到经营意识

这八个层次不是一开始就设计好的。 它们是在一次次"卧槽这里崩了"之后,在模拟面试中被追问到语塞之后,在反复推敲中长出来的。

你不是要在设计阶段就预见到所有问题,而是要有一个能持续发现问题、验证假设、低成本迭代的架构习惯。


仍然开放的问题

有两个问题我们讨论过,但没有形成最终方案。留在这里,算是个 TODO:

  1. LRU 片段缓存边界:流式 token 一个接一个到达,怎么判断"一段完整的上下文"结束了?按固定 token 数截断?按换行符?按语法启发式?还是按退避超时?------这是一个有挑战的设计题
  2. 8s 宽限期的线上验证:8s 在理论上足够覆盖 3-5s 的 IP 漂移窗口,但实际运维中是否遇到过 TCP 栈状态和业务心跳不同步的情况?我没有线上数据来验证它

写在最后

这篇文章里的每一个"坑",都不是从教科书上读到的------而是在真实场景里遇到了、在模拟面试中被追问了、在反复推敲中迭代出来的。

架构思维不是在 PowerPoint 的架构图上画出来的,是在每次"卧槽这里崩了"之后的复盘里出来的。

如果你也在做类似的后端项目,希望这些坑能帮你少踩一些。如果有更好的方案,或者踩过不一样的坑------欢迎讨论。

每一轮追问,都是一次进化。

相关推荐
TrisighT2 小时前
Agent 聊了 20 轮 token 翻了 8 倍——三种截断方案实测,最蠢的那种反而最稳
aigc·openai·ai编程
奋飛2 小时前
Agent系列:从 grill-me 学写 Skill——先找默认错误,再写最小约束
ai编程
赫媒派2 小时前
MCP Server 安全防护:5 个实用配置清单
ai编程
咏方舟【长江支流】2 小时前
【开源】跨语言·跨平台·跨数据库(3) —— 为用宝框架增加ORM,让AI执行
数据库·人工智能·开源·ai编程·orm·咏方舟-长江支流·用宝框架
怕浪猫2 小时前
第14章 性能优化与成本控制
openai·agent·ai编程
程序员黎剑3 小时前
我把算卦变成了一段if-else
java·vue.js·spring boot·后端·ai编程
wangruofeng12 小时前
AGENTS.md 告诉 Agent 怎么写代码,DESIGN.md 告诉它怎么长得好看
aigc·ai编程
_山海12 小时前
Openclaw for windows 原生安装与配置
ai编程