兄弟们,姐妹们,这都 2025 年了!如果你还在数据处理、消息队列、微服务这些领域摸爬滚打,却对“背压(Backpressure)”这个词一知半解,甚至闻所未闻,那可真得好好补补课了。别以为这是什么高深莫测的理论,它可能就是你下一个线上 OOM 的“元凶”!
不信?今天就拿我们最近亲身经历的一次 RocketMQ 推送服务 OOM 大事故开刀,带你看看当“背压”缺席时,系统是如何一步步走向崩溃的,以及我们是如何亡羊补牢,最终走向“背压治理”的正轨。
案发现场:一切看起来都很美
我们的推送业务,背后是几十个业务方,大家各玩各的,最终都汇聚到我们这儿。技术选型嘛,也挺常规:
- 消息队列: RocketMQ,老朋友了,用的还是 Pull 模式。想着能自由控制消费节奏,统一组装数据,美滋滋。
- 队列与线程: 为了隔离不同业务线,我们大手一挥,上了 100+ 个消息队列 (Queue) 。每个 Queue 都配了一个专门的线程,这个线程老哥就负责从对应的 RocketMQ Queue 里吭哧吭哧拉数据。
- 批量处理: 为了效率,每个线程每次从 RocketMQ 拉取 500 条消息。
- 数据加工与分发: 拉到原始消息后,线程老哥还不算完,得对这 500 条消息进行一番“梳妆打扮”(组装数据),然后把这些打扮好的“包裹”丢进一个线程池 A。
- 最终推送: 线程池 A 里的苦力们拿到“包裹”,就通过 Firebase SDK 把消息往外推送。
画个简图就是:
RocketMQ (100+ Queues) -> 100+ 拉取线程 (每批500条) -> 数据组装 -> 线程池 A -> Firebase SDK -> 用户手机
听起来是不是挺稳的?每个环节各司其职,井井有条。一开始,它也确实跑得挺欢。
“猪队友”现形:Firebase SDK 的无底洞线程池
然而,好景不长。某天,告警响了,线上服务开始间歇性 OOM,最后直接“躺平”罢工。兄弟们赶紧查日志、dump 内存,一顿操作猛如虎。
最后发现,问题出在 Firebase SDK (我们用的 9.3.0 版本) 这位“浓眉大眼”的家伙身上。这家伙内部竟然用了一个无限制的线程池!
这意味着什么?
- 我们上游的“数据组装工”(拉取线程)源源不断地生产“包裹”(组装好的数据)。
- 线程池 A 把这些“包裹”也源源不断地交给 Firebase SDK。
- Firebase SDK 来者不拒,照单全收,把这些任务全塞进它自己那个无底洞般的内部线程池。
- 但是,Firebase SDK 往外推送是需要时间的(网络 I/O、对方服务器响应等),它的实际处理速度是有限的。
于是,上游生产速度 >> Firebase SDK 实际处理速度。Firebase SDK 内部队列里的任务越堆越多,就像滚雪球一样,内存占用蹭蹭往上涨。最终,Boom!OOM 了。
简单说,就是上游只管生产,下游只管接收,但下游消化不良,活活把自己给“撑死”了。
初步急救:给 Firebase SDK 套上“紧箍咒”
既然知道了是 Firebase SDK 的无界线程池惹的祸,那第一反应自然是给它限制一下。
我们的处理方式是:给 Firebase SDK 换了一个 ThreadManager 的实现,把默认的无界线程池换成了一个有界的线程池。
具体来说,就是创建一个固定大小的线程池(ThreadPoolExecutor 设置好核心线程数、最大线程数、队列类型和拒绝策略),然后通过 Firebase 的配置,让它使用我们提供的这个有界线程池。
效果: 立竿见影!至少服务不会再因为 Firebase SDK 内部任务堆积而直接 OOM 了。当有界线程池的任务队列满了之后,新的任务提交会根据我们设置的拒绝策略被处理(比如抛异常、丢弃、或者让提交者线程自己执行等)。
这就好比,以前食堂打饭窗口无限供应,厨师炒多少,窗口就堆多少,最后堆不下了。现在窗口限制了容量,堆满了就不让厨师再上新菜了。
但是,这只是治标,还没治本。 压力只是从 Firebase SDK 内部转移到了提交任务给 Firebase SDK 的那一步。如果上游的生产速度依旧远大于下游的处理能力,现在可能不会 OOM 在 Firebase SDK 内部,但可能会导致线程池 A 出现任务积压,或者大量的任务被拒绝。
深入治疗:打好“背压”这场硬仗
要从根本上解决问题,就得处理“背压”(Backpressure)—— 也就是当下游消费者处理不过来时,能够有效地向上游生产者传递压力,让上游放慢速度。
我们后续的思考和可以尝试的方案有这么几个:
- 信号量 (Semaphore):卡住上游的“水龙头”
-
思路: 这是一个经典且有效的控制并发访问的工具。我们可以用信号量来限制同时可以有多少“数据组装”任务在进行,或者限制最终提交给 Firebase SDK (那个有界线程池) 的任务数量。
-
怎么做:
- 在我们的“数据组装”逻辑之前,或者在向线程池 A 提交任务之前,先尝试获取一个信号量的许可 (
semaphore.acquire())。 - 当 Firebase SDK 处理完一个推送任务(可能需要回调或者其他机制来得知),或者线程池 A 中的任务执行完毕后,释放一个信号量的许可 (
semaphore.release())。 - 信号量的初始许可数量可以根据下游 Firebase 有界线程池的处理能力来设定一个合理的值,比如
有界线程池大小 + 队列容量的一个比例。
- 在我们的“数据组装”逻辑之前,或者在向线程池 A 提交任务之前,先尝试获取一个信号量的许可 (
-
优点: 实现相对简单,能有效控制上游生产速度,使其匹配下游消费速度。当信号量许可耗尽时,上游的拉取和组装线程就会阻塞等待,自然就慢下来了。
-
缺点: 需要精细调整信号量的大小;如果释放逻辑处理不当(比如异常导致没有释放),可能会造成死锁或处理能力下降。
- 响应式编程 (Reactive Programming):从设计上解决背压
-
思路: 这是一套更优雅、更声明式的处理异步数据流和背压的范式。像 RxJava、Project Reactor (Spring WebFlux 的核心) 都是这个领域的佼佼者。
-
怎么做:
- 将从 RocketMQ 拉取数据、组装数据、到 Firebase 推送的整个流程,用响应式流(如
Flux或Observable)来编排。 - 在响应式模型中,消费者(Subscriber,这里是 Firebase 推送的逻辑)会向上游(Publisher,这里是数据拉取和组装的逻辑)请求特定数量的数据 (
request(n))。 - 上游只会向下游发送被请求数量的数据。当下游处理完这些数据后,会再次向上游请求更多数据。
- 将从 RocketMQ 拉取数据、组装数据、到 Firebase 推送的整个流程,用响应式流(如
-
优点: 背压是响应式框架内置的核心机制,处理起来非常自然和高效。代码逻辑可能更清晰(一旦熟悉了响应式思维)。
-
缺点: 学习曲线较陡峭,对现有代码的改动可能较大。整个调用链都需要支持响应式,或者在非响应式和响应式边界做好桥接。
- 动态调整 RocketMQ 拉取行为 + 应用层缓冲队列
-
思路: 更直接地从源头控制。
-
怎么做:
- 监控应用内缓冲: 在拉取线程将数据组装后,并不是直接丢给线程池 A,而是先放入一个应用内的有界阻塞队列 B。线程池 A 从这个队列 B 中获取任务。
- 反馈调节拉取: 拉取线程在每次成功拉取一批数据(比如500条)并放入队列 B 后,检查队列 B 的当前容量。
- 如果队列 B 的使用率很高(比如超过 80%),说明下游消费慢了,那么拉取线程可以在下次拉取前
Thread.sleep()一小段时间,或者动态减小下次从 RocketMQpullBatchSize的数量。 - 如果队列 B 的使用率很低,可以恢复正常的拉取频率和批量大小。
-
优点: 逻辑相对直观,改动范围可控,直接作用于消息的源头。
-
缺点: 动态调整的参数(睡眠时间、批次大小变化幅度)需要仔细调优,否则可能过于敏感或迟钝。
- 优化 Firebase SDK 的使用或升级
-
思路: 有没有可能是我们对 Firebase SDK 的使用方式不够优化?或者新版本的 SDK 已经解决了这个问题?
-
怎么做:
- 仔细阅读 Firebase SDK 的文档,看看是否有关于线程池、并发控制的最佳实践。
- 调研新版本的 Firebase SDK 是否提供了更完善的线程管理机制或背压支持(新更新的 9.4 版本就不再使用无界队列了)。有时候,升级版本就能解决很多头疼的问题(当然也可能引入新问题,哈哈)。
- 加强监控和告警
- 亡羊补牢,更要防患于未然。
- 监控我们自定义的 Firebase 有界线程池的各项指标:活跃线程数、队列长度、任务拒绝次数。
- 监控 RocketMQ Consumer 的消息堆积情况。
- 监控应用内我们可能增加的缓冲队列(比如方案3中的队列B)的长度。
- 当这些指标达到阈值时,及时告警,让人工介入分析,或者触发一些自动化的降级、限流措施。
总结与反思
这次 OOM 事件,给我们敲响了警钟:
-
警惕第三方库的“黑盒” :特别是涉及线程池、网络请求这类资源密集型操作的库,一定要了解其内部机制,不能想当然。无界队列、无界线程池往往是潜在的风险点。
-
背压是数据流系统的生命线:在一个处理链条中,上下游速率不匹配是常态。没有背压,压力就会在最薄弱的环节堆积,直至系统崩溃。有效的背压能让系统各部分协同工作,而不是互相伤害。
-
2025 年了,背压治理是必修课:随着微服务架构和异步消息的广泛应用,数据流越来越复杂,处理背压的能力已经成为衡量一个系统健壮性的重要标准。
-
方案组合,持续优化:解决背压问题往往不是单一方案就能搞定的,需要结合业务场景,综合运用限流、降级、队列管理、响应式设计等多种手段,并持续监控和调优。
兄弟们,你们在工作中遇到过类似的“坑”吗?又是怎么填平的?欢迎在评论区交流经验,一起成长!