一、秒杀(高并发)系统关注的问题

秒杀业务:

​ 秒杀具有瞬间高并发的特点,针对这一特点,必须要做限流 + 异步 + 缓存(页面静态化)+ 独立部署

限流方式:

1. 前端限流,一些高并发的网站直接在前端页面开始限流,例如:小米的验证码设计
  1. nginx,限流,直接负载部分请求到错误的静态页面:令牌算法漏斗算法
  2. 网关限流,限流的过滤器
  3. 代码中使用分布式信号量
  4. rabbitmq 限流(能者多劳: chanel.basicOos(1)),保证发挥所有服务器的性能。

秒杀流程:

​ 1、先新增秒杀场次到 DB【后台系统新增】
​ 2、再关联商品【后台系统关联】
​ 3、定时任务将最近三天的场次+关联商品上传到 redis 中【定时 上架 3 天内的秒杀场次+商品】

image-20220409124657130

二、创建秒杀服务

添加 gateway 路由转发

1
2
3
4
5
6
- id: coupon_route
uri: lb://gulimall-coupon
predicates:
- Path=/api/coupon/**
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment}

登录后台管理界面,添加秒杀场次

**例如: **添加 8 点场,对应表 sms_seckill_session【秒杀场次表】

image-20220409233513165

秒杀场次关联商品

sms_seckill_sku_relation【关联表】
字段:
promotion_id【活动 id】、promotion_session_id【活动场次 id】、sku_id、排序、价格、总量、每人限购数量

  • SeckillSkuRelationServiceImpl.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public PageUtils queryPage(Map<String, Object> params) {

QueryWrapper<SeckillSkuRelationEntity> queryWrapper = new QueryWrapper<>();

String promotionSessionId = (String) params.get("promotionSessionId");

if (!StringUtils.isEmpty(promotionSessionId)) {
queryWrapper.eq("promotion_session_id", promotionSessionId);
}

IPage<SeckillSkuRelationEntity> page = this.page(
new Query<SeckillSkuRelationEntity>().getPage(params),
queryWrapper
);

return new PageUtils(page);
}

image-20220409234834159

image-20220409235719712

创建秒杀 gulimall-seckill 微服务

  • redis、openFeign、spring boot devtools、spring web、lombok

image-20220410002635158

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<dependencies>
<dependency>
<groupId>com.oy.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

三、定时任务-QUARTZ

多种实现方式:

​ Timer、线程池、mq 的延迟队列、QUARTZ【搭配 cron 表达式使用】、spring 框架的定时任务,可以整合 QUARTZ(springboot 默认定时任务框架不是 QUARTZ,如果需要使用引入即可)

最终解决方案:使用异步任务 + 定时任务来完成定时任务不阻塞的功能

1、减轻 DB 压力,定时任务查询需要上架的秒杀商品上架到 redis 中,库存信息等

2、语法:秒 分 时 日 月 周 年 (spring 不支持年,所以可以不写)

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Format
A cron expression is a string comprised of 6 or 7 fields separated by white space. Fields can contain any of the allowed values, along with various combinations of the allowed special characters for that field. The fields are as follows:

Field Name Mandatory Allowed Values Allowed Special Characters
Seconds YES 0-59 , - * /
Minutes YES 0-59 , - * /
Hours YES 0-23 , - * /
Day of month YES 1-31 , - * ? / L W
Month YES 1-12 or JAN-DEC , - * /
Day of week YES 1-7 or SUN-SAT , - * ? / L #
Year NO empty, 1970-2099 , - * /

特殊字符:
,:枚举;
(cron="7,9,23****?"):任意时刻的7,923秒启动这个任务;
-:范围:
(cron="7-20****?""):任意时刻的7-20秒之间,每秒启动一次
*:任意;
指定位置的任意时刻都可以
/:步长;
(cron="7/5****?"):第7秒启动,每5秒一次;
(cron="*/5****?"):任意秒启动,每5秒一次;

