学习 Disruptor 模式以实现低延迟:用队列、内存和架构选择设计具可预测响应时间的实时系统。

速度有两个维度:吞吐量和延迟。吞吐量是你每秒完成多少工作(请求、消息、帧)。延迟是单个工作从开始到结束花费的时间。
一个系统可以吞吐量很高但仍然感觉慢,如果有些请求比其他请求慢得多。这就是为什么平均值会误导人。如果 99 次操作花 5 ms,而一次花 80 ms,平均值看起来不错,但遭遇 80 ms 的用户会感觉到卡顿。在实时系统中,那些罕见的峰值才是关键,因为它们打断了节奏。
可预测延迟意味着你不仅追求低平均值,还追求一致性,让大多数操作在一个紧凑的区间内完成。这就是团队关注尾部(p95、p99)的原因:停顿就藏在那里。
50 ms 的尖峰在语音和视频(音频爆音)、多人游戏(回弹)、实时交易(错过价格)、工业监控(告警迟到)和实时仪表盘(数字跳动、告警不可靠)等场景中都可能产生明显影响。
举个简单例子:聊天应用大多数时候能快速交付消息。但如果某次后台停顿导致一条消息晚到 60 ms,输入状态指示会闪烁,对话会感觉滞后,即使服务器平均看起来“很快”。
如果你想让实时感觉真实,你需要更少的意外,而不仅仅是更快的代码。
大多数实时系统之所以感觉慢,并不是因为 CPU 吃力。它们感觉慢是因为工作大部分时间在等待:等待调度、等待队列、等待网络或等待存储。
端到端延迟是从“发生某事”到“用户看到结果”的完整时间。即使你的处理器在 2 ms 内完成,若请求在五个不同的地方停顿,也可能总共需要 80 ms。
一种有用的分解方式是:
这些等待会叠加。这里几毫秒、那里几毫秒,会把“快”的代码路径变成用户感知的慢体验。
尾部延迟是用户开始抱怨的地方。平均延迟可能看起来不错,但 p95 或 p99 代表最慢的 5% 或 1% 请求。异常通常来自罕见的停顿:一次 GC 周期、宿主上的噪声邻居、短暂的锁竞争、缓存补充,或产生队列的突发流量。
具体例子:一次价格更新通过网络到达用时 5 ms,等一个繁忙的工作线程 10 ms,在其他事件后面排队花 15 ms,然后遇到数据库停顿 30 ms。你的代码仍在 2 ms 内运行,但用户等待了 62 ms。目标是让每一步都可预测,而不仅仅是计算快。
一个快速算法仍可能感觉慢,如果每次请求的时间波动很大。用户注意到的是峰值而不是平均值。这种波动就是抖动,通常来自代码无法完全控制的因素。
CPU 缓存和内存行为是隐藏成本。如果热数据放不进缓存,CPU 就会在等待内存时停顿。对象繁多、内存分散和“再多一次查找”会导致重复的缓存未命中。
内存分配会带来随机性。分配大量短寿命对象会增加堆压力,最终表现为停顿(垃圾回收)或分配器争用。即便没有 GC,频繁分配也会碎片化内存并损害局部性。
线程调度是另一个常见来源。当线程被换出,你要付出上下文切换开销并丢失缓存热度。在繁忙的机器上,你的“实时”线程可能会排在无关工作的后面。
锁竞争是可预测系统经常瓦解的地方。一个“通常是空闲”的锁可能变成车队:线程被唤醒、为锁争抢、再被挂起。工作仍然完成,但尾部延迟被拉长。
I/O 等待可以把其他因素比得很小。一条系统调用、满的网络缓冲区、一次 TLS 握手、磁盘刷新或慢 DNS 查找都能制造出任何微优化都无法修复的尖峰。
如果你在寻找抖动来源,先看缓存未命中(常由指针密集结构和随机访问引起)、频繁分配、过多线程或噪声邻居导致的上下文切换、锁竞争,以及任何阻塞性 I/O(网络、磁盘、日志、同步调用)。
举例:一个价格播报服务可能以微秒级计算更新,但一次同步日志调用或一个有争用的指标锁就能间歇性地增加几十毫秒延迟。
Martin Thompson 在低延迟工程领域以关注系统在压力下的表现著称:不仅看平均速度,更看可预测的速度。他与 LMAX 团队一起推动了 Disruptor 模式的流行,这是一个让事件以小且稳定延迟在系统中移动的参考方法。
Disruptor 的出现是为了解决许多“快速”应用不可预测的根源:争用与协调。典型队列常依赖锁或大量原子操作,频繁唤醒线程,当生产者和消费者争抢共享结构时会产生等待的突发。
与其使用队列,Disruptor 使用环形缓冲区:一个固定大小的循环数组,将事件放在槽位中。生产者声明下一个槽位,写入数据,然后发布序列号。消费者按序列号顺序读取。因为缓冲区是预分配的,你避免了频繁分配并减轻了垃圾回收压力。
一个关键思想是单写者原则:让单个组件负责一份共享状态(例如推进环形缓冲区的游标)。写者更少意味着更少的“谁下一个?”时刻。
回压是显式的。当消费者落后时,生产者最终会到达仍在使用的槽位。此时系统必须等待、丢弃或放慢,但它以可控且可见的方式进行,而不是把问题藏在一个不断增长的队列里。
让 Disruptor 风格设计快速的并不是某个巧妙的微优化,而是移除系统自相残杀时产生的不可预测停顿:分配、缓存未命中、锁竞争以及把耗时操作混进热路径。
一个有用的思路是把系统想象成装配线。事件沿固定路线移动,并有清晰的交接。这减少了共享状态,使每一步更容易保持简单和可测。
快速系统避免意外分配。如果你预分配缓冲并重用消息对象,就能减少垃圾回收、堆增长和分配器锁带来的“有时发生”的峰值。
让消息保持小而稳定也很重要。当每个事件触及的数据能放进 CPU 缓存时,你就会减少等待内存的时间。
实践中,通常最重要的习惯有:重用对象而不是为每个事件创建新对象、保持事件数据紧凑、对共享状态采用单写者优先,以及谨慎地进行批处理以降低协调开销的频率。
实时应用常常需要日志、度量、重试或写库等额外功能。Disruptor 思维是把这些从核心循环中隔离出来,避免阻塞核心路径。
在实时价格流中,热路径可能只验证一个 tick 并发布下一个价格快照。任何可能阻塞的工作(磁盘、网络调用、重度序列化)都移到独立的消费者或侧通道,这样可预测路径保持可预测。
可预测延迟大多是架构问题。你可以拥有快速的代码,但如果太多线程在同一数据上争抢,或消息无缘无故跨网络跳转,仍会出现峰值。
先决定有多少写者和读者会触及同一个队列或缓冲区。单个生产者更容易保持平稳,因为它避免了协调。多生产者设置能提升吞吐量,但往往会增加争用并使最坏情况的时间变得不可预测。如果需要多个生产者,通过按键分片事件(例如按 userId 或 instrumentId)来减少共享写入,让每个分片有自己的热路径。
在消费者端,单消费者在需要保持顺序时提供最稳定的时序,因为状态保存在单线程本地。工作池在任务真正独立时有用,但它们会增加调度延迟并可能打乱顺序,除非你小心处理。
批处理是另一种权衡。小批次降低开销(更少的唤醒、更少的缓存未命中),但为了凑批而等待会增加等待时间。如果在实时系统中批处理,请对等待时间设上限(例如“最多 16 条事件或 200 微秒,以先到者为准”)。
服务边界也很重要。当需要紧密延迟时,进程内消息通常是最佳选择。网络跳转能带来扩展性,但每次跳转都会增加队列、重试和可变延迟。如果必须有网络跳转,保持协议简单并避免在热路径中做扇出。
一套实用规则是:尽量为每个分片保留单写者路径,通过分片键扩展而不是共享一个热队列;只有在严格时间上限下才进行批处理;仅在工作真正独立时添加工作池;并把每次网络跳当作潜在抖动源直到你测量确认它不是。
在动手写代码前先写下延迟预算。选一个目标(什么是“好”的感觉)和一个 p99(必须低于的值)。把这个数拆分给输入、校验、匹配、持久化和出站更新等阶段。如果某一阶段没有预算,那它就没有限制。
接着画出完整的数据流并标出每次交接:线程边界、队列、网络跳和存储调用。每个交接都是抖动藏匿的地方。把它们看清楚后,你就能减少它们。
一个让设计更诚实的工作流程:
然后决定哪些可以异步化而不破坏用户体验。一个简单规则是:任何会改变用户“现在”看到内容的东西都留在关键路径,其他都移出。
分析、审计日志和二级索引通常可以推离热路径。验证、排序和产生下一个状态所需的步骤通常不能推离。
即便代码很快,运行时或操作系统在错误时刻暂停你的工作也会让应用感觉慢。目标不只是高吞吐量,而是让最慢的 1% 请求出现更少的意外。
垃圾回收的运行时(JVM、Go、.NET)在生产力上很有优点,但在内存需要回收时会引入停顿。现代回收器比以前好多了,但在高负载下创建大量短寿命对象仍会让尾部延迟跳升。非 GC 语言(Rust、C、C++)避免了 GC 停顿,但把成本转移到手动内存管理和严格的分配纪律上。不管怎样,内存行为和 CPU 速度一样重要。
实用习惯很简单:找出分配发生的位置并让它“无聊化”。重用对象、预设缓冲大小,避免把热路径数据变成临时字符串或映射。
线程选择也会以抖动表现出来。每多一个队列、异步跳转或线程池交接都会增加等待并扩大方差。优先使用少量长寿命线程,清晰划定生产者-消费者边界,并在热路径上避免阻塞调用。
一些 OS 和容器设置经常决定你的尾部是干净还是突变:CPU 被紧限导致节流、共享宿主上的噪声邻居、以及放置不当的日志或度量都会制造突发慢点。如果只能改一件事,先在延迟峰值发生时度量分配速率和上下文切换数。
平均值会掩盖极端的停顿。如果大多数操作都很快但少数操作明显更慢,用户会把那些慢的情况感觉成卡顿或“延迟”,尤其是在实时流程中节奏感重要时。
请关注尾部延迟(例如 p95/p99),因为明显的停顿通常就藏在那里。
吞吐量表示你每秒完成多少工作。延迟表示一条请求从开始到结束需要多长时间。
你可以拥有很高的吞吐量,但仍会出现偶发的长等待,而那些长等待会让实时应用感觉变慢。
尾部延迟(p95/p99)衡量的是最慢的请求,而不是典型请求。p99 表示 1% 的操作比这个值更慢。
在实时应用中,这 1% 往往会以可见的抖动表现出来:音频爆音、回弹(rubber-banding)、界面指示闪烁或错过时钟周期等。
大部分时间通常花在“等待”上,而不是计算上:
即便处理函数只耗 2 ms,若它在多个地方等待,总时延仍可能达到 60–80 ms。
常见的抖动来源包括:
排查时把延迟峰值与分配速率、上下文切换次数和队列深度关联起来看。
Disruptor 是一种将事件按小而稳定的延迟传递通过流水线的模式。它使用预分配的环形缓冲区和序列号来替代典型的共享队列。
目标是减少由争用、内存分配和线程唤醒引起的不可预期停顿——让延迟保持“无聊稳定”,而不是仅仅平均值很低。
在关键循环中预先分配并重用对象/缓冲区可以减少:
同时保持事件数据紧凑,减少每个事件触及的内存量,有助于提升缓存命中率。
当可能时,优先使用每个分片的单写者路径(更易推理、争用更少)。通过按 key(如 userId 或 instrumentId)进行分片扩展,而不是让多个线程争抢一个热队列。
仅当任务真正相互独立时才使用工作线程池,否则你通常会用吞吐量换取更差的尾部延迟与更难排查的问题。
批处理能减少开销,但如果为凑批而等待,就会增加延迟。
实用规则是对批量大小和时间都设上限(例如“最多 N 条或最多 T 微秒,以先到者为准”),这样凑批就不会悄悄破坏你的延迟预算。
先写下延迟预算(目标与 p99),然后把它拆分到各个阶段。绘出每次交接(队列、线程边界、网络跳、存储调用),并用指标把等待情况可视化(队列深度、分段耗时等)。
把阻塞型 I/O 移出关键路径,使用有界队列,并提前决定过载时的策略(丢弃、降级、合并或背压)。如果在 Koder.ai 上原型开发,Planning Mode 可以帮助你早期勾勒这些边界,快照/回滚让你能安全反复测试影响 p99 的改动。