Redis分布式锁怎么实现一次放2请求

文章导读
「一次放 2 个请求」不是把分布式锁改成「2 把锁」:用 SET key token NX PX 做出来的互斥锁,同一时刻只能放过 1 个请求,第 2 个请求调用这个命令一定返回 nil。要让 2 个请求同时通过,你需要在一个键上记录「当前有几个请求在运行」,再用一段 Lua 脚本把「查数量 + 占坑」合并成一次原子操作;否则两个并发请求会同时读到「还没满」,结果一起挤进去。下面按排查顺序写清命令
📋 目录
  1. 一 Redis分布式锁:先把能核对的事实摆清楚
  2. 二 Redis分布式锁最容易搞错的一点
  3. 三 Redis分布式锁:一个照着就能做完的做法
  4. 四 Redis分布式锁:怎么验证结果对不对
A A

「一次放 2 个请求」不是把分布式锁改成「2 把锁」:用 SET key token NX PX 做出来的互斥锁,同一时刻只能放过 1 个请求,第 2 个请求调用这个命令一定返回 nil。要让 2 个请求同时通过,你需要在一个键上记录「当前有几个请求在运行」,再用一段 Lua 脚本把「查数量 + 占坑」合并成一次原子操作;否则两个并发请求会同时读到「还没满」,结果一起挤进去。下面按排查顺序写清命令、判断边界和验证方式。

只有「同一时刻最多 N 个」这种需求才需要配额键,互斥锁解决不了。做法是在一个 ZSET 里放 N 个带过期时间的 token,用 Lua 原子的判断并写入,释放时 ZREM。验证看 ZCARD 是否恰好停在 2、ZRANGE WITHSCORES 里分数是否约等于「当前毫秒 + 租约」。租约必须大于业务最长耗时,否则会多放出第 3 个请求;集群模式下所有键必须在同一 slot。

Redis分布式锁:先把能核对的事实摆清楚

先别急着改代码,把当前环境里能直接看到的东西列出来,判断才有落点。

  • 锁命令本身:SET lock:job tok-1 NX PX 30000 返回 OK 表示占位成功,返回 nil 表示已被别人占着。这条命令的语义固定是「一个键只有一个持有者」,不管 value 写成什么。
  • 当前值:GET lock:job 看是谁拿的;PTTL lock:job 看还剩多少毫秒过期。PTTL 返回 -1 说明这个键没有过期时间,进程崩了也不会自动释放,这是需要立刻修的事实。
  • Redis 版本:redis-cli INFO server | grep redis_version。Lua 脚本的行为、ZSET 相关命令的参数,都以这个版本为准,不同版本可能有差异。
  • 真实下发的命令:redis-cli MONITOR 能看到应用实际发了什么,只建议在测试实例上短时间开,生产上长时间挂会拖慢实例。
  • 应用侧日志:如果用 Redisson 这类封装,释放非自己持有的锁时通常会打 attempt to unlock lock, not locked by current thread 一类的 warn,日志文件位置取决于你的日志配置,先确认 logback.xml 或 log4j2.xml 里的输出路径。

前提条件也需要先确认:Redis 是单实例还是集群。集群模式下,一次 Lua 脚本里用到的所有键必须落在同一个 hash slot,否则命令会直接报 CROSSSLOT 错误。这一点在写脚本之前就要定下来。

Redis分布式锁最容易搞错的一点

最常见的误解是「并发数控制 = 加锁」,于是把 SET NX 的锁包在业务外面,以为请求会排队。实际上多个请求会同时去抢同一个键,抢到的那个执行,其余全部立刻失败,实际通过量恒为 1,和你要的 2 没有关系。把锁键复制成两个(lock:job:1、lock:job:2)也不对:两个请求可能都去抢 1 号键,2 号键空着,并发数变成 1 而不是 2。

判断分界是:你要的是「同一时刻只有 1 个」(互斥),还是「同一时刻最多 N 个」(配额)。互斥用 SET NX PX 就够;配额必须有一个能被读写的计数结构。

能把两种情况区分开的检查动作:在测试实例上并发跑 5 个请求,业务里先 SET lock:job $token NX PX 30000,成功的打一行 acquired 日志。跑完数一下日志里 acquired 的条数——如果是 1 条,说明你现在拿到的是互斥锁,需求没被满足。然后按下面的顺序判断:

  1. 看需求措辞:是「不能重复执行」还是「最多 N 个并行」。前者用锁,后者用配额。
  2. 看键的数量:只有一个锁键且没有计数器,一定做不到 N > 1。
  3. 看抢锁和判断之间有没有别的命令:如果先 GET 再 SET,中间存在窗口,并发下会超发。
  4. 看有没有释放逻辑:没有 finally 里释放,或者没有过期时间,键会一直挂在那里。
  5. 看租约时长和业务耗时的关系:租约短于业务耗时,配额会被提前回收。

