字节二面:Spring Boot Redis 可重入分布式锁实现原理?

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 字节二面:Spring Boot Redis 可重入分布式锁实现原理?

我是码哥,可以叫我靓仔。

这是我们最常用的分布式锁方案,今天码哥给你来一个进阶。

Chaya:「码哥,上次的分布式锁版本虽然好,但是不支持可重入获取锁,还差一点点意思。」

Chaya 别急,今日码哥给你带来一个高性能可重入 Redis 分布式锁解决方案,直捣黄龙,一笑破苍穹。

什么是可重入锁

当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

public synchronized void a() {
    b();
}
public synchronized void b() {
    // doWork
}

假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。

锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~

可重入锁实现原理

Chaya:「Redis String 数据结构无法满足可重入锁,key 表示锁定的资源,value 是客户端唯一标识,可重入没地方放了。」

我们可以使用 Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 存储客户端唯一标识,fieldKey 的 value 则保存加锁的次数。

图 5-26

加锁原理

可重入锁加锁的过程中有以下场景需要考虑。

  1. 锁已经被 A 客户端获取,客户端 B 获取锁失败。
  2. 锁已经被客户端 A 获取,客户端 A 多次执行获取锁操作。
  3. 锁没有被其他客户端获取,那么此刻获取锁的客户端可以获取成功。

按照之前的经验,多个操作的原子性可以用 lua 脚本实现。可重入锁加锁 lua 脚本如下。

if ((redis.call('exists', KEYS[1]) == 0) or
   (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;
return redis.call('pttl', KEYS[1]);
  • KEYS[1]是 lockKey 表示获取的锁资源,比如 lock:168
  • ARGV[1] 表示表示锁的有效时间(单位毫秒)。
  • ARGV[2] 表示客户端唯一标识,在 Redisson 中使用 UUID:ThreadID

下面我来接下是这段脚本的逻辑。

  1. 锁不存在或者锁存在且值与客户端唯一标识匹配,则执行 'hincrby'pexpire指令,接着 return nil。表示的含义就是锁不存在就设置锁并设置锁重入计数值为 1,设置过期时间;锁存在且唯一标识匹配表明当前加锁请求是锁重入请求,锁从如计数 +1,重新锁超时时间。
  • redis.call('exists', KEYS[1]) == 0判断锁是否存在,0 表示不存在。
  • redis.call('hexists', KEYS[1], ARGV[2]) == 1)锁存在的话,判断 hash 结构中 fieldKey 与客户端的唯一标识是否相等。相等表示当前加锁请求是锁重入。
  • redis.call('hincrby', KEYS[1], ARGV[2], 1)将存储在 hash 结构的 ARGV[2] 的值 +1,不存在则支持成 1。
  • redis.call('pexpire', KEYS[1], ARGV[1])KEYS[1] 设置超时时间。

锁存在,但是唯一标识不匹配,表明锁被其他线程持有,调用 pttl返回锁剩余的过期时间。

Chaya:「“脚本执行结果返回 nil、锁剩余过期时间有什么目的?”」

当且仅当返回 nil才表示加锁成功;客户端需要感知锁是否成功的结果。

解锁原理

解锁逻辑复杂一些,不仅要保证不能删除别人的锁。还要确保,重入次数为 0 才能解锁。

解锁代码执行方式与加锁类似,三个返回值含义如下。

  • 1 代表解锁成功,锁被释放。
  • 0 代表可重入次数被减 1。
  • nil 代表其他线程尝试解锁,解锁失败。
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
    return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end;
return nil;
  • KEYS[1]是 lockKey,表示锁的资源,比如 lock:order:pay
  • ARGV[1],锁的超时时间。
  • ARGV[2],Hash 表的 FieldKey。

首先使用 hexists 判断 Redis 的 Hash 表是否存在 fileKey,如果不存在则直接返回 nil解锁失败。

若存在的情况下,且唯一标识匹配,使用 hincrby 对 fileKey 的值 -1,然后判断计算之后可重入次数。当前值 > 0 表示持有的锁存在重入情况,重新设置超时时间,返回值 1;

若值小于等于 0,表明锁释放了,执行 del释放锁。

Chaya:“可重入锁很好,依然存在的一个问题是:加锁后,业务逻辑执行耗时超过了 lockKey 的过期时间,lockKey 会被 Reids 删除。”

这个时间不能瞎写,一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出接口平均执行时间 200 ms。那么锁的超时时间就放大为平均执行时间的 3~5 倍。

Chaya:“锁的超时时间怎么计算合适呢?”

这个时间不能瞎写,一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出接口平均执行时间 200 ms。那么锁的超时时间就放大为平均执行时间的 3~5 倍。

Chaya:“为啥要放大呢?”

因为如果锁的操作逻辑中有网络 IO 操作、JVM FullGC 等,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。

Chaya:“有没有完美的方案呢?不管时间怎么设置都不大合适。”

我们可以让获得锁的线程开启一个守护线程,用来给当前客户端快要过期的锁续航,续命的前提是,得判断是不是当前进程持有的锁,如果不是就不进行续。

如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。

图 5-27

这就是下一篇我要说的超神方案,加入看门狗机制实现锁自动续期。不过锁自动续期比较复杂,今天的 Redis 可重入分布式锁王者方案已经可以让你称霸武林,接下来上实战。

可重入分布式锁实战

关于 Spring Boot 的环境搭建以及普通分布式锁实战详见上一篇《纠正误区:这才是 SpringBoot Redis 分布式锁的正确实现方式》。今天直接上可重入锁核心代码。

ReentrantDistributedLock

可重入锁由ReentrantDistributedLock标识,它实现 Lock接口,构造方法实现 resourceNameStringRedisTemplate 的属性设置。

客户端唯一标识使用uuid:threadId 组成。

public class ReentrantDistributedLock implements Lock {
    /**
     * 锁超时时间,默认 30 秒
     */
    protected long internalLockLeaseTime = 30000;
    /**
     * 标识 id
     */
    private final String id = UUID.randomUUID().toString();
    /**
     * 资源名称
     */
    private final String resourceName;
    private final List<String> keys = new ArrayList<>(1);
    /**
     * Redis 客户端
     */
    private final StringRedisTemplate redisTemplate;
    public ReentrantDistributedLock(String resourceName, StringRedisTemplate redisTemplate) {
        this.resourceName = resourceName;
        this.redisTemplate = redisTemplate;
        keys.add(resourceName);
    }
}

加锁 tryLock、lock

tryLock 以阻塞等待 waitTime 时间的方式来尝试获取锁。获取成功则返回 true,反之 false。

tryLock不同的是, lock 一直尝试自旋阻塞等待获取分布式锁,直到获取成功为止。而 tryLock 只会阻塞等待 waitTime 时间。

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
    // lua 脚本获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return true;
    }
    time -= System.currentTimeMillis() - current;
    // 等待时间用完,获取锁失败
    if (time <= 0) {
        return false;
    }
    // 自旋获取锁
    while (true) {
        long currentTime = System.currentTimeMillis();
        ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return true;
        }
        time -= System.currentTimeMillis() - currentTime;
        if (time <= 0) {
            return false;
        }
    }
}
@Override
public void lock(long leaseTime, TimeUnit unit) {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired
    if (ttl == null) {
        return;
    }
    do {
        ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
    } while (ttl != null);
}
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    // 执行 lua 脚本
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LuaScript.reentrantLockScript(), Long.class);
    return redisTemplate.execute(redisScript, keys, String.valueOf(unit.toMillis(leaseTime)), getRequestId(threadId));
}
private String getRequestId(long threadId) {
    return id + ":" + threadId;
}

