Redis分布式锁常见坑点分析

简介: Redis分布式锁常见坑点分析

image.png

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情

相信在日常开发中,基于 Redis 天然支持分布式锁,大家在线上分布式项目中都使用过 Redis 锁。本文主要针对日常开发中加锁过程中某些异常场景进行讲解与分析。本文讲解示例代码都在 github.com/wayn111/new… 项目 test 目录下 RedisLockTest 类中。

版本声明:

一、任务超时,锁已经过期

这个异常场景说实话发生概率很低,大部分情况下加锁时任务执行都会很快,锁还没到期,任务自己就会删除锁。除非说任务调用第三方接口不稳定导致超时、数据库查询突然变得非常慢就可能会产生这个异常场景。

那怎么处理这个异常嘞?大部分人可能都会回答添加一个定时任务,在定时任务内检测锁快过期时,进行续期操作。OK,这么做好像是可以解决这个异常,那么博主在这里给出自己的见解。

1.1 先说一个暴论:如果料想到有这类异常产生,为什么不在加锁时,就把加锁过期时间设置大一点

不管所续期还是增大加锁时长,都会导致一个问题,其他线程会迟迟获取不到锁,一直被阻塞。那结果都一样,为什么不直接增大加锁时间?

想法是好的,但是实际上,加锁时间的设置是我们主观臆断的,我们无法保证这个加锁代码的执行时间一定在我们的锁过期时间内。作为一个严谨的程序员,我们需要对我们的代码有客观认知,任务执行可能几千上亿万次都是正常,但就是那么一次它执行超时了,可能由于外部依赖、当前运行环境的异常导致。

1.2 直接不设置过期时间,任务不执行完,不释放锁

如果在加锁时就不设置过期时间的话,理论上好像是可以解决这个问题,任务不执行完,锁就不会释放。但是作为程序员,总觉得哪里怪怪的,任务不执行完,锁就不会释放!

仔细想想,我们一般在 try 中进行加锁 在 finally 进行锁释放,这个好像也没毛病哦。但是实际针对一些极端异常场景下,如果任务执行过程中,服务器宕机、程序突然被杀掉、网络断连等都可能造成这个锁释放不了,另一个任务就一直获取不到锁。

这个方案程序正常的情况下,可以满足我们的要求,但是一旦发生异常将导致锁无法释放的后果,也就是说只要我们解决这个锁在异常场景下无法释放的问题,这个方案还是OK的。博主这里直接给出方案:

在不设置过期时间的加锁操作成功时,给一个默认过期时间比如三十秒,同时启动一个定时任务,给我们的锁进行自动续期,每隔 默认过期时间 / 3 秒后执行一次续期操作,发生锁剩余时长小于 默认过期时间 / 2 就重新赋值过期时长为三十秒。这样的话,可以保证锁必须由任务执行完才能释放,当程序异常发生时,仍然能保证锁会在三十秒内释放。

1.3 设置过期时间,任务不执行完,不释放锁

这个方案本质上与方案二的解决方案相同,还是启动定时任务进行续期操作,流程这里不做多余讲述。需要注意的就是加锁指定过期时间会比较符合我们的客观认知。实际上他的底层逻辑跟方案二相同,无非就是定时任务执行间隔,锁剩余时长续期判断要根据过期时间来计算。


综合来看:方案三会最合适,符合我们的客观认知,跟我们之前对 Redis 的使用逻辑较为相近。

二、线程B加锁执行中未释放锁,线程A释放了线程B的锁

说实话我仔细思考了一下这个异常场景,发现这个异常是个伪命题,如果线程 B 正在执行时,线程 A 怎么能获取到线程B的锁!线程 A 获取不到线程 B 的锁,谈何来去释放线程 B 的锁!如果线程 A 能获取到线程 B 的锁那么这个分布式锁的代码一开始就已经错了。

这里回到这个异常场景本身,我们可以给每个线程设置请求ID,加锁成功将请求ID设置为加锁 key 的对应 value,线程释放锁时需要判断当前线程的请求ID与 加锁 key 的对应 value 是否相同,相同则可以释放锁,不相同则不允许释放。

