2586 字
约 8 分钟
17
S05-X24 · 高性能队列:Disruptor 与无锁设计的边界
2024-12-31
2026-11-08

"把 ArrayBlockingQueue 换成 Disruptor,吞吐提升一个数量级"——这类结论被反复引用,却很少附带前提。Disruptor 快的真正原因不是"用了 CAS",而是它先消除了锁、再消除了伪共享、最后消除了对象分配。三者缺一,收益就没了。这篇从瓶颈出发,讲清它为什么快、什么时候不快。


1. 瓶颈先定位:队列慢在哪一层

瓶颈 机制 表现
锁竞争 入队/出队共用一把锁,线程休眠与唤醒 线程数上升,吞吐反而下降
伪共享 头尾指针或相邻计数器落在同一缓存行 大量缓存行失效(cache miss)
内存屏障 每次可见性保证都刷新写缓冲 CPU 利用率高但有效吞吐低
对象分配 每个事件都是一个新对象 Young GC 频繁,停顿抖动
上下文切换 阻塞唤醒导致线程状态切换 延迟长尾明显

ArrayBlockingQueue 用单锁 + 两个 Condition,前三条都会命中。LinkedBlockingQueue 改成双锁(putLock/takeLock),缓解了入队出队互斥,但仍有节点对象分配和链表指针跳转的开销。


2. Disruptor 的四个设计

设计 替代了什么 效果
环形数组预分配 每次 new 事件对象 零分配,GC 压力归零
序号 + 屏障代替锁 入队出队的互斥 无锁,只有 CAS 与屏障
依赖关系图(gatingSequences) 消费者之间的协调 支持多级流水线
批量消费 逐条取 摊薄序号检查开销
// 骨架:预分配 + 序号申领 + 发布
RingBuffer<Event> ring = RingBuffer.createSingleProducer(Event::new, 1024, strategy);
long seq = ring.next();          // ① 申领一个序号(CAS 或普通递增)
try {
    Event e = ring.get(seq);     // ② 拿到已预分配的对象,直接改字段
    e.setValue(v);
} finally {
    ring.publish(seq);           // ③ 发布,消费者才可见
}

第③步的 publish 是新手的坑:忘了它,消费者永远看不到该事件,而生产者继续往前覆盖序号——表现为"消费者卡住 + 数据凭空消失"。

next() 的语义是申领,不是"插入"。序号只能单调递增,超过容量时对容量取模,落到哪个槽位就是哪个槽位。


3. waitStrategy:四种等待策略的取舍

策略 机制 CPU 占用 延迟 适用
BlockingWaitStrategy 锁 + Condition 低 高(微秒级,含唤醒) CPU 紧张、延迟不敏感
BusySpinWaitStrategy 纯自旋 极高(独占核) 最低 绑核、极致低延迟
YieldingWaitStrategy 自旋 + Thread.yield() 高 低 线程数 < 核数
SleepingWaitStrategy 自旋 + LockSupport.parkNanos 低 中(有睡眠阶梯) 通用折中,常用默认
// 典型选择:延迟与 CPU 的折中
WaitStrategy strategy = new SleepingWaitStrategy();      // 通用
// 绑核 + 独占 CPU 的撮合场景才用
WaitStrategy ultraLow = new BusySpinWaitStrategy();

YieldingWaitStrategy 的前提是"线程数严格小于可用核数",否则 yield 之后拿不到 CPU,延迟反而比 Sleeping 更差。BusySpin 同理,超配线程时会互相抢核。


4. 生产者类型与序号申领

类型 申领方式 冲突处理 吞吐
createSingleProducer 普通 long 自增 无冲突 最高
createMultiProducer AtomicLong / Sequencer CAS 多生产者竞争 低于单生产者
createMultiProducer(..., 批量) 按批申领 冲突概率下降 接近单生产者
// 单生产者:没有 CAS,只有一次普通写 + 屏障
RingBuffer<Event> one = RingBuffer.createSingleProducer(Event::new, 4096);

// 多生产者:必须用多生产者版本,否则序号会冲突
RingBuffer<Event> many = RingBuffer.createMultiProducer(Event::new, 4096,
        new SleepingWaitStrategy());

选错类型的后果很隐蔽:用单生产者版本配多线程写入,序号会重复分配,两个线程改同一个槽位,数据互相覆盖且不报错。


5. 伪共享与缓存行填充

环形数组的 Sequence 和消费者的 Sequence[] 会被频繁并发读写。若两个 Sequence 落在同一 64 字节缓存行,一方的写会让另一方的缓存行失效,性能断崖。

// 手动填充(Disruptor 内部用 @Contended 或 padding 字段实现)
class PaddedSequence {
    long p1, p2, p3, p4, p5, p6, p7;   // 7 × 8 = 56 字节填充
    volatile long value;
    long p9, p10, p11, p12, p13, p14, p15;
}

JDK 8 起可以用 @sun.misc.Contended,但要加 -XX:-RestrictContended 才能对用户类生效。按 JDK 17 核对:这个参数仍然有效,只是 sun.misc 包在编译期需要特殊处理,工程上更常见的是手写 padding。