Redis分布式锁:一个照着就能做完的做法

用 ZSET 存 N 个带过期时间的 token,申请和释放各一段 Lua。成员是 token,分数是「释放时间戳」,过期成员在申请时顺手清掉,等于每个持有者各自有一份独立的租约。

第一步,写申请脚本 acquire.lua:

-- KEYS[1] = 配额键,例如 sem:job:sync
-- ARGV[1] = 上限 2;ARGV[2] = token;ARGV[3] = 当前毫秒时间戳;ARGV[4] = 租约毫秒
local key   = KEYS[1]
local max   = tonumber(ARGV[1])
local token = ARGV[2]
local now   = tonumber(ARGV[3])
local lease = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
if redis.call('ZCARD', key) < max then
  redis.call('ZADD', key, now + lease, token)
  redis.call('PEXPIRE', key, lease)
  return 1
end
return 0

第二步,写释放脚本 release.lua,只删自己那个 token,避免把别人的名额放掉:

-- KEYS[1] = 配额键;ARGV[1] = token
redis.call('ZREM', KEYS[1], ARGV[1])
return redis.call('ZCARD', KEYS[1])

第三步,在业务代码里这样调用(token 用「主机名-请求号」这类唯一值,时间戳由应用取,不要在脚本里用 TIME,集群下主从切换时 TIME 的行为要单独确认)。本地可以先直接用 redis-cli 验证:

Redis分布式锁怎么实现一次放2请求
redis-cli --eval acquire.lua sem:job:sync , 2 tok-A-1 $(date +%s%3N) 30000
# 期望输出:1

redis-cli --eval release.lua sem:job:sync , tok-A-1
# 期望输出:0(释放后已无占用)

注意 --eval 后面键和参数之间那个逗号,前后要留空格,写成 key,2 会被当成一个参数解析错。脚本路径放绝对路径或当前目录都行,执行前建议先 redis-cli SCRIPT LOAD 加 EVALSHA 拆开试一次,确认返回值符合预期再接业务。

这一步该看到什么:第一次申请返回 1,再换一个 token 申请还返回 1(此时 redis-cli ZCARD sem:job:sync 为 2),第三次申请返回 0。释放一次后 ZCARD 回到 1,再申请又能拿到。

容易踩的坑:租约 30000 毫秒,但业务里有慢 SQL 或大批量写,实际跑到了 40 秒。这段时间里分数已经过期被清掉,名额被别的请求占走,同时在跑的请求变成 3 个,而且原请求释放时 ZREM 删的是自己的 token(其实已经没了),不会报错,所以问题很安静。策略是把租约设成明显大于业务最长耗时,或者再压一个续租逻辑;如果业务耗时无法估上界,就别用固定租约的容量控制,改成在入口处排队。另外,申请成功但业务抛异常时,释放必须放在 finally 里,否则名额要等到租约到期才回来。

Redis分布式锁:怎么验证结果对不对

先做并发验证。把申请命令并发跑 5 次,看返回值分布:

for i in 1 2 3 4 5; do
  redis-cli --eval acquire.lua sem:job:sync , 2 tok-"$i" $(date +%s%3N) 30000 &
done
wait

正常结果:恰好两个 1、三个 0,没有出现三个 1。这一条能同时验证上限和原子性。

再看键的内部状态:

redis-cli ZCARD sem:job:sync
redis-cli ZRANGE sem:job:sync 0 -1 WITHSCORES
redis-cli PTTL sem:job:sync

正常结果:ZCARD 等于 2;ZRANGE 里两个 token 的分数都约等于「当前毫秒 + 30000」,比当前时间大;PTTL 返回一个正数,并且不小于 0。如果 PTTL 返回 -1,说明过期没生效,进程崩了名额就回不来;如果 ZRANGE 里出现两个以上的成员,说明申请路径没走 Lua,或者有地方直接 ZADD 绕过了脚本。

最后验证释放和回收。逐个释放 token,每释放一个就跑一次 ZCARD,正常应看到 2 → 1 → 0 的下降;全部释放后 EXISTS sem:job:sync 在租约到期后返回 0。再模拟一次崩溃:申请一个 token 后不释放,等租约时间过去,直接申请,正常应能返回 1,说明 ZREMRANGEBYSCORE 的清理确实起作用。应用侧可以对照日志里「拿到名额 / 被拒 / 释放」三类计数,被拒次数应和成功执行次数的上限对得上。