Redis实现分布式锁

一、锁的种类

常见的锁有两个种类:

  1. 单机版同一个JVM虚拟机内,synchronized或者Lock接口
  2. 分布式多个不同JVM虚拟机,单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了。

二、分布式锁具备的特点

实现的分布式锁,需要具备一下特征:

特点 描述
独占性 任何时刻有且只有一个线程持有使用该锁
高可用

在redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况;

在高并发请求下,分布式锁依旧具有良好的性能;

防死锁 不能出现死锁问题,必须有超时重试机制或者撤销操作,有个终止跳出的途径;
不乱抢 多线程下,防止张冠李戴,只能解锁自己的锁,不能把别人的锁给释放了;
重入性 同一节点的同一线程如果获得锁之后,该线程可以再次获取使用这个锁

三、Redis中分布式锁的实现

在常规的实现方式中Redis锁机制一般是由setnx命令实现,,语法如下:

SETNX key value:

将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。

返回值Integer reply, 特定值:

  • 1 如果key被设置了
  • 0 如果key没有被设置

例如:

# 设置k1的值为1
127.0.0.1:6379> SETNX k1 1
1
# 设置k1的过期时间为60s
127.0.0.1:6379> EXPIRE k1 60
1

但是上面的etnx和expire实现分布式锁的方式是不安全,两条命令非原子性的,并不能保证一致性,所以下面就是自研分布式锁来实现

四、基础案例代码

分布式锁实现功能:多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击),创建两个模块:redis_distributed_lock2和redis_distributed_lock3,内容一样的,直接复制即可

4.1.创建模块:

创建springboot模块:

设置包名,选择项目模式和Java版本

设置模块名称

4.2.添加依赖

在pom.xml引入需要的依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.12</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.augus.redis</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <!--SpringBoot通用依赖模块-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--SpringBoot与Redis整合依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--通用基础配置boottest/lombok/hutool-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

4.3.添加配置文件

在application.properties中添加配置文件

server.port=7777
spring.application.name=redis_distributed_lock

# ========================swagger2=====================
# http://localhost:7777/swagger-ui.html
spring.swagger2.enabled=true
spring.mvc.pathmatch.matching-strategy=ant_path_matcher

# ========================redis单机=====================
spring.redis.database=0
spring.redis.host=192.168.42.132
spring.redis.port=6379
spring.redis.password=123456
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-wait=-1ms
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=0

4.4.在service包下创建 InventoryService

在InventoryService中内容如下:

package com.augus.redis.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    private Lock lock = new ReentrantLock();

    public String sale(){
        String message = "";
        lock.lock();
        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);
            }else {
                message = "商品已经售完";
            }

        }finally {
            lock.unlock();
        }

        return message+"\t"+"服务端口号:"+port;
    }
}

4.5.在controller包下创建 InventoryController

在InventoryController中内容如下:

package com.augus.redis.controller;

import com.augus.redis.service.InventoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Api(tags = "Redis分布式锁测试")
public class InventoryController {
    @Autowired
    private InventoryService inventoryService;

    @ApiOperation("扣减库存,一次卖一个")
    @GetMapping(value = "/inventory/sale")
    public String sale(){
        return inventoryService.sale();
    }
}

4.5.在Redis中创建创建数据模拟商品库存

创建键 inventory001值为100,模拟有100件商品

4.6.测试

保证Redis启动,同时启动redis_distributed_lock2模块,访问swagger:http://localhost:7777/swagger-ui.html,点击执行减扣库存的接口:

查看Redis中数据,发现数据已经扣减

下面开始通过不断地迭代来对自研的分布式锁进行完善

五、Nginx分布式微服务架构

5.1.架构图实现如下

两个订单服务实现负载均衡,都去调用库存模块,库存模块通过Redis缓存库存数据,同时另外通过一个Redis实现分布式锁

5.2.安装nginx,设置负载均衡

下面讲解一下如何在CentOS7系统下快速安装Nginx。

5.2.1.下载Nginx安装包

进入Nginx官网(),点击 download 链接

在 Stable version 中,下载最新版本

5.2.2.安装依赖包

在CentOS7命令行模式下,依次输入以下命令,安装所需的依赖包

