库存消息消费的正确性设计——从幂等窗口到批量流水线

库存系统每天处理大量到货消息。商品入库、调拨、退货,每一步都产生库存变更消息。但并不是所有 SKU 都需要监控到货量------只有白名单内的 SKU 才纳入累加计算,写入分布式缓存供下游查询。白名单主要在预售及初期阶段使用,随活动变化动态调整。流量不小,每分钟原始消息量在百万级,但白名单命中率约 10%,真正需要处理的只有十几万条。

这个场景的约束条件:

  1. 消息队列是 at-least-once 语义,crash、ack 失败、rebalance 都会触发重投,重复投递是常态不是异常
  2. 到货量是累加操作,不可回滚,重复消费一次就多算一份
  3. 计数缓存 TTL 30 天,因为活动关注的是发售首月的到货情况,多算的那一笔会在系统里沉淀 30 天,不是冲掉就好
  4. 流量不稳定,有明显的峰谷,活动期间流量会激增数倍
  5. 白名单随活动动态变化,需要热更新,不能停服刷新
  6. 服务要高可用,部分节点故障不能导致整个消费链路停摆
  7. 能根据流量大小弹性调整资源节点数,但正确性不能因为扩缩容打折扣
  8. 高吞吐要求批量高效写入,at-least-once 要求每条消息恰好处理一次,两个目标互相冲突

为什么不能直接在缓存里加减

库存累加,说到底是收到消息、提取 SKU 和变化量、往缓存里加一笔。这个操作本身不复杂。复杂的是在百万级流量下,每个环节的代价会被放大到不可忽视的程度。

准确性

at-least-once 意味着重投不可避免。保守按 0.1% 的重投率算,每分钟约 1670 条重复消息,对应约 5000 次重复增量写入,全部计入累计到货量。每小时重复增量约 30 万次,30 天累积超过 2 亿次。不是噪声级误差,是系统级偏差。累加操作不可回滚,多算的那一笔会在缓存里沉淀 30 天。重投不是异常而是常态,问题不是"会不会重投",而是"重投之后怎么保证不多算"。

性能

每条消息 100~500K,除了 SKU 列表和变化信息,还带着大量其他维度的嵌套字段。每分钟 167 万条这样的消息进来,光是解析就要消耗不少 CPU 和内存,而真正需要的只有其中一小部分。

真正需要处理的消息约 16.7 万条/分钟,每条含一个 SKU 通知列表,保守按平均 3 项算,就是 50 万次缓存原子增量写入。消费并发度上限 8,单消费线程要承受约 1000 次/秒的缓存往返。按同机房乐观值每次往返 1ms 算,单线程已接近网络 I/O 瓶颈,8 线程加起来也才 8000 次/秒,余量极小。流量峰值时连这点余量都没有。

还有无效 I/O 的问题。白名单命中率 10%,意味着 90% 的消息是噪声。如果不做前置过滤,先写缓存再发现不该写,每分钟约 150 万次无效缓存操作。相当于 25 个满负载线程的 I/O 全部浪费在噪声上------消费并发度才 8,浪费量是它的三倍。

稳定性

假设不做启动检查,白名单配置缺失时消费者照样订阅。每批消息进来都抛异常,MQ nack,重投,再抛异常,形成 nack 风暴。167 万条/分钟全部 nack,消息堆积,消费 lag 持续增长,最终触发死信队列,消息永久丢失。流量峰谷和活动期间的激增会放大这个问题------低谷时浪费资源,高峰时 nack 风暴更猛。

可观测性

直接加减方案即使产生了上亿次的重复计数偏差,没有任何指标能告诉你偏差有多大、出在哪个环节。缓存写入成功还是失败,重复消息过滤了多少条,白名单命中了多少------全是黑盒。偏差是沉默的,直到下游业务发现数据不对才被动排查,到时候已经沉淀了好几天,定位无从下手。

问题与解决方向

把四个维度的问题和对应的解决方向列出来:

维度 直接增量写入的问题 解决方向
准确性 ~2 亿次/30天重复计数偏差 幂等去重过滤重投
性能 100~500K/条,解析开销大 流式解析,只提取需要的字段
性能 ~50 万次/分钟缓存往返 批量聚合压缩往返
性能 ~150 万次/分钟无效 I/O 白名单前置过滤
稳定性 空配置引发 nack 风暴 → 死信丢消息 启动 fail-fast + 运行期兜底
稳定性 流量峰谷、节点故障、扩缩容 高可用 + 弹性伸缩
可观测性 偏差沉默,无法定位 全链路计数指标 + 按阶段拆分错误

每一层设计都在消除一个具体的、能用数据量化的风险。

消费链路设计

链路全景

处理一条消息要做这些事:拿到消息,先解析出需要的字段,判断要不要处理------业务类型在不在白名单内、SKU 在不在监控范围。要处理的话,还得判断是不是重复投递的。确认没问题,把变化量累加到缓存里。最后标记已处理过,下次重投进来能认出来。

这些步骤有先后顺序。顺序不对,前面做的功白费,甚至引入新问题。

第一个原则:过滤先于去重,去重先于累加。每一步都在减少下一步的工作量。白名单过滤把 90% 的噪声消息挡在门外,幂等去重再把重复投递剔除,最后只有真正需要累加的消息才进入聚合和写入。如果反过来先去重再过滤,90% 的噪声消息也要查一遍幂等键,缓存压力白白增加。

第二个原则:幂等键的占用必须晚于缓存写入。先占键再写缓存,crash 在两者之间,缓存没写但键已占,这条消息永远不会被处理------丢数据。先写缓存再占键,crash 在两者之间,缓存写了但键没占,重投会重复计数------多算。丢数据不可见不可修复,多算可观测可估算,两害相权取其轻。

第三个原则:批量聚合后再写入。500 条消息里可能只有 30~50 个不同的 SKU,每个 SKU 对应几个库位的增量。先在内存里合并,一次管道提交写入缓存,把 1500 次网络往返压成 1 次。

幂等两阶段与崩溃窗口

幂等设计把处理一条消息分成两步:预检查和占用。预检查查幂等键是否存在,存在就跳过;不存在就继续处理。处理完写入缓存后,再占用幂等键(设 TTL 10 分钟)。下次重投进来,预检查发现键已存在,直接跳过。

问题在于写缓存和占幂等键是两次独立的缓存操作,中间存在一个时间窗口。如果进程在这个窗口内 crash------缓存写了,幂等键没占------重投时预检查查不到键,会重新处理,重复计数。这个窗口叫崩溃窗口,无法消除。

为什么不用事务把两步绑成原子操作?分布式缓存不支持跨 key 事务,引入外部事务协调器会增加复杂度和故障点。崩溃窗口的偏差概率极低------需要恰好 crash 在几毫秒的窗口内,且重投在 TTL 过期后到达。TTL 10 分钟,crash 恢复通常在分钟级,恢复后重投的请求预检查能挡住。真正危险的场景是 crash 后超过 TTL 才恢复,此时重投的幂等键已过期,预检查失效。

崩溃窗口无法消除,但可以界定和监控。TTL 10 分钟内恢复的 crash,预检查能挡住重投。超过 TTL 才恢复的,需要人工评估偏差影响。所以崩溃窗口的风险大小取决于 crash 恢复时间,这是一个可观测的指标。

批量聚合

一条消息里是一组 SKU,不是单个 SKU。每条消息的 SKU 通知列表可能包含多个 SKU,每个 SKU 又关联多个库位的数量变化。500 条消息展开后约 1500 个 SKU 项,但去重合并后通常只有 30~50 个不同 SKU。

聚合在内存里完成。收到一个 MQ 批次后,先解析提取需要的字段,过滤掉白名单外的消息,做幂等预检查,然后把剩下的 SKU 增量按 SKU + 库位维度合并到一个 Map 里。Map 的 key 是 SKU,value 是一个子 Map,子 Map 的 key 是库位 ID,value 是该库位的累计增量。整个批次处理完后,一次管道提交把 Map 里所有增量写入缓存。