三、线程加锁成功后继续申请加锁

这个场景主要发生在加锁代码内部调用栈过深,比如说加锁成功执行方法 a,在方法 a 内又重复申请了同一把锁,导致线程把自己锁住了,这个业界的主流叫法是叫锁的可重入性。

解决方式有两种,一是修改方法内的加锁逻辑,不要加同一把锁,修改方法 a 内的加锁 key 名称。二是针对加锁逻辑做修改,实现可重入性。

这里简单介绍如何实现可重入性,给每个线程设置请求ID,加锁成功将请求ID设置为加锁 key 的对应 value,针对同一个线程的重复加锁,判断当前线程已存在请求ID的情况下,请求ID直接与加锁 key 的对应 value 相比较,相同则直接返回加锁成功。

四、 代码实践

4.1 加锁自动续期实践

设置锁过期时间为10秒,然后该任务执行15秒,代码如下:

ps: 以下代码都可以在 github.com/wayn111/new… 项目 test 目录下 RedisLockTest 类中找到

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisLockTest {
    @Autowired
    private RedisLock redisLock;
    @Test
    @Test
    public void redisLockNeNewTest() {
        String key = "test";
        try {
            log.info("---申请加锁");
            if (redisLock.lock(key, 10)) {
                // 模拟任务执行15秒
                log.info("---加锁成功");
                Thread.sleep(15000);
                log.info("---执行完毕");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }
}

执行如下:

image.png

可以看出就算任务执行超过过期时间也能通过自动续期让代码正常执行。

4.2 多线程下其他线程无法共同申请到同一把锁实践

启动两个线程,线程 A 先加锁, 线程 B 后枷锁

@Test
public void redisLockReleaseSelfTest() throws IOException {
    new Thread(() -> {
        String key = "test";
        try {
            log.info("---申请加锁");
            if (redisLock.lock(key, 10)) {
                // 模拟任务执行15秒
                log.info("---加锁成功");
                Thread.sleep(15000);
                log.info("---执行完毕");
            } else {
                log.info("---加锁失败");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }, "thread-A").start();
    new Thread(() -> {
        String key = "test";
        try {
            Thread.sleep(100L);
            log.info("---申请加锁");
            if (redisLock.lock(key, 10)) {
                // 模拟任务执行15秒
                log.info("---加锁成功");
                Thread.sleep(15000);
                log.info("---执行完毕");
            } else {
                log.info("---加锁失败");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }, "thread-B").start();
    System.in.read();
}

结果如下:

image.png

可以看到,线程 A 先申请到锁,线程 B 后申请锁,结果线程 B 申请加锁失败。

4.3 锁得可重入性实践

当前线程加锁成功后,在线程执行中继续申请同一把锁,代码如下:

@Test
public void redisLockReEntryTest() {
    String key = "test";
    try {
        log.info("---申请加锁");
        if (redisLock.lock(key, 10)) {
            // 模拟任务执行15秒
            log.info("---加锁第一次成功");
            if (redisLock.lock(key, 10)) {
                // 模拟任务执行15秒
                log.info("---加锁第二次成功");
                Thread.sleep(15000);
                log.info("---加锁第二次执行完毕");
            } else {
                log.info("---加锁第二次失败");
            }
            Thread.sleep(15000);
            log.info("---加锁第一次执行完毕");
        } else {
            log.info("---加锁第一次失败");
        }
    } catch (Exception e) {
        log.error(e.getMessage(), e);
    } finally {
        redisLock.unLock(key);
    }
}

结果如下:

image.png

可以看到,线程在第一次申请加锁成功后,继续申请同一把锁,仍然加锁成功,因此这个操作是实现了可重入性的,虽然没那么细致。

4.4 加解锁逻辑讲解

直接贴出本文最核心 RedisLock 类全部代码:

@Slf4j
@Component
public class RedisLock {
    @Autowired
    public RedisTemplate redisTemplate;
    /**
     * 默认锁过期时间20秒
     */
    public static final Integer DEFAULT_TIME_OUT = 30;
    /**
     * 保存线程id-ThreadLocal
     */
    private ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
    /**
     * 保存定时任务(watch-dog)-ThreadLocal
     */
    private ThreadLocal<ExecutorService> executorServiceThreadLocal = new ThreadLocal<>();
    /**
     * 加锁,不指定过期时间
     *
     * @param key key名称
     * @return boolean
     */
    public boolean lock(String key) {
        return lock(key, null);
    }
    /**
     * 加锁
     *
     * @param key     key名称
     * @param timeout 过期时间
     * @return boolean
     */
    public boolean lock(String key, Integer timeout) {
        Integer timeoutTmp;
        if (timeout == null) {
            timeoutTmp = DEFAULT_TIME_OUT;
        } else {
            timeoutTmp = timeout;
        }
        String nanoId;
        if (stringThreadLocal.get() != null) {
            nanoId = stringThreadLocal.get();
        } else {
            nanoId = IdUtil.nanoId();
            stringThreadLocal.set(nanoId);
        }
        RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaLockScript(), Long.class);
        Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId, timeoutTmp);
        boolean flag = execute != null && execute == 1;
        if (flag) {
            ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
            executorServiceThreadLocal.set(scheduledExecutorService);
            scheduledExecutorService.scheduleWithFixedDelay(() -> {
                RedisScript<Long> renewRedisScript = new DefaultRedisScript<>(buildLuaRenewScript(), Long.class);
                Long result = (Long) redisTemplate.execute(renewRedisScript, Collections.singletonList(key), nanoId, timeoutTmp);
                if (result != null && result == 2) {
                    ThreadUtil.shutdownAndAwaitTermination(scheduledExecutorService);
                }
            }, 0, timeoutTmp / 3, TimeUnit.SECONDS);
        }
        return flag;
    }
    /**
     * 释放锁
     *
     * @param key key名称
     * @return boolean
     */
    public boolean unLock(final String key) {
        String nanoId = stringThreadLocal.get();
        RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaUnLockScript(), Long.class);
        Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId);
        boolean flag = execute != null && execute == 1;
        if (flag) {
            if (executorServiceThreadLocal.get() != null) {
                ThreadUtil.shutdownAndAwaitTermination(executorServiceThreadLocal.get());
            }
        }
        return flag;
    }
    private String buildLuaLockScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local time_out = ARGV[2]
                local result = redis.call('get', key)
                if result == value then
                    return 1;
                end
                local lock_result = redis.call('setnx', key, value)
                if tonumber(lock_result) == 1 then
                    redis.call('expire', key, time_out)
                    return 1;
                else
                    return 0;
                end
                """;
    }
    private String buildLuaUnLockScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local result = redis.call('get', key)
                if result ~= value then
                    return 0;
                else
                    redis.call('del', key)
                end
                return 1;
                """;
    }
    private String buildLuaRenewScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local timeout = ARGV[2]
                local result = redis.call('get', key)
                if result ~= value then
                    return 2;
                end
                local ttl = redis.call('ttl', key)
                if tonumber(ttl) < tonumber(timeout) / 2 then
                    redis.call('expire', key, timeout)
                    return 1;
                else
                    return 0;
                end
                """;
    }
}

加锁逻辑:这里我把加锁逻辑分解成三步展示给大家

  • 加锁前:先判断当前线程是否存在请求ID,不存在则生成,存在就直接使用。
  • 加锁中:通过 lua 脚本执行原子加锁操作, 加锁时先判断当前线程ID与加锁 key 得 value 是否相等,相等则是同一个线程的锁重入,直接返加锁成功。不相等则设置加锁 value 为请求ID以及过期时间。
  • 加锁后:启动一个定时任务,每隔 过期时间 / 3 秒后执行一次续期操作,发现锁剩余时间不足 过期时间 / 2 秒后,通过 lua 脚本进行续期操作。

解锁逻辑:这里我把解锁逻辑分解成两步展示给大家

  • 解锁中:通过 lua 脚本执行解锁操作,先判断加锁 key 的 value 是否与自身请求ID相同,相同则让解锁,不相同则不让解锁。
  • 解锁后:删除续期定时任务。

五、总结

其实本文得核心逻辑有许多都是参考 Redission 客户端而写,对于这些常见得坑点,博主结合自身思考,业界知识总结并自己实现一个分布式锁得工具类。希望大家看了有所收获,对日常业务中 Redis 分布式锁的使用能有更深的理解。



目录
相关文章
|
6月前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
473 2
|
6月前
|
存储 缓存 NoSQL
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
本文解析 Redisson 如何通过 Redis 实现分布式信号量(RSemaphore)与倒数闩(RCountDownLatch),利用 Lua 脚本与原子操作保障分布式环境下的同步控制,帮助开发者更好地理解其原理与应用。
423 6
|
7月前
|
存储 缓存 NoSQL
Redis核心数据结构与分布式锁实现详解
Redis 是高性能键值数据库,支持多种数据结构,如字符串、列表、集合、哈希、有序集合等,广泛用于缓存、消息队列和实时数据处理。本文详解其核心数据结构及分布式锁实现,帮助开发者提升系统性能与并发控制能力。
|
4月前
|
NoSQL 算法 Redis
【Docker】(3)学习Docker中 镜像与容器数据卷、映射关系!手把手带你安装 MySql主从同步 和 Redis三主三从集群!并且进行主从切换与扩容操作,还有分析 哈希分区 等知识点!
Union文件系统(UnionFS)是一种**分层、轻量级并且高性能的文件系统**,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem) Union 文件系统是 Docker 镜像的基础。 镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像。
656 5
|
5月前
|
NoSQL Java 调度
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
分布式锁是分布式系统中用于同步多节点访问共享资源的机制,防止并发操作带来的冲突。本文介绍了基于Spring Boot和Redis实现分布式锁的技术方案,涵盖锁的获取与释放、Redis配置、服务调度及多实例运行等内容,通过Docker Compose搭建环境,验证了锁的有效性与互斥特性。
475 0
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
|
5月前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
11月前
|
数据采集 存储 数据可视化
分布式爬虫框架Scrapy-Redis实战指南
本文介绍如何使用Scrapy-Redis构建分布式爬虫系统,采集携程平台上热门城市的酒店价格与评价信息。通过代理IP、Cookie和User-Agent设置规避反爬策略,实现高效数据抓取。结合价格动态趋势分析,助力酒店业优化市场策略、提升服务质量。技术架构涵盖Scrapy-Redis核心调度、代理中间件及数据解析存储,提供完整的技术路线图与代码示例。
1258 0
分布式爬虫框架Scrapy-Redis实战指南
|
NoSQL Java 中间件
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
本文介绍了从单机锁到分布式锁的演变,重点探讨了使用Redis实现分布式锁的方法。分布式锁用于控制分布式系统中多个实例对共享资源的同步访问,需满足互斥性、可重入性、锁超时防死锁和锁释放正确防误删等特性。文章通过具体示例展示了如何利用Redis的`setnx`命令实现加锁,并分析了简化版分布式锁存在的问题,如锁超时和误删。为了解决这些问题,文中提出了设置锁过期时间和在解锁前验证持有锁的线程身份的优化方案。最后指出,尽管当前设计已解决部分问题,但仍存在进一步优化的空间,将在后续章节继续探讨。
1489 131
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
|
9月前
|
数据采集 存储 NoSQL
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
598 67
|
7月前
|
NoSQL Redis
Lua脚本协助Redis分布式锁实现命令的原子性
利用Lua脚本确保Redis操作的原子性是分布式锁安全性的关键所在,可以大幅减少由于网络分区、客户端故障等导致的锁无法正确释放的情况,从而在分布式系统中保证数据操作的安全性和一致性。在将这些概念应用于生产环境前,建议深入理解Redis事务与Lua脚本的工作原理以及分布式锁的可能问题和解决方案。
287 8