一个Redis分布式锁的实现引发的思考

数据库 Redis
释放锁时,会通过 UUID 去判断这个锁的值,避免释放其他线程加的锁,但是没有考虑到这个 get 和 del 是两个操作,还是会有意外,比如 releaseLock 时,执行完 get ,判断这个 uuid 是自己的,准备删除,但此时 锁过期 了,其他线程刚好加锁成功,结果又被你删除了。

最近看了一个老项目(2018年的),发现其中用 Redis 来实现分布式锁🔒。

代码如下 👇

// jedis 

public String lock(String lockName, long acquireTimeout) {
    return lockWithTimeout(lockName, acquireTimeout, DEFAULT_EXPIRE);
}

public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {

        RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
        RedisConnection redisConnection = connectionFactory.getConnection();
        /** 随机生成一个value */
        String identifier = UUID.randomUUID().toString();
        String lockKey = LOCK_PREFIX + lockName;
        int lockExpire = (int) (timeout / 1000);

        long end = System.currentTimeMillis() + acquireTimeout;    /** 获取锁的超时时间,超过这个时间则放弃获取锁 */
        while (System.currentTimeMillis() < end) {
            if (redisConnection.setNX(lockKey.getBytes(), identifier.getBytes())) {
                redisConnection.expire(lockKey.getBytes(), lockExpire);
                /** 获取锁成功,返回标识锁的value值,用于释放锁确认 */
                RedisConnectionUtils.releaseConnection(redisConnection, connectionFactory);
                return identifier;
            }
            /** 返回-1代表key没有设置超时时间,为key设置一个超时时间 */
            if (redisConnection.ttl(lockKey.getBytes()) == -1) {
                redisConnection.expire(lockKey.getBytes(), lockExpire);
            }
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                log.warn("获取分布式锁:线程中断!");
                Thread.currentThread().interrupt();
            }
        }
        RedisConnectionUtils.releaseConnection(redisConnection, connectionFactory);
        return null;
    }

public boolean releaseLock(String lockName, String identifier) {

    if (StringUtils.isEmpty(identifier)) return false;

    RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
    RedisConnection redisConnection = connectionFactory.getConnection();
    String lockKey = LOCK_PREFIX + lockName;
    boolean releaseFlag = false;
    while (true) {
        try {
            byte[] valueBytes = redisConnection.get(lockKey.getBytes());

            /**  value为空表示锁不存在或已经被释放*/
            if (valueBytes == null) {
                releaseFlag = false;
                break;
            }

            /** 通过前面返回的value值判断是不是该锁,若是该锁,则删除,释放锁 */
            String identifierValue = new String(valueBytes);
            if (identifier.equals(identifierValue)) {
                redisConnection.del(lockKey.getBytes());
                releaseFlag = true;
            }
            break;
        } catch (Exception e) {
            log.warn("释放锁异常", e);
        }
    }
    RedisConnectionUtils.releaseConnection(redisConnection, connectionFactory);
    return releaseFlag;
}


public void lockTest(String lockName, Long acquireTimeout, CouponSummary couponSummary)  {

    String lockIdentify = redisLock.lock(lockName,acquireTimeout);
    if (StringUtils.isNotEmpty(lockIdentify)){
        // 业务代码
        redisLock.releaseLock(lockName, lockIdentify);
    }
    else{
        System.out.println("get lock failed.");
    }

}

分析

看完之后,有这几点感悟

  1. setNX 和 expire 两个操作是分开的,有一定的风险(忘了释放锁,expire 失败)
  2. 加锁时,除了 setNX ,还会去 ttl ,防止死锁的发生。
  3. 释放锁时,会通过 UUID 去判断这个锁的值,避免释放其他线程加的锁,但是没有考虑到这个 get 和 del 是两个操作,还是会有意外,比如 releaseLock 时,执行完 get ,判断这个 uuid 是自己的,准备删除,但此时 锁过期 了,其他线程刚好加锁成功,结果又被你删除了。
  4. 释放锁时没有在 finally 块中执行
  5. 获取不到锁时,尝试自旋等待锁

