为什么需要分布式锁
当并发去读写⼀个【共享资源】的时候,我们为了保证数据的正确,需要控制同⼀时刻只有⼀个线程访问。分布式锁就是⽤来控制同⼀时刻,只有⼀个 JVM 进程中的⼀个线程可以访问被保护的资源。
在同一个JVM虚拟机内,可以使用synchronized或者Lock接口,但是在分布式的环境下,有多个不同JVM虚拟机时单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了,这个时候就需要分布式锁了。
分布式锁的特点
-
独占排他互斥
- 可以通过 setnx (redis命令:执行多次,只有一次能够成功)
- set key value ex 3 nx
-
防死锁发生
-
请求获取到锁之后,服务器挂掉了,导致锁无法释放:给lock锁添加过期时间
- 通过 set key value ex 3 nx
- 通过redis命令 expire
-
-
保证原子性
- 获取锁和设置过期时间之间
- 判断和删除之间:lua脚本
-
自动续期
-
防误删
- uuid给每个线程的锁添加唯一标识
-
可重入
- hash数据结构 + lua脚本
基于redis分布式锁的实现
使用场景:多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)
创建redis项目
- 创建名称为redis_distributed_lock_1的maven项目
- 修改pom文件
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.distribute.redislock</groupId>
<artifactId>redis_distributed_lock_1</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.12</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<lombok.version>1.16.18</lombok.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>
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.4</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>
-
创建yml文件
创建名称为application.properties的配置文件
server.port=8081 spring.application.name=redis_distributed_lock2 # ========================swagger2===================== # http://localhost:8081/swagger-ui.html swagger2.enabled=true spring.mvc.pathmatch.matching-strategy=ant_path_matcher # ========================redis\u5355\u673A===================== spring.redis.database=0 spring.redis.host=192.168.181.8 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
-
修改主启动类
package com.distribute.redislock; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class RedisDistributedLockApp8081 { public static void main(String[] args) { SpringApplication.run(RedisDistributedLockApp8081.class,args); } }
-
编写业务类代码
创建RedisConfig类
package com.distribute.redislock.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) { RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(lettuceConnectionFactory); //设置key序列化方式string redisTemplate.setKeySerializer(new StringRedisSerializer()); //设置value的序列化方式json redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.afterPropertiesSet(); return redisTemplate; } }
创建Swagger2Config类
package com.distribute.redislock.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Configuration @EnableSwagger2 public class Swagger2Config { @Value("${swagger2.enabled}") private Boolean enabled; @Bean public Docket createRestApi() { return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) .enable(enabled) .select() .apis(RequestHandlerSelectors.basePackage("com.atguigu.redislock")) //你自己的package .paths(PathSelectors.any()) .build(); } private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now())) .description("springboot+redis整合") .version("1.0") .termsOfServiceUrl("https://www.baidu.com/") .build(); } }
创建InventoryService类
package com.distribute.redislock.service; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @Service @Slf4j public class InventoryService { @Autowired private StringRedisTemplate stringRedisTemplate; @Value("${server.port}") private String port; private Lock lock = new ReentrantLock(); public String sale() { String retMessage = ""; lock.lock(); try { //1 查询库存信息 String result = stringRedisTemplate.opsForValue().get("inventory001"); //2 判断库存是否足够 Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result); //3 扣减库存 if(inventoryNumber > 0) { stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber)); retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber; System.out.println(retMessage); }else{ retMessage = "商品卖完了,o(╥﹏╥)o"; } }finally { lock.unlock(); } return retMessage+"\t"+"服务端口号:"+port; } }
创建InventoryController类
package com.distribute.redislock.controller; import com.distribute.redislock.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(); } }
Redis实现分布式锁初始化
复制项目,将8081的业务逻辑拷贝到8082,这样有两个后台项目,模拟多个微服务的情况。
Nginx分布式服务架构
为了方便直接在PC电脑上安装nginx,也可以在linux系统中安装,安装教程可以百度,出现如下所示,说明安装成功。
在配置中添加负载均衡设置
upstream mynginx {
server 127.0.0.1:8081 weight=1;
server 127.0.0.1:8082 weight=2;
}
启动两个服务8081和8082,请求链接http://127.0.0.1/inventory/sale,可以看到可以轮询请求8081和8082服务器。
使用apifox压测请求,如下图所示。
如下图所示,可以看到两个服务,同一个商品卖出了多次。
可以得出结论,在单机环境下,可以使用synchronized或Lock来实现。
但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jvm中),所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程。
使用自旋锁重试
public String sale() {
String resMessgae = "";
String key = "distributeRedisLock";
String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();
//用自旋调用;也不用if,用while代替
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) {
// 线程休眠20毫秒,进行递归重试
try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e) {e.printStackTrace();}
}
try {
// 1 抢锁成功,查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory01");
// 2 判断库存书否足够
Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
// 3 扣减库存,每次减少一个库存
if (inventoryNum > 0) {
stringRedisTemplate.opsForValue().set("inventory01", String.valueOf(--inventoryNum));
resMessgae = "成功卖出一个商品,库存剩余:" + inventoryNum + "\t" + ",服务端口号:" + port;
log.info(resMessgae);
} else {
resMessgae = "商品已售罄。" + "\t" + ",服务端口号:" + port;
log.info(resMessgae);
}
} finally {
stringRedisTemplate.delete(key);
}
return resMessgae;
}
宕机与过期防止死锁
部署了微服务的Java程序挂了,代码层面根本没有走到finally这块,没办法保证解锁(无过期时间该key一直存在),这个key没有被删除,需要加入一个过期时间限定key。
解决办法
-
高并发多线程下的一致性和原子性,设置了key+过期时间分开了,必须合并成一行具备原子性
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) { // 线程休眠20毫秒,进行递归重试 try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e){e.printStackTrace();} } // 设置过期时间 stringRedisTemplate.expire(key, 30L, TimeUnit.SECONDS);
-
加锁和过期时间设置必须同一行,保证原子性
public String sale()
{
String retMessage = "";
String key = "distributeRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
{
log.info("没有获取到锁");
//暂停毫秒
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
stringRedisTemplate.delete(key);
}
return retMessage+"\t"+"服务端口号:"+port;
}
如下图所示,保证了每次请求只有一个服务可以扣减。
防止误删key的问题
- 进程1 获取锁成功并设置设置 30 秒超时;
- 进程1 因为⼀些原因导致执⾏很慢(⽹络问题、发⽣ FullGC……),过了 30 秒依然没执⾏完,但是锁过期“⾃动释放了”;
- 进程2 申请加锁成功;
- 进程1 执⾏完成,执⾏ DEL 释放锁指令,这个时候就把线程2 的锁给释放了。
解决办法
在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁,这里的唯一标识就是uuidValue。
public String sale()
{
String retMessage = "";
String key = "distributeRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
{
log.info("没有获取到锁");
//暂停毫秒
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
// 改进点,判断加锁与解锁是不同客户端,自己只能删除自己的锁,不误删别人的锁
if (stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)) {
stringRedisTemplate.delete(key);
}
}
return retMessage+"\t"+"服务端口号:"+port;
}
Lua脚本保证原子性
可以看出,finally中的代码块并不是原子性的,所以需要使用lua脚本来保证原子性。
public String sale()
{
String retMessage = "";
String key = "distributeRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key,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 = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存,每次减少一个
if(inventoryNumber > 0)
{
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余:"+inventoryNumber;
System.out.println(retMessage+"\t"+"服务端口号"+port);
testReEnter();
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
//改进点,修改为Lua脚本的redis分布式锁调用,必须保证原子性,参考官网脚本案例
String luaScript =
"if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
stringRedisTemplate.execute(new DefaultRedisScript(luaScript,Boolean.class), Arrays.asList(key),uuidValue);
}
return retMessage+"\t"+"服务端口号"+port;
}
private void testReEnter()
{
String key = "distributeRedisLock";
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
while(!stringRedisTemplate.opsForValue().setIfAbsent(key,uuidValue,30L,TimeUnit.SECONDS))
{
//暂停20毫秒,进行递归重试.....
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
// redislock();
// //biz......
// unredislock();
//改进点,修改为Lua脚本的redis分布式锁调用,必须保证原子性,参考官网脚本案例
String luaScript =
"if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
stringRedisTemplate.execute(new DefaultRedisScript(luaScript,Boolean.class), Arrays.asList(key),uuidValue);
}
执行上述代码发现,当自己持有锁的时候,想再次获取所得时候,发现无法获取,只有等到锁释放了,才能重入,显然这样是不可以的,所以需要实现锁的可重入性。
可重入性锁
什么是可重入性锁
指同一个线程可以多次获取同一个锁,而不会导致死锁或其他线程无法获取该锁的情况,可重入锁是一种特殊的锁,它允许一个线程在已经持有该锁的情况下,再次获取(或重入)该锁,而不会产生冲突或死锁,synchronized和ReentrantLock就是可重入性锁。
当线程拥有锁之后,往后再遇到加锁⽅法,直接将加锁次数加 1,然后再执⾏⽅法逻辑。 退出加锁⽅法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。 可以看到可重⼊锁最⼤特性就是计数,计算加锁的次数。 所以当可重⼊锁需要在分布式环境实现时,我们也就需要统计加锁次数。
加锁逻辑
我们可以使⽤ Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁 的次数。
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
加锁代码⾸先使⽤ Redis exists 命令判断当前 lock 这个锁是否存在。 如果锁不存在的话,直接使⽤ hincrby 创建⼀个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0, 然后再次加 1,最后再设置过期时间。 如果当前锁存在,则使⽤ hexists 判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使 ⽤ hincrby 加 1,最后再次设置过期时间。 最后如果上述两个逻辑都不符合,直接返回。
测试是否可以重入
#在redis客户端中执行命令
#加锁命令
EVAL "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" 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 30
#查询结果,查询到2说明可以累加
> HGET distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
2
解锁逻辑
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
⾸先使⽤ hexists 判断 Redis Hash 表是否存给定的域。 如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil 。 若存在的情况下,代表当前锁被其持有,⾸先使⽤ hincrby 使可重⼊次数减 1 ,然后判断计算之后可重⼊次数, 若⼩于等于 0,则使⽤ del 删除这把锁。
解锁代码执⾏⽅式与加锁类似,只不过解锁的执⾏结果返回类型使⽤ Long 。这⾥之所以没有跟加锁⼀样使⽤ Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下: 1 代表解锁成功,锁被释放 0 代表可重⼊次数被减 1,null 代表其他线程尝试解锁,解锁失败。
测试是否删除
#执行解锁逻辑,根据解锁情况可以看到解锁是否成功
> eval "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 " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
0
> eval "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 " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
1
> eval "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 " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
null
将lua脚本集成到java代码中
- 初始化sale方法
//注释之前的代码,新建方法,初始化为无锁版
public String sale()
{
String retMessage = "";
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t";
System.out.println(retMessage);
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
return retMessage+"\t"+"服务端口号:"+port;
}
- 新建RedisDistributedLock类并实现JUC里面的Lock接口
package com.distribute.redislock.myLock;
import cn.hutool.core.util.IdUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
//@Component 引入DistributedLockFactory工厂模式,从工厂获得而不再从spring拿到
public 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)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();//UUID:ThreadID
this.expireTime = 30L;
}
@Override
public void lock()
{
tryLock();
}
@Override
public boolean tryLock()
{
try {
tryLock(-1L,TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 实现加锁功能,实现这一个干活的就OK,全盘通用
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException{
if(time != -1L){
this.expireTime = unit.toSeconds(time);
}
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("script: "+script);
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
TimeUnit.MILLISECONDS.sleep(50);
}
return true;
}
/**
*实现解锁功能
*/
@Override
public void unlock()
{
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 void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
- 使用工厂模式实现可扩展接口
package com.distribute.redislock.myLock;
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;
public Lock getDistributedLock(String lockType)
{
if(lockType == null) return null;
if(lockType.equalsIgnoreCase("REDIS")){
lockName = "distributeRedisLock";
return new RedisDistributedLock(stringRedisTemplate,lockName);
} else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
//TODO zookeeper版本的分布式锁实现
return null;
} else if(lockType.equalsIgnoreCase("MYSQL")){
//TODO mysql版本的分布式锁实现
return null;
}
return null;
}
}
- 修改InventoryService类的sale方法业务方法
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0)
{
inventoryNumber = inventoryNumber - 1;
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t服务端口:" +port;
System.out.println(retMessage);
}else {
retMessage = "商品卖完了,o(╥﹏╥)o"+"\t服务端口:" +port;
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage;
}
- 单机和并发测试通过
- 可重入锁问题
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存
if(inventoryNumber > 0)
{
inventoryNumber = inventoryNumber - 1;
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t服务端口:" +port;
System.out.println(retMessage);
testReEnter();
}else {
retMessage = "商品卖完了,o(╥﹏╥)o"+"\t服务端口:" +port;
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################测试可重入锁#######");
}finally {
redisLock.unlock();
}
}
启动服务,请求http://127.0.0.1/inventory/sale,发现问题,ThreadId一致,但是UUID不一致。
lockName: distributeRedisLock
uuidValue: fed6f92ac7d7497485b8a14284a501ac:32
expireTime: 30
lockName: distributeRedisLock
uuidValue: badbf6bb94534409b1c5edfaea3d640a:32
expireTime: 30
- 解决方案
DistributedLockFactory新增一个无参构造函数。
@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 = "distributeRedisLock";
//uuidValue由工厂方法中传入
return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
} else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
//TODO zookeeper版本的分布式锁实现
return null;
} else if(lockType.equalsIgnoreCase("MYSQL")){
//TODO mysql版本的分布式锁实现
return null;
}
return null;
}
}
RedisDistributedLock修改构造方法,增加了uuidValue参数。
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName,String uuidValue)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
this.expireTime = 30L;
}
- 测试是否可以重入
使用@Autowired创建的工厂类是一个单例的,在Spring进行注入的时候已经初始化好了,所以所有线程产生的UUID都是一样的。
自动续期
如果RedisLock过期时间大于业务执行时间怎么办?
只要客户端一旦加锁成功,就会启动一个后台线程,会每隔10秒检查一下,如果客户端还持有锁key,那么就会不断的延长锁key的生存时间。
默认情况下,加锁的时间是30秒,.如果加锁的业务没有执行完,就会进行一次续期,把锁重置成30秒。
- 修改RedisDistributedLock类,添加自动续期方法
**
* 实现加锁功能,全盘通用
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (-1 == time) {
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("lock() lockName:" + lockName + "\t" + "uuidValue:" + uuidValue);
// 加锁失败需要自旋一直获取锁
while (!stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Arrays.asList(lockName),
uuidValue,
String.valueOf(expireTime))) {
// 休眠60毫秒再来重试
try {
TimeUnit.MILLISECONDS.sleep(60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 新建一个后台扫描程序,来检查Key目前的ttl,是否到我们规定的剩余时间来实现锁续期
resetExpire();
return true;
}
return false;
}
// 自动续期
private void resetExpire() {
String script =
"if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
"return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
"return 0 " +
"end";
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if (stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Arrays.asList(lockName),
uuidValue,
String.valueOf(expireTime))) {
// 续期成功,继续监听
System.out.println("resetExpire() lockName:" + lockName + "\t" + "uuidValue:" + uuidValue);
resetExpire();
}
}
}, (this.expireTime * 1000 / 3));
}
- 修改InventoryService业务类
//实现自动续期功能的完善,后台自定义扫描程序,如果规定时间内没有完成业务逻辑,会调用加钟自动续期的脚本
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查询库存信息
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判断库存是否足够
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣减库存,每次减少一个
if(inventoryNumber > 0)
{
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功卖出一个商品,库存剩余:"+inventoryNumber;
System.out.println(retMessage+"\t"+"服务端口号"+port);
//暂停120秒钟线程,故意的,演示自动续期的功能。。。。。。
try { TimeUnit.SECONDS.sleep(120); } catch (InterruptedException e) { e.printStackTrace(); }
}else{
retMessage = "商品卖完了,o(╥﹏╥)o";
}
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服务端口号"+port;
}
- 测试是否续期
可以看到续期成功。
小结
加锁关键逻辑
- 加锁:加锁实际上就是在redis中,给key设置一个值,为了避免死锁,并给一个过期时间
- 可重入:加锁的LUA脚本,通过redis里面的hash数据类型,加锁和可重入性都要保证
- 自旋:加锁不成,需要while进行重试并自旋,AQS
- 续期:在过期时间内,一定时间内业务还未完成,自动给锁续期
解锁关键逻辑
- 将redis的key删除,但是也不能乱删,不能说客户端1的请求将客户端2的锁给删掉,只能自己删除自己的锁
- 考虑可重入性的递减,加锁几次就需要删除几次
- 最后到零了,直接del删掉
上述实现的分布式锁存在的问题
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
这样就会导致客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
红锁
Redis作者针对Redis分布式锁的缺点提出了红锁的概念算法如下:
- 顺序向五个节点请求加锁
- 根据一定的超时时间来推断是不是跳过该节点
- 三个节点加锁成功并且花费时间小于锁的有效期
- 认定加锁成功
也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。这只是举个例子,实际上并不应该等每个节点那么长时间,就像官网所说的那样,假设有效期是10秒,那么单个redis实例操作超时时间,应该在5到50毫秒(注意时间单位)还是假设我们设置有效期是30秒,图中超时了两个redis节点。那么加锁成功的节点总共花费了3秒,所以锁的实际有效期是小于27秒的。即扣除加锁成功三个实例的3秒,还要扣除等待超时redis实例的总共时间。