聚合的好处不只是减少网络往返。它还让写入失败的处理变得简单------整批成功或整批重投,不需要做部分重试。部分重试要跟踪哪些 SKU 写成功了哪些没有,复杂度高且容易出错。整批重投虽然会重复处理,但幂等机制保证不会重复计数(除了崩溃窗口)。

白名单:加载与防护

白名单的两层过滤

白名单其实有两层。第一层是业务类型白名单,过滤掉不属于监控范围的消息类型。第二层是 SKU 白名单,从命中业务类型的消息里再筛出需要监控的 SKU。两层过滤下来,167 万条/分钟的原始消息只剩几百条进入聚合。

全量加载与增量刷新

白名单不能每次消息来了都去远程查,那样缓存往返比消息处理本身还频繁。做法是启动时全量加载白名单到本地内存,运行期间通过配置中心的增量推送保持更新。

全量加载的问题是:如果配置中心暂时不可用怎么办?如果加载到的白名单是空的怎么办?空白名单意味着所有消息都被过滤掉,表面上消费正常,实际上数据全丢了------比 nack 风暴更隐蔽。

fail-fast 双层防护

启动期的防护是 fail-fast。全量加载时如果配置中心不可用,或者加载到的白名单为空,直接启动失败。服务起不来,部署流水线会报错,人能看到。比起起来之后静默丢数据,启动失败的影响范围小得多。

运行期的防护是兜底。配置中心增量推送一个空白名单过来,不能把服务杀了,但也不能默默接受。做法是检测到空白名单时 nack 消息,触发 MQ 重投,同时告警。消息不会丢(重投在),但消费暂停,逼着人来看。

两层防护的逻辑:启动期暴露问题成本最低------服务还没接管流量;运行期暴露问题成本较高------需要 nack 和告警配合。越早暴露影响越小。

容错与停机

优雅停机

容器化部署的每次发版都会触发进程退出。如果消费者正在处理一个批次,直接杀进程,批次内已写入缓存但未占用幂等键的消息会进入崩溃窗口。

优雅停机做的事是给消费链路留出收尾时间。容器收到停止信号后,先通知 MQ 框架停止拉取新消息,然后等当前正在处理的批次跑完,再销毁 bean、退出进程。容器给的这个超时窗口是 60 秒,正常批次处理在秒级,足够收尾。

这个时间窗口是不是真生效,需要能观测------停机时打两条日志记录关闭起点和终点,时间差就是实际留给消费链路的收尾时间。时间差接近 0 说明没来得及收尾就被杀了,部署时可能有消息丢失。

高可用与弹性伸缩

消费端是集群消费模式。多个实例共同消费同一个 topic,MQ 消息按分区分配给各实例。一个实例挂了,它负责的分区会被重新分配给其他实例,消息不丢,消费不停。

弹性伸缩靠增减实例数实现。流量低谷时缩容,省资源;活动期间扩容,扛流量。扩容后新实例加入消费组,MQ 重新分配分区,消费速率提升。缩容时实例退出,分区再分配给剩余实例。

但扩缩容会触发 rebalance,rebalance 会带来消息重投。分区重新分配的瞬间,正在处理但还没 ack 的消息会被重投给新分配到的实例。这和 crash 场景一样------幂等机制保证重投的消息不会重复计数,除了崩溃窗口。

所以扩缩容的正确性保证和 crash 恢复是同一套机制。不需要为弹性伸缩单独设计正确性方案。只要幂等两阶段和崩溃窗口的界定做到位,扩缩容就是安全的。需要注意的一点是缩容频率------频繁缩容会增加 rebalance 次数,每次 rebalance 都有崩溃窗口的风险。活动期间流量大但稳定时不缩容,等活动结束流量回落了再缩,比按固定阈值自动伸缩更稳妥。

可观测性

