使用Redis实现简单的分布式锁

redis的set命令可以将键key设置为指定的值,如果 key 已经保存了一个值,那么这个操作会直接覆盖原来的值,并且忽略原始类型。当set命令执行成功之后,之前设置的过期时间都将失效。

从2.6.12版本开始,redis为SET命令增加了一系列选项:

EX seconds – 设置键key的过期时间,单位时秒
PX milliseconds – 设置键key的过期时间,单位时毫秒
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值

获取键为foo的锁,可以使用以下操作:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果客户端获得锁,SETNX返回1,lock.foo键的值设置为一个时间戳。具体业务操作完成后可以使用DEL lock.foo去释放该锁。如果SETNX返回0,那么该键已经被其他的客户端锁定。这时可以立刻返回给调用者加锁失败,或者尝试重新获取该锁,直到成功或者超时。

如果客户端出现故障,崩溃或者其他情况无法释放该锁时,可以使用该锁键值中设置的那个Unix时间戳来判断该锁是否过期,如果当前时间大于等于该时间戳,说明该锁过期了。

但是当发生以下这种情况发生时,需要注意一个问题:

  • C3 加锁成功持有锁,并且 C3 崩溃。
  • C1 和 C2 尝试获取锁,执行SETNX都返回0。获取锁失败。检查时间戳发现锁过期。
  • C1 发送DEL lock.foo
  • C1 发送SETNX lock.foo命令并且成功返回
  • C2 发送DEL lock.foo
  • C2 发送SETNX lock.foo命令并且成功返回

错误:由于竞态条件 C2 删除了 C1 加的锁导致 C1 和 C2 都获取到了锁
所以当多个客户端察觉到一个过期的锁并且都尝试去释放它时,不能直接调用DEL来删除该锁后执行SETNX。如何避免这种情况呢?

  • C3 加锁成功持有锁,并且 C3 崩溃。
  • C4 尝试获取锁,执行SETNX返回0,获取锁失败。检查时间戳发现锁过期
  • C4 尝试执行以下的操作:

GETSET lock.foo <current Unix timestamp + lock timeout + 1>

     GETSET 命令用于设置指定 key 的值,并返回 key 的旧值,当 key 没有旧值时,即 key 不存         在 时,返回 nil 。

  • C4 检查已经过期的旧值是否仍然存储在lock.foo中。如果是的话,C4 获得锁。

如果另一个客户端C5 ,比 C4 更快的通过GETSET操作获取到锁,那么 C4 执行GETSET操作会被返回一个未过期的时间戳,立即返回获取锁失败或者重试。

以上具体实现为:

    //加锁过期时间,毫秒
    public static final int LOCK_EXPIRE = 600; // ms

    private RedisTemplate redisTemplate;

    /**
     * 加锁
     * @param lock
     * @return
     */
    public boolean lock(String lock) {
        Boolean execute = (Boolean) redisTemplate.execute((RedisCallback) connection -> {
            //获取时间毫秒值
            long expireAt = System.currentTimeMillis() + LOCK_EXPIRE + 1;
            //获取锁
            Boolean acquire = connection.setNX(lock.getBytes(), String.valueOf(expireAt).getBytes());
            if (Boolean.TRUE.equals(acquire)) {
                return true;
            } else {
                byte[] bytes = connection.get(lock.getBytes());
                //非空判断
                if (Objects.nonNull(bytes) && bytes.length > 0) {
                    long expireTime = Long.parseLong(new String(bytes));
                    // 如果锁已经过期
                    if (expireTime < System.currentTimeMillis()) {
                        // 重新加锁,防止死锁
                        byte[] set = connection.getSet(lock.getBytes(), String.valueOf(System.currentTimeMillis() + LOCK_EXPIRE + 1).getBytes());

                        return Optional.ofNullable(set).map(String::new)
                                .map(Long::parseLong)
                                .map(m -> m < System.currentTimeMillis())
                                .orElse(false);
                    }
                }
            }
            return false;
        });
        return Boolean.TRUE.equals(execute);
    }

    /**
     * 解锁锁
     */
    public void release(String key) {
        redisTemplate.delete(key);
    }

此方法可以实现一个简单的分布式锁,但还是会有几个小问题:

  1. 键值设置为过期时间,这就要求各个客户端之间要有严格的时钟同步
  2. 锁未标志是那个客户端设置的,解锁时不能保证和加锁的是同一个客户端。不是自己加锁的,也会被删除。再极端情况下可能会导致以下问题:  
  • C3 加锁成功持有锁,并且 C3 阻塞导致锁过期
  • C4 尝试获取锁,发现锁过期使用GETSET命令获取锁,这时 C3、C4 都持有锁
  • C3 恢复执行完业务释放锁
  • C5 尝试获取锁由于锁已释放,加锁成功,此时 C4 和 C5 都持有锁
  •                                   .....

要解决上述问题可以使用以下方法: 

SET resource-name anystring NX PX max-lock-time

    加锁时将键值设置为唯一id,同时设置过期时间,键key过期后会被redis自动删除。解锁时使用Lua脚本:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

通过键值的唯一id判断锁是否是自己加的,再删除并且同时保证了原子性:

    //加锁过期时间,毫秒
    public static final int LOCK_EXPIRE = 600; // ms

    private RedisTemplate redisTemplate;

    /**
     * 加锁
     *
     * @param key      键名
     * @param lockId   锁id,键值
     * @param lockWait 加锁等待时间,毫秒
     * @return
     */
    public boolean lock(String key, String lockId, long lockWait) {
        try {
            long waitEnd = System.currentTimeMillis() + lockWait;
            while (System.currentTimeMillis() < waitEnd) {
                Boolean isLocked = (Boolean) redisTemplate.execute((RedisCallback<Boolean>) connection -> {
                    Object execute = connection.execute("set",
                            redisTemplate.getKeySerializer().serialize(key),
                            redisTemplate.getValueSerializer().serialize(lockId),
                            "NX".getBytes(StandardCharsets.UTF_8),
                            "PX".getBytes(StandardCharsets.UTF_8),
                            String.valueOf(LOCK_EXPIRE).getBytes(StandardCharsets.UTF_8));
                    return Optional.ofNullable(execute).map(Object::toString).map("OK"::equals).orElse(false);
                });
                if (Boolean.TRUE.equals(isLocked)) {
                    return true;
                }
                TimeUnit.MICROSECONDS.sleep(10L);
            }
        } catch (Exception e) {
            log.error("lock error", e);
            throw new BizException(e.getMessage());
        }
        return false;
    }

    /**
     * 解锁
     *
     * @param key    键名
     * @param lockId 锁id,键值
     * @return
     */
    public boolean release(String key, String lockId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object execute = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), lockId);
        return Optional.ofNullable(execute).map(m -> (long) m).map(m -> m == 1).orElse(false);
    }

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值