面试知识库

增强 D · 事件回放(Redis Stream)—— 开发文档(面试向)#

这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应 docs/BACKEND_ENHANCEMENT.md 的 D 块(第二波 P1,单机定稿部分)。

一句话概括#

让用户刷新页面 / 短暂断网后还能无缝看到事件流:把每个会话的事件(Agent 在想什么、调了哪个工具)持久化进一条 Redis Stream,断线重连时带上「我收到的最后一个事件 id」,服务端把这期间漏掉的事件补发回来,再接着直播。任务本来就在后台跑、不受断线影响——补的只是「用户没看到的那段」。

打个比方:直播信号断了几秒,重连后不是从当前画面接着看(中间漏了),而是先把断的那几秒回放给你,再转直播。


1. 背景:connect-first 防住了「开头」,没防住「中途」#

项目原来有个挺巧的 connect-first 约定解决「早期事件丢失」:前端先连 WebSocket、收到确认了才发起任务,保证任务一开始上报的事件(会话已创建等)连接已经在、不会丢。

但它只防住了「开头」。还有个洞没补:任务跑到一半,用户刷新页面 / 手机切后台 / 网络抖一下——WebSocket 断了。任务本身在后台继续跑(它和 WebSocket 是解耦的,断连接不杀任务),可断开那几秒里 Agent 上报的事件,前端没连着、就丢了。重连上来,事件流中间缺一块。

D 块就是补这个洞:断开窗口的事件,重连后补发回来。

(注:这里说的是页面还开着、只是连接瞬时掉线的情形——网络抖一下、切后台几秒,靠自动重连 + 补发就能续上。整页刷新 / 切换对话是更狠的「冷启动」:前端的 JS 上下文整个没了,光靠重连接不住——那条路径怎么续看,见 §5。)


2. 方案:用 Redis Stream 当「带位置的事件日志」#

核心选型:每个会话一条 Redis Stream,事件发出时除了直播给前端,也 XADD 进这条流。

为什么是 Redis Stream,而不是自己在内存里存个数组:

  • Stream 天生就是为「带位置追踪的事件日志」设计的XADD 返回的 id(单调递增)天然就是 last_event_id;「补发 last_event_id 之后的事件」对应 XRANGE 的一个排他区间查询——不用自己造轮子。
  • MAXLEN 自动裁剪老事件,单条流不会无限长。
  • 同一套 Stream 正是将来「多副本部署」的事件总线底座(毕业线)——现在单机用它做回放,将来多副本直接用它做跨实例广播,一个决定两处收益。

重连流程:前端重连时把 last_event_id 放进连接地址,服务端登记连接后,先从 Stream 把这个 id 之后的缺口事件补发,再转直播。


3. 三块联动:D 复用 B 的断路器、断路器又进 A 的仪表盘#

这块最值得讲的是和前两块的联动,体现「基础设施复用」:

  • Redis 不可用怎么办? 不能因为加了回放就让主链路变脆——Redis 没配 / 没装 / 挂了,事件上报(每个事件都要写一次 Redis)绝不能拖垮 Agent。这里直接复用 B 块的断路器包住 Redis 操作:连续失败就熔断,之后快速失败(不再每次干等连接超时),Redis 恢复了半开探测自动恢复。降级时持久化静默跳过、补发返回空,退回到现状(只直播、无回放)——功能优雅缺失,主链路毫发无伤。
  • 这个断路器又自动进了 A 块的监控:因为 A 块的 metrics 会枚举所有断路器的状态,event_log 的断路器一创建就被收进去,/metrics 上能看到 Redis 这条线熔没熔。

C(背压)、B(韧性)、A(可观测)、D(回放)就这样串起来了——不是四个孤立的功能,而是互相用对方的能力。


4. 几个把细节做对的地方(含 review 抓到的)#

  • 不动直播核心,Stream 只作持久化 + 补发。没有把原来进程内的直播推送(连接管理器)推倒重写成「全部读 Stream」——那是大改、风险高。Stream 是旁路的持久化副本,直播照旧。改动小、风险低。
  • 补发和直播的竞态,用「事件带 id + 前端去重」化解。重连时先登记连接(开始收新直播)再补发历史,这两路可能重叠、甚至乱序到达。不用加锁去严格排队,而是让每个事件都带单调 id,前端按 id 去重;顺序上容忍极少数乱序(只影响思考行展示顺序,不影响正确性)。诚实标注:这是单机下的务实取舍,真要严格有序就得把直播也改成读 Stream(毕业线)。
  • TTL:防止「流的数量」无界增长(review 抓到的 must-fix)。MAXLEN 只限单条流的长度,不限流的数量——每个会话一条流,不设过期就会随会话越积越多。所以每次写入都滑动刷新一个 TTL(默认 6 小时):活跃期间不过期,任务结束几小时后自动回收。回放本就是「断线后短时间内重连」的事,过期了也不需要再回放。
  • 只持久化「根会话」的事件(review 抓到的)。子 Agent(fork 出去并行搜平台的那些)内部的事件,前端根本不会去回放它们(前端只连主会话)。所以子 Agent 的事件不写流——省掉大量无谓的写入,也不留下没人读的流。判据复用了「活动流录制」已有的「根会话」口径。
  • 操作超时,不只连接超时(review 抓到的)。光设「连接超时」挡不住「连上了但 Redis 卡住」那种 hang——所以也设了操作超时,卡住就快速失败、喂给断路器。
  • 前端重连定时器在组件卸载时清掉(review 抓到的)。退避重连用的定时器,如果在等待期间组件被卸载,挂起的定时器还会触发、又建一个连接——卸载时清掉它。

