一、秒杀介绍:

1.1 需求分析:

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

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

1.2 表结构说明

秒杀商品信息表

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

秒杀订单表:

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

1.3 秒杀需求分析:

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

1.4 秒杀商品压入缓存

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

二、 秒杀服务工程zyg-seckill-interface与zyg-seckill-service

2.1 业务需求:

我们将商品数据压入到Reids缓存,可以在秒杀工程的服务工程中完成,可以按照如下步骤实现:

1.查询活动没结束的所有秒杀商品
1) 状态必须为审核通过 status=1 2) 商品库存个数>0 3) 活动没有结束 endTime>now() 4) 在Redis中没有该商品的缓存 5) 执行查询获取对应的结果集 2.将活动没有结束的秒杀商品入库

2.2 在zyg-seckill-service中添加依赖如下:

  1. <dependencies>
  2. <dependency>
  3. <groupId>org.springframework.boot</groupId>
  4. <artifactId>spring-boot-starter-web</artifactId>
  5. </dependency>
  6. <dependency>
  7. <groupId>com.zelin</groupId>
  8. <artifactId>zyg-seckill-interface</artifactId>
  9. <version>2.0</version>
  10. </dependency>
  11. <dependency>
  12. <groupId>com.zelin</groupId>
  13. <artifactId>zyg-dao</artifactId>
  14. <version>2.0</version>
  15. </dependency>
  16. <!--1. 引入thymeleaf-->
  17. <dependency>
  18. <groupId>org.springframework.boot</groupId>
  19. <artifactId>spring-boot-starter-thymeleaf</artifactId>
  20. </dependency>
  21. <!--2. 添加activemq依赖-->
  22. <dependency>
  23. <groupId>org.springframework.boot</groupId>
  24. <artifactId>spring-boot-starter-activemq</artifactId>
  25. </dependency>
  26. <!--3.引入redis-->
  27. <dependency>
  28. <groupId>org.springframework.boot</groupId>
  29. <artifactId>spring-boot-starter-data-redis</artifactId>
  30. </dependency>
  31. <dependency>
  32. <groupId>org.springframework.boot</groupId>
  33. <artifactId>spring-boot-starter-test</artifactId>
  34. </dependency>
  35. </dependencies>
  36. </project>

2.3 在zyg-seckill-service中添加application.yml文件:

  1. server:
  2. port: 7009
  3. logging:
  4. level:
  5. com.zelin: debug
  6. spring:
  7. dubbo:
  8. application:
  9. name: zyg-seckill-service
  10. registry:
  11. address: zookeeper://192.168.56.10:2181
  12. base-package: com.zelin.seckill.service
  13. protocol:
  14. name: dubbo
  15. port: 21889
  16. thymeleaf:
  17. cache: false
  18. activemq:
  19. broker-url: tcp://192.168.56.10:61616
  20. packages:
  21. trust-all: true
  22. redis:
  23. host: 192.168.56.10

三、定时任务介绍:

3.1 介绍:

一会儿我们采用Spring的定时任务定时将符合参与秒杀的商品查询出来再存入到Redis缓存,所以这里需要使用到定时任务。
这里我们了解下定时任务相关的配置,配置步骤如下:

1) 在定时任务类的指定方法上加上@Scheduled开启定时任务 2) 定时任务表达式:使用cron属性来配置定时任务执行时间 3) 在类上添加@Component注解及@EnableScheduling注解才可以。

3.2 定时任务常用时间表达式

CronTrigger配置完整格式为: 小时 [年]

序号 说明 是否必填 允许填写的值 允许的通配符
1 0-59 , - * /
2 0-59 , - * /
3 小时 0-23 , - * /
4 1-31 , - * ? / L W
5 1-12或JAN-DEC , - * /
6 1-7或SUN-SAT , - * ? / L W
7 empty 或1970-2099 , - * /

使用说明:

