Redis读写锁原理与实战:读多写少场景下的缓存一致性方案
2026/9/12 23:56:04 网站建设 项目流程

读多写少这四个字,听起来像是一个特别普通的业务特征,但如果你真正在一个高并发服务里维护过商品详情、配置项、排行榜这类热点数据,就会明白“读多写少”其实是最容易翻车的场景之一。热点 key 读流量一上来,缓存击穿、脏数据、接口抖动全来了,而大部分团队的第一反应就是“加锁”,结果一不小心就把本来可以并行读的流量全部串行化,性能反而更差。

Redis 里的读写锁(ReadWriteLock)专门解决这个尴尬:读锁之间共享,写锁独占,读多写少时既能保证数据一致性,又不把读流量堵死。这篇文章我会围绕 Redis 读写锁,从原理、选型、落地代码到排查经验完整讲一遍,适合正在做缓存治理、分布式系统设计的后端同学,也适合那些已经听过分布式锁、但不清楚读写锁和普通互斥锁区别的读者。看完之后,你至少能明确一个问题:读多写少的场景到底该不该上读写锁,上了之后怎么用才不会踩坑。

1. 读多写少场景到底需不需要锁

1.1 没有锁的时候,问题出在哪里

先说一个我很常见的业务模型:一个商品详情接口,查询 QPS 能冲到 5000,但真正的商品信息变更,可能一天也就几十次。这种数据放数据库扛不住,大家自然就会引入 Redis 做缓存。但缓存一旦用上,就会有三个经典问题:缓存穿透、缓存击穿、缓存不一致。

穿透和击穿属于另一个话题,今天我们重点盯缓存不一致。想象这样一个流程:线程 A 读缓存,发现没有,去数据库查出旧数据准备回填;线程 B 这时候修改了数据库,写入了新数据并更新了缓存;线程 A 回填时把旧数据覆盖进了 Redis。最终结果就是缓存里的数据和数据库对不上,而且如果不做额外处理,这个脏数据可能要在缓存过期之后才被纠正。更麻烦的是读多写少场景下数据读得频繁,脏数据被反复读到,影响面一下就变大了。

要解决这种并发覆盖问题,最直接的办法就是让“读缓存、回填缓存”和“写数据库、更新缓存”这两个流程互斥。可一旦用了普通互斥锁,问题又来了:5000 个读请求必须排队拿锁,每次只有一个人能查库回填,其他的全在等待。哪怕缓存命中的读操作本身没有并发安全问题,也被锁强行串行化了。这就好像一条四车道的高速路,因为入口有一个单车道收费站,所有车都得堵在入口,明明里面路很宽,却跑不起来。

1.2 锁的粒度选择:JVM 锁、分布式锁与 Redis 读写锁

提到“加锁”,很多人的第一反应是 synchronized 或者 ReentrantLock。如果服务是单机部署,用 JVM 锁没有任何问题,简单又高效。但实际分布式中服务往往是多实例部署,请求会负载均衡到不同机器,JVM 锁只能锁住一个进程,解决不了跨进程的并发覆盖问题。这时候就需要分布式锁。

Redis 分布式锁是业界最常用的方案,核心思想是利用 Redis 单线程执行命令的原子性,通过 SETNX 这类操作实现“同时只有一个客户端能拿到锁”。但它提供的语义是“完全互斥”,并没有区分读和写。也就是说,两个读请求也会被互斥,读多写少场景下依旧是浪费。

Redis 读写锁则在分布式锁的基础上增加了“模式”维度:读锁可以被多个持有者共享,写锁只能被一个持有者获取,且写锁存在时读锁也拿不到。这样设计最巧妙的地方在于,它把锁的粒度细分到了“读读共享”的级别。高并发读场景下,读锁完全不阻塞其他读线程,只有真正写数据时才会短暂阻塞读操作,从而在一致性和并发度之间找到了平衡点。

1.3 什么情况下才值得上读写锁

