从0~1实现分布式锁演变过程
工作中用到分布式锁的场景并不多(行业性质决定),后来在一幅漫画中了解到分布式锁的实现思路对我影响挺深的,在经过一系列的查阅资料后终于决定从0到1将分布式锁的过程演变一次。
首先看一段模仿减库存代码:
@GetMapping("/getStock")
public String getStock() {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
return "操作成功!";
}
以上代码很简单,就是从redis中取库存并判断是否大于0(有库存),有库存则减少1,最后更新redis中的库存。
以上代码在单线程中是没有问题,但如果在多线程的情况下就会出现重复减库存现象,这里我用的是JMeter模仿高并发场景,参数配置及压测结果如下:
线程组配置,参数如图所示
请求参数配置,http://www.com.zxf/getStock 是我本地自己用Nginx 配置的反向代理和负载均衡
Nginx主要配置如下
#配置上游服务器网关端口集群
upstream backServer{
server 127.0.0.1:8081 weight=1;
server 127.0.0.1:8082 weight=1;
}
server {
listen 80;
server_name www.com.zxf;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
#root html;
proxy_pass http://backServer/;
index index.html index.htm;
}
}
压测结果
如上图所示,在多线程情况下会出现重复减库存现象,出现这种情况通常做法是加锁,加锁的目的是为了在多线程情况下每次只能有一个线程去减库存,等到该线程处理完释放锁后其他线程再继续执行,修改后的代码如下:
@GetMapping("/getStock")
public String getStock() {
synchronized (this){
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
}
return "操作成功!";
}
压测结果如下:
扣减成功,剩余库存:49
扣减成功,剩余库存:48
扣减成功,剩余库存:47
扣减成功,剩余库存:46
扣减成功,剩余库存:45
扣减成功,剩余库存:44
扣减成功,剩余库存:43
扣减成功,剩余库存:42
扣减成功,剩余库存:41
扣减成功,剩余库存:40
扣减成功,剩余库存:39
扣减成功,剩余库存:38
扣减成功,剩余库存:37
扣减成功,剩余库存:36
扣减成功,剩余库存:35
扣减成功,剩余库存:34
扣减成功,剩余库存:33
扣减成功,剩余库存:32
扣减成功,剩余库存:31
扣减成功,剩余库存:30
扣减成功,剩余库存:29
扣减成功,剩余库存:28
扣减成功,剩余库存:27
扣减成功,剩余库存:26
扣减成功,剩余库存:25
扣减成功,剩余库存:24
扣减成功,剩余库存:23
扣减成功,剩余库存:22
扣减成功,剩余库存:21
扣减成功,剩余库存:20
扣减成功,剩余库存:19
扣减成功,剩余库存:18
扣减成功,剩余库存:17
扣减成功,剩余库存:16
扣减成功,剩余库存:15
扣减成功,剩余库存:14
扣减成功,剩余库存:13
扣减成功,剩余库存:12
扣减成功,剩余库存:11
扣减成功,剩余库存:10
扣减成功,剩余库存:9
扣减成功,剩余库存:8
扣减成功,剩余库存:7
扣减成功,剩余库存:6
扣减成功,剩余库存:5
扣减成功,剩余库存:4
扣减成功,剩余库存:3
扣减成功,剩余库存:2
扣减成功,剩余库存:1
扣减成功,剩余库存:0
扣减失败,库存不足
扣减失败,库存不足
如上,在使用synchronized后解决了重复减库存现象,但是又引来了新的问题:synchronized作用是在JVM层,也就是在单体中生效,而我们的服务布署一般都是多节点分布式布署,举个例子,假如布署两个结点,每个结点中都有各自的JVM,而synchronized只是在自己结点中的JVM生效,同样也会出现问题,为了验证上面的问题,我启动两个服务,端口分别是8081和8082,验证如下,
8081端口
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.4.0)
2021-08-13 12:26:35.416 INFO 7540 --- [ main] com.zxf.DistributeApplication : Starting DistributeApplication using Java 1.8.0_45 on DESKTOP-KB6ANRO with PID 7540 (H:\IDEA\workspace\distribute\target\classes started by Administrator in H:\IDEA\workspace\distribute)
2021-08-13 12:26:35.420 INFO 7540 --- [ main] com.zxf.DistributeApplication : No active profile set, falling back to default profiles: default
2021-08-13 12:26:36.596 INFO 7540 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
2021-08-13 12:26:36.600 INFO 7540 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2021-08-13 12:26:36.628 INFO 7540 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 7 ms. Found 0 Redis repository interfaces.
2021-08-13 12:26:37.583 INFO 7540 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8081 (http)
2021-08-13 12:26:37.604 INFO 7540 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2021-08-13 12:26:37.604 INFO 7540 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.39]
2021-08-13 12:26:37.745 INFO 7540 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2021-08-13 12:26:37.745 INFO 7540 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 2148 ms
2021-08-13 12:26:38.908 INFO 7540 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
2021-08-13 12:26:39.415 INFO 7540 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8081 (http) with context path ''
2021-08-13 12:26:39.431 INFO 7540 --- [ main] com.zxf.DistributeApplication : Started DistributeApplication in 4.953 seconds (JVM running for 5.868)
2021-08-13 12:30:45.410 INFO 7540 --- [io-8081-exec-59] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2021-08-13 12:30:45.426 INFO 7540 --- [io-8081-exec-59] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2021-08-13 12:30:45.426 INFO 7540 --- [io-8081-exec-59] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
扣减成功,剩余库存:49
扣减成功,剩余库存:48
扣减成功,剩余库存:47
扣减成功,剩余库存:46
扣减成功,剩余库存:45
扣减成功,剩余库存:44
扣减成功,剩余库存:43
扣减成功,剩余库存:42
扣减成功,剩余库存:41
扣减成功,剩余库存:40
扣减成功,剩余库存:39
扣减成功,剩余库存:38
扣减成功,剩余库存:37
扣减成功,剩余库存:36
扣减成功,剩余库存:35
扣减成功,剩余库存:34
扣减成功,剩余库存:33
扣减成功,剩余库存:32
扣减成功,剩余库存:31
扣减成功,剩余库存:30
扣减成功,剩余库存:29
扣减成功,剩余库存:28
扣减成功,剩余库存:26
扣减成功,剩余库存:25
扣减成功,剩余库存:24
扣减成功,剩余库存:23
扣减成功,剩余库存:22
扣减成功,剩余库存:21
扣减成功,剩余库存:20
扣减成功,剩余库存:21
扣减成功,剩余库存:20
扣减成功,剩余库存:19
扣减成功,剩余库存:18
扣减成功,剩余库存:16
扣减成功,剩余库存:15
扣减成功,剩余库存:14
扣减成功,剩余库存:14
扣减成功,剩余库存:13
扣减成功,剩余库存:12
扣减成功,剩余库存:11
扣减成功,剩余库存:10
扣减成功,剩余库存:9
扣减成功,剩余库存:7
扣减成功,剩余库存:6
扣减成功,剩余库存:5
扣减成功,剩余库存:4
扣减成功,剩余库存:3
扣减成功,剩余库存:2
扣减成功,剩余库存:1
扣减成功,剩余库存:0
扣减失败,库存不足
8082端口
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.4.0)
2021-08-13 12:26:52.642 INFO 5456 --- [ main] com.zxf.DistributeApplication : Starting DistributeApplication using Java 1.8.0_45 on DESKTOP-KB6ANRO with PID 5456 (H:\IDEA\workspace\distribute\target\classes started by Administrator in H:\IDEA\workspace\distribute)
2021-08-13 12:26:52.648 INFO 5456 --- [ main] com.zxf.DistributeApplication : No active profile set, falling back to default profiles: default
2021-08-13 12:26:53.802 INFO 5456 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
2021-08-13 12:26:53.806 INFO 5456 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data Redis repositories in DEFAULT mode.
2021-08-13 12:26:53.834 INFO 5456 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 7 ms. Found 0 Redis repository interfaces.
2021-08-13 12:26:54.907 INFO 5456 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8082 (http)
2021-08-13 12:26:54.926 INFO 5456 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2021-08-13 12:26:54.926 INFO 5456 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.39]
2021-08-13 12:26:55.091 INFO 5456 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2021-08-13 12:26:55.092 INFO 5456 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 2254 ms
2021-08-13 12:26:56.296 INFO 5456 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
2021-08-13 12:26:56.709 INFO 5456 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8082 (http) with context path ''
2021-08-13 12:26:56.725 INFO 5456 --- [ main] com.zxf.DistributeApplication : Started DistributeApplication in 5.081 seconds (JVM running for 6.427)
2021-08-13 12:30:45.894 INFO 5456 --- [nio-8082-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2021-08-13 12:30:45.894 INFO 5456 --- [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2021-08-13 12:30:45.910 INFO 5456 --- [nio-8082-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 16 ms
扣减成功,剩余库存:37
扣减成功,剩余库存:32
扣减成功,剩余库存:30
扣减成功,剩余库存:27
扣减成功,剩余库存:26
扣减成功,剩余库存:24
扣减成功,剩余库存:22
扣减成功,剩余库存:19
扣减成功,剩余库存:17
扣减成功,剩余库存:15
扣减成功,剩余库存:13
扣减成功,剩余库存:11
扣减成功,剩余库存:8
扣减成功,剩余库存:7
扣减成功,剩余库存:6
扣减成功,剩余库存:5
扣减成功,剩余库存:3
扣减成功,剩余库存:1
扣减成功,剩余库存:0
扣减失败,库存不足
如上结果显示,在两结点中公有37、32、30等库存字样,从而说明上面的问题是存在的。
那么怎么解决这个问题呢,首先,还是加锁的概念,加锁的目的是为了在多线程情况下每次只能有一个线程去减库存,只不过这儿的锁换成了redis锁而非JVM中的同步锁。
修改后的代码如下:
@GetMapping("/getStock")
public String getStock() {
String lockKey = "lockKey_stock";
try {
//setIfAbsent 相当于setnx(key, vlue),只有在key不存在时设置key的值,key存在时返回false
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lockKey_stock");
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
if (!result) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
// 释放锁
stringRedisTemplate.delete(lockKey);
}
return "操作成功!";
}
如上所示代码,有没有什么问题呢,或者有什么可以改进的地方,答案是肯定的,首先,
问题一:先加锁,再设置过期时间就有问题,如果加锁成功后由于不可抗力因素(redis宕机)没有成功设置过期时间,那么就会一致处于被锁状态;
问题二:默认过期时间为10秒,假设线程一进来处理业务需要15秒,10秒后redis自动释放锁,这时线程二进来一看没有锁,于是线程二加锁,这时恰好第一个线程业务处理完释放锁,注意:此时线程一释放的是线程二的锁,以此类推会导致锁失效问题。
针对以上两点问题代码优化如下:
@GetMapping("/getStock")
public String getStock() {
String lockKey = "lockKey_stock";
String threadId = String.valueOf(Thread.currentThread().getId());
try {
//setIfAbsent 相当于setnx(key, vlue),只有在key不存在时设置key的值,key存在时返回false
/*Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lockKey_stock");
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);*/
// 在添加锁的同时设置过期时间
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, threadId, 10, TimeUnit.SECONDS);
if (!result) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
// 释放锁,解决锁失效问题
if (threadId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "操作成功!";
}
针对问题1解决方案:在添加锁的同时设置过期时间,这时一个原子性操作,要么同时成功,要么同时失败;
针对问题2解决方案:在加锁时获取当前线程ID,在释放时判断当前线程跟加锁的线程是否一致。
再仔细考虑一下,设置的锁的默认过期时间是10秒,但是如果处理业务代码超过10秒依然会释放,能不能做到让程序自动去判断是否要给当前锁延续寿命,
思路:后台写一个定时器定时监控当前线程是否还持有锁,如果持有说明业务逻辑还没有处理完,自动更新过期时间!
其实对于以上的一切Redisson框架已经帮我们全部实现了!其流程如图所示
引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
实现代码
@GetMapping("/getStock")
public String getStock() {
String lockKey = "lockKey_stock";
RLock redissonLock = redisson.getLock(lockKey);
try {
redissonLock.lock(10, TimeUnit.SECONDS);
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
// 释放锁,解决锁失效问题
redissonLock.unlock();
}
return "操作成功!";
}
如上,使用Redisson实现分布式锁只需要简单的三步就可以做好上面写的一切,而且还更加完美。
获取锁:RLock redissonLock = redisson.getLock(lockKey);
设置初始过期时间:redissonLock.lock(10, TimeUnit.SECONDS);
释放锁:redissonLock.unlock();