4、锁优化与无锁编程:细粒度锁、读写锁、CAS原子操作、无锁队列(Disruptor模式)在撮合引擎中的应用

说到撮合引擎的锁优化,我得先坦白一件事。

我早年做第一版撮合引擎时,图省事,直接上了个大粗锁。整个订单簿一把锁,撮合、撤单、查询全串行。结果呢?压测到每秒5000笔订单,CPU直接飙到90%,延迟从5微秒跳到200微秒。客户骂娘,我背锅。

从那以后,我算是彻底明白了——锁,是性能的第一杀手。但不用锁又不行,数据一致性谁来保证?

所以今天咱们聊聊,怎么在保证正确性的前提下,把锁的代价降到最低。

核心观点:锁不是不能用,而是要用对地方、用对粒度、用对时机。无锁编程不是银弹,但特定场景下确实能带来数量级的提升。

4.1 细粒度锁:别一把锁锁死全世界

什么叫细粒度锁?说白了,就是把一把大锁拆成多把小锁。

举个例子。订单簿里通常有多个价格档位,每个档位有自己的买卖队列。如果你用一把锁锁住整个订单簿,那一个档位的操作会阻塞其他所有档位。这合理吗?不合理。

我建议的做法是:每个价格档位一把锁

// 粗粒度锁 - 不推荐
public class OrderBook {
    private final ReentrantLock globalLock = new ReentrantLock();
    private Map<Double, OrderQueue> priceLevels;
    
    public void addOrder(Order order) {
        globalLock.lock();
        try {
            // 操作某个价格档位
            priceLevels.get(order.getPrice()).add(order);
        } finally {
            globalLock.unlock();
        }
    }
}

// 细粒度锁 - 推荐
public class OrderBook {
    private final Map<Double, PriceLevel> priceLevels;
    
    public void addOrder(Order order) {
        PriceLevel level = priceLevels.get(order.getPrice());
        level.lock();  // 只锁当前档位
        try {
            level.add(order);
        } finally {
            level.unlock();
        }
    }
}

我在项目中遇到过一个问题:某个热门价格档位竞争激烈,其他档位却空闲。这时候细粒度锁的优势就出来了——热门档位忙它的,冷门档位不受影响。整体吞吐量提升了3倍多。

避坑指南:细粒度锁虽然好,但别分得太细。我曾经把每个订单都单独加锁,结果锁对象太多,内存开销暴涨,GC频繁触发,反而更慢了。适度就好。

4.2 读写锁:读多写少的场景是绝配

撮合引擎里有个典型场景:行情查询。

你想想看,一个订单簿上,可能每秒有1000次查询,但只有100次撮合写入。读操作远多于写操作。这时候用普通的互斥锁,读和读之间也要互相等待,太浪费了。

读写锁就是干这个的——读读不互斥,读写互斥,写写互斥

public class OrderBookSnapshot {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private Snapshot currentSnapshot;
    
    // 读操作 - 可以并发
    public Snapshot getSnapshot() {
        rwLock.readLock().lock();
        try {
            return currentSnapshot.copy();
        } finally {
            rwLock.readLock().unlock();
        }
    }
    
