Geek漫游指南

Disruptor 为什么快:真正的核心不只是环形数组

date
slug
disruptor-w1
author
status
Public
tags
编程随想
技术分享
summary
type
Post
thumbnail
category
💻 Backend
updatedAt
Aug 27, 2026 02:44 PM
Disruptor 这个名字在 Java 高性能组件里经常出现,提到它,后面通常会跟着 RingBuffer、无锁、缓存行、百万级吞吐这些词。问题是,看完不少介绍以后,最后很容易只记住一句:环形数组比普通队列快。
这个理解不算错,但离 Disruptor 真正的设计还差得有点远。
为了把它到底快在哪里说清楚,这篇直接把 4.0.0 的官方文档和几条核心源码链路串起来看一遍。从生产者申请序号开始,一路看到事件发布、消费者等待、批量处理和槽位复用。整理下来,有几个判断和常见印象不太一样:真正的核心不是 RingBuffer,而是 Sequencer;无锁不等于没有同步成本;WaitStrategy 也不是越激进越好;很多系统用了它,最后的瓶颈可能仍然在数据库和网络。
先说明边界:这里没有真实项目压测,所以只讲文档和源码能确认的机制,再给出一套可复现的测试方法,不写脱离环境的性能倍数。本文基于 Disruptor 4.0.0,它要求 Java 11 或更高版本。

先把几个结论放前面

Disruptor 的快,不是某一个技巧带来的,主要是下面四件事一起起作用:
  1. 固定容量与事件预分配:启动时创建事件槽位,运行时反复改写已有对象,减少热路径上的对象分配和 GC 压力。
  1. 基于 Sequence 的并发协调:生产者和消费者主要通过单调递增的序号协作,不需要围绕一个队头反复加锁、出队和清空槽位。
  1. 批量与多播消费:消费者拿到可用区间后可以连续处理一批事件;多个消费者也可以读取同一事件,并通过依赖图组织并行和串行关系。
  1. 可选择的等待方式:系统可以用阻塞等待节省 CPU,也可以用让步或忙等减少调度带来的延迟抖动。
四件事背后都有代价。固定容量要提前设计背压,忙等会持续吃 CPU,预分配会长期占内存,慢消费者还能把整条链路拖住。生产者模式或者依赖关系配错,影响的也不只是性能,而是正确性。
简单说,Disruptor 是用更确定的资源模型和更复杂的协作方式,去换进程内事件处理的延迟与吞吐上限。它不是一条换上就会更快的通用队列。

先别把它当成更快的 BlockingQueue

ArrayBlockingQueue 和 Disruptor 都能在线程之间传递数据,看起来像是同一种东西,但它们默认解决的不是同一个问题。
ArrayBlockingQueue 是一个有界 FIFO 队列。一个元素被某个消费者 take() 后,就从队列中移除;多个消费者面对同一个队列时,通常是竞争消费。其实现使用锁和 notEmpty、notFull 条件来协调生产者与消费者。
Disruptor 的默认行为更接近事件流:同一个事件可以被多个 EventHandler 看见,每个处理器维护自己的进度。两个处理器既可以并行消费全部事件,也可以声明 A → B,让 B 只处理 A 已完成的部分。
维度ArrayBlockingQueueDisruptor基本语义单个元素由一个消费者取走同一事件可多播给多个消费者数据生命周期入队、出队、槽位清空预分配槽位被循环复用消费进度队列头尾与锁保护状态每个组件维护自己的 Sequence消费关系多消费者通常竞争任务支持并行、串行和依赖图满载处理put 阻塞,offer 失败或超时publishEvent 等待容量,tryPublishEvent 返回失败持久化与跨进程不提供不提供
这里有个很容易忽略的坑:不能随便把“两条消费者的 Disruptor”与“一个队列加两个竞争消费者”放在一起测。前者每个事件会执行两份工作,后者每个事件只由一个消费者处理,跑出来的数字再漂亮也没有可比性。
Disruptor 同样不是 Kafka、RabbitMQ 这类消息系统的替代品。它本身没有 broker、磁盘持久化、跨进程传输、消费确认和进程崩溃后的消息恢复。可以给它增加日志落盘消费者,但那是应用额外构建的持久化链路,不是 RingBuffer 自带的能力。

真正的核心不是 RingBuffer

