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乐观锁不是万能的,它最适合冲突概率低但绝对不能丢更新的场景,正确评估冲突率是方案选型的关键。