再结合 redisson 框架来看的话,就会发现

  1. 少了 自动续期 的功能,如果业务执行时间较长,锁过期释放掉了,就可能出现并发问题。
  2. 少了 可重入锁 的功能,可以预见获取锁的线程,再次去加锁也会失败。
  3. 少了 lua脚本 ,lua 脚本能保证原子性操作,减少这个网络开销。

再把视角移到 Redis 服务器来,就会发现 单点问题 的存在,此时分布式锁就无法使用了。

这个问题可以通过 主从,哨兵,集群 模式解决,但是又有了一个 故障转移问题 。

先简要介绍下这几个模式

  1. Redis 主从复制模式:

一主多从,主节点负责写,并同步到从节点。

从节点负责备份数据,处理读操作,提供读负载均衡和故障切换。

  1. Redis 哨兵模式:
  • 主从基础上增加了哨兵节点(Sentinel),一个独立进程,去监控所有节点,当主节点宕机时,会从 slave 中选举出新的主节点,并通知其他从节点更新配置
  • 哨兵节点负责执行故障转移、选举新的主节点等操作
  1. Redis 集群模式:
  • 多个主从组成,由 master 去瓜分 16384 个 slot, 将数据分片存储在多个节点上。
  • 节点间通过 Gossip 协议进行广播通信,比如 新节点的加入,主从变更等

回到 分布式锁 这个话题,通过主从切换,可以实现故障转移。但是当加锁成功时,master 挂了,此时还没同步锁信息到这个 slave 上,那这个分布式锁也是失效了。

网上的方案是通过  Redlock(红锁) 来解决。

Redlock 的大致意思就是给多个节点加锁,超过半数成功的话,就认为加锁成功。

redisson 的红锁用法👇

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

我更偏向于解决这个 主从复制延迟 的问题,比如

  • 升级硬件,更好的 CPU,带宽
  • 避免从节点阻塞,比如操作一些 大Key
  • 调大 repl_backlog_size 参数,避免全量同步

当然,具体问题具体分析,可以根据业务准备补偿措施,但也要避免这个过度设计。

红锁争论

在查阅资料时,看到了这么一个事情 👇

《数据密集型应用系统设计》的作者 Martin 去反驳这个 Redlock ,并用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入 GC(时间比较久)
  3. 所有 Redis 节点上的锁都过期了
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到了锁,发生「冲突」

图片图片

还有 时钟 漂移的问题

这里我就不过多 CV 了,可以看看原文👇

相关文章

《一文讲透Redis分布式锁安全问题》:https://cloud.tencent.com/developer/article/2332108

《How to do distributed locking》https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

NPC 异常场景

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停(GC)
  • C:Clock Drift,时钟漂移
责任编辑:武晓燕 来源: Java4ye
相关推荐

2020-07-30 09:35:09

Redis分布式锁数据库

2021-11-01 12:25:56

Redis分布式

2022-04-14 07:56:30

公平锁Java线程

2024-02-19 00:00:00

Redis分布式

2019-06-19 15:40:06

分布式锁RedisJava

2024-07-15 08:25:07

2022-09-22 13:28:34

Redis分布式锁

2022-09-29 08:28:57

SpringRedis分布式

2023-08-21 19:10:34

Redis分布式

2022-01-06 10:58:07

Redis数据分布式锁

2023-03-01 08:07:51

2020-05-12 14:03:51

RedisZooKeeper分布式锁

2019-02-26 09:51:52

分布式锁RedisZookeeper

2022-12-18 20:07:55

Redis分布式

2022-11-11 08:19:03

redis分布式

2023-10-11 09:37:54

Redis分布式系统

2024-10-07 10:07:31

2019-12-25 14:35:33

分布式架构系统

2023-09-21 22:22:51

开发分布式锁

2024-11-28 15:11:28

点赞
收藏

51CTO技术栈公众号