Ohhnews

分类导航

$ cd ..
InfoQ Java原文

扩展基于Java的实时系统:事件驱动设计的隐藏权衡

#事件驱动架构#java实时系统#kafka#分布式系统#性能权衡

关键要点

  • 在实时通信系统中,通话信令路径上的最终一致性在功能上等同于失败;任何允许在这些路径上出现读后写违例的 Java 微服务架构,在生产环境中都会导致路由错误。
  • JVM 启动期间的 Kafka 事件回放会导致启动风暴,从而禁用 Kubernetes HPA 自动伸缩。通过将 Kafka 全局状态存储替换为基于 Redis 的本地缓存层,Spring Boot 服务的启动时间可缩短 60%。
  • 使用 RocksDB 的 Kafka Streams 会引入不可预测的压缩延迟峰值,使其不适合基于 Java 的通信系统中的亚秒级实时需求。
  • 先写者胜出的 Redis 模式可将跨集群 gRPC 扇出去重引入的最小延迟从每跳 200 毫秒降低到接近零的轮询开销。
  • Kafka 消费者线程内单个阻塞同步 REST 调用可能级联引发超过 30 分钟的消费者滞后,导致一万名座席的批量配置操作失败,并使基于 JVM 的系统处于部分不一致状态。

事件驱动架构已成为构建可扩展分布式系统的默认推荐。其承诺极具吸引力:松耦合、独立可扩展性、故障隔离,以及无需紧密同步依赖即可处理海量吞吐的能力。对于联络中心、统一通信系统和视频会议等实时协作平台,这些特性似乎是量身定做的。我花了数年时间构建并扩展了一个云联络中心平台,该平台在 10,000 名并发座席间处理超过 80,000 个繁忙小时呼叫完成量 (BHCC),每天处理超过 500 万笔事务。我们全面采用事件驱动架构,以 Apache Kafka 作为主要消息骨干。结果好坏参半,这种复杂性是架构图永远不会展示的。本文并非反对事件驱动设计。它是一份关于那些只有在生产环境中才显现的权衡的真实记录,尤其是在实时响应不是锦上添花而是核心产品要求的系统中。

根本矛盾:默认异步,实时要求

联络中心平台是一个不可通融的环境。当座席接到来电时,UI 必须在毫秒级内反映该状态,而非秒级。当主管查看团队仪表板时,陈旧的存在状态并非小麻烦;它会实时影响劳动力管理决策。

相关赞助商

事件驱动架构本质上是异步的。每一条被发布、消费和处理的 Kafka 消息都会在每个环节增加延迟。在微服务架构中,单个用户操作会触发一系列下游事件,包括路由引擎、座席状态服务、存在状态服务和 UI 通知服务,这进一步叠加了延迟。我们观察到,座席发起外呼时,UI 延迟达到两到三秒,界面才反映新的通话状态。在峰值负载下,来话应答事件有时无法及时传播,导致源端超时。系统在技术上运行正确——事件正在处理,但管道的异步特性违反了产品所需的实时契约。学到的教训是:当允许最终一致性时,事件驱动架构非常适合。但在实时通信系统中,存在一些关键路径(例如通话信令、座席状态转换和存在更新),在这些路径上最终一致性在功能上等同于失败。这些路径需要同步或近同步的通信,而非异步事件管道。

缓存不匹配问题:伪装成分布式的状态

事件驱动架构的典型好处之一是每个服务都从事件流派生自己的本地状态。这种架构消除了共享数据库和紧密耦合。但在实践中,在实时协作平台中,这种方法会引发一个微妙且危险的问题:服务实例间的缓存不匹配。在我们的系统中,每个微服务都维护一个从 Kafka 事件构建的本地内存缓存。正常条件下,所有服务实例消费相同的事件流并保持一致的状态。但在边缘条件下,包括网络分区、消费者滞后和部分重启,不同实例会发生分歧。后果不是错误或异常,而是静默的不正确:语音和聊天会话的工作卡会卡在座席 UI 上,反映出一个不再匹配现实的状态。由于不匹配发生在跨 Pod 的内存缓存之间,标准监控无法检测到。座席报告卡片卡住,但等工程师调查时,状态往往已经自愈。在某些情况下,工作卡片卡住超过 24 小时才被识别和解决。这一时刻促使我们转向 Redis 作为权威共享状态存储,这是我们状态管理演进的第三代,后面会详细描述。

