redis vs etcd 分布式锁实现

在构建分布式系统时,确保数据一致性和操作原子性至关重要。分布式锁是实现这一目标的关键工具,它允许多个节点在共享资源上进行互斥访问。本文将深入探讨两种主流的分布式锁实现:基于 Redis 的分布式锁和基于 etcd 的分布式锁,分析它们的原理、核心问题、解决方案,并提供相应的 Golang 代码示例。


Redis 分布式锁

Redis 因其高性能、单线程执行命令的原子性以及丰富的数据结构,成为实现分布式锁的热门选择。

基本原理

Redis 分布式锁的核心是利用 SETNX (SET if Not eXists) 命令。该命令的特性是:如果指定的 key 不存在,则设置 key 对应的值,并返回 1;如果 key 已经存在,则不执行任何操作,并返回 0。

一个最简单的加锁操作如下: SETNX lock_key 1

为了防止死锁(即获得锁的客户端崩溃而无法释放锁),我们需要为锁设置一个过期时间。在早期,这需要两步操作:SETNXEXPIRE。但这两步并非原子操作,如果 SETNX 成功后客户端崩溃,EXPIRE 未能执行,依然会导致死锁。

从 Redis 2.6.12 版本开始,SET 命令支持了扩展参数,使得加锁和设置过期时间可以成为一个原子操作: SET lock_key unique_value NX PX 30000

  • unique_value:一个唯一的客户端标识符。用于安全地释放锁,防止客户端 A 释放了客户端 B 的锁。
  • NX:表示只在 key 不存在时才设置。
  • PX 30000:表示锁的过期时间为 30000 毫秒(30 秒)。

释放锁则需要一个 "查询-判断-删除" 的原子操作,通常使用 Lua 脚本实现,以确保只有锁的持有者才能删除锁。

核心问题及解决方案

1. 死锁 (Deadlock)

  • 问题: 客户端 A 获得锁后,在执行业务逻辑时崩溃,未能显式释放锁,导致其他客户端永远无法获得该锁。
  • 解决方案: 在加锁时设置一个合理的过期时间(TTL)。这是分布式锁的生命线,确保即使客户端崩溃,锁最终也会被自动释放。

2. 锁被误删 / 业务超时问题

  • 问题: 并发场景超时问题。
    1. 客户端 A 获得锁,设置过期时间为 10 秒。
    2. 客户端 A 的业务逻辑复杂或因网络、GC 等原因,执行了超过 10 秒。
    3. 在第 10 秒,Redis 自动释放了锁。
    4. 客户端 B 此时请求,成功获得了锁。
    5. 客户端 A 的业务逻辑执行完毕,执行 DEL lock_key 命令,结果误删了客户端 B 持有的锁
  • 解决方案:
    1. 唯一标识: 在加锁时,将 value 设置为一个唯一的随机字符串(如 UUID)。在释放锁时,先获取 key 对应的 value,判断是否与自己加锁时设置的唯一标识相同,如果相同才执行删除。这个“获取-判断-删除”的过程必须是原子的,需要使用 Lua 脚本 来完成。
    2. 锁续期 (Watchdog): 对于业务执行时间不确定的情况,可以启动一个“看门狗”线程或 Goroutine。当客户端 A 获得锁后,看门狗在后台定期检查客户端 A 是否还持有锁,如果是,则自动延长锁的过期时间。当业务执行完毕后,客户端 A 显式释放锁,并停止看门狗。这种机制可以有效防止因业务超时导致锁被动释放的问题。

3. 脑裂 (Split-Brain)

  • 问题: 在 Redis 主从(Master-Slave)或哨兵(Sentinel)集群中,如果 Master 节点宕机,可能会发生脑裂。
    1. 客户端 A 在 Master 节点上获得了锁。
    2. Master 节点的数据还没来得及同步到 Slave 节点,就宕机了。
    3. 哨兵将一个 Slave 节点提升为新的 Master。
    4. 客户端 B 在这个新的 Master 节点上请求同一个锁,由于数据未同步,客户端 B 也成功获得了锁。
    5. 此时,系统中有两个客户端(A 和 B)同时持有同一个锁,分布式锁失效。
  • 解决方案:
    • Redlock 算法: Redis 的作者提出了一种名为 Redlock 的算法。其思想是客户端向 N 个独立的 Redis 实例(非主从)发起加锁请求,当且仅当客户端从超过半数((N/2)+1)的实例上成功获取锁,并且总耗时小于锁的有效时间,才认为加锁成功。释放锁时,需要向所有实例发送释放锁的命令。Redlock 极大增加了锁的可靠性,但也提高了实现的复杂度和运维成本,同时在时钟漂移等极端情况下仍有争议。在实践中,除非对锁的可靠性有极高要求,否则需要谨慎评估是否使用。