通配符说明: 表示所有值. 例如:在分的字段上设置 ““,表示每一分钟都会触发。 ? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值。 例如:要在每月的10号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为”?” 具体设置为 0 0 0 10 * ?

  • 表示区间。例如 在小时上设置 “10-12”,表示 10,11,12点都会触发。 , 表示指定多个值,例如在周字段上设置 “MON,WED,FRI” 表示周一,周三和周五触发 12,14,19 / 用于递增触发。如在秒上面设置”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”,正好第五周没有周六,则不会触发该配置(用在母亲节和父亲节再合适不过了) ;

常用表达式Cron

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

3.3 秒杀商品压入缓存实现

3.3.1 数据检索条件分析

按照2.1中的几个步骤实现将秒杀商品从数据库中查询出来,并存入到Redis缓存

  1. 1.查询活动没结束的所有秒杀商品
  2. 1)计算秒杀时间段
  3. 2)状态必须为审核通过 status=1
  4. 3)商品库存个数>0
  5. 4)活动没有结束 endTime>=now()
  6. 5)在Redis中没有该商品的缓存
  7. 6)执行查询获取对应的结果集
  8. 2.将活动没有结束的秒杀商品入库

上面这里会涉及到时间操作,所以这里提前准备了一个时间工具包MyDate(在common工程下),
内容如下:

  1. /**
  2. * ------------------------------
  3. * 功能:
  4. * 作者:WF
  5. * 微信:hbxfwf13590332912
  6. * 创建时间:2021/8/18-15:23
  7. * ------------------------------
  8. */
  9. public class MyDate {
  10. public static void main(String[] args) {
  11. List<Date> dateMenus = getDateMenus();
  12. SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmm");
  13. for (Date date : dateMenus) {
  14. String format = sdf.format(date);
  15. System.out.println( format);
  16. }
  17. }
  18. /**
  19. * 功能: 将指定的日期转换为:yyyyMMddHH这种格式串输出
  20. * 参数:
  21. * 返回值:
  22. * 时间: 2021/8/18 15:39
  23. */
  24. public static String getDateStr(Date date){
  25. SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHH");
  26. return sdf.format(date);
  27. }
  28. /***
  29. * 获取时间菜单
  30. * @return
  31. */
  32. public static List<Date> getDateMenus(){
  33. //定义一个List<Date>集合,存储所有时间段
  34. List<Date> dates = getDates(12);
  35. //判断当前时间属于哪个时间范围
  36. Date now = new Date();
  37. SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmm");
  38. for (Date cdate : dates) {
  39. //开始时间<=当前时间<开始时间+2小时
  40. if(now.getTime() >= cdate.getTime() && now.getTime()< addDateHour(cdate,2).getTime()){
  41. now = cdate;
  42. break;
  43. }
  44. }
  45. //当前需要显示的时间菜单
  46. List<Date> dateMenus = new ArrayList<Date>();
  47. for (int i = 0; i < 5 ; i++) {
  48. dateMenus.add(addDateHour(now,i*2));
  49. }
  50. return dateMenus;
  51. }
  52. /***
  53. * 指定时间往后N个时间间隔
  54. * @param hours
  55. * @return
  56. */
  57. public static List<Date> getDates(int hours) {
  58. List<Date> dates = new ArrayList<Date>();
  59. //循环12次
  60. Date date = toDayStartHour(new Date()); //凌晨
  61. for (int i = 0; i <hours ; i++) {
  62. //每次递增2小时,将每次递增的时间存入到List<Date>集合中
  63. dates.add(addDateHour(date,i*2));
  64. }
  65. return dates;
  66. }
  67. /**
  68. * 功能: 给指定日期添加指定的小时数
  69. * 参数:
  70. * 返回值: java.util.Date
  71. * 时间: 2021/8/18 15:27
  72. */
  73. public static Date addDateHour(Date date, int hour) {
  74. Calendar calendar = Calendar.getInstance();
  75. calendar.setTime(date);
  76. calendar.add(Calendar.HOUR_OF_DAY,hour);
  77. return calendar.getTime();
  78. }
  79. /**
  80. * 功能: 将传入的日期设置为00:00:00
  81. * 参数:
  82. * 返回值: java.util.Date
  83. * 时间: 2021/8/18 15:24
  84. */
  85. public static Date toDayStartHour(Date date) {
  86. Calendar calendar = Calendar.getInstance();
  87. calendar.setTime(date);
  88. calendar.set(Calendar.HOUR_OF_DAY,0);
  89. calendar.set(Calendar.MINUTE,0);
  90. return calendar.getTime();
  91. }
  92. }