6. SequenceBarrier 与消费依赖

SequenceBarrier 是"消费者能看到哪个位置"的闸门:它由一组 gatingSequences 构成,消费者只能推进到所有依赖都已处理完的最小序号。

// 两级流水线:handlerB 依赖 handlerA
SequenceBarrier barrierA = ring.newBarrier();
SequenceBarrier barrierB = ring.newBarrier(handlerA.getSequences());
ring.addGatingSequences(handlerB.getSequences());

BatchEventProcessor<Event> pa = new BatchEventProcessor<>(ring, barrierA, handlerA);
BatchEventProcessor<Event> pb = new BatchEventProcessor<>(ring, barrierB, handlerB);
new Thread(pa).start();
new Thread(pb).start();
配置 语义 用途
不传依赖 可与生产者并行消费 独立消费者(如日志落盘)
传 handlerA.getSequences() 必须等 A 处理完 串行流水线
多个 gatingSequences 等最慢的那个 菱形依赖汇合

addGatingSequences 必须覆盖所有消费者,否则环形数组会误判"可以覆盖",把未消费的事件冲掉。


7. 队列选型对照

队列 延迟量级 是否持久化 能否重放 堆积能力
ArrayBlockingQueue 微秒 否 否 受容量限制
LinkedBlockingQueue 微秒 否 否 近似无界(危险)
Disruptor 亚微秒(典型量级) 否 否 容量即上限,写满覆盖
MQ(Kafka/RocketMQ) 毫秒 是 是 磁盘级

判断只需回答三个问题:延迟是否在微秒级敏感?需不需要重启后重放?消费失败能不能重试?

三个"是"里有任何一个指向"需要持久化/重放",Disruptor 就该出局——它把事件放在堆内数组里,进程一挂全丢。


8. 无锁的正确性边界

无锁不等于"没有同步",它只是把同步点从锁换成了序号。要证明没有丢失,需要三个不变量同时成立:

不变量 内容 破坏后果
单调性 生产者序号严格递增、无重复 槽位被覆盖
可见性 publish 前对槽位的写必须先于序号发布 消费者读到半写状态
闸门完整性 所有消费者都注册进 gatingSequences 未消费数据被覆盖

这里的线性一致性点在 publish(seq):在此之前的写对消费者不可见,之后才可见。所以"先改字段、再 publish"的顺序不能颠倒,中间也不能抛异常导致 publish 被跳过——这正是 try/finally 的意义。

多生产者模式下,线性一致性点前移到序号 CAS 成功的那一刻:CAS 决定了谁拿到哪个槽位,后续对该槽位的写由"已获得序号"这一事实隔离。


9. 排错与坑

现象 根因 处理
消费者完全不动 生产者漏了 publish 检查 try/finally
数据错乱、字段串值 预分配对象里有可变状态未重置 每轮消费后清空或整体替换
吞吐远低于预期 容量不是 2 的幂,取模退化为除法 容量取 1024/4096
偶发停摆 消费者抛异常,BatchEventProcessor 终止 处理器内 try/catch,不让异常外溢
CPU 100% 但没产出 BusySpin 超配线程 换 Sleeping/Yielding
高并发下计数错乱 单生产者版本被多线程使用 换 createMultiProducer

两个最致命的问题值得展开:

消费者异常导致整个环停摆:BatchEventProcessor.run() 捕获异常后会退出循环并停止推进序号。序号不动,生产者很快被闸门挡住,整条链路静止。处理器内部必须自己兜住异常。

容量必须是 2 的幂:Disruptor 用位运算替代取模(seq & (capacity - 1))。容量传 1000 不会报错,但序号映射会算错,事件落错槽位——这类 bug 只在特定序号下复现,极难排查。


10. 边界:Disruptor 不适用的场景

场景 结论 原因
跨进程/跨机房 不适用 它只在单 JVM 内
需要消息持久化 不适用 堆内数组,无落盘
消费失败要重试 不适用 无 ack/重投语义
生产速率远高于消费 不适用 写满即覆盖,天然丢弃
事件量小、延迟不敏感 收益不足 复杂度成本 > 性能收益
单机极低延迟撮合/风控 适用 无锁 + 预分配的核心优势
日志/指标的异步落盘 适用 可丢弃、单机、量可控

一句话:Disruptor 是"把队列做成内存里的流水线",不是"更快的 MQ"。把它当缓冲层用来吸收堆积,方向就错了——它的容量就是上限,超过就覆盖。


11. 自测

  1. Disruptor 快的三个来源分别消除了哪类开销?
  2. publish 漏掉的后果是什么?为什么必须放在 try/finally 里?
  3. 四种 waitStrategy 中,哪一种要求线程数严格小于核数?
  4. 用单生产者版本配多线程写入会出现什么现象?
  5. 环形数组容量为什么必须是 2 的幂?传 1000 会怎样?
  6. 判断"该用 Disruptor 还是 MQ"的三个问题是什么?
S05-X24 · 高性能队列:Disruptor 与无锁设计的边界
https://www.clxhxhhr.top/posts/5929/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。