三代状态管理演进

我们的状态管理策略演变过程是整个系统中最具指导意义的部分,包括三代设计,每一代都解决了上一代的问题,但同时引入了自己的问题。

第一代:Kafka 全局状态存储

我们的第一个方法使用 Kafka Streams 全局状态存储,这是一种内置机制,将主题的数据复制到所有 Pod 上,使每个实例都拥有共享状态的完整本地副本。这种方法看似理想:每个 Pod 都拥有完整的状态可见性,无需网络调用。问题是同步延迟。全局状态存储通过 Kafka 的变更日志主题异步复制。在负载下,Pod 之间的复制延迟是可测量的。在实时联络中心中,这种延迟是不可接受的。Pod A 和 Pod B 同时持有同一座席通话状态的不同版本。Pod A 做出的路由决策可能与几秒后 Pod B 的决策冲突。用户状态和通话状态变成短暂的不一致,难以检测,且几乎不可能在测试中重现。

第二代:通过 Kafka 回放的本地内存缓存

解决同步延迟的方法是彻底放弃全局状态存储,让每个 Pod 从 Kafka 事件流构建自己的本地内存缓存。每个事件携带一个头部标志(CREATED、UPDATED 或 DELETED),Pod 独立维护自己的状态,无需跨 Pod 同步,也没有复制延迟。这个设计消除了一致性问题,但引入了两个新问题。首先是启动延迟:冷启动的 Pod 必须重放整个事件积压,才能从头重建缓存。在我们的系统中,这个重放每个 Pod 大约需要 5 分钟,实际上禁用了 Kubernetes 水平 Pod 自动伸缩器(HPA),因为负载峰值触发的新 Pod 在重建状态时无法提供服务,长达 5 分钟。其次是边缘条件(如网络分区、消费者滞后和部分重启),不同 Pod 实例会发生分歧,产生前面描述的缓存不匹配问题——工作卡片卡住超过 24 小时,静默不正确且标准监控不可见。

第三代:带有韧性层的 Redis 共享缓存

最终架构用 Redis 取代本地缓存作为权威共享状态存储。Kafka 事件更新 Redis;所有 Pod 直接读取 Redis。这种直接读取消除了跨 Pod 不一致和启动重放问题。启动延迟下降了 60%。Pod 从 Redis 初始化,而不是重放数千个 Kafka 事件。但将 Redis 引入为关键路径依赖需要一个韧性策略。Redis 中断绝不能导致座席状态完全崩溃。解决方案是一个静默恢复线程。在 Pod 启动时,如果 Redis 不可用或部分填充,后台线程会静默地从 Kafka 事件流重建 Redis 缓存,而不阻塞 Pod 提供服务。Pod 会在降级状态下启动,随着恢复线程填充 Redis 而逐步改善,而不是等待 5 分钟才接受任何流量。如图 1 所示,这种设计为我们提供了共享状态的一致性、热缓存的启动速度以及自愈恢复路径的韧性,同时避免了前三代的所有原始故障模式。

[LOADING...]

图 1:三代状态管理演进(作者绘制)。

学到的教训是:事件驱动系统中的状态管理并非已解决的问题,而是一系列只有在生产负载下才会显现的权衡。全局状态存储引入同步延迟。本地缓存引入启动延迟和分歧。共享缓存引入可用性依赖。关键在于一开始就针对所有三种故障模式进行设计:使用 Redis 实现共享权威状态,使用快照初始化实现快速启动,使用后台恢复线程实现韧性。不要等到一次次生产事故才去发现这些需求。

