Redis 分布式锁的价值不是“让所有分布式业务绝对安全”,而是在多个服务实例竞争同一项短时、可重建的工作时,减少重复执行和下游压力。
它适合控制并发进入临界区;它不能替代数据库事务、唯一约束、条件更新、幂等设计或业务侧的权限校验。尤其在锁租约到期、进程暂停或故障切换后,旧持有者仍可能继续执行,因此业务正确性不能只依赖 Redis 锁。
本文使用 github.com/redis/go-redis/v9 展示 Go 中的基础实现,并重点说明它的失效边界。
1. 先判断是否真的需要分布式锁
先问三个问题:
- 多个实例是否真的会同时操作同一资源?
- 重复执行的后果是什么,能否通过幂等处理?
- 数据库或下游系统是否已经能用条件更新、唯一约束或事务保证正确性?
很多场景不应先加锁:
-- 扣减库存应优先由数据层保证不会扣成负数
UPDATE inventory
SET available = available - :count
WHERE sku_id = :sku_id
AND available >= :count;
若受影响行数为 0,说明库存不足或资源已被并发修改。这里的正确性来自条件更新,而不是“先拿 Redis 锁再更新”。同样,创建订单可依赖唯一约束和幂等键;支付、扣款等关键操作还应具备可追溯的状态机与补偿机制。
Redis 锁更适合以下场景:
- 多实例只应有一个执行某项短任务,例如单个资源的缓存重建。
- 同一资源的高成本回源需要请求合并,避免瞬间压垮数据库。
- 已经具备幂等和数据层保护,但希望减少重复工作。
2. Redis 锁能提供什么,不能提供什么
一个基础 Redis 锁由三部分组成:
SET lock:{resource} {random-token} NX PX {lease-ms}
NX:只有锁不存在时才能获取,提供互斥占用。PX:为锁设置租约,持有者崩溃后锁最终会过期。random-token:标识持有者,释放或续租时用于校验归属。
它能避免两个客户端在同一时刻都认为自己刚获取了同一 Redis 主节点上的锁;但它不能保证旧客户端在租约过期后自动停止业务代码。
例如:客户端 A 拿到 10 秒锁后发生长时间暂停;第 10 秒锁过期,客户端 B 获得锁并完成写入;A 在第 12 秒恢复并继续写入。A 的 token 能防止它删除 B 的锁,却不能阻止它向数据库或外部系统提交过期操作。
这就是为什么“唯一 token + Lua 解锁”是必要条件,但不是完整的业务正确性方案。
3. Go 的最小正确实现
3.1 获取锁:原子设置 token 和租约
不要使用 SETNX 后再单独调用 EXPIRE。两条命令之间发生崩溃,会留下没有过期时间的锁。使用 SET ... NX PX 对应的客户端 API:
package lock
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/redis/go-redis/v9"
)
type Lock struct {
Key string
Token string
}
func TryAcquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, bool, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, false, err
}
token := hex.EncodeToString(raw)
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return nil, ok, err
}
return &Lock{Key: key, Token: token}, true, nil
}
token 必须由足够随机的值生成,不能用固定服务名、goroutine 编号或可预测时间戳。
3.2 释放锁:比较 token 后原子删除
不能先 GET 再 DEL。在两条命令之间,原锁可能已经过期并被其他客户端获得。使用 Lua 在 Redis 端原子完成比较与删除:
var releaseScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
func (l *Lock) Release(ctx context.Context, rdb *redis.Client) (bool, error) {
deleted, err := releaseScript.Run(ctx, rdb, []string{l.Key}, l.Token).Int()
if err != nil {
return false, err
}
return deleted == 1, nil
}
调用方应在完成临界区后尽快释放锁。若返回 false,通常说明锁已过期或不再归当前持有者;此时绝不能退化为直接 DEL。
3.3 获取失败时:有界等待而非无限自旋
锁获取失败后应遵循调用方的 context.Context 截止时间,以有限次数、带随机抖动的退避重试。每次重试前都应重新检查业务结果或缓存,避免所有请求只为等待锁而堆积。
对热点缓存重建,同一实例内优先使用请求合并;跨实例竞争确实存在时再使用 Redis 锁。锁不是高并发下的队列系统。
4. 长任务、续租与取消
如果临界区的执行时间能由 P99 估算,优先选择一个略大于 P99 的有限租约,并保持临界区尽可能短。不要把远程 RPC、人工审批或整批离线任务长期包在 Redis 锁内。
执行时间无法可靠预估时,才考虑续租。续租也必须在 Redis 端比较 token 后原子执行:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
续租协程必须受父 context.Context 控制,并在业务完成、续租失败或锁不再归属当前 token 时停止。续租不是“永不释放锁”:它会延长故障恢复时间,也不能解决旧持有者恢复后继续写入的问题。
5. 真正关键:fencing token 与幂等
当锁用于保护不可逆或顺序敏感的写操作时,需要让被保护的存储系统拒绝旧持有者。常见做法是为每次成功获取协调权限分配单调递增的 fencing token,并把它随写请求传给数据库或下游服务。
下游只接受比已处理 token 更大的请求。即使 A 的锁过期后恢复,A 携带的旧 token 也会被拒绝;B 的新 token 才能继续写入。
fencing token 必须由能满足业务故障模型的协调机制提供,并由业务存储实际校验。简单地在 Redis 中 INCR 一个计数并不自动获得跨故障切换的强正确性。
无论是否使用 fencing,关键写操作仍应设计幂等键、唯一约束、状态版本或条件更新。Redis 锁是减少竞争的辅助层,不是最终裁判。
6. 两个 Go 场景
6.1 热点缓存重建
目标是减少重复回源,而不是建立全局互斥:
- 先读取缓存;命中直接返回。
- 本实例内合并同 Key 请求。
- 跨实例只有一个请求获得短租约并回源、回填缓存。
- 其他请求短暂退避后重读缓存;超时则返回陈旧值或执行业务降级。
数据库仍要有连接池、并发和超时保护。锁失效时最多会出现重复回源,不能让它演变成无界回源。
6.2 单资源任务执行
例如多个实例都可能处理同一资源的补偿任务。Redis 锁可以减少并发处理,但任务表仍应记录唯一任务 ID、状态和版本;写入时使用条件更新,确保重复投递或旧执行者恢复不会重复生效。
对跨系统副作用,使用幂等请求键和可恢复的任务状态比延长锁租约更可靠。
7. 如何选型
| 需求 | 优先方案 | Redis 锁的角色 |
|---|---|---|
| 防止库存超卖、状态乱序 | 数据库条件更新、事务、版本号 | 通常不应作为正确性基础 |
| 防止重复创建/重复提交 | 唯一约束、幂等键 | 可减少前置竞争 |
| 热点缓存重建 | 请求合并、短租约、陈旧值降级 | 控制跨实例回源 |
| 定时任务重复执行 | 任务状态机、幂等执行 | 减少并发抢占 |
| 必须拒绝过期持有者 | fencing token + 下游校验 | 单独 Redis 租约不足以保证 |
如果业务需要强协调语义,应根据故障模型选择能提供相应保证的协调系统,并把 token/版本校验落实到资源存储端。不要仅因已有 Redis 就假设它适合所有锁场景。
8. 上线检查清单
- 已确认数据库约束、事务或幂等机制无法单独满足需求。
- 锁 Key 的粒度只覆盖同一业务资源,临界区足够短。
- 获取锁使用原子的
SET NX PX,token 不可预测。 - 解锁和续租均在 Redis 端比较 token 后原子执行。
- 重试受 context、次数和退避控制,回源有全局并发上限。
- 锁过期后旧执行者恢复时,业务写入仍能由版本、幂等或 fencing 拒绝。
- 已演练 Redis 超时、客户端暂停、锁续租失败和主从切换等故障。