解锁 unlock

public void unlock() {
        long threadId = Thread.currentThread().getId();
        // 执行 lua 脚本
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LuaScript.reentrantUnlockScript(), Long.class);
        Long opStatus = redisTemplate.execute(redisScript, keys, String.valueOf(internalLockLeaseTime), getRequestId(threadId));
        if (opStatus == null) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
        }
    }

LuaScript

这个脚本就是在讲解可重入分布式锁原理具体逻辑已经解释过,这里就不再重复分析。

public class LuaScript {
    private LuaScript() {
    }
    /**
     * 可重入分布式锁加锁脚本
     *
     * @return 当且仅当返回 `nil`才表示加锁成功;返回锁剩余过期时间是让客户端感知锁是否成功。
     */
    public static String reentrantLockScript() {
        return "if ((redis.call('exists', KEYS[1]) == 0) " +
                "or (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; " +
                "return redis.call('pttl', KEYS[1]);";
    }
    /**
     * 可重入分布式锁解锁脚本
     *
     * @return 当且仅当返回 `nil`才表示解锁成功;
     */
    public static String reentrantUnlockScript() {
        return "if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then " +
                "return nil;" +
                "end; " +
                "local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1); " +
                "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return 0; " +
                "else " +
                "redis.call('del', KEYS[1]); " +
                "return 1; " +
                "end; " +
                "return nil;";
    }
}

RedisLockClient

最后,还需要提供一个客户端给方便使用。

@Component
public class RedisLockClient {
    @Autowired
    private StringRedisTemplate redisTemplate;
    /**
     * 获取可重入分布式锁
     * @param name
     * @return
     */
    public Lock getReentrantLock(String name) {
        return new ReentrantDistributedLock(name, redisTemplate);
    }
}

单元测试走一个,验证下分布式锁是否支持可重入。

@Slf4j
@SpringBootTest(classes = RedisApplication.class)
public class RedisLockTest {
    @Autowired
    private RedisLockClient redisLockClient;
    @Test
    public void testTryReentrantLockSuccess() throws InterruptedException {
        Lock lock = redisLockClient.getReentrantLock("order:pay");
        try {
            boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!isLock) {
                log.warn("加锁失败");
                return;
            }
            // 重复加锁
            reentrant(lock);
            log.info("业务逻辑执行完成");
        } finally {
            lock.unlock();
        }
    }
    private void reentrant(Lock lock) throws InterruptedException {
        try {
            boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!isLock) {
                log.warn("加锁失败");
                return;
            }
            log.info("业务逻辑执行完成");
        } finally {
            lock.unlock();
        }
    }
}