分区限制:水平伸缩的隐藏天花板

Kafka 的可伸缩性模型在纸上很优雅:添加更多分区,添加更多消费者,线性扩展。但在一个多服务共享主题的生产系统中,现实更加受限。在我们的架构中,主要主题有 12 个分区,中等主题有 6 个,低流量主题有 3 个。消费者组模型设计为:每个主题的活动消费者最大数量等于分区数。如果消费者 Pod 数量多于分区数,多余的 Pod 将处于空闲状态,占用内存和计算资源却对吞吐量毫无贡献。对于被多个服务消费的共享主题,这种设计形成了一个硬性天花板:我们无法独立扩展单个服务超出分区数,同时还要为所有消费者的空闲 Pod 付出代价。

解决这个问题的明显方法是增加分区数,但这会引入自己的问题。重新分区一个正在运行的 Kafka 主题会触发消费者组再平衡。再平衡期间,消费者处理暂停。在实时联络中心中,任何处理暂停都具有操作上的重大影响。生产环境再平衡事件的风险使得分区增加在操作上变得危险,实际上将我们锁定在初始部署时设置的分区数上。

我们最终通过两个改变解决了这个问题,这些改变是更大范围 Redis 迁移的一部分。将共享状态从 Kafka 主题移到 Redis 中,显著减少了跨服务主题依赖。同时,将过度碎片化的微服务整合为内聚的特性服务,减少了争夺分区的消费者组数量。这两个改变都是反应性的,由生产痛点驱动,而非预先设计。学到的教训是:在设计时就要慎重选择分区数,因为安全地在生产环境中更改它们很困难。尽可能避免跨多个服务共享主题;每个服务使用独立主题可以让各个团队拥有独立的伸缩控制。最后,要认识到 Kafka 中的水平伸缩受限于分区,而非计算资源。

跨集群事件传播:去重税

我们的平台跨越两个 Azure Kubernetes 服务集群,UI 框架在一个集群,后端路由核心在另一个集群,语音交换机则单独运行在 Google Cloud Platform (GCP) 上。这种拓扑引入了一个去重问题,成为我们最大的延迟源之一。UI 通过 gRPC 与后端通信,具有独立的上行和下行通道。由于所有后端 gRPC Pod 都订阅同一个通道,每个 Pod 同时接收每一条 UI 消息。如果不进行去重,每个 Pod 都会独立处理相同事件并发布重复的下游消息。我们最初的解决方案是使用 Kafka 本身作为去重机制。所有 gRPC Pod 将收到的消息写入一个原始的 Kafka 主题,使用 call_id 作为分区键。由于 Kafka 保证相同分区键的消息落在同一个分区上,单个消费者 Pod 将收到给定通话的所有重复消息。该消费者取第一条消息,丢弃其余,并发布到已去重的下游主题。这个解决方案功能正确,但引入了一个复合延迟问题。Kafka 消费者默认轮询间隔为 100 毫秒,因此每个事件在每个 Kafka 环节至少增加 100 毫秒的延迟。去重模式为每次 UI 交互增加了两次完整的 Kafka 环节:一次到原始主题,一次从去重主题到下游服务。仅按最小轮询间隔计算,下游服务看到任何事件之前就已经有 200 毫秒不可避免的延迟,这还不包括任何处理、REST 调用或跨集群网络开销。

我们最终用 Redis 先写者胜出模式取代了基于 Kafka 的去重,如图 2 所示。第一个成功在 Redis 中声明 call_id 键的 Pod 成为指定处理器;所有其他处理器立即丢弃该消息。这种方法完全消除了原始主题,并从关键路径中移除了一次完整的 Kafka 环节。

[LOADING...]

图 2:Redis 先写者胜出跨集群去重(作者绘制)。

