Redis 作为分布式锁的实现细节:`SET NX PX` 与 Lua 脚本

好的,各位程序猿们,攻城狮们,以及未来将要加入我们行列的准码农们,晚上好!欢迎来到今晚的“Redis分布式锁:一场关于原子性的浪漫邂逅”讲座!

今天咱们不谈枯燥的理论,只聊实战,用最接地气的方式,揭开Redis分布式锁的神秘面纱,尤其是SET NX PX命令和Lua脚本这两大利器的爱恨情仇。准备好了吗?让我们开始这场代码与艺术的碰撞之旅吧!🚀

第一幕:锁的江湖,风起云涌

在单机时代,线程锁就能搞定一切。那时候的日子,简单而美好,就像初恋,甜甜蜜蜜,毫无压力。 可是,随着互联网的飞速发展,我们的应用也变得越来越庞大,单机已经无法满足日益增长的业务需求。于是,我们不得不走向分布式架构。

分布式架构就像一个复杂的多人游戏,不同的服务器就像不同的玩家,都需要争夺共享资源。如果没有一个统一的规则,那就会乱成一锅粥,数据错乱,业务崩溃,简直就是一场灾难!

这个时候,分布式锁就应运而生,它就像一个公正的裁判,确保同一时刻只有一个玩家能够访问共享资源,从而保证数据的一致性和正确性。

第二幕:Redis登场,自带光环

在众多分布式锁的实现方案中,Redis凭借其高性能、高可用、易于部署等优点,成为了众多开发者的首选。它就像一位身披战甲的骑士,英勇地守护着我们的数据安全。

为什么Redis如此受欢迎呢?原因很简单:

  • 高性能: Redis基于内存操作,速度极快,能够快速获取和释放锁,极大地降低了锁的竞争开销。
  • 原子性: Redis的单线程架构保证了命令执行的原子性,避免了并发问题。
  • 简单易用: Redis提供了简单易懂的API,方便开发者快速实现分布式锁。

第三幕:SET NX PX:简单粗暴的“抢锁”姿势

SET NX PX 命令是Redis 2.6.12版本之后新增的,它提供了一种更加简洁高效的原子性操作,用于设置键值对,并同时设置过期时间。 它的语法如下:

SET key value [NX|XX] [EX|PX] [time]

其中:

  • key:要设置的键名。
  • value:要设置的键值。
  • NX:仅当键不存在时才设置键值。
  • XX:仅当键存在时才设置键值。
  • EX:设置键的过期时间,单位为秒。
  • PX:设置键的过期时间,单位为毫秒。
  • time:过期时间。

我们可以利用 SET NX PX 命令来实现一个简单的分布式锁:

  1. 尝试获取锁: 使用 SET key value NX PX timeout 命令尝试设置一个键,如果设置成功,则表示获取锁成功;否则,表示获取锁失败。
  2. 释放锁: 使用 DEL key 命令删除该键,释放锁。

举个例子,假设我们要保护的共享资源是商品库存,我们可以使用如下代码:

// 尝试获取锁,key为商品ID,value为随机生成的UUID,防止误删锁,过期时间为10秒
String lockKey = "product_stock_" + productId;
String lockValue = UUID.randomUUID().toString();
String result = jedis.set(lockKey, lockValue, "NX", "PX", 10000);

if ("OK".equals(result)) {
    // 获取锁成功,执行业务逻辑
    try {
        // 减少库存
        decreaseStock(productId);
    } finally {
        // 释放锁
        if (lockValue.equals(jedis.get(lockKey))) { // 再次确认是自己加的锁,防止误删
            jedis.del(lockKey);
        }
    }
} else {
    // 获取锁失败,稍后重试
    Thread.sleep(100);
    // 递归调用,或者使用循环重试
    acquireLock();
}

