如何写出一把高性能的Redis分布式锁?

众所周知,分布式锁在微服务架构中是重头戏,尤其是在互联网公司,基本上企业内部都会有自己的一套分布式锁开发框架。

分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。
本文主要介绍使用Redis如何构建高并发分布式锁。

假设 存在一个SpringBoot的控制器,其扣减库存的业务逻辑如下:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {

    // 将库存取出来
    int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

    // 判断库存够不够减
    if (stock > 0) {
        // 将库存回写到redis
        int tmp = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", tmp.toString());
        logger.info("库存扣减成功");
    } else {
        logger.info("库存扣减失败");
    }

    return "finished.";
}

单机使用sync同步锁

不难看出,在应用服务器运行这段代码的时候就会有线程安全性问题。因为多个线程同时去修改Redis服务中的数据。因此考虑给这段代码加上一把锁:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {
    synchronized (this) {
        int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        // 判断库存够不够减
        if (stock > 0) {
            // 将库存回写到redis
            int tmp = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", tmp.toString());
            logger.info("库存扣减成功");
        } else {
            logger.info("库存扣减失败");
        }
    }
    return "finished.";
}

这样一来,当多个HTTP请求来请求数据的时候,多个线程去修改同一数据会有JVM本地锁来进行合理的资源限制。虽然这样解决了线程安全性问题,但是这仅仅是JVM级别的锁,在分布式的环境下,由于像这样的Web应用随时会进行动态扩容,因此当多个应用的时候,同样会有线程安全性问题,当上面这段代码遇到类似下面的架构时还是会有各种各样的问题:

Redis如何实现高并发分布式锁?

分布式使用redis setnx分布式锁

对于上述的情况,我们可以使用redis api提供的setnx方法解决:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {

    // 尝试获取锁
    Boolean flag = stringRedisTmplate.opsForValue().setIfAbsent("Hello", "World");

    // 判断是否获得锁
    if (!flag) { return "error"; }

    int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

    // 判断库存够不够减
    if (stock > 0) {
        // 将库存回写到redis
        int tmp = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", tmp.toString());
        logger.info("库存扣减成功");
    } else {
        logger.info("库存扣减失败");
    }

    // 删除锁
    stringRedisTemplate.delete("Hello");

    return "finished.";
}

setnx key value是将key的值设置为value,当且仅当key不存在的时候。如果设置成功就返回1,否则就返回0。

加入try…finally——防止出现异常无法释放锁

这样的话,首先尝试获取锁,然后当业务执行完成的时候再删除锁。但是还是有问题的,当获取锁的时候抛出异常或者业务执行抛出异常怎么办,所以加入异常处理逻辑:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {
    try {
        // 尝试获取锁
        Boolean flag = stringRedisTmplate.opsForValue().setIfAbsent("Hello", "World");

        // 判断是否获得锁
        if (!flag) { return "error"; }

        int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        // 判断库存够不够减
        if (stock > 0) {
            // 将库存回写到redis
            int tmp = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", tmp.toString());
            logger.info("库存扣减成功");
        } else {
            logger.info("库存扣减失败");
        }
    } finally {
        // 删除锁
        stringRedisTemplate.delete("Hello");
    }
    return "finished.";
}

设置超时时间——防止程序突然挂掉

经过这样的修改,看起来没什么问题了。但是当程序获得锁并且开始执行业务逻辑的时候,突然程序挂掉了或者被一些粗暴的运维工程师给kill,在finally中删除锁的逻辑就会得不到执行,因此就会产生死锁。对于这种情况,我们可以给这个锁设置一个超时时间:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {
    try {
        // 尝试获取锁
        Boolean flag = stringRedisTmplate.opsForValue().setIfAbsent("Hello", "World");

        // 设置超时时间, 根据业务场景估计超时时长
        stringRedisTmplate.expire("Hello", 10, TimeUnit.SECONDS);

        // 判断是否获得锁
        if (!flag) { return "error"; }

        int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        // 判断库存够不够减
        if (stock > 0) {
            // 将库存回写到redis
            int tmp = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", tmp.toString());
            logger.info("库存扣减成功");
        } else {
            logger.info("库存扣减失败");
        }
    } finally {
        // 删除锁
        stringRedisTemplate.delete("Hello");
    }
    return "finished.";
}

加锁和设置超时操作——原子性

如果程序这么来写,相对来说安全一些了,但是还是存在问题。试想一下,当获取锁成功时,正想给这把锁设置超时的时候,程序挂掉了,还是会出现死锁的,因此在redis较高的版本中提供的setIfAbsent方法中可以同时设置锁的超时时间。
(注意低版本暂不支持!!)

 Boolean flag = stringRedisTmplate.opsForValue().setIfAbsent("Hello", "World", 10, TimeUnit.SECONDS);