? :(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使用?
(cron="***1*?"):每月的1号,而且必须是周二然后启动这个任务;

L:(出现在日和周的位置)”,
last:最后一个
(cron="***?*3L"):每月的最后一个周二

W:Work Day:工作日
(cron="***W*?"):每个月的工作日触发
(cron="***LW*?"):每个月的最后一个工作日触发
#:第几个
(cron="***?*5#2"):每个月的 第2个周4

3、在线定时器 https://cron.qqe2.com/

4、Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Examples
Here are some full examples:

**Expression** **Meaning**
0 0 12 * * ? Fire at 12pm (noon) every day
0 15 10 ? * * Fire at 10:15am every day
0 15 10 * * ? Fire at 10:15am every day
0 15 10 * * ? * Fire at 10:15am every day
0 15 10 * * ? 2005 Fire at 10:15am every day during the year 2005
0 * 14 * * ? Fire every minute starting at 2pm and ending at 2:59pm, every day
0 0/5 14 * * ? Fire every 5 minutes starting at 2pm and ending at 2:55pm, every day
0 0/5 14,18 * * ? Fire every 5 minutes starting at 2pm and ending at 2:55pm, AND fire every 5 minutes starting at 6pm and ending at 6:55pm, every day
0 0-5 14 * * ? Fire every minute starting at 2pm and ending at 2:05pm, every day
0 10,44 14 ? 3 WED Fire at 2:10pm and at 2:44pm every Wednesday in the month of March.
0 15 10 ? * MON-FRI Fire at 10:15am every Monday, Tuesday, Wednesday, Thursday and Friday
0 15 10 15 * ? Fire at 10:15am on the 15th day of every month
0 15 10 L * ? Fire at 10:15am on the last day of every month
0 15 10 L-2 * ? Fire at 10:15am on the 2nd-to-last last day of every month
0 15 10 ? * 6L Fire at 10:15am on the last Friday of every month
0 15 10 ? * 6L Fire at 10:15am on the last Friday of every month
0 15 10 ? * 6L 2002-2005 Fire at 10:15am on every last friday of every month during the years 2002, 2003, 2004 and 2005
0 15 10 ? * 6#3 Fire at 10:15am on the third Friday of every month
0 0 12 1/5 * ? Fire at 12pm (noon) every 5 days every month, starting on the first day of the month.
0 11 11 11 11 ? Fire every November 11th at 11:11am.

springboot 开启定时任务 Demo

​ 解决:使用异步任务 + 定时任务来完成定时任务不阻塞的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1、加在类上
@Component
@EnableScheduling开启定时任务【spring 默认是使用自己的定时任务】
@EnableAsync:开启异步任务【定时任务不应该阻塞,需要异步执行(不加该注解是同步的,例如方法内部sleep会阻塞)】
解决办法:1、自己异步执行【CompletableFuture.runAsync】
2、使用spring的 定时任务线程池scheduling.pool.size: 5
3、使用springboot的异步定时任务@EnableAsync
然后配置异步任务的属性
spring:
task:
execution:
pool:
core-size: 5
max-size: 50
然后给定时任务方法加上@Async【这个注解就是异步执行,不一定是定时任务】

2、加载异步定时任务方法上

1
2
@Async 异步执行的方法标注
@Scheduled(cron = "*/5 * * ? * 4")

3、编写任务

1
每周4的任意秒启动,5S一次执行

4、spring 注意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

1)spring周一都周天就是1-7
2)没有年,只有6
/**
* 定时任务
* 1、@EnableScheduling 开启定时任务
* 2、@Scheduled开启一个定时任务
* 3、自动配置类TaskSchedulingAutoConfiguration
* 异步任务
* 1、@EnableAsync:开启异步任务
* 2、@Async:给希望异步执行的方法标注
* 3、自动配置类TaskExecutionAutoConfiguration
*/

@Slf4j
@Component
// @EnableAsync
// @EnableScheduling
public class HelloScheduled {

/**
* 1、在Spring中表达式是6位组成,不允许第七位的年份
* 2、在周几的的位置,1-7代表周一到周日
* 3、定时任务不该阻塞。默认是阻塞的
* 1)、可以让业务以异步的方式,自己提交到线程池
* CompletableFuture.runAsync(() -> {
* },execute);
*
* 2)、支持定时任务线程池;设置 TaskSchedulingProperties
* spring.task.scheduling.pool.size: 5
*
* 3)、让定时任务异步执行
* 异步任务
*
* 解决:使用异步任务 + 定时任务来完成定时任务不阻塞的功能
*
*/
@Async
@Scheduled(cron = "*/5 * * ? * 4")
public void hello() {
log.info("hello...");
try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }

}

}

