我相信只有先去体验才能更好的感知。 -- 微微一笑
引言
随着互联网业务的不断发展,流量、用户和数据存储,包括性能问题和可维护性,单体应用中都面临诸多瓶颈。为更好的维护系统的高可用,如今多数企业采用分布式系统架构。 在现代分布式系统中,保障数据一致性和并发控制至关重要。
分布式锁作为一种关键的工具,用于协调多节点之间对共享资源的访问。在本文中,我们将从场景出发,引入分布式锁的概念、实现方式以及深入探讨Redis分布式锁的实现原理。阅读此文,您将收获:
整体思路
我们借助经典的秒杀案例说明,缘起于锁 的理解,进而引入分布式锁的概念。本文整体思路如下:
案例分析
前期准备
- 场景描述: 现有一种上架商品,参与月底促销活动。
- 商品详情:库存量:100,为方便测试,存入redis中;
- 订购详情:同一时间线上抢购人数:1000人
- 环境配置:
- redis 6.2
- JDK 1.8
- maven 3.9
- SpringBoot 2.5
- apache-jmeter-5.6.1
- 配置application.yml
yaml
spring:
redis:
host: 1.92.80.47
port: 6379
database: 1
- pom.xml引入redis-starter
xml
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 库存数据准备:
至此,前期准备已完毕 。下面逐一进行场景切入。
场景一:无锁状态并发出现超卖
1、架构设计
- 程序设计:
ini
@RestController
public class IndexController {
@Autowired
private StringRedisTemplate redisTemplate;
int i = 1;
@RequestMapping("/noLock/reduceStock")
public String reduceStock() {
String msg = "";
//用redis模拟数据库
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
redisTemplate.opsForValue().set("stock", realStock + "");
msg = "当前客户:" + (i++) + " 购买成功,剩余:" + realStock;
System.out.println(msg);
} else {
msg = "购买失败,库存不足";
System.out.println(msg);
}
return msg;
}
}
- 场景模拟: jemeter模拟1000人同时下单
- 测试结果
- 结果分析
显然,测试结果显示:有不同的客户端请求获得了同一个库存99,出现超卖现象。为了避免类似的情况发生,我们通过加锁的方式控制并发。
JVM锁并发控制
场景二:单机环境下使用JVM锁 synchronized
1、架构设计
- 程序设计:
ini
@RequestMapping("/jvmLock/reduceStock")
public String reduceStock() {
String msg = "";
//JVM锁,this指当前线程操作对象
synchronized (this) {
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
redisTemplate.opsForValue().set("stock", realStock + "");
msg = "当前客户:" + (i++) + " 购买成功,剩余:" + realStock;
System.out.println(msg);
} else {
msg = "购买失败,库存不足";
System.out.println(msg);
}
return msg;
}
}
- 场景模拟: 用jemeter再次模拟1000人同时下单,多测试几次,避免偶发因素带来的结果不准确。
- 测试结果
共有100用户下单,库存扣减为:0,结果符合预期。
- 结果分析
根据测试结果,单机环境下,使用jvm锁synchronized可以解决并发产生的超卖问题。
场景二:分布式环境下使用JVM锁 synchronized
1、架构设计
- 程序设计:同场景二,这里注意下启动配置:
bash
#模拟分布式环境:
Pod1: 8080 url : http://localhost:8080/jvmLock/reduceStock
Pod2: 8081 url : http://localhost:8081/jvmLock/reduceStock
IDEA启动参数配置: VM-Option: -Dserver.port=8081
- 场景模拟:
分别启动 Pod1:8080 和 Pod2:8081,重复场景二测试。
- 测试结果 Pod1:8080
Pod2:8081
结果显示:分布式环境下,使用jvm锁synchronized失效。
- 结果分析
jvm锁只对当前所在的单机环境生效,对多实例分布式环境需要使用分布式锁
分布式锁并发控制
分布式锁是一种用于协调分布式系统中多个节点对共享资源进行访问的机制。
所谓共享资源:
- 在电商系统中可能是一类商品库存
- 在金融系统中可能是一笔账户余额
- 在订单系统中可能是一个订单编号
- 在工单系统中可能是一条代办数据 ......
换句话说,就是在分布式系统中多个节点(每个节点都是一个单独的线程)操作同一个数据(共享资源)。而同一时刻一次只有一个线程可操作,这样才能保持数据安全性和一致性。实现这个功能的就是分布式锁。
Redis分布式锁:setNX+Lua
一般来说,我们使用setnx+expire 实现
makefile
127.0.0.1:6379> setnx key value
127.0.0.1:6379> expire key seconds
在java代码中
ini
String lockKey = "product_01";
String versionId = UUID.randomUUID().toString();
//setnx key value @link1
redisTemplate.opsForValue().setIfAbsent(lockKey,versionId);
//expire key seconds @link2
redisTemplate.expire(lockKey,30, TimeUnit.SECONDS);
但是,在使用中会有诸多问题值得考量。
问题一:@link1加锁和@link2超时设置是分开执行的,如果@link1执行成功,@link2执行失败,则锁得不到释放,造成死锁。我们迫切需要这两行命令能保证一起执行(即原子性操作)
解决方案:
利用Redis提供了这样的命令
vbnet
127.0.0.1:6379> SET key value EX SECONDS
相应的在SpringBoot中
ini
//原子操作
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, versionId, 30, TimeUnit.SECONDS);
问题二:现在原子性操作的问题已经解决,但是细心的同学一定会发现:本次设置的过期时间30s. 那么真实环境下如何选择更加合理过期时间呢?
事实上,选择合理的过期时间取决于你的应用场景和业务需求。过期时间的设置应该根据具体的情况来权衡以下几个因素:
-
业务需求: 考虑你的业务逻辑和对数据的敏感度。某些数据可能需要更短的过期时间,以确保及时性,而对于一些相对静态的数据,可以设置较长的过期时间。
-
系统访问频率: 如果某个键值对的访问频率很高,可以考虑设置较短的过期时间,以便及时释放资源。相反,如果访问频率较低,可以适度延长过期时间,减少过期操作的频率。
-
内存和存储成本: 长时间保持数据可能会占用更多的内存和存储资源。考虑系统的整体容量和资源使用情况,适度设置过期时间以释放不再需要的数据。
关于这过期时间的考虑,我们希望有一种程序能实现锁的监控和自动续期。后边还会总结一篇《常用经典分布式锁方案》,敬请期待。
基于此,目前我们给出完整的程序设计:
ini
@RequestMapping("/redisLock/reduceStock")
public String reduceStock() {
String lockKey = "product_01"; //key
String versionId = UUID.randomUUID().toString(); //value
//原子操作
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, versionId, 30, TimeUnit.SECONDS);
if (!result) {
return "系统异常";
}
String msg = "";
try {
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
redisTemplate.opsForValue().set("stock", realStock + "");
msg = "减库存成功,剩余:" + realStock;
System.out.println(msg);
} else {
msg = "减库存失败,库存不足";
System.out.println(msg);
}
} finally {
redisTemplate.delete(lockKey);
}
return msg;
}
问题三:上述程序体现了原子性加锁,执行过后释放锁。看似完美。但是在高并发环境下,由于系统慢查询、FullGC、网络抖动等因素,不可避免的出现线程A释放线程B的锁的问题。
示意图如下:
显然,线程B未执行完毕,锁已经被释放。这样如果在同一时刻,出现多个类似情况,那整个系统将进入无锁状态,混乱不堪。
如何解决锁被别的线程释放的问题?
解决方案:
在删除之前进行逻辑判断,判断是自身的锁才进行释放。通常使用versionId,程序简化代码:
scss
String versionId = UUID.randomUUID().toString();
//原子操作
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, versionId, 30, TimeUnit.SECONDS);
try {
//执行业务逻辑
} finally {
//判断如果是自己加的锁,就释放
if (versionId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
关于versionId序列号的选择,建议选择UUID或者分布式ID,而尽量不要使用线程ThreadId,因为分布式环境下,无法保证ThreadId不会重复。
问题四:上述代码如果在delete(lockKey)之前出现网络抖动,依然会出现线程错误释放锁的可能,如何处理?
详情如下;
csharp
finally {
if (versionId.equals(redisTemplate.opsForValue().get(lockKey))) {
//TODO 网络抖动,服务挂掉
redisTemplate.delete(lockKey);
}
}
情景分析:
实际上究其原因:获取锁、判断和删除的逻辑同样不是原子操作导致的。为此我们希望有一种办法保证原子性。即利用redis自带的Lua脚本。
解决方案:
- 工程目录resource下新增unlock.lua
vbnet
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
脚本声明:
arduino
private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT;
static {
// 类加载时就初始化
UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>();
// 加载Lua脚本
UNLOCK_LUA_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 返回值类型
UNLOCK_LUA_SCRIPT.setResultType(Long.class);
}
- 释放锁代码:
csharp
finally {
// 执行lua脚本释放锁
redisTemplate.execute(UNLOCK_LUA_SCRIPT, Collections.singletonList(lockKey), versionId);
}
- 工程结构:
综上,我们是时候给出最终版的测试用例代码:
kotlin
@RestController
public class RedisLockController{
@Autowired
private StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT;
static {
// 类加载时就初始化
UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>();
// 加载Lua脚本
UNLOCK_LUA_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 返回值类型
UNLOCK_LUA_SCRIPT.setResultType(Long.class);
}
@RequestMapping("/redisLock/reduceStock")
public String reduceStock() {
String lockKey = "product_01";
String versionId = UUID.randomUUID().toString();
//原子操作
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, versionId, 10, TimeUnit.SECONDS);
if (!result) {
return "系统异常";
}
String msg = "";
try {
int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
redisTemplate.opsForValue().set("stock", realStock + "");
msg = "减库存成功,剩余:" + realStock;
System.out.println(msg);
} else {
msg = "减库存失败,库存不足";
System.out.println(msg);
}
} finally {
System.out.println("锁释放前:" + redisTemplate.opsForValue().get(lockKey));
// 执行lua脚本释放锁
redisTemplate.execute(UNLOCK_LUA_SCRIPT, Collections.singletonList(lockKey), versionId);
System.out.println("锁释放后:" + redisTemplate.opsForValue().get(lockKey));
}
return msg;
}
}
- 模拟分布式环境,重启Pod1:8080,Pod2:8081,再次测试
- 测试结果
Pod1:8080
Pod2:8081
- 结果分析
显然,在分布式环境下使用分布式锁后,未出现超卖现象,符合预期。而且数据显示:Pod1和Pod2分别交叉执行了扣减库存。当然,我们也间接的看到使用Lua脚本执行释放锁是成功的。
总结
文本主要从超卖案例切入,在控制并发的问题上,先后在单机环境和分布式环境下使用JVM锁进行测试(有关JVM锁还有Lock、CAS等,读者可根据兴趣探究,此处不详细做介绍),解决了单机环境并发问题,但是在分布式环境下引入了新的问题。于是引入分布式锁的概念,本篇文章详细记录了问题分析过程,提供了具体可执行的代码供读者使用。详细事件流如下:
结尾
我相信只有先去体验才能更好的感知。关于分布式锁的方案的比较,限于篇幅,敬请期待下一篇《常用经典分布式锁解决方案》。希望本篇文章对你有帮助,欢迎转发、评论。如果感兴趣,欢迎加入公众号【码易有道】,一起做长期且正确的事情!!!