跳到主要内容

设计分布式锁

问题

如何用 Go 实现分布式锁?Redis 锁和 etcd 锁各有什么优缺点?

答案

为什么需要分布式锁

单机 sync.Mutex 只能控制单进程内的并发。分布式环境下,多个服务实例需要互斥访问共享资源(如库存扣减),必须用分布式锁。

Redis 分布式锁

基础实现(SETNX + TTL)

func TryLock(ctx context.Context, rdb *redis.Client, key, value string, ttl time.Duration) (bool, error) {
// SET key value NX EX ttl — 原子操作
ok, err := rdb.SetNX(ctx, key, value, ttl).Result()
return ok, err
}

// 释放锁:必须用 Lua 保证"判断+删除"原子性
const unlockLua = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`

func Unlock(ctx context.Context, rdb *redis.Client, key, value string) error {
_, err := rdb.Eval(ctx, unlockLua, []string{key}, value).Result()
return err
}

// 使用示例
func DoWithLock(rdb *redis.Client) error {
lockKey := "lock:order:123"
lockValue := uuid.NewString() // 唯一标识,防止误删别人的锁
ttl := 10 * time.Second

ok, err := TryLock(context.Background(), rdb, lockKey, lockValue, ttl)
if err != nil || !ok {
return fmt.Errorf("获取锁失败")
}
defer Unlock(context.Background(), rdb, lockKey, lockValue)

// 执行业务逻辑...
return nil
}
常见陷阱
  • 必须设过期时间:否则进程崩溃后锁永远无法释放
  • 必须用唯一 value:释放时比对 value,防止误删其他进程的锁
  • Lua 脚本解锁:GET + DEL 必须原子,不能分两步

Redisson 风格(看门狗续期)

type RedisLock struct {
rdb *redis.Client
key string
value string
ttl time.Duration
cancel context.CancelFunc
}

func (l *RedisLock) Lock(ctx context.Context) error {
l.value = uuid.NewString()
for {
ok, err := l.rdb.SetNX(ctx, l.key, l.value, l.ttl).Result()
if err != nil {
return err
}
if ok {
// 启动看门狗,自动续期
wdCtx, cancel := context.WithCancel(ctx)
l.cancel = cancel
go l.watchdog(wdCtx)
return nil
}
time.Sleep(50 * time.Millisecond) // 自旋等待
}
}

// 看门狗:每 ttl/3 续期一次
func (l *RedisLock) watchdog(ctx context.Context) {
ticker := time.NewTicker(l.ttl / 3)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
l.rdb.Expire(context.Background(), l.key, l.ttl)
}
}
}

func (l *RedisLock) Unlock(ctx context.Context) error {
l.cancel() // 停止看门狗
_, err := l.rdb.Eval(ctx, unlockLua, []string{l.key}, l.value).Result()
return err
}

etcd 分布式锁(推荐)

etcd 基于 Raft 一致性协议,天然支持强一致性分布式锁:

import (
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)

func EtcdLockExample() error {
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
})
defer cli.Close()

// 创建 session(绑定 Lease,自动续约)
session, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
defer session.Close()

// 创建互斥锁
mutex := concurrency.NewMutex(session, "/locks/order/123")

// 获取锁(阻塞直到获取成功)
if err := mutex.Lock(context.Background()); err != nil {
return err
}
defer mutex.Unlock(context.Background())

// 执行业务逻辑...
return nil
}

方案对比

特性Redis 锁etcd 锁
一致性AP(可能主从切换丢锁)CP(Raft 强一致)
性能高(10万+ QPS)中(万级)
可靠性需 Redlock 提高天然可靠
续期机制手动实现看门狗session 自动续约
复杂度简单引入 etcd 依赖
适用场景高并发、允许极端情况强一致性要求
选型建议
  • 高并发 + 允许极端情况下少量不一致 → Redis + 看门狗
  • 强一致性要求(如订单、支付)→ etcd
  • 已有 etcd(K8s 环境)→ 直接用 etcd 锁

常见面试问题

Q1: Redis 主从切换导致锁丢失怎么办?

答案

  • Redlock 算法:向 N 个独立 Redis 节点同时加锁,超过半数成功才算获取锁
  • etcd 替代:Raft 协议保证主从切换不丢锁
  • 实际项目中,Redis 锁 + 幂等性设计通常已足够

Q2: 锁过期但业务没执行完怎么办?

答案:看门狗机制自动续期。如果进程崩溃,看门狗停止,锁自然过期释放,不会死锁。

Q3: 可重入锁怎么实现?

答案:用 Redis Hash 存储锁信息:

  • HSET lock:key holder <goroutine_id> count 1
  • 重入时 HINCRBY count 1,释放时 HINCRBY count -1
  • count 归零时删除 key

相关链接