Redis List 换成 Streams,以为能睡安稳觉了——结果消息还是在丢

Redis List 换成 Streams,以为能睡安稳觉了------结果消息还是在丢

MySQL任务卡在 UPLOADING 状态,started_at 显示已经卡了三四个小时,但 Redis 对应的队列里空空如也------消息不知道去了哪儿。

先交代一下背景

项目是一个视频解析 + 上传的后端服务,职责很直白:前端传一个视频页面链接进来,系统负责把实际的视频文件解析出来、下载、再转存到对象存储上,后续业务按 URL 使用。因为接入的源站种类不少(多家内容平台,下文统称"上游平台"),每个源站的解析逻辑差异大、失败率参差不齐,整条链路做成了异步:

markdown 复制代码
HTTP API  →  resolve 队列  →  解析 worker  →  upload 队列  →  上传 worker  →  对象存储
                                                                                    ↓
                           MySQL (任务状态机)                                   Webhook 回调

两段队列的意义是把同步的 HTTP 请求和异步的长耗时任务解耦:用户调一次 API 只拿到一个 job_id 就返回,后面解析和上传的结果通过 webhook 推回去。任务的业务状态在 MySQL 里,Redis 队列只负责"唤醒 worker 去做事"------这一点后面会反复提到,是整个架构的一条主脊。

本文的主角就是这两段 Redis 队列------它们最初用 Redis List 实现,后来换成了 Redis Streams。为什么要换、怎么换的、换完之后是不是真解决了问题,以下展开。

发现问题

先讲事故本身。

那天大概八点多,报警群里开始刷屏:upload queue 积压告警、webhook 失败率告警、用户工单同步进来。上线看情况,MySQL 里一批视频任务卡在 UPLOADING 状态已经两三个小时没动,started_at 都是凌晨三四点。Redis 里对应的 upload queue 长度是 0,干干净净。

一时看不出什么原因,但是要先解决问题。把 UPLOADING 超过 30 分钟的任务批量重置回 RESOLVED 重新入队,大部分任务就这么跑完了。急性问题 11 点前解决,然后坐下来,想搞清楚到底发生了什么。

排查问题

第一步,锁定问题范围。

先不看日志不看进程,只看数据。从 MySQL 里捞:

sql 复制代码
SELECT id, platform, status, started_at
FROM video_job
WHERE status = 3  -- UPLOADING
  AND started_at < NOW() - INTERVAL 30 MINUTE;

有14 条数据,started_at 全部集中在 03:40 -- 03:55 这个 15 分钟窗口里,解析阶段一条没卡,全卡在上传,也就是说,03:40 -- 03:55 这段时间,upload 阶段有 14 个任务"失联"。

第二步,对照 Redis 队列。

这 14 个任务在数据库里是 UPLOADING,说明有 worker 拿到过它们并改过状态。按正常流程,upload queue 里应该有对应消息,worker 处理完之后从队列里 pop 掉。

查 Redis:LLEN app:upload0。也扫了一下相关 key,没有任何残留。

这里发现第一个问题:DB 层显示任务在上传中,Redis 队列里却没有消息。两边对不上,说明消息早就出队了,但任务没跑完。

第三步,看 worker 进程。

5 台 worker 节点挨个上去看。ps 看到的 PID 全都和事故前的不一样,systemd 重启过------其中 3 台节点的 journalctl 里出现:

vbnet 复制代码
systemd: upload-worker.service: Main process exited, code=killed, signal=KILL
systemd: upload-worker.service: Scheduled restart job, restart counter is at 1

再 dmesg,OOM killer 的记录赫然在列,时间戳 03:48。到这里有了第二个问题:事故时段内 5 台 worker 节点中有 3 台被 OOM 杀过

事故原因看起来是因为worker OOM 导致消息丢失。但这个结论只解释了"worker 死了",没解释"消息为什么连带着一起消失"------因为 worker 挂了之后 systemd 很快把它拉起来了,如果 Redis 队列里还有消息,新起的 worker 应该会继续消费,不会留下卡单。

第四步,初步怀疑。

挑一个典型卡住的 job(比如 id=9823),挨个节点日志去查,同时记录时间线:

  • node-A03:47:22 upload start job_id=9823 → 之后 03:49:xx 日志突然中断
  • node-B:grep 不到 9823 任何记录
  • node-C:同上