官方文档专门强调过,从 3.0 开始,RingBuffer 主要负责保存和更新事件,真正承载并发算法的是 Sequencer。这件事很重要,因为后面大多数性能和正确性问题,最后都要回到序号怎么推进。
先把六个概念放在一张表里,后面顺着一条事件再串起来:
组件负责什么RingBuffer保存预分配事件,并用序号映射到数组槽位Sequence记录某个生产者或消费者已经推进到哪里Sequencer分配发布序号、检查容量、协调生产者与消费者SequenceBarrier根据发布进度和上游消费者进度,判断某消费者能读到哪里BatchEventProcessor运行消费循环,批量调用业务 EventHandlerWaitStrategy决定消费者等待下一条事件时阻塞、让步还是忙等
它们组成的最小链路如下:
生产者 | | 1. next(): 申请序号 v Sequencer -------- 检查最慢叶子消费者,避免覆盖未消费槽位 | | 2. sequence & (bufferSize - 1) v RingBuffer 槽位 ---- 3. 写入预分配 Event | | 4. publish(): 发布可见进度 v SequenceBarrier ---- 5. waitFor(): 等待发布进度和上游依赖 | v BatchEventProcessor ---- 6. 连续处理可用区间并推进消费 Sequence
这张图真正需要记住的是两个方向:
  • 消费者不能越过生产者已发布的进度,也不能越过自己依赖的上游消费者。
  • 生产者不能绕环覆盖最慢消费者尚未处理的槽位。
前者保证数据写好以后再读,后者保证数据读完以后再复用。整个 RingBuffer 能循环起来,靠的就是这两个方向一起卡住边界,不是单靠一个数组下标。

一条事件到底是怎么跑的

RingBuffer 的容量必须是 2 的幂。假设容量为 1024,源码通过下面的位运算定位槽位:
index = sequence & (bufferSize - 1);
序号 0 和 1024 都会映射到槽位 0,但不是同一代数据。序号一直往前走,循环的只是数组下标。位运算本身当然比通用取模更容易优化,不过别把这个细节放得太大,真正重要的是同一个序号同时表达了位置、进度和容量边界。
一次底层发布分为两个阶段:
long sequence = ringBuffer.next(); try {    OrderEvent event = ringBuffer.get(sequence);    event.reset(orderId, amountCents); } finally {    ringBuffer.publish(sequence); }
next() 只是认领槽位,publish() 才表示这个序号可以被消费者看到。如果手工调用 next() 后没有发布,特别是在多生产者模式下,消费者可能一直等在缺失的序号上。官方因此更推荐 Translator API,它把“写入后必须发布”封装在内部的 finally 中。
消费者侧也不是来一条取一条。BatchEventProcessor 先通过 sequenceBarrier.waitFor(nextSequence) 拿到当前最大可用序号,再连续处理整个区间,最后一次性推进自己的 Sequence。比如生产者已经到 120,消费者还在 100,它可以直接处理 101 到 120,不用为每条事件重新走一遍完整等待。
所以批量并不是后面额外加上的优化,它一开始就在消费循环里。

环转一圈以后,为什么不会覆盖旧数据

固定容量最危险的情况,是生产者跑完一圈后覆盖消费者还没读完的数据。
假设 RingBuffer 容量是 1024,生产者准备发布序号 2048,那么即将复用的就是序号 1024 对应的槽位。此时必须确认所有相关消费者都已经越过 1024。源码中的核心判断可以简化为:
wrapPoint = nextSequence - bufferSize 只有 minimumGatingSequence >= wrapPoint,生产者才能继续
Gating Sequence 就是生产者必须关注的消费进度。存在依赖链时,它通常只需要看叶子节点。例如 规则计算 → 审计记录 中,审计不可能跑到规则计算前面,生产者只盯住更靠后的审计进度,就已经间接约束了整条链。
这也是消费依赖图里一个挺巧的地方:它不只表达业务顺序,还能减少生产者需要反复读取的进度数量。
但边界也在这里。只要一个叶子消费者长期阻塞,生产者迟早会追上它,然后停下来。Disruptor 没有消灭背压,只是把背压变成了一个很明确的序号差。

快在哪里,拆开看就四件事

1. 预分配减少热路径分配,但不等于零 GC

构造 RingBuffer 时,EventFactory 会为每个槽位创建一个 Event。后续发布不是把新 Event 放进队列,而是获取已有对象并更新字段。
这能减少 Event 本身的分配,但离“零 GC”还很远:
  • 上游生成的字符串、集合和业务对象仍然可能分配内存;
  • 捕获外部变量的 Lambda、装箱参数和日志格式化也可能制造对象;
  • 预分配 Event 中的引用字段若不清理,可能让大对象存活到很久以后。
