1. 模拟场景和环境说明
- 模拟高并发下卖电影票场景
- 使用SpringBoot编写卖票的业务,Redis存储热点数据
- 发布两个卖票服务,使用Nginx做负载均衡
-
JMeter压测工具模拟高并发
在这里插入图片描述
2. 环境搭建
2.1编写卖票服务
因为篇幅问题这里只展示Controller层代码,这里不做锁操作,只是搭建实验环境。
@RestController
public class StockController {
@Autowired
StockService stockService;
@Autowired
RedisUtil redisUtil ; //自己封装的Redis工具类
/**
* 卖票
* @return
*/
@GetMapping("/sell_stock")
public String sellStock(){
//从redis中取出票信息
Stock stock = (Stock)redisUtil.get("stock");
int num = stock.getNum();
if(num>0){
stock.setNum(num-1);
redisUtil.set("stock",stock);
System.out.println("卖票成功,剩余【 "+(num-1)+" 】张票");
}else{
System.out.println("票售罄!");
}
return "end";
}
}
2.2发布该服务两次
由于这里是做模拟,所以服务都发布在一台机器里,做个场景模拟。
注意:
- 有些IDEA同时发布两次服务需要在配置中开启,如图所示
- 两个服务端口分别是8080、8090
-
发布完一个服务,修改端口之后用maven重新编译,要不然发布的还是没修改端口的那个版本
在这里插入图片描述
2.3 Nginx负载均衡
2.3.1 安装nginx服务(酌情跳过)
我使用的是Ubuntu的安装包安装,也可以下载源码安装
# 在ubuntu 中安装nginx
sudo apt-get install nginx
# 启动nginx
sudo /etc/init.d/nginx start
正常情况下可以访问 http://localhost/
说明: 所有的配置文件都在/etc/nginx下
程序文件在/usr/sbin/nginx 日志放在了/var/log/nginx中
并已经在/etc/init.d/下创建了启动脚本nginx 默认的虚拟主机的目录设置在了/var/www/nginx-default
(有的版本 默认的虚拟主机的目录设置在了/var/www, 请参考/etc/nginx/sites-available里的配置)
2.3.2 设值负载均衡
- 在配置过程中只需要改代理服务器的配置就行,其他服务器不用管。
# 停止nginx服务
sudo /etc/init.d/nginx stop
# 配置负载均衡
sudo vim nginx.conf
http {
### 省略
upstream redistest {
# redistest 可以随意取名字
# 192.168.0.102 是我的本机ip,切记不能设置127.0.0.1
server 192.168.0.102:8080 weight=1;
server 192.168.0.102:8090 weight=1;
}
}
sudo vim /etc/nginx/sites-available/default
#在文件最低端添加如下
server{
listen 8030; #监听端口
location / {
proxy_pass http://redistest; # redistest对应上面设值的
}
}
#最后开启nginx服务
sudo /etc/init.d/nginx start
2.4 JMeter建立测试计划
新建线程组,设置线程数,Ramp-Up时间设置0表示一次将所有请求发送过去
添加HTTP请求,路径写上自己的请求路径,在HTTP请求下添加聚合报告查看测试结果。
3. 模拟和问题解决
3.1 第一次模拟
现在的代码没做并发处理,肯定会出现超卖现象,两个服务卖同一张票。
为什么会超卖呢?
因为Java在读取Redis的时候是两个服务同时去读,访问Redis的时候没有加锁。
弹幕 : 为什么不在买票业务加上同步代码呢?
UP : JDK加的同步机制只能作用在当前Tomcat的JVM里面,我们的环境是两个服务发布在不同的Tomcat里加了同步代码也无济于事。
3.2 第一次尝试方案
通过上面的分析,我们知道锁应该加在读取Redis的时候,熟悉Redis的小伙伴都知道Redis里有个【setnx】命令,表示如果不存在就se值,如果key存在就不再设置。于是想到了第一个解决办法:
- 在读取Redis前使用setnx设置一个值,并且设置过期时间,防止JVM宕机之后该值没释放,导致其他服务不能读写产生死锁,设置成功的线程代表拿到了锁,可以读写,读写之后释放锁。下面是代码实现:
@GetMapping("/sell_stock")
public String sellStock(){
//作为锁
String lockKey = "movie_001";
try{
//设置锁的过期时间,防止jvm宕机之后,锁永远不释放
//setIfAbsent(lockKey, lockId,10,TimeUnit.SECONDS); 该语句是原子性的
boolean result = redisUtil.setnx(lockKey, "movie_001",10);
if(!result){
return "error_code";
}
Stock stock = (Stock)redisUtil.get("stock");
int num = stock.getNum();
if(num>0){
stock.setNum(num-1);
redisUtil.set("stock",stock);
System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
}else{
System.out.println("口票失败(error)");
}
}finally {
//释放锁,在finally中,防止,中间出现异常,锁没有释放,出现死锁
redisUtil.del(lockKey);
}
return "end";
}
问题真的解决了吗?
使用JMeter压测N次,感觉没问题啊,如果这是一个真实活动 这不是演习, 这个任务交到了刚来公司不久的你,要是做得好可能有奖励,要是超卖严重,可能就要卷铺盖走人了,不行得考虑所有的可能出想的问题。
倒杯卡布奇诺冷静一下
仔细检查逻辑,考虑多种情境,发现:万一业务代码执行时间长,第一个线程还没释放锁,结果lockKey过期了;这个时候第二个线程可以拿到锁了,开始执行业务代码;正当这时第一个线程执行到了释放锁的语句,把第二个线程拿到的锁释放了;假设第二个线程还没执行到释放锁,这个时候第三个线程可以拿到锁,开始执行业务代码;第二个线程执行到了释放锁的语句,把第三个线程拿到的锁释放了... ... 细思极恐,老子加的锁没用了。
3.3 第二次尝试方案
现在的问题是线程二加的锁被线程一释放了,以此类推。如果可以保证我加的锁我自己释放,别人不能动我的锁... ...突然灵光一现,只要实现每个线程设置的lockKey的value不同就可以了,放下手中的卡布奇诺,撸出了下面的代码:
@GetMapping("/sell_stock")
public String sellStock(){
//作为锁
String lockKey = "movie_001";
//uuid 作为锁的值
String lockId = UUID.randomUUID().toString();
try{
//setIfAbsent(lockKey, lockId,10,TimeUnit.SECONDS); 该语句是原子性的
boolean result = redisUtil.setnx(lockKey, lockId,10);
if(!result){
return "error_code";
}
Stock stock = (Stock)redisUtil.get("stock");
int num = stock.getNum();
if(num>0){
stock.setNum(num-1);
redisUtil.set("stock",stock);
System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
}else{
System.out.println("口票失败(error)");
}
}finally {
if(lockId.equals(redisUtil.get(lockKey))){
redisUtil.del(lockKey);
}
}
return "end";
}
觉得哪里不对劲
lockKey的过期时间设置,这里是设置固定值,这里设置固定值总觉得不是很爽,于是开始自言自语...
我:当初为什么设值固定值?
另一个我:为了防止在没释放锁之前JVM凉了,锁一直不释放造成死锁
我:要是程序10秒内没运行到解锁代码,锁就过期了业务代码还在执行,第二个线程此时可以拿到锁,开始执行它的业务代码,不妙!
另一个我:对啊,要是可以在第8,9秒的时候看看第一个线程有没有执行结束,如果没结束给它的锁续命,这样不久可以保证锁是线程一自己释放的了吗?秒啊!
我:可是这个怎么实现呢?
怎么给锁续命?
这个场景很熟悉,就像一个有轮训检查,过一段时间看看现在的线程是否还活着,活着的话就给它的锁续命。是不是可以创建一个线程作为轮训检查的线程呢?貌似可行。接下来的工作量想想都头疼,或许自己写了一堆代码前辈们早就有更好的解决方案,况且作为小白的我就是面向Google编程。
接着就找到了Redisson,为使用者提供了一系列具有分布式特性的常用工具类 Redisson项目介绍
3.4 第三次尝试方案
现在就相当于有了大佬的加持,激情澎湃开始整合Redisson,跟着Redisson官方教程整合,这里不多逼逼了。整合结束后代码如下:
@Autowired
Redisson redisson;
@GetMapping("/sell_stock")
public String sellStock(){
//作为锁
String lockKey = "movie_001";
RLock redissonLock = redisson.getLock(lockKey);
try{
redissonLock.lock();
Stock stock = (Stock)redisUtil.get("stock");
int num = stock.getNum();
if(num>0){
stock.setNum(num-1);
redisUtil.set("stock",stock);
System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
}else{
System.out.println("口票失败(error)");
}
}finally {
redissonLock.unlock();
}
return "end";
}
到这里真的结束了吗?
作为小白的我经过这次模拟,觉得这才刚刚开始,写这篇博客就是记录一下自己学习过程,大佬都是从小白一步步踩坑过来的,或许有一天我也可以和大佬一起喝卡布奇诺。
推荐文章 | 评论区可以互相分享优质文章|一同进步
慢谈 Redis 实现分布式锁 以及 Redisson 源码解析