学到的教训是:在延迟敏感路径上使用 Kafka 作为去重机制成本高昂。轮询间隔在每个环节都创建了一个不可避免的延迟下限。对于实时系统,基于 Redis 的协调模式可以消除这个下限,应成为跨实例去重的首选。

JVM 特定考量:Java 为这些权衡增加了什么

上述故障模式是架构性的,与实现语言无关。但使用 Java 在 JVM 上构建此系统引入了额外约束,值得 Java 架构师特别关注。

Spring Boot 启动开销

Spring Boot 的应用上下文初始化在 Kafka 消费者准备好处理事件之前增加了可观的启动时间。在我们的系统中,Spring Boot 上下文初始化在 Kafka 消费者注册开始之前就增加了大约 30 到 45 秒的 Pod 启动时间。结合前面描述的 Kafka 事件重放问题,在最坏情况下 Pod 启动时间接近 6 分钟,使得 HPA 自动伸缩问题比在更轻量级的运行时中严重得多。两个改变直接解决了这个问题:延迟初始化(spring.main.lazy-initialization=true)将 Bean 创建推迟到首次使用;迁移到 Redis 快照初始化消除了 Kafka 重放阶段。这两个改变一起使 Spring Boot Pod 启动时间在正常条件下降低到 90 秒以下。

高吞吐量 Kafka 消费下的 GC 压力

在峰值负载下(80,000 个繁忙小时呼叫完成量和 500 万日交易量),大量 Kafka 消息反序列化、对象创建和状态操作给 JVM 堆带来了严重的垃圾回收(GC)压力。我们观察到了峰值期间的 Stop-The-World GC 暂停,这导致了消费者延迟高峰。一个经历 200 到 400 毫秒 GC 暂停的 Pod 在峰值消费期间会落后于其分区的事件速率,产生可能需要数分钟才能恢复的滞后。迁移到 JDK 17 是我们做出的影响最大的单一改变。JDK 17 改进的 G1GC 实现显著减少了暂停频率。除了 JDK 升级,还有三个额外的 JVM 调优改变进一步提升了效果。G1GC 暂停目标(-XX:MaxGCPauseMillis=100)为 GC 暂停持续时间设置了明确的上限,减少了峰值负载下较长暂停的频率。分层编译(-XX:+TieredCompilation)允许 JVM 在方法执行阶段结合解释执行、C1 编译器和 C2 编译器优化,减少了频繁调用的 Kafka 消费者方法和热状态管理代码路径的 CPU 开销。在持续峰值负载下,CPU 和内存使用率的改进是可测量的。最后,对象池化改善了情况。频繁分配的消息包装器对象被池化,以减少分配压力和 GC 收集频率。这些改变一起使 CPU 和内存使用率在峰值负载下达到可接受水平,相比 JDK 17 之前 GC 压力持续导致消费者延迟高峰的基线有了显著改进。

JDK 21 虚拟线程

JDK 21 的虚拟线程(Project Loom)直接解决了这类问题。一个使用虚拟线程的 Kafka 消费者可以在不阻塞底层载体线程的情况下进行阻塞的 REST 调用,允许 JVM 在 I/O 完成时调度其他工作。虽然在我们描述的事件发生时它尚不可用,但任何无法完全避免下游同步调用的基于 Java 的 Kafka 消费者都值得评估这个解决方案。学到的教训是:构建基于 Kafka 的实时系统的 Java 架构师应将 Spring Boot 启动时间、GC 暂停行为和消费者线程中的阻塞 I/O 视为一等架构关注点,而非实现细节。JDK 21 虚拟线程有意义地解决了阻塞 I/O 问题,而 GC 调优和延迟初始化解决了启动开销问题。明确规划这些细节非常重要。

Kafka Streams 与 RocksDB 性能陷阱

