Redis 分布式锁
前提条件:
初始库存:50
SpringBoot工程搭建:
- pom.xml依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
- application.yaml配置
server:
port: 8081
spring:
redis:
host: 192.168.216.129
port: 6379
1、单机场景下,请问会出现超卖场景?
@RestController
public class StockController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
String stockKey = "stock";
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
return "succees";
}
}
理论分析:
压测结果(利用jmeter工具,模拟100个线程同时访问该接口,并测10组下的结果):
jmeter配置如下:
- Thread Group配置
- Http Request配置
- Aggregate Report配置,保存报告生成位置
解决方案:
使用synchronized同步代码块,即可。
压测结果(无超卖):
2、分布式场景下,请问会出现超卖场景?
负债均衡架构:
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
String stockKey = "stock";
synchronized (this) {
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
}
return "succees";
}
理论分析:
会出现超卖场景,因为synchronized锁住的代码块,仅生效于单个服务器上,多个服务器之间,锁是不生效的。代表两个服务器之间可能获取到redis同一条数据,再在同一条数据上做减库存操作,出现超卖。
IDEA修改端口号及修改启动方式支持平行,快速开启两个tomcat服务:
jmeter修改访问地址为nginx地址:
压测结果(超卖,发现两台服务器出现相同的库存,代表同一时刻获取到了一样的库存,再做减库存操作):
3、分布式场景下超卖现象如何解决呢?
方案架构图:
3.1 解决方案一:利用SETNX
方案详解:
SETNX:当给锁指定的key设值时,若key不存在,则设置成功,返回1。相反,若key存在,则设置失败,返回0。
利用该思路,我们设计一个共同标识,加锁相当于执行SETNX,加锁成功,返回1,执行业务,相反,设值失败,代表别的线程正在执行,该线程等待。解锁相当于删除共同标识,让别的线程可以重新得到锁。
代码修改第一版:
@RestController
public class StockController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
// 分布式锁key定义
String redisLock = "distributed-lock";
// 设值一个唯一标识,保障改线程仅能释放自己加的锁,不能释放别的线程的锁
UUID clientId = UUID.randomUUID();
String stockKey = "stock";
try {
// 相当于执行 setnx distributed-lock lock
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(redisLock, clientId.toString());
// 设值失败,相当于没有得到锁,通过自旋方式尝试获得锁
if (!result) {
// 如果没有抢到锁,返回一个错误码,让前台做友好提示
return "error";
}
// 减库存
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
return "succees";
} finally {
// 仅加锁的线程,能够释放该锁
if (clientId.toString().equals(stringRedisTemplate.opsForValue().get(redisLock))) {
// 放在finally中执行,保障异常场景下,锁能够得到释放
stringRedisTemplate.delete(redisLock);
}
}
}
}
压测结果(无超卖):
想想上述代码可能还有那些问题?
问题:
因为setnx设置的key是永久的,代表如果线程A获得锁,迟迟不释放,比如运行10多分钟都不释放,那么别的线程都得不到锁,整个服务就无法正常对外提供。
如何解决:
问题点在key是永久的,那么给key设置一个过期时间,比如30s,当线程A如果30s内仍然不释放锁,那么key过期,别的线程依旧能够正常获取到锁,正常往下执行。
解决代码如下:
@RestController
public class StockController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
// 分布式锁key定义
String redisLock = "distributed-lock";
// 设值一个唯一标识,保障改线程仅能释放自己加的锁,不能释放别的线程的锁
UUID clientId = UUID.randomUUID();
String stockKey = "stock";
try {
// 相当于执行 setnx distributed-lock lock
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(redisLock, clientId.toString());
// 设值过期时间为30s
stringRedisTemplate.expire(redisLock, 30, TimeUnit.MILLISECONDS);
// 设值失败,相当于没有得到锁,通过自旋方式尝试获得锁
if (!result) {
// 如果没有抢到锁,返回一个错误码,让前台做友好提示
return "error";
}
// 减库存
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
return "succees";
} finally {
// 仅加锁的线程,能够释放该锁
if (clientId.toString().equals(stringRedisTemplate.opsForValue().get(redisLock))) {
// 放在finally中执行,保障异常场景下,锁能够得到释放
stringRedisTemplate.delete(redisLock);
}
}
}
}
想想上述代码是否还有问题?
问题:
// 相当于执行 setnx distributed-lock lock
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(redisLock, clientId.toString());
// 设值过期时间为30s
stringRedisTemplate.expire(redisLock, 30, TimeUnit.MILLISECONDS);
由于上述两段代码不是原子执行的,所以有可能第一个方法执行完,系统宕机,那么也会出现锁得不到释放。
解决办法:
将两个方法,提成原子执行即可。
@RestController
public class StockController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
// 分布式锁key定义
String redisLock = "distributed-lock";
// 设值一个唯一标识,保障改线程仅能释放自己加的锁,不能释放别的线程的锁
UUID clientId = UUID.randomUUID();
String stockKey = "stock";
try {
// 相当于执行 setnx distributed-lock lock
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(redisLock, clientId.toString(), 30, TimeUnit.MILLISECONDS);
// 设值失败,相当于没有得到锁,通过自旋方式尝试获得锁
if (!result) {
// 如果没有抢到锁,返回一个错误码,让前台做友好提示
return "error";
}
// 减库存
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
return "succees";
} finally {
// 仅加锁的线程,能够释放该锁
if (clientId.toString().equals(stringRedisTemplate.opsForValue().get(redisLock))) {
// 放在finally中执行,保障异常场景下,锁能够得到释放
stringRedisTemplate.delete(redisLock);
}
}
}
}
想想上述代码是否还有问题?
问题:
假如线程A获得锁,30s没有把业务完成,比如还未获取库存,锁过期了,那么线程B获取锁,继续减库存,由于线程A业务还没完成,所以库存还未减,那么此时相当于两个线程同时去执行减库存了,也会出现超卖,不过概率很小。
解决:
Redisson
3.2 解决方案二:Redisson
- 添加依赖
<!-- springboot整合redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.0</version>
</dependency>
- 注入Redisson对象,定义Redisson Bean
@Configuration
public class ReddsionConfig {
/**
* 配置Redisson,来获取到redisLock
*
* @return Redisson
*/
@Bean
public Redisson redisson() {
// Redisson为单机模式
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.216.129:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
}
- 业务代码使用Redisson加锁、解锁实现分布式锁
@RestController
public class StockController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private Redisson redisson;
@RequestMapping("/distributed-lock/substract-stock")
public String subtractStock() {
// 分布式锁key定义
String redisLock = "distributed-lock";
// 获取分布式锁,注意,此处默认过期时间是30s,由于没有显示定义过期时间,所以会默认给key续命
RLock redissonLock = redisson.getLock(redisLock);
try {
// 加锁
redissonLock.lock();
String stockKey = "stock";
// 减库存
int stock = Integer.parseInt(Objects.requireNonNull(stringRedisTemplate.opsForValue().get(stockKey)));
if (stock > 0) {
stock = stock - 1;
stringRedisTemplate.opsForValue().set(stockKey, stock + "");
System.out.println("减库存成功,当前库存:" + stock);
} else {
System.out.println("库存不足");
}
return "succees";
} finally {
// 解锁
redissonLock.unlock();
}
}
}
- 压测结果(无超卖)
注意点:
-
RLock redissonLock = redisson.getLock(redisLock);
不显示给定key默认过期时间,Ression底层会给定30s过期时间,并且每隔30*1/3=10s,发现业务仍为执行完成,会继续续命到30s。
-
redissonLock.lock(30, TimeUnit.MILLISECONDS);
显示给定超时时间,实际就是key过期时间,如果到了key过期时间,业务仍为执行完成,可能被别的线程解锁,导致报错,报错信息:java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id
Redisson底层原理图:
想想上述方案是否还有问题?
如果Redis搭哨兵集群方式,如果某个线程使用Ression加锁成功,突然Redis主节点宕机了,该锁还未来得及释放,那么Redis从节点此时选举为主节点,其Redis中的数据,相比主节点有缺失,可能之前锁信息丢失,导致别的线程又能获取到锁,造成分布式锁失效。
解决办法:
可以使用Zookeeper集群方式代替Redis方式,不过性能大大降低,所以需要在性能和高可用方面做取舍。