加锁设置唯一标识——只有持有者能释放自己的锁

这样一来,尝试获取锁和设置锁的超时时间就具备原子性了。实际上经过我们这一番改造,这在小型企业已经没有太大的问题, 因为像这种代码每天也就执行几百次,并不算做高并发的场景。当这样的代码被暴露在超高并发场景下的时候,还是会存在各种各样的问题。试想一个场景,当一个HTTP请求请求到控制器的时候,应用获取到锁了,超时时间也设置成功了,但是应用的业务逻辑超过了超时时间,我们这里的超时时间设置的是10秒,当应用的业务逻辑执行15秒的时候,锁就被redis服务删除了。假设恰好此时又有一个HTTP请求来请求控制器,此时应用服务器会再启动一个线程来获取锁,而且还获取成功了,但是这次的HTTP请求对应的业务逻辑还没有执行完。新来的TTTP请求也在执行,由于新来的HTTP请求也在执行,因为锁超时后被删除,新的HTTP请求也成功获取锁了。当原来的HTTP请求对应的业务逻辑执行完成以后,尝试删除锁,这样正好删除的是新来的HTTP请求对应的锁。这个时候redis中又没有锁了,这样第三个HTTP请求又会获得锁,所以情况就不妙了。

为了解决上面的问题,我们可以将代码优化为下面的样子:

@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {
    String clientUuid = UUID.randomUUID().toString();
    try {
        // 尝试获取锁,设置超时时间, 根据业务场景估计超时时长
        Boolean flag = stringRedisTmplate.opsForValue().setIfAbsent("Hello", clientUuid, 10, TimeUnit.SECONDS);

        // 判断是否获得锁
        if (!flag) { return "error"; }

        int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        // 判断库存够不够减
        if (stock > 0) {
            // 将库存回写到redis
            int tmp = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", tmp.toString());
            logger.info("库存扣减成功");
        } else {
            logger.info("库存扣减失败");
        }
    } finally {
        // 删除锁的时候判断是不是自己的锁
        if (clientUuid.equals(stringRedisTemplate.opsForValue().get("Hello"))) {
            stringRedisTemplate.delete("Hello");   
        }
    }
    return "finished.";
}

删除锁之前判断是不是自己的锁——Lua脚本保证原子性

注意这个操作,其实也不是原子性的。

 // 删除锁的时候判断是不是自己的锁
        if (clientUuid.equals(stringRedisTemplate.opsForValue().get("Hello"))) {
            stringRedisTemplate.delete("Hello");   
        }
    }

这个问题在于如果调用stringRedisTemplate.delete("Hello")方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
解决:(这里只是提供一个思路,具体可参考别的文章如何解决)
从 2.6版本 起, Redis 开始支持 Lua 脚本,Redis 允许将 Lua 脚本传到 Redis 服务器中执行, 脚本内可以调用大部分 Redis 命令, 且 Redis 保证脚本的原子性:

准备Lua代码: script.lua

--
-- Created by IntelliJ IDEA.
-- User: jifang
-- Date: 16/8/24
-- Time: 下午6:11
--

local key = "rate.limit:" .. KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = ARGV[2]

local is_exists = redis.call("EXISTS", key)
if is_exists == 1 then
    if redis.call("INCR", key) > limit then
        return 0
    else
        return 1
    end
else
    redis.call("SET", key, 1)
    redis.call("EXPIRE", key, expire_time)
    return 1
end

Java

private boolean accessLimit(String ip, int limit, int timeout, Jedis connection) throws IOException {
    List<String> keys = Collections.singletonList(ip);
    List<String> argv = Arrays.asList(String.valueOf(limit), String.valueOf(timeout));

    return 1 == (long) connection.eval(loadScriptString("script.lua"), keys, argv);
}

// 加载Lua代码
private String loadScriptString(String fileName) throws IOException {
    Reader reader = new InputStreamReader(Client.class.getClassLoader().getResourceAsStream(fileName));
    return CharStreams.toString(reader);
}

Lua 嵌入 Redis 优势:

  • 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, * 减少网络传输;
  • 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
  • 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.

使用redisson(锁续命)——解决锁过期了业务操作未执行完成

这个问题可详细参考我的另一篇文章:

解决redis分布式锁过期时间到了业务没执行完问题

但是由于程序的不可预知性,谁也不能保证极端情况下,同时会有多个线程同时执行这段业务逻辑。我们可以在当执行业务逻辑的时候同时开一个定时器线程,每隔几秒就重新将这把锁设置为10秒,也就是给这把锁进行“续命”。这样就用担心业务逻辑到底执行多长时间了。但是这样程序的复杂性就会增加,每个业务逻辑都要写好多的代码,因此这里推荐在分布式环境下使用redisson。因此我们使用redisson实现分支线程的代码:

  • 引入依赖:
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.6.5</version>
</dependency>
  • 初始化Redisson的客户端配置:
@Bean
public Redisson redisson () {
    Config cfg = new Config();
    cfg.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
    return (Redisson) Redisson.create(cfg);
}
  • 在程序中注入Redisson客户端:
@Autowired
private Redisson redisson;
  • 对应的业务逻辑:
@Autowired
private StringRedisTemplate stringRedisTemplate;

@RequestMapping(value = "/deduct-stock")
public String deductSotck() throws Exception {
    // 获取锁对象
    RLock lock = redisson.getLock("Hello");
    try {
        // 尝试加锁, 默认30秒, 自动后台开一个线程实现锁的续命
        lock.tryLock();

        int i = Interger.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        // 判断库存够不够减
        if (stock > 0) {
            // 将库存回写到redis
            int tmp = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", tmp.toString());
            logger.info("库存扣减成功");
        } else {
            logger.info("库存扣减失败");
        }
    } finally {
        // 释放锁
        lock.unlock();
    }
    return "finished.";
}

Redisson分布式锁的实现原理如下:

Redis如何实现高并发分布式锁?
但是这个架构还是存在问题的,因为redis服务器是主从的架构,当在master节点设置锁之后,slave节点会立刻同步。但是如果刚在master节点设置上了锁,slave节点还没来得及设置,master节点就挂掉了。还是会产生上同样的问题,新的线程获得锁。

因此使用redis构建高并发的分布式锁,仅适合单机架构,当使用主从架构的redis时还是会出现线程安全性问题。

总结

  • 对于一些不可手动刷新的,单纯为了减少DB库压力的缓存,一般来说设定的超时时间不宜过长,以避免线上缓存数据异常时,需要很长的时间才能清理缓存的情况出现。一般来说,这类缓存,建议缓存时间定在10S左右。
  • 主键Key值设计,不宜太长,影响查询效率。
  • 千万不要忘记设置超时时间…可以通过提供团队统一的工具类的方式,控制必须设定超时时间。
  • 对于强依赖Redis缓存服务,进行逻辑处理的场景,考虑使用一致性Hash算法,作为负载路由策略。避免在动态扩容时,大量Redis缓存失效的情况出现。
  • 分布式锁在使用时,不要单纯依赖超时时间,一定要在finally释放锁。
  • 分布式锁在释放时,需要控制只有锁的持有者可以释放锁。

参考文章:
https://blog.51cto.com/xvjunjie/2428610
https://www.cnblogs.com/barrywxx/p/8563284.html
https://www.cnblogs.com/linjiqin/p/8003838.html

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
在Spring Boot中实现Redis分布式锁可以通过以下步骤: 1. 添加Redis依赖:在`pom.xml`文件中添加Redis的依赖,例如: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> ``` 2. 配置Redis连接信息:在`application.properties`或`application.yml`文件中配置Redis连接信息,例如: ```properties spring.redis.host=127.0.0.1 spring.redis.port=6379 ``` 3. 创建Redis分布式锁实现类:创建一个实现了分布式锁接口的类,例如`RedisDistributedLock`,在该类中注入`StringRedisTemplate`用于操作Redis。 4. 实现加锁方法:在`RedisDistributedLock`类中实现加锁方法,可以使用Redis的`setnx`命令来进行加锁操作,例如: ```java public boolean lock(String key, String value, long expireTime) { Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.MILLISECONDS); return success != null && success; } ``` 5. 实现释放锁方法:在`RedisDistributedLock`类中实现释放锁方法,使用Redis的`del`命令来删除锁对应的键,例如: ```java public boolean unlock(String key) { return redisTemplate.delete(key); } ``` 6. 在业务代码中使用分布式锁:在需要加锁的代码块前后调用加锁和释放锁方法,例如: ```java @Autowired private RedisDistributedLock redisDistributedLock; public void doSomethingWithLock() { String lockKey = "my-lock"; String lockValue = UUID.randomUUID().toString(); long expireTime = 10000; // 过期时间,单位为毫秒 try { boolean locked = redisDistributedLock.lock(lockKey, lockValue, expireTime); if (locked) { // 执行业务逻辑 } else { // 获取锁失败,可以进行重试或处理其他逻辑 } } finally { redisDistributedLock.unlock(lockKey); } } ``` 通过以上步骤,就可以在Spring Boot中实现Redis分布式锁。注意在使用分布式锁时需要考虑锁的粒度和超时处理等问题,以确保分布式锁的正确使用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Apple_Web

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值