redis分布式锁实现

1、非公平锁

1、底层结构:map

2、实现方式是lua脚本+map

3、实现逻辑:map不存在,创建map并创建key-value,加锁成功,若map存在,判断key是否为请当前线程,是当前线程则value+1,value+1用于支持可重入,如果key不是当前线程,则返回过期时间

2、公平锁

脚本参数说明

  • KEYS1:锁的哈希表(存储持有锁的线程ID及其重入次数)

  • KEYS2:列表,作为 FIFO 等待队列(存储等待线程的ID)

  • KEYS3:有序集合,存储每个等待线程的超时时间戳(score = 超时时间点)

  • ARGV1:锁的租约时间(毫秒)

  • ARGV2:当前线程的唯一标识ID

  • ARGV3:线程的等待时间(毫秒,即每个线程在队列中最多等多久)

  • ARGV4:当前时间戳(毫秒)

Lua 复制代码
"while true do " +
                        "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
                        "if firstThreadId2 == false then " +
                            "break;" +
                        "end;" +

                        "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
                        "if timeout <= tonumber(ARGV[4]) then " +
                            // remove the item from the queue and timeout set
                            // NOTE we do not alter any other timeout
                            "redis.call('zrem', KEYS[3], firstThreadId2);" +
                            "redis.call('lpop', KEYS[2]);" +
                        "else " +
                            "break;" +
                        "end;" +
                    "end;" +

                    // check if the lock can be acquired now
                    "if (redis.call('exists', KEYS[1]) == 0) " +
                        "and ((redis.call('exists', KEYS[2]) == 0) " +
                            "or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

                        // remove this thread from the queue and timeout set
                        "redis.call('lpop', KEYS[2]);" +
                        "redis.call('zrem', KEYS[3], ARGV[2]);" +

                        // decrease timeouts for all waiting in the queue
                        "local keys = redis.call('zrange', KEYS[3], 0, -1);" +
                        "for i = 1, #keys, 1 do " +
                            "redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);" +
                        "end;" +

                        // acquire the lock and set the TTL for the lease
                        "redis.call('hset', KEYS[1], ARGV[2], 1);" +
                        "redis.call('pexpire', KEYS[1], ARGV[1]);" +
                        "return nil;" +
                    "end;" +

                    // check if the lock is already held, and this is a re-entry
                    "if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2],1);" +
                        "redis.call('pexpire', KEYS[1], ARGV[1]);" +
                        "return nil;" +
                    "end;" +

                    // the lock cannot be acquired
                    // check if the thread is already in the queue
                    "local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
                    "if timeout ~= false then " +
                        // the real timeout is the timeout of the prior thread
                        // in the queue, but this is approximately correct, and
                        // avoids having to traverse the queue
                        "return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
                    "end;" +

                    // add the thread to the queue at the end, and set its timeout in the timeout set to the timeout of
                    // the prior thread in the queue (or the timeout of the lock if the queue is empty) plus the
                    // threadWaitTime
                    "local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
                    "local ttl;" +
                    "if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
                        "ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
                    "else " +
                        "ttl = redis.call('pttl', KEYS[1]);" +
                    "end;" +
                    "local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
                    "if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
                        "redis.call('rpush', KEYS[2], ARGV[2]);" +
                    "end;" +
                    "return ttl;"

第1步:清理队列中已超时的等待线程

Lua 复制代码
while true do
    local firstThreadId2 = redis.call('lindex', KEYS[2], 0);
    if firstThreadId2 == false then break; end;
    local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));
    if timeout <= tonumber(ARGV[4]) then
        redis.call('zrem', KEYS[3], firstThreadId2);
        redis.call('lpop', KEYS[2]);
    else
        break;
    end;
end
复制代码
  • 循环检查队列头部 的线程是否已超时(timeout <= 当前时间)。

  • 如果超时,则将其从等待队列(KEYS2)和超时集合(KEYS3)中移除。

  • 一旦遇到头部线程未超时,停止清理。

    👉 作用:防止已超时的线程长期占据队列位置,影响后续线程获取锁。


第2步:尝试获取锁(锁空闲且队列允许)

