ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis读写锁原理与实战:读多写少场景下的缓存一致性方案

Redis读写锁原理与实战:读多写少场景下的缓存一致性方案 读多写少这四个字听起来像是一个特别普通的业务特征但如果你真正在一个高并发服务里维护过商品详情、配置项、排行榜这类热点数据就会明白“读多写少”其实是最容易翻车的场景之一。热点 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 秒。 这个设计主要是防止业务代码异常退出后锁被永久持有。但使用读写锁时要注意看门狗续期只针对“显式设置了锁过期时间”的反向情况如果你自己传了 leaseTimeRedisson 就不会自动续期。实际开发中我建议优先使用默认方式让看门狗来兜底防止业务处理时间超过锁超时时间导致锁提前释放。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 Starterdependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency然后在application.yml里配置连接信息spring: data: redis: host: 127.0.0.1 port: 6379Redisson 的 Starter 会自动读取 Spring Boot 的 Redis 配置并创建 RedissonClient直接注入就能用。如果你用的是非 Spring 项目也可以手动构建 Config 对象这一步很简单就不展开了。3.2 核心代码实现读写锁保护“读缓存-写库-更新缓存”流程下面我用一个商品详情的例子来演示。这个场景足够经典商品信息读多写少缓存查询频繁管理员更新商品的次数很少但每次更新必须让缓存尽快和数据库同步。首先要定义一个服务类注入 RedissonClientService public class ProductService { Resource private RedissonClient redissonClient; Resource private ProductMapper productMapper; Resource private RedisTemplateString, 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查看锁 keyredis-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。设计如下keyrwlock:{业务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 的锁请求量甚至超过了业务请求量白白浪费了性能。后来把锁的范围缩小到“缓存未命中回填”这一步效果立竿见影。最后再分享一个小技巧加锁前先想清楚锁的粒度、锁的持有时间、锁的释放路径这三件事想明白了任何读写锁方案基本都不会出大问题。
返回列表