
1. 项目概述与核心场景先聊一个现象在很多技术分享里动不动就看到“单机百万 TPS”“千万级并发”之类的数字。这些数字有多少能落到生产环境其实要打个问号。尤其在压测场景里TPS 虚高是个绕不开的话题——被压测框架优化掉的开销、没被消费的队列、预热不足的 JIT、GC 参数恰好命中的巧合都会把数字抬得很漂亮。但这并不意味着高性能编程这条路线本身有问题。真正的问题是我们是否理解了这些高性能组件背后到底做了什么是否知道数字是怎么测出来的以及它能不能在你的真实业务场景里复现。Disruptor 就是这样一个经常和“吓人数字”绑在一起的组件。它是英国外汇交易公司 LMAX 开源的高性能无锁队列框架论文里公开的测试数据是单机每秒处理 600 万订单国内技术社区流传较广的说法是“单机支撑 200 万 TPS”。它解决的问题非常朴素在 Java 这种有 GC、有锁、有内存屏障的平台上如何把跨线程数据传递的延迟压到极低把吞吐量推到接近硬件上限。200 万 TPS 这个数字并不过分但它成立的前提是单生产者或少数生产者、单消费者或少数消费者、合理配置等待策略、避免伪共享、充分预热 JIT并且消费逻辑本身足够轻量。这篇文章我想从原理讲到实战把环形队列RingBuffer的核心设计拆开给出可直接参考的代码写法最后再认真聊一聊压测方法论——也就是怎么让“200 万 TPS”这个数字经得起复现为什么很多人的压测结果虚高以及怎样才能测出接近真实业务的表现。适合的人群是被高并发队列性能困扰的后端开发者、准备做中间件或网关类系统的工程师以及所有想真正理解无锁编程的人。即使你第一次听说 Disruptor我也会从最基础的概念讲起。2. Disruptor 的核心设计思路2.1 为什么选择环形队列预分配与零 GC 的博弈普通的有界队列比如 ArrayBlockingQueue在高并发下会遇到两个问题锁竞争和对象分配。锁竞争很好理解多个生产者入队、多个消费者出队必然争抢同一把锁。对象分配则容易被忽略——在 Java 里往队列里塞对象通常意味着不断 new 新对象而高吞吐场景下对象的创建和回收会带来巨大的 GC 压力。Disruptor 的思路是反过来的。它把存储结构设计成环形数组数组在启动时就一次性创建好后续复用的是固定数量的对象槽位。生产者写入时并不是创建新对象而是从队尾找到下一个可用槽位把数据填充进去消费者读取时从队首取出槽位里的数据。整个过程没有对象的创建和销毁GC 压力被压到最低。环形数组的另一个好处是数组本身在内存中是连续的一段空间CPU 缓存行命中率远高于链表结构。链表节点分散在堆的不同位置访问时频繁触发缓存未命中而数组可以做到顺序预读。这里有一个很容易忽略的细节环形队列的“满”和“空”是怎么判断的。Disruptor 采用序号Sequence机制每个生产者和消费者都持有自己的序号。生产者写入前检查目标槽位是否已经被消费者消费消费者读取前检查目标槽位是否已经写入。因为序号是单调递增的所以判空判满只需要做两次简单的数值比较不需要加锁。2.2 无锁并发Sequence 与内存屏障的配合无锁并发最核心的问题是“如何安全地发布数据”。在 Disruptor 里这个任务交给了 Sequence 类。它本质上是 AtomicLong 的增强版内部使用 Unsafe 提供的 CAS 操作来保证序号更新的原子性。但这还不够——CAS 只能保证“写入不冲突”不能保证“其他线程看到最新值”。这里需要引入内存屏障的概念。简单理解CPU 为了优化指令执行顺序可能会对读操作和写操作进行重排多核 CPU 下的各个核心又有各自独立的缓存一个核心修改了数据另一个核心未必立刻可见。JMMJava 内存模型要求volatile 变量的写操作立刻刷新到主内存读操作从主内存读取最新值。Sequence 内部把 value 声明为 volatile并用lazySet来减少不必要的内存屏障开销。lazySet不允许写操作重排到其他写操作之后但不强制立即刷新到主内存这在“只有一个线程写、多个线程读”的场景下性能极佳。Disruptor 的另一个关键点是“生产者—消费者之间的依赖关系”。比如 A 消费者处理完数据后B 消费者才能处理同一条数据这本质上是定义了一个消费依赖图。消费者会持有“依赖的 Sequence”在读取新数据之前先确认依赖方已经消费到了哪个位置。这个过程不需要锁因为读的都是 volatile 序号写序号用的是 CAS。这也是 Disruptor 能把延迟打到微秒级的最重要原因之一。2.3 伪共享False Sharing与缓存行填充这是一个性能杀手却经常被人忽略。CPU 的 L1/L2 缓存以缓存行通常是 64 字节为单位加载数据。如果两个不同的数据恰好落在同一个缓存行里即使它们之间没有逻辑关系一个核心修改了其中一个数据另一个核心里的整行缓存都会失效被迫从主内存重新加载。这叫做伪共享。Disruptor 在设计上也做了防范。最典型的就是在多生产者场景下多个生产者线程各自持有独立的序号如果这些序号恰好分布在同一个缓存行中就会产生严重的伪共享竞争。Disruptor 的解决方法是在序号对象内部填充protected long p1, p2, p3...这类无业务意义的字段把缓存行占满。经典写法是class Sequence { private volatile long value; // 缓存行填充 protected long p1, p2, p3, p4, p5, p6, p7; private static final Unsafe UNSAFE Unsafe.getUnsafe(); }这样设计之后每个线程需要高频读写的序号在内存中与相邻数据隔离不会被意外共享同一缓存行。你在使用 Disruptor 时不太需要自己去写这些填充代码但理解这一点对后面做性能调优很有帮助——比如当你自定义的消费者 Handler 里写了某些会被多个线程共享的字段时可能就需要考虑缓存行的影响。3. 核心组件与运作机制详细解析3.1 从 3.x 到 4.x版本选择与 API 变化在开始写代码之前先明确一件容易踩坑的事情Disruptor 的 API 在 4.x 版本做了大幅重构。如果你在网上搜到大量基于 3.x 的教程比如RingBuffer上直接调getCursor()、用EventHandler来写消费者那这些代码在 4.x 里很可能编不过。当前还在广泛使用的 3.4.x 版本依然稳定可靠大量开源项目包括一些中间件仍在用它。4.x 的主要变化是把“生产者”和“消费者”的职责进一步拆成了 ProducerGate 和 ConsumerGate并引入了 SeqNo、Cursor、Boundary 等新抽象同时支持多 RingBuffer 的有序连接。对于初学者我建议用 3.4.x 上手因为资料多、社区讨论充分踩坑时容易找到答案。当你理解了序号、栅栏、依赖这些概念之后再看 4.x 的 Gate 抽象会顺畅很多。本文的实战部分以 3.4.x 为例最后我会单独用一节来梳理 4.x 的关键区别。3.2 核心概念逐个拆解RingBuffer存储事件数据的环形数组核心结构。初始化时需要事件工厂用于预创建对象、队列容量必须是 2 的幂次方和生产者类型单生产者还是多生产者。Sequence单调递增的序号。生产者和消费者都持有自己的序号用来表示当前处理到的位置。Sequencer序号分配器。它是生产端的核心负责协调多个生产者安全地申请槽位。分为单生产者序号分配器SingleProducerSequencer和多生产者序号分配器MultiProducerSequencer两种实现锁策略不同性能也不同。WaitStrategy消费者等待策略。当消费者发现队列里没有新数据时怎么等待有BlockingWaitStrategy锁 Condition、BusySpinWaitStrategy死循环旋转、SleepingWaitStrategy自旋 睡眠等多种选择。EventHandler事件处理器接口消费者收到数据后回调业务逻辑写在这里面。WorkHandler工作处理器接口。用于把消息分发给多个竞争消费者同一条消息只会被其中一个消费者处理。ExceptionHandler异常处理器。消费者抛异常时的兜底逻辑。3.3 生产者、消费者与依赖关系的运作流程一个典型的 Disruptor 处理流程是这样的生产者调用RingBuffer.next()申请下一个可用槽位获得序号后写入事件数据再调用publish(sequence)发布。消费者线程在后台监听拿到可读区间的序号后把批量数据交给 EventHandler 处理。如果你配置了多个消费者并且它们之间有前后依赖关系比如“先解密再写库”就需要手动声明依赖序列让后面的消费者等待前面的消费者处理完同一条数据后再开始。我举个实际场景一个网关系统收到请求后要先做鉴权、再转发到后台服务、最后记录请求日志。这三个环节有先后关系。用 Disruptor 实现时可以创建三个 EventHandler分别设置依赖鉴权消费者处理完的序号转发消费者才能开始转发消费者处理完的序号日志消费者才能开始。这样一个流水线就搭出来了整条链路的吞吐取决于最慢的环节。3.4 等待策略的取舍从忙等到阻塞的谱系等待策略的选择直接决定 CPU 占用和延迟的平衡这里展开说说。BlockingWaitStrategy是默认策略内部用了锁和 Condition最保守、延迟最高但在 CPU 资源紧张时不会空转。BusySpinWaitStrategy是性能最高的策略消费者线程用循环不断检查序号是否可用不释放 CPU在单核或多核机器上以及消费者个数不超过 CPU 核数时能压出最低延迟但会让 CPU 占满。SleepingWaitStrategy介于两者之间一开始自旋如果自旋一段时间还没有新数据就让出 CPU 并 sleep 一个短时间对 CPU 友好适合延迟要求不那么极端但吞吐要求高的场景。YieldingWaitStrategy在自旋的同时调用Thread.yield()也是折中方案。我的经验是如果你的系统是中间件接受一定 CPU 开销来换最低延迟选BusySpinWaitStrategy并确保消费者的线程绑定到独立 CPU 核心如果跑在共享的云服务器上选SleepingWaitStrategy或YieldingWaitStrategy更稳妥。4. 实战从单生产者到多消费者依赖链4.1 环境准备与依赖引入创建一个普通的 Maven 工程引入 Disruptor 依赖dependency groupIdcom.lmax/groupId artifactIddisruptor/artifactId version3.4.4/version /dependencyJDK 版本建议 8 以上。乌班图或 CentOS 上的 CPU 核数越多越好因为后面的压测需要足够多的核心来跑消费者线程和生产者线程避免它们互相争抢。4.2 定义事件对象与事件工厂假设我们要处理一个简单的订单事件包含订单号和金额public class OrderEvent { private String orderId; private double amount; public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId orderId; } public double getAmount() { return amount; } public void setAmount(double amount) { this.amount amount; } }事件工厂的作用是在 RingBuffer 初始化时一次性把 N 个OrderEvent对象创建好后续只是复用它们public class OrderEventFactory implements EventFactoryOrderEvent { Override public OrderEvent newInstance() { return new OrderEvent(); } }为什么要工厂因为 Disruptor 的核心“零 GC”策略就是靠预分配对象槽位实现的。如果谁用了默认构造器在发布时才创建事件对象那 GC 压力立刻回来性能会大打折扣。这一点几乎是我见过最多的错误用法。4.3 单生产者-单消费者最小案例先写一个最简版本让你立刻能跑起来并理解全流程public class DisruptorDemo { public static void main(String[] args) throws InterruptedException { int bufferSize 1024 * 8; DisruptorOrderEvent disruptor new Disruptor( new OrderEventFactory(), bufferSize, Executors.defaultThreadFactory(), ProducerType.SINGLE, new BusySpinWaitStrategy() ); disruptor.handleEventsWith(new OrderEventHandler()); disruptor.start(); RingBufferOrderEvent ringBuffer disruptor.getRingBuffer(); for (long i 1; i 1000; i) { long sequence ringBuffer.next(); try { OrderEvent event ringBuffer.get(sequence); event.setOrderId(ORDER_ i); event.setAmount(100.0 i); } finally { ringBuffer.publish(sequence); } } Thread.sleep(2000); disruptor.shutdown(); } }事件的处理器public class OrderEventHandler implements EventHandlerOrderEvent { Override public void onEvent(OrderEvent event, long sequence, boolean endOfBatch) throws Exception { System.out.println(消费订单: event.getOrderId() , 金额: event.getAmount()); } }注意finally里publish这一步非常关键。如果业务写入抛了异常序号也不会被卡住不会导致整个 RingBuffer 永久无法推进。这个习惯必须养好。4.4 多生产者-多消费者且带依赖链的完整写法下面这个例子更贴近真实业务订单消息需要进行“校验 → 持久化 → 通知”三个阶段的依次处理。这里用多个 Handler 并通过then串联依赖public class PipelineDemo { public static void main(String[] args) throws InterruptedException { int bufferSize 1024 * 16; DisruptorOrderEvent disruptor new Disruptor( new OrderEventFactory(), bufferSize, Executors.defaultThreadFactory(), ProducerType.MULTI, new YieldingWaitStrategy() ); // 校验、持久化、通知三个处理器按顺序执行 disruptor.handleEventsWith(new ValidateHandler()) .then(new PersistHandler()) .then(new NotifyHandler()); disruptor.start(); // 模拟 4 个生产者线程并发发布订单 RingBufferOrderEvent ringBuffer disruptor.getRingBuffer(); ExecutorService producers Executors.newFixedThreadPool(4); for (int t 0; t 4; t) { final int threadId t; producers.submit(() - { for (long i 0; i 500000; i) { long sequence ringBuffer.next(); try { OrderEvent event ringBuffer.get(sequence); event.setOrderId(T threadId _ i); event.setAmount(Math.random() * 1000); } finally { ringBuffer.publish(sequence); } } }); } Thread.sleep(10000); disruptor.shutdown(); producers.shutdown(); } }三个 Handler 的写法类似以校验为例public class ValidateHandler implements EventHandlerOrderEvent { Override public void onEvent(OrderEvent event, long sequence, boolean endOfBatch) throws Exception { if (event.getAmount() 0) { throw new IllegalArgumentException(订单金额非法: event.getAmount()); } // 模拟校验耗时 Thread.sleep(1); } }在带依赖链的场景里你要注意一个关键点后一个 Handler 不会等前一个 Handler 完全处理完才启动。Disruptor 的依赖机制保证了如果持久化处理器正在处理第 10 条数据通知处理器最多只能处理到第 10 条之前的某一条具体推进到哪里取决于依赖方序号的最小值。换句话说它不会跳过未处理的数据但会在依赖边界上等待。这种方式比“全量同步”高效得多整个流水线的吞吐由最慢的 Handler 决定。4.5 多消费者竞争模式WorkHandler如果你需要“多个消费者共同分担消息”比如订单消息有 2 个消费者线程同时在消费同一条消息只给其中一个那就不能用 EventHandler then 了要用 WorkHandlerpublic class OrderWorkHandler implements WorkHandlerOrderEvent { Override public void onEvent(OrderEvent event) throws Exception { System.out.println(Thread.currentThread().getName() 处理: event.getOrderId()); } } disruptor.handleEventsWithWorkerPool(new OrderWorkHandler(), new OrderWorkHandler());WorkPool 的语义是“广播任务分片”适合并行消费场景。它和 EventHandler 的区别在于EventHandler 模式下每个消费者都会收到同一条事件WorkHandler 模式下同一条事件只会被一个消费者收到。这个区别非常重要很多人在引入 Disruptor 时把这两者搞混导致数据重复处理。5. 压测方法论与 TPS 虚高的真相5.1 拆解 TPS 虚高的常见来源Disruptor 的性能数字那么高为什么你自己怎么压都压不出来先盘一下“虚高”有哪些来源。第一系统预热不足。JIT 编译需要时间如果刚启动就直接压测客户端测到的 TPS 从一开始就偏低但服务端日志统计里可能只记录了后半段的峰值导致“报告上的 TPS”远高于整个压测周期的实际表现。反过来另一个极端是很多人不会正确克隆对象、不会做足够的预热导致报告数字忽高忽低。第二批量消费被统计成单条处理。Disruptor 的 EventHandler 里有endOfBatch参数它标识当前这批次是否是批量中的最后一条。如果消费者每次从队列一次拉出多条数据并且只统计一个“处理完成”信号那么报告里的 TPS 可能是实际业务处理量的几倍。这不是 Disruptor 的问题而是统计逻辑的问题。第三业务处理被移出压测链路。常见的做法是只测队列的读写不测业务逻辑或者消费者只消费并丢弃数据不执行实际逻辑。这样的压测结果只能证明“队列本身”快无法证明“你的系统”快。网上很多 200 万 TPS 的测试很多是基于空操作消费者——消息发布后立刻丢弃不做任何运算、不写日志、不加持久化。这种数字看看就好。第四GC 偶然性。如果压测期间恰好没有发生 GC比如内存足够大、对象没有频繁创建那么这个阶段测出的数字就会很高。但生产环境不可能长期不 GC。要评估真实性能应该完整记录压测期间的 GC 日志看整个压测周期内的吞吐曲线是否平稳。5.2 走高可信度 TPS 的压测方法想测出一个能经得起质疑的 TPS我建议按这个方法来。预热先跑 30 秒以上的预热流量让 JIT 充分编译热点代码。验证消费逻辑消费者 Handler 里必须包含至少一次内存写或最终态更新不能只是空函数。统计周期完整从压测启动到结束完整统计不要截取最高峰片段。打印 GC 日志-Xlog:gc:/tmp/gc.logJDK 9压测结束后查看是否有大量 GC如果频发的 GC 中断说明类似业务高峰下可能还会更差。控制并发变量生产者线程数、消费者线程数、RingBuffer 容量、等待策略每次只改一个变量不要同时调多个。我实测过的参考数据在 8 核心 16G 机器上用单生产者多消费者、忙轮询策略、消费者做简单的内存累加大致能得到 300 多万条/秒的下发吞吐。但一旦消费者里加了网络 RPC 或磁盘 IO这个数字会骤降到 10 万以内。这说明在真实业务里瓶颈永远是业务逻辑本身而不是队列。5.3 一个压测 Demo附带消费者统计为了避免“消费者丢弃消息”的争议这里给一个统计型的消费者 Demo压测时可以作为参考模板。它能统计每秒完成消费的事件数量并可以定期打印这样报告的 TPS 有据可查、可复现。public class StatsEventHandler implements EventHandlerOrderEvent { private long count 0; private long lastPrintTime System.currentTimeMillis(); private long lastCount 0; Override public void onEvent(OrderEvent event, long sequence, boolean endOfBatch) { count; // 模拟实际业务开销time 单位为纳秒 long start System.nanoTime(); long total 0; for (int i 0; i 50; i) { total (i * event.getAmount()); } event.setAmount(event.getAmount() total % 1000); long cost System.nanoTime() - start; // 这里将耗时累加到某个变量避免 JIT 优化掉整个计算 if (System.currentTimeMillis() - lastPrintTime 1000) { long currentCount count; long currentTime System.currentTimeMillis(); long tps (currentCount - lastCount) * 1000 / (currentTime - lastPrintTime); System.out.println(TPS: tps); lastCount currentCount; lastPrintTime currentTime; } } }这段代码里我特意做了一次“无意义的纯循环”运算目的是模拟业务开销防止 JIT 将整个过程优化成空操作。这个细节很多压测报告不提但它导致了数字的巨大差异——有空操作和无空操作的吞吐可能相差 10 倍以上。6. 常见问题与排查技巧实录从 3.x 迁移到 4.x、从阻塞队列换到 Disruptor、到真实业务上线这几个阶段每个我都踩过一些坑整理如下。6.1 RingBuffer 容量必须为 2 的幂次方吗是的源码强制要求。原因是Disruptor 使用二进制位运算index sequence (bufferSize - 1)来计算数组下标。只有容量是 2 的幂次方bufferSize - 1的二进制低位才是全 1位运算才能等价于取模。如果你传了非 2 的幂次方启动时会直接抛出异常。提示如果业务中需要很大的容量比如 32K 或 64K因为每个槽位都需要预创建对象容量越大初始内存占用越高。你的事件对象如果很重包含大数组建议估算一下总内存再定容量。6.2 消费者处理速度慢导致生产者阻塞变严重生产者在next()申请序号时如果队列满了会顺着 Sequencer 的等待策略自旋等待。如果消费者处理慢生产者自旋时间长CPU 占用飙升。这不是死锁是背压backpressure机制在起作用。解决思路是优化消费者逻辑、增加消费者线程数WorkHandler 模式、或者扩大 RingBuffer 容量。但要注意扩大容量并不能提高吞吐只是让生产者等待更少、消费者可以缓冲更多数据而已。6.3 消息丢失风险其实很低但有一个前提Disruptor 宕机或进程崩溃时如果事件还没被消费者落库那这些数据确实是会丢的。它本质上是一个内存队列没有持久化机制。要保证不丢消息需要消费者在收到数据后尽快写入持久化存储并采用 ACK 机制。这也是为什么在金融、交易这种强一致场景Disruptor 通常只作为内存加速层后端还要挂消息中间件或数据库来做持久化。6.4 多生产者并发发布如何保证顺序如果业务要求严格的全局顺序比如数据库 binlog 的有序回放就不能用多生产者必须用单生产者模式。多生产者并发发布时序号分配是原子的但是不同生产者进入的顺序和发布的顺序并不完全一致——线程 A 先申请了序号 1可能因为被线程调度暂停线程 B 随后申请了序号 2 并先发布了。如果全局顺序是硬要求请使用ProducerType.SINGLE并且业务上自己保证在同一生产线程内顺序提交。6.5 ErrorHandler 没配置导致消费者静默失败EventHandler 里的异常如果不做处理会传到 Disruptor 设的异常处理器。如果没有配置异常可能会被吞掉你只能从日志里发现消费者线程停止了。建议至少要配置一个ExceptionHandler并做日志记录disruptor.handleExceptionsFor(new ExceptionHandlerOrderEvent() { public void handleEventException(Throwable ex, long sequence, OrderEvent event) { log.error(消费异常sequence{}, sequence, ex); } public void handleOnShutdownException(Throwable ex) {} public void handleOnStartException(Throwable ex) {} });6.6 4.x 版本迁移的几个关键差异如果你真的要上 4.x需要接受这些变化。4.x 中RingBuffer被设计成更薄的数据结构类Sequence拆分为Cursor消费位置游标和ProducerGate生产者准入控制ConsumerGate取代了消费者依赖序列。原来的 Eh 接口变为Consumer抽象你不再直接用start()而是通过ProducerGate.tryPublish()发布数据。4.x 的价值在于让“多 RingBuffer 串联”场景更自然但它对新手的学习曲线也更陡。当前开源社区的迁移热度还不算高团队里如果没人熟悉 4.x我建议 3.4.x 再用一段时间完全不丢人。7. 写在最后的一些经验说了这么多技术细节最后回归到一个更本质的问题你的业务真的需要 Disruptor 吗我在实际项目中做过一次改造把一个核心链路上的 ArrayBlockingQueue 替换成 Disruptor。改造前系统的吞吐瓶颈确实在队列锁竞争上替换后吞吐提升了约 40%。但同一时期另一个项目瓶颈在数据库写入替换 Disruptor 后几乎没有任何提升。判断是否值得引入 Disruptor先看你的链路里是否存在“高并发跨线程传递数据”这个明确瓶颈。如果你的业务单机每秒也就几万个请求普通的有界队列配合合理的并发策略完全够用没必要引入无锁编程的复杂度。还有一点容易被忽视Disruptor 的生产者、消费者都在同一 JVM 内它解决的是线程间通信问题不是跨进程、跨网络通信问题。如果你的场景是微服务之间调接口请考虑消息队列或 RPC 框架Disruptor 不适用。如果你决定用我的建议是把 RingBuffer 容量定在默认值 1024 的 8 倍8192附近开始压测消费端尽量做到快速释放事件资源等待策略先用 Yielding 跑到热点 CPU 资源吃紧时再调优。希望这篇从原理到实战再到压测方法论的文章能帮你在面对“200 万 TPS”这样的数字时多一分冷静也多一分让数字落地的能力。