秒杀系统之分布式锁

本文详细介绍了在Redis环境下构建秒杀系统时遇到的并发问题及解决方案,从悲观锁到简易分布式锁,再到使用Redisson实现的高可用分布式锁。通过逐步优化,确保了在高并发场景下的库存准确性和系统稳定性,最终展示了使用Redisson如何有效地避免锁超时和非原子性操作的风险。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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 "秒杀活动抢购成功!";
    }

测试结果
在这里插入图片描述在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值