还有一个容易被漏掉的问题:Event 会复用,它里面引用的对象却未必会自动释放。引用清理只能由最后一个消费者完成,并行分支里任何一个处理器提前把字段设为 null,其他处理器都可能读到被清掉的数据。多条并行分支需要清理时,应该在它们后面再接一个共同的清理节点。

2. Sequence 把竞争拆开,而不是让所有线程争一个队头

每个消费者都有自己的 Sequence,生产者也用 Sequence 或内部序号记录进度。大多数时候,各个线程只推进自己的位置,到了容量、发布可见性和依赖边界才需要协调。
4.0.0 的 Sequence 使用 VarHandle 提供 CAS、原子累加以及 acquire/release 语义,热值两侧还放了填充字段,用来缓解伪共享。不过这里别说得太绝对,Java 对象最后怎么布局仍然受 JVM 影响。源码做的是明确的工程优化,不是对所有硬件和 JVM 的缓存行承诺。

3. 发布动作建立可见性,而不只是修改一个数字

生产者先写 Event 字段,再发布对应序号;消费者观察到序号可用后,才读取 Event。这个顺序依靠 acquire/release 屏障和原子操作建立可见性。
单生产者发布时推进 cursor;多生产者可能先后认领不同序号,因此还要在 availableBuffer 中记录每个槽位的发布代数。写入标记使用 release,读取标记使用 acquire。消费者会寻找连续已发布的最高序号,不能因为序号 11 先发布,就绕过仍未发布的序号 10。
这就是另一个反直觉的地方:无锁不等于没有同步成本。内存屏障、CAS、缓存一致性流量一个都不会凭空消失,只是同步方式从通用锁竞争,变成了更贴近事件进度的原子协调。

4. 批量处理摊薄协调成本

队列 API 容易让人形成“一次取一条”的处理习惯。Disruptor 的消费循环天然知道当前可用区间,可以把边界检查、序号推进和部分业务动作按批次处理。
批量对吞吐量很重要,但也不是越大越好。批次太大,会拉长单条事件的尾延迟,也可能让一个消费者长时间占着线程。4.0.0 的 BatchEventProcessor 已经支持限制最大批量,最后配多大,还是要看延迟目标和单次业务耗时。

单生产者和多生产者,差的不只是一个枚举

Disruptor 提供 ProducerType.SINGLE 和 ProducerType.MULTI。看起来只是一个枚举,背后其实是完全不同的并发假设。
单生产者模式中,只有一个线程申请序号。SingleProducerSequencer 可以在普通字段中维护下一个位置,不需要和其他生产者争抢;发布时再推进对消费者可见的 cursor。
多生产者模式必须协调多个线程:4.0.0 的阻塞式 next(n) 通过原子累加认领一段序号,非阻塞式 tryNext(n) 使用 CAS;不同生产者完成写入的先后顺序又可能与认领顺序不同,因此还需要 availableBuffer 记录每个序号是否已真正发布。
结论很直接:能保证只有一个发布线程,就明确用 ProducerType.SINGLE;保证不了,就老老实实用 MULTI。为了快而错误选择 SINGLE,不是在做优化,而是在拿正确性碰运气。
顺手提醒一个默认值:Disruptor 的三参数构造方法使用 MULTI + BlockingWaitStrategy。如果实际只有一个发布线程却没显式指定 SINGLE,就会白白承担多生产者的协调成本。

用一个订单事件把消费拓扑串起来