4. 惊群效应 (Thundering Herd)

  • 问题: 当一个热点 key 的锁被释放时,大量等待该锁的客户端(线程/进程)被同时唤醒,并一起涌向 Redis 请求加锁,对系统造成巨大的瞬时压力。
  • 解决方案: 惊群效应在分布式锁场景下相对可控,因为最终只有一个客户端能成功。但为了优化,可以引入一些机制:
    • 分级锁/读写锁: 将一把大锁拆分为多把小粒度的锁,或根据业务场景使用读写锁,允许多个读操作并行。
    • 引入本地队列或延时: 客户端获取锁失败后,不立即重试,而是在本地等待一个随机的时间,错开重试请求的高峰。

Golang 实现

下面是一个使用 go-redis 库实现的、包含“唯一标识”和“Lua脚本释放”的安全 Redis 分布式锁。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/google/uuid"
)

// RedisLock 是一个基于 Redis 的分布式锁
type RedisLock struct {
	client *redis.Client
	key    string
	value  string // 锁的唯一标识
}

// NewRedisLock 创建一个新的分布式锁实例
func NewRedisLock(client *redis.Client, key string) *RedisLock {
	return &RedisLock{
		client: client,
		key:    key,
		value:  uuid.NewString(), // 使用 UUID 作为唯一值
	}
}

// TryLock 尝试获取锁
// ttl: 锁的过期时间
func (rl *RedisLock) TryLock(ctx context.Context, ttl time.Duration) (bool, error) {
	// SET key value NX PX ttl
	ok, err := rl.client.SetNX(ctx, rl.key, rl.value, ttl).Result()
	if err != nil {
		return false, fmt.Errorf("failed to acquire lock: %w", err)
	}
	return ok, nil
}

// Unlock 释放锁
func (rl *RedisLock) Unlock(ctx context.Context) error {
	// 使用 Lua 脚本保证原子性:先GET,再比较,最后DEL
	script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
	// 执行 Lua 脚本
	res, err := rl.client.Eval(ctx, script, []string{rl.key}, rl.value).Result()
	if err != nil {
		return fmt.Errorf("failed to release lock: %w", err)
	}

	// 返回值为 1 表示删除成功,0 表示锁不存在或值不匹配
	if n, ok := res.(int64); !ok || n == 0 {
		// 注意:这里可能意味着锁已因超时而自动释放,或者被其他客户端持有。
		// 在业务上可能需要记录日志或进行相应的处理。
		return fmt.Errorf("failed to release lock: key not found or value mismatched")
	}

	return nil
}

//模拟扣减逻辑
func deductStock(lock *RedisLock) {
	ctx := context.Background()
	
	// 尝试获取锁,超时时间为10秒
	locked, err := lock.TryLock(ctx, 10*time.Second)
	if err != nil {
		fmt.Printf("Error acquiring lock: %v\n", err)
		return
	}
	if !locked {
		fmt.Println("Could not acquire lock, another operation is in progress.")
		return
	}
	// 获取锁成功后,确保最终会释放锁
	defer func() {
		if err := lock.Unlock(ctx); err != nil {
			fmt.Printf("Error releasing lock: %v\n", err)
		} else {
			fmt.Println("Lock released successfully.")
		}
	}()

	fmt.Println("Lock acquired, proceeding with stock deduction...")
	
	// 模拟耗时操作,比如超过锁的过期时间
	// time.Sleep(12 * time.Second) 
	time.Sleep(5 * time.Second) // 正常情况

	fmt.Println("Stock deducted successfully.")
}

func main() {
	// 初始化 Redis 客户端
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})
	_, err := rdb.Ping(context.Background()).Result()
	if err != nil {
		panic(err)
	}

	lockKey := "stock_lock"
	
	// 模拟并发请求
	for i := 0; i < 3; i++ {
		go func(id int) {
			fmt.Printf("Goroutine %d trying to deduct stock...\n", id)
			// 每个 goroutine 使用自己的 lock 实例,确保 value 唯一
			lock := NewRedisLock(rdb, lockKey) 
			deductStock(lock)
		}(i)
	}
	
	// 等待 goroutines 执行
	time.Sleep(20 * time.Second)
}

etcd 分布式锁

etcd 是一个高可用的分布式键值存储系统,常用于服务发现、配置共享和分布式协调。其底层基于 Raft 一致性算法,天然就为构建分布式系统组件提供了强大的支持。

基本原理

