读写锁:读优先、写优先与公平读写锁的实际应用

读写锁这东西,说白了就是一把可以「区别对待」的锁。读操作可以共享,写操作必须互斥。听起来很简单对吧?但真正用起来,里面的门道可不少。

我在设计缓存系统的时候,就因为这个锁的选择问题折腾了好几个版本。今天咱们就来聊聊三种主流模式:读优先、写优先,还有公平读写锁。每个模式都有它自己的脾气。

为什么需要读写锁?

你先想想看,高并发场景下,读操作通常远多于写操作。如果一视同仁,把所有读写操作都串行化,那性能损失太大了。读写锁的核心思想就是:读读不互斥,读写互斥,写写互斥。

核心指标:读写锁能有效提升读多写少场景下的吞吐量,但不同的公平策略会影响读写线程的等待时间。

读优先锁(Read-Preferring Lock)

读优先锁,顾名思义,就是读线程优先获得锁。只要有一个读线程正在持有锁,其他读线程都可以同时进入。写线程呢?得等到所有读线程都释放了才行。

听起来挺合理的,对吧?但你想想看,如果读操作一直源源不断,写线程可能会被活生生饿死。我在一个缓存系统项目中就踩过这个坑。

实际案例:缓存系统的读优先陷阱

当时我们做了一个内存缓存,用来存热点数据。读请求高峰期,每秒几十万次。用的是读优先锁。结果呢?写线程(也就是缓存更新线程)等了整整几分钟都拿不到锁。

为什么呢?因为读操作太快了,一个刚释放,另一个又进来了。写线程永远排在后面。这不叫并发控制,这叫写操作罢工。

// 读优先锁的典型实现(Java ReadWriteLock 默认就是读优先)
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadPreferringCache {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
    
    private String cachedData;
    
    public String getData() {
        readLock.lock();
        try {
            return cachedData;
        } finally {
            readLock.unlock();
        }
    }
    
    public void updateData(String newData) {
        writeLock.lock();  // 写线程可能永远等不到
        try {
            cachedData = newData;
        } finally {
            writeLock.unlock();
        }
    }
}
避坑指南:我曾经在一个日志采集系统里也用读优先锁,结果写线程被阻塞了十几秒,导致数据积压,最终 OOM 了。读优先锁不适合写操作频繁或写操作有严格延迟要求的场景。

写优先锁(Write-Preferring Lock)

写优先锁就反过来。只要有一个写线程在排队,新的读线程就不能进入。写线程能更快获得锁。

但这里也有问题:如果写线程太频繁,读线程的延迟会暴涨。我记得在一次数据库连接池的项目中,就因为锁切换不留意,导致读超时暴增。

// 写优先锁的典型实现(Java StampedLock 支持写优先模式)
import java.util.concurrent.locks.StampedLock;

public class WritePreferringCache {
    private final StampedLock stampedLock = new StampedLock();
    private String cachedData;
    
    public String getData() {
        long stamp = stampedLock.readLock();
        try {
            return cachedData;
        } finally {
            stampedLock.unlockRead(stamp);
        }
    }
    
    public void updateData(String newData) {
        long stamp = stampedLock.writeLock();
        try {
            cachedData = newData;
        } finally {
            stampedLock.unlockWrite(stamp);
        }
    }
}
技巧:写优先锁适合写操作不多但要求写完成后立刻可见的场景。比如配置中心的热更新。

公平读写锁(Fair Read-Write Lock)

公平模式,说白了就是按排队顺序来。不管你是读还是写,先到先得。这样不会饿死任何一方。

Java 的 ReentrantReadWriteLock(true) 就支持公平模式。我一般在高并发、读写比例比较均衡的场景下用这个。

// 公平读写锁
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class FairCache {
    private final ReentrantReadWriteLock fairLock = 
        new ReentrantReadWriteLock(true);  // true 表示公平
    private final ReentrantReadWriteLock.ReadLock readLock = fairLock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = fairLock.writeLock();
    
    private String cachedData;
    
    public String getData() {
        readLock.lock();
        try {
            return cachedData;
        } finally {
            readLock.unlock();
        }
    }
    
    public void updateData(String newData) {
        writeLock.lock();
        try {
            cachedData = newData;
        } finally {
            writeLock.unlock();
        }
    }
}
公平模式的特点:
  • 避免线程饥饿,所有请求都有机会执行
  • 吞吐量通常略低于非公平模式(因为有排队开销)
  • 适合读写频率接近、延迟要求稳定的场景