四、秒杀架构设计

4.1 秒杀架构图

  • 项目独立部署,独立秒杀模块gulimall-seckill
  • 使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
  • 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口
  • 库存预热,先从数据库中扣除一部分库存以redisson 信号量的形式存储在 redis 中
  • 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单

4.2 存储模型设计

  • 秒杀场次存储的List可以当做hash keySECKILL_CHARE_PREFIX 中获得对应的商品数据
1
2
3
4
5
6
7
8
9
10
11
12
13
//存储的秒杀场次对应数据
//K: SESSION_CACHE_PREFIX + startTime + "_" + endTime
//V: sessionId+"-"+skuId的List
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";

//存储的秒杀商品数据
//K: 固定值SECKILL_CHARE_PREFIX
//V: hash,k为sessionId+"-"+skuId,v为对应的商品信息SeckillSkuRedisTo
private final String SECKILL_CHARE_PREFIX = "seckill:skus";

//K: SKU_STOCK_SEMAPHORE+商品随机码
//V: 秒杀的库存件数
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; //+商品随机码
  • 存储后的效果

  • 用来存储的 to

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Data
public class SeckillSkuRedisTo {
private Long id;
/**
* 活动id
*/
private Long promotionId;
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private Integer seckillCount;
/**
* 每人限购数量
*/
private Integer seckillLimit;
/**
* 排序
*/
private Integer seckillSort;
//以上都为SeckillSkuRelationEntity的属性

//skuInfo
private SkuInfoVo skuInfoVo;

//当前商品秒杀的开始时间
private Long startTime;

//当前商品秒杀的结束时间
private Long endTime;

//当前商品秒杀的随机码
private String randomCode;
}

4.3 商品上架

4.3.1 定时上架

  • 开启对定时任务的支持
1
2
3
4
5
@EnableAsync //开启对异步的支持,防止定时任务之间相互阻塞
@EnableScheduling //开启对定时任务的支持
@Configuration
public class ScheduledConfig {
}
  • 每天凌晨三点远程调用coupon服务上架最近三天的秒杀商品

  • 由于在分布式情况下该方法可能同时被调用多次,因此加入分布式锁,同时只有一个服务可以调用该方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//秒杀商品上架功能的锁
private final String upload_lock = "seckill:upload:lock";

/**
* 定时任务
* 每天三点上架最近三天的秒杀商品
*/
@Async
@Scheduled(cron = "0 0 3 * * ?")
public void uploadSeckillSkuLatest3Days() {
//为避免分布式情况下多服务同时上架的情况,使用分布式锁
RLock lock = redissonClient.getLock(upload_lock);
try {
lock.lock(10, TimeUnit.SECONDS);
secKillService.uploadSeckillSkuLatest3Days();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}

@Override
public void uploadSeckillSkuLatest3Days() {
R r = couponFeignService.getSeckillSessionsIn3Days();
if (r.getCode() == 0) {
List<SeckillSessionWithSkusVo> sessions = r.getData(new TypeReference<List<SeckillSessionWithSkusVo>>() {
});
//在redis中分别保存秒杀场次信息和场次对应的秒杀商品信息
saveSecKillSession(sessions);
saveSecKillSku(sessions);
}
}

4.3.2 获取最近三天的秒杀信息

  • 获取最近三天的秒杀场次信息,再通过秒杀场次 id 查询对应的商品信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public List<SeckillSessionEntity> getSeckillSessionsIn3Days() {
QueryWrapper<SeckillSessionEntity> queryWrapper = new QueryWrapper<SeckillSessionEntity>()
.between("start_time", getStartTime(), getEndTime());
List<SeckillSessionEntity> seckillSessionEntities = this.list(queryWrapper);
List<SeckillSessionEntity> list = seckillSessionEntities.stream().map(session -> {
List<SeckillSkuRelationEntity> skuRelationEntities = seckillSkuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", session.getId()));
session.setRelations(skuRelationEntities);
return session;
}).collect(Collectors.toList());

return list;
}

//当前天数的 00:00:00
private String getStartTime() {
LocalDate now = LocalDate.now();
LocalDateTime time = now.atTime(LocalTime.MIN);
String format = time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}

//当前天数+2 23:59:59..
private String getEndTime() {
LocalDate now = LocalDate.now();
LocalDateTime time = now.plusDays(2).atTime(LocalTime.MAX);
String format = time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}

4.3.3 在 redis 中保存秒杀场次信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {

if(sessions != null && sessions.size() > 0){
sessions.stream().forEach(session -> {

//获取当前活动的开始和结束时间的时间戳
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();

//存入到Redis中的key
String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime;

//判断Redis中是否有该信息,如果没有才进行添加
Boolean hasKey = redisTemplate.hasKey(key);
//缓存活动信息
if (!hasKey) {
//获取到活动中所有商品的skuId
List<String> skuIds = session.getRelationSkus().stream()
.map(item -> item.getPromotionSessionId() + "-" + item.getSkuId().toString()).collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key,skuIds);
}
});
}
}