yum install -y gcc-c++
yum install -y pcre pcre-devel
yum install -y zlib zlib-devel
yum install -y openssl openssl-devel

5.2.3.将Nginx安装包拷贝到CentOS7系统并解压

工具连接CentOS7系统后这里我们将Nginx安装包放到 /opt 目录下。进入 /home 目录,输入 tar -zxvf nginx-1.24.0.tar.gz 命令,对安装包进行解压缩

5.2.4.配置Nginx

进入/opt/nginx-1.24.0 目录然后解压Nginx文件夹

./configure \
--prefix=/usr/local/nginx \
--pid-path=/var/local/nginx/nginx.pid \
--lock-path=/var/local/nginx/nginx.lock \
--error-log-path=/var/local/nginx/error.log \
--http-log-path=/var/local/nginx/access.log \
--with-http_gzip_static_module \
--http-client-body-temp-path=/var/local/nginx/client \
--http-proxy-temp-path=/var/local/nginx/proxy \
--http-fastcgi-temp-path=/var/local/nginx/fastcgi \
--http-uwsgi-temp-path=/var/local/nginx/uwsgi \
--http-scgi-temp-path=/var/local/nginx/scgi

5.2.5.编译并安装

编译,输入命令:

make

安装,编译完成后,输入命令:

make install

5.2.6.启动Nginx

进入到Nginx目录:

cd /usr/local/nginx/sbin

输入启动命令:

./nginx

此时使用浏览器访问CentOS7服务器的IP地址 http://192.168.42.132/,可以看到Nginx的首页。这里注意需要开启防火墙允许访问

5.2.6.Nginx的常用命令

Nginx的强行停止命令:    # ./nginx -s stop

Nginx的优雅停止命令:    # ./nginx -s quit  // 停止,是等最后一次交互执行完再停止。

Nginx检查配置文件是否有错: # ./nginx -t

Nginx的重新加载命令:    # ./nginx -s reload

查看Nginx版本:       # ./nginx -v

查看Nginx详细版本:     # ./nginx -V

5.2.7.新增反向代理和负载均衡配置

在 /usr/local/nginx/conf 目录下修改配置文件nginx.conf文件,添加配置,如下(建议修改前先保存一份,防止修改出现问题,便于恢复):

# upstream 各服务器地址以及权重,权重越大代表访问率越大
    upstream alie.com {
        server 192.168.1.3:7777 weight=1;
        server 192.168.1.3:8888 weight=1;
    }

    server {

        location / {
            # 反向代理,这里的地址与上面配置的upstream需一致,实现负载均衡
            proxy_pass http://alie.com;
            proxy_redirect default;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

如下图所示:

5.2.8.指定配置文件启动nginx

进入到:/usr/local/nginx/sbin目录,执行如下命令:

./nginx -c /usr/local/nginx/conf/nginx.conf

如下图所示

5.2.9.测试

启动Redis和两个微服务模块,通过nginx访问,地址的nginx服务器的IP,如:http://192.168.42.132/inventory/sale,如下图7777和8888交替出现

5.2.模拟高并发情况下访问

通过jmeter模块高并发下订单,演示商品超卖的情况,这里Redis中商品数量回复设置为100,jmeter设置如下:

使用100个线程1s内访问该接口

http请求设置如下:

执行后,正常而言,应该是redis中商品数量应该为0,才对,可是查看redis发现并非如此

查看控制台7777和8888服务的执行情况,发现98和99商品被卖了两次

为什么会出现上面同一件商品买了两次的情况?加了synchronized或者Lock还是没有控制住?

  • 在单机环境下,可以使用synchronized或Lock来实现。
  • 但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jvm中),所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)
  • 因为不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程

5.3.分布式锁

分布式锁可以使用例如像Redis、zookeeper等中间件实现,需要满足一下几点:

  • 跨进程和跨服务
  • 防止缓存击穿
  • 解决超卖

这里可以通过Redis的分布式锁解决,通过Redis中setnx命令来实现

六、Redis实现分布式锁

6.1.递归重试的方式实现

6.1.1.修改InventoryService方法中内容