下面这个例子只用来讲清 API 和消费拓扑,不冒充真实业务。规则处理器先计算高金额标记,审计处理器再读取结果,所以两者不是并行关系,而是明确的前后依赖。
<dependency>    <groupId>com.lmax</groupId>    <artifactId>disruptor</artifactId>    <version>4.0.0</version> </dependency>
import com.lmax.disruptor.BlockingWaitStrategy; import com.lmax.disruptor.EventHandler; import com.lmax.disruptor.EventTranslatorTwoArg; import com.lmax.disruptor.RingBuffer; import com.lmax.disruptor.dsl.Disruptor; import com.lmax.disruptor.dsl.ProducerType; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public final class DisruptorOrderDemo {    private static final int BUFFER_SIZE = 1024;    private static final EventTranslatorTwoArg<OrderEvent, String, Long> TRANSLATOR =       (event, sequence, orderId, amountCents) ->            event.reset(orderId, amountCents);    public static void main(String[] args) {        AtomicInteger threadId = new AtomicInteger();        ThreadFactory threadFactory = task ->            new Thread(task, "order-event-" + threadId.incrementAndGet());        Disruptor<OrderEvent> disruptor = new Disruptor<>(            OrderEvent::new,            BUFFER_SIZE,            threadFactory,            ProducerType.SINGLE,            new BlockingWaitStrategy()       );        EventHandler<OrderEvent> ruleHandler = (event, sequence, endOfBatch) ->            event.highValue = event.amountCents >= 100_000L;        EventHandler<OrderEvent> auditHandler = (event, sequence, endOfBatch) -> {            System.out.printf(                "order=%s, amountCents=%d, highValue=%s%n",                event.orderId,                event.amountCents,                event.highValue           );            event.clearReferences();       };        disruptor.handleEventsWith(ruleHandler).then(auditHandler);        RingBuffer<OrderEvent> ringBuffer = disruptor.start();        try {            ringBuffer.publishEvent(TRANSLATOR, "ORDER-1001", 128_000L);       } finally {            disruptor.shutdown();       }   }    static final class OrderEvent {        String orderId;        long amountCents;        boolean highValue;        void reset(String orderId, long amountCents) {            this.orderId = orderId;            this.amountCents = amountCents;            this.highValue = false;       }        void clearReferences() {            this.orderId = null;       }   } }
这段代码按 Java 11 目标和 Disruptor 4.0.0 源码编译、运行通过,输出为:
order=ORDER-1001, amountCents=128000, highValue=true
真正值得看的不是打印结果,而是下面两行配置。代码只差一个 .then(),语义完全不一样:
// 两个处理器都收到所有事件,并行执行;audit 不能依赖 rule 的写入结果 disruptor.handleEventsWith(ruleHandler, auditHandler); // audit 只处理 rule 已完成的序号,可以读取 rule 写入的 highValue disruptor.handleEventsWith(ruleHandler).then(auditHandler);
示例里的 System.out.printf 会阻塞,Long 参数也有装箱,它们都不是低延迟生产写法。这里保留只是为了让结果一眼能看懂,真实热路径需要移除同步日志,再按实际输入模型检查对象分配。

WaitStrategy 没有最优解,只有 CPU 和延迟的交换

消费者等不到下一条事件时,可以阻塞,也可以持续检查。WaitStrategy 只决定消费者如何等待可用序号,并不负责生产者在 RingBuffer 满载时如何等待。
策略等待方式CPU 开销延迟特征适用条件BlockingWaitStrategy使用监视器等待与通知;依赖消费者未完成时继续检查低线程唤醒会增加抖动普通服务、共享 CPU、延迟要求不极端YieldingWaitStrategy先短暂自旋,之后 Thread.yield()高通常比阻塞稳定有足够逻辑核,但不希望完全忙等BusySpinWaitStrategy持续 Thread.onSpinWait()很高避免阻塞系统调用带来的抖动消费线程有专用 CPU 核,且经过实测
BusySpinWaitStrategy 不是打开以后就会变快的高级开关。容器 CPU 配额紧张、线程数超过可用核心,或者机器上还有其他重要任务时,忙等反而可能把整个系统拖慢。官方源码给出的适用条件也很明确:最好能把消费线程绑定到专用 CPU 核。
所以选 WaitStrategy 之前,先问两个问题:空闲时愿意烧多少 CPU,尾延迟又允许多大抖动。没有隔离核心,也没有实测数据,从 BlockingWaitStrategy 开始通常更稳。

真正容易踩坑的是背压

RingBuffer 是固定容量,初始化后不能动态扩容。容量太小,短暂流量峰值就会让生产者追上消费者;容量太大,则会预分配更多 Event,并可能让引用对象更久才被覆盖或清理。
当容量不足时有两种基本选择:
  • publishEvent(...) 最终会在申请序号时等待最慢 Gating Sequence 前进,调用线程被背压。
  • tryPublishEvent(...) 不等待,容量不足时返回 false,由调用方决定丢弃、降级、重试还是转移到其他通道。
这里没有自动正确的答案。订单、行情、遥测和日志对丢弃的容忍度完全不同,背压策略必须属于业务设计,不能留给一个默认 API 临场决定。
消费者这边更容易出问题。把数据库调用、远程 HTTP、同步磁盘写或者慢日志直接塞进 EventHandler,它很快就会变成最慢的叶子节点,最后把生产者一起拖住。业务确实需要阻塞 I/O 时,应该考虑隔离阶段、受控异步化,或者干脆换成更适合任务排队的组件,而不是只把 RingBuffer 调大。
异常策略也要显式设计:哪类错误可以跳过、哪类需要重试、哪类必须停止处理器。事件槽位会复用,失败后的部分写入和引用清理尤其不能靠偶然行为兜底。

