一粟 · yisu.app
· 12 min read
分布式锁

分布式锁:从 Redis 到 etcd 的工程抉择

"用 Redis 做锁"和"用 etcd 做锁"是两种完全不同的工程哲学。本文从一致性、可用性、性能三个维度做出对比与决策树。

"分布式锁用 Redis 还是 etcd"是工程师面试里的高频题,也是生产里的真实选择。这两者背后是两种完全不同的哲学。本文不讲"谁对谁错",只讲"什么场景该用什么"。

两种锁的本质区别

Redis 锁:单主异步复制 + TTL。锁的"安全"依赖客户端在 TTL 内完成业务。etcd 锁:Raft 强一致 + lease。锁的"安全"由 Raft 保证。

维度Redisetcd
一致性异步复制,可能脑裂Raft 强一致
可用性主挂需哨兵切换多数派可用
性能极快(< 1ms)较快(5–10ms)
TTL 实现被动过期lease 主动续约
fencing token无原生支持revision 可做 token

Redis 锁的经典争议

Martin Kleppmann 在 2016 年指出 Redis 锁的两个问题:

  1. 主从切换期间锁丢失:客户端 A 在 master 拿到锁,master 还没同步到 slave 就挂了,slave 升级为新 master,客户端 B 拿到同一把锁。
  2. 没有 fencing token:即使锁过期,持有旧锁的客户端仍可能在写后端存储,造成数据错乱。

Antirez 的回应:Redis 锁是"best effort"——在大多数时间能工作,配合 Redlock 算法可以提升可靠性。但他也承认,对"绝对安全"的场景,Redis 不是首选。

什么时候 Redis 锁够用

以下场景,Redis 锁是合理选择:

  • 限流防刷:同一 user 1 秒内只能一次。锁失效一次也没大问题。
  • 缓存单飞:防止缓存击穿。锁错也只是多回源一次。
  • 任务去重:多个 worker 不要做同一件事。锁错也只是重复劳动。
  • 非金融业务:业务上能容忍极小概率的"锁失效"。

共同特点:锁失效的代价是"多做一次",不是"数据错乱"。

什么时候必须用 etcd / ZooKeeper

  • 资金操作:扣款、转账。锁失效会造成双重消费。
  • 库存扣减:超卖很难补救。
  • 分布式任务调度:同一任务被两个节点执行可能造成数据冲突。
  • 选主:两个 leader 同时写会造成数据错乱。

共同特点:锁失效的代价是"数据错乱",不是"重试一次"。

选锁的判断标准只有一个:"如果锁失效一次,业务能不能容忍?"

fencing token:被忽视的安全网

即使锁本身可靠,客户端也可能在锁过期后才完成业务(GC 停顿、网络延迟)。fencing token 是解决这个问题的标准方法:

  1. 每次拿锁时获得一个单调递增的 token。
  2. 写后端存储时带上 token。
  3. 存储检查 token,拒绝旧 token 的写入。

etcd 的 revision 天然就是 fencing token——每次锁的获取对应一个 revision,业务端写存储时带 revision,存储拒绝低 revision 的写入。Redis 没有原生 fencing token,需要自己实现(通常用时间戳,但不严格单调)。

决策树

锁失效业务能容忍吗?
├─ 是 → 用 Redis(性能更好)
│   ├─ 单 Redis 实例够吗?
│   │   ├─ 是 → SET key val NX PX 30000
│   │   └─ 否 → Redlock(多实例多数派)
│   └─ 需要 fencing token 吗?
│       └─ 是 → 自己加 token 机制
└─ 否 → 用 etcd / ZK
    ├─ 需要 leader election?
    │   └─ etcd 的 election 包直接用
    └─ 需要简单的互斥锁?
        └─ etcd 的 concurrency 包

实现上的几个坑

  • Redis 锁要设合理 TTL:太短业务没做完就过期;太长宕机后等待久。我们一般 30 秒 + watchdog 续约。
  • 解锁必须 Lua 脚本:避免"删别人的锁"。脚本里检查 value 是不是自己设的。
  • etcd 锁要注意 session 续约:lease 过期锁自动释放,但客户端可能不知道,要监听 lease revoked 事件。
  • 不要嵌套锁:A 锁里再拿 B 锁,容易死锁。

Redis 锁的 Lua 解锁

-- 解锁脚本:确认 value 是自己的再删
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

etcd 锁的伪代码

s := concurrency.NewSession(client)
defer s.Close()
l := concurrency.NewLocker(s, "my-lock")
l.Lock()
defer l.Unlock()
// 业务逻辑
// revision = s.Lease() 可作为 fencing token

给团队的简短建议

  • 先回答"锁失效业务能容忍吗",再选技术。
  • 能容忍 → Redis;不能容忍 → etcd。
  • 所有锁都要配 fencing token 或业务幂等。
  • 不要把锁当事务用——锁不是事务。

分布式锁是工程里最容易"看似实现简单"但实际上"边界极多"的工具。把它选对、用对,是工程师的基本功。