第2节 Redis非集群模式下的分布式锁

67 阅读7分钟

第1节 分布式锁问题背景 中我们介绍秒杀超卖库存的问题。并给出了synchronized 或 ReentrantLock 的解决方案,但是在大多数场景下无法解决超卖问题,比如:

  • MySQL事务会导致synchronized 失效
  • 应用集群部署会导致synchronized 失效

本篇笔记将介绍利用 Redis 分布式锁来彻底解决该问题,并介绍如何正确的实现一个Redis分布式锁。

1. 用Redis模拟超卖现象

  • 引入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • 业务代码
// @Autowired 是根据类型去找的,所以变量名可以叫 redisTemplate;
// 如果使用 @Resource,则变量名不能叫 redisTemplate
@Autowired
private StringRedisTemplate redisTemplate;

public void decuct() {
    // goods:count的值就是商品总量,这里设置成 5
    String count = redisTemplate.opsForValue().get("goods:count");
    log.info("当前库存值,count = {}", count);
    if (!StringUtils.isEmpty(count)) {
        Integer goodsCount = Integer.valueOf(count);
        if (goodsCount > 0) {
            // 扣减库存
            redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
        }
    }
}

1.1 问题复现

  • 设置redis中的库存为 5 image.png

  • 同时启动 3 个线程并发扣减库存 image.png

  • 观察结果

image.png

image.png

结论:本应该扣除 3 ,通过并发请求发现只扣除了 1

2. redis 分布式锁解决库存扣减问题

2.1 Lua 简单入门

2.1 Redis 中如何解析 Lua 脚本

Redis 从 2.6 版本开始使用 EVAL 命令来对 Lua 脚本解析。 EVAL script numkeys key [key ...] arg [arg ...]

  • numkeys : 表示 key 参数的个数
  • key : 表示要操作的redis key
  • arg : 表示要操作的每个 key 对应的值

2.2 Lua 语法

127.0.0.1:6679> eval "return 'hello Lua'" 0
"hello Lua"
127.0.0.1:6679>

(1)变量定义

Redis 中使用 Lua 脚本时只支持局部变量定义

local a=5
127.0.0.1:6679> eval " local a=5 return a" 0
(integer) 5
127.0.0.1:6679>

(2)分支控制

  • if 语句
if 布尔表达式
then 
    布尔表达式 true 时执行
end    
  • if...else 语句
if 布尔表达式
then 
    布尔表达式 true 时执行
else 
    布尔表达式 false 时执行
end    
  • if 嵌套
if 布尔表达式1
then
   布尔表达式1true 时执行
   
   if 布尔表达式2
   then
      布尔表达式2true 时执行
   end
   
end
  • elseif 嵌套
if 布尔表达式1 then 
    布尔表达式1true 时执行
elseif 布尔表达式2 then 
    布尔表达式2true 时执行 
else 
    布尔表达式2false 时执行 
end 

(3)实战案例

  • 案例一
127.0.0.1:6679> eval " if 10 > 20 then return 10 else return 20 end  " 0
(integer) 20
127.0.0.1:6679>
  • 案例二
127.0.0.1:6679> eval "if KEYS[1] > ARGV[1] then return KEYS[2] else return ARGV[2] end" 2 10 20 30 40
"40"
127.0.0.1:6679>
  • 案例三
127.0.0.1:6679> eval "return {10, 20, 30, 40}" 0
1) (integer) 10
2) (integer) 20
3) (integer) 30
4) (integer) 40
127.0.0.1:6679>
  • 案例三
127.0.0.1:6679> eval "return { KEYS[1],KEYS[2],ARGV[1],ARGV[2] }" 3 10 20 30 40 50 60
1) "10"
2) "20"
3) "40"
4) "50"
127.0.0.1:6679>

(4)redis.call() 函数使用

127.0.0.1:6679> eval "return redis.call('set','lock','aaa')" 0
OK
127.0.0.1:6679>
127.0.0.1:6679>
127.0.0.1:6679> get lock
"aaa"
127.0.0.1:6679>

或者