我们的几个服务使用了 Kafka Streams 库进行有状态流处理,将座席事件、UserFeature 事件和 VoiceAgent 事件连接成统一的 UserAggregate 事件。理论上,Kafka Streams 是正确的工具:它提供恰好一次语义、内置状态管理和紧密的 Kafka 集成。在实践中,性能特性对于实时联络中心工作负载来说是不合适的。Kafka Streams 使用 RocksDB 作为其中间聚合的嵌入式状态存储。RocksDB 是一个高性能的磁盘支持键值存储,对于批处理和近实时工作负载来说很快,但并非内存级速度。对于一个座席状态变化需要在几百毫秒内传播的平台,RocksDB 物化的磁盘 I/O 开销增加了延迟,这在开发和测试中不可见,但在生产负载下却很显著。我们没有直接测量 RocksDB 压缩延迟,但在峰值负载下,端到端座席状态传播的性能下降是可观察的。根本原因是拓扑设计本身:Kafka Streams 管道中的每个转换器阶段都维护自己的由 RocksDB 支持的中间状态存储。当源端到目的地之间有多个转换器阶段时,对 RocksDB 的读写操作在每个阶段都会叠加。我们的评估是:一个转换器阶段较少的拓扑,或者一个完全绕过 RocksDB 的纯 Kafka 消费者,可以保守地将状态存储读写开销减少约 30%,这基于中间物化点的减少。压缩驱动的延迟峰值在峰值负载窗口中最明显,此时 RocksDB 的后台压缩与服务于实时状态查询的前台读写操作直接竞争。更深层的问题是消费者组设计。同一个消费者组既消费源主题(例如座席、UserFeature 和 VoiceAgent),也消费连接操作产生的内部 UserAggregate 主题。源主题的滞后直接饿死了聚合消费者——上游一个慢分区就可能延迟整个连接管道。

学到的教训是:Kafka Streams 非常适合分析和近实时工作负载,在这些场景中 RocksDB 的磁盘支持性能是可以接受的。对于亚秒级实时需求,最小化转换器阶段以减少 RocksDB 物化点——每个中间状态存储在压缩过程中都是延迟倍增器。源主题和派生主题使用不同的消费者组是不可妥协的。共享消费者组会在管道阶段之间创建不可见的耦合。尽可能选择带有 Redis 支持状态的纯 Kafka 消费者,而不是用于延迟敏感路径的 Kafka Streams 与 RocksDB。

级联故障:当下游延迟冻结上游消费者

我们经历的最严重的生产事故并非单一故障,而是一个由单个慢速 REST API 调用引起的级联效应,向上游传播穿过整个管道。场景是批量座席配置。管理员从管理 UI 触发多达 10,000 名座席的配置,发布事件到只有 3 个分区的 Kafka 主题。我们的服务消费这些事件,并调用下游 REST API 在客户端云上配置每个座席。管理 UI 对批量操作强制执行 30 分钟的总超时。故障链有四个同时作用的复合问题。首先,只有 3 个分区,并且 Kafka Streams 每个分区使用两个线程,最多只有 6 个线程处理 10,000 个座席配置事件。其次,每个消费者线程都向下游服务发出同步阻塞的 REST 调用。当下游 REST API 在负载下降级时,这 6 个线程被阻塞并保持阻塞。在线程阻塞期间,Kafka 消息消费完全停止。第三,配置管道每个座席需要三个独立事件(例如座席、UserFeature 和 VoiceAgent)连接成一个 UserAggregate,导致 30,000 条消息流经一个 3 分区的瓶颈。第四,共享消费者组意味着源事件滞后和聚合事件滞后互相叠加。消费者滞后累积超过 30 分钟。管理 UI 超时触发。批量配置操作失败,但并非干净地失败。一些座席已在客户端侧配置完成,另一些则没有。系统处于不一致状态,没有自动协调。修复需要两个重大的架构改变。首先,三个独立的配置事件被合并为一个统一事件,消息量减少了三分之二,并完全消除了内部连接主题。其次,同步 REST 调用被替换为 Redis 队列。消费者线程将配置请求写入 Redis 后立即返回,由单独的异步工作池独立处理 REST 调用。消费者线程从此不再被下游延迟阻塞。部分不一致(一些座席在客户端侧已配置,另一些则没有)需要手动协调过程。恢复团队将客户端侧配置系统的已确认记录与管理 UI 操作的预期配置列表进行交叉引用。在客户端侧确认已配置但平台内部状态中缺失的座席,使用幂等性检查逐个重新触发:每个配置调用包含一个唯一操作 ID,下游系统拒绝已配置座席的重复配置请求。在客户端侧失败的座席被识别并通过修复后的管道重新入队。整个协调过程约需三个小时的手工工程工作。结果是可以衡量的:消费者滞后下降了大约 50%;通过 Redis 队列持久性,配置操作变得重启安全;失败的 REST 调用会自动重试,无需从 Kafka 重新消费。学到的教训是:Kafka 消费者线程内的同步阻塞调用是一个架构危险。单个慢速下游依赖可以冻结整个主题的消费,并向上游传播延迟。任何必须调用外部服务的消费者线程都应通过写入持久队列然后返回消耗下一条消息的方式异步进行。将消费者线程视为神圣的,它唯一的任务就是消费和交接,永远不要阻塞。