etcd 实现分布式锁主要依赖其三大特性:

  1. Lease (租约):

    • 客户端可以创建一个租约,并指定一个 TTL(过期时间)。
    • 客户端在持有租约期间需要不断地“续约”(KeepAlive),像心跳一样。
    • 如果客户端在租约到期前没有续约(比如客户端崩溃或网络中断),租约会自动过期。
    • 所有与该租约关联的 key 都会被 etcd 自动删除。
    • 这完美地解决了 Redis 锁的死锁和过期问题。
  2. Key-Value Store 与 Revision:

    • 客户端通过将一个 key 与一个租约绑定来尝试获取锁。
    • etcd 会为每个 key 的每次变更维护一个全局递增的 Revision 号。
    • 多个客户端可以同时创建带相同前缀的临时 key(例如 /locks/my_lock/uuid_1, /locks/my_lock/uuid_2)。
    • 通过比较谁创建的 key 的 Revision 号最小,来确定谁获得了锁。
  3. Watch 机制:

    • 获取锁失败的客户端,不需要盲目地轮询(Spin Lock)。
    • 它们可以 Watch(监视)比自己 Revision 号小的前一个 key。
    • 当前一个 key 因为锁被释放或租约过期而被删除时,etcd 会通知等待的客户端。
    • 该客户端被唤醒后,再次检查自己是否是 Revision 最小的 key,如果是,则获得锁。
    • 这优雅地解决了惊群效应,实现了公平、有序的锁等待队列。

Golang 实现

etcd 官方提供了 clientv3/concurrency 包,极大地简化了分布式锁的实现。

package etcdlock

import (
	"context"
	"fmt"
	"time"

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

// Locker 封装了 etcd 会话与互斥锁
type Locker struct {
	cli     *clientv3.Client
	key     string
	ttl     int
	session *concurrency.Session
	mutex   *concurrency.Mutex
	locked  bool
}

// NewLocker 使用给定客户端、锁 key 与 TTL(秒) 创建锁器
func NewLocker(cli *clientv3.Client, key string, ttl int) (*Locker, error) {
	s, err := concurrency.NewSession(cli, concurrency.WithTTL(ttl))
	if err != nil {
		return nil, fmt.Errorf("new session: %w", err)
	}
	return &Locker{
		cli:     cli,
		key:     key,
		ttl:     ttl,
		session: s,
		mutex:   concurrency.NewMutex(s, key),
	}, nil
}

// Lock 阻塞直到获取锁
func (l *Locker) Lock(ctx context.Context) error {
	if err := l.mutex.Lock(ctx); err != nil {
		return err
	}
	l.locked = true
	return nil
}

// LockWithTimeout 在超时时间内尝试加锁
func (l *Locker) LockWithTimeout(ctx context.Context, timeout time.Duration) error {
	ctx2, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	return l.Lock(ctx2)
}

// Unlock 释放锁
func (l *Locker) Unlock(ctx context.Context) error {
	if !l.locked {
		return nil
	}
	if err := l.mutex.Unlock(ctx); err != nil {
		return err
	}
	l.locked = false
	return nil
}

// Close 关闭会话(会导致未释放的锁随租约失效自动释放)
func (l *Locker) Close() error {
	if l.session != nil {
		return l.session.Close()
	}
	return nil
}
package main

import (
	"context"
	"fmt"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
	"your/module/etcdlock"
)

func main() {
	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   []string{"localhost:2379"},
		DialTimeout: 5 * time.Second,
	})
	if err != nil { panic(err) }
	defer cli.Close()

	locker, err := etcdlock.NewLocker(cli, "/locks/my-job", 10)
	if err != nil { panic(err) }
	defer locker.Close()

	if err := locker.LockWithTimeout(context.Background(), 5*time.Second); err != nil {
		fmt.Println("acquire failed:", err)
		return
	}
	defer locker.Unlock(context.Background())

	fmt.Println("locked, do critical work...")
	time.Sleep(2 * time.Second)
	fmt.Println("done")
}

etcd 如何解决 Redis 的核心问题

  • 死锁: Lease 机制保证了即使客户端崩溃,锁也会在租约到期后自动释放。
  • 锁安全: concurrency 包内部处理了锁的归属权,Unlock 时会验证锁是否仍被当前 session 持有。
  • 脑裂: etcd 基于 Raft 协议,本身就是为解决分布式一致性而设计的,不存在 Redis 主从模式下的脑裂问题。
  • 惊群效应: Watch 机制使得只有一个等待者会被唤醒,实现了公平的排队机制。

总结:Redis vs. etcd

特性 Redis 分布式锁 etcd 分布式锁
性能 非常高,基于内存操作。 较高,但涉及磁盘 I/O 和 Raft 协议,性能低于 Redis。
可靠性 相对较低,主从模式有脑裂风险,需 Redlock 增强。 非常高,基于 Raft 协议保证了强一致性。
实现复杂度 较高,需要自己处理超时、续期、原子释放等问题。 非常低,官方库提供了开箱即用的封装。
特性 简单直接。 提供租约、Watch、Revision 等高级特性,更适合复杂协调场景。
适用场景 对性能要求极高,能容忍极低概率的锁失效场景。 对数据一致性和可靠性要求极高的场景,如分布式系统协调。

选择建议:

  • 如果业务场景追求极致的性能,并且可以接受在极端情况(如主节点宕机)下可能出现的短暂锁失效,那么 Redis 是一个不错的选择。
  • 如果对数据一致性和可靠性有非常严格的要求,例如金融交易、分布式调度等,那么 etcd 提供的强一致性保证使其成为更安全、更可靠的选择。

打 赏