文章目录
代码链接: 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。 -
时间菜单分析
将商品数据从数据库中查询出来,并存入 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,"下单成功");
}
上述功能完成了秒杀抢单操作,但没有解决并发相关的问题,例如并发、超卖现象,这块甚至有可能产生雪崩问题。
测试:
五、多线程抢单
使用多线程+队列削峰:
在实际秒杀中,操作一般都是比较复杂的,而且并发量特别高,比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等。
下订单这里,我们一般采用多线程下单,但多线程中我们又需要保证用户抢单的公平性,也就是先抢先下单。我们可以这样实现,用户进入秒杀抢单,如果用户符合抢单资格,只需要记录用户抢单数据,存入队列,多线程从队列中进行消费即可,使用 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
的记录,用于记录抢单成功的订单状态,后续可以进行查询。(否则多线程就消费,出队了,查询不到了)