优惠卷秒杀(并发问题)

Redis实战篇 | Kyle's Blog (cyborg2077.github.io)

目录

一、Redis实现全局唯一id

二、添加优惠卷

 三、实现秒杀下单

 四、解决超卖问题(库存为负)

乐观锁解决超卖问题(CAS法)

五、实现一人一单

​编辑 悲观锁解决一人一单问题

六、集群环境下的并发问题(引出分布式锁)


一、Redis实现全局唯一id

  • 在各类购物App中,都会遇到商家发放的优惠券
  • 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
    1. id规律性太明显
    2. 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
  • 那么这就引出我们的全局ID生成器
  • 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
  • ID组成部分
    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
    • 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID

全局唯一id: 

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final long COUNT_BITS = 32L;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 根据传进的参数区分不同的业务,生成唯一id
    public long nextId(String keyPrefix){
        // 1. 生成时间戳
        //获取当前时间 转换为秒, 当前时间减起始时间为时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long nowTimeStamp = nowSecond - BEGIN_TIMESTAMP;

        // 2. 生成序列号
        //获取当前日期,精确到天, 设置序列号自增长
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3. 拼接并返回
        return nowTimeStamp << COUNT_BITS | count;
    }
}

二、添加优惠卷

由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill, 请求方式POST

新增普通券,也就只是将普通券的信息保存到表中

/**
 * 新增普通券
 * @param voucher 优惠券信息
 * @return 优惠券id
 */
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

新增秒杀券主要看addSeckillVoucher中的业务逻辑 

/**
 * 新增秒杀券
 * @param voucher 优惠券信息,包含秒杀信息
 * @return 优惠券id
 */
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    // 关联普通券id
    seckillVoucher.setVoucherId(voucher.getId());
    // 设置库存
    seckillVoucher.setStock(voucher.getStock());
    // 设置开始时间
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    // 设置结束时间
    seckillVoucher.setEndTime(voucher.getEndTime());
    // 保存信息到秒杀券表中
    seckillVoucherService.save(seckillVoucher);
}

 三、实现秒杀下单

实现类 

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        // 4.判断库存是否充足
        // 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        // 5.库存充足 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        // 6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 6.1.订单id(全局唯一id)
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 6.2.用户id (从拦截器中获取)
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        // 6.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        // 6.4.保存订单
        save(voucherOrder);
        // 7.返回订单id
        return Result.ok(orderId);
    }
}

接口

public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);
}

Controller 

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
    @Autowired
    private IVoucherOrderService voucherOrderService;
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
}

 四、解决超卖问题(库存为负)

实现秒杀下单的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景。

测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张 

  • 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {
    return Result.fail("库存不足");
}

  • 假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题

  • 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案

 

乐观锁解决超卖问题(CAS法)

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。因为我们还需要判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作 

修改“实现秒杀下单”的代码为:

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        // 3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        // 4.判断库存是否充足
        // 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        // 5.库存充足 扣减库存
        // 乐观锁解决超卖问题
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")// set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0)// where stock > 0 库存大于0就扣减库存
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }
        // 6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 6.1.订单id(全局唯一id)
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 6.2.用户id (从拦截器中获取)
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        // 6.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        // 6.4.保存订单
        save(voucherOrder);
        // 7.返回订单id
        return Result.ok(orderId);
    }
}

五、实现一人一单

  • 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
  • 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
    • 如果已存在,则不能下单,返回错误信息
    • 如果不存在,则继续下单,获取优惠券

 悲观锁解决一人一单问题

 实现类

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Override
    public Result seckillVoucher(Long voucherId) {
        LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
        //1. 查询优惠券
        queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
        SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
        //2. 判断秒杀时间是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("秒杀还未开始,请耐心等待");
        }
        //3. 判断秒杀时间是否结束
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            // 获取当前代理对象 确保下面的事务生效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 一人一单逻辑
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
            if (count > 0) {
                return Result.fail("你已经抢过优惠券了哦");
            }
            //5. 扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId)
                    .gt("stock", 0)
                    .update();
            if (!success) {
                return Result.fail("库存不足");
            }
            //6. 创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            //6.1 设置订单id
            long orderId = redisIdWorker.nextId("order");
            //6.2 设置用户id
            Long id = UserHolder.getUser().getId();
            //6.3 设置代金券id
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setId(orderId);
            voucherOrder.setUserId(id);
            //7. 将订单数据保存到表中
            save(voucherOrder);
            //8. 返回订单id
            return Result.ok(orderId);
        }
        //执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
    }
}

启动类添加

@EnableAspectJAutoProxy(exposeProxy = true)

 依赖

        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>

