白板上画着一条横向的数据流,下方三个格子写着清理、再平衡、积压,格子边缘贴着待办纸条

一、”非平替”这句话在工程上指什么

据公开报道,有讲用 Redis Stream 搭轻量队列的实践把话说得很直接:这不是替代品。它没有协调器,不会自动做消费者的分片再分配,也不会自己清理历史数据;确认动作和待确认集合需要主动管理,漏做确认会让消息永远停在待确认队列里;挂掉的消费者没有心跳剔除机制;第一次读取必须显式指定起始位置;写入时标识由毫秒时间加序号自动拼成。

这份清单值得逐条读,因为它列的不是功能缺失而是责任转移。接口层面的东西很好搬:生产、消费、消费者组、确认,都能找到对应的调用。搬过去之后,原来由集群协调器承担的运维语义没有跟过来,得自己实现并且自己监控。站内有一篇专门讨论成熟队列位点提交粒度的文章,本篇不重复那个问题域,讲的是一件相反的事:在一个没有协调器也不会自清理的组件上承担队列职责,要自己补哪三件运维语义,以及不补时故障会以什么形态出现。

不补的形态通常不是崩溃,而是两种很慢的症状:消息莫名重复,内存缓慢上涨。前者来自待确认集合无人处理,后者来自历史条目从未裁剪。这两个症状都有足够的观察期,所以完全可以在上线时被判断成”运行正常”。

二、三项运维语义在无协调器条件下的实现方式

运维语义 组件给到的原语 需要自己实现的部分 不实现时的表现 谁来盯
分片再平衡 消费者组与按组领取 自己的心跳与租约表、成员进出时的分片重分配、退出前排空在途消息 成员消失后它的分片无人领取;新成员加入与旧成员退出交错造成同一分片双跑 应用侧的租约维护任务加值班告警
保留期清理 按长度或者按时间裁剪流 裁剪策略选择、与下游消费速度联动、误删后的补齐路径 内存只涨不降;磁盘型部署则是数据目录无上限增长 发布流程里的固定步骤,不能只靠定时任务
积压口径 组信息里的待确认数量与条目标识 深度、最老未确认年龄、生产与消费速率差三个指标的采集与阈值 积压被发现的方式是业务投诉或者实例被打满 监控平台,阈值来自业务时限反推

双跑这一项要单独强调。没有协调器时,”这个分片现在归谁”这件事由你的租约表决定,而租约表一旦靠应用自己写,就会遇到经典的两个问题:租约到期时间和心跳周期之间的抖动、时钟不同步导致两边都以为自己持有。我们的处理是给租约加栅栏条件,领取和确认动作必须带上自己那一次的分片版本,版本不匹配就放弃并重新排队,而不是强行覆盖。这个做法不新鲜,但必须显式写,因为组件不会替你做。

退出的顺序也要定:先停止领取新消息,再把在途的处理完并逐条确认,最后从租约表里摘掉自己。跳过中间那步,等于把自己领走但未确认的消息留给下一次重投。

三、清理策略与积压口径要一起设计

裁剪有两种口径,按条目数和按时间。按条目数好算、和内存关系直接,但它和真实的时间窗口脱钩,流量翻倍时保留的业务时间就被压缩。按时间和业务语义对得上,代价是流量低谷时保留的条目更多、峰值时可能来不及裁剪。两种都要设硬上限兜住,避免异常流量把上限撑爆。

裁剪和下游消费速度必须联动,否则会删掉还没被消费的数据。可用的做法是把裁剪线放在”所有消费者组都已确认的位置之后”,而不是放在流的尾部。这意味着裁剪前要读一次各组进度,任何一个组滞后,裁剪线就不能越过它;如果有临时加入的调试组,要设过期时间,否则一个忘记删除的组会把整条流的保留期无限拉长。删错了能不能补,取决于你有没有第二条来源:有原始事件存储可以回放,就写成补偿流程;没有,就要在裁剪前留一份落到对象存储的备份,并且把”这份备份留多久”写进文档。

积压口径的三个数各自说明的事情不一样。待确认深度大,说明有消费者领了没还,可能是处理慢,也可能是漏做确认;最老未确认消息的年龄,直接对应业务能等多久,比深度更有指导意义;生产速率与消费速率的差,是趋势指标,只有它持续为正才说明容量真的不够。三个数分开看,混成一个”积压条数”就什么都看不出来。

四、确认语义、起始位点与幂等

确认时机只有两种,各自导致的东西不同。处理前确认,一旦处理失败消息已经不在待确认集合里,结果是丢;处理后确认,失败或超时会让它留在待确认集合里被重投,结果是重复。队列语义上”至少一次”是最容易实现的,代价全压在幂等上。所以要不要自补的东西不是”事务消息”这种名词,而是一份幂等键设计:键要落在业务实体上而不是消息标识上,因为同一业务动作可能有多条消息;键要有存储位置和一个明确的去重窗口;窗口长度由业务的可容忍重复时间决定。

