在双 11 大促的商品链路中,读写流量的比例通常呈现极端的“二八原则”甚至“一九九原则”:
每当有 1 个用户真正执行下单扣减库存(写操作)时,往往有超过1,000 个用户在反复刷新商品详情页查看实时库存与价格(读操作)。
如果此时架构师图省事,无论读还是写,全部统一使用一把普通的排他独占锁(如lock:stock:sku_1001):
- 这意味着那 1000 个仅仅是想看一眼库存的只读请求,必须老老实实跟在写请求后面串行排队;
- 读请求互相之间明明没有任何数据冲突,却被强制互斥挂起;
- 商品详情页的平均响应时间瞬间从 15ms 暴涨到数秒,系统吞吐量遭受灭顶之灾。
针对这种典型的“读多写少”高并发场景,最优雅的解法是引入读写锁(ReadWriteLock):允许多个读线程同时并行持有锁,只有写线程进入时才执行排他独占。
今天我们深度剖析 Redisson 分布式读写锁RReadWriteLock的底层源码,揭开其状态管理的精妙设计,更要弄懂为什么必须进行锁降级(Lock Downgrading),以及为什么在多线程环境下绝对不能尝试锁升级。
本地 vs 分布式:状态管理的异曲同工之妙
在 Java 单机环境中,ReentrantReadWriteLock底层通过 AQS 的一个 32 位整型state,巧妙地进行了高低位拆分:
- 高 16 位:记录读锁的共享持有计数(Read Count);
- 低 16 位:记录写锁的独占重入计数(Write Count)。
而在分布式环境下,Redisson 面对的是跨 JVM、跨物理服务器的集群并发。它利用 Redis 的String 与 Hash 组合数据结构,完美复刻了这一语义。
Redisson 读写锁的物理存储结构:
- 写锁(Write Lock):
在 Redis 中表现为一个 Hash 结构,记录当前持有写锁的客户端UUID:ThreadId与重入次数;并在 String 键mode中标记当前锁为独占模式:Key: "lock:stock:sku_1001" Field: "mode" -> "write" Field: "e1a9c3d4-8b9a:142:write" -> 1 - 读锁(Read Lock):
如果当前没有写锁,多个客户端可以同时申请读锁。Redisson 会在 Hash 中为每个读线程记录各自的计数,并在全局read字段上进行原子累加:Key: "lock:stock:sku_1001" Field: "mode" -> "read" Field: "e1a9c3d4-8b9a:142" -> 1 Field: "f8c2b1e0-7a3d:205" -> 1 Field: "total_reads" -> 2
逐行拆解:读锁与写锁互斥的 Lua 核心判定
1. 读锁获取时的 Lua 逻辑简析:
-- 1. 判断锁是否存在,或者当前锁的模式是否为读模式 local mode = redis.call('hget', KEYS[1], 'mode'); if (mode == false or mode == 'read') then -- 没有写锁占用:允许多个读操作并发进入 redis.call('hset', KEYS[1], 'mode', 'read'); -- 为当前读线程的租约计数 +1 redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; -- 加读锁成功! end; -- 2. 如果当前有写锁,且持有写锁的恰好就是当前线程自己(允许锁降级的前提!) if (mode == 'write' and redis.call('hexists', KEYS[1], ARGV[3]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; -- 同一线程在持有写锁时,允许重入获取读锁 end; -- 3. 其他线程正在持有写锁:加读锁失败,返回等待时间 return redis.call('pttl', KEYS[1]);核心设计亮点:
- 当读锁并发进入时,Redis 只做简单的原子计数累加,网络延迟极低;
- 只要没有写操作发生,几千个读请求可以百分之百并行通过,详情页吞吐量直接提升了数十倍!
生产级实战:锁降级(Lock Downgrading)的正确姿势
在商品库存变更并刷新缓存的场景中,经常需要执行这样的逻辑:“修改库存数据 $\rightarrow$ 刷新本地与分布式缓存 $\rightarrow$ 随后继续以只读方式使用最新数据”。
很多开发者写完修改逻辑后,直接writeLock.unlock(),然后再去调readLock.lock()。
这是极其危险的漏洞写法!
在释放写锁与重新获取读锁的这几微秒间隙内,其他并发写线程可能会瞬间插进来修改数据,导致当前线程后续读取到的数据根本不是刚才自己写入的版本。
必须执行标准的**锁降级(Lock Downgrading)**流程:
- 获取写锁;
- 执行数据变更与写入;
- 在不释放写锁的前提下,就地直接获取读锁;
- 释放写锁;(此时线程依然安全地持有读锁,其他写线程无法插队修改数据!)
- 执行只读业务操作;
- 最终释放读锁。
@Service public class SafeProductInventoryService { @Autowired private RedissonClient redissonClient; @Autowired private ProductMapper productMapper; public void updateAndRefreshCache(String skuId, int delta) { RReadWriteLock rwLock = redissonClient.getReadWriteLock("rw:stock:" + skuId); RLock writeLock = rwLock.writeLock(); RLock readLock = rwLock.readLock(); // 1. 获取写锁 writeLock.lock(); try { // 2. 执行核心写库操作 productMapper.deductStock(skuId, delta); // 3. 核心关键:在持有写锁的同时,提前获取读锁(完成锁降级准备!) readLock.lock(); } finally { // 4. 释放写锁!此时当前线程依然持有读锁,没有任何其他写操作能够侵入! writeLock.unlock(); } try { // 5. 在读锁保护下,安全地读取最新落盘数据并刷新各级缓存 Product latest = productMapper.selectById(skuId); refreshLocalCache(latest); } finally { // 6. 最终释放读锁 readLock.unlock(); } } }为什么说“锁升级(Lock Upgrading)”必死无疑?
很多同学自然会反向思考:“既然能从写锁降级为读锁,那能不能反过来——先加读锁看一眼,如果需要修改,再就地升级为写锁?”
绝对不行!在任何多线程或分布式环境下,锁升级是导致死锁的头号杀手!
假设有两个并发线程 A 和 B,同时持有了读锁(这是完全合法的并发操作):
- 线程 A 发现数据需要更新,试图在持有读锁的前提下申请写锁;写锁要求“全场没有任何读锁”,因此线程 A 在等待线程 B 释放读锁;
- 恰好,线程 B 也发现数据需要更新,同样在持有读锁的前提下申请写锁;它也在死等线程 A 释放读锁;
- 两个线程都在等待对方释放读锁,同时自己绝不撒手,当场形成不可解的死锁闭环!
因此,不管是 Java 本地的ReentrantReadWriteLock还是 Redisson,都严禁锁升级。一旦发现需要写操作,必须先老老实实释放读锁,再重新去抢写锁。
掌握读写锁的边界与降级之道,是在读多写少的亿级流量战场上,将吞吐量压榨到物理极限的最强利刃。