    // 写操作 - 必须独占
    public void updateSnapshot(OrderBook book) {
        rwLock.writeLock().lock();
        try {
            currentSnapshot = book.buildSnapshot();
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

我记得有一次,一个团队用普通锁做行情推送,延迟波动很大。换成读写锁后,读操作几乎零等待,写操作虽然还是独占,但写频率低,影响微乎其微。整体延迟从50微秒降到了5微秒。

注意:读写锁不是万能的。如果写操作占比超过20%,读写锁的优势就不明显了。因为写锁会阻塞所有读锁,写多了反而比普通锁更差。我见过有人不分青红皂白全上读写锁,结果性能反而下降。

4.3 CAS原子操作:无锁的基石

CAS,全称Compare-And-Swap,翻译过来就是「比较并交换」。它是个CPU指令级别的原子操作。

为什么说它是无锁的基石?因为很多无锁数据结构,底层都是靠CAS实现的。

// 使用CAS实现线程安全的计数器
public class AtomicCounter {
    private volatile int value;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
    
    static {
        try {
            valueOffset = unsafe.objectFieldOffset(
                AtomicCounter.class.getDeclaredField("value"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
    
    public int incrementAndGet() {
        int current;
        do {
            current = unsafe.getIntVolatile(this, valueOffset);
            // CAS: 如果当前值等于期望值,就更新为新值
        } while (!unsafe.compareAndSwapInt(this, valueOffset, current, current + 1));
        return current + 1;
    }
}

CAS有个经典问题叫ABA问题。什么意思呢?就是线程1读到值是A,准备CAS时,线程2把A改成B又改回A。线程1的CAS成功了,但它不知道中间发生过变化。

嗯,这里要注意。在撮合引擎里,ABA问题可能导致订单状态错乱。解决办法是加版本号,或者用AtomicStampedReference

我的经验:CAS适合轻量级的原子操作,比如计数器、状态标记。但别用它实现复杂的业务逻辑,否则代码会变得极其难读且容易出错。我曾经用CAS实现了一个无锁的订单状态机,调试了整整三天才跑通,后来还是换回了锁——因为维护成本太高了。

4.4 无锁队列:Disruptor模式

说到无锁编程,就绕不开Disruptor。这是LMAX交易所开源的一个高性能无锁队列框架,号称单线程每秒能处理600万笔订单。

Disruptor的核心思想其实很简单:用环形缓冲区 + 预分配内存 + 序列号机制,避免锁和GC

Disruptor 无锁队列架构图 槽位0 槽位1 槽位2 槽位3 槽位4 槽位5 槽位6 槽位7 生产者 消费者 序列号(Sequence)机制 预分配内存 + 序列号CAS更新 = 无锁、无GC

Disruptor的几个关键点:

  • 环形缓冲区:固定大小的数组,避免动态扩容和内存分配
  • 预分配事件对象:创建时就分配好内存,运行时复用,避免GC
  • 序列号:用CAS更新序列号,代替锁
  • 批处理:消费者可以一次拉取多个事件,减少上下文切换
// Disruptor 在撮合引擎中的简化使用
public class MatchingEngine {
    private final RingBuffer<OrderEvent> ringBuffer;
    private final EventHandler<OrderEvent> handler;
    
    public MatchingEngine() {
        // 创建环形缓冲区,大小必须是2的幂
        this.ringBuffer = RingBuffer.createSingleProducer(
            OrderEvent::new,  // 事件工厂
            1024,             // 缓冲区大小
            new YieldingWaitStrategy()  // 等待策略
        );
        
        this.handler = (event, sequence, endOfBatch) -> {
            // 处理订单撮合逻辑
            matchOrder(event.getOrder());
        };
        
        // 绑定消费者
        ringBuffer.addGatingSequences(handler);
    }
    
    // 发布订单 - 无锁操作
    public void submitOrder(Order order) {
        long sequence = ringBuffer.next();  // CAS获取序列号
        try {
            OrderEvent event = ringBuffer.get(sequence);
            event.setOrder(order);
        } finally {
            ringBuffer.publish(sequence);  // CAS发布
        }
    }
}

我在项目中用Disruptor替换了原来的BlockingQueue,延迟从30微秒降到了3微秒。为什么?因为BlockingQueue底层用了ReentrantLock,而Disruptor全程无锁。

注意:Disruptor虽然快,但使用门槛高。你得自己管理事件对象的生命周期,处理序列号溢出问题,还要选择合适的等待策略。我见过有人把Disruptor用成了BlockingQueue,结果性能还不如原来的——因为等待策略选错了。

4.5 实战建议:怎么选?

说了这么多,到底该怎么选?我总结了一张表:

场景 推荐方案 原因
订单簿不同档位操作 细粒度锁 减少锁竞争,提高并发度
行情快照查询(读多写少) 读写锁 读读不互斥,提升查询性能
计数器、状态标记 CAS原子操作 轻量级,无阻塞
订单事件传递(高吞吐) Disruptor无锁队列 无锁、无GC、批处理
复杂业务逻辑(状态机) 还是用锁吧 无锁实现太复杂,容易出bug

最后说一句:不要为了无锁而无锁。我见过一个团队,花了两周时间把一段代码改成无锁的,结果性能只提升了5%,代码却变得谁都看不懂。值吗?不值。

锁优化也好,无锁编程也罢,最终目标都是在保证正确性的前提下,把延迟降到最低。选对方案,比炫技重要得多。

我的习惯:先上锁,跑压测,看瓶颈在哪。如果锁竞争不严重,就别动。如果锁是瓶颈,再针对性地优化。别一开始就搞无锁,那是最后的手段。


公众号:蓝海资料掘金营,微信deep3321