3.3.2 时间菜单分析

我们将商品数据从数据库中查询出来,并存入Redis缓存,但页面每次显示的时候,只显示当前正在秒杀以及往后延时2个小时、4个小时、6个小时、8个小时的秒杀商品数据。我们要做的第一个事是计算出秒杀时间菜单,这个菜单是从后台获取的。
这个时间菜单的计算我们来分析下,可以先求出当前时间的凌晨,然后每2个小时后作为下一个抢购的开始时间,这样可以分出12个抢购时间段,如下:

  1. 00:00-02:00
  2. 02:00-04:00
  3. 04:00-06:00
  4. 06:00-08:00
  5. 08:00-10:00
  6. 10:00-12:00
  7. 12:00-14:00
  8. 14:00-16:00
  9. 16:00-18:00
  10. 18:00-20:00
  11. 20:00-22:00
  12. 22:00-00:00

而现实的菜单只需要计算出当前时间在哪个时间段范围,该时间段范围就属于正在秒杀的时间段,而后面即将开始的秒杀时间段的计算也就出来了,可以在当前时间段基础之上+2小时、+4小时、+6小时、+8小时。

3.3.3 查询秒杀商品导入Reids

我们可以写个定时任务,查询从当前时间开始,往后延续4个时间菜单间隔,也就是一共只查询5个时间段抢购商品数据,并压入缓存,实现代码如下:
修改SeckillGoodsPushTask的loadDataToRedis方法,代码如下:

  1. /**
  2. * ------------------------------
  3. * 功能:定时向redis中添加商品
  4. * 作者:WF
  5. * 微信:hbxfwf13590332912
  6. * 创建时间:2021/8/18-16:23
  7. * ------------------------------
  8. */
  9. @Component
  10. @EnableScheduling
  11. public class SeckillGoodsPushTask {
  12. @Autowired
  13. private SeckillGoodsDao seckillGoodsDao;
  14. @Autowired
  15. private StringRedisTemplate redisTemplate;
  16. /**
  17. * 功能: 将数据库中的数据加载到redis中
  18. * 参数:
  19. * 返回值: void
  20. * 时间: 2021/8/18 16:24
  21. */
  22. @Scheduled(cron = "0/30 * * * * ?")
  23. public void loadDataToRedis(){
  24. //1. 得到日期菜单
  25. List<Date> dateMenus = MyDate.getDateMenus();
  26. //2. 遍历日期菜单
  27. for (Date dateMenu : dateMenus) {
  28. //2.1 定义查询条件
  29. QueryWrapper<SeckillGoodsEntity> queryWrapper = new QueryWrapper<SeckillGoodsEntity>()
  30. .eq("status", 1)
  31. .ge("start_time",dateMenu)
  32. .lt("end_time",MyDate.addDateHour(dateMenu,2))
  33. .gt("stock_count",0);
  34. //2.2 查询redis中指定大key中是否包含有小key集合
  35. Set keys = redisTemplate.boundHashOps("SeckillGoods_" + MyDate.getDateStr(dateMenu)).keys();
  36. //2.3 判断是否keys存在
  37. if(keys != null && keys.size() > 0){
  38. queryWrapper.notIn("id",keys);
  39. }
  40. //2.4 查询商品列表
  41. List<SeckillGoodsEntity> goodsEntities = seckillGoodsDao.selectList(queryWrapper);
  42. //2.5 将商品放到redis中
  43. for (SeckillGoodsEntity goodsEntity : goodsEntities) {
  44. BoundHashOperations<String, String, String> hashOperations = redisTemplate.boundHashOps("SeckillGoods_" + MyDate.getDateStr(dateMenu));
  45. hashOperations.put(goodsEntity.getId() + "", JSON.toJSONString(goodsEntity));
  46. //设置过期时间
  47. hashOperations.expire(MyDate.addDateHour(dateMenu,2).getHours() - new Date().getHours() , TimeUnit.HOURS);
  48. }
  49. }
  50. }
  51. }

3.3.4 查看redis中的存放数据:

image.png
3.3.5 从数据库中取出的商品信息的开始与结束时间相比实际时间少8小时问题:

1、application.yml文件中添加如下配置:

  1. server:
  2. port: 9008
  3. spring:
  4. datasource:
  5. driver-class-name: com.mysql.cj.jdbc.Driver
  6. url: jdbc:mysql://192.168.56.16:3306/zyg_seckill?serverTimezone=GMT%2B8
  7. username: root
  8. password: 123
  9. jackson:
  10. date-format: yyyy-MM-dd HH:mm:ss
  11. time-zone: GMT+8
  12. cloud:
  13. nacos:
  14. config:
  15. server-addr: localhost:8848
  16. discovery:
  17. server-addr: localhost:8848
  18. thymeleaf:
  19. cache: false
  20. redis:
  21. host: 192.168.56.16
  22. application:
  23. name: zyg-seckill
  24. logging:
  25. level:
  26. com.zyg.seckill: debug
  27. mybatis-plus:
  28. mapper-locations: classpath*:/mapper/**/*.xml

2、在相关实体类中添加对时间处理的注解:

  1. @Data
  2. @AllArgsConstructor
  3. @NoArgsConstructor
  4. @TableName(value = "tb_seckill_goods")
  5. public class TbSeckillGoods implements Serializable {
  6. @TableId(value = "id", type = IdType.AUTO)
  7. private Integer id;
  8. /**
  9. * spu ID
  10. */
  11. @TableField(value = "goods_id")
  12. private Long goodsId;
  13. /**
  14. * sku ID
  15. */
  16. @TableField(value = "item_id")
  17. private Integer itemId;
  18. /**
  19. * 标题
  20. */
  21. @TableField(value = "title")
  22. private String title;
  23. /**
  24. * 商品图片
  25. */
  26. @TableField(value = "small_pic")
  27. private String smallPic;
  28. /**
  29. * 原价格
  30. */
  31. @TableField(value = "price")
  32. private BigDecimal price;
  33. /**
  34. * 秒杀价格
  35. */
  36. @TableField(value = "cost_price")
  37. private BigDecimal costPrice;
  38. /**
  39. * 商家ID
  40. */
  41. @TableField(value = "seller_id")
  42. private String sellerId;
  43. /**
  44. * 添加日期
  45. */
  46. @TableField(value = "create_time")
  47. private Date createTime;
  48. /**
  49. * 审核日期
  50. */
  51. @TableField(value = "check_time")
  52. private Date checkTime;
  53. /**
  54. * 审核状态
  55. */
  56. @TableField(value = "status")
  57. private String status;
  58. /**
  59. * 开始时间
  60. */
  61. @TableField(value = "start_time")
  62. @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
  63. private Date startTime;
  64. /**
  65. * 结束时间
  66. */
  67. @TableField(value = "end_time")
  68. @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
  69. private Date endTime;
  70. /**
  71. * 秒杀商品数
  72. */
  73. @TableField(value = "num")
  74. private Integer num;
  75. /**
  76. * 剩余库存数
  77. */
  78. @TableField(value = "stock_count")
  79. private Integer stockCount;
  80. /**
  81. * 描述
  82. */
  83. @TableField(value = "introduction")
  84. private String introduction;
  85. private static final long serialVersionUID = 1L;
  86. }