这个代码看起来很简单,对不对?但是,它也存在一些问题:

  • 删除锁的逻辑不够严谨: 我们需要先判断锁的值是否是自己设置的,才能删除锁,防止误删其他客户端的锁。
  • 重试机制不够优雅: 获取锁失败后,我们只是简单地sleep一段时间后重试,这种方式效率较低,并且可能造成线程阻塞。
  • 缺少自动续租机制: 如果业务逻辑执行时间超过了锁的过期时间,锁会自动释放,导致其他客户端获取到锁,造成并发问题。

总的来说,SET NX PX 命令就像一个愣头青,简单粗暴,虽然能够解决一些问题,但是也存在不少缺陷。

第四幕:Lua脚本:优雅而强大的“锁匠”

为了解决 SET NX PX 命令的缺陷,我们可以使用Lua脚本来实现更加完善的分布式锁。Lua脚本是一种轻量级的脚本语言,它可以嵌入到其他应用程序中使用。Redis支持执行Lua脚本,并且保证脚本执行的原子性。

使用Lua脚本,我们可以将获取锁、判断锁的值、删除锁等操作放在一个原子操作中执行,从而避免并发问题。

下面是一个使用Lua脚本实现分布式锁的示例:

-- 获取锁的脚本
local key = KEYS[1]
local value = ARGV[1]
local expireTime = ARGV[2]

if redis.call("SETNX", key, value) == 1 then
    redis.call("PEXPIRE", key, expireTime)
    return 1
else
    return 0
end

这个脚本首先尝试使用 SETNX 命令设置键值对,如果设置成功,则使用 PEXPIRE 命令设置过期时间,并返回 1;否则,返回 0。

释放锁的脚本如下:

-- 释放锁的脚本
local key = KEYS[1]
local value = ARGV[1]

if redis.call("GET", key) == value then
    return redis.call("DEL", key)
else
    return 0
end

这个脚本首先判断锁的值是否是自己设置的,如果是,则使用 DEL 命令删除该键,并返回 1;否则,返回 0。

在Java代码中,我们可以这样使用Lua脚本:

// 获取锁的脚本
String acquireLockScript = "local key = KEYS[1]n" +
        "local value = ARGV[1]n" +
        "local expireTime = ARGV[2]n" +
        "n" +
        "if redis.call("SETNX", key, value) == 1 thenn" +
        "    redis.call("PEXPIRE", key, expireTime)n" +
        "    return 1n" +
        "elsen" +
        "    return 0n" +
        "end";

// 释放锁的脚本
String releaseLockScript = "local key = KEYS[1]n" +
        "local value = ARGV[1]n" +
        "n" +
        "if redis.call("GET", key) == value thenn" +
        "    return redis.call("DEL", key)n" +
        "elsen" +
        "    return 0n" +
        "end";

// 加载脚本
String acquireLockSha = jedis.scriptLoad(acquireLockScript);
String releaseLockSha = jedis.scriptLoad(releaseLockScript);

// 尝试获取锁
Object result = jedis.evalsha(acquireLockSha, 1, lockKey, lockValue, String.valueOf(10000));

if (result.equals(1L)) {
    // 获取锁成功,执行业务逻辑
    try {
        // 减少库存
        decreaseStock(productId);
    } finally {
        // 释放锁
        jedis.evalsha(releaseLockSha, 1, lockKey, lockValue);
    }
} else {
    // 获取锁失败,稍后重试
    Thread.sleep(100);
    // 递归调用,或者使用循环重试
    acquireLock();
}

使用Lua脚本,我们可以将获取锁和释放锁的逻辑封装在一个原子操作中,避免了并发问题。同时,Lua脚本还提供了更强的灵活性,我们可以根据实际需求定制更加复杂的锁逻辑。

第五幕:自动续租:让锁更持久

前面我们提到,如果业务逻辑执行时间超过了锁的过期时间,锁会自动释放,导致其他客户端获取到锁,造成并发问题。为了解决这个问题,我们可以引入自动续租机制。

