当前位置:首页 > 问答 > 正文

用Redis怎么搞分布式乐观锁,感觉挺有意思也不难理解

说到用Redis搞分布式乐观锁,这个想法确实挺有意思,而且它的核心思想一点也不复杂,咱们可以把它想象成一个大家都能看到的公共“小黑板”,这个锁的精髓不在于“霸占”,而在于“验证”。

核心思想:版本号就是一切

乐观锁,顾名思义,它很“乐观”,它假设大部分情况下,不会有好几个人同时去修改同一个东西,它不会像悲观锁那样,一上来就把资源锁死,不让别人碰,相反,它允许任何人都可以来读取数据,甚至在你修改的时候,别人也能读取和修改他们自己的副本。

那怎么保证不出错呢?关键就在于一个“版本号”,你可以把这个版本号理解成贴在数据上的一个“标签”,每次数据被成功修改后,这个标签就换一个新的、更大的号码。

这个过程,完全可以用Redis轻松实现,因为Redis的SETNX命令和Lua脚本简直就是为这个场景量身定做的,下面我们一步步拆开来看。

第一步:先读数据和版本号

假设我们有一个商品库存,键叫 item:1001,它的库存数量是 100,我们怎么知道这个100是不是最新的呢?我们给它配一个版本号键,item:1001:version,初始值是 1

当你想减库存时,比如买一件商品,你首先需要把当前的库存值(100)和当前的版本号(1)一起读出来,这一步很简单,就是用Redis的 GET 命令分别获取这两个键的值。

第二步:本地计算,准备修改

你在你的程序里进行业务计算,新库存 = 100 - 1 = 99,这个计算是在你的应用服务器上完成的,Redis并不知道。

第三步:提交修改,关键一步——检查版本

这是最核心的一步,你现在要告诉Redis:“请把 item:1001 的值改成99,前提是它的版本号必须还是我之前读到的那个1,如果版本号变了,说明有别人在我之前改过了,那你就别执行这个操作。”

在Redis里,我们怎么实现这个“前提”呢?有几种方法,最常用、最可靠的是用 Lua脚本

一个简单的Lua脚本是这样的:

local currentVersion = redis.call('GET', KEYS[2])
if currentVersion == ARGV[1] then
    -- 版本号对得上,说明在我读取之后没人修改过
    redis.call('SET', KEYS[1], ARGV[2]) -- 设置新库存值
    redis.call('INCR', KEYS[2]) -- 将版本号加1
    return true -- 返回成功
else
    return false -- 返回失败,版本号不一致
end

这个脚本的执行是原子性的,也就是说,Redis保证在执行这个脚本的时候,不会被其他命令打断,这是实现乐观锁安全性的关键。

我们来模拟一下两种场景:

  • 场景A(没有冲突): 你执行脚本,传入旧的版本号 1,Redis一检查,发现当前的版本号确实是 1,于是它放心地把库存改为 99,并把版本号增加为 2,返回成功,你的事务完成了。
  • 场景B(发生冲突): 在你读取版本号 1 之后,准备提交之前,另一个人手更快,他已经成功地修改了库存,并把版本号变成了 2,这时你再执行脚本,传入旧的版本号 1,Redis一检查,发现当前版本号是 2,和你的 1 对不上,于是它拒绝执行修改,返回失败,你的事务就失败了。

第四步:根据结果处理

如果第三步返回成功,恭喜你,操作顺利完成,如果返回失败,就意味着发生了“写冲突”,这时候你该怎么办?乐观锁的标准处理方式是:重试

你可以选择重新开始整个流程:再次读取最新的库存和版本号,在本地重新计算新库存(因为此时的库存可能已经不是99了,比如被别人买到了98),然后再次执行那个Lua脚本,这个过程可以重复几次,直到成功或者超过重试次数后报错给用户。

除了Lua脚本,还有别的招吗?

有,但没那么完美,比如可以用 WATCH / MULTI / EXEC 组合。

  1. WATCH key:你可以把它理解为“盯住”版本号这个键。
  2. 读取数据和版本号。
  3. 开启一个事务(MULTI)。
  4. 在事务里发送你的修改命令(SET 库存,INCR 版本号)。
  5. 尝试提交事务(EXEC)。

Redis会在执行 EXEC 时检查,如果从 WATCH 开始到 EXEC 的这段时间里,你“盯住”的那个版本号键被其他客户端修改过了,那么整个事务就会失败,返回 nil

这个方法也能实现乐观锁,但它有个小缺点:如果冲突很频繁,会导致很多事务失败,浪费资源,而Lua脚本是在服务端一次性完成检查和写入,更高效一些,所以现在大家更倾向于直接用Lua脚本。

总结一下Redis分布式乐观锁的优缺点

  • 优点:

    • 简单直观: 核心逻辑就是比版本号,很容易理解。
    • 高性能: 在冲突不激烈的场景下,避免了传统锁的等待开销,并发性很好。
    • 避免死锁: 因为它根本不会长期占有锁,都是短平快的操作,所以没有死锁问题。
  • 缺点:

    • 冲突处理: 一旦冲突发生,业务层需要有“重试”机制,这增加了程序的复杂性。
    • 不保证绝对公平: 可能会出现某个请求一直重试但总被别的请求抢先的情况(饥饿)。
    • 适用场景有限: 非常适合像“秒杀扣库存”、“更新用户积分”这种冲突不那么剧烈的场景,但如果某个数据被超高并发地修改(比如热点商品),重试会非常频繁,效率反而会下降,这时候可能就需要其他方案了(比如悲观锁、队列等)。

用Redis搞分布式乐观锁,就像是在一个开放的操场上玩游戏,大家遵守同一个规则:修改前先看一眼版本号,如果发现世界已经变了,那就坦然接受失败,然后从头再来一次,这种轻量级、非阻塞的思想,正是它在很多分布式系统中广受欢迎的原因。

用Redis怎么搞分布式乐观锁,感觉挺有意思也不难理解