不是所有读多写少场景都应该上读写锁。我自己的判断标准是三个条件同时满足:第一,读请求量足够大,大到排队的损耗不可忽略;第二,写操作确实存在,并且会引发缓存与数据库的不一致;第三,业务对一致性有一定要求,能接受写操作期间短暂阻塞读,但不能接受长期脏数据。

如果只是纯读场景,比如静态配置加载后基本不变,那连锁都不需要,直接用缓存就行。如果是频繁写场景,比如秒杀库存,读写锁反而会成为瓶颈,因为写锁和读锁互斥,写多的时候读会被频繁打断,吞吐量远不如无锁优化或队列化写入。搞清这一点,比会敲几行代码重要得多。上锁的最终目标不是“看起来安全”,而是“在安全的前提下尽量不损失性能”。

2. Redis 读写锁的原理与选型思路

2.1 读写锁的基础语义与 Redis 实现切入点

读写锁来自 Java 的 ReadWriteLock 思想,核心规则我总结成三句话:读锁与读锁共享,读锁与写锁互斥,写锁与写锁互斥。 翻译成业务语言就是:大家一起读没事,但有人写的时候谁也不能读,写者之间也不能同时写。

在 Redis 里实现这套语义,最关键的是“原子性”。为了判断一个读锁能否获取,需要先检查是否存在写锁,如果不存在才把读锁计数加一。这个“检查”和“加一”必须是原子操作,否则多个线程同时检查会同时通过,计数就乱了。Redis 单线程执行命令天然适合做这种原子操作,但多条命令组合在一起就不一定了,所以真正工程化的实现都会选择 Lua 脚本,或者封装好的客户端库。

读锁计数可以用 Redis 的 Hash 数据结构来存:field 为读锁标识,value 为持有次数,写锁则用一个普通字符串 key 表示。这样可以在一把“锁”里同时管理读状态和写状态。如果你用 Redis Desktop Manager 之类的可视化工具看过 Redisson 生成的锁键,会发现它内部就是一个 Hash 类型的 key,里面存着模式标识、线程标识和重入次数。

2.2 Redisson 的 RReadWriteLock 到底做了什么

生产环境大多数团队不会自己写 Lua 脚本,而是直接使用 Redisson。Redisson 提供了现成的 RReadWriteLock,用法和 Java 的 ReadWriteLock 几乎一模一样,但底层实现是基于 Redis 的分布式锁。 它内部用的也是 Lua 脚本,实现了一套公平的、支持可重入的读写锁。

具体来说,RReadWriteLock 会为同一个业务 key 维护一把“锁”,获取读锁时调用readLock(),获取写锁时调用writeLock()。第一次获取锁时,Redisson 会在 Redis 里创建一个 Hash 结构,里面记录当前模式(读或写),以及持有锁的线程 ID 和重入次数。后续的加锁、解锁、超时续期,全部由 Lua 脚本完成,保证原子性。

Redisson 的锁还有一个特性,就是默认开启看门狗机制:如果锁没有指定过期时间,Redisson 会每 10 秒自动续期到 30 秒。 这个设计主要是防止业务代码异常退出后锁被永久持有。但使用读写锁时要注意,看门狗续期只针对“显式设置了锁过期时间”的反向情况,如果你自己传了 leaseTime,Redisson 就不会自动续期。实际开发中我建议优先使用默认方式,让看门狗来兜底,防止业务处理时间超过锁超时时间导致锁提前释放。

2.3 选型对比:别把读写锁当成万能药

聊原理的时候也要聊聊选型,毕竟很多团队一开始会纠结“我用 SETNX 实现分布式锁不就行了,为什么要用读写锁?”我把几个常用方案放在一起对比:

方案并发度一致性保障适用场景缺点
JVM 锁(synchronized)单机内互斥单机强一致单实例应用无法跨进程
SETNX 分布式锁完全互斥强一致写多读少的临界区读流量也被串行化
Redis 读写锁读读共享、读写互斥写期间一致读多写少热点数据实现复杂,需成熟客户端
数据库乐观锁不阻塞读最后提交生效写冲突概率极低不适合高并发写