自动续租的原理很简单:在锁即将过期的时候,自动延长锁的过期时间,从而保证锁的持久性。

实现自动续租,我们可以使用一个后台线程,定时检查锁的过期时间,如果锁即将过期,则自动延长锁的过期时间。

下面是一个简单的自动续租的示例:

private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// 启动自动续租
public void startAutoRenew() {
    scheduler.scheduleAtFixedRate(() -> {
        // 判断锁是否存在,并且锁的值是自己设置的
        if (jedis.exists(lockKey) && lockValue.equals(jedis.get(lockKey))) {
            // 延长锁的过期时间
            jedis.pexpire(lockKey, 10000);
            System.out.println("续租成功!");
        } else {
            // 锁已经被释放,停止续租
            System.out.println("锁已释放,停止续租!");
            scheduler.shutdown();
        }
    }, 5, 5, TimeUnit.SECONDS); // 每隔5秒检查一次,如果锁还有5秒过期,则续租
}

// 停止自动续租
public void stopAutoRenew() {
    scheduler.shutdown();
}

这个代码使用 ScheduledExecutorService 定时检查锁的过期时间,如果锁即将过期,则使用 pexpire 命令延长锁的过期时间。

第六幕:Lua脚本 + 自动续租:终极解决方案

为了实现更加完善的分布式锁,我们可以将Lua脚本和自动续租机制结合起来。

我们可以使用Lua脚本来实现自动续租的逻辑,从而避免并发问题。

下面是一个使用Lua脚本实现自动续租的示例:

-- 续租的脚本
local key = KEYS[1]
local value = ARGV[1]
local expireTime = ARGV[2]

if redis.call("GET", key) == value then
    redis.call("PEXPIRE", key, expireTime)
    return 1
else
    return 0
end

这个脚本首先判断锁的值是否是自己设置的,如果是,则使用 PEXPIRE 命令延长锁的过期时间,并返回 1;否则,返回 0。

在Java代码中,我们可以这样使用Lua脚本:

// 续租的脚本
String renewLockScript = "local key = KEYS[1]n" +
        "local value = ARGV[1]n" +
        "local expireTime = ARGV[2]n" +
        "n" +
        "if redis.call("GET", key) == value thenn" +
        "    redis.call("PEXPIRE", key, expireTime)n" +
        "    return 1n" +
        "elsen" +
        "    return 0n" +
        "end";

// 加载脚本
String renewLockSha = jedis.scriptLoad(renewLockScript);

// 启动自动续租
public void startAutoRenew() {
    scheduler.scheduleAtFixedRate(() -> {
        // 续租
        Object result = jedis.evalsha(renewLockSha, 1, lockKey, lockValue, String.valueOf(10000));
        if (result.equals(1L)) {
            System.out.println("续租成功!");
        } else {
            // 锁已经被释放,停止续租
            System.out.println("锁已释放,停止续租!");
            scheduler.shutdown();
        }
    }, 5, 5, TimeUnit.SECONDS); // 每隔5秒检查一次,如果锁还有5秒过期,则续租
}

第七幕:总结:选择最适合你的“锁”

好了,经过一番讲解,相信大家对Redis分布式锁的实现细节已经有了更深入的了解。

特性 SET NX PX Lua脚本 Lua脚本 + 自动续租
简单性
原子性 部分
可靠性
灵活性
适用场景 简单业务,对可靠性要求不高 中等复杂业务 复杂业务,对可靠性要求高

总的来说,SET NX PX 命令简单易用,适用于简单的业务场景;Lua脚本提供了更强的原子性和灵活性,适用于中等复杂的业务场景;Lua脚本 + 自动续租机制提供了最高的可靠性,适用于复杂的业务场景。

选择哪种方案,需要根据实际业务需求进行权衡。记住,没有最好的方案,只有最适合你的方案!

最后,希望大家在实际工作中能够灵活运用这些技巧,写出更加健壮、可靠的分布式应用! 感谢大家的聆听! 👏

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注