不能直接用SETNX+EXPIRE实现集群锁,因为二者非原子执行:主节点执行SETNX成功后宕机,EXPIRE未执行会导致永久锁;且Redis集群要求单key操作、key需带hash tag(如lock:{order_123})确保同slot,跨slot的Lua脚本会报CROSSSLOT错误。

为什么不能直接用 SETNX + EXPIRE 实现集群锁
因为 Redis 集群模式下,SETNX 和 EXPIRE 是两个独立命令,无法保证原子性。当客户端在主节点执行 SETNX 成功后、还没来得及发 EXPIRE,主节点就宕机或发生故障转移,新主节点上这个 key 就没过期时间——变成永久锁。
更麻烦的是:Redis 集群对 key 做哈希分片,DEL 或 EVAL 操作如果涉及多个 slot(比如用 Lua 脚本同时操作多个 key),会直接报错 CROSSSLOT Keys in a request don't hash to the same slot。所以所有加锁/解锁逻辑必须只操作单个 key,且该 key 的 slot 必须可预测。
- 加锁必须用
SET key value EX seconds NX一条命令完成,value 必须是客户端唯一标识(如 UUID) - 解锁必须用 Lua 脚本,先校验 value 是否匹配再
DEL,否则可能误删其他客户端的锁 - key 名要带 hash tag,比如
lock:{order_123},确保同一业务 key 总落在同一个 slot
go-redis + redsync 在集群模式下的坑
redsync 默认基于单节点 Redis 设计,底层用 GETSET 和 DEL 做锁续期和释放,不兼容 Redis 集群的 slot 约束。直接用它连集群会遇到两种典型错误:
-
redis: nil:因为redsync内部调用GET时 key 被路由到错误节点,返回空 -
CROSSSLOT:它的续期逻辑尝试对锁 key 和内部心跳 key 同时操作,跨 slot 失败
解决办法不是禁用集群,而是绕过 redsync 的自动逻辑,手动构造符合集群约束的锁流程:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 用
redis.NewClusterClient()初始化客户端,配置RouteByLatency或RouteRandomly - 加锁用
client.Set(ctx, "lock:{resource}", uuid, time.Second*30).Err() - 解锁必须封装 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end - 不要依赖
redsync的Extend或LockWithCtx,这些在集群里不可靠
Gin 中间件里怎么安全地加集群锁
不能把锁逻辑写进 gin.HandlerFunc 顶层,否则每次请求都新建锁实例、重复初始化连接池,容易耗尽连接。重点在于复用 client 和控制锁粒度:
- 锁 key 要带业务上下文,比如下单接口用
lock:{order_id},而不是固定lock:global - 在
gin.Engine初始化阶段创建全局*redis.ClusterClient,注入到 handler 闭包中 - 加锁超时建议设为业务最大执行时间的 1.5 倍,但不超过 60 秒(避免集群 failover 窗口影响)
- 获取锁失败时,别用
time.Sleep硬等,改用指数退避 + 最大重试次数(例如 3 次,间隔 10ms/30ms/100ms)
示例片段:
func withDistributedLock(client *redis.ClusterClient, lockKey string, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
uuid := uuid.NewString()
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
err := client.Set(ctx, lockKey, uuid, timeout).Err()
if err != nil && err != redis.Nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "lock failed"})
return
}
if err == redis.Nil { // 已存在
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": "locked by others"})
return
}
// 绑定解锁函数到 c,确保 defer 执行
c.Set("unlock", func() {
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
script.Eval(ctx, client, []string{lockKey}, uuid)
})
c.Next()
}
}
Redis 集群锁失效的三个隐性风险
集群模式下,锁看似加成功了,但实际可能已不可靠,尤其在以下场景:
- 主从异步复制延迟:客户端 A 在主节点加锁成功,但数据还没同步到从节点;此时主节点宕机,从节点升主,客户端 B 连新主节点又成功加了同名锁——出现双持有
-
集群拓扑变更:reshard 或新增节点过程中,slot 迁移未完成,
SET请求可能被重定向或拒绝,返回MOVED错误但被忽略 -
客户端时间漂移:不同机器系统时间不一致,导致锁过期判断混乱;尤其用
redis.Time()做租约校验时,误差超过 1s 就可能误判
真正需要强一致性的场景(如库存扣减),不能只靠单集群 Redis 锁,得叠加应用层幂等(如唯一订单号 + DB 唯一索引)或升级到 Redlock——但 Redlock 本身在现代云环境(尤其是容器漂移、网络分区频繁)下维护成本极高,多数业务其实只需做好锁 + 幂等 + 补偿,比死磕锁一致性更务实。

