你会发现,读写锁适合的是一个特定的区间:有一定并发读量、写频率不高、要求数据一致性。 如果读请求没多到成为压力,用 SETNX 或直接查库都行;如果写请求很多,读写锁的互斥开销会让读体验变得糟糕。选型时我们经常讲的“性能优化”,不是单纯地调一个参数或者换一个中间件,而是找到当前系统的瓶颈在哪,再用最小成本去解决它。

3. 实战:Spring Boot 集成 Redisson 实现读写锁

3.1 环境准备与依赖配置

本地跑这个示例,我建议直接用 Docker 起一个 Redis 服务,方便快速验证。如果你要模拟生产环境,还可以用 Docker 搭 Redis 主从,读写锁在高可用下面临的一些问题我会在后面的常见问题部分展开。

docker run -d --name redis-rw -p 6379:6379 redis:7-alpine

依赖方面,假设你用的是 Spring Boot 项目,只需要引入 Redisson 的 Spring Boot Starter:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>

然后在application.yml里配置连接信息:

spring: data: redis: host: 127.0.0.1 port: 6379

Redisson 的 Starter 会自动读取 Spring Boot 的 Redis 配置并创建 RedissonClient,直接注入就能用。如果你用的是非 Spring 项目,也可以手动构建 Config 对象,这一步很简单,就不展开了。

3.2 核心代码实现:读写锁保护“读缓存-写库-更新缓存”流程

下面我用一个商品详情的例子来演示。这个场景足够经典:商品信息读多写少,缓存查询频繁,管理员更新商品的次数很少,但每次更新必须让缓存尽快和数据库同步。

首先要定义一个服务类,注入 RedissonClient:

@Service public class ProductService { @Resource private RedissonClient redissonClient; @Resource private ProductMapper productMapper; @Resource private RedisTemplate<String, Object> redisTemplate; private static final String CACHE_KEY_PREFIX = "product:detail:"; private static final String LOCK_KEY_PREFIX = "rwlock:product:"; public Product getProductById(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; // 1. 先查缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (Product) cached; } // 2. 缓存未命中,获取读锁,防止回填时被写操作覆盖 RLock readLock = redissonClient.getReadWriteLock(LOCK_KEY_PREFIX + id).readLock(); readLock.lock(); try { // 双重检查:获取到读锁后可能其他线程已经回填了缓存 cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (Product) cached; } Product product = productMapper.selectById(id); if (product != null) { redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES); } return product; } finally { readLock.unlock(); } } public void updateProduct(Product product) { // 写锁:同一时刻只能有一个线程更新同一个商品 RLock writeLock = redissonClient.getReadWriteLock(LOCK_KEY_PREFIX + product.getId()).writeLock(); writeLock.lock(); try { // 1. 更新数据库 productMapper.updateById(product); // 2. 更新缓存 redisTemplate.opsForValue().set(CACHE_KEY_PREFIX + product.getId(), product, 30, TimeUnit.MINUTES); } finally { writeLock.unlock(); } } }

这个代码有几个地方值得展开说说。第一,读锁并不是在请求入口就无脑加,而是只在“缓存未命中需要回填”时才加,这样绝大多数命中缓存的读请求根本不会碰锁,性能损耗降到最低。第二,回填之前又查了一次缓存,这叫双重检查,防止多个线程同时回填。第三,写锁保护的路径非常明确:先更新数据库,再更新缓存。这个顺序不能颠倒,因为如果先更新缓存而后数据库更新失败,缓存里就会出现不存在的商品数据。

代码里使用的 Redisson 读写锁是可重入的,所以同一个线程在持锁期间再次获取同样的锁不会死锁。但这里有个很容易踩的坑:读锁和写锁之间的重入互斥。 假如你在一个事务方法里先获取了读锁,然后调用了一个写操作,这个写操作又尝试获取写锁,Redisson 会直接抛异常或者进入等待,因为同一个线程不能在持有读锁的情况下再申请写锁。这种场景在代码组织不清晰的项目里非常常见,我后面会专门讲。

3.3 锁超时、自动续期与释放顺序

刚接触 Redisson 的读者可能不太清楚锁的超时策略。默认情况下,Redisson 的lock()方法不带参数,会使用 30 秒作为锁的默认租约,同时启动看门狗线程每 10 秒检查一次,如果业务还没结束就自动把锁续到 30 秒。这是我最推荐的使用方式,因为不需要自己估计业务执行时间。

如果你调用lock(10, TimeUnit.SECONDS),那就相当于告诉 Redisson:这个锁最多持有 10 秒,10 秒后自动释放,不要续期。 这种方式适合那些你能明确估算耗时的操作,但万一业务执行到一半 Redis 里的锁没了,其他线程就会进来,造成并发问题。所以没有十足把握时,我宁可使用默认方式。

锁的释放顺序同样重要。一定要在finally块里释放锁,但要注意释放的必须是同一个锁对象。很多新手会犯一个错误:加锁时用的是readLock(),释放时不小心调用了另一个锁对象的unlock(),导致IllegalMonitorStateException。 本质上就是因为 Redis 侧的锁持有者线程标识对不上,Redisson 会拒绝释放不属于自己的锁。

3.4 缓存更新策略:Cache Aside 与 读写锁的配合

读多写少场景下,现在比较通用的缓存更新模式叫 Cache Aside,也就是先更新数据库,再删除或更新缓存。在这个模式里,读写锁真正要保护的是“缓存回填”这个步骤。

为什么更推荐“更新缓存”而不是“删除缓存”?因为删除缓存后,下一次读请求必须走数据库,读多场景下你就要承担一次缓存击穿的压力。虽然我们加了读锁,但如果并发特别高,读锁排队也会拖慢响应。我的实践是:写操作更新数据库后,直接把新值写入缓存;读操作未命中时,也把数据库读到的值回填缓存。加上读写锁保护之后,缓存回填和写缓存这两个动作不会互相覆盖,一致性就得到保障了。

读多写少性能优化的链条到这里基本就闭环了:读请求大量命中缓存,偶尔未命中时用读锁防止回填乱序;写请求较少,但一旦发生就通过写锁独占整个更新流程,保证缓存和数据库一致。从 Redis 的层面看,锁 key 的生命周期非常短,读锁在绝大多数情况下只是短暂地做一次“读计数器加一”操作,对性能影响很小。

4. 常见问题与性能排查实战

4.1 锁失效、死锁与释放异常

Redis 读写锁最常见的死锁场景之一是同一线程内锁的互相等待。比如 getProductById 里,先加了读锁,后面因为某些逻辑需要重新加载配置,而配置加载又需要获取同一个 key 的写锁,这时候 Redisson 的默认策略会阻塞等待,直到读锁超时。 因为读锁未释放,写锁永远获取不到,而业务线程又在等写锁,整个线程就卡死了。

解决思路很简单:尽量缩小锁的粒度,把读锁和写锁放在不同方法边界;如果确实需要“先读后写”,可以先释放读锁,再获取写锁,但要注意释放到重新加锁之间可能有其他线程插入写操作,因此要做二次校验。生产上遇到死锁,最好的排查工具是jstack,先看线程栈是不是卡在 Redisson 的锁等待方法上,再去看 Redis 里对应的锁 key 是否长时间存在。

另一个高频问题是锁提前过期。手动指定 leaseTime 时,如果业务执行时间超过了锁的租约时间,锁会被 Redis 自动删除,其他线程就能立即获取同一把锁。 这种情况下原本受保护的临界区会同时进入多个线程,缓存和数据库的一致性就会出问题。我的建议是线上尽量不要手动传 leaseTime,让看门狗去续期。

如果你想自己确认锁是否释放,可以用redis-cli查看锁 key:

redis-cli hgetall rwlock:product:1001

正常情况下,锁释放后这个 key 应该不存在。如果 key 还在且 field 里的线程 ID 一直不变,说明持有锁的线程可能已经异常退出,正在等待锁超时强制释放。这时候用 Redis Desktop Manager 或另一个 Redis Desktop Manager 那一类可视化工具找起来更方便,直接看 key 的存活时间就能判断。

4.2 性能压测与关键指标:别只看平均耗时

读写锁引入后,性能到底有没有提升,不能靠感觉,得压测。我常用 Apache Bench 或 JMeter 做对比测试:同一台机器、同一个接口,分别测试“无锁”“互斥分布式锁”“读写锁”三组,每个组至少跑 3 分钟,观察吞吐量、P99 耗时和错误率。

这里强调一下:平均耗时会掩盖长尾问题,你必须重点观察 P99 甚至 P999。 在缓存命中的读请求上,读写锁几乎不增加耗时,但缓存未命中回填时,读锁会和其他回填线程竞争,P99 可能略有上升。只要 P99 涨幅在可接受范围内,而吞吐量明显提高,这个方案就是值得的。我见过一个案例,用普通分布式锁后平均耗时只增加了 2 毫秒,但压测线程数一提高,P99 直接翻了 5 倍,就是因为锁等待把线程池打满了。读写锁在这种场景下收益会明显更大。

除了锁本身的耗时,Redis 侧也要关注。读写锁在 Redis 中执行的是 Lua 脚本,每次加锁解锁会有一个或多个命令往返。如果 Redis 实例的 CPU 已经很高,加锁脚本的耗时也会上升。这时候用redis-cli --latency看 Redis 响应延迟,用SLOWLOG看是否有慢查询,能快速定位瓶颈到底在 Redis 还是应用侧。

4.3 和 Redis 主从架构、分布式锁的关系

很多团队为了高可用,会用 Docker 部署 Redis 主从架构,读写锁自然也会跟着部署到这套环境里。但这里有一个必须知道的坑:Redisson 的读写锁默认写入的是主节点,如果主节点故障,锁数据还没来得及同步到从节点,哨兵或集群就会把从节点提升为主节点,这时候原来的锁就“丢失”了。 也就是说,在高可用切换的瞬间,可能出现两个客户端同时持有同一把写锁的情况。

严格意义上,这属于分布式锁在异步复制架构下的固有问题。如果你的业务对一致性要求极高,可以引入 RedLock 思路,但 RedLock 本身在业界也有争议,它需要部署奇数个独立 Redis 节点并逐个加锁,成本高且系统复杂度成倍上升。大多数读多写少业务其实不需要这么强的保证,因为写操作非常少,主从切换造成的锁丢失概率极低。

我的个人建议是:读写锁更适合用在“允许极短暂不一致”的场景,比如商品信息缓存、配置数据缓存;若涉及资金、订单状态这类绝对不能出错的资源,不要仅仅依靠 Redis 锁,还需要数据库乐观锁或事务机制兜底。

4.4 常见问题速查表

问题现象原因排查方式
锁异常释放多个线程同时进入临界区手动指定 leaseTime 且业务超时检查代码是否传了锁过期时间,优先使用看门狗
死锁线程卡住,接口超时同一线程先读锁后写锁jstack 查看线程栈,雷迪锁 key
锁未释放Redis 中锁 key 长期存在未在 finally 中解锁,或持有线程异常查看锁 key 的 TTL,代码加 try/finally
性能下降吞吐量不升反降锁粒度太大,读请求全部排队调整锁范围,只在回填缓存时加锁
主从切换后丢锁短暂出现两个写线程异步复制导致锁数据丢失评估一致性强要求,增加数据库兜底

5. 进阶实践:基于 Lua 自定义读写锁与缓存治理

5.1 什么情况下值得自己动手写 Lua 脚本

Redisson 虽然好用,但总有一些场景需要自己控制锁的逻辑。比如你想在锁里额外存业务标识,想在获取锁时顺带做一次数据检查,又或者想实现“读优先”或“写优先”的调度策略。 这些需求都超出了 Redisson 默认 API 的范围,这时候自己写 Lua 脚本反而更灵活。

自定义读写锁的基础数据结构,我推荐还是用 Hash。设计如下:

  • key:rwlock:{业务key}
  • fieldwriteLock:写锁持有者线程标识,为空表示没有写锁
  • fieldreadCount:读锁持有数量

因为 Redis 的所有 Lua 脚本执行是原子性的,多个命令不会被其他客户端打断,这就保证了锁逻辑的可靠性。

5.2 一个可运行的 Lua 读写锁示例

获取读锁的核心逻辑是:如果写锁字段不存在,就把读锁计数加一。用 Lua 写大概是这样的:

-- KEYS[1] 锁key -- ARGV[1] 客户端标识 if redis.call('hexists', KEYS[1], 'writeLock') == 1 then return 0 end return redis.call('hincrby', KEYS[1], 'readCount', 1)

释放读锁则是:

local count = redis.call('hget', KEYS[1], 'readCount') if not count then return 0 end if tonumber(count) <= 1 then redis.call('hdel', KEYS[1], 'readCount') return 1 end return redis.call('hincrby', KEYS[1], 'readCount', -1)

获取写锁的逻辑稍微复杂一点:写锁字段不能存在,且读锁计数必须为 0,满足条件后才能设置 writeLock 字段。

if redis.call('hexists', KEYS[1], 'writeLock') == 1 then return 0 end if redis.call('hget', KEYS[1], 'readCount') and tonumber(redis.call('hget', KEYS[1], 'readCount')) > 0 then return 0 end redis.call('hset', KEYS[1], 'writeLock', ARGV[1]) return 1

这段简单的脚本已经能覆盖读写锁最核心的语义,但它不支持可重入,也没有自动续期。 如果你要用于生产,还需要在写锁字段里记录持有线程 ID 和过期时间,并在锁快要过期时做续期。这也是我为什么建议大家优先用 Redisson 的原因:自己实现到这一步很容易,但如果把看门狗、公平排队、故障恢复都加进去,工作量会翻好几倍。自研适合学习原理和满足特殊需求,不适合从零重复造轮子。

5.3 多级缓存与读写锁结合,进一步压榨读性能

读写锁解决的是“缓存与数据库一致性”的问题,但读多写少性能优化的天花板,往往取决于缓存层数。现在很多高并发系统都会做多级缓存:本地缓存(如 Caffeine)+ Redis 分布式缓存 + 数据库。

本地缓存有天然的本机读写优势,缺点是多实例之间数据不一致。配合 Redis 读写锁后,可以这样做:读请求优先查本地缓存,未命中再走 Redis 读锁去回填;写请求先更新数据库,再获取写锁,更新 Redis 缓存后,主动通知各实例清空本地缓存。 因为写请求量少,清空本地缓存的代价很低,而读请求绝大多数会被本地缓存命中,连 Redis 都很少访问,性能提升非常明显。

我遇到过的最极端案例,是一个排行榜接口,团队把所有数据都塞进了 Redis,每次请求都查 Redis,导致 Redis 的单实例 CPU 被打满。后来改成 Caffeine 本地缓存 + Redis 读写锁治理更新,Redis 的 QPS 从几十万降到了几千,接口 P99 反而从 80 毫秒降到 5 毫秒。 这说明一个思路:读写锁的价值不只是“加锁”,而是通过锁把复杂的并发更新收敛起来,给上层腾出足够空间去做缓存优化。

分享一点实战体会

读写锁这个方案,在我参与过的数个后端项目里都有用到,但我真正想提醒你的是:不要为了用读写锁而用读写锁。读多写少场景下,如果读请求本身没有并发写的问题,甚至不需要加锁;只有当“缓存回填”和“写缓存”可能相互覆盖时,读写锁才是最合适的那把钥匙。我自己在项目里踩过最大的坑,就是一开始把所有读请求都加了读锁,结果 Redis 的锁请求量甚至超过了业务请求量,白白浪费了性能。后来把锁的范围缩小到“缓存未命中回填”这一步,效果立竿见影。最后再分享一个小技巧:加锁前先想清楚锁的粒度、锁的持有时间、锁的释放路径,这三件事想明白了,任何读写锁方案基本都不会出大问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询