订单量 5 万,推送 70 万+:一次 Redis Stream 积压事故后的完整处理过程
2026/6/9 18:27:23 网站建设 项目流程

背景:

这次问题发生在 ERP 与电商平台的订单同步链路中。

平台会在订单状态发生变化时主动推送订单数据,ERP 侧通过 Redis Stream 接收消息,再由消费者完成订单标准化、订单源表入库以及订单总表同步。

一、Redis Stream 开始持续积压

项目上线运行后不久,监控开始报警。

Redis Stream 长度持续增长:

XLEN ​ 10415 ↓ 116606 ↓ 135448 ↓ 158536

消息不断堆积。

但与此同时,消费者并不是挂了。当时观察到:

消费者正常运行 Pending 很少 没有大量失败重试

这说明问题不是消费失败,也不是大量消息卡在 Pending 里没有 ACK。

真正的问题是:

生产速度 > 消费速度

消费者一直在工作,但处理速度追不上平台的推送速度。


二、第一反应:先恢复业务,而不是马上重构

真实生产环境里,遇到这种问题,第一目标不是立刻写一个完美方案。

当时 Redis Stream 已经积压了十几万条消息。如果这个时候直接进入:

分析原因 设计方案 开发代码 测试上线

至少需要一天以上。

但业务等不了。

商家在等订单同步,运营在等数据,ERP 后续流程也依赖订单状态继续流转。

所以当时最重要的目标是:

先把积压处理掉 先让订单同步恢复 先把业务影响压下来

也就是先止血。


三、为什么不是无脑扩容

最直接的止血方式,是增加消费者实例。

但这里有一个前提:

不是所有订单同步系统都能直接扩消费者

因为订单状态消息天然存在乱序问题。

比如同一个订单,真实状态流转是:

已付款 → 已发货 → 已签收

但消息到达消费者的顺序可能变成:

已签收 → 已付款 → 已发货

如果系统只是简单地“后写入覆盖先写入”,那么多个消费者并发处理时,很可能出现:

新状态先写入 旧状态后覆盖 订单状态回退

这会比积压本身更严重。

所以在决定扩消费者之前,必须先确认一个问题:

系统能不能承受乱序和并发消费?

我当时敢直接扩容,并不是因为想当然觉得“多开几个实例就行”。

而是因为这个系统在上线前已经做过一个关键兜底设计:

状态时间版本控制

四、上线前做过的兜底:状态时间版本控制

在设计订单源表同步时,我没有依赖 Redis Stream 的消费顺序来保证订单状态正确。

因为订单同步系统天然会遇到:

重复推送 乱序推送 旧状态晚于新状态到达

所以订单源表采用:

ON CONFLICT (ref_ol_id) DO UPDATE

并在更新时增加业务时间判断:

incoming_time > existing_time ​ OR ​ ( incoming_time = existing_time AND hash_changed )

核心思想是:

新状态可以覆盖旧状态 旧状态不能覆盖新状态

例如数据库里已经是:

已签收

后面又来了一条更早的:

已付款

即使这条消息被消费者处理到了,也不会把订单状态回退。

也就是说,这套机制保证的是:

最终状态正确

而不是依赖:

每条消息严格按顺序执行

这点非常关键。

因为有了这个兜底,我才能在发现 Redis Stream 积压时,快速增加消费者实例,而不用担心多个实例并发消费直接把订单状态写乱。


五、十几分钟扩出四个消费者实例

确认状态时间兜底可以承受乱序和并发消费后,我采用了最直接的止血方案:

增加消费者实例

当时我直接在:

办公室电脑 个人电脑 笔记本 测试环境

启动多个项目实例。

这些实例全部连接生产 Redis,共同消费同一个 Consumer Group。

整个过程大约十几分钟。

没有改代码,没有发版本,只是利用现有架构快速横向扩容。

效果很明显:

消费速度提升 积压增长趋势被压制 业务同步逐步恢复

这一步的目的不是彻底解决问题,而是先把业务影响压下来。

扩容争取到了时间,后面才有空间继续分析根因和重构消费者结构。


六、项目背景:ERP 订单同步链路

这次问题发生在 ERP 与平台的订单同步链路中。

整体流程大致如下:

订单推送 ↓ Redis Stream ↓ 消费者 ↓ 订单标准化 ↓ 订单源表 ↓ 订单总表同步

平台会在订单状态发生变化时主动推送数据,例如:

创建订单 买家付款 商家发货 买家签收 售后申请 售后完成

项目刚上线前,我对数据量做过一次预估。

当时的估算方式比较简单:

订单量 × 5

假设每天 5 万订单,那么每天大约是:

25 万条消息

按照当时的消费能力来看,这个量级完全可以处理。

但实际运行后,很快发现事情没有这么简单。


七、真正的问题不是订单量,而是状态变化量

积压暂时控制住后,开始分析根因。

最开始我一直用“订单量”来估算数据规模:

订单量 = 数据量

但实际统计后发现,这个理解是错的。

我原来的预估是:

订单量 × 5

但真实情况是,一个订单在生命周期内可能产生十几次推送。

例如:

订单创建 ↓ 买家付款 ↓ 部分发货 ↓ 全部发货 ↓ 买家签收 ↓ 售后申请 ↓ 售后完成

而且中间还有一些平台侧状态变更、物流状态变更、售后状态变更。

统计后发现:

平均超过 15 次推送

也就是说:

5 万订单 × 15 次状态变化 = 75 万条消息

真正压垮系统的不是订单数量,而是订单状态变化次数。

这是这次事故里最大的认知偏差。

系统容量评估时,不能只看订单量,而要看状态流转次数、重复推送次数和峰值推送速度。


八、为什么状态时间控制还不够

前面提到的状态时间版本控制,解决的是数据正确性问题。

它能保证:

旧状态不会覆盖新状态 乱序消息不会导致订单状态回退 重复消息不会反复污染数据

但它没有解决吞吐问题。

因为即使一条消息最终会被忽略,消费者仍然需要做完整处理:

读取消息 解析报文 标准化字段 查询数据库 执行判断 写入或跳过

这些动作都会消耗 CPU、数据库连接和 IO 资源。

所以结果就是:

数据正确性有保障 但队列仍然会积压

短期可以靠多实例扩容顶住,但长期看,消费模型必须重构。


九、旧消费模型的问题

旧版本架构是:

Redis Stream ↓ 消费线程 ↓ 订单标准化 ↓ 订单源表同步 ↓ 订单总表同步 ↓ ACK

也就是说,消费者线程必须等整个业务流程处理完成后,才会 ACK 并继续处理下一条消息。

这个模型在低并发时没问题。

但当消息量从预估的 25 万变成 70 万+ 后,问题就暴露出来了:

消费线程被业务逻辑拖住 数据库操作拖慢 ACK Redis Stream backlog 持续增长

Redis Stream 本来只是入口缓冲,但旧模型里它被迫承担了完整业务处理链路的等待成本。

这就导致消费速度被最慢的业务环节拖住。


十、第二轮优化:重构消费者结构

最终我采用了新的消费模型:

Redis Stream ↓ 入口消费线程 ↓ Hash 分片队列 ↓ 8 个 Worker ↓ 业务处理

核心思路是:

入口线程只负责快速拉取消息 业务处理交给本地 Worker 并行执行

这样 Redis Stream 的消费线程不再被订单标准化、数据库同步、总表同步这些耗时操作拖住。


十一、Hash 分片:同订单串行,不同订单并行

分片规则是根据订单相关字段生成 partition key,然后取模:

partition = Math.floorMod(partitionKey.hashCode(), 8);

例如:

订单 A → 分片 1 订单 B → 分片 5 订单 C → 分片 7

这样设计有两个好处。

第一,相同订单永远进入同一个队列:

订单 A 已付款 订单 A 已发货 订单 A 已签收

都会由同一个 Worker 串行处理。

这样可以避免同一个订单被多个线程并发更新,减少状态覆盖和锁竞争问题。

第二,不同订单可以并行处理:

订单 A → Worker 1 订单 B → Worker 5 订单 C → Worker 7

最终实现:

同订单串行 不同订单并行

这比单纯开多个线程更安全,也更符合订单状态同步的业务特点。


十二、ACK 策略调整:快速释放 Redis Backlog

旧版本 ACK 策略是:

业务处理完成 ↓ ACK

新版本调整为:

消息进入本地分片队列 ↓ 立即 ACK

