Redis分布式锁实现原理与Redlock算法在Go语言中的工程实践

Redis单实例分布式锁SET NX实现

分布式锁是微服务架构中保证数据一致性的关键组件。在多实例部署环境下,本地互斥锁无法跨进程协调,需要借助Redis等外部存储实现跨实例的锁机制。Redis分布式锁因其高性能、低延迟的特性,在高并发设计和分布式事务场景中被广泛采用。

最基础的Redis分布式锁通过SET命令的NX(Not eXists)选项实现原子性加锁:

package distributedlock

import (
    "context"
    "time"
    "github.com/redis/go-redis/v9"
)

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

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

func (l *RedisLock) TryLock(ctx context.Context) (bool, error) {
    ok, err := l.client.SetNX(ctx, l.key, l.value, l.ttl).Result()
    if err != nil {
        return false, err
    }
    return ok, nil
}

func (l *RedisLock) Unlock(ctx context.Context) error {
    script := redis.NewScript(`
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `)
    _, err := script.Run(ctx, l.client, []string{l.key}, l.value).Result()
    return err
}

加锁时使用SET key value NX PX ttl一条命令完成设置键值、条件判断和过期时间三个操作,保证原子性。解锁时必须使用Lua脚本先比较value再删除,不能直接DEL。原因在于锁可能已经过期被其他实例获取,直接DEL会误删别人的锁。

value必须是全局唯一标识(如UUID),用于解锁时验证锁的持有者。如果所有实例使用相同value,就无法区分锁属于谁,导致误删。

锁超时与续期机制设计

设置TTL是防止持有锁的实例崩溃导致死锁的必要措施。但业务执行时间超过TTL时,锁会自动释放,其他实例获取锁后可能导致数据不一致。续期机制通过后台goroutine定期延长锁的TTL解决此问题:

func (l *RedisLock) LockWithRenewal(ctx context.Context) error {
    ok, err := l.TryLock(ctx)
    if err != nil {
        return err
    }
    if !ok {
        return ErrLockConflict
    }

    renewalCtx, cancel := context.WithCancel(ctx)
    go l.renewLoop(renewalCtx)

    l.cancelFunc = cancel
    return nil
}

func (l *RedisLock) renewLoop(ctx context.Context) {
    ticker := time.NewTicker(l.ttl / 3)
    defer ticker.Stop()

    script := redis.NewScript(`
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("pexpire", KEYS[1], ARGV[2])
        else
            return 0
        end
    `)

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            _, err := script.Run(ctx, l.client,
                []string{l.key},
                l.value,
                int64(l.ttl.Milliseconds()),
            ).Result()
            if err != nil {
                return
            }
        }
    }
}

续期间隔设为TTL的1/3,在锁过期前有两次续期机会。续期脚本同样是原子操作,先验证value再延长过期时间。Unlock时先取消续期goroutine再释放锁,避免goroutine泄漏。这种续期机制类似于Redisson的WatchDog机制。

Redlock算法原理与多节点容错

单实例Redis分布式锁存在单点故障风险。当Redis主节点宕机时,若从节点尚未同步锁数据就提升为主,会导致锁丢失。Redlock算法由Redis作者Antirez提出,通过在多个独立Redis实例上加锁解决单点问题。

Redlock的核心流程:客户端获取当前时间T1,依次向N个(通常5个)独立Redis实例发送SET NX加锁请求,统计成功加锁的实例数。如果超过半数(N/2+1)成功且总耗时小于锁的TTL,则认为加锁成功。实际锁的有效时间等于TTL减去获取锁的耗时。

type RedLock struct {
    clients []*redis.Client
    quorum  int
    retryCount int
    retryDelay time.Duration
}

func (rl *RedLock) Lock(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
    for attempt := 0; attempt < rl.retryCount; attempt++ {
        success := 0
        start := time.Now()

        for _, client := range rl.clients {
            ok, err := client.SetNX(ctx, key, value, ttl).Result()
            if err == nil && ok {
                success++
            }
        }

        elapsed := time.Since(start)
        drift := ttl / 10

        if success >= rl.quorum && elapsed < (ttl - drift) {
            return true, nil
        }

        rl.unlockAll(ctx, key, value)

        if attempt < rl.retryCount-1 {
            time.Sleep(rl.retryDelay)
        }
    }
    return false, nil
}

Redlock的争议点在于对时钟同步的依赖。如果系统发生长时间的GC暂停或时钟跳变,客户端可能持有已过期的锁。实际工程中,Redlock适用于对正确性要求较高但可容忍极小概率错误的场景,金融级场景建议使用基于共识算法的锁服务(如etcd、ZooKeeper)。

分布式锁的羊群效应与可重入设计

当大量客户端同时竞争同一把锁时,锁释放瞬间所有等待者同时发起SET NX请求,造成Redis负载突增。引入随机退避时间缓解羊群效应:加锁失败后等待一个随机时间再重试,避免所有客户端同步竞争。

可重入锁允许同一持有者多次获取锁,适用于递归调用场景。实现方式是在Redis中维护一个Hash结构,key存储锁名,field存储持有者标识,value存储重入计数:

var reentrantLockScript = redis.NewScript(`
    local key = KEYS[1]
    local holder = ARGV[1]
    local ttl = tonumber(ARGV[2])

    if redis.call("exists", key) == 0 then
        redis.call("hset", key, holder, 1)
        redis.call("pexpire", key, ttl)
        return 1
    end

    if redis.call("hexists", key, holder) == 1 then
        redis.call("hincrby", key, holder, 1)
        redis.call("pexpire", key, ttl)
        return 1
    end

    return 0
`)

var reentrantUnlockScript = redis.NewScript(`
    local key = KEYS[1]
    local holder = ARGV[1]

    if redis.call("hexists", key, holder) == 0 then
        return -1
    end

    local count = redis.call("hincrby", key, holder, -1)
    if count <= 0 then
        redis.call("del", key)
        return 0
    end
    return count
`)

可重入锁的解锁操作递减计数,当计数归零时删除锁。可重入锁增加了实现复杂度,在服务治理中需要根据业务场景权衡是否需要可重入特性。大多数场景下,非重入锁配合合理的锁粒度设计已能满足需求。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-fen-bu-shi-suo-shi-xian-yuan-li-yu-redlock-suan-fa/

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

相关推荐