表面看没问题,9823 被 node-A 拿走,node-A 在 48 分被 OOM 杀死,消息随进程一起消失。

继续查 Redis 文档:BRPOP 返回给客户端的那一瞬间,消息就已经从列表里删除了,不等客户端返回任何确认(事实上 List 根本没有"确认"这个概念)。也就是说,消息不是"随 node-A 进程一起消失",而是"早在 node-A 开始执行 upload 之前,Redis 就已经把它删了"------node-A 崩不崩都改变不了这件事。

第五步,换角度再想一次。

原来的认知是"worker 崩溃偶尔带着消息一起丢",言外之意是"大部分时候 worker 能正常把消息消化掉"。现在的认知是"只要 worker 在拿到消息和任务结束之间的任何时刻崩溃,消息就必然丢失"------区别只在崩得快还是崩得慢:

  • 崩得快:消息拿到还没开始做事,Redis 那边已经把消息删了,任务卡在原始状态(PENDING / RESOLVED)
  • 崩得慢:已经把数据库状态改到 RUNNING / UPLOADING,Redis 那边消息更是早就删了,任务卡在中间态

14 条卡单对应的是第二种情况。第一种情况之所以没被发现,是因为"PENDING 超时"的数据库 fallback 扫描阈值配得相对宽松,偶尔回收不会被注意到。

第六步,往回翻,找规律。

带着这个推测,翻了三个月的工单。同类问题出现过四次,之前每次都归因到"偶发 / 重启解决",没当回事。把这四次事件和各节点的 OOM / crash 记录对照,全部吻合。也就是说------这不是第一次了,只是之前量小没人当回事。

第七步,确定根因。

到这一步才算真正看清楚根因。不是 worker 不够稳(任何分布式系统都有崩溃的可能),也不是某次 OOM 的偶发问题,是 LPUSH/BRPOP 这个模型从一开始就没给崩溃恢复留任何余地,消息读出来就是读出来了,没有"未确认"的概念。

只要 worker 有崩溃的可能,List 模型下的消息丢失就是必然事件,不是偶发事件。

这个判断一旦成立,问题就从"运维层"(某台机器扛不住)转到了"架构层"(整套队列模型不支持崩溃恢复),动的范围也就从"调内存 / 调参数"变成"换队列"。

复盘会上另一件事也浮出水面:上传阶段 DB fallback 扫描的阈值 stale_uploading_s = 21600,6 小时。这个值当初设这么大是有原因的------那会儿上传侧还没有单任务超时机制,一个任务进了 UPLOADING 之后,系统没有任何办法区分它是"还在传大视频"还是"已经卡死"。为了不误杀真正在跑的大文件上传(传一两个小时属于正常),stale_uploading_s 只能往大了设。说到底是没有更细粒度的手段,只能靠宽到离谱的阈值被动避让。

顺便说清楚当初为什么选 List

追到根因那一步,很自然地会问:那一开始为啥不直接上 Streams?

更早的版本里其实根本没用队列------任务流转完全靠 DB 轮询,dispatcher 每秒 SELECT ... WHERE status=PENDING ... FOR UPDATE SKIP LOCKED 抢一批出来跑。量上来之后两头都痛:延迟波动两三秒、慢查询日志里全是 dispatcher 的轮询。那时候切到 Redis List,要解决的是"并发"和"延迟",从一开始就没把"消息可靠性"列进需求。

为什么是 List 不是 Streams?说白了就是熟。LPUSH/BRPOP 两个命令的语义 Redis 入门就见过,心智负担接近零。Streams 虽然 2018 年就有了,但团队里属于"听说过没用过"------consumer group、PEL、XAUTOCLAIM 是全新概念,要啃一遍文档。当时业务量不大,worker 就两个,感觉没必要为一个"够用"的场景引入这么重的东西。

这个判断有对有错。对的部分是 List 确实跑了好几个月没事;错的部分是我们默认"崩溃恢复"是个不存在的问题,事故之前从来没人问过"如果 worker 在拿到消息之后挂了会怎么样"。

更换技术栈

摆上桌面的候选有三个。