有两个点需要注意。

  1. 释放锁的代码一定要放在 finally{} 块中。否则一旦执行业务逻辑过程中抛出异常,程序就无法执行释放锁的流程。只能干等着锁超时释放。
  2. 加锁的代码应该写在 try {} 代码中,放在 try 外面的话,如果执行加锁异常(客户端网络连接超时),但是实际指令已经发送到服务端并执行,就会导致没有机会执行解锁的代码。

CHaya:“码哥,这个方案确实很王者,大开眼界,接下来的超神版可以实现看门狗自动续期么?”

鉴于篇幅有限,今天就跟大家介绍 Redis 可重入分布式锁王者方案,关注我,下一篇给你分享、超神版分布式锁解决方案。

下期见~


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
1月前
|
XML Java 开发者
Spring Boot开箱即用可插拔实现过程演练与原理剖析
【11月更文挑战第20天】Spring Boot是一个基于Spring框架的项目,其设计目的是简化Spring应用的初始搭建以及开发过程。Spring Boot通过提供约定优于配置的理念,减少了大量的XML配置和手动设置,使得开发者能够更专注于业务逻辑的实现。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,为开发者提供一个全面的理解。
34 0
|
4天前
|
NoSQL Java Redis
Spring Boot 自动配置机制:从原理到自定义
Spring Boot 的自动配置机制通过 `spring.factories` 文件和 `@EnableAutoConfiguration` 注解,根据类路径中的依赖和条件注解自动配置所需的 Bean,大大简化了开发过程。本文深入探讨了自动配置的原理、条件化配置、自定义自动配置以及实际应用案例,帮助开发者更好地理解和利用这一强大特性。
42 14
|
1月前
|
存储 Dubbo Java
分布式 RPC 底层原理详解,看这篇就够了!
本文详解分布式RPC的底层原理与系统设计,大厂面试高频,建议收藏。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
分布式 RPC 底层原理详解,看这篇就够了!
|
18天前
|
机器学习/深度学习 存储 运维
分布式机器学习系统:设计原理、优化策略与实践经验
本文详细探讨了分布式机器学习系统的发展现状与挑战,重点分析了数据并行、模型并行等核心训练范式,以及参数服务器、优化器等关键组件的设计与实现。文章还深入讨论了混合精度训练、梯度累积、ZeRO优化器等高级特性,旨在提供一套全面的技术解决方案,以应对超大规模模型训练中的计算、存储及通信挑战。
48 4
|
19天前
|
缓存 NoSQL Java
Spring Boot中的分布式缓存方案
Spring Boot提供了简便的方式来集成和使用分布式缓存。通过Redis和Memcached等缓存方案,可以显著提升应用的性能和扩展性。合理配置和优化缓存策略,可以有效避免常见的缓存问题,保证系统的稳定性和高效运行。
35 3
|
25天前
|
存储 Java 关系型数据库
在Spring Boot中整合Seata框架实现分布式事务
可以在 Spring Boot 中成功整合 Seata 框架,实现分布式事务的管理和处理。在实际应用中,还需要根据具体的业务需求和技术架构进行进一步的优化和调整。同时,要注意处理各种可能出现的问题,以保障分布式事务的顺利执行。
46 6
|
1月前
|
Java Spring
SpringBoot自动装配的原理
在Spring Boot项目中,启动引导类通常使用`@SpringBootApplication`注解。该注解集成了`@SpringBootConfiguration`、`@ComponentScan`和`@EnableAutoConfiguration`三个注解,分别用于标记配置类、开启组件扫描和启用自动配置。
56 17
|
26天前
|
Java 容器
springboot自动配置原理
启动类@SpringbootApplication注解下,有三个关键注解 (1)@springbootConfiguration:表示启动类是一个自动配置类 (2)@CompontScan:扫描启动类所在包外的组件到容器中 (3)@EnableConfigutarion:最关键的一个注解,他拥有两个子注解,其中@AutoConfigurationpackageu会将启动类所在包下的所有组件到容器中,@Import会导入一个自动配置文件选择器,他会去加载META_INF目录下的spring.factories文件,这个文件中存放很大自动配置类的全类名,这些类会根据元注解的装配条件生效,生效
|
1月前
|
NoSQL Java API
springboot项目Redis统计在线用户
通过本文的介绍,您可以在Spring Boot项目中使用Redis实现在线用户统计。通过合理配置Redis和实现用户登录、注销及统计逻辑,您可以高效地管理在线用户。希望本文的详细解释和代码示例能帮助您在实际项目中成功应用这一技术。
38 4
|
1月前
|
消息中间件 NoSQL Java
Spring Boot整合Redis
通过Spring Boot整合Redis,可以显著提升应用的性能和响应速度。在本文中,我们详细介绍了如何配置和使用Redis,包括基本的CRUD操作和具有过期时间的值设置方法。希望本文能帮助你在实际项目中高效地整合和使用Redis。
57 2
下一篇
DataWorks