127.0.0.1:6679> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 lock aaa
OK
127.0.0.1:6679> get lock
"aaa"
127.0.0.1:6679>

2.2 用Redis锁来防止并发扣减库存

2.2.1 setIfAbsent方法实现锁

// @Autowired 是根据类型去找的,所以变量名可以叫 redisTemplate;
// 如果使用 @Resource,则变量名不能叫 redisTemplate
@Autowired
private StringRedisTemplate redisTemplate;

public void decuct() {
    // 1. 加 redis 锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
    if (!lock) {
        log.info("锁获取失败 ...");
        return;
    }

    try {
        String count = redisTemplate.opsForValue().get("goods:count");
        log.info("当前库存值,count = {}", count);
        if (!StringUtils.isEmpty(count)) {
            Integer goodsCount = Integer.valueOf(count);
            if (goodsCount > 0) {
                redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
            }
        }
    } finally {
        // 2. 释放 redis 锁
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
                + "      then "
                + "          return redis.call( 'del', KEYS[1] ) "
                + "      else "
                + "          return 0 "
                + "      end";
        redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);
        log.info("redis 锁释放成功 ...");
    }
}
  • 当用 Jmeter 启动并发线程请求扣减库存时,可以得到正确的结果,并不会出现超卖的情况!!!

image.png

image.png

(1)方法 setIfAbsent 原理

  String uuid = UUID.randomUUID().toString();
  Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);

setIfAbsent 方法的底层也是调用SET key value [EX seconds] [PX milliseconds] [NX|XX] 命令

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

实际开发中我们通常使用 EX + NX 组合,代码如下:

127.0.0.1:6679> set locked ok EX 60 NX
OK
127.0.0.1:6679> ttl locked
(integer) 52
127.0.0.1:6679>
127.0.0.1:6679> ttl locked
(integer) 47

(2)必须设置锁过期时间

设置过期时间是为了防止死锁的发生。如果应用程序从 Redis 中拿到锁后服务宕机,此时锁没有过期时间,那么就会成为死锁

(3)必须防止锁误删

  • 这点很多开发人员都容易忽略。在高并发场景,很容易出现 A 线程删除了 B 线程的锁
  • 解决方案就是删除锁之前先判断锁是否属于当前线程的锁,使用 Redis 的 get + del 命令组合
String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
                + "      then "
                + "          return redis.call( 'del', KEYS[1] ) "
                + "      else "
                + "          return 0 "
                + "      end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);
  • 锁的唯一标识最好使用雪花ID、UUID 这样全局唯一的ID
  • 为什么要使用Lua脚本
    Redis 内置执行Lua脚本,能够保证将组合命令一次性发送到 Redis 服务端,然后 Redis 服务端排队执行,避免了其他命令插队的情况

3. Redis 分布式可重入锁

利用 Redis 分布式锁解决秒杀超卖问题,可以说能够满足日常大部分开发功能的需要。但是在极少数场景中也可能会出现死锁,此时就需要可重入锁 !!!

掘金-第 10 页.drawio (1).png

  • 实现 Redis 分布式可重入锁的思路可参考JDK的 ReentrantLock 非公平可重入锁的思路,ReentrantLock的实现原理
  • 利用 Redis 的 hash数据类型 + Lua 脚本 来 Redis 分布式可重入锁

3.1 加锁实现

  • 使用EXISTS 命令判断锁是否存在,不存在直接上锁 HSET key field value
  • 如果锁存在,使用 HEXISTS命令判断是否是当前线程的锁,如果是当前线程的锁则重入 HINCRBY key field increment

Lua 脚本如下:

if redis.call('exists', 'lock') == 0
then  
    redis.call('hset', 'lock', uuid, 1)
    redis.call('expire', 'lock', 10)
    return 1
elseif redis.call('hexists', 'lock', uuid) == 1
then 
    redis.call('hincrby', 'lock', 1)
    redis.call('expire', 'lock', 10)
    return 1
else 
    return 0
end  

-- 上述代码可优化
if redis.call('exists', 'lock') == 0 or redis.call('hexists', 'lock', uuid) == 1
then  
    redis.call('hincrby', 'lock', 1)
    redis.call('expire', 'lock', 10)
    return 1
