三、实战篇 优惠券秒杀

news2024/11/18 23:28:01

源码仓库地址:git@gitee.com:chuangchuang-liu/hm-dingping.git

1、全局唯一ID

数据库默认自增的存在的问题:

  • id增长规律明显
  • 受单表数据量的限制

场景一分析:id如果增长规律归于明显,容易被用户或者商业对手猜测出一些敏感信息,比如早上出的第一个单子的id是1,晚上再查看出的单子的id是1001,那别人就很容易猜测出你这一天的销售情况。
场景二分析:Mysql数据库的由于查询性能的考虑,单表数据量不建议超过500W。数据量更大时,需要进行分库分表,但从逻辑上来讲这两张表是同一个表,需要保证id的唯一性,增添了一定的维护成本。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

特性含义
唯一性需要保证id全局唯一
高可用生成器需要保证所有线程来调用时都能提供服务来生成id
高性能很多业务要求执行时间不能过长如缓存重建,所以对生成器生成id的性能有一定要求
递增性为了不重复、便于管理和查询以及提高系统的性能和效率
安全性生成的id不易被猜测或篡改

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
1653363172079.png

符号位:永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

  • 编码实现

全局唯一id生成器

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

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

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

单元测试

@SpringBootTest
class HmDianPingApplicationTests {
    @Autowired
    private RedisIdWorker redisIdWorker;

    private ExecutorService es;
    /**
     * 初始化线程池
     */
    @BeforeEach
    void setUp() {
        es = Executors.newFixedThreadPool(10);
    }

    @Test
    void testIdWorker() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(300);

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }
}

2、实现优惠券秒杀下单

秒杀需要思考的点:

  • 秒杀活动是否开始或结束
  • 库存是否足够

思路分析:
image.png

  • 编码实现
/**
 * 下单购买秒杀券
 * @param voucherId 秒杀券id
 * @return 订单id
 */
@Override
@Transactional
public Result order(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    if (ObjectUtil.isEmpty(voucher)) {
        return Result.fail("id为" + voucherId + "的秒杀券不存在");
    }
    // 判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("秒杀还未开始");
    }
    // 判断秒杀是否结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("秒杀已结束");
    }
    // 判断秒杀券是否已售罄
    if (voucher.getStock() < 1) {
        return Result.fail("秒杀券已售罄");
    }
    // 扣减库存
    boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    return Result.ok(orderID);
}

3、超卖问题

超卖问题原因分析:假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁,而对于加锁通常有两种方案:

方案名称含义例子
悲观锁认为线程安全问题一定会发生,因此在操作数据前,添加互斥锁,保证操作是串行的Synchronized、Lock
乐观锁认为线程安全问题不一定会发生,因此就不添加锁,只有在做更新操作时,判断其他线程是否做过更新。如果没有改过,才去做更新CAS

什么是CAS?
CAS(Compare And Swap)是一种用于管理并发数据访问的无锁算法。CAS操作包含三个参数:内存位置(V)、预期原值(A)和新值(B)。执行CAS操作的基本步骤是:如果V的值和预期原值A相同,那么就用新值B替换V的值;如果V的值和预期原值A不相同,就不做任何操作。

乐观锁的两种常见方式:

  • 版本号法(version)
  • 利用业务数据本身来充当版本号

1653369268550.png

  • 编码实现解决超卖问题

一般是CAS+自旋组合来解决超卖问题:如果CAS失败,但只要库存仍大于0,就允许其继续尝试购买秒杀券
但这里可以简化为只要当前库存量大于0,就允许其继续尝试购买秒杀券

// 扣减库存,添加乐观锁
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
        // 只要库存还大于0,就认为下单成功
        .gt("stock", 0)
        .update();

4、一人一单

需求: 要求一个用户只能购买一张秒杀券
目前存在的问题: 一个用户可以无限制次数的抢优惠券。应当加一层判断逻辑,当用户成功下完单后,不允许其再次抢优惠券。
判断逻辑: 查询该用户和优惠券在订单表里是否已经存在,如果存在,说明其之前已经抢过优惠券了

  • 编码实现
