不能直接用 set 实现信号量,因其无法原子性地完成“检查剩余许可数+减1+设置过期”三步操作;必须用 lua 脚本将 incr/decr 与 expire 绑定以保证一致性。

为什么不能直接用 redis.Set 实现信号量
直接用 SET key value NX EX ttl 模拟“抢锁”看似可行,但信号量的核心是计数 + 原子增减 + 超时自动释放,而单纯 SET 无法安全实现「获取一个许可」的原子判断(比如:当前剩余 2 个许可,两个协程同时读到 2,都执行 -1,结果变成 0,实际应只允许一个成功)。Redis 的单线程特性可帮我们靠 Lua 脚本保证原子性,但必须把“检查 + 修改 + 设置过期”打包进一个脚本里。
EVAL 脚本实现 acquire/release 的原子操作
Go 客户端(如 github.com/go-redis/redis/v9)调用 Eval 执行 Lua 是最稳妥的方式。关键逻辑:acquire 尝试对计数器 INCR,若结果 ≤ 总容量则成功;release 则 DECR 并确保不跌破 0。注意:计数器本身不带过期,所以要用 EXPIRE 单独设 TTL,且必须在 acquire 成功后立刻执行——否则可能因网络中断导致 key 永久存在。
示例 acquire 脚本:
if redis.call("INCR", KEYS[1]) == tonumber(ARGV[1]) then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if tonumber(redis.call("GET", KEYS[1])) > tonumber(ARGV[1]) then
redis.call("DECR", KEYS[1])
return 0
end
return 1
其中 KEYS[1] 是信号量 key,ARGV[1] 是最大许可数,ARGV[2] 是租约 TTL(秒)。返回 1 表示获取成功,0 表示拒绝。
Go 中封装成可重入、带超时的结构体
不要每次手动写 Eval 调用。建议封装为 RedisSemaphore 结构体,字段包含:client *redis.Client、key string、limit int64、lease time.Duration。重点处理三件事:
- acquire 方法需支持 context.Context 超时,内部用
client.Eval+context.WithTimeout配合重试(失败后 sleep 再试,避免忙等) - release 方法必须用相同 Lua 脚本确保幂等:先 GET 当前值,仅当 >0 才 DECR,防止误释放
- 所有 key 命名需带业务前缀和唯一标识(如
"sem:order:create:2024"),避免不同服务共用同一 key 导致干扰
容易被忽略的边界问题
分布式信号量不是本地 mutex,很多陷阱藏在细节里:
-
lease时间必须显著大于业务最长处理时间(建议 ≥3×),否则业务还没做完 key 就过期,其他节点可能重复获取,造成超发 - acquire 成功后,客户端崩溃或网络断开,无人调用 release —— 这时只能依赖 key 自动过期回收,所以
lease不是可选,是必设 - Redis 主从异步复制下,failover 可能导致短暂重复许可(如主挂了,从升主,旧主上的 key 未同步就恢复),高一致性场景需配合 Redis Redlock 或降级为单点 Redis
- 不要用
GETSET或SETEX替代INCR/DECR,它们无法表达“仅当 ≤ limit 时才接受”的语义
真正难的不是写对那几行 Lua,而是想清楚 lease 该设多久、失败后重试间隔怎么定、以及监控是否出现大量超时 acquire —— 这些才是线上稳定运行的关键。