else 
    return 0
end 

Redis可执行脚本如下:

String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 "
        + "      then "
        + "      redis.call('hincrby', KEYS[1], ARGV[1], 1) "
        + "      redis.call('expire', KEYS[1], ARGV[2]) "
        + "            return 1 "
        + "      else "
        + "            return 0 "
        + "      end ";

3.2 释放锁实现

  • 使用hexists命令判断当前线程的锁是否存在,不存在则返回 nil
  • 如果当前线程的锁存在,则使用hincrby命令减 1 操作,然后判断减 1 后的值是否为 0,为 0 则使用 del 命令删除锁并返回 1
  • 如果减 1 后的值不为 0,则返回 0
if redis.call('hexists', 'lock', uuid) == 0
then 
    return nil
elseif redis.call('hincrby', 'lock', uuid, -1) == 0
then 
    return redis.call('del', 'lock')
else
    return 0
end    

Redis可执行脚本如下:

String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 "
        + "      then "
        + "           return nil "
        + "      elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 "
        + "      then "
        + "           return redis.call('del', KEYS[1]) "
        + "      else "
        + "           return 0 "
        + "      end";

4. Redis 分布式锁工具类封装

4.1 封装分布式全局ID工具类

  • 简单实现可使用 UUID
  • 如果有条件,可使用雪花ID
import org.springframework.util.AlternativeJdkIdGenerator;

import java.util.UUID;

public class UuidUtil {

    public static String jdkGenerateId() {
        return UUID.randomUUID().toString().replace("-", "");
    }

    public static String highGenerateId() {
        AlternativeJdkIdGenerator uuidGenerator = new AlternativeJdkIdGenerator();
        return uuidGenerator.generateId().toString().replace("-", "");
    }
}

4.2 Redis 锁工具类

@Component
public class RedisLock {

    private static final Logger log = LoggerFactory.getLogger(RedisLock.class);

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 加分布式独占锁
     * @param expireSeconds 锁过期秒时间
     * @return
     */
    public boolean tryLock(String key, String value, long expireSeconds) {
        try {

            boolean lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
            if (lock) {
                return true;
            }

        } catch (Exception e) {
            log.error("加 redis 分布式锁异常,key = {}", key, e);
        }
        return false;
    }

    /**
     * 脚本加分布式锁
     * (1) setnx + expire 命令组合
     * @return
     */
    public boolean tryScriptLock(String key, String value, long expireSeconds) {
        String script = "if redis.call('setNX',KEYS[1], ARGV[1]) == 1 "
                + "          then "
                + "              return redis.call('expire',KEYS[1], ARGV[2]) "
                + "          else "
                + "             return 0 "
                + "          end";

        try {
            boolean lock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(value), String.valueOf(expireSeconds));

            if (lock) {
                return true;
            }

        } catch (Exception e) {
            log.error("lua脚本加 redis 分布式锁异常,key = {}", key, e);
        }