// 保证一人一单
Integer count = query().eq("voucher_id", voucherId)
        .eq("user_id", userId).count();
if (count > 0) {
    return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
}

存在问题: 在并发情况下仍然会出现一人多单的问题
分析: 该问题和超卖的问题是一致的,因为在查询时,多个线程都进来查询,发现该用户没有下过单,因此都做创建订单操作。
解决方案: 和之前一样,通过添加锁来实现。乐观锁比较适合更新数据,而这里是插入数据操作,适合悲观锁。
注:添加悲观锁这里,存在诸多问题,一个个来分析:

  • 锁的存放位置:在方法上添加同步锁。这种方式下,锁的粒度太粗了,导致每一个线程进来都会被锁住,性能太差
@Transactional
/*
    将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题
 */
public synchronized Result oneUserAndOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    // 保证一人一单
    Integer count = query().eq("voucher_id", voucherId)
            .eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
    }

    // 扣减库存,添加乐观锁
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
            // 只要我库存还大于0,就允许用户继续下单
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    return Result.ok(orderID);
}
  • 锁的存放位置:在方法体内添加同步锁,且以用户id进行加锁,这样锁的粒度更细,同一个用户线程进来后会去争锁资源,而不会导致所有线程都被锁住。

存在的问题: 在方法体内添加同步代码块,代码块执行完毕后立即释放锁,但事务又是由Spring管理的,此时事务还未提交。其他线程进来后,查询用户未下单,执行创建订单操作。因此这种方式仍会出现多线程并发问题。
解决方案: 事务提交后再释放锁

  • 编码实现
<!--引入aspectJ-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
@Override
public Result order(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    if (ObjectUtil.isEmpty(voucher)) {
        return Result.fail("id为" + voucherId + "的秒杀券不存在");
    }
    // 判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("秒杀还未开始");
    }
    // 判断秒杀是否结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("秒杀已结束");
    }
    // 判断秒杀券是否已售罄
    if (voucher.getStock() < 1) {
        return Result.fail("秒杀券已售罄");
    }

    // 保证一人一单
    Long userId = UserHolder.getUser().getId();
    // 3、事务提交后,锁会被释放
    synchronized (userId.toString().intern()) {
        // 获取代理对象,代理对象才具备事务功能
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.oneUserAndOrder(voucherId);
    }
}
@Transactional
public Result oneUserAndOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    // 保证一人一单
    Integer count = query().eq("voucher_id", voucherId)
            .eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
    }

    // 扣减库存,添加乐观锁
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
            // 只要我库存还大于0,就允许用户继续下单
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    Result.ok(orderID);
}
@MapperScan("com.hmdp.mapper")
// 启动aop,否则service获取不到aop代理类
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
  • JMeter进行并发安全测试

image.png
image.png
image.png
image.png

可以发现,至此已成功添加了一人一单的限制!

5、集群环境下的并发问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:
image.png
2、修改nginx的配置文件

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;
}  

具体操作:
1、通过IDEA克隆一份应用配置
image.png
2、添加 vm options
image.png
image.png
3、重启nginx

nginx -s reload

集群环境下锁失效的原因分析:
现在部署了多套tomcat服务器,每个tomcat内部都有一个jvm,jvm内部多个线程间可以实现锁互斥,但jvm间的线程的锁并不互斥,从而导致互斥锁失效,出现一人多单的问题,这就是集群环境下syn锁失效的原因,在这种情况下就需要使用分布式锁来解决该问题!
1653374044740.png

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【JavaScript】JavaScript 变量 ① ( JavaScript 变量概念 | 变量声明 | 变量类型 | 变量初始化 | ES6 简介 )

