文章目录

  • 一、秒杀业务分析
    • 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: seckilldatasource:driver-class-name: com.mysql.jdbc.Driverurl: jdbc:mysql://192.168.211.132:3306/changgou_seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTCusername: rootpassword: 123456rabbitmq: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/eurekainstance:prefer-ip-address: true
feign:hystrix:enabled: true
#hystrix 配置
hystrix:command:default:execution:timeout:#如果enabled设置为false,则请求超时交给ribbon控制enabled: trueisolation:thread:timeoutInMilliseconds: 10000strategy: 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);}@Beanpublic 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。

  • 时间菜单分析

         将商品数据从数据库中查询出来,并存入 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 {@Autowiredprivate SeckillGoodsMapper seckillGoodsMapper;@Autowiredprivate 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 >02、审核状态为通过 status3、开始时间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);// 存入 Redisfor (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 {@Autowiredprivate RedisTemplate redisTemplate;/**** Redis 中根据 Key 获取秒杀商品列表* @param key* @return*/@Overridepublic 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);

实现:

@Overridepublic 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);
}

实现:

 @Overridepublic 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 中将该商品删除,并将数据同步到 Mysqlif (seckillGoods.getStockCount() <= 0) {seckillGoodsMapper.updateByPrimaryKey(seckillGoods);redisTemplate.boundHashOps(nameSpace).delete(id);} else {// 同步数据到 RedisredisTemplate.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,"下单成功");}

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

五、多线程抢单

使用多线程+队列削峰:

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

1、异步实现

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

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

@Component
public class MultiThreadingCreateOrder {/**** 多线程下单操作*/@Asyncpublic 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;//秒杀的商品IDprivate 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() 方法:

@Asyncpublic 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();// 商品idLong 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 中将该商品删除,并将数据同步到 Mysqlif (seckillGoods.getStockCount() <= 0) {seckillGoodsMapper.updateByPrimaryKey(seckillGoods);redisTemplate.boundHashOps(nameSpace).delete(id);} else {// 同步数据到 RedisredisTemplate.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);

实现:

 @Overridepublic 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
的记录,用于记录抢单成功的订单状态,后续可以进行查询。(否则多线程就消费,出队了,查询不到了)

微服务商城系统(十五)秒杀基础相关推荐

  1. springcloud 整合 gateway_GitHub上最火的SpringCloud微服务商城系统项目,附全套教程

    项目介绍 mall-swarm是一套微服务商城系统,采用了 Spring Cloud Greenwich.Spring Boot 2.MyBatis.Docker.Elasticsearch等核心技术 ...

  2. mall-swarm是一套微服务商城系统

    介绍: mall-swarm是一套微服务商城系统,采用了 Spring Cloud Hoxton & Alibaba.Spring Boot 2.3.Oauth2.MyBatis.Elasti ...

  3. mall-swarm微服务商城系统

    mall-swarm是一套微服务商城系统,采用了 Spring Cloud 2021 & Alibaba.Spring Boot 2.7.Oauth2.MyBatis.Docker.Elast ...

  4. 微服务商城系统(十六)秒杀核心

    代码链接: https://github.com/betterGa/ChangGou 文章目录 一.防止秒杀重复排队 二. 并发超卖问题解决 三. 订单支付 1.实现根据不同类型订单识别不同操作队列 ...

  5. 微服务商城系统(十四)微信支付

    文章目录 一.支付微服务 1.微信支付 API 2.HttpClient 工具类 3.支付微服务搭建 二.微信支付二维码生成 三.检测支付状态 四.内网穿透 五.支付结果通知 1.支付结果回调通知 2 ...

  6. 微服务商城系统(十) Spring Security Oauth2 + JWT 用户认证

    文章目录 一.用户认证分析 1.认证 与 授权 2.单点登录 3.第三方账号登录 4.第三方认证 5.认证技术方案 6.Security Oauth 2.0 入门 7. 资源服务授权 (1)资源服务授 ...

  7. 微服务商城系统(一)框架搭建、商品微服务搭建

    文章目录 一.预备 1.微服务 2.缓存 3.通用Mapper 和 PageHelper 4.持久化 5.电商模式 二.系统设计 三.框架搭建 1.环境准备 2.项目结构介绍 3.公共工程搭建 (1) ...

  8. SpringCloud Alibaba 微服务架构(十五)- 一文详解 Nacos 高可用特性

    前言 服务注册发现是一个经久不衰的话题,Dubbo 早期开源时默认的注册中心 Zookeeper 最早进入人们的视线,并且在很长一段时间里,人们将注册中心和 Zookeeper 划上了等号,可能 Zo ...

  9. 微服务商城系统(六)商品搜索 SpringBoot 整合 Elasticsearch

    文章目录 一.Elasticsearch 和 IK 分词器的安装 二.Kibana 使用 三.数据导入 Elasticsearch 1.SpringData Elasticsearch 介绍 2.搜索 ...

  10. 微服务商城系统 实战记录 用户、商家、后台管理员注册与登录功能实现

    代码见 https://github.com/betterGa/ChangGou 文章目录 一.用户注册 1.使用 ajax (POST 方法) 2.使用 thymeleaf 3.解决跨域问题 二.用 ...

最新文章

  1. AI如何帮助我们理解意识——麻省理工最新大脑研究
  2. util.Date与sql.Date的相互转换以及区别
  3. 【Visual C++】一些开发心得与调试技巧
  4. 网络:TCP/UDP
  5. kiss原则包括什么_和女孩牵手与kiss的具体方法
  6. 【机器视觉】 dev_update_var算子
  7. 织梦 css里的图片标签,织梦{dede:field.body /}中用CSS的expression参数控制图片大小
  8. 列表生成式(List)
  9. 前后端分离 与 不分离
  10. ASS 字幕格式规范
  11. C语言求若干个数的均值和方差
  12. win7家庭版如何升级到专业版和旗舰版
  13. 宇视摄像头安装——筒机安装
  14. Nexus私服的下载、安装、启动、配置教程
  15. golang读取conf文件的两种方式(ini和Viper)
  16. 最邻近差值算法(nearest)和双线性插值算法(bilinear)
  17. 台大李宏毅课程笔记3——New Optimization for Deep Learning深度学习新优化
  18. 英文中 vi和vt的区别
  19. Ubuntu 16.04 源码编译安装GPU tensorflow(二)
  20. Python股票监控机器人,加强版!

热门文章

  1. HazelEngine 学习记录 - Layers
  2. MacOS提示“Developer tools access“需要控制另一个进程,以便继续调试
  3. 初二计算机听课笔记,初二物理上听课记录20篇
  4. hdu2859Phalanx
  5. WinDBG技巧:this指针的常见误区 (ECX寄存器存放this指针)
  6. Numpy-如何对数组进行切割
  7. 侯宁彬出席“春风拂槛”唐文化论坛并发表主题演讲
  8. 独家 | Fomo 3D 沦陷?为何又是 DDoS攻击?来听听区块链安全大牛的深度解析
  9. 基于mysql 批量插入100w测试数据
  10. 认识SlackwareLinux及制作系统安装磁片之关於bootdisk(转)