redis下的秒杀系统–如何加入简单分布式锁
1.尝试加入 synchronized 悲观锁
2.尝试加入 简易版分布式锁
3.加入 redisson框架实现分布式锁
使用工具
JMeter: 高并发压力测试工具
Idea: 代码编译器
Redis: noSQL存储缓存数据
Nginx: 反向代理两台web服务器,负载均衡使用轮询模式
Tomcat: springboot自带的web服务器
下面我们加入一段简单代码 描述秒杀在高并发下存在的问题
JMeter基本配置:在http请求默认值中输入 协议+服务器名称+端口号
输入请求方式和请求路径,点击绿色图标按钮发起请求
Nginx基本配置:http://api.pethome.com是我们在nginx配置的正向代理,为两个tomcat localhost:8081与localhost:8082做反向代理,配置如下
worker_processes 1;
events {
#默认支持的连接,也是并发数
worker_connections 1024;
}
http {
upstream pets{
server localhost:8081;
server localhost:8082;
}
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
#反向代理主机
server {
listen 80;
server_name api.pethome.com;
#跨域配置,因为端口和ip不同一定会报跨域问题
location / {
add_header 'Access-Control-Allow-Origin' $http_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET,POST,DELETE,PUT,OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,web-token,app-token,Authorization,Accept,Origin,Keep-Alive,User-Agent,X-Mx-ReqToken,X-Data-Type,U-TOKEN,A-TOKEN,E-TOKEN,X-Auth-Token,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $http_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' '*';
add_header 'Access-Control-Allow-Headers' 'DNT,web-token,app-token,Authorization,Accept,Origin,Keep-Alive,User-Agent,X-Mx-ReqToken,X-Data-Type,U-TOKEN,A-TOKEN,E-TOKEN,X-Auth-Token,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://pets;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5;
}
}
}
如果你们的域名,比如 api.pethome.com没有备案,可以在C盘中的host文件修改内容让 api.pethome.com 指向本机的 127.0.0.1
在本机输入 api.pethome.com后,电脑会先找本地的DNS解析域名,找到了就不会去找网上的DNS解析域名
一般win7的hosts目录 C:\Windows\System32\drivers\etc
启动redis后在里面加入200个商品数据:如下
key-农夫山泉9527,value-200
idea中的简单代码如下:
/**
* 秒杀减少redis库存测试
*/
@RestController
@RequestMapping("/seckill")
public class ReduceStockController {
@Autowired
private RedisTemplate redisTemplate;
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public void reduce_stock() {
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
}
@RequestMapping(value = "/add/redis", method = {RequestMethod.GET})
public void add_stock() {
redisTemplate.opsForValue().set("农夫山泉9527", 200);
}
}
上述代码问题在于:如果同时有三个线程进来,每个线程从redis里拿到的stock 都是200,减去一个商品后重新设置进redis为|<农夫山泉9527,199>,这个三个线程都能设置农夫山泉9527的value为199,redis中减一 订单却生成了三条已经出现了库存超卖问题,我们尝试使用加悲观锁synchronized来解决这个问题
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public void reduce_stock() {
synchronized (this) {
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
}
}
JMeter一秒内两次发送200个请求,测试结果如下:
web1
web2
synchronized悲观锁在JVM层面可以防止并发问题,一旦我们做了分布式有两台服务器,synchronized只能锁住web1的部分,web2的部分依旧可以在redis中修改同一件商品的数量,悲观锁在单个web下可以解决高并发超卖问题,但是在多个web下,无法解决高并发,需要用到分布式锁。
为了解决这个问题,我们采用一个简单的分布式锁 使用redis中的setnx<key,value>
若key不存在,能设置value
若key存在,不能设置value
在springbooot中被封装成setIfAbsent(key,value)
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "用户id");
if(!result){ //当前用户没拿到锁,就不执行扣减库存的操作
return "当前抢购过于火爆,请稍后重试!";
}
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
redisTemplate.delete(lockKey);//当前线程结束后释放该锁
return "秒杀活动抢购成功!";
}
查看秒杀结果,由于只在1秒钟发送了200个请求,系统只来得及处理这么多,但可以看到,这次已经没有重复下的订单,商品超卖问题已经解决
1秒卖出了13件商品,没有发生超卖问题,但是存在一个问题,
如果其中一个webredis上锁后,执行下面的代码时发生异常
redisTemplate.delete(lockKey);这句代码这么执行就抛出了异常,锁没有被释放出来,其他的web即使没有发生异常,我们的秒杀也无法抢购了,为了优化我们加上
try{
—异常代码—
}catch (RuntimeException e) {
—e.printStackTrace();—
}finally{
—释放锁—
}
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "用户id");
if(!result){ //当前用户没拿到锁,就不执行扣减库存的操作
return "当前抢购过于火爆,请稍后重试!";
}
try {
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
//int a = 5/0;
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
} catch (RuntimeException e) {
e.printStackTrace();
} finally {
redisTemplate.delete(lockKey);//当前线程结束后释放该锁
}
return "秒杀活动抢购成功!";
}
以上代码虽然解决了,代码出现异常锁能被释放的问题,但是一旦项目宕机或被运维重启,我们释放锁的语句依旧执行不了
redisTemplate.delete(lockKey);
为了解决这个问题,我们可以再度优化
将redis中的锁设置一个过期时间,即使出现意外,让锁自动过期删除
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
//设置lockKey十秒内失效
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "用户id");
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);//设置lockKey十秒内失效
if(!result){ //当前用户没拿到锁,就不执行扣减库存的操作
return "当前抢购过于火爆,请稍后重试!";
}
try {
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
//int a = 5/0;
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
} catch (RuntimeException e) {
e.printStackTrace();
} finally {
redisTemplate.delete(lockKey);//当前线程结束后释放该锁
}
return "秒杀活动抢购成功!";
}
redisTemplate.opsForValue().setIfAbsent(lockKey, "用户id");
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
保证以上代码的原子性,保证lockKey一定被设置过期时间
问题分析:如果一个线程执行需要15秒才能执行完成(遇到慢查询、GC等情况),但执行到10秒的时候锁就过期释放掉了,此时第二个线程进来上锁,它需要执行8秒才执行完,当第二个线程执行到5秒的时候,第一个线程执行结束把第二个线程的锁释放掉了。第三个线程进来执行3秒,第二个线程会把第三个线程的锁释放掉。线程的执行顺序是不可控的,极端情况下,这把分布式锁会永久失效,依旧会产生商品超卖情况。
问题的本质在于我自己加的锁被被人释放掉了,如果能做到我自己的锁只能自己释放,这个问题就解决 了
解决方案:释放锁的时候判断这个锁是不是自己加的
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
String onlyId = UUID.randomUUID().toString();//唯一uuid
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, onlyId);
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);//设置lockKey十秒内失效
if(!result){ //当前用户没拿到锁,就不执行扣减库存的操作
return "当前抢购过于火爆,请稍后重试!";
}
try {
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
//int a = 5/0;
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
} catch (RuntimeException e) {
e.printStackTrace();
} finally {
//判断该锁是否是自己加的
if(onlyId.equals(redisTemplate.opsForValue().get(lockKey))){
redisTemplate.delete(lockKey);//当前线程结束后释放该锁
}
}
return "秒杀活动抢购成功!";
}
以上代码看似完美但依旧存在问题
1.第一处非原子性
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, onlyId);
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);//设置lockKey十秒内失效
2.第二处非原子性
if(onlyId.equals(redisTemplate.opsForValue().get(lockKey))){
redisTemplate.delete(lockKey);//当前线程结束后释放该锁
}
高并发下在临界值处代码进入判断内还没来及其释放锁,发生了GC,到达超时时间,锁立即失效,此后释放的锁依旧不是自己的锁。
这情况很难发生,但依旧存在。
解决方案:深化并优化分布式锁,使用redisson框架,解决原子性和锁续命问题等
引入redisson
<!--redission-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
//初始化redisson客户端
@Bean
public Redisson redisson(){
Config config = new Config();
//redis单机模式
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0).setPassword("你的redis密码");
return (Redisson)Redisson.create(config);
}
以下是加入redisson改造后的代码
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
RLock redissonLock = redisson.getLock(lockKey);//创造锁
try {
redissonLock.lock();//加锁
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
} catch (RuntimeException e) {
e.printStackTrace();
} finally {
redissonLock.unlock();//释放锁
}
return "秒杀活动抢购成功!";
}
对比
@RequestMapping(value = "/reduce/redis", method = {RequestMethod.GET})
public String reduce_stock() {
String lockKey = "农夫山泉";
//String onlyId = UUID.randomUUID().toString();//唯一uuid
//Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, onlyId);
//redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);//设置lockKey十秒内失效
//if(!result){ //当前用户没拿到锁,就不执行扣减库存的操作
// return "当前抢购过于火爆,请稍后重试!";
//}
RLock redissonLock = redisson.getLock(lockKey);//创造锁
try {
redissonLock.lock();//加锁
Integer stock = (Integer) redisTemplate.opsForValue().get("农夫山泉9527");
System.out.println("库存剩余" + stock);
if (stock == null || stock <= 0) {
throw new RuntimeException("已被抢购一刻!");
}
redisTemplate.opsForValue().set("农夫山泉9527", stock - 1);
} catch (RuntimeException e) {
e.printStackTrace();
} finally {
redissonLock.unlock();//释放锁
//判断该锁是否是自己加的
//if(onlyId.equals(redisTemplate.opsForValue().get(lockKey))){
// redisTemplate.delete(lockKey);//当前线程结束后释放该锁
//}
}
return "秒杀活动抢购成功!";
}
测试结果