如何在Redis缓存中实现CAS乐观锁更新?

来源:网络学院作者:北京GEO公司头衔:草根站长
导读:本期聚焦于北京GEO公司创作的《如何在Redis缓存中实现CAS乐观锁更新?》,敬请观看详情。CAS乐观锁的核心是比较并交换,Redis中通常借助WATCH命令或Lua脚本实现。当多个客户端并发更新同一缓存键时,直接SET覆盖容易造成丢失更新。本文从Redis单线程执行模型出发,解析WATCH事务在检测键版本变化时的机制,并演示基于版本号字段与Lua脚本的原子CAS更新流程。通过对比WATCH事务、Lua版本号CAS以及悲观锁方案的性能与适用场景,帮助读者理解如何避免ABA问题,并在秒杀库存扣减、用户资料写入等场景中做出合适的并发控制选择。文章还讨论了重试策略与Redis集群下的注意事项。

Redis缓存在高并发场景下经常需要保证数据一致性,多个客户端同时读取并更新同一个键时,简单的GET后SET会产生丢失更新。例如两个请求同时读取库存为100,各自减1后写回99,但实际应该变成98。CAS乐观锁通过比较当前值与预期值是否一致来决定是否允许更新,可以在不加锁的情况下解决这类问题。Redis本身提供了WATCH事务和Lua脚本两条路径来实现CAS语义,下面从原理到实践分别展开。

Redis事务与WATCH命令的CAS基础

Redis事务通过MULTI、EXEC命令批量执行操作,但事务本身不会回滚,也没有条件判断能力。WATCH命令弥补了这一点,它可以在EXEC执行前监控一个或多个键,如果被监控的键在WATCH之后发生过修改,整个事务会被放弃,EXEC返回空回复。这就是Redis内置的乐观锁机制:先监控键,读取值,计算新值,再在事务中写入,只要键没有被其他客户端改过,写入就能成功。

下面的Python代码展示了使用WATCH实现计数器自增的典型流程。客户端不断尝试,一旦遇到WatchError就重试,从而保证并发安全。

import redis

r = redis.Redis(host='127.0.0.1', port=6379, db=0)

def increment_counter(key):
    with r.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key)
                current = int(pipe.get(key) or 0)
                next_value = current + 1
                pipe.multi()
                pipe.set(key, next_value)
                pipe.execute()
                return next_value
            except redis.WatchError:
                continue

WATCH的乐观锁本质是检测键是否被修改过,并不记录修改的具体内容。如果键从A改到B再改回A,即发生ABA问题,WATCH无法感知,因为它的监控粒度是键的改动标记,而非值的版本号。对于大多数读多写少的缓存更新场景,这种机制已经足够,但在需要严格校验值版本时,WATCH就显得不够精细。

另一个局限是WATCH事务需要客户端与Redis之间多次往返,通常包括WATCH、GET、MULTI、SET、EXEC五步,在高并发下会产生较多的网络开销。此外,如果客户端在WATCH之后、EXEC之前断开连接,监控会自动解除,不会影响其他客户端,但当前客户端的更新也就失败了。尽管如此,WATCH仍是Redis原生支持的最简单CAS实现方式,适合逻辑简单、冲突率不高的场景。

使用Lua脚本实现带版本号的CAS更新

为了增强CAS的判定能力,可以在缓存值中显式维护一个版本号字段。更新时先比较当前版本号与客户端持有的预期版本号,只有相等时才写入新值并递增版本号。由于Redis单线程执行命令,Lua脚本可以让比较和更新作为整体原子运行,避免窗口期被其他命令插入。这种方式比WATCH更加精确,也能有效防御ABA问题。

推荐使用Redis的Hash结构存储业务值和版本号,例如键为user:1001,字段value保存实际数据,字段version保存递增的整数版本。更新时通过一段Lua脚本完成版本比较、值替换和版本递增,全部操作在服务端原子执行。

local key = KEYS[1]
local newValue = ARGV[1]
local expectedVersion = tonumber(ARGV[2])

local currentVersion = tonumber(redis.call('HGET', key, 'version') or -1)
if currentVersion == expectedVersion then
    redis.call('HSET', key, 'value', newValue)
    redis.call('HINCRBY', key, 'version', 1)
    return 1
else
    return 0
end

客户端调用EVAL命令传入键、新值和预期版本号。脚本返回1表示更新成功,返回0表示版本不匹配,客户端可以选择丢弃结果或稍后重试。如果使用Java或Go客户端,可以封装一个专门的updateWithVersion方法,将EVALSHA缓存脚本SHA值以减少网络传输。

版本号可以采用从0开始的整数,每次成功更新后递增1。如果业务需要更复杂的历史追踪,也可以使用时间戳或UUID,但整数递增的开销最小且比较直观。需要注意的是,Lua脚本内部使用redis.call执行命令,如果涉及多个键,所有键必须位于Redis Cluster的同一个哈希槽中,否则脚本会执行失败。生产环境通常通过哈希标签将相关键映射到同一节点,例如user:{1001}:data和user:{1001}:version。

方案对比与生产实践要点

WATCH事务适合简单并发控制,优势是无需引入额外数据结构,直接操作普通字符串键即可。缺点是网络往返多、无法精确比较版本值、可能受ABA问题影响。基于Lua脚本的版本号CAS则弥补了这些不足,它把比较和更新压缩为一次原子执行,版本号使判定更加严格,但需要业务配合维护版本字段,且Lua脚本的复杂度略高。通常在高冲突、强一致的场景下优先选择Lua版本号CAS。

悲观锁方案使用SETNX获取分布式锁,再执行读取、计算、写入、释放锁。虽然逻辑直观,但存在锁超时、误删他人锁、死锁等问题,且同一时刻只有一个客户端能操作,吞吐量较低。乐观锁不阻塞其他客户端,仅让冲突的更新失败重试,在读多写少的缓存更新场景中优势明显。

在生产环境使用CAS乐观锁时,重试策略需要精心设计。如果冲突频繁,立即重试会加剧竞争,可以使用随机退避或指数退避算法。例如第一次失败后等待10毫秒,第二次等待20毫秒,最大等待200毫秒。同时需要设置最大重试次数,避免无限循环消耗客户端资源。对于版本不匹配的失败,业务应该读取最新值重新计算,而不是盲目覆盖。

Redis集群环境下,Lua脚本必须保证所有操作的键位于同一节点。如果版本号和业务值分散在不同槽位,脚本会报错。使用哈希标签可以强制键路由到同一槽位,但这可能影响数据分布均衡。另外,脚本应尽量简短,避免长时间占用Redis单线程,否则会阻塞其他命令。对于大值写入,可以在脚本外完成序列化,只把最终字符串传入Lua脚本。

最后需要监控CAS重试率指标,如果重试率持续偏高,说明冲突严重,可能需要从业务逻辑上减少写竞争,例如使用本地缓存合并更新,或者改为消息队列串行处理。CAS乐观锁不是万能的,它最适合冲突概率低但绝对不能丢更新的场景,正确评估冲突率是方案选型的关键。

RedisCAS乐观锁缓存更新修改时间:2026-08-30 00:19:24

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。