6

day05-优惠券秒杀01 - 一刀一个小西瓜

 1 year ago
source link: https://www.cnblogs.com/liyuelian/p/17351032.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

功能03-优惠券秒杀01

4.功能03-优惠券秒杀

4.1全局唯一ID

4.1.1全局ID生成器

每个店铺都可以发布优惠券:

image-20230423154152138

当用户抢购时,就会生成订单,并保存到tb_voucher_order这张表中。订单表如果使用数据库的自增id就存在一些问题:

  1. id的规律性太明显:用户可以根据id猜测一些信息,从而非法得到数据
  2. 受单表数据量的限制:由于单张表的数据限制,需要进行分表,而如果每张表都采取自增长,容易出现id重复,会影响订单之后的业务,比如说售后服务(因为售后服务一般是根据订单id来进行的)

解决方案:使用全局ID生成器。

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

(2)全局唯一ID生成策略:

  • Redis自增
  • snowflake算法
  • 数据库自增

(3)我们这里使用redis作为全局唯一生成器的实现方案,原因如下:

  1. redis是独立于数据库之外的,它只有一个,当所有人都来访问redis时,它的自增一定是唯一的(唯一性)

  2. 使用redis的集群、主从方案、哨兵功能,可以维持它的高可用性(高可用)

  3. redis具有高性能(高性能)

  4. 可以使用redis的String类型,具有自增性(如:incr命令)(自增性)

    Redis Incr 命令将 key 中储存的数字值增一

    如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作

  5. 为了增加id的安全性,我们不会直接使用自增redis自增的id,而是拼接一些其他信息:(安全性)

    ID构造:时间戳+计数器(使用long类型,共八字节,64bit)

    • 符号位:1bit,永远为0

    • 时间戳:31bit,以秒为单位,可以使用约69年

    • 序列号:32bit,秒内的计数器,这样可以支持每秒产生2^32个不同的ID

      image-20230423175259846

4.2Redis实现全局唯一ID

(1)创建全局ID生成器RedisIdWorker