每一层设计都提到"能发现、能告警"。但前提是有指标可看。如果消费链路是个黑盒,出了偏差只能等下游业务投诉,那就谈不上工程化。

双轨计数

监控平台有计数器指标,面向告警聚合,但在应用内部读不到累计值。排查问题时,不一定每次都能打开监控控制台------有时候就是想请求一下接口,看看各阶段计数对不对。

所以在应用内维护一份原子计数器副本,和监控平台的指标同步递增。监控平台的数据给告警系统用,应用内的副本给接口查询用。两套数据,同一个来源,不互相依赖。

错误按阶段拆

缓存操作在链路里出现三次:预检查、写入、占用。三次操作的失败语义完全不同。

预检查失败按放行处理------查不到就当没查过,交给占用阶段兜底。写入失败意味着这批聚合数据全丢了,MQ 会重投,数据不丢只是延迟。占用失败意味着缓存已经写了但幂等键没占上,重投会重复计数------这是最危险的情况。

如果这三个错误混在一个指标里,看到错误数涨了,不知道是哪种错误,不知道该不该慌。拆开之后一眼就知道问题出在哪一步,该做什么响应。

两类需要告警的情况

第一类是写入失败。聚合数据全部丢失,MQ 会重投,数据不会真的丢,但持续失败会导致消费积压。响应动作是看缓存是不是挂了,等恢复后重投的消息自动追上。

第二类是占用失败但写入成功。缓存写了,幂等键没占上,重投会重复计数。响应动作不一样------数据已经多算了,需要人工评估偏差影响。等重投不会解决问题,因为问题已经发生了。

两类情况的响应动作完全不同,所以得分开看。混在一起的话,值班人员不知道是该等重投还是要人工介入。

收束

可观测性的核心思路是:链路上每一步都有计数,每个失败路径都有对应指标,每个指标都有明确的健康预期。排查问题时从接收数开始往后看,哪一步的比例不对,问题就定位到哪一步。指标不是事后补的仪表盘,是设计的一部分------在设计链路时就想好每一步会出什么问题,出问题时怎么看出来。

高吞吐和 at-least-once 这对矛盾没有完美解。硬要追求恰好一次,得引入两阶段事务、分布式锁、外部状态存储,每多一层组件就多一个故障点,多一份运维负担。崩溃窗口的偏差概率极低,花大力气消灭它,带来的复杂度可能比偏差本身更危险。我的做法是把偏差圈在一个已知范围里,盯着它,够了。

做权衡得有数据依据。每分钟 167 万条消息、重投率 0.1%、幂等键 TTL 10 分钟------这些数字决定了崩溃窗口多大、批量聚合提升多少倍吞吐、白名单前置省下多少 I/O。没有数据,"崩溃窗口很小"只是个感觉,该花多少力气控制它也无从判断。

相关推荐
彧azz1 小时前
操作系统时间管理与系统核心板块学习总结
c语言·笔记·学习·系统架构
风123456789~4 小时前
【架构专栏】第15章 面向服务架构设计 2/3
系统架构
风123456789~4 小时前
【架构专栏】第15章 面向服务架构设计 1/3
系统架构
智慧物业老杨7 小时前
物业日常巡查的数智化重构:从“打卡式巡检“到“闭环式风控“
android·java·人工智能·系统架构·rxjava
数安旭说15 小时前
从“堆叠工具”到“一体化治理”:端点安全的技术演进与实践观察
网络安全·系统架构·数据安全·企业安全·端点安全·防泄密·一体化管理
Liaiyang6619 小时前
空圈容错视角下的无人机全链路审计:从理论框架到耦合式检验
人工智能·pytorch·python·深度学习·系统架构·自动驾驶·无人机
珠海西格电力19 小时前
零碳园区管理系统“智慧大脑”功能对园区运营成本的影响有哪些?
大数据·人工智能·安全·系统架构·能源
风123456789~21 小时前
【架构设计】第14章 云原生架构设计 2/2
系统架构