接口

public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);

    Result createVoucherOrder(Long voucherId);
}

六、集群环境下的并发问题(引出分布式锁)

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

    1. 我们将服务启动两份,端口分别为8081和8082
    2. 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
  • 具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。

  • 失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥

  • 这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

1. 添加Tomcat 形成集群

 改端口

 

2. 修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡


worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/json;

    sendfile        on;
    
    keepalive_timeout  65;

    server {
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / {
            root   html/hmdp;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

//反向代理
        location /api {  
            default_type  application/json;
            #internal;  
            keepalive_timeout   30s;  
            keepalive_requests  1000;  
            #支持keep-alive  
            proxy_http_version 1.1;  
            rewrite /api(/.*) $1 break;  
            proxy_pass_request_headers on;
            #more_clear_input_headers Accept-Encoding;  
            proxy_next_upstream error timeout;  
            #proxy_pass http://127.0.0.1:8081;
            proxy_pass http://backend;
        }
    }
//负载均衡(轮询)
    upstream backend {
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }  
}

 启动nginx输入该命令重新配置nginx

nginx.exe -s reload

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/728837.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C++ 教程 - 05 构建编译

文章目录 构建工具cmake安装与使用CMakeLists.txt编写使用案例 构建工具 cmake, Cross Platform Make&#xff0c; &#xff08;对C&#xff09;跨平台编译工具&#xff0c;将CMakeLists.txt 文件编译为对应的文件&#xff0c;如linux下的 Makefile&#xff0c;然后使用make命…

这几个都秒懂的都是资深程序猿/媛了吧?

放松第三期下&#xff0c;不讲编程技术&#xff0c;来看看几个冷笑话&#xff0c;最后一个最近还真的遇到了T_T ......想知道有多少人是秒懂的&#xff0c;欢迎大家在评论区交流讨论分享自己身边的搞笑趣事。 大家身边还有什么搞笑趣事呢&#xff1f;欢迎评论区留言交流分享&am…

收银系统源码推荐,线下线上一体化收银系统

1.收银系统源码开发语言 核心开发语言: PHP、HTML5、Dart后台接口: PHP7.3后台管理网站: HTML5vue2.0element-uicssjs收银端【安卓/PC收银】: Dart3&#xff0c;框架&#xff1a;Flutter 3.11.0-6.0.pre.27商家小程序助手端: uniapp线上商城: uniapp 2.功能介绍 支持测试体验…

SARscape——Refined Lee滤波

目录 一、算法原理1、概述2、参考文献 二、软件操作三、结果展示1、原始图像2、滤波结果 一、算法原理 1、概述 精致Lee滤波通过定义8种非正方形局部窗口&#xff0c;将均匀区域像素值等于其平均值&#xff0c;将非均匀区域近似于局部窗口中心像素值。 精致 Lee 滤波 8 种模板…

C#调用OpenCvSharp和SkiaSharp绘制图像直方图

最近在B站上学习OpenCv教程&#xff0c;学到图像直方图&#xff0c;后者描述的是不同色彩在整幅图像中所占的比例&#xff08;统计不同色彩在图像中的出现次数&#xff09;&#xff0c;可以对灰度图、彩色图等计算并绘制图像直方图。本文学习OpenCvSharp中与计算直方图相关的函…

全志 Android 11:实现响应全局按键

一、篇头 最近实现热键想功能&#xff0c;简单总结了下全志平台Android 11 的响应全局热键的方法。 二、需求 实现全局热键&#xff0c;响应F-、AF、F三个按键&#xff0c;AF只用于启动调焦界面&#xff0c;F-和F除了可以启动调焦界面外&#xff0c;还用于调整镜头的焦距&…

锂电池寿命预测 | Matlab基于ARIMA的锂电池寿命预测

目录 预测效果基本介绍程序设计参考资料 预测效果 基本介绍 锂电池寿命预测 | Matlab基于ARIMA的锂电池寿命预测 NASA数据集&#xff0c;B0005号电池&#xff0c;选择前110个数据训练&#xff0c;后58个数据测试预测。程序包含去趋势线、差分、平稳化及AIC准则判定p和q。命令窗…

幂集000

题目链接 幂集 题目描述 注意点 集合中不包含重复的元素 解答思路 可以使用深度优先遍历的思想按顺序将相应的元素添加到子集中&#xff0c;并将每个子集添加到结果集 代码 class Solution {public List<List<Integer>> subsets(int[] nums) {List<List&…

openGauss安装流程2024

