分布式锁的实现与选择:Redis、ZooKeeper 与 etcd 方案对比
如果你在写单体应用,synchronized 或 mutex 就够了。但一旦服务拆分、多实例部署,本地锁立刻失效——两个 Pod 各锁各的,谁也拦不住谁。这时候你需要一把所有实例都看得见、争得了的锁,也就是分布式锁。
看起来很简单:往 Redis 里写个 key,谁先写入谁拿到锁。但分布式环境下的锁远比想象中复杂——节点挂了怎么办?锁过期了但业务没跑完?主从切换导致锁丢失?这些问题一不小心就会在凌晨三点变成生产事故。本文带你逐一拆解。
分布式锁的核心要求
一把合格的分布式锁至少要满足三个基本属性:
- 互斥性:同一时刻只能有一个客户端持有锁。这是最基本的要求,做不到互斥就不叫锁。
- 无死锁:即使持有锁的客户端崩溃或网络分区,锁最终也要能被释放,不能永远卡死。
- 容错性:锁服务本身不能是单点故障。只要锁服务集群中大多数节点存活,锁就可以正常获取和释放。
在实际生产中,还会衍生出更多要求:可重入、阻塞/非阻塞获取、锁粒度控制、性能开销、运维复杂度等。不同的业务场景对这些属性的优先级不同,这正是我们需要理解不同方案的原因。
方案一:Redis 分布式锁
基础实现:SET NX + 过期时间
最早的做法是两步:SETNX key value 然后 EXPIRE key ttl。这有两个问题:一是两步操作不是原子的,如果在 SETNX 成功后、EXPIRE 之前进程崩溃,锁就永远不会过期;二是 value 没有任何标识,解锁时可能误删别人的锁。
Redis 2.6.12 起,SET 命令支持 NX 和 PX 参数,一行搞定原子加锁:
SET lock_key unique_value NX PX 30000
解锁时不能直接 DEL,必须用 Lua 脚本先校验 value 是否匹配:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
这套基础方案满足了互斥和无死锁,但在 Redis 单机或主从模式下,容错性是有缺陷的。
Redlock 算法
Redis 作者 antirez 提出了 Redlock 算法,核心思路是用 N 个独立的 Redis 节点(通常 5 个),客户端依次向每个节点尝试获取锁,当成功获取超过半数(N/2 + 1)节点的锁,且在有效时间内完成,才算获取成功。
Redlock 的关键参数:
- 锁的有效时间:比如 30 秒,需要在所有节点上设置一致
- 获取超时时间:向单个节点请求的超时,远小于锁的有效时间,比如 10 毫秒
- 时钟漂移补偿:实际锁的有效时间 = TTL - 获取总耗时 - 时钟漂移补偿
但 Redlock 并非银弹。Martin Kleppmann 在 2016 年发文指出,Redlock 在 GC 停顿、时钟跳跃等场景下,安全性并非绝对保证。如果你的场景涉及金钱、库存扣减等强一致性需求,Redlock 不够安全;但对于缓存更新、幂等控制这类场景,它已经足够。
Go 语言 Redlock 实现
package main
import (
"context"
"fmt"
"time"
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v9"
goredis "github.com/redis/go-redis/v9"
)
func main() {
client := goredis.NewClient(&goredis.Options{
Addr: "localhost:6379",
})
pool := goredis.NewPool(client)
rs := redsync.New(pool)
mutex := rs.NewMutex("order:create:lock",
redsync.WithExpiry(10*time.Second),
redsync.WithTries(3),
redsync.WithRetryDelay(200*time.Millisecond),
)
ctx := context.Background()
if err := mutex.LockContext(ctx); err != nil {
panic(err)
}
defer func() {
if ok, err := mutex.UnlockContext(ctx); !ok || err != nil {
fmt.Println("unlock failed:", err)
}
}()
fmt.Println("do some critical work here...")
time.Sleep(5 * time.Second)
}
Watchdog 自动续期
Redis 锁的一个致命问题:锁的 TTL 是固定的,但业务执行时间不可预测。如果业务跑了 15 秒而锁的 TTL 只有 10 秒,锁自动释放后其他请求进来,你的"互斥"就形同虚设了。
解决方案是 Watchdog(看门狗)机制:加锁后启动一个后台协程,每隔 TTL/3 的时间检查业务是否还在持有锁,如果是,就续期。Redisson(Java 客户端)内置了这个功能,Go 生态中需要自己实现:
type WatchdogMutex struct {
client *redis.Client
key string
value string
ttl time.Duration
stopCh chan struct{}
}
func (w *WatchdogMutex) Lock(ctx context.Context) error {
ok, err := w.client.SetNX(ctx, w.key, w.value, w.ttl).Result()
if err != nil || !ok {
return fmt.Errorf("lock failed")
}
w.stopCh = make(chan struct{})
go w.renew(ctx)
return nil
}
func (w *WatchdogMutex) renew(ctx context.Context) {
ticker := time.NewTicker(w.ttl / 3)
defer ticker.Stop()
for {
select {
case <-ticker.C:
script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
`
w.client.Eval(ctx, script, []string{w.key}, w.value, w.ttl.Milliseconds())
case <-w.stopCh:
return
}
}
}
方案二:ZooKeeper 分布式锁
临时顺序节点 + Watch 机制
ZooKeeper 的锁方案完全不同——它不依赖"写入一个 key 并设 TTL",而是利用 ZK 的临时顺序节点(EPHEMERAL_SEQUENTIAL)和 Watch 机制。
具体流程:
- 所有竞争者在一个指定路径(如
/locks/order)下创建临时顺序节点,ZK 自动分配递增序号,如/locks/order/0000000001,/locks/order/0000000002 - 客户端获取该路径下所有子节点,排序后检查自己是不是序号最小的那个
- 如果是最小的 → 获得锁;如果不是 → 对前一个节点设置 Watch,进入等待
- 前一个节点释放(客户端断开或主动删除)→ ZK 通知等待者 → 重新检查是否轮到自己
这套机制有天然的优势:
- 无死锁:客户端崩溃 → 会话断开 → 临时节点自动删除 → 锁自动释放
- 公平性:按序号排队,不会出现抢占饥饿
- 通知机制:Watch 让等待者在锁释放时立即被唤醒,而非忙等(busy-wait)
Go 语言 ZK 锁实现
package main
import (
"fmt"
"sort"
"strings"
"time"
"github.com/go-zookeeper/zk"
)
type ZkMutex struct {
conn *zk.Conn
path string
lockPath string
}
func NewZkMutex(conn *zk.Conn, path string) *ZkMutex {
return &ZkMutex{conn: conn, path: path}
}
func (z *ZkMutex) Lock() error {
// Create ephemeral sequential node
prefix := z.path + "/lock-"
created, err := z.conn.CreateProtectedEphemeralSequential(prefix, []byte{}, zk.WorldACL(zk.PermAll))
if err != nil {
return err
}
z.lockPath = created
for {
children, _, err := z.conn.Children(z.path)
if err != nil {
return err
}
sort.Strings(children)
// Extract our sequence number
parts := strings.Split(created, "/")
mySeq := parts[len(parts)-1]
if children[0] == mySeq {
// We are the first in line - got the lock
return nil
}
// Watch the node ahead of us
var prevNode string
for i, child := range children {
if child == mySeq && i > 0 {
prevNode = children[i-1]
break
}
}
_, _, ch, err := z.conn.ExistsW(z.path + "/" + prevNode)
if err != nil {
return err
}
<-ch // Wait for the previous node to be deleted
}
}
func (z *ZkMutex) Unlock() error {
return z.conn.Delete(z.lockPath, -1)
}
ZooKeeper 方案的缺点是依赖较重。ZK 集群本身需要 3-5 台机器维护,且基于 Java,引入一个 ZK 集群只是为了做分布式锁,运维成本可能比业务本身还高。但如果你的架构中已经有了 ZK(比如用 Kafka 就自带 ZK 依赖),那它就是最自然的选择。
方案三:etcd 分布式锁
Lease + 事务 + Watch
etcd 是 CoreOS 开发的分布式 KV 存储,基于 Raft 共识算法。它的分布式锁思路与 ZK 类似但更现代:
- 创建 Lease(租约),设置 TTL
- 通过事务写入 key,条件是该 key 的 Revision 为 0(即不存在)
- 后台协程自动续租(KeepAlive)
- 其他竞争者通过 Watch 监听 key 的删除事件
Go 语言 etcd 锁实现
package main
import (
"context"
"fmt"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
func main() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
panic(err)
}
defer cli.Close()
// Create a session (lease-based)
session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
panic(err)
}
defer session.Close()
// Create a mutex on the given key prefix
mutex := concurrency.NewMutex(session, "/locks/order-create")
ctx := context.Background()
if err := mutex.Lock(ctx); err != nil {
panic(err)
}
fmt.Println("got the lock, doing critical work...")
time.Sleep(3 * time.Second)
if err := mutex.Unlock(ctx); err != nil {
panic(err)
}
fmt.Println("lock released")
}
如果想要更精细的控制,可以用 etcd 原生操作手写锁逻辑:
func TryLock(cli *clientv3.Client, key string, ttl int64) (clientv3.LeaseID, error) {
ctx := context.Background()
lease, err := cli.Grant(ctx, ttl)
if err != nil {
return 0, err
}
txn := cli.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
Then(clientv3.OpPut(key, "locked", clientv3.WithLease(lease.ID))).
Else(clientv3.OpGet(key))
resp, err := txn.Commit()
if err != nil {
return 0, err
}
if !resp.Succeeded {
return 0, fmt.Errorf("lock held by another client")
}
return lease.ID, nil
}
etcd 的锁在 Go 生态中体验极佳——concurrency 包直接提供了 Mutex,一行代码加锁,还有自动续期。加上 etcd 基于 Raft 协议天然支持强一致性,安全性比 Redlock 高一个档次。
三种方案全方位对比
| 维度 | Redis(Redlock) | ZooKeeper | etcd |
|---|---|---|---|
| 一致性 | 最终一致性,不安全(GC/时钟影响) | 顺序一致性 | 强一致性(Raft) |
| 性能 | 极高(内存操作) | 中等(磁盘 + 选举开销) | 中等(Raft 日志复制) |
| 公平性 | 非公平(随机抢占) | 公平(顺序节点排队) | 公平(FIFO 队列) |
| 自动续期 | 需自己实现 Watchdog | 不需要(Session 心跳) | 内置 Lease KeepAlive |
| 锁释放 | 主动释放 或 TTL 超时 | Session 断开即释放 | Lease 过期 或 主动释放 |
| 可重入 | 需额外逻辑(ThreadLocal 计数) | 天然支持(同客户端可多次创建节点) | 需额外逻辑 |
| 运维复杂度 | 低(Redis 常见组件) | 高(Java、选举、JVM 调优) | 中(Go 编写、部署简单) |
| 最小集群 | 单机可用,Redlock 需 5 节点 | 3 节点(生产) | 3 节点(生产) |
| Go 生态 | 成熟(go-redis, redsync) | 一般(go-zookeeper 不够活跃) | 官方 SDK,体验极佳 |
| 适用场景 | 高性能、允许偶发冲突 | 已有 ZK 生态、需严格排队 | 云原生、Go 技术栈、强一致性需求 |
选型决策指南
没有银弹,但有一个实用的决策路径:
- 你的架构里已经有什么? 如果你已经在维护 ZK 集群(比如配合 Kafka),直接用 ZK 做锁是最省事的。如果你的服务注册发现用的是 etcd(Kubernetes 标配),那把 etcd 也用来做锁是最自然的。什么都没用?Redis 最常见、最简单。
- 你的场景能不能容忍偶尔的锁冲突? 缓存击穿保护、定时任务去重这类场景,Redis 完全够用。但如果是订单库存扣减、账户余额变更——必须用 etcd 或 ZK 这类共识算法保证的方案。
- 你的技术栈是什么? 全 Go 技术栈用 etcd 体验最好;Java 栈用 ZK 或 Redisson 都很成熟;Node.js/Python 用 Redis 生态最丰富。
- 要不要公平性? 如果你的锁竞争激烈,需要"先来后到"的公平排队,ZK 和 etcd 天然支持,Redis 做不到。
锁续期:99% 的事故都发生在这里
锁续期(或锁过期)是分布式锁最常出问题的地方。几个典型案例:
- GC 停顿:Java 应用 Full GC 卡了 15 秒,Redis 锁的 TTL 只有 10 秒,锁早就自动释放了。GC 结束后你的代码继续执行,但锁已经被别人拿走了。这就是 Martin Kleppmann 批评 Redlock 不安全的关键论据。
- 网络抖动:客户端和锁服务之间的网络短暂断开,心跳丢失,锁被判定为过期。
- 业务超时:你以为 5 秒能跑完的批量操作,遇上数据量暴涨跑了 30 秒,锁在中间就释放了。
防御措施:
- Watchdog 自动续期:只要业务还活着,后台自动续期。Redisson 和 etcd concurrency 包都有内置支持,Redis 基础客户端需要自己实现。
- 锁粒度尽量细:不要用一把大锁锁住整个模块。比如扣库存,用
stock:sku_12345而非order:lock,减少竞争。 - 业务幂等设计:这是兜底手段。即使锁出了问题,重复执行同一操作也不会产生副作用。在锁里写入一个唯一请求 ID,下游用这个 ID 做幂等判断。
- 设置合理的超时:TTL 设置成业务 P99 耗时的 3-5 倍,给 Watchdog 足够的缓冲时间。
常见陷阱速查
| 陷阱 | 现象 | 修复 |
|---|---|---|
| 直接 DEL 而不校验 value | 误删别人的锁 | Lua 脚本 GET + 比较 + DEL |
| 锁 TTL 太短 | 业务没跑完锁就释放 | 加 Watchdog 或增大 TTL |
| Redlock 用于强一致性场景 | 偶发锁冲突导致数据错误 | 切换到 etcd 或 ZK |
| 忘记 defer Unlock | 业务 panic 后锁不释放 | 总是 defer unlock,配合 TTL 兜底 |
| 可重入锁实现不正确 | 同一个线程加锁两次导致死锁 | 用 ThreadLocal / goroutine ID 计数器 |
| Watchdog 协程泄漏 | goroutine 数量持续增长 | 确保 Stop channel 在 Unlock 时关闭 |
| 主从切换丢锁 | Redis 主节点挂了,从节点还没同步到锁 | 用 Redlock 多节点,或等 Redis 7.4 WAITAOF |
总结
分布式锁是一个"简单概念、复杂实现"的典型案例。几行代码打天下很容易,但搞清楚你的锁在节点宕机、网络分区、GC 停顿这些极端情况下会怎么行为,才是区分熟练工程师和架构师的分水岭。
如果你的需求是高性能 + 可容忍偶发冲突,用 Redis + Watchdog 就够了,这是投入产出比最高的方案。如果你的需求是强一致性 + 零容忍,用 etcd 或 ZK,基于共识算法的方案从理论上保证了安全性。重点是搞清楚自己的需求落在哪个象限里,别用错了工具。
最后记住:分布式锁只解决"互斥"问题,不解决"幂等"问题。如果你的业务逻辑本身就是幂等的,锁的很多风险就降级为性能问题而非正确性问题。