这里通过实现Redis所来解决商品超卖的现象,将 redis_distributed_lock2和redis_distributed_lock3的service层,修改 InventoryService为如下内容:

package com.augus.redis.service;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;//3.1版本
    public String sale(){
        String message = "";
        //创建的锁的名字,在Redis中作为键存在
        String lockKey = "redisLock";
        //通过UUID和线程ID组合成值
        String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();

        //在Redis中创建键值对作为锁使用
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuidValue);

        //flag为true表示拿到锁,为false表示没有抢到锁,就要重试
        if(!flag){
            //暂停20毫秒后递归调用,重试获取锁
            try {
                TimeUnit.MILLISECONDS.sleep(20);
                sale();
            }catch (InterruptedException e){
                e.printStackTrace();
            }

        }else {
            try {
                //1.查询库存
                String result = stringRedisTemplate.opsForValue().get("inventory001");
                //2.判断库存是否充足
                Integer inventoryNumber = null;

                if(result==null){
                    inventoryNumber=0;
                }else {
                    inventoryNumber=Integer.parseInt(result);
                }

                //3.减扣库存
                if(inventoryNumber>0){
                    //将库存自减1,然后保存
                    stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                    message = "卖出一件商品,库存剩余:"+inventoryNumber;
                    System.out.println(message);
                }else {
                    message = "商品已经售完";
                }

            }finally {
                //用完了就删除该key
                stringRedisTemplate.delete(lockKey);
            }
        }
        return message+"\t"+"服务端口号:"+port;
    }
}

6.1.2.测试

启动Redis,将键inventory001的值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本

查看控制台,也没有出现商品超卖,一件商品买两次的情况。

查看Redis中商品库存减扣为0

6.2.自旋的方式实现重试

6.2.1.修改service中InventoryService代码

在6.1中通过递归的方式实现重试获取锁的操作,但是递归机制本身存在一定的问题,极其容易出现StackOverflowError 的问题,所以使用while代替if,用自选替代递归重试。修改service中InventoryService代码如下:

package com.augus.redis.service;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    public String sale(){
        String message = "";
        //创建的锁的名字,在Redis中作为键存在
        String lockKey = "redisLock";
        //通过UUID和线程ID组合成值
        String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();

        //这里通过自旋的方式,替换掉之前递归的方式
        while (!stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuidValue)){
            //暂停20毫秒后递归调用,重试获取锁
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);
            }else {
                message = "商品已经售完";
            }

        }finally {
            //用完了就删除该key
            stringRedisTemplate.delete(lockKey);
        }
        return message+"\t"+"服务端口号:"+port;
    }
}

6.2.2.测试

将Redis中模块库存的键inventory001值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本

查看控制台,也没有出现商品超卖,一件商品买两次的情况。这个锁谁拿到了谁用

查看Redis中库存数量,也是为0

七、Redis宕机、锁过期和防止死锁

在第六章节中部署了微服务的Java程序机器挂了,代码层面根本没有走到finally这块,无法保证解锁(无过期时间该key一直存在),这个key没有被删除,所以需要加入一个过期时间限定key,

7.1.分布式锁设置过期时间

但是需要注意加锁和过期时间的设置必须具有原子性,同时完成,如下图:

 代码如下:

package com.augus.redis.service;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    //v3.2版本
    public String sale(){
        String message = "";
        //创建的锁的名字,在Redis中作为键存在
        String lockKey = "redisLock";
        //通过UUID和线程ID组合成值
        String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();

        //这里通过自旋的方式,替换掉之前递归的方式
        while (!stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuidValue,30L,TimeUnit.SECONDS)){
            //暂停20毫秒后递归调用,重试获取锁
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);
            }else {
                message = "商品已经售完";
            }

        }finally {
            //用完了就删除该key
            stringRedisTemplate.delete(lockKey);
        }
        return message+"\t"+"服务端口号:"+port;
    }
}

7.2.测试

启动Redis,将键inventory001的值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本,然后查看Redis中库存数量为0,未出现超卖现象

八、解决误删key的问题

在实际业务中,有可能存在张冠李戴,删除了别人的锁,如下图

这个肯定是需要避免的,要求只能删除自己的锁,修改两个模块service包下InventoryService如下:

代码如下:

package com.augus.redis.service;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    public String sale(){
        String message = "";
        //创建的锁的名字,在Redis中作为键存在
        String lockKey = "redisLock";
        //通过UUID和线程ID组合成值
        String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();

        //这里通过自旋的方式,替换掉之前递归的方式
        while (!stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuidValue,30L,TimeUnit.SECONDS)){
            //暂停20毫秒后递归调用,重试获取锁
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);
            }else {
                message = "商品已经售完";
            }

        }finally {
            // 判断解锁是否和加的锁是同一个客户端,自己只能删除自己的锁,不误删他人的
            // equalsIgnoreCase() 方法用于将字符串与指定的对象比较,不考虑大小写。
            if(stringRedisTemplate.opsForValue().get(lockKey).equalsIgnoreCase(uuidValue)){
                stringRedisTemplate.delete(lockKey);
            }
        }
        return message+"\t"+"服务端口号:"+port;
    }
}

启动Redis,将键inventory001的值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本,然后查看Redis中库存数量为0,未出现超卖现象

九、Lua脚本保证原子性

在第八章节中,增加了误删key的防止,但是finally中判断和删除并不是原子性的,这样会告知各种问题

所以本章就是采用lua脚本编写Redis分布式锁将判断删除key的操作通过原子性来代替,修改如下:

 

 

代码如下:

package com.augus.redis.service;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    //v3.2版本
    public String sale(){
        String message = "";
        //创建的锁的名字,在Redis中作为键存在
        String lockKey = "redisLock";
        //通过UUID和线程ID组合成值
        String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();

        //这里通过自旋的方式,替换掉之前递归的方式
        while (!stringRedisTemplate.opsForValue().setIfAbsent(lockKey, uuidValue,30L,TimeUnit.SECONDS)){
            //暂停20毫秒后递归调用,重试获取锁
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);
            }else {
                message = "商品已经售完";
            }

        }finally {
            //将判断和删除key的操作合并为lua脚本保证原子性
            String luaScript =  "if (redis.call('get',KEYS[1]) == ARGV[1]) then " +
                    "return redis.call('del',KEYS[1]) " +
                    "else " +
                    "return 0 " +
                    "end";

            //执行lua脚本,  Arrays.asList(lockKey)是传入给KEYS[1], ARGV[1]传入的是uuidValue
            // Arrays.asList该方法是将数组转化成List集合的方法。
            stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript,Boolean.class), Arrays.asList(lockKey),uuidValue);

        }
        return message+"\t"+"服务端口号:"+port;
    }
}

启动Redis,将键inventory001的值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本,未出现超卖现象

 

 

同时查看Redis中,商品数量变为0

 

 

十、可重入锁结合设计模式实现和自动续期

可重入锁又名递归锁:是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。

如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了,就很麻烦。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

在Redis中可以通过setnx解决有无的效果,够用但是不够完美,hset不但能解决有无锁机制的问题,还可以解决重入锁机制的问题,下面的实现思路:

  • 引入工厂模式 DistributedLockFactory, 实现 Lock 接口,实现redis的可重入锁
    • lock() 加锁的关键逻辑
      • 加锁 (实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间)
      • 自旋
      • 续期
    • unlock() 解锁关键逻辑
      • 将 Key 键删除,只能自己删自己的锁,不能删除其他线程的锁。
  • 实现自动续期功能的完善,后台自定义扫描程序,如果规定时间内没有完成业务逻辑,会调用加时间自动续期的脚本。

创建mylock包,在下面创建:RedisDistributedLock,内容如下:

package com.augus.redis.mylock;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

class RedisDistributedLock implements Lock {

    private StringRedisTemplate stringRedisTemplate;
    private String lockName; // KEYS[1] 锁名字
    private String uuidValue; // ARGV[1] 值
    private long expireTime; // ARGV[2] 过期时间