文章目录 一、JavaScript 变量1、变量概念2、变量声明3、ES6 简介4、变量类型5、变量初始化 二、JavaScript 变量示例1、代码示例2、展示效果 一、JavaScript 变量 1、变量概念 JavaScript 变量 是用于 存储数据 的 容器 , 通过 变量名称 , 可以 获取 / 修改 变量 中的数据 ; …

C语言——函数指针——函数指针变量(详解)

函数指针变量 函数指针变量的作用 函数指针变量是指向函数的指针&#xff0c;它可以用来存储函数的地址&#xff0c;并且可以通过该指针调用相应的函数。函数指针变量的作用主要有以下几个方面&#xff1a; 回调函数&#xff1a;函数指针变量可以作为参数传递给其他函数&…

VBA_NZ系列工具NZ02:VBA读取PDF使用说明

我的教程一共九套及VBA汉英手册一部&#xff0c;分为初级、中级、高级三大部分。是对VBA的系统讲解&#xff0c;从简单的入门&#xff0c;到数据库&#xff0c;到字典&#xff0c;到高级的网抓及类的应用。大家在学习的过程中可能会存在困惑&#xff0c;这么多知识点该如何组织…

C#MQTT编程10--MQTT项目应用--工业数据上云

1、文章回顾 这个系列文章已经完成了9个内容&#xff0c;由浅入深地分析了MQTT协议的报文结构&#xff0c;并且通过一个有效的案例让伙伴们完全理解理论并应用到实际项目中&#xff0c;这节继续上马一个项目应用&#xff0c;作为本系列的结束&#xff0c;奉献给伙伴们&#x…

AOP切面编程,以及自定义注解实现切面

AOP切面编程 通知类型表达式重用表达式切面优先级使用注解开发&#xff0c;加上注解实现某些功能 简介 动态代理分为JDK动态代理和cglib动态代理当目标类有接口的情况使用JDK动态代理和cglib动态代理&#xff0c;没有接口时只能使用cglib动态代理JDK动态代理动态生成的代理类…

Autosar教程-Mcal教程-GPT配置教程

3.3GPT配置、生成 3.3.1 GPT配置所需要的元素 GPT实际上就是硬件定时器,需要配置的元素有: 1)定时器时钟:定时器要工作需要使能它的时钟源 2)定时器分步:时钟源进到定时器后可以通过分频后再给到定时器 定时器模块选择:MCU有多个定时器模块,需要决定使用哪个定时器模块作…

基于Yolo5模型的动态口罩佩戴识别安卓Android程序设计

禁止完全抄袭&#xff0c;引用注明出处。 下载地址 前排提醒&#xff1a;文件还没过CSDN审核&#xff0c;GitHub也没上传完毕&#xff0c;目前只有模型的.pt文件可以下载。我会尽快更新。 所使用.ptl文件 基于Yolo5的动态口罩佩戴识别模型的pt文件资源-CSDN文库 项目完整文…

使用 Docker 部署 Next Terminal 轻量级堡垒机

1&#xff09;Next Terminal 介绍 官网&#xff1a;https://next-terminal.typesafe.cn/ GitHub&#xff1a;https://github.com/dushixiang/next-terminal 想必经常玩服务器的都了解过 堡垒机&#xff0c;类似于跳板机&#xff0c;但与跳板机的侧重点不同。堡垒机的主要功能是…

python编程从入门到实践答案二

python编程从入门到实践 第五章 if语句1.条件测试&#xff1a;2.更多的条件测试&#xff1a;3.外星人颜色#1&#xff1a;4. 外星人颜色#2&#xff1a;5. 外星人颜色#3&#xff1a;6. 人生的不同阶段&#xff1a;7. 喜欢的水果&#xff1a;8. 以特殊方式跟管理员打招呼&#xff…

前端加密面面观:常见场景与方法解析

&#x1f90d; 前端开发工程师、技术日更博主、已过CET6 &#x1f368; 阿珊和她的猫_CSDN博客专家、23年度博客之星前端领域TOP1 &#x1f560; 牛客高级专题作者、打造专栏《前端面试必备》 、《2024面试高频手撕题》 &#x1f35a; 蓝桥云课签约作者、上架课程《Vue.js 和 E…