Lua 复制代码
if (redis.call('exists', KEYS[1]) == 0)
   and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2]))
then
    -- 从队列和超时集合中移除当前线程
    redis.call('lpop', KEYS[2]);
    redis.call('zrem', KEYS[3], ARGV[2]);

    -- 减少队列中剩余所有等待线程的超时时间
    local keys = redis.call('zrange', KEYS[3], 0, -1);
    for i = 1, #keys, 1 do
        redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);
    end;

    -- 获取锁并设置租约时间
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end

条件 :锁不存在(空闲) (等待队列为空 队列头正是当前线程)。

  • 如果当前线程就是队头,将其从队列和超时集合中移除(表明它即将获得锁)。

  • 然后,遍历超时集合中的所有剩余线程,减少它们的超时时间zincrby 负值)。这是因为队列中前面的线程已获得锁,后面的线程应相应减少等待时间(公平锁中的时间补偿)。

  • 最后,通过 hsetpexpire 将锁分配给当前线程,返回 nil 表示获取成功。


第3步:处理锁重入

Lua 复制代码
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end
  • 如果当前线程已经持有该锁(哈希表中存在该线程ID),则重入计数加1,并刷新锁的过期时间。

    👉 支持可重入锁,同一线程可多次获取锁而不会死锁。


第4步:当前线程已在等待队列中

Lua 复制代码
local timeout = redis.call('zscore', KEYS[3], ARGV[2]);
if timeout ~= false then
    return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);
end
  • 如果当前线程已经在等待队列中(通过 zscore 检查),则直接返回剩余等待时间

    👉 公式:超时时间戳 - 等待时间 - 当前时间。客户端可根据此值决定继续等待还是放弃。


第5步:将当前线程加入等待队列

Lua 复制代码
local lastThreadId = redis.call('lindex', KEYS[2], -1);
local ttl;
if lastThreadId ~= false and lastThreadId ~= ARGV[2] then
    ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);
else
    ttl = redis.call('pttl', KEYS[1]);
end
local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);
if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then
    redis.call('rpush', KEYS[2], ARGV[2]);
end
return ttl;
  • 先获取队列最后一个线程ID(即队尾)。

  • 计算当前线程的 基准等待时间 ttl

    • 如果队列非空且最后一个线程不是自己(正常情况),ttl = 最后一个线程的超时时间戳 - 当前时间。

    • 否则(队列空或最后一个线程是自己),ttl = 锁当前的剩余时间(pttl)。

  • 然后计算出当前线程的超时时间戳:ttl + 等待时间 + 当前时间

  • 将当前线程加入超时集合(zadd)和队列尾部(rpush)。

  • 最后返回 ttl,告诉客户端需要等待多久(毫秒)。


总结

返回值 含义
nil 成功获取锁(首次获取或重入)
正数(毫秒) 无法获取锁,返回需要等待的时间
负数(或0) 等待超时或立即失败(由调用方解释)

该脚本实现了公平、可重入、带超时的分布式锁,所有操作在 Redis 中原子执行,避免了竞态条件。

相关推荐
珠***格2 小时前
四可装置核心技术:高精度采集、边缘计算、协议自适应
大数据·人工智能·分布式·能源·边缘计算
白菜欣2 小时前
【MySQL】MySQL数据的增删改查(入门版)
数据库·mysql
unicorn312 小时前
r-pan
数据库
AI人工智能+电脑小能手3 小时前
【大白话说Java面试题 第97题】【Mysql篇】第27题:说说分库与分表的设计?
java·开发语言·数据库·分布式·mysql·算法
真实的菜3 小时前
Redis 从入门到精通(一):重新认识 Redis —— 不只是缓存
redis
飞函安全3 小时前
飞函Webhook能力如何帮助企业把监控告警、设备异常第一时间推到对应群组
网络·数据库·安全·私有化im
map1e_zjc3 小时前
Redis入门笔记(2)
数据库·redis·笔记
开发者联盟league3 小时前
container登录失败解决方法。http: server gave HTTP response to HTTPS client
数据库·http·https
有想法的py工程师3 小时前
PostgreSQL分区表父索引INVALID排查实战:缺少某个分区索引导致父索引INVALID
数据库·postgresql