    public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName, String uuidValue) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockName = lockName;
        this.uuidValue = uuidValue + ":" + Thread.currentThread().getId();
        this.expireTime = 30L;
    }

    @Override
    public void lock() {
        tryLock();
    }

    @Override
    public boolean tryLock() {
        try {
            tryLock(-1L,TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time == -1L) {
            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";
            System.out.println("lockName = " + lockName +"\t" + "uuidValue = " + uuidValue);
            while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuidValue, String.valueOf(expireTime))) {
                // 暂停 60ms
                Thread.sleep(60);
            }
            // 新建一个后台扫描程序,来监视key目前的ttl,是否到我们规定的 1/2 1/3 来实现续期
            resetExpire();
            return true;
        }
        return false;
    }

    private void resetExpire() {
        //判断该键值是否存在Redis中,如果为1就自动续期,0就是直接已经将key删除了。不需要续期
        String script = "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
                "return redis.call('expire',KEYS[1],ARGV[2]) " +
                "else " +
                "return 0 " +
                "end";

        //java定时器,schedule可以设置多久执行一次
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if (!stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuidValue)){
                    resetExpire();
                }
            }
        }, (this.expireTime * 1000) / 3);//设置过期时间,单位是毫秒,所以*1000,也就是10s就需要判断一次是否需要续期
    }

    //实现解锁
    @Override
    public void unlock() {
        //如果
        //Redis Hincrby 命令用于为哈希表中的字段值加上指定增量值。增量也可以为负数,相当于对指定字段进行减法操作。
        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";

        // nil = false 1 = true 0 = false
        System.out.println("lockName: "+lockName);
        System.out.println("uuidValue: "+uuidValue);
        System.out.println("expireTime: "+expireTime);
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
        if(flag == null)
        {
            throw new RuntimeException("This lock doesn't EXIST");
        }
    }

    // 下面两个用不上
    @Override
    public Condition newCondition() {
        return null;
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }
}

创建mylock包,在下面创建DistributedLockFactory引入工厂模式,去实例化RedisDistributedLock类对象然后调用,内容如下:

package com.augus.redis.mylock;

import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.locks.Lock;

@Component
public class DistributedLockFactory
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    private String lockName;
    private String uuidValue;

    public DistributedLockFactory()
    {
        this.uuidValue = IdUtil.simpleUUID();//UUID
    }

    public Lock getDistributedLock(String lockType)
    {
        if(lockType == null) return null;

        if(lockType.equalsIgnoreCase("REDIS")){
            lockName = "hiRedisLock";
            return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
        } else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
            //zookeeper版本的分布式锁实现
            //return new ZookeeperDistributedLock();

            return null;
        } else if(lockType.equalsIgnoreCase("MYSQL")){
            //mysql版本的分布式锁实现
            return null;
        }
        return null;
    }
}

修改service包中的 InventoryService,代码如下:

package com.augus.redis.service;

import com.augus.redis.mylock.DistributedLockFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.locks.Lock;

@Service
public class InventoryService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String port;

    @Resource
    private DistributedLockFactory distributedLockFactory;


    //最终版本实现自动续期功能的完善,后台自定义扫描程序,如果规定时间内没有完成业务逻辑,会调用加钟自动续期的脚本
    public String sale(){
        String message = "";

        //指定锁的类型
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");

        //加锁
        redisLock.lock();

        try {
            //1.查询库存
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2.判断库存是否充足
            Integer inventoryNumber = null;

            if(result==null){
                inventoryNumber=0;
            }else {
                inventoryNumber=Integer.parseInt(result);
            }

            //3.减扣库存
            if(inventoryNumber>0){
                //将库存自减1,然后保存
                stringRedisTemplate.opsForValue().set("inventory001", String.valueOf(--inventoryNumber));
                message = "卖出一件商品,库存剩余:"+inventoryNumber;
                System.out.println(message);

                // 演示自动续期的的功能
                /*try {
                    TimeUnit.SECONDS.sleep(120);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }*/
            }else {
                message = "商品已经售完";
            }

        }finally {
            //释放锁
            redisLock.unlock();
        }
        return message+"\t"+"服务端口号:"+port;
    }
}

启动Redis,将键inventory001的值设置为100,重启 redis_distributed_lock2和redis_distributed_lock3微服务模块,执行jmeter中测试脚本,未出现超卖现象

posted @ 2023-04-13 14:55  酒剑仙*  阅读(209)  评论(0)    收藏  举报