失败重投要有次数上限和退避,没有上限的重投会把一个坏消息变成整条分片的阻塞源。超过上限的进人工介入队列,队列要有归属人和处理时限,否则它只是把积压换了个地方。人工介入之后要能重新投递,也要能标记为放弃并留原因。

起始位点的选择有四种,后果各不相同:

  1. 从最早可读的位置开始,恢复期会重放全部在流里的历史,适合初次接入,不适合每次重启。
  2. 只读新到达的消息,重启期间未领取的部分会被永久跳过。
  3. 先领取本组已确认过的和待确认的未处理部分,再接新消息,这是重启恢复该选的那一种。
  4. 按业务时间戳续读,需要自己维护位点存储,好处是不受组件保留期约束。

按用途看,第三种配合待确认集合才是正确形态,而第四种只在保留期比业务回溯窗口短时才有必要。首次读取要显式给位置这件事,看起来是接口细节,实际是在提醒你:这个组件不替你记进度,进度是你的责任。

五、扩展与容量:两条绕不过的约束

单个流的容量不能被”再加一台机器”扩出去。组件不会把一个流的数据自动分到更多节点,能做的只有横向开更多流、自己在前面做路由。路由键的选择因此变成一个业务决定:按实体取模最均匀,但同一实体的有序性会被打散到不同流;按实体哈希到固定数量的流能保住同一实体的顺序,代价是分片数量在建立时就要定,后期改数量等于重排历史。这两条之间没有免费解,只有提前定下来。

内存模型这条约束更硬。全内存的存储意味着保留期里的每一条数据都占内存,量级和”磁盘上按顺序追加”的组件完全不是一个数量级。这就是为什么”消息堆三天再消费完”这类要求在这个组件上代价很高:不是做不到,是三天对应的容量要一直备着,还要给峰值留出余量。反过来,短时缓冲、任务分发、状态变更通知这类保留期只需分钟到小时级、并且已经有一套现成实例的场景,用现有组件承担就很划算,少一个组件就少一套运维。

判断边界我们按四问答:吞吐是不是已经超出单实例可承担的量;有序范围要不要跨实体;回溯窗口是不是长于合理保留期;是不是有多个下游组要各自独立地订阅同一份数据。四问里中两条以上,就应该上真正的流式系统而不是继续加裁剪脚本。这个判断和”哪个组件更好”无关,两者各在自己设定的场景里成立,我们不做这种比较。

六、把运维语义变成可监控的字段

核心交付物的第二张表是监控字段。阈值不要照搬默认值,全部由业务最大可容忍延迟反推:先问业务”一条消息最晚多久处理完还能接受”,再把这个时限拆到队列等待、消费处理两段,队列等待段的可容忍时长才是下面这些阈值的来源。

监控字段 含义 阈值怎么定 触发后的动作
待确认条目数 已被领取但未确认的总量 按正常在途量的倍数设,倍数由消费平均耗时与并发决定 检查是否有消费者崩溃未释放,查漏做确认的代码路径
最老待确认年龄 待确认集合里最老一条等了多久 直接取业务可容忍延迟的一半 提前介入,给重投留时间
组的已投递位置与写入尾部位置差 大致反映落后程度 按业务时限反推的条目数 判定是容量不足还是消费卡住
生产与消费速率差 趋势指标 连续为正的周期数,而不是单点数值 扩容或者降级非关键消费者
租约表活跃成员数 自己实现的成员状态 与期望并发数比对 有人掉线,检查再平衡是否卡住
流长度与裁剪线位置 保留期是否在按计划执行 与裁剪策略声明的上限比对 裁剪任务失败或被某个滞后组挡住
人工介入队列条目数 死信与放弃处理情况 按值班可处理量设 说明失败原因集中在某类,需要修上游

灰度迁移阶段还要多两条:双写期间的对账口径和回退窗口。双写会同时产生两份数据,对账要比对条数还是比对业务结果,必须提前定,我们的做法是按业务结果对,条数只做辅助;切主之前的对账要连续多轮通过,而不是通过一次。回退窗口要写明保留多久,窗口内旧链路必须仍处于可切回状态,而不是”已经停了但没人确认过”。

洹拓内部把这套东西叫”运维语义清单”,任何一个不承担队列职责的组件被推上去承担时,先按三件事过一遍:谁清理、谁再平衡、谁报积压。这三件事都有归属人和对应字段之后,用现成实例做队列是一个正常取舍;没定就把接口对上就切流量,那么等待的就是开头说的那两个慢症状。