Go语言分布式锁实现实战:Redis Redlock算法与etcd租约锁方案

分布式锁的核心要求与Redis单节点实现

分布式锁需满足互斥性(同一时刻仅一个客户端持有)、可重入性、防死锁(锁自动过期)和容错性(锁服务部分节点宕机仍可用)四个核心要求。Redis基于SET命令的NX+PX参数可实现单节点分布式锁:

package distributed

import (
	"context"
	"errors"
	"time"
	"github.com/go-redis/redis/v8"
	"github.com/google/uuid"
)

type RedisLock struct {
	client *redis.Client
	key    string
	value  string
	ttl    time.Duration
}

func NewRedisLock(client *redis.Client, key string, ttl time.Duration) *RedisLock {
	return &RedisLock{
		client: client,
		key:    key,
		value:  uuid.New().String(),
		ttl:    ttl,
	}
}

// Lock 尝试获取锁
func (l *RedisLock) Lock(ctx context.Context) error {
	ok, err := l.client.SetNX(ctx, l.key, l.value, l.ttl).Result()
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("lock acquired by another client")
	}
	return nil
}

// Unlock 释放锁(使用Lua脚本保证原子性)
func (l *RedisLock) Unlock(ctx context.Context) error {
	// 必须验证value一致,防止误删其他客户端的锁
	script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
	return redis.call("DEL", KEYS[1])
else
	return 0
end
`
	result, err := l.client.Eval(ctx, script, []string{l.key}, l.value).Int()
	if err != nil {
		return err
	}
	if result == 0 {
		return errors.New("lock not held by this client")
	}
	return nil
}

// TryLockWithRetry 带重试的锁获取
func (l *RedisLock) TryLockWithRetry(ctx context.Context, retries int, interval time.Duration) error {
	for i := 0; i < retries; i++ {
		err := l.Lock(ctx)
		if err == nil {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(interval):
		}
	}
	return errors.New("failed to acquire lock after retries")
}

// StartAutoRenew 自动续期(watchdog机制)
func (l *RedisLock) StartAutoRenew(ctx context.Context, interval time.Duration) context.CancelFunc {
	renewCtx, cancel := context.WithCancel(ctx)
	script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
	return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
	return 0
end
`
	go func() {
		ticker := time.NewTicker(interval)
		defer ticker.Stop()
		for {
			select {
			case <-renewCtx.Done():
				return
			case <-ticker.C:
				l.client.Eval(renewCtx, script, []string{l.key}, l.value, l.ttl.Milliseconds())
			}
		}
	}()
	return cancel
}

Unlock操作必须通过Lua脚本实现GET+DEL的原子性执行,避免客户端A的锁过期后客户端B获取锁,客户端A再执行DEL误删B的锁。value使用UUID保证唯一性。

watchdog自动续期机制适用于执行时间不可预估的场景。续期间隔通常设置为TTL的1/3,如TTL=30s则每10s续期一次。但续期存在网络延迟风险,高安全场景需评估续期失败后的处理策略。

Redlock算法:多节点容错锁

Redis作者Antirez提出的Redlock算法通过在多个独立Redis节点上获取锁实现容错。假设N个Redis节点(推荐5个),客户端在多数节点(N/2+1)上成功获取锁即认为锁获取成功:

package distributed

import (
	"context"
	"errors"
	"time"
	"github.com/go-redis/redis/v8"
	"github.com/google/uuid"
)

type RedLock struct {
	clients []*redis.Client
	key     string
	value   string
	ttl     time.Duration
}

func NewRedLock(clients []*redis.Client, key string, ttl time.Duration) *RedLock {
	return &RedLock{
		clients: clients,
		key:     key,
		value:   uuid.New().String(),
		ttl:     ttl,
	}
}

func (rl *RedLock) Lock(ctx context.Context) error {
	n := len(rl.clients)
	majority := n/2 + 1
	successCount := 0
	startTime := time.Now()

	// 在所有节点上尝试获取锁
	for _, client := range rl.clients {
		ok, err := client.SetNX(ctx, rl.key, rl.value, rl.ttl).Result()
		if err == nil && ok {
			successCount++
		}
	}

	// 检查是否获取多数节点锁,且耗时未超过TTL的一半
	elapsed := time.Since(startTime)
	if successCount >= majority && elapsed < rl.ttl/2 {
		// 锁有效时间 = TTL - 获取耗时
		return nil
	}

	// 未获取多数锁,释放已获取的锁
	rl.unlockPartial(ctx)
	if successCount < majority {
		return errors.New("failed to acquire majority locks")
	}
	return errors.New("lock acquisition too slow")
}

func (rl *RedLock) Unlock(ctx context.Context) error {
	return rl.unlockPartial(ctx)
}