package com.hmdp.utils; import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Component; import javax.annotation.Resource;import java.time.LocalDateTime;import java.time.ZoneOffset;import java.time.format.DateTimeFormatter; /** * @author 李 * @version 1.0 */@Componentpublic class RedisIdWorker { //开始时间戳(1970-01-01T00:00:00到2022-01-01T00:00:00的秒数) private static final long BEGIN_TIMESTAMP = 1640995200L; //序列号的位数 private static final int COUNT_BITS = 32; @Resource private StringRedisTemplate stringRedisTemplate; //public static void main(String[] args) { // //开始时间 // LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0); // //得到1970-01-01T00:00:00Z.到指定时间为止的具体秒数 // long second = time.toEpochSecond(ZoneOffset.UTC); // System.out.println(second);//1640995200L //} public long nextId(String keyPrefix) { //1.生成时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); //开始时间到当前时间的 时间戳 long timeStamp = nowSecond - BEGIN_TIMESTAMP; //2.生成序列号(keyPrefix代表业务前缀) /* * Redis的 Incr命令将 key 中储存的数字值增1,如果key不存在,那么key的值会先被初始化为0,然后再执行INCR操作。 * 根据这个特性,我们每一天拼接不同的日期,当做key。也就是说同一天下单采用相同的key,不同天下单采用不同的key * 这种方法不仅可以防止订单号使用完(redis的的自增最多可以有2^64位,我们采取其中32位作计数器), * 还可以根据不同的日期,统计该天的订单数量 */ //2.1获取当前的日期(精确到天) String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); //2.2做自增长 Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); //3.拼接并返回 //将时间戳左移32位,空出来的右边32位使用count填充,共64位 return timeStamp << COUNT_BITS | count; }}

(2)测试类(部分代码)

@Resourceprivate RedisIdWorker redisIdWorker;private ExecutorService es = Executors.newFixedThreadPool(500); @Testpublic void testIdWorker() throws InterruptedException { CountDownLatch latch = new CountDownLatch(300); //线程,生成100个id Runnable task = () -> { for (int i = 0; i < 100; i++) { long id = redisIdWorker.nextId("order"); System.out.println("id" + id); } latch.countDown(); }; long start = System.currentTimeMillis(); //共执行300次任务 for (int i = 0; i < 300; i++) { es.submit(task); } //让所有线程执行完才计时 latch.await(); long end = System.currentTimeMillis(); System.out.println("共用时=" + (end - start));}

关于countdownlatch

countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题。如果没有CountDownLatch ,由于程序是异步的,当异步程序没有执行完时,主线程可能就已经执行完了。如果期望的是分线程全部走完之后,主线程再走,此时就需要使用到CountDownLatch。CountDownLatch 中有两个最重要的方法:1.countDown 2.await

await 方法是阻塞方法,使用await可以让main线程阻塞,当CountDownLatch 内部维护的变量变为0时,就不再阻塞,直接放行。那么什么时候CountDownLatch 维护的变量变为0 呢?我们只需要调用一次countDown ,内部变量就减少1。

根据这个性质,让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

测试结果:

image-20230423191756438

查看redis中的数据:对应的key的自增值已经变为30000,说明生成了3w个id

image-20230423195906103

4.2.1总结

全局唯一ID生成策略:

  • Redis自增
  • snowflake算法
  • 数据库自增(使用一张表来单独记录id)

Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID结构:时间戳+计数器

4.2实现优惠券秒杀下单

4.2.1需求分析&业务流程

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

image-20230423201358147

这两张券对应的数据库表结构如下:

  • tb_voucher:(优惠券表)优惠券的基本信息、优惠金额、使用规则等(包括平价券和秒杀券)

    image-20230424161312210
  • tb_seckill_voucher:(秒杀优惠券表)优惠券的库存、开始抢购时间、结束抢购时间。秒杀优惠券才需要填写这些信息。

    image-20230424161158004

要求在店铺详情中实现下单购买秒杀券:

下单时需要判断两点:

  1. 秒杀是否开始或者结束,如果尚未开始或者已经结束则无法下单
  2. 秒杀券的库存是否充足,不足则无法下单
image-20230424152833646

优惠券订单表结构:

image-20230424161458352

业务流程分析:

image-20230424153759552

4.2.2代码实现

(1)优惠券订单实体:VoucherOrder.java

package com.hmdp.entity; import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import lombok.EqualsAndHashCode;import lombok.experimental.Accessors; import java.io.Serializable;import java.time.LocalDateTime; /** * 优惠券订单实体 * * @author 李 * @version 1.0 */@Data@EqualsAndHashCode(callSuper = false)@Accessors(chain = true)@TableName("tb_voucher_order")public class VoucherOrder implements Serializable { private static final long serialVersionUID = 1L; //主键 @TableId(value = "id", type = IdType.INPUT) private Long id; //下单的用户id private Long userId; //购买的代金券id private Long voucherId; //支付方式 1:余额支付;2:支付宝;3:微信 private Integer payType; //订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款 private Integer status; //下单时间 private LocalDateTime createTime; //支付时间 private LocalDateTime payTime; //核销时间 private LocalDateTime useTime; //退款时间 private LocalDateTime refundTime; //更新时间 private LocalDateTime updateTime;}

(2)mapper接口

package com.hmdp.mapper; import com.hmdp.entity.VoucherOrder;import com.baomidou.mybatisplus.core.mapper.BaseMapper; /** * Mapper 接口 * * @author 李 * @version 1.0 */public interface VoucherOrderMapper extends BaseMapper<VoucherOrder> { }

(3)IVoucherOrderService 服务类

package com.hmdp.service; import com.hmdp.dto.Result;import com.hmdp.entity.VoucherOrder;import com.baomidou.mybatisplus.extension.service.IService; /** * 服务类 * * @author 李 * @version 1.0 */public interface IVoucherOrderService extends IService<VoucherOrder> { Result seckillVoucher(Long voucherId);}

(4)VoucherOrderServiceImpl 服务实现类

package com.hmdp.service.impl; import com.hmdp.dto.Result;import com.hmdp.entity.SeckillVoucher;import com.hmdp.entity.VoucherOrder;import com.hmdp.mapper.VoucherOrderMapper;import com.hmdp.service.ISeckillVoucherService;import com.hmdp.service.IVoucherOrderService;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.hmdp.utils.RedisIdWorker;import com.hmdp.utils.UserHolder;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource;import java.time.LocalDateTime; /** * 服务实现类 * * @author 李 * @version 1.0 */@Servicepublic class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override @Transactional public Result seckillVoucher(Long voucherId) { //根据id查询优惠券信息 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher == null) { return Result.fail("该优惠券不存在,请刷新!"); } //判断秒杀券是否在有效时间内 //若不在有效期,则返回异常结果 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("秒杀券库存不足!"); } //扣减库存成功,则创建订单,返回订单id VoucherOrder voucherOrder = new VoucherOrder(); //设置订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); //设置用户id Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); //设置代金券id voucherOrder.setVoucherId(voucherId); //将订单写入数据库(操作优惠券订单表) this.save(voucherOrder); //返回订单id return Result.ok(orderId); }}

(5)控制器 VoucherOrderController

package com.hmdp.controller; import com.hmdp.dto.Result;import com.hmdp.service.IVoucherOrderService;import com.hmdp.service.IVoucherService;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; /** * 秒杀券前端控制器 * * @author 李 * @version 1.0 */@RestController@RequestMapping("/voucher-order")public class VoucherOrderController { @Resource private IVoucherOrderService voucherOrderService; @PostMapping("seckill/{id}") public Result seckillVoucher(@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); }}

(6)测试,在前端页面点击购买,显示抢购成功,订单号如下:

image-20230424163828781

优惠券订单表tb_voucher_order成功插入一条数据:

image-20230424164101927

对应的秒杀券的库存减一:

image-20230424164300622

4.3超卖问题

4.3.1问题分析

4.2的代码并没有考虑到并发的问题:当有多个用户同时对一个秒杀券进行抢购,并发会让系统出现超卖问题:即卖出的秒杀券数量>实际的秒杀券库存

我们使用jemeter测试:

image-20230424165758548
image-20230424165828025
image-20230424170309980

运行上述设置,测试结果如下:

  1. 秒杀券表中,id=2的秒杀券库存出现了负数:

    image-20230424170139020
  2. 订单表中,对应的数量为104单,但是对应的秒杀券的库存最多只有100张。也就是说:出现了超卖问题

    image-20230424170544754

出现超卖问题的原因:

4.2的代码只是简单地进行库存判断,并没有考虑到线程并发。当有多个线程同时去判断库存时,如果当前库存大于0,则这些线程都会去进行库存扣减,从而发生并发安全问题:

image-20230424171155754
image-20230424171918982

4.3.2解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

image-20230424172233037

这里使用乐观锁方案。乐观锁的关键是判断之前查询到的数据是否有被修改过:

常见的方式有两种:

(1)版本号法:

表中设置一个版本号字段,线程在修改表之前,先查询一次版本号。对数据库表操作时,再查询一次版本号,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作,并设置新的版本号。

update语句会对当前修改的行进行锁定操作(数据库有行级锁,不用担心一行记录被同时修改)。

因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存

sql执行是交给数据库的,如果开启了事务的话,就是两个事务的并发问题,此时将会启动两阶段封锁协议,保证事务并发安全

image-20230424180914306
image-20230424181018721
image-20230424181058145

(2)CAS法:

这里为了简化,使用库存代替版本号,原理和方案1是一致的:线程在修改表之前,先查询一次库存的值。对数据库表操作时,再查询一次库存值,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作。

image-20230424183341487
image-20230424183557589
image-20230424183641411

CAS思想:Compare-And-Swap

CAS 有三个操作数:内存值 V、预期值 A、要修改的值 B。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。

ABA问题

为了简便,这里使用方案2,但实际的业务还是建议使用版本法来避免其他问题。

4.3.3代码实现

(1)修改VoucherOrderServiceImpl,添加如下代码:

image-20230424191811284

(2)测试:

清除之前的订单信息(tb_voucher_order):

image-20230424192136997

还原tb_seckill_voucher表的测试数据:

image-20230424192255563

然后使用jemeter进行测试:

image-20230424204420148
image-20230424204442421

测试结果:

券没有超卖,但是出现了新的问题:前几个请求中就出现了下单失败的情况,200个线程只有100-63=37个线程下单成功(理想情况下是100,即秒杀券全部卖出)

image-20230424204357434

原因分析:这是因为,当有一个线程去修改数据时,其他很多的线程也来同时请求,它们都根据第一次查询的stock值去判断,发现stock值变化了,因此当第一个线程修改数据后,都没有去对数据进行操作),导致发生了库存充足,仍然抢不到券的情况(抢券失败率偏高)。

(3)改进:修改VoucherOrderServiceImpl,修改如下划线处:

分析:线程A获取stock值,通过业务判断,然后去对库存值进行update操作;因为update语句会对当前修改的行进行锁定操作,因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存。当等待后获取锁,将where stock > 0作为update条件,这时,只要stock不小于0就仍可以售券。

update where 是先走where去拿锁,拿不到就阻塞,等拿到锁了再去执行update

image-20230424205453393

再次对其测试:可以看到200个线程并发,100张秒杀券全部售完。并且没有出现超卖现象,同时解决了库存充足却抢不到券的问题。

image-20230424212235502

4.3.4总结

超卖这样的线程安全问题,解决方案有哪些?

  1. 悲观锁:添加同步锁,让线程串行执行
    • 优点:简答粗暴
    • 缺点:性能一般
  2. 乐观锁:不加锁,在更新时判断是否有其他线程在修改
    • 优点:性能好
    • 缺点:成功率低

4.4一人一单

4.5分布式锁

4.6Redis优化秒杀

4.7Redis消息队列实现异步秒杀


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK