signed

QiShunwang

“诚信为本、客户至上”

微服务商城系统(十五)秒杀基础

2021/4/26 14:29:20   来源:

文章目录

      • 一、秒杀业务分析
        • 1、需求
        • 2、表结构说明
        • 3、秒杀需求分析
      • 二、秒杀商品压入缓存
        • 1、搭建秒杀服务工程
        • 2、定时任务
        • 3、秒杀商品压入缓存实现
      • 三、秒杀频道页
      • 四、下单实现
      • 五、多线程抢单
        • 1、异步实现
        • 2、排队下单
        • 3、下单状态查询
      • 六、总结

代码链接: https://github.com/betterGa/ChangGou

一、秒杀业务分析

1、需求

     所谓 “秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲,就是 网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
    
秒杀商品通常有两种限制:库存限制、时间限制。

需求:
(1)录入秒杀商品数据,主要包括:商品标题、原价、秒杀价、商品图片、介绍、秒杀时段等信息
(2)秒杀频道首页列出秒杀商品(进行中的)点击秒杀商品图片跳转到秒杀商品详细页。
(3)商品详细页显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存。当库存为 0 或不在活动期范围内时无法秒杀。
(4)秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(5)当用户秒杀下单 5 分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。

2、表结构说明

秒杀商品信息表:

CREATE TABLE `tb_seckill_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `sup_id` bigint(20) DEFAULT NULL COMMENT 'spu ID',
  `sku_id` bigint(20) DEFAULT NULL COMMENT 'sku ID',
  `name` varchar(100) DEFAULT NULL COMMENT '标题',
  `small_pic` varchar(150) DEFAULT NULL COMMENT '商品图片',
  `price` decimal(10,2) DEFAULT NULL COMMENT '原价格',
  `cost_price` decimal(10,2) DEFAULT NULL COMMENT '秒杀价格',
  `create_time` datetime DEFAULT NULL COMMENT '添加日期',
  `check_time` datetime DEFAULT NULL COMMENT '审核日期',
  `status` char(1) DEFAULT NULL COMMENT '审核状态,0未审核,1审核通过,2审核不通过',
  `start_time` datetime DEFAULT NULL COMMENT '开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  `num` int(11) DEFAULT NULL COMMENT '秒杀商品数',
  `stock_count` int(11) DEFAULT NULL COMMENT '剩余库存数',
  `introduction` varchar(2000) DEFAULT NULL COMMENT '描述',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

秒杀订单表:

CREATE TABLE `tb_seckill_order` (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `seckill_id` bigint(20) DEFAULT NULL COMMENT '秒杀商品ID',
  `money` decimal(10,2) DEFAULT NULL COMMENT '支付金额',
  `user_id` varchar(50) DEFAULT NULL COMMENT '用户',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `pay_time` datetime DEFAULT NULL COMMENT '支付时间',
  `status` char(1) DEFAULT NULL COMMENT '状态,0未支付,1已支付',
  `receiver_address` varchar(200) DEFAULT NULL COMMENT '收货人地址',
  `receiver_mobile` varchar(20) DEFAULT NULL COMMENT '收货人电话',
  `receiver` varchar(20) DEFAULT NULL COMMENT '收货人',
  `transaction_id` varchar(30) DEFAULT NULL COMMENT '交易流水',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

3、秒杀需求分析

     秒杀技术实现核心思想是运用缓存 减少数据库瞬间的访问压力,读取商品详细信息时运用缓存,当用户点击抢购时减少缓存中的库存数量,当库存数为 0 时或活动期结束时,同步到数据库。 产生的秒杀预订单也不会立刻写到数据库中,而是先写到缓存,当用户付款成功后再写入数据库。
    当然,上面实现的思路只是一种最简单的方式,并未考虑其中一些问题,例如并发状况容易产生的问题。我们看看下面这张思路更严谨的图:
在这里插入图片描述

二、秒杀商品压入缓存

    这里秒杀商品列表和秒杀商品详情都是从 Redis 中取出来的,所以我们首先要将符合参与秒杀的商品定时查询出来,并将数据存入到 Redis 缓存中。
     数据存储类型我们可以选择 Hash 类型。
     秒杀分页列表这里可以通过 redisTemplate.boundHashOps(key).values() 获取结果数据。
     秒杀商品详情,可以通过 redisTemplate.boundHashOps(key).get(key) 获取详情。
    

1、搭建秒杀服务工程

我们首先搭建一个秒杀服务工程,然后按照上面步骤实现。

搭建 changgou-service-seckill 和 changgou-service-seckill-api,作为秒杀工程的服务提供工程。
(1)导入依赖:

<dependencies>
    <dependency>
        <groupId>com.changgou</groupId>
        <artifactId>changgou-service-seckill-api</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

(2)application.yml 配置

server:
  port: 18093
spring:
  application:
    name: seckill
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://192.168.211.132:3306/changgou_seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 123456
  rabbitmq:
    host: 192.168.211.132 #mq的服务器地址
    username: guest #账号
    password: guest #密码
  main:
    allow-bean-definition-overriding: true
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:7001/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
        #如果enabled设置为false,则请求超时交给ribbon控制
          enabled: true
        isolation:
          thread:
            timeoutInMilliseconds: 10000
          strategy: SEMAPHORE

(3)导入生成文件
使用代码生成器,生成 dao、Pojo,并导入到工程中。

(4)启动类

@SpringBootApplication
@EnableEurekaClient
@MapperScan(basePackages = {"com.changgou.seckill.dao"})
@EnableScheduling
public class SeckillApplication {

    public static void main(String[] args) {
        SpringApplication.run(SeckillApplication.class,args);
    }

    @Bean
    public IdWorker idWorker(){
        return new IdWorker(1,1);
    }
}

2、定时任务

     采用 Spring 的定时任务,定时将在秒杀时间段内的商品查询出来再存入到 Redis 缓存。
    
定时任务相关的配置,配置步骤如下:
(1)在定时任务类的指定方法上加上 @Scheduled 开启定时任务
(2)定时任务表达式:使用 cron 属性来配置定时任务执行时间

创建 com.changgou.seckill.timer.SeckillGoodsPushTask 类,并在类中加上定时任务执行方法,代码如下:

@Component
public class SeckillGoodsPushTask {

    /****
     * 每30秒执行一次
     */
    @Scheduled(cron = "0/30 * * * * ?")
    public void loadGoodsPushRedis(){
        System.out.println("task demo");
    }
}
  • 定时任务常用时间表达式
    CronTrigger 配置完整格式为: [秒][分][小时][日][月][周][年]
    在这里插入图片描述
    使用说明:

通配符说明:

  • * 表示所有值. 例如:在分的字段上设置 *,表示每一分钟都会触发。
  • ? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值,只有 日 和 周 可以用。
    例如:要在每月的 10 号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为"?" 具体设置为 0 0 0 10 * ?
  • - 表示区间。例如 在小时上设置 “10-12”,表示 10,11,12 点都会触发。
  • , 表示指定多个值,例如在周字段上设置 “MON,WED,FRI” 表示周一,周三和周五触发
  • / 用于递增触发。如在秒上面设置 “5/15” 表示从 5 秒开始,每增 15 秒触发 (5,20,35,50)。 在月字段上设置 ‘1/3’ 所示每月 1 号开始,每隔三天触发一次。
  • L 表示最后的意思。在日字段设置上,表示当月的最后一天(依据当前月份,如果是二月还会依据是否是润年[leap]), 在周字段上表示星期六,相当于 “7” 或 “SAT”。如果在 “L” 前加上数字,则表示该数据的最后一个。例如在周字段上设置"6L"这样的格式,则表示“本月最后一个星期五"
  • W 表示离指定日期的最近那个工作日(周一至周五). 例如在日字段上设置 “15W”,表示离每月15号最近的那个工作日触发。如果 15 号正好是周六,则找最近的周五(14号)触发, 如果15号是周未,则找最近的下周一(16号)触发.如果 15 号正好在工作日(周一至周五),则就在该天触发。如果指定格式为 “1W”,它则表示每月 1 号往后最近的工作日触发。如果1号正是周六,则将在3号下周一触发。(注,W"前只能设置具体的数字,不允许区间 - ).
  • # 序号(表示每月的第几个周几),例如在周字段上设置"6#3"表示在每月的第三个周六.注意如果指定 “#5”,正好第五周没有周六,则不会触发该配置(用在母亲节和父亲节再合适不过了) ;

常用表达式:

0 0 10,14,16 * * ? 每天上午10点,下午2点,4点 
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时 
0 0 12 ? * WED 表示每个星期三中午12点 
"0 0 12 * * ?" 每天中午12点触发 
"0 15 10 ? * *" 每天上午10:15触发 
"0 15 10 * * ?" 每天上午10:15触发 
"0 15 10 * * ? *" 每天上午10:15触发 
"0 15 10 * * ? 2005" 2005年的每天上午10:15触发 
"0 * 14 * * ?" 在每天下午2点到下午2:59期间的每1分钟触发 
"0 0/5 14 * * ?" 在每天下午2点到下午2:55期间的每5分钟触发 
"0 0/5 14,18 * * ?" 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发 
"0 0-5 14 * * ?" 在每天下午2点到下午2:05期间的每1分钟触发 
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44触发 
"0 15 10 ? * MON-FRI" 周一至周五的上午10:15触发 
"0 15 10 15 * ?" 每月15日上午10:15触发 
"0 15 10 L * ?" 每月最后一日的上午10:15触发 
"0 15 10 ? * 6L" 每月的最后一个星期五上午10:15触发 
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最后一个星期五上午10:15触发 
"0 15 10 ? * 6#3" 每月的第三个星期五上午10:15触发

3、秒杀商品压入缓存实现

  • 数据检索条件分析
         按照 2.1 中的几个步骤实现将秒杀商品从数据库中查询出来,并存入到 Redis 缓存
    (1)查询 活动没结束的所有秒杀商品
    ① 计算秒杀时间段
    ② 状态必须为审核通过 status=1
    ③ 商品库存个数 >0
    ④ 活动没有结束 endTime>=now()
    在 Redis 中没有该商品的缓存
    ⑥ 执行查询获取对应的结果集
    (2)将活动没有结束的秒杀商品入库
         这里会涉及到时间操作,所以这里提前准备了一个时间工具包DateUtil。

  • 时间菜单分析
    1558681303792
         将商品数据从数据库中查询出来,并存入 Redis 缓存,但页面每次显示的时候,只显示当前正在秒杀以及往后延时 2个小时、4个小时、6个小时、8个小时 的秒杀商品数据。我们要做的是:
    (1)求出整个时间菜单
    (2)确定每个时间菜单的区间值
    (3)根据菜单时间的区间值,求对应的秒杀商品数据
    (4)将秒杀商品数据存到 Redis 中,使用 hash 类型,namespace 为时间,key 为商品 id,value 为 seckillgoods 商品信息。
        

  • 时间菜单计算
         可以先求出当前时间的凌晨,然后每 2 个小时后作为下一个抢购的开始时间,这样可以分出 12 个抢购时间段,如下:
    00:00-02:00
    02:00-04:00
    04:00-06:00
    06:00-08:00
    08:00-10:00
    10:00-12:00
    12:00-14:00
    14:00-16:00
    16:00-18:00
    18:00-20:00
    20:00-22:00
    22:00-00:00
        而现实的菜单只需要计算出当前时间在哪个时间段范围,该时间段范围就属于正在秒杀的时间段,而后面即将开始的秒杀时间段的计算也就出来了,可以在当前时间段基础之上+2小时、+4小时、+6小时、+8小时。

关于时间菜单的运算,在给出的 DateUtil 包里已经实现,代码如下:

/***
 * 获取时间菜单
 * @return
 */
public static List<Date> getDateMenus(){
    //定义一个List<Date>集合,存储所有时间段
    List<Date> dates = getDates(12);
    //判断当前时间属于哪个时间范围
    Date now = new Date();
    for (Date cdate : dates) {
        //开始时间<=当前时间<开始时间+2小时
        if(cdate.getTime()<=now.getTime() && now.getTime()<addDateHour(cdate,2).getTime()){
            now = cdate;
            break;
        }
    }

    //当前需要显示的时间菜单
    List<Date> dateMenus = new ArrayList<Date>();
    for (int i = 0; i <5 ; i++) {
        dateMenus.add(addDateHour(now,i*2));
    }
    return dateMenus;
}

/***
 * 指定时间往后 N 个时间间隔
 * @param hours
 * @return
 */
public static List<Date> getDates(int hours) {
    List<Date> dates = new ArrayList<Date>();
    //循环12次
    Date date = toDayStartHour(new Date()); //凌晨
    for (int i = 0; i <hours ; i++) {
        //每次递增2小时,将每次递增的时间存入到List<Date>集合中
        dates.add(addDateHour(date,i*2));
    }
    return dates;
}

使用主方法进行测试:

public static void main(String[] args) {
    List<Date> dateMenus = getDateMenus();
    for (Date dateMenu : dateMenus) {
        SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyyMMddHH");
        System.out.println(simpleDateFormat.format(dateMenu));
    }
}

运行结果:
在这里插入图片描述

  • 查询秒杀商品导入 Reids
        写个定时任务,查询从当前时间开始,往后延续 4 个时间菜单间隔,也就是一共只查询 5 个时间段抢购商品数据,并压入缓存,实现代码如下:
@Component
public class SeckillGoodsPushTask {

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 每 5 秒执行一次
     */
    @Scheduled(cron = "0/60 * * * * ?")
    public void loadGoodsPushRedis() {
       //System.out.println("task demo");

        // 查询符合当前参与秒杀活动的时间清单
        List<Date> dateMenus = DateUtil.getDateMenus();

        // 遍历时间清单
        for (Date datemenus : dateMenus) {
            // 求时间的字符串格式
            String timeSpace = DateUtil.data2str(datemenus, "yyyyMMddHH");
            System.out.println(timeSpace);

            /** 满足以下条件,将商品读入 Redis 缓存
             1、秒杀商品库存stock_count >0
             2、审核状态为通过 status
             3、开始时间start_time <= 当前时间 && 当前时间+2 < 结束时间 end_time
             */

            // 构建条件
            Example example = new Example(SeckillGoods.class);
            Example.Criteria criteria = example.createCriteria();
            criteria.andGreaterThan("stockCount", 0);
            criteria.andEqualTo("status", "1");
           criteria.andGreaterThanOrEqualTo("startTime", datemenus);
           criteria.andLessThan("endTime", DateUtil.addDateHour(datemenus, 2));

            // 查询数据
            List<SeckillGoods> seckillGoods = seckillGoodsMapper.selectByExample(example);

            // 存入 Redis
            for (SeckillGoods seckillgood : seckillGoods) {
                // namespace是当前时间,key是商品id,value是商品信息
                redisTemplate.boundHashOps("SeckillGoods_"+timeSpace).put(seckillgood.getId(), seckillgood);
            }
        }
    }
}

这时数据库中有数据:
在这里插入图片描述
运行程序,Redis 数据如下:
在这里插入图片描述
(注意到,namespace有奇怪的前缀,key 和 value 是编码的形式,需要解决一下 Redis 乱码问题:
提供 Redis 配置类:

@Configuration
public class RedisConfig {

    @Bean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();

        template.setConnectionFactory(factory);
        //key序列化方式
        template.setKeySerializer(redisSerializer);
        //value序列化
        template.setValueSerializer(redisSerializer);
        //value hashmap序列化
        template.setHashValueSerializer(redisSerializer);
        //key haspmap序列化
            template.setHashKeySerializer(redisSerializer);

        return template;
    }
}

改为 String 类型:

redisTemplate.boundHashOps("SeckillGoods_" + timeSpace).put(seckillgood.getId().toString(), JSON.toJSONString(seckillgood));

再看 Redis :
在这里插入图片描述
还有一个问题,Java 的时间总比数据库的早 8 个小时,所以需要在项目中设置 jdbc 驱动的 url serverTimezone=Asia/Shanghai

    因为定时查询商品的,所以对于 Redis 中已经存在的商品,需要进行判断,不重复添加:
在这里插入图片描述

三、秒杀频道页

在这里插入图片描述
秒杀频道首页,显示正在秒杀的和未开始秒杀的商品(已经开始或者还没开始,未结束的秒杀商品)

  • 秒杀时间菜单
         如上图,时间菜单需要根据当前时间动态加载,时间菜单的计算上面功能中已经实现,在 DateUtil 工具包中。我们只需要将时间菜单获取,然后响应到页面,页面根据对应的数据显示即可。

在 SeckillGoodsController 中提供方法:

  @GetMapping("/menus")
    public Result<List<Date>> menus(){
        List<Date> dates= DateUtil.getDateMenus();
        return new Result<>(true,StatusCode.OK,"查询秒杀时间菜单成功");
    } 

测试:
在这里插入图片描述

  • 秒杀频道页
         秒杀频道页是指将对应时区的秒杀商品从 Reids 缓存中查询出来,并到页面显示。对应时区秒杀商品存储的时候以 Hash 类型进行了存储,每次用户在前端点击对应时间菜单的时候,可以将时间菜单的开始时间以 yyyyMMddHH 格式提交到后台,后台根据时间格式查询出对应时区秒杀商品信息。
    • 控制层
      修改 com.changgou.seckill.controller.SeckillGoodsController,并添加秒杀商品查询方法,代码如下:
@RestController
@RequestMapping("/seckillGoods")
@CrossOrigin
public class SeckillGoodsController {

@Autowired
private SeckillGoodsService seckillGoodsService;

  @GetMapping(value = "/list")
    public Result<List<SeckillGoods>> list(String time) {
        List<SeckillGoods> list = seckillGoodsService.list(time);
        return new Result(true, StatusCode.OK, "秒杀商品列表查询成功", list);
    }
    • 业务层
      创建 com.changgou.seckill.service.SeckillGoodsService,添加根据时区查询秒杀商品的方法:
public interface SeckillGoodsService {

    /***
     * 获取指定时间对应的秒杀商品列表
     * @param key
     */
    List<SeckillGoods> list(String time);
}

实现:

@Service
public class SeckillGoodsServiceImpl implements SeckillGoodsService {

    @Autowired
    private RedisTemplate redisTemplate;

    /***
     * Redis 中根据 Key 获取秒杀商品列表
     * @param key
     * @return
     */
    @Override
    public List<SeckillGoods> list(String time) {
        return redisTemplate.boundHashOps("SeckillGoods_"+time).values();
    }
}

测试:
在这里插入图片描述

  • 秒杀详情页
    通过秒杀频道页点击请购按钮,会跳转到商品秒杀详情页,秒杀详情页需要根据商品 ID 查询商品详情,我们可以在频道页点击秒杀抢购的时候将 ID 一起传到后台,然后根据 ID 去 Redis 中查询详情信息。

    • 业务层
      在 com.changgou.seckill.service.SeckillGoodsService,中添加查询秒杀商品详情的方法,需要提供时间 和 商品 ID:
SeckillGoods one(String time,Long id);

实现:

@Override
     public SeckillGoods one(String time, Long id) {
        return JSON.parseObject((String) redisTemplate.boundHashOps("SeckillGoods_"+time).get(String.valueOf(id)),
                SeckillGoods.class);
    }

(因为前面把 Redis 里的 key、value 都转化成 String 类型了,所以这里查询的时候也需要转化回去)

    • 控制层
   @GetMapping(value = "/one")
    public Result one(Date time,Long id){
        SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
        return new Result(true,StatusCode.OK,"查询秒杀商品信息成功",seckillGoods);
    }

测试:
在这里插入图片描述

四、下单实现

     用户下单,从控制层->Service层->Dao层,所以我们先把 dao 创建好,再创建 service 层,再创建控制层。
     用户下单,为了提升下单速度,我们将订单数据存入到 Redis 缓存中,如果用户支付了,则将 Reids 缓存中的订单存入到 MySQL 中,并清空 Redis 缓存中的订单。

  • 业务层
    创建 com.changgou.seckill.service.SeckillOrderService,并在接口中增加下单方法,代码如下:
public interface SeckillOrderService {
    Boolean add(Long id, String time, String username);
}

实现:

 @Override
    public boolean add(String time, Long id, String username) {

        // 查询秒杀商品
        String nameSpace = "SeckillGoods_" + time;
        SeckillGoods seckillGoods = JSON.parseObject((String) redisTemplate.boundHashOps(nameSpace).get(id.toString()),
                SeckillGoods.class);

        // 判断有无库存
        if (seckillGoods == null || seckillGoods.getStockCount() <= 0) {
            throw new RuntimeException("已售罄!");
        }

        // 创建订单对象
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setSeckillId(id);
        seckillOrder.setMoney(seckillGoods.getCostPrice());
        seckillOrder.setUserId(username);
        seckillOrder.setCreateTime(new Date());

        // 订单状态:未支付
        seckillOrder.setStatus("0");

        // 将订单对象存储到 Redis 中
        // 一个用户只允许有一个未支付秒杀订单
        redisTemplate.boundHashOps("SeckillOrder").put(username, seckillOrder.toString());

        // 库存递减
        seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);

        // 如果商品是最后一个,需要将 Redis 中将该商品删除,并将数据同步到 Mysql
        if (seckillGoods.getStockCount() <= 0) {
            seckillGoodsMapper.updateByPrimaryKey(seckillGoods);
            redisTemplate.boundHashOps(nameSpace).delete(id);

        } else {
            // 同步数据到 Redis
           redisTemplate.boundHashOps(nameSpace).put(id.toString(), JSON.toJSONString(seckillGoods));
        }

        return true;
    }
  • 控制层
@RequestMapping(value = "/add")
    public Result add(String time,Long id){
        String username="jia";
        seckillOrderService.add(time,id,username);
        return new Result(true,StatusCode.OK,"下单成功");
    }

     上述功能完成了秒杀抢单操作,但没有解决并发相关的问题,例如并发、超卖现象,这块甚至有可能产生雪崩问题。
测试:
在这里插入图片描述

五、多线程抢单

使用多线程+队列削峰:
1557038616667
     在实际秒杀中,操作一般都是比较复杂的,而且并发量特别高,比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等。
     下订单这里,我们一般采用多线程下单,但多线程中我们又需要保证用户抢单的公平性,也就是先抢先下单。我们可以这样实现,用户进入秒杀抢单,如果用户符合抢单资格,只需要记录用户抢单数据,存入队列,多线程从队列中进行消费即可,使用 Redis 实现队列, 存入队列采用左压,多线程下单采用右取的方式。

1、异步实现

     要想使用 Spring 的 异步操作(底层是多线程),需要先开启异步操作,用 @EnableAsync 注解开启,然后在对应的异步方法上添加注解 @Async 即可。

创建 com.changgou.seckill.task.MultiThreadingCreateOrder 类,在类中创建一个 createOrder 方法,并在方法上添加 @Async,代码如下:

@Component
public class MultiThreadingCreateOrder {

    /***
     * 多线程下单操作
     */
    @Async
    public void createOrder(){
        try {
            System.out.println("准备执行....");
            Thread.sleep(20000);
            System.out.println("开始执行....");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

     可以看到, createOrder 方法进行了休眠阻塞操作,我们在下单的方法 add 里 调用 createOrder 方法,并在调用后和整个方法结束前分别打印 “1” 和 ”2“,如果下单的方法没有阻塞,继续执行,说明属于异步操作,如果阻塞了,说明没有执行异步操作。
在这里插入图片描述

在这里插入图片描述
运行结果:
在这里插入图片描述
     可以看到,实现了多线程的效果。接下来,需要将之前下单的 add 方法里的逻辑挪到多线程的方法 createOrder 中:
在这里插入图片描述

在这里插入图片描述
     可以看到,username、time、id 的值写si。
在这里插入图片描述
     运行起来是没问题的。
    

2、排队下单

(1)排队信息封装
     用户每次下单的时候,我们可以创建一个队列进行排队,然后采用多线程的方式创建订单,排队我们可以采用 Redis 的队列实现。 排队信息中需要有用户抢单的商品信息,主要包含商品 ID,商品抢购时间段,用户登录名。我们可以设计个 Javabean,如下:

public class SeckillStatus implements Serializable {

    //秒杀用户名
    private String username;
    
    //创建时间
    private Date createTime;
    
    //秒杀状态  1:排队中,2:秒杀等待支付,3:支付超时,4:秒杀失败,5:支付完成
    private Integer status;
    
    //秒杀的商品ID
    private Long goodsId;

    //应付金额
    private Float money;

    //订单号
    private Long orderId;
    //时间段
    private String time;

    public SeckillStatus() {
    }

    public SeckillStatus(String username, Date createTime, Integer status, Long goodsId, String time) {
        this.username = username;
        this.createTime = createTime;
        this.status = status;
        this.goodsId = goodsId;
        this.time = time;
    }
    
    //get、set...略
}

(2)排队实现
我们可以将秒杀抢单信息存入到 Redis 中,这里采用 List 方式存储,List 本身是一个队列,用户点击抢购的时候,就将用户抢购信息存入到 Redis 中,代码如下:
在这里插入图片描述
多线程下单:
在这里插入图片描述
运行,可以看到,会先入队,生成 namespace 为 SeckillOrderQueue 的记录:
在这里插入图片描述
10s 后:
在这里插入图片描述
可以看到,用户的抢单请求出队了,并且生成了 namespace 为 SeckillOrder 的记录,而且 SeckillGoods_xxx 记录的库存也发生了变化,源码:
在这里插入图片描述
     至此,只是生成了 Redis 中的记录,并没有真正下单,所以我们把 ”下单成功“ 改成 ”正在排队“。

3、下单状态查询

     按照上面的流程,虽然可以实现用户下单异步操作,但是并不能确定下单是否成功,所以我们需要做一个页面判断,每过 1 秒钟查询一次下单状态,多线程下单的时候,需要修改抢单状态,支付的时候,清理抢单状态(未实现)。
(1)下单更新抢单状态
     用户每次点击抢购的时候,如果排队成功,则将用户抢购状态存储到 Redis 中,多线程抢单的时候,如果抢单成功,则更新抢单状态。
修改 SeckillOrderServiceImpl 的 add 方法,记录状态,代码如下:
在这里插入图片描述
修改 MultiThreadingCreateOrder 类的 createOrder() 方法:

@Async
    public void createOrder() {
        try {
        System.out.println("准备执行下单......");
        // 睡眠
        Thread.sleep(10000);

        // 从队列中获取用户排队信息,先抢先下单
        SeckillStatus seckillStatus= JSON.parseObject((String) redisTemplate.boundListOps("SeckillOrderQueue").
                        rightPop(),
                SeckillStatus.class);

        if(seckillStatus==null){
            return;
        }

            // 抢单所属时间段
            String time = seckillStatus.getTime();

            // 商品id
            Long id = seckillStatus.getGoodsId();

            // 用户名
            String username = seckillStatus.getUsername();

            // 查询秒杀商品
            String nameSpace = "SeckillGoods_" + time;
            SeckillGoods seckillGoods = JSON.parseObject((String) redisTemplate.boundHashOps(nameSpace).get(id.toString()),
                    SeckillGoods.class);
            // 判断有无库存
            if (seckillGoods == null || seckillGoods.getStockCount() <= 0) {
                throw new RuntimeException("已售罄!");
            }
            // 创建订单对象
            SeckillOrder seckillOrder = new SeckillOrder();

            seckillOrder.setId(idWorker.nextId());

            seckillOrder.setSeckillId(id);
            seckillOrder.setMoney(seckillGoods.getCostPrice());
            seckillOrder.setUserId(username);
            seckillOrder.setCreateTime(new Date());
            // 订单状态:未支付
            seckillOrder.setStatus("0");
            // 将订单对象存储到 Redis 中
            // 一个用户只允许有一个未支付秒杀订单
            redisTemplate.boundHashOps("SeckillOrder").put(username, JSON.toJSONString(seckillOrder));
            // 库存递减
            seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
            // 如果商品是最后一个,需要将 Redis 中将该商品删除,并将数据同步到 Mysql
            if (seckillGoods.getStockCount() <= 0) {
                seckillGoodsMapper.updateByPrimaryKey(seckillGoods);
                redisTemplate.boundHashOps(nameSpace).delete(id);
            } else {
                // 同步数据到 Redis
                redisTemplate.boundHashOps(nameSpace).put(id.toString(), JSON.toJSONString(seckillGoods));

                /**
                 * 更新订单状态
                 */
                seckillStatus.setOrderId(seckillOrder.getId());
                // 支付金额
                seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));

                // 待付款
                seckillStatus.setStatus(2);

                redisTemplate.boundHashOps("UserQueueStatus").put(username,JSON.toJSONString(seckillStatus));
            }
            System.out.println("10 秒钟后下单完成!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

主要添加了逻辑:

seckillOrder.setId(idWorker.nextId());
				 /**
                 * 更新订单状态
                 */
                seckillStatus.setOrderId(seckillOrder.getId());
                // 支付金额
                seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));

                // 待付款
                seckillStatus.setStatus(2);
  • 业务层:
	 /***
     * 根据用户名查询订单
     * @param username
     * @return
     */
   SeckillStatus queryStatus(String username);                

实现:

 @Override
    public SeckillStatus queryStatus(String username) {
        return JSON.parseObject((String) redisTemplate.boundHashOps("UserQueueStatus").get(username),
                SeckillStatus.class);
    }
  • 控制层:
	@RequestMapping(value = "/query")
    public Result queryStatus() {
        String username = "jia";
        SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);
        if (seckillStatus == null) {
            return new Result(false, StatusCode.NOTFOUNDERROR, "查询抢单失败");
        }
        return new Result(true, StatusCode.OK, "查询抢单成功", seckillStatus);
    }

测试:
在这里插入图片描述

六、总结

(1)秒杀是商家为了促销,组织的限时抢购活动。
    秒杀技术实现核心思想是运用缓存减少数据库瞬间的访问压力。
    读取秒杀活动时间段内的商品详细信息时,访问缓存;当用户抢购成功时,减少缓存中的库存数量;当库存数为 0 时或秒杀活动期结束时,将缓存中的商品删除。
用户抢购成功时,产生的秒杀预订单不会立刻写到数据库中,而是先写到缓存,当用户成功付款后,才写入数据库。缓存使用 Redis 数据库。
(2)查询活动时间段内的所有秒杀商品,需要满足以下条件:
① 状态必须为审核通过: status=1
② 商品库存个数 >0
③ 活动时间:startTime >= 当前时间, endTime < 当前时间+2 ,比如说商品的秒杀时间设置startTime 和 endTime 是 11:00 ~ 11:30,当前时间段是 10:00 ~ 12:00,是需要将商品在时间段内展示出来的。
    使用 @Scheduled 开启定时任务,每 60 s 查询一次,从当前时间所在时间段算起,查询 5 个时间段内秒杀活动的商品数据。以 SeckillGoods_[time] 为 namespace,key 是商品的id,value是商品对象。还需要去重,也就是说,已经在缓存中的 key,不需要再存了,逻辑是先遍历 key,然后使用 SQL 语句,查询出条件为 id not in keys 的记录。、
    
(3)用户抢购成功时,产生的秒杀预订单不会立刻写到数据库中,而是先写到缓存,用户下单时,是先到缓存中查询秒杀商品,如果无库存,会提示“已售罄”,然后创建 SeckillOrder 订单对象,设置id、金额、用户id、创建时间、订单状态(未支付)、并把订单对象存入缓存,namespace 为 SeckillOrder,key 为 用户名,value 为订单对象,然后商品库存递减,如果商品是最后一个,也就是说递减后数目 <(这里可以小于吗???)=0 时,需要把商品从 SeckillGoods_[time] 中移除,也就是说,商品不再作秒杀活动了。
    使用 @Async 开启多线程,用户抢单入队,多线程从队列中消费,队列使用 Redis 的 List 实现,左进右出,实现“先抢先下单”。封装排队信息 SeckillStatus,包括 username、createTime、status(排队中;等待支付;支付超时;秒杀失败;支付完成)、goodsId 等属性。
    可以在消费的 createOrder 方法中,sleep 10s,这样就能看到先入队,再出队的效果。list 名为 SeckillOrderQueue 。 入队后,生成 namespace 为 UserQueueStatus ,key 为 username,value 为 SeckillStratus
的记录,用于记录抢单成功的订单状态,后续可以进行查询。(否则多线程就消费,出队了,查询不到了)