我会如何重新设计

回顾过去,有五个架构决策会显著改变我们的轨迹:

  • 对延迟敏感的操作使用同步路径。 通话信令、座席状态转换和 UI 通知应使用同步或低延迟的发布/订阅通信(例如 WebSockets 或 gRPC 流),而非 Kafka。将 Kafka 留给耐用、非延迟敏感的流程:分析、审计日志和下游 CRM 同步。

  • 从第一天起使用 Redis 共享缓存并具备韧性。 不要经历三代状态管理演变(全局状态存储、本地缓存、然后 Redis),而是从一开始就用 Redis 作为权威实时状态存储,快照初始化用于快速启动,后台恢复线程用于 Redis 中断韧性。这三个要素都是必不可少的;只实现一个或两个会产生新的故障模式。

  • 使用快照优先初始化。 任何需要本地状态的服务都应该从快照初始化,而非完整的事件重放。在部署到生产环境之前设计好快照机制;事后改造比从一开始就构建要复杂得多。

  • 对跨集群扇出使用 Redis 优先去重。 任何多个 Pod 同时收到相同消息的架构都应该从第一天起使用 Redis 先写者胜出。基于 Kafka 的去重在每个环节增加不可避免的轮询延迟。

  • 永远不要阻塞消费者线程。 消费者线程绝不能执行同步的外部调用。从一开始就设计异步交接模式(例如 Redis 队列和独立工作池)。消费者线程的工作就是消费和交接,仅此而已。

结论

这些问题中没有一个是理论上的。每个问题都表现为生产事故,包括工作卡片卡住、座席配置超时、自动伸缩器瘫痪、通话状态不一致以及 UI 延迟。每起事故都违反了产品所需的实时契约。这些并非反对事件驱动设计的论点。它们是要求我们睁大眼睛设计的论点:理解异步适合哪里、不适合哪里;将消费者线程视为神圣之物;从一开始就针对所有三种故障模式规划状态管理;认识到分区拓扑和消费者组设计是一等架构决策,而非实现细节。

在生产环境中运行最好的系统很少是完全投入单一范式的系统。它们是那些有意应用事件驱动模式,并清楚知道模型在哪里失效的系统。

关于作者

[LOADING...]

Sagar Deepak Joshi

Sagar Deepak Joshi 是 Credit Acceptance 的一名高级软件工程师,拥有近二十年的分布式系统和实时通信基础设施经验。他是两项美国专利的共同发明人,这些专利被 Cisco、Microsoft、Avaya 和 RingCentral 引用 90 次。他专注于高规模基于 Java 的分布式架构。他在 COEP Pune 获得计算机科学学士学位。显示更多 显示更少