openGauss安装流程2024 报错解决&#xff1a;https://blog.csdn.net/weixin_47115107/article/details/139844012?spm1001.2014.3001.5501 openGauss安装 之后安装过程中openGauss用户互信&#xff0c;openEuler服务器需要用到Python-3.7.x命令&#xff0c;但是默认Python版…

GNSS边坡监测站

TH-WY1随着科技的飞速发展&#xff0c;各种先进的监测技术不断涌现&#xff0c;为边坡安全监测提供了有力保障。其中&#xff0c;GNSS边坡监测站以其高精度、实时性强的特点&#xff0c;受到了广泛关注。 GNSS边坡监测站&#xff0c;全称为全球导航卫星系统边坡监测站&#xf…

掌握心理学知识成为产品经理一门必修课?

文章目录 心理学与产品设计的关联关系产品经理需要学习哪些心理学知识产品心理学的学习对象包含哪些 谈及心理学&#xff0c;往往认为它是一门研究人类心理现象及其影响下的精神功能和行为活动的科学&#xff0c;很多情况下&#xff0c;我们的直观印象是把心理学与医学领域进行…

代码随想录刷题复习day01

day01 数组-二分查找 class Solution {public int search(int[] nums, int target) {// 左闭右闭int left 0;int right nums.length - 1;int mid 0;while (right > left) {mid left (right - left) / 2;if (nums[mid] > target)right mid - 1;else if (nums[mid]…

HoVer-Net复现:手把手带你实现细胞核的分割与分类,并输出叠加图像|24-06-21

小罗碎碎念 先说一下&#xff0c;只要你跟着我一步一步走&#xff0c;你能实现的效果——对细胞核进行分割和分类&#xff0c;并在原始图像上以颜色叠加的方式直观地展示这些结果。 昨天我在交流群里进行了一下预热&#xff0c;并且提供了一些前期的教程&#xff0c;反响还不…

会声会影2024专业免费版下载附带激活码序列号

&#x1f31f; 会声会影2024&#xff1a;你的视频编辑新伙伴&#xff01;大家好&#xff0c;今天来给你们安利一个超级棒的视频编辑软件——会声会影2024最新版本&#xff01;作为一位热爱创作的小伙伴&#xff0c;找到一款既强大又易用的视频编辑工具真的太重要了。而会声会影…

CentOS 8.5 - 配置ssh的免密登录

文章目录 生成ssh密钥公钥内容放入服务器 生成ssh密钥 在本地主机安装 ssh工具&#xff0c;并生成公钥、私钥。 # 命令行输入 ssh-keygen -r rsa# 会在当前用户的家目录下生成一个.ssh目录公钥内容放入服务器 将上一步生成的id_rsa.pub公钥的内容复制到远程服务器 # 编辑文…

【Golang - 90天从新手到大师】Day11 - 包的管理

系列文章合集 Golang - 90天从新手到大师 Go语言中包的使用 Go语言使用包&#xff08;package&#xff09;这种语法元素来组织源码&#xff0c;所有语法可见性均定义在package这个级别&#xff0c;与Java 、python等语言相比&#xff0c;这算不上什么创新&#xff0c;但与C传…

跨区域文件管控解决方案,一文了解

跨区域文件管控是一个涉及在不同地域或区域之间管理和控制文件的过程&#xff0c;它包括安全性、合规性和管理效率等多个方面。以下是一些关键的考量因素&#xff1a; 1.安全性&#xff1a;确保在传输过程中文件不被截获、篡改或泄露。使用加密技术保护文件&#xff0c;并确保传…

递推和递归

递推 何为递推 用若干步可重复运算来描述复杂问题的方法 递推算法是一种用若干步可重复运算来描述复杂问题的方法。递推是序列计算中的一种常用算法。通常是通过计算前面的一些项来得出序列中的指定项的值。 经典例题 import java.util.Scanner;public class Tuzi {static …

java面向对象(上)

一.面向对象与面向过程 1.面向过程 面向过程(procedure Oriented Programming),简称POP,主要思想就是将问题分解成一个个步骤去解决,把这个步骤称为函数. 典型语言:C语言 优点:可以大大简化代码 缺点:当代码量过大时,不方便维护 2.面向对象 面向对象(Object Oriented Pr…

酷开科技丨引领家庭娱乐新潮流,酷开系统带你开启多彩生活新篇章

在繁忙的都市生活节奏中&#xff0c;人们对生活品质的追求从未停歇。家庭娱乐作为提升生活质量的重要部分&#xff0c;随着科技进步和个性化需求的增长&#xff0c;已经发生了翻天覆地的变化。多样化的娱乐方式不仅为家庭生活增添了色彩&#xff0c;也为家庭成员间的相聚带来了…