三种模式的对比表格

特性 读优先 写优先 公平读写锁
读线程延迟 低(写线程少时) 可能较高 中等
写线程延迟 可能极高(读频繁时) 低 中等
线程饥饿风险 写线程可能饿死 读线程可能饿死 无饥饿
适用场景 读多写少、写可容忍延迟 写少但写后需立即生效 读写均衡、延迟敏感
吞吐量 高(读操作多时) 高(写操作多时) 中等

我在缓存系统中的锁选择经验

最后分享一下我个人的经验。在做缓存系统时,我试过三种模式。最后怎么选的?看场景。

如果缓存更新频率极低(比如每小时一次),我选读优先。因为写几乎不会发生,读性能最大化。

如果缓存更新必须在毫秒级完成(比如秒杀系统的价格更新),我选写优先。为了写性能,牺牲一点读延迟也值得。

如果读写比例接近 7:3,而且延迟抖动不能超过 10%,我选公平锁。虽然吞吐量会降一点,但能保证两边都不挨饿。

注意:公平锁不是万能的。我有个同事在 Redis 客户端连接池用了公平锁,结果因为排队检查的开销,吞吐量掉了 30%。合理评估你的并发模型,别盲目选公平模式。
读写锁三种模式对比流程图 读写锁公平策略选择流程 选择读写锁策略 读写比例如何? 读多写少 读优先模式 写线程可能饿死 读写均衡 公平读写锁 无饥饿,延迟稳定 写多读少 写优先模式 读线程可能饿死 适用:缓存系统 写操作极少 适用:配置中心 读写比例均衡 适用:价格更新 写操作及时生效 根据场景选择最优公平策略 提示:可用 StampedLock 实现乐观读优化

关于锁的升级与降级

还有一个点你可能第一次接触——锁的升级和降级。Java 的 ReentrantReadWriteLock 不支持从读锁升级到写锁(会死锁)。但 StampedLock 支持乐观读,可以尝试转换为读锁或者写锁。

// StampedLock 的乐观读与锁升级
import java.util.concurrent.locks.StampedLock;

public class StampedCache {
    private final StampedLock lock = new StampedLock();
    private String data;
    private long version;
    
    public String readOptimistically() {
        long stamp = lock.tryOptimisticRead();
        String currentData = this.data;
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                currentData = this.data;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return currentData;
    }
    
    public void updateAndUpgrade(String newData) {
        long stamp = lock.readLock();
        try {
            // 检查是否需要更新...
            long ws = lock.tryConvertToWriteLock(stamp);
            if (ws != 0L) {
                stamp = ws;
                this.data = newData;
                this.version++;
            } else {
                lock.unlockRead(stamp);
                stamp = lock.writeLock();
                this.data = newData;
                this.version++;
            }
        } finally {
            lock.unlock(stamp);
        }
    }
}
经验之谈:我个人建议,StampedLock 的乐观读模式在缓存读取场景下特别好用。它能避免大部分锁竞争。但要注意,乐观读的使用场景是有条件的——你得允许读到脏数据,或者在验证失败后回退到悲观读。

选型建议总结

我的决策清单:
  1. 如果读操作占比 > 95%,写操作可以等几秒→用读优先
  2. 如果写操作必须秒级生效,读操作可以等→用写优先
  3. 如果读写比例差不多,而且延迟都很敏感→用公平锁
  4. 如果读操作极其频繁,且可以接受偶尔读到旧数据→用StampedLock 乐观读

嗯,这就是我这些年用读写锁的心得。记住一点:没有万能的锁策略,只有适合你场景的锁策略。多测试,多看实际压测数据,比光看理论强。下次你就可以自信地跟别人说「这个场景该用哪一种读写锁」了。