别先相信“每秒几百万”

“每秒几百万条”离开机器、JDK、消费拓扑、事件大小和等待策略,基本没什么参考价值。真正能比较的测试,第一步不是跑数字,而是先把两边语义对齐。
最小测试矩阵可以这样设计:
变量建议取值拓扑先测 1P1C,再分别测多生产者和多播/依赖图对照ArrayBlockingQueue 与 Disruptor 执行相同业务计算指标吞吐量、P50、P99、P999、CPU 使用率、分配率、GC 暂停容量两者使用相同的有界容量JVM固定 JDK、堆大小、GC 和启动参数等待方式Blocking、Yielding、BusySpin 分开测试运行方式足够预热,多 fork,记录硬件和操作系统噪声
如果测试多播,队列侧需要为每个消费者提供等价的数据副本或独立队列;如果只用一个队列让消费者竞争,就不是同一个工作负载。
Disruptor 仓库同时提供自定义 src/perftest 和 JMH 基准。官方开发文档还专门说明了隔离 CPU、CPU 集合和线程绑定,因为低延迟测试很容易被内核任务、GC 线程和调度迁移污染。普通开发机可以得到相对趋势,但不应把一次本地运行包装成普遍结论。
还有一种很常见的测法,只统计生产者把事件塞进去的速度,却不等最终消费者完成。这测到的是暂存速度,不是端到端吞吐。结束条件必须是叶子消费者已经推进到目标序号。

最后回到选型:大多数系统未必需要它

Disruptor 更适合下面这组条件同时成立的场景:
  • 数据只需在同一 JVM 内的线程之间传递;
  • 对尾延迟和吞吐量有明确目标,并愿意为此做基准测试;
  • 事件结构相对稳定,可以接受固定容量和对象复用;
  • 同一事件需要广播给多个处理器,或存在明确的消费依赖图;
  • 消费步骤主要是可预测的 CPU 计算,而不是大量不可控阻塞 I/O;
  • 团队能维护背压、异常、停机和对象生命周期规则。
遇到下面这些情况,普通并发队列或消息系统通常更合适:
  • 只是把少量后台任务交给线程池,延迟没有严格要求;
  • 需要动态伸缩、任务竞争消费,而不是每个消费者都处理全部事件;
  • 需要跨进程、持久化、重放、消费确认或故障恢复;
  • 消费者大量调用远程服务,性能瓶颈不在线程间交接;
  • 没有基准和可观测性,只是因为听说 Disruptor 快而引入。
可以把选型压缩成五个问题:
  1. 这是进程内事件流,还是需要可靠消息系统?
  1. 需要竞争消费,还是多播与依赖图?
  1. 当前瓶颈真在线程协调和分配,还是在数据库与网络?
  1. 能否给固定容量、背压和等待策略写出明确规则?
  1. 基准测试能否证明收益覆盖了额外复杂度?
前几个问题还答不清,就先别研究选哪个 WaitStrategy。很可能真正需要解决的,根本不是线程间交接性能。

把整篇收成一个模型

RingBuffer 很容易被记住,因为它直观,也容易画图。但 Disruptor 真正有价值的地方,是把生产、发布、依赖、消费和复用都映射到持续推进的 Sequence 上。
预分配降低分配压力,单生产者路径减少竞争,内存屏障保证发布可见性,批量消费摊薄协调成本,依赖图让同一份事件在不同处理阶段之间有序流动。它们共同构成了性能,而不是某一个神奇数据结构。
反过来,Disruptor 的代价也来自同一套设计:固定容量要求背压策略,对象复用要求严格生命周期,高性能等待需要 CPU 预算,多播与依赖图提高了拓扑治理成本。
更合理的采用顺序应该反过来:先用数据确认瓶颈,再用最简单的 1P1C 建立基线,最后才逐步加入多生产者、消费依赖和更激进的等待策略。
Disruptor 可以很快,但“快”应该是最后跑出来的结果,不应该是一开始就相信的前提。

参考资料

  1. LMAX Disruptor 4.0.0 Release Notes
  1. LMAX Disruptor User Guide: Using the Disruptor
  1. Disruptor 4.0.0 RingBuffer 源码
  1. Disruptor 4.0.0 Sequence 源码
  1. Disruptor 4.0.0 SingleProducerSequencer 源码
  1. Disruptor 4.0.0 MultiProducerSequencer 源码
  1. Disruptor Performance Tests
  1. Java 11 ArrayBlockingQueue API