一,在 List 上自建 ACK 机制 :用一个 processing list 和主 list 配合,worker BRPOPLPUSH 到 processing list,处理完再 LREM 删掉。可行,但 processing list 里的消息需要单独的回收逻辑(谁负责扫?空闲多久算崩溃?回收时怎么避免并发重复拉取?),相当于手搓一个缩水版的 Streams。社区有现成轮子,但维护成本不低。

二,换 RabbitMQ / Kafka:两者都成熟,但对我们来说都重。RabbitMQ 要新引入一个中间件,运维面变复杂;Kafka 更重,我们的消息量完全不配得上它的设计定位。更关键的是两者都要改部署拓扑,发布节奏拖不起。

三,换 Redis Streams :Redis 本来就在用,运维面不变,只是 API 换一套。Streams 自带 consumer group、PEL、XAUTOCLAIM,我们关心的那几件事------消息可追踪、崩溃可接管、多消费者可分发------原生就有。

我们最终选了Streams。但当时有个担忧一直没放下:Streams 真的能解决事故那天的消息丢失吗? 这个问题的答案后面会讲,比想象中更微妙。

换完之后

改造本身只改了一个动作:LPUSH/BRPOP 换成 XADD/XREADGROUP。但围绕它的基础设施全得重写------consumer group 的创建、PEL 处理、ACK 时机、XAUTOCLAIM 接管逻辑,落地五百多行代码。

然后要说一句让人有点失落的实话:上 Streams 之后,那次事故场景下的消息丢失并没有被彻底消除

原因是我们最后选了 eager ACK 策略------worker 读到消息后先去数据库抢状态,抢到立刻 XACK,然后才开始真正的业务。如果 worker 在"ACK 之后 + 业务执行中"这段时间崩溃,消息早就从 PEL 里销毁了,XAUTOCLAIM 也捞不回来。那次事故里 14 条卡单全都已经进了 UPLOADING 状态,对应的 Redis 消息按 eager ACK 逻辑早就被 ACK 掉了。同样的事故在 Streams 下再发生一次,消息照样会丢。

那换 Streams 到底图的是什么?图三件事。

一,排查从黑盒变成透明的。

事故那天排查花了大半天才把证据链拼出来,最难的是"消息到底被谁消费过、有没有被投递过多次"------List 没留任何痕迹。如果是 Streams,XPENDING app:stream:upload app:workers:upload 一条命令就能看到"consumer-A 手里挂着 14 条消息、最老一条空闲了 8 分钟",XINFO CONSUMERS 看各消费者状态,XINFO STREAM 看整体生产消费情况。排查思路不再是"猜 + 对日志",而是"看一眼就知道"。那次事故里花半天拼的证据链,Streams 下几分钟就能拼完。

二,读取到 claim 之间那段窗口被补上了。

List 时代,只要 worker 拿到消息,不管是还没开始改 DB 状态还是已经跑到一半,崩溃就丢消息。Streams 把这段拆成了两段:

  • XREADGROUP 读到 → DB claim 成功 + XACK :这段时间内崩溃,PEL 保留消息,60 秒后 XAUTOCLAIM 自动接管。可靠性从 0% 变 100%。
  • XACK 之后 → 业务跑完:这段时间内崩溃,消息仍然会丢(eager ACK 的代价)。

第一段窗口在 eager ACK 下其实很短------就一次 DB UPDATE 的时间,毫秒级。但这一小段的可靠性提升在某些场景下(比如 DB 连接池满、DB 抖动)是实实在在的。

三,stale_uploading_s 从 21600 降到了 1200。

第二段窗口的崩溃(也就是原始事故的场景)还是只能靠 DB fallback 扫描兜底。但这次敢把阈值从 6 小时降到 20 分钟,不是单靠 Streams 一个东西,而是两块底气凑齐了才敢动。

第一块是 Streams 之前就引入的 upload_timeout_s = 840(14 分钟)------给单任务加了硬超时。正常上传跑不过 14 分钟就自己抛 timeout 进 UPLOAD_FAILED,不会再长期停在 UPLOADING,"正常任务"和"崩溃任务"在时间上就可区分了,stale_uploading_s 不用再为大文件留六小时的缓冲。这一步其实比 Streams 迁移更早做,是个独立改造,但它是后面敢降阈值的真正根因------没有它,换多少种队列都没用。

