INCR/DECR 无法安全实现信号量,因其仅支持原子增减,不支持“检查计数是否足够再执行减操作”的复合逻辑,易导致超卖或负值。

为什么直接用 INCR/DECR 无法安全实现信号量
Redis 的 INCR 和 DECR 虽然原子,但信号量需要「先检查再操作」的复合逻辑:比如「当前计数 GET + INCR),中间可能被其他客户端抢占,导致超发。Lua 脚本在 Redis 单线程中执行,整个逻辑能真正原子化。
Lua 脚本必须包含获取与更新的完整判断逻辑
一个可用的分布式信号量脚本需同时完成:读当前值、比较是否可获取、决定是否递增、返回结果。不能只做 INCR 后让客户端自己判断——那样原子性就断了。
典型脚本结构如下(以最大并发数 5 为例):
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current <p>调用时传入:<code>KEYS[1]</code> 是信号量 key(如 <code>"sem:api_limit"</code>),<code>ARGV[1]</code> 是最大值(如 <code>"5"</code>)。返回 <code>1</code> 表示成功获取,<code>0</code> 表示已被占满。</p>
- 必须用
tonumber()转换,否则字符串比较会出错(如"10" 成立) - 初始化 key 不要依赖
SETNX预设——脚本里用or 0更健壮 - 不要在脚本里写过期逻辑(如
EXPIRE),应由业务层在获取成功后单独设置 TTL,避免锁被意外续期
释放信号量必须用 DECR,且需防止负值
释放就是简单递减,但要注意:如果客户端误释放多次,或 key 本身不存在,DECR 会让值变成负数,破坏信号量语义。所以释放脚本应带防护:
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current > 0 then
redis.call('DECR', KEYS[1])
end
return current > 0 and 1 or 0
这个脚本只在值大于 0 时才执行 DECR,并返回是否真实释放成功(用于日志或告警)。
- 不检查 key 是否存在——
GET返回nil时or 0保证逻辑安全 - 不返回新值,避免客户端根据返回值做二次判断(又引入竞态)
- 业务代码里必须确保「获取成功才调用释放」,且释放操作本身也应有超时重试保护
实际使用时最容易忽略的三个点
很多人把脚本贴进去就以为万事大吉,但线上出问题往往卡在这几个细节:
-
EVAL命令没做 SHA 缓存——高频调用应先SCRIPT LOAD得到 SHA,再用EVALSHA,减少网络和解析开销 - 没处理 Lua 脚本超时(
BUSY错误)——Redis 默认脚本超时 5 秒,复杂逻辑需调大lua-time-limit,但更要优先优化脚本本身(禁止循环、禁用随机等) - 信号量 key 缺少命名空间隔离——不同服务共用同一个 key 名(如都用
"sem:limit")会导致互相干扰,务必带上服务名或接口路径前缀,如"sem:payment:create_order"
信号量不是万能锁,它只控制“数量”,不保证顺序;如果业务要求 FIFO 或者持有时间可控,就得叠加租约机制(比如用 hash 存 client_id + timestamp),那已经是另一个层级的设计了。