        return false;
    }

    /**
     * 释放分布式锁
     * @return
     */
    public boolean releasesLock(String key, String value) {
        final String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
                + "      then "
                + "          return redis.call( 'del', KEYS[1] ) "
                + "      else "
                + "          return 0 "
                + "      end";

        boolean unLock = false;
        try {

            unLock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(key), value);

        } catch (Exception e) {

            log.error("释放 redis 分布式锁异常,key = {}", key, e);
        }
        return unLock;
    }

    /**
     * 加分布式可重入锁:hash + lua 脚本实现
     * (1) 使用 `exists` 命令判断锁是否存在
     * (2) 如果锁存在,使用 `hexists` 命令判断是否是当前线程的锁,如果是当前线程的锁则重入 `hincrby key field increment`
     * @param expireSeconds 锁过期秒时间
     * @return
     */
    public boolean tryReentrantLock(String key, String value, long expireSeconds) {
        final String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 "
                + "            then "
                + "                 redis.call('hincrby', KEYS[1], ARGV[1], 1) "
                + "                 redis.call('expire', KEYS[1], ARGV[2]) "
                + "                 return 1 "
                + "             else "
                + "                 return 0 "
                + "             end ";
        try {

            boolean lock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(key), value, String.valueOf(expireSeconds));

            if (lock) {
                return true;
            }
        } catch (Exception e) {
            log.error("加 redis 可重入分布式锁异常,key = {}", key, e);
        }
        return false;
    }

    /**
     * 释放分布式可重入锁
     * (1) 使用`hexists`命令判断当前线程的锁是否存在,不存在则返回 nil
     * (2) 如果当前线程的锁存在,则使用`hincrby`命令减 1 操作,然后判断减 1 后的值是否为 0,为 0 则使用 `del` 命令删除锁并返回 1
     * (3) 如果减 1 后的值不为 0,则返回 0
     * @return
     */
    public void releasesReentrantLock(String key, String value) {

        final String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 "
                + "            then "
                + "                 return nil "
                + "            elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 "
                + "            then "
                + "                 return redis.call('del', KEYS[1]) "
                + "            else "
                + "                 return 0 "
                + "            end";
        try {

            Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(key), value);
            if (null == result) {
                throw new IllegalMonitorStateException("the lock don't belong to the current thread");
            }

        } catch (Exception e) {
            log.error("释放 redis 分布式可重入锁异常,key = {}", key, e);
        }
    }

    /**
     * 加分布式可重试锁
     * (1) 并发请求时会导致线程阻塞
     * (2) 一定程度上保证了锁的可靠性
     * @param expireSeconds 锁过期秒时间
     * @param retrySeconds  获取锁失败后重试秒时间
     * @return
     */
    public boolean tryRetryLock(String key, String value, long expireSeconds, long retrySeconds) {
        try {

            boolean lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);

            // 加锁失败后重试
            long exitTime = 0;
            retrySeconds = retrySeconds > expireSeconds ? expireSeconds : retrySeconds;
            long timeout = System.currentTimeMillis() + retrySeconds * 1000;

            while ((exitTime = timeout - System.currentTimeMillis()) > 0L && !lock) {
                lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
                log.warn("锁重试,退出重试剩余时间,exitTime = {} ms", exitTime);
            }

            if (lock) {
                return true;
            }

        } catch (Exception e) {
            log.error("加 redis 分布式锁异常,key = {}", key, e);
        }
        return false;
    }
}

4.3 可重入锁验证

public void decuctReentrantLock() {
    // 1. 加 redis 锁
    String uuid = UuidUtil.highGenerateId();
    boolean lock = redisLock.tryReentrantLock("reent:lock", uuid, 30);
    if (!lock) {
        return;
    }

    try {
        String count = redisTemplate.opsForValue().get("goods:count");
        log.info("当前库存值,count = {}", count);
        if (!StringUtils.isEmpty(count)) {
            Integer goodsCount = Integer.valueOf(count);
            if (goodsCount > 0) {
                redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
            }
        }

        this.getLockAgain(uuid);

    } finally {
        // 2. 释放 redis 锁
        redisLock.releasesReentrantLock("reent:lock", uuid);

        Map<Object, Object> map3 = redisTemplate.opsForHash().entries("reent:lock");
        log.info("当前锁情况3:" + JacksonUtil.toJson(map3));
        log.info("redis 锁释放成功 ...");
    }
}

public void getLockAgain(String uuid) {
    redisLock.tryReentrantLock("reent:lock", uuid, 30);

    Map<Object, Object> map = redisTemplate.opsForHash().entries("reent:lock");
    log.info("当前锁情况:" + JacksonUtil.toJson(map));

    redisLock.releasesReentrantLock("reent:lock", uuid);

    Map<Object, Object> map2 = redisTemplate.opsForHash().entries("reent:lock");
    log.info("当前锁情况2:" + JacksonUtil.toJson(map2));
}

image.png

5. Redis 分布式锁的错误案例

5.1 setnxexpire 加锁

image.png

5.2 del 释放锁

image.png

image.png

6. Redis 分布式锁大厂解决方案