这一步当然不是白捡的。加了 upload_timeout_s = 840 之后,14 分钟传不完的任务直接失败,没有"继续等"的路径------也就是说现在队列稳定性的一部分代价,是让业务层提前规划好"什么样的视频值得传"。我们通过 upload_limit(storage 侧的文件大小上限)把明显太大的任务前置拦掉,剩下的由业务保证带宽够用。换句话说,"敢降 stale_uploading_s"这件事不仅靠技术升级,也靠业务端优化------两条线一起动,阈值才敢真正压下去。

第二块是 Streams 给的 XPENDING 这种排障抓手。万一误判,能立刻看清 PEL 里挂了什么,不至于一降出事就乱。

从"用户等 6 小时"到"用户等 20 分钟"------这才是原始事故的真正修复点。(顺带一提,resolve 侧的 stale_resolving_s 也做了类似收敛,现在默认 2400 秒。)

换个说法:消息丢失这件事本身并没有被消灭,是从"不可见、恢复慢、每次都要人工介入"变成了"可见、恢复快、系统自己恢复大半"

还有一个没预期到的收获是,换 Streams 给"崩溃恢复"这个问题提供了讨论框架。List 时代"消息丢失"这个问题没法被正视,因为队列层没有任何抽象让你去聊它;换 Streams 之后,consumer、PEL、idle time、ACK 时机、reclaim 都有了名字,团队里终于可以坐下来讨论"ACK 要放在业务前还是业务后"------这种讨论在 List 时代根本开不起来。后面 lease 去重、DB 状态机做权威源、eager ACK 的取舍,都是在 Streams 提供的抽象上谈出答案的。

事后回想,这件事比想象中更有分量。选一个中间件,不光是选它的功能,更是选了一套讨论问题的词汇------这套词汇决定你未来能把问题拆到多细。List 下"消息可靠性"这个话题之所以一直含糊,不是团队不关心,是压根没词:没有 ACK、PEL、consumer idle 这些概念,就没法在任何粒度上切分这个问题,讨论很容易停在"偶尔会丢消息"这种泛泛的描述上,然后散掉。Streams 没让问题本身变简单,但把它切成了可讨论的形状。

所以后来再做中间件选型时,我会额外问一句:用了这个之后,团队能讨论、能观测、能拆分的问题,会变多还是变少? 这条问题没法在 POC 阶段量化,但长期看是最能拉开差距的一条。

潜在问题

上 Streams 之前,我以为换完就能睡安稳觉了。真上完才发现,Streams 只是把"调度消息不丢"这件事做到了一半(PEL 覆盖读取到 ACK 之间那段窗口),业务正确性一分都没帮你。

上线没多久又吃了个典型的亏:有个上传任务业务跑完了、正要 ACK 的时候 Redis 短暂抽风,ACK 失败,XAUTOCLAIM 60 秒之后把消息捞给另一个 worker,那个 worker 又跑了一遍上传------同一个视频传了两次,OSS 上两份文件,用户收到两次 webhook。

这事儿逼着我们认真想"至少一次"这四个字的分量。Streams 的语义是 at-least-once,重复消费是必然事件,不是异常。光靠队列层做不到 exactly-once,必须业务层自己控制。

这之后定下三条规矩。

第一条,DB 是唯一权威状态源。 Stream 只是唤醒机制,告诉 worker "这个 job 你可以看看"。真正能不能处理,要去 DB 抢状态:PENDING → RUNNING 是个条件 UPDATE,谁改成功谁有处理权。抢不到的直接 ACK 走人,不管消息是正常投的还是 reclaim 过来的。

第二条,ACK 策略明确选 eager。 claim 到状态立刻 ACK,不等业务跑完。这个选择是有争议的------部分同事觉得应该业务跑完再 ACK,这样崩溃重试能复用队列。我们选 eager 的逻辑是:DB 状态机本身已经是兜底,Stream 层再背一遍责任是重复的,而且长时间不 ACK 的消息会让 PEL 膨胀、XAUTOCLAIM 判断变复杂。代价前面已经说过------ACK 之后崩溃只能靠 DB fallback 恢复。取舍的结果是:让 DB fallback 承担"业务执行中崩溃"的兜底,让 PEL 承担"读取到 claim 之间崩溃"的兜底,分工清晰。