也就是说,Redis Stream 不再承担完整业务处理过程的等待。

它只负责:

入口缓冲 消息分发

真正耗时的部分:

订单标准化 订单源表同步 订单总表同步

全部交给本地 Worker 处理。

这样做之后,Redis Stream backlog 可以快速下降,消费线程也不会被数据库操作拖住。


十三、这个设计的代价

当然,这种方案也有代价。

因为消息进入本地队列后,Redis 已经 ACK。

如果此时进程崩溃:

Redis 不会再次投递这条消息

也就是说:

吞吐能力提升 可靠性有所下降

这是一次明确的工程权衡。

在当前业务场景下可以接受这个取舍,原因是:

平台存在状态重复推送 订单状态可以通过后续推送修正 数据库有状态时间版本控制兜底 核心目标是尽快追平生产速度

如果后续对可靠性要求更高,可以继续演进为:

本地持久化队列 ACK 后置但拆分批处理 失败补偿任务 订单状态定时对账

但在当时的生产压力下,快速降低 backlog、恢复同步能力是更优先的目标。


十四、最终效果

优化完成后,系统从:

单线程同步处理

演进为:

入口线程快速消费 + 8 路 Hash 分片 Worker 并行处理

Redis Stream backlog 快速下降。

最终:

lag = 0

消费能力追平生产速度,系统恢复稳定。


十五、复盘与思考

1. 不要只用订单量估算系统容量

订单同步系统真正的压力来源,不是订单数量本身,而是状态变化次数。

应该关注:

订单量 状态流转次数 重复推送次数 峰值推送速度 数据库写入能力

这次原本预估:

3 万订单 × 5 = 15 万消息

实际变成:

3 万订单 × 14+ = 40 万+ 消息

容量评估直接偏差了接近 3 倍。

2. 关键兜底设计决定事故处理空间

这次能在十几分钟内扩出四个消费者实例,不是因为随便加机器就行,而是因为系统一开始就做了状态时间版本控制。

它保证了:

旧状态不能覆盖新状态 乱序消息不会导致状态回退 多实例消费不会轻易写乱订单

很多时候,线上事故能不能快速止血,取决于前期有没有留下安全边界。

3. 先止血,再根治

当 Redis Stream 已经积压十几万条消息时,最重要的不是马上写出一个完美方案,而是先恢复业务。

这次处理顺序是:

发现 Redis Stream 持续积压 ↓ 确认不是消费失败,而是生产速度大于消费速度 ↓ 检查系统已有状态时间控制兜底 ↓ 快速扩多个消费者实例止血 ↓ 压住 backlog 增长趋势 ↓ 争取时间分析根因 ↓ 重构消费者结构

这比一上来就重构更符合真实生产环境。

4. 最终状态正确,比严格消息顺序更重要

对于订单同步系统来说,严格保证每条消息按顺序执行,成本很高。

但很多业务真正需要的是:

最终状态正确

通过业务时间版本控制,可以把“顺序问题”转化为“状态新旧判断问题”。

这比完全依赖消息队列顺序更稳。

5. Redis Stream 只是工具,消费模型才是关键

Redis Stream 提供的是:

消息缓冲 消费者组 Pending 管理 消息重投

但真正决定吞吐能力的,是消费者如何处理业务逻辑。

旧模型的问题是:

消费线程承担完整业务链路 ACK 被数据库操作拖慢

新模型的问题拆分后变成:

Redis Stream 快速入口 Hash 分片保证订单内串行 Worker 并行业务处理

吞吐能力自然会提升。


十六、总结

表面上看,这是一次 Redis Stream 积压问题。

但本质上,这是一次容量评估偏差引发的系统演进。

系统从最初的:

单线程同步消费

逐步演进为:

状态时间版本控制兜底 + 多实例快速止血 + Hash 分片并行消费 + 快速 ACK 释放 backlog

最终恢复稳定。

这次事故里,最重要的不是 Redis Stream 本身,而是三个工程判断:

提前用业务时间保证状态正确 故障时先恢复业务 根因明确后再重构消费模型

很多线上问题真正考验的不是某个技术组件,而是当业务规模超出预期时,系统有没有兜底设计,工程师能不能快速止血,并找到长期可持续的解决方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询