4.3.4 在 redis 中保存秒杀商品信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private void saveSessionSkuInfo(List<SeckillSessionWithSkusVo> sessions) {

if(sessions != null && sessions.size() > 0){
sessions.stream().forEach(session -> {
//准备hash操作,绑定hash
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
//生成随机码
String token = UUID.randomUUID().toString().replace("-", "");
String redisKey = seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString();
if (!operations.hasKey(redisKey)) {

//缓存我们商品信息
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
Long skuId = seckillSkuVo.getSkuId();
//1、先查询sku的基本信息,调用远程服务
R info = productFeignService.info(skuId);
if (info.getCode() == 0) {
SkuInfoVo skuInfo = info.getData("skuInfo",new TypeReference<SkuInfoVo>(){});
redisTo.setSkuInfoVo(skuInfo);
}

//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo,redisTo);

//3、设置当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());

//4、设置商品的随机码(防止恶意攻击)
redisTo.setRandomCode(token);

//序列化json格式存入Redis中
String seckillValue = JSON.toJSONString(redisTo);
operations.put(seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString(),seckillValue);

//如果当前这个场次的商品库存信息已经上架就不需要上架
//5、使用库存作为分布式Redisson信号量(限流)
// 使用库存作为分布式信号量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
// 商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
}
});
});
}
}

4.4 首页展示秒杀活动