第三条,上传阶段单独加一层 lease。 光有 DB 状态机还不够------同一个视频如果被不同用户提交两次,会生成两个独立的 job,DB 层抢不到同一行。所以上传阶段用 app:upload:lease:{platform}:{video_id} 这个 key 做视频维度的去重,抢到的真传,没抢到的进 WAITING_UPLOAD 排队,传成功后批量把等待的 job 标成 MERGED 复用结果。

踩完这些坑回头看,整个链路的稳定性大概三成来自 Streams(可观测性 + 前半段窗口的 PEL 保护),七成来自数据库状态机 + DB fallback + lease。网上那些"换 Streams 就解决消息可靠性"的说法要打个大问号------它只解决了一部分消息层的可靠性,业务层的正确性得自己扛。

现在的状态

Streams 上线之后稳定跑了一段时间。类似事故那天的 worker OOM 还会发生------那种事没法避免------但现在已经不是事故了,是日常:

  • 如果崩在"读取到 claim 之间",60 秒内 XAUTOCLAIM 自动接管,用户完全感知不到。
  • 如果崩在"业务执行中",消息确实丢了,但 stale_resolving_s = 1200 会在 20 分钟内回收重投,用户感知是"这个任务有点慢",而不是"失败了"。
  • XPENDING 成了日常排障的第一站,卡单原因一眼能看出来是"没人拿"还是"被谁拿了没 ACK"。

DB fallback 扫描没删,定位从"事故级兜底"变成"正常运行的一部分"------它负责接住 eager ACK 窗口之后崩溃的消息,这部分工作本来就不是 Streams 能覆盖的,不算退化,算分工。

业务层那三条规矩(DB 权威 / eager ACK / lease)比想象中更重要。上 Streams 之后半年又遇到过几起问题,复盘下来根因都不在队列层:一次是 DB 状态机漏了个分支让两个 worker 同时抢到权,一次是 lease 超时配短了导致正在传的任务被抢。这些问题跟 Streams 没关系,换 RabbitMQ 或 Kafka 一样会踩。所以团队里现在的共识是:队列层再好也挡不住业务层的漏洞;业务层稳了,队列层的选择反而没那么关键

如果让我重做一次

List 这一步我可能就跳过了。不是说 List 不行,而是一个认真要做的任务队列迟早要补上 PEL / reclaim / consumer group 这些能力,自己在 List 上造出这一整套,工作量不比直接用 Streams 少,还要维护一堆边角 case。更重要的是,List 不提供"可观测性"这个东西------出事时你连在看什么都不知道,这个代价远比多读两天 Streams 文档要贵。

但具体还要看业务场景,场景足够简单的时候------纯异步通知、丢了也无所谓、没有长耗时任务------List 依然合理。选型这事儿最怕的不是选错,是选的时候没想清楚后面要什么。我们当初用 List 不算错,错的是一直拖到 worker OOM 把消息冲没了才开始认真考虑替换。

也正是这次经历让我改了一个习惯:选型评审的时候,除了看"够不够用",还要问一个问题------万一出了问题,我能不能看到问题? 这个问题在一切顺利的时候显得多余,在出事的那一天会变成最贵的那个问题。

相关推荐
沛沛rh453 小时前
用 Rust 实现用户态调试器:mini-debugger项目原理剖析与工程复盘
开发语言·c++·后端·架构·rust·系统架构
SamDeepThinking4 小时前
Spring AOP记录日志,生产环境的代码长什么样
java·后端·架构
陈天伟教授4 小时前
四川省中小学和职业院校教师校长省级培训专家库专家名单
人工智能·安全·架构
亚马逊云开发者5 小时前
【Bedrock AgentCore】Multi-Agent 架构实战:用 6 个 Agent 打通零售供应链数据→洞察→行动全链路
大数据·架构·零售
踩着两条虫5 小时前
VTJ:技术架构概述
前端·架构·ai编程
超级无敌攻城狮5 小时前
Agent 到底是怎么跑起来的
前端·后端·架构
无心水6 小时前
14、企业级表格|AWS Textract 扫描件表格自动结构化
架构·pdf·云计算·aws·pdf解析·pdf抽取·aws textract
预知同行6 小时前
深入解析 MCP 协议:从架构设计到生产级安全防护实战指南
架构
小谢小哥6 小时前
43-Kafka 核心原理与实战
后端·架构