用 Redis Stream 顶消息队列:先补上清理、再平衡与积压口径

一、”非平替”这句话在工程上指什么
据公开报道,有讲用 Redis Stream 搭轻量队列的实践把话说得很直接:这不是替代品。它没有协调器,不会自动做消费者的分片再分配,也不会自己清理历史数据;确认动作和待确认集合需要主动管理,漏做确认会让消息永远停在待确认队列里;挂掉的消费者没有心跳剔除机制;第一次读取必须显式指定起始位置;写入时标识由毫秒时间加序号自动拼成。
这份清单值得逐条读,因为它列的不是功能缺失而是责任转移。接口层面的东西很好搬:生产、消费、消费者组、确认,都能找到对应的调用。搬过去之后,原来由集群协调器承担的运维语义没有跟过来,得自己实现并且自己监控。站内有一篇专门讨论成熟队列位点提交粒度的文章,本篇不重复那个问题域,讲的是一件相反的事:在一个没有协调器也不会自清理的组件上承担队列职责,要自己补哪三件运维语义,以及不补时故障会以什么形态出现。
不补的形态通常不是崩溃,而是两种很慢的症状:消息莫名重复,内存缓慢上涨。前者来自待确认集合无人处理,后者来自历史条目从未裁剪。这两个症状都有足够的观察期,所以完全可以在上线时被判断成”运行正常”。
二、三项运维语义在无协调器条件下的实现方式
| 运维语义 | 组件给到的原语 | 需要自己实现的部分 | 不实现时的表现 | 谁来盯 |
|---|---|---|---|---|
| 分片再平衡 | 消费者组与按组领取 | 自己的心跳与租约表、成员进出时的分片重分配、退出前排空在途消息 | 成员消失后它的分片无人领取;新成员加入与旧成员退出交错造成同一分片双跑 | 应用侧的租约维护任务加值班告警 |
| 保留期清理 | 按长度或者按时间裁剪流 | 裁剪策略选择、与下游消费速度联动、误删后的补齐路径 | 内存只涨不降;磁盘型部署则是数据目录无上限增长 | 发布流程里的固定步骤,不能只靠定时任务 |
| 积压口径 | 组信息里的待确认数量与条目标识 | 深度、最老未确认年龄、生产与消费速率差三个指标的采集与阈值 | 积压被发现的方式是业务投诉或者实例被打满 | 监控平台,阈值来自业务时限反推 |
双跑这一项要单独强调。没有协调器时,”这个分片现在归谁”这件事由你的租约表决定,而租约表一旦靠应用自己写,就会遇到经典的两个问题:租约到期时间和心跳周期之间的抖动、时钟不同步导致两边都以为自己持有。我们的处理是给租约加栅栏条件,领取和确认动作必须带上自己那一次的分片版本,版本不匹配就放弃并重新排队,而不是强行覆盖。这个做法不新鲜,但必须显式写,因为组件不会替你做。
退出的顺序也要定:先停止领取新消息,再把在途的处理完并逐条确认,最后从租约表里摘掉自己。跳过中间那步,等于把自己领走但未确认的消息留给下一次重投。
三、清理策略与积压口径要一起设计
裁剪有两种口径,按条目数和按时间。按条目数好算、和内存关系直接,但它和真实的时间窗口脱钩,流量翻倍时保留的业务时间就被压缩。按时间和业务语义对得上,代价是流量低谷时保留的条目更多、峰值时可能来不及裁剪。两种都要设硬上限兜住,避免异常流量把上限撑爆。
裁剪和下游消费速度必须联动,否则会删掉还没被消费的数据。可用的做法是把裁剪线放在”所有消费者组都已确认的位置之后”,而不是放在流的尾部。这意味着裁剪前要读一次各组进度,任何一个组滞后,裁剪线就不能越过它;如果有临时加入的调试组,要设过期时间,否则一个忘记删除的组会把整条流的保留期无限拉长。删错了能不能补,取决于你有没有第二条来源:有原始事件存储可以回放,就写成补偿流程;没有,就要在裁剪前留一份落到对象存储的备份,并且把”这份备份留多久”写进文档。
积压口径的三个数各自说明的事情不一样。待确认深度大,说明有消费者领了没还,可能是处理慢,也可能是漏做确认;最老未确认消息的年龄,直接对应业务能等多久,比深度更有指导意义;生产速率与消费速率的差,是趋势指标,只有它持续为正才说明容量真的不够。三个数分开看,混成一个”积压条数”就什么都看不出来。
四、确认语义、起始位点与幂等
确认时机只有两种,各自导致的东西不同。处理前确认,一旦处理失败消息已经不在待确认集合里,结果是丢;处理后确认,失败或超时会让它留在待确认集合里被重投,结果是重复。队列语义上”至少一次”是最容易实现的,代价全压在幂等上。所以要不要自补的东西不是”事务消息”这种名词,而是一份幂等键设计:键要落在业务实体上而不是消息标识上,因为同一业务动作可能有多条消息;键要有存储位置和一个明确的去重窗口;窗口长度由业务的可容忍重复时间决定。
失败重投要有次数上限和退避,没有上限的重投会把一个坏消息变成整条分片的阻塞源。超过上限的进人工介入队列,队列要有归属人和处理时限,否则它只是把积压换了个地方。人工介入之后要能重新投递,也要能标记为放弃并留原因。
起始位点的选择有四种,后果各不相同:
- 从最早可读的位置开始,恢复期会重放全部在流里的历史,适合初次接入,不适合每次重启。
- 只读新到达的消息,重启期间未领取的部分会被永久跳过。
- 先领取本组已确认过的和待确认的未处理部分,再接新消息,这是重启恢复该选的那一种。
- 按业务时间戳续读,需要自己维护位点存储,好处是不受组件保留期约束。
按用途看,第三种配合待确认集合才是正确形态,而第四种只在保留期比业务回溯窗口短时才有必要。首次读取要显式给位置这件事,看起来是接口细节,实际是在提醒你:这个组件不替你记进度,进度是你的责任。
五、扩展与容量:两条绕不过的约束
单个流的容量不能被”再加一台机器”扩出去。组件不会把一个流的数据自动分到更多节点,能做的只有横向开更多流、自己在前面做路由。路由键的选择因此变成一个业务决定:按实体取模最均匀,但同一实体的有序性会被打散到不同流;按实体哈希到固定数量的流能保住同一实体的顺序,代价是分片数量在建立时就要定,后期改数量等于重排历史。这两条之间没有免费解,只有提前定下来。
内存模型这条约束更硬。全内存的存储意味着保留期里的每一条数据都占内存,量级和”磁盘上按顺序追加”的组件完全不是一个数量级。这就是为什么”消息堆三天再消费完”这类要求在这个组件上代价很高:不是做不到,是三天对应的容量要一直备着,还要给峰值留出余量。反过来,短时缓冲、任务分发、状态变更通知这类保留期只需分钟到小时级、并且已经有一套现成实例的场景,用现有组件承担就很划算,少一个组件就少一套运维。
判断边界我们按四问答:吞吐是不是已经超出单实例可承担的量;有序范围要不要跨实体;回溯窗口是不是长于合理保留期;是不是有多个下游组要各自独立地订阅同一份数据。四问里中两条以上,就应该上真正的流式系统而不是继续加裁剪脚本。这个判断和”哪个组件更好”无关,两者各在自己设定的场景里成立,我们不做这种比较。
六、把运维语义变成可监控的字段
核心交付物的第二张表是监控字段。阈值不要照搬默认值,全部由业务最大可容忍延迟反推:先问业务”一条消息最晚多久处理完还能接受”,再把这个时限拆到队列等待、消费处理两段,队列等待段的可容忍时长才是下面这些阈值的来源。
| 监控字段 | 含义 | 阈值怎么定 | 触发后的动作 |
|---|---|---|---|
| 待确认条目数 | 已被领取但未确认的总量 | 按正常在途量的倍数设,倍数由消费平均耗时与并发决定 | 检查是否有消费者崩溃未释放,查漏做确认的代码路径 |
| 最老待确认年龄 | 待确认集合里最老一条等了多久 | 直接取业务可容忍延迟的一半 | 提前介入,给重投留时间 |
| 组的已投递位置与写入尾部位置差 | 大致反映落后程度 | 按业务时限反推的条目数 | 判定是容量不足还是消费卡住 |
| 生产与消费速率差 | 趋势指标 | 连续为正的周期数,而不是单点数值 | 扩容或者降级非关键消费者 |
| 租约表活跃成员数 | 自己实现的成员状态 | 与期望并发数比对 | 有人掉线,检查再平衡是否卡住 |
| 流长度与裁剪线位置 | 保留期是否在按计划执行 | 与裁剪策略声明的上限比对 | 裁剪任务失败或被某个滞后组挡住 |
| 人工介入队列条目数 | 死信与放弃处理情况 | 按值班可处理量设 | 说明失败原因集中在某类,需要修上游 |
灰度迁移阶段还要多两条:双写期间的对账口径和回退窗口。双写会同时产生两份数据,对账要比对条数还是比对业务结果,必须提前定,我们的做法是按业务结果对,条数只做辅助;切主之前的对账要连续多轮通过,而不是通过一次。回退窗口要写明保留多久,窗口内旧链路必须仍处于可切回状态,而不是”已经停了但没人确认过”。
洹拓内部把这套东西叫”运维语义清单”,任何一个不承担队列职责的组件被推上去承担时,先按三件事过一遍:谁清理、谁再平衡、谁报积压。这三件事都有归属人和对应字段之后,用现成实例做队列是一个正常取舍;没定就把接口对上就切流量,那么等待的就是开头说的那两个慢症状。