func (rl *RedLock) unlockPartial(ctx context.Context) error {
	script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
	return redis.call("DEL", KEYS[1])
else
	return 0
end
`
	var lastErr error
	for _, client := range rl.clients {
		_, err := client.Eval(ctx, script, []string{rl.key}, rl.value).Result()
		if err != nil {
			lastErr = err
		}
	}
	return lastErr
}

Redlock的关键设计点:获取锁的耗时必须小于TTL的一半,否则锁的有效期不足以完成业务操作。锁的有效时间计算为effectiveTTL = TTL - acquisitionTime,业务操作必须在此时间内完成。

Redlock的争议主要在于时钟同步问题。如果某节点发生时钟跳跃(NTP校正),锁可能提前过期。对此,Martin Kleppmann建议使用fencing token方案:每次获取锁生成递增token,资源服务端拒绝低token的请求。

etcd分布式锁:Lease机制实现

etcd基于Raft共识算法提供强一致性存储,分布式锁实现依赖Lease(租约)机制。Lease绑定到key,租约到期后key自动删除,天然实现防死锁:

package distributed

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

type EtcdLock struct {
	client   *clientv3.Client
	key      string
	ttl      int
	session  *concurrency.Session
	mutex    *concurrency.Mutex
}

func NewEtcdLock(client *clientv3.Client, key string, ttl int) (*EtcdLock, error) {
	// 创建session,TTL到期后自动释放锁
	session, err := concurrency.NewSession(client, concurrency.WithTTL(ttl))
	if err != nil {
		return nil, err
	}

	mutex := concurrency.NewMutex(session, key)

	return &EtcdLock{
		client:  client,
		key:     key,
		ttl:     ttl,
		session: session,
		mutex:   mutex,
	}, nil
}

// Lock 阻塞式获取锁
func (el *EtcdLock) Lock(ctx context.Context) error {
	return el.mutex.Lock(ctx)
}

// TryLock 非阻塞式尝试获取锁
func (el *EtcdLock) TryLock(ctx context.Context) error {
	return el.mutex.TryLock(ctx)
}

// Unlock 释放锁
func (el *EtcdLock) Unlock(ctx context.Context) error {
	return el.mutex.Unlock(ctx)
}

// Close 关闭session,释放锁并清理资源
func (el *EtcdLock) Close() error {
	return el.session.Close()
}

etcd的concurrency包封装了完整的锁实现逻辑。与Redis不同,etcd锁的获取是排队式的——当锁被持有时,其他客户端通过Watch机制监听前一个持有者的key删除事件,形成公平锁队列。

实际业务场景的封装使用示例:

package service

import (
	"context"
	"fmt"
	"log"
	"time"
	"go.etcd.io/etcd/client/v3"
	"distributed"
)

type OrderService struct {
	etcdClient *clientv3.Client
}

func (s *OrderService) ProcessOrderWithLock(ctx context.Context, orderID string) error {
	lockKey := fmt.Sprintf("/locks/order/%s", orderID)

	lock, err := distributed.NewEtcdLock(s.etcdClient, lockKey, 30)
	if err != nil {
		return fmt.Errorf("create lock: %w", err)
	}
	defer lock.Close()

	// 尝试获取锁,最多等待10秒
	lockCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	if err := lock.Lock(lockCtx); err != nil {
		return fmt.Errorf("acquire lock for order %s: %w", orderID, err)
	}

	// 执行业务逻辑
	if err := s.processOrder(ctx, orderID); err != nil {
		// 即使业务失败也必须释放锁
		_ = lock.Unlock(context.Background())
		return fmt.Errorf("process order: %w", err)
	}

	// 释放锁
	if err := lock.Unlock(ctx); err != nil {
		log.Printf("unlock failed for order %s: %v", orderID, err)
		return err
	}

	return nil
}

func (s *OrderService) processOrder(ctx context.Context, orderID string) error {
	// 业务处理逻辑
	return nil
}

Redis与etcd分布式锁方案对比

选择分布式锁实现方案时,需在性能、一致性、运维复杂度之间权衡:

// 方案选型对比表
+------------------+--------------------------+--------------------------+
| 维度             | Redis分布式锁            | etcd分布式锁             |
+------------------+--------------------------+--------------------------+
| 一致性           | AP(最终一致性)         | CP(强一致性,Raft)     |
| 性能             | 单节点TPS 10万+          | 集群TPS 1-2万            |
| 锁公平性         | 非公平(抢占式)         | 公平(FIFO队列)        |
| 防死锁           | TTL过期                  | Lease过期                |
| 时钟依赖         | 强(时钟跳跃影响TTL)    | 弱(基于Leader租约)     |
| 可重入性         | 需自行实现               | concurrency包内置        |
| 运维复杂度       | 低(Redis广泛部署)      | 中(需维护etcd集群)     |
| 适用场景         | 高并发、短任务、         | 长任务、强一致性要求、   |
|                  | 最终一致性可接受         | 配置/选主/任务调度       |
+------------------+--------------------------+--------------------------+

Redis锁在高并发短任务场景下性能优势明显,如秒杀库存扣减、限流计数。但存在时钟跳跃导致锁失效的理论风险,不适合金融级强一致性场景。etcd锁基于Raft共识,在网络分区时优先保证一致性而非可用性,适合配置管理、分布式任务调度、选主等场景。

无论选择哪种方案,业务代码都应遵循锁持有时间最小化原则:获取锁后只执行必要的临界区操作,避免在锁内进行网络调用或长时间计算。锁TTL应设置为业务最大执行时间的2-3倍,作为安全边界。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/go-yu-yan-fen-bu-shi-suo-shi-xian-shi-zhan-redisredlock/

(0)
小编小编
上一篇 21小时前
下一篇 21小时前

相关推荐