image-20220428231645991

  • 业务说明和逻辑分析

    业务说明:展示符合当前页面时间的秒杀活动,把关联的商品都显示出来

    逻辑分析

    1. 判断当前时间是否落在了活动信息 startend 之间
      long time = new Date().getTime(); 然后判断这个 time 在哪个活动的 start_end 之间,因为 start_end 也是 long 类型,与 1970 的差值
      查询所有场次的 key 信息:keys seckill:sessions:
      【匹配所有】
      java 代码:redisTemplate.keys(“seckill:sessions:_“),然后遍历 key 获得 start、end
    2. 返回商品信息的时候,要屏蔽掉随机码信息【这个业务还是需要的,在商品页,如果当前商品参与了秒杀,不返回随机码信息】
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
public class SeckillController {

@Autowired
private SeckillService seckillService;

@GetMapping(value = "/getCurrentSeckillSkus")
@ResponseBody
public R getCurrentSeckillSkus(){

// 获取到当前可以参加秒杀商品的信息
List<SeckillSkuRedisTo> vos = seckillService.getCurrentSeckillSkus();

return R.ok().setData(vos);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
if(keys != null && keys.size() > 0){
long currentTime = System.currentTimeMillis();
for (String key : keys) {
String replace = key.replace(SESSION_CACHE_PREFIX, "");
String[] split = replace.split("_");
long startTime = Long.parseLong(split[0]);
long endTime = Long.parseLong(split[1]);
// 当前秒杀活动处于有效期内
if(currentTime > startTime && currentTime < endTime){
// 取出当前秒杀活动对应商品存储的hash key
List<String> range = redisTemplate.opsForList().range(key, -100, 100);
BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
// 取出存储的商品信息并返回
List<SeckillSkuRedisTo> collect = range.stream().map(s -> {
String json = ops.get(s);
SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
return redisTo;
}).collect(Collectors.toList());
return collect;
}
}
}
return null;
}

首页获取并拼装数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="swiper-slide">
<!-- 动态拼装秒杀商品信息 -->
<ul id="seckillSkuContent"></ul>
</div>

<script type="text/javascript">
$.get("http://seckill.gulimall.com/getCurrentSeckillSkus", function (res) {
if (res.data.length > 0) {
res.data.forEach(function (item) {
$("<li onclick='toDetail(" + item.skuId + ")'></li>").append($("<img style='width: 130px; height: 130px' src='" + item.skuInfoVo.skuDefaultImg + "' />"))
.append($("<p>"+item.skuInfoVo.skuTitle+"</p>"))
.append($("<span>" + item.seckillPrice + "</span>"))
.append($("<s>" + item.skuInfoVo.price + "</s>"))
.appendTo("#seckillSkuContent");
})
}
})

function toDetail(skuId) {
location.href = "http://item.gulimall.com/" + skuId + ".html";
}

</script>

首页展示效果

image-20220430155702881

4.5 获取当前商品的秒杀信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@ResponseBody
@GetMapping(value = "/getSeckillSkuInfo/{skuId}")
public R getSeckillSkuInfo(@PathVariable("skuId") Long skuId) {
SeckillSkuRedisTo to = secKillService.getSeckillSkuInfo(skuId);
return R.ok().setData(to);
}

@Override
public SeckillSkuRedisTo getSeckillSkuInfo(Long skuId) {
BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
//获取所有商品的hash key
Set<String> keys = ops.keys();
for (String key : keys) {
//通过正则表达式匹配 数字-当前skuid的商品
if (Pattern.matches("\\d-" + skuId,key)) {
String v = ops.get(key);
SeckillSkuRedisTo redisTo = JSON.parseObject(v, SeckillSkuRedisTo.class);
//当前商品参与秒杀活动
if (redisTo!=null){
long current = System.currentTimeMillis();
//当前活动在有效期,暴露商品随机码返回
if (redisTo.getStartTime() < current && redisTo.getEndTime() > current) {
return redisTo;
}
//当前商品不再秒杀有效期,则隐藏秒杀所需的商品随机码
redisTo.setRandomCode(null);
return redisTo;
}
}
}
return null;
}

在查询商品详情页的接口中查询秒杀对应信息

Snipaste_2020-10-25_19-00-51

更改商品详情页的显示效果

1
2
3
4
5
6
7
8
9
10
11
12
13
<li style="color: red" th:if="${item.seckillSkuVo != null}">
<span th:if="${#dates.createNow().getTime() < item.seckillSkuVo.startTime}">
商品将会在[[${#dates.format(new
java.util.Date(item.seckillSkuVo.startTime),"yyyy-MM-dd
HH:mm:ss")}]]进行秒杀
</span>

<span
th:if="${#dates.createNow().getTime() >= item.seckillSkuVo.startTime && #dates.createNow().getTime() <= item.seckillSkuVo.endTime}"
>
秒杀价 [[${#numbers.formatDecimal(item.seckillSkuVo.seckillPrice,1,2)}]]
</span>
</li>

image-20220430162732492

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="box-btns-two" th:if="${item.seckillSkuVo == null }">
<a
class="addToCart"
href="http://cart.gulimall.com/addToCart"
th:attr="skuId=${item.info.skuId}"
>
加入购物车
</a>
</div>

<div
class="box-btns-two"
th:if="${item.seckillSkuVo != null && (#dates.createNow().getTime() >= item.seckillSkuVo.startTime && #dates.createNow().getTime() <= item.seckillSkuVo.endTime)}"
>
<a
class="seckill"
href="#"
th:attr="skuId=${item.info.skuId},sessionId=${item.seckillSkuVo.promotionSessionId},code=${item.seckillSkuVo.randomCode}"
>
立即抢购
</a>
</div>

image-20220504233320560

image-20220504233458185

五、秒杀

5.1 秒杀接口

  • 点击立即抢购,会发送请求
  • 秒杀请求会对请求校验时效、商品随机码、当前用户是否已经抢购过当前商品、库存和购买量,通过校验的则秒杀成功,发送消息创建订单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@GetMapping("/kill")
public String kill(@RequestParam("killId") String killId,
@RequestParam("key")String key,
@RequestParam("num")Integer num,
Model model) {
String orderSn= null;
try {
orderSn = secKillService.kill(killId, key, num);
model.addAttribute("orderSn", orderSn);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "success";
}

@Override
public String kill(String killId, String key, Integer num) throws InterruptedException {
BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
String json = ops.get(killId);
String orderSn = null;
if (!StringUtils.isEmpty(json)){
SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
//1. 验证时效
long current = System.currentTimeMillis();
if (current >= redisTo.getStartTime() && current <= redisTo.getEndTime()) {
//2. 验证商品和商品随机码是否对应
String redisKey = redisTo.getPromotionSessionId() + "-" + redisTo.getSkuId();
if (redisKey.equals(killId) && redisTo.getRandomCode().equals(key)) {
//3. 验证当前用户是否购买过
MemberResponseVo memberResponseVo = LoginInterceptor.loginUser.get();
long ttl = redisTo.getEndTime() - System.currentTimeMillis();
//3.1 通过在redis中使用 用户id-skuId 来占位看是否买过
Boolean occupy = redisTemplate.opsForValue().setIfAbsent(memberResponseVo.getId()+"-"+redisTo.getSkuId(), num.toString(), ttl, TimeUnit.MILLISECONDS);
//3.2 占位成功,说明该用户未秒杀过该商品,则继续
if (occupy){
//4. 校验库存和购买量是否符合要求
if (num <= redisTo.getSeckillLimit()) {
//4.1 尝试获取库存信号量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + redisTo.getRandomCode());
boolean acquire = semaphore.tryAcquire(num,100,TimeUnit.MILLISECONDS);
//4.2 获取库存成功
if (acquire) {
//5. 发送消息创建订单
//5.1 创建订单号
orderSn = IdWorker.getTimeId();
//5.2 创建秒杀订单to
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setMemberId(memberResponseVo.getId());
orderTo.setNum(num);
orderTo.setOrderSn(orderSn);
orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
orderTo.setSeckillPrice(redisTo.getSeckillPrice());
orderTo.setSkuId(redisTo.getSkuId());
//5.3 发送创建订单的消息
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
}
}
}
}
}
return orderSn;
}

5.2 创建订单

发送消息

1
2
//发送创建订单的消息
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);

创建秒杀所需队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 /**
* 商品秒杀队列
* @return
*/
@Bean
public Queue orderSecKillOrrderQueue() {
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}

@Bean
public Binding orderSecKillOrrderQueueBinding() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
Binding binding = new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);

return binding;
}

监听队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class SeckillOrderListener {
@Autowired
private OrderService orderService;

@RabbitHandler
public void createOrder(SeckillOrderTo orderTo, Message message, Channel channel) throws IOException {
System.out.println("***********接收到秒杀消息");
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
orderService.createSeckillOrder(orderTo);
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
channel.basicReject(deliveryTag,true);
}
}
}

创建订单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Transactional
@Override
public void createSeckillOrder(SeckillOrderTo orderTo) {
MemberResponseVo memberResponseVo = LoginInterceptor.loginUser.get();
//1. 创建订单
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(orderTo.getOrderSn());
orderEntity.setMemberId(orderTo.getMemberId());
orderEntity.setMemberUsername(memberResponseVo.getUsername());
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
orderEntity.setCreateTime(new Date());
orderEntity.setPayAmount(orderTo.getSeckillPrice().multiply(new BigDecimal(orderTo.getNum())));
this.save(orderEntity);
//2. 创建订单项
R r = productFeignService.info(orderTo.getSkuId());
if (r.getCode() == 0) {
SeckillSkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SeckillSkuInfoVo>() {
});
OrderItemEntity orderItemEntity = new OrderItemEntity();
orderItemEntity.setOrderSn(orderTo.getOrderSn());
orderItemEntity.setSpuId(skuInfo.getSpuId());
orderItemEntity.setCategoryId(skuInfo.getCatalogId());
orderItemEntity.setSkuId(skuInfo.getSkuId());
orderItemEntity.setSkuName(skuInfo.getSkuName());
orderItemEntity.setSkuPic(skuInfo.getSkuDefaultImg());
orderItemEntity.setSkuPrice(skuInfo.getPrice());
orderItemEntity.setSkuQuantity(orderTo.getNum());
orderItemService.save(orderItemEntity);
}
}

页面跳转效果

image-20220612225510053