单链表(下)

我们在单链表&#xff08;上&#xff09;中了解了一些需要实现的函数&#xff0c;这一篇就让我们一起来实现。 1.创建新节点 2.打印 3.尾插 4.头插 5.尾删 6.头删 7.查找 8.计算节点个数 9.在指定位置之前插入数据 10.在指定位置之前插入数据 11.删除指定位置的节点 12.删除指…

RIPEMD算法:多功能哈希算法的瑰宝

title: RIPEMD算法&#xff1a;多功能哈希算法的瑰宝 date: 2024/3/10 17:31:17 updated: 2024/3/10 17:31:17 tags: RIPEMD起源算法优势安全风险对比SHA优于MD5应用领域工作原理 一、RIPEMD算法的起源与历程 RIPEMD&#xff08;RACE Integrity Primitives Evaluation Messag…

阿里云服务器多少钱一年?价格表新鲜出炉(2024)

2024阿里云服务器优惠活动政策整理&#xff0c;阿里云99计划ECS云服务器2核2G3M带宽99元一年、2核4G5M优惠价格199元一年&#xff0c;轻量应用服务器2核2G3M服务器61元一年、2核4G4M带宽165元1年&#xff0c;云服务器4核16G10M带宽26元1个月、149元半年&#xff0c;云服务器8核…

维修家用美容射频美容仪

今天收到客户寄过来的一款家用射频美容仪。根据客户的反馈&#xff0c;插电开机没反应&#xff0c;经过排查&#xff0c;原来是12v-2A电源坏了。给客户更换一个新电源就可以了。

Mysql 死锁案例2-间隙锁与意向插入锁冲突

死锁复现 CREATE TABLE t (id int(11) NOT NULL,c int(11) DEFAULT NULL,d int(11) DEFAULT NULL,PRIMARY KEY (id),KEY c (c) ) ENGINEInnoDB DEFAULT CHARSETutf8;/*Data for the table t */insert into t(id,c,d) values (0,0,0),(5,5,5),(10,10,10) 事务1事务2T1START …

【Linux】常用操作命令

目录 基本命令关机和重启帮助命令 用户管理命令添加用户&#xff1a;useradd 命令修改密码&#xff1a;passwd 命令查看登录用户&#xff1a;who 命令查看登录用户详细信息 :w切换用户 目录操作命令cdpwd命令目录查看 ls [-al] 目录操作【增&#xff0c;删&#xff0c;改&#…

Jenkins 部署 RuoYi

目录 1、项目介绍 2、部署若依 clone 源代码 导入数据库到 node-16 修改 RuoYi 配置文件 推送 RuoYi 项目至 Gitlab 3、配置 Jenkins 配置本地 Maven Jenkins 配置 Maven 新建 Maven 项目 编写构建后的脚本 Jenkins 构建后执行脚本 4、Jenkins 传递参数 设置项目部…

el-table 插入输入框并进行校验

<template><div><el-form :model"list" ref"ruleForm"><el-table :data"list.tableData" style"width: 100%"><el-table-column prop"time" label"日期" width"180"><…

Swagger修改Api文档中的数据类型

swagger不陌生,API接口利器,本次要解决的问题是:我们知道前端在接收Long类型的属性时会出现精度问题,一般我们会在序列化的时候将Long类型的数字转换成String但是swagger的API文档中的类型还是Long,我们要解决的就是这个问题 不知道swagger怎么配置得可以看之前的文章:springb…

matlab阶段学习笔记小节2

syms定义符号变量 求极限 第二题 第三题 limit(y,n,inf);求的的函数y关于自变量n在无穷处&#xff08;inf&#xff09;的极限 exp(2)即指数为2&#xff0c;底为e,也就是e^2 求导数 第一题 log(x)默认是以e为底的指数函数&#xff0c;也就是ln(x). 使用diff(f)对函数进行求…