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

Redis并发问题咋整?Lua脚本来帮忙,实操讲解和思路分享

说到Redis的并发问题,咱们可以想象一个超市里抢购特价鸡蛋的场景,Redis就像一个超级快的收银台,但问题是,很多顾客(也就是客户端)可能同时来结账,都想要最后那几盒鸡蛋,如果管理不好,就会出现超卖,也就是鸡蛋卖多了,实际库存不够。

(来源:常见的并发场景,如秒杀、库存扣减)

最典型的例子就是扣减库存,一个商品库存就剩下1件了,两个用户A和B几乎同时下单,他们的操作逻辑都是一样的:

  1. 从Redis里读取当前库存,stock = 1
  2. 判断库存是否大于0,stock > 0
  3. 如果大于0,则执行扣减,stock = stock - 1

在单线程下这没问题,但在高并发下,可能发生这种情况:

  • 用户A读取库存,stock=1
  • 几乎同时,用户B也读取库存,stock还是1(因为A还没扣减)。
  • 用户A判断库存大于0,执行扣减,库存变为0。
  • 用户B也判断库存大于0,执行扣减,库存变成了-1。

看,这就超卖了,问题就出在“读取”和“写入”这两个操作不是一气呵成的,中间被插队了,Redis虽然是单线程执行命令,保证每个命令是原子的,但一连串命令组成的业务逻辑就不是原子的了。

(来源:Redis官方文档对事务和原子性的说明)

那咋整呢?Redis自己也提供了一些方案。

用WATCH命令(乐观锁) 这有点像网上抢票,你先把商品库存WATCH起来,相当于盯着库存这个数据,然后开始你的操作(读库存、判断、扣减),在最终执行事务(MULTI/EXEC)时,Redis会检查这个库存值在你WATCH之后有没有被别人改过,如果被改过了(比如已经被别人买走了),那你这个事务就执行失败,你需要重试。

  • 缺点:如果并发很高,很多人都会失败,需要不停地重试,有点像一群人不断刷新页面抢票,体验不好,对客户端压力也大,这被称为“乐观锁”的缺点。

用Redis事务(MULTI/EXEC) Redis的事务和数据库的不太一样,它只是把一系列命令打包,确保这一批命令能按顺序执行,中间不会被其他命令打断,它没有回滚功能,更重要的是,事务里的命令是在EXEC时才执行的,所以在MULTI之后,EXEC之前,你无法读取事务内命令的结果,对于我们扣减库存的场景,你没法在事务内部根据读取的库存值做判断,所以单纯用事务解决不了我们的问题。

(来源:Redis官方对MULTI/EXEC和Lua脚本原子性的对比)

Redis并发问题咋整?Lua脚本来帮忙,实操讲解和思路分享

这时候,主角Lua脚本就登场了。

Lua脚本为啥是终极武器?

Redis允许你把一整个Lua脚本作为一个命令发送给服务器,Redis保证了一点:在执行Lua脚本的时候,不会被其他任何命令打断,这个脚本会像一条单行命令一样,原子性地执行。

这就完美解决了我们的问题,我们把“读库存 -> 判断 -> 扣减”这一整套逻辑,写在一个Lua脚本里,这个脚本在Redis服务器上执行的时候,就相当于一个操作,要么全部成功,要么全部失败,中间绝对不会有其他命令插进来捣乱。

实操一下:用Lua脚本扣减库存

Redis并发问题咋整?Lua脚本来帮忙,实操讲解和思路分享

假设我们的库存键叫 item:1001:stock

Lua脚本可以这么写(看不懂语法没关系,看注释):

-- 获取商品库存的key
local stock_key = KEYS[1]
-- 获取要扣减的数量,这里我们默认扣1
local change_amount = tonumber(ARGV[1]) or 1
-- 从Redis读取当前库存值
local current_stock = tonumber(redis.call('GET', stock_key)) or 0
-- 判断库存是否充足
if current_stock >= change_amount then
    -- 库存充足,执行扣减
    redis.call('DECRBY', stock_key, change_amount)
    -- 返回扣减后的库存,或者返回成功标识,比如1
    return 1
else
    -- 库存不足,返回失败标识,比如0
    return 0
end

(来源:基于Redis EVAL命令的常见Lua脚本模式)

我们在程序里(比如用Python)这样调用:

import redis
r = redis.Redis(host='localhost', port=6379)
# 先把脚本加载到Redis服务器,返回一个SHA1校验和,后续用这个校验和执行,避免每次传输脚本内容
script = """
...上面的Lua脚本内容...
"""
sha = r.script_load(script)
# 执行脚本
# 参数说明:脚本的SHA1值,键的数量,键,其他参数
result = r.evalsha(sha, 1, 'item:1001:stock', 1)
if result == 1:
    print("扣减成功!")
else:
    print("库存不足,扣减失败。")

这样一来,无论多少请求并发过来,每个请求的整个检查-扣减逻辑都是在服务器端原子性完成的,第一个脚本执行时把库存从1减到0,后续所有脚本在执行到 current_stock >= change_amount 这一步时,读到的库存已经是0了,都会直接返回失败,彻底杜绝了超卖。

总结一下思路:

  1. 识别风险点:找到你的业务中哪些“读-判断-写”的逻辑在并发下会出问题。
  2. 逻辑封装:把这些逻辑完整地写进一个Lua脚本里。
  3. 原子执行:通过Redis的EVAL或EVALSHA命令执行这个脚本。

Lua脚本是解决Redis复杂原子操作的“银弹”,尤其适合秒杀、抢券、库存扣减、限流等场景,它比乐观锁(WATCH)的效率更高,因为避免了客户端的频繁重试,写Lua脚本的时候要注意别写太复杂的逻辑,别写死循环,不然会阻塞Redis的单线程,影响其他命令,对于解决并发问题,它绝对是个好帮手。