5. 后续增强:整页刷新 / 切换对话也能「续看」#

D 块最初把回放基础设施(Stream + last_event_id + 补发)搭好了,前端也接上了一种断连:页面没关、只是连接瞬时掉线(网络抖、切后台)——自动重连、带 last_event_id 补缺口。但还有两种「断连」当时没接到这套基础设施上,是后来补的:

  • 整页刷新 / 重开页面:JS 上下文整个销毁重建,自动重连那套根本不触发。重建后的前端走「冷启动」路径——只拿 threadId 读历史、把状态设成「已完成」,正在后台跑的那一轮就这么丢出了视野:任务其实还活着(任务和连接解耦,刷新不杀任务),但前端既不重连也不轮询,看不到进度、也看不到它最终的结果(除非再刷一次、等它已写进历史)。
  • 切换到别的对话:更糟——当时切换会主动取消当前在跑的任务。用户只是想去看看别的对话,却把自己的请求杀了。

后续增强把这条冷启动路径也接上回放基础设施,并定了条更干净的语义:任务一旦发起就归后台,刷新、切对话都只是「换个视角看」,绝不隐式打断它

  • 后端加一个「这会话还在跑吗」的探口:在跑就连同「正在跑那一轮」的提问原文 + 已发生的事件一起回吐。「已发生的事件」靠一个新的回放口径取——每轮任务开头都会发一条「会话已创建」,所以同一条 Stream 里最后一个「会话已创建」之后的事件,就是「当前这轮」的全部进度(更早的历史轮早落了历史,不重复)。
  • 前端把刷新和切对话统一成一条「进入对话」路径:进入任一对话时,先拉历史渲染已完成的轮,再问一下探口——还在跑就用回吐的提问 + 事件重建出「正在跑的那一轮」,再带 last_event_id 重连补缺口、接着直播。切走时只断订阅、不再取消任务;真要终止,仍有显式「取消」按钮。
  • 一个去重保护:任务刚收尾、结论已写进历史、但后台任务表还没把它摘掉的那一瞬,光看「任务在不在表里」会把它误判成「还在跑」,从而和刚落盘的历史轮重出一份。所以以「Stream 里最后一条是不是终结类事件」为准——终结事件一进流,就判它已结束。
  • 一个前端坑(React StrictMode):开发模式下 React 会把挂载逻辑跑两次,而「重建在跑轮」是追加式、不幂等,会追出两条一模一样的对话。用一个「同一会话只续看一次」的同步守卫挡住(离开对话时清掉,好让切走再切回能重新续看)。

效果:刷新、切走再切回,都能无缝接着看那一轮的进度;看别的对话,不影响自己的请求。

这块同样是「叠加在既有基础设施上、不推倒重写」的延续——续看复用的就是 D 块已有的 Stream + last_event_id + 前端去重,只是把它从「页面没关的瞬时重连」推广到「冷启动重进」,再加一个探口告诉前端「值不值得重建」。


6. 范围与诚实边界#

  • 做的是「断线重连补发」(单机现做档)。
  • 不做「多副本路由」(毕业线):部署两个以上后端副本时,任务在 A 副本、连接在 B 副本就推不过去——那需要用 Redis 发布订阅做跨实例广播。单机不触发,写进毕业线;而且回放用的这套 Stream 正是它的底座,将来要做时地基已经在了。
  • 前端做了自动重连 + last_event_id + 去重的瞬时断连闭环,后又补上整页刷新 / 切换对话的「续看」(冷启动探口 + 重建在跑轮,见 §5),并把「切对话隐式取消任务」改成「绝不隐式打断、只显式取消」。端到端真实断网 / 刷新验证仍需手动跑前后端(无前端自动化测试;后端逻辑——探口、按轮回放——有 pytest 覆盖)。

7. 面试可讲点小结#

  1. 「connect-first 防开头、回放防中途」:能说清原有机制解决了什么、还剩什么洞,D 块补的是哪个洞。
  2. 「为什么用 Redis Stream」:它天生是带位置的事件日志,XADD 的 id 即 last_event_id,且是将来多副本总线的底座——一个决定两处收益。
  3. 「三块联动」:D 用 B 的断路器做降级、断路器又进 A 的 metrics——基础设施互相复用,不是堆孤立功能。
  4. 「竞态用 id 去重而非加锁」:补发与直播重叠/乱序,靠单调 id 去重 + 容忍轻微乱序,务实化解,并讲得出严格有序的代价。
  5. 「MAXLEN 限长度、TTL 限数量」:能讲清两者管的是不同维度,不设 TTL 流的数量会无界增长。
  6. 「瞬时掉线 vs 冷启动续看」:能区分「页面没关、连接抖一下」(重连 + 补发就够)和「整页刷新 / 切对话」(JS 上下文全没、得另接探口 + 重建在跑轮),并讲得出『任务归后台、视图只是换个角度看、绝不隐式打断』这条语义——以及「当前这轮 = 最后一个『会话已创建』之后的事件」这个干净的边界从哪来。附带能讲两个把细节做对的点:终结事件进流即判结束(避开与历史轮重出)、React StrictMode 下非幂等的重建逻辑用同步守卫挡重复。
  7. 诚实边界:只持久化根会话、单机回放、多副本留毕业线——讲得出做了什么、没做什么、为什么。