目录
[1 后台添加秒杀商品](#1 后台添加秒杀商品)
[1.1 配置优惠券服务网关](#1.1 配置优惠券服务网关)
[1.2 添加秒杀场次](#1.2 添加秒杀场次)
[1.3 上架秒杀商品](#1.3 上架秒杀商品)
[2 定时任务](#2 定时任务)
[2.1 cron 表达式](#2.1 cron 表达式)
[2.2 cron表达式特殊字符](#2.2 cron表达式特殊字符)
[2.3 cron示例](#2.3 cron示例)
[3 秒杀服务](#3 秒杀服务)
[3.1 创建秒杀服务模块](#3.1 创建秒杀服务模块)
[3.1.1 pom.xml](#3.1.1 pom.xml)
[3.1.2 application.yml配置](#3.1.2 application.yml配置)
[3.1.3 bootstrap.yml配置](#3.1.3 bootstrap.yml配置)
[3.1.4 启动类上添加注解](#3.1.4 启动类上添加注解)
[3.2 SpringBoot整合定时任务与异步任务](#3.2 SpringBoot整合定时任务与异步任务)
[3.2.1 整合定时任务](#3.2.1 整合定时任务)
[3.2.2 整合异步任务](#3.2.2 整合异步任务)
[3.2.2.1 定时任务阻塞](#3.2.2.1 定时任务阻塞)
[3.2.2.2 解决定时任务阻塞的方式](#3.2.2.2 解决定时任务阻塞的方式)
[3.2.2.3 整合异步任务步骤](#3.2.2.3 整合异步任务步骤)
[3.3 秒杀商品上架](#3.3 秒杀商品上架)
[3.3.1 秒杀商品上架流程](#3.3.1 秒杀商品上架流程)
[3.3.2 时间日期处理](#3.3.2 时间日期处理)
[3.3.2.1 获取当天0点整的时间](#3.3.2.1 获取当天0点整的时间)
[3.3.2.2 获取含今天的三天后的最后时间](#3.3.2.2 获取含今天的三天后的最后时间)
[3.3.3 获取三天内要开始的秒杀活动及秒杀商品信息](#3.3.3 获取三天内要开始的秒杀活动及秒杀商品信息)
[3.3.4 秒杀商品定时上架](#3.3.4 秒杀商品定时上架)
[3.3.4.1 使用定时任务上架最近三天需要秒杀的商品](#3.3.4.1 使用定时任务上架最近三天需要秒杀的商品)
[3.3.4.2 定时任务分布式情况下的问题](#3.3.4.2 定时任务分布式情况下的问题)
[3.3.4.3 解决同一活动同一商品重复上架(幂等性保证)](#3.3.4.3 解决同一活动同一商品重复上架(幂等性保证))
[3.3.5 首页展示上架的秒杀商品](#3.3.5 首页展示上架的秒杀商品)
[3.3.5.1 配置网关](#3.3.5.1 配置网关)
[3.3.5.2 SwitchHosts增加配置](#3.3.5.2 SwitchHosts增加配置)
[3.3.5.3 获取当前时间可以参与秒杀的商品信息](#3.3.5.3 获取当前时间可以参与秒杀的商品信息)
[3.3.5.4 首页代码](#3.3.5.4 首页代码)
[3.3.5.5 测试](#3.3.5.5 测试)
[3.3.6 秒杀页面渲染](#3.3.6 秒杀页面渲染)
[3.3.6.1 根据skuId查询商品是否参加秒杀活动](#3.3.6.1 根据skuId查询商品是否参加秒杀活动)
[3.3.6.2 查询商品详情时验证当前商品是否参与秒杀活动](#3.3.6.2 查询商品详情时验证当前商品是否参与秒杀活动)
[3.3.6.3 商品详情页代码](#3.3.6.3 商品详情页代码)
[3.4 秒杀](#3.4 秒杀)
[3.4.1 秒杀架构](#3.4.1 秒杀架构)
[3.4.2 秒杀(高并发)系统关注的问题](#3.4.2 秒杀(高并发)系统关注的问题)
[3.4.3 登录检查(配置登录拦截器)](#3.4.3 登录检查(配置登录拦截器))
[3.4.3.1 商品详情页登录拦截](#3.4.3.1 商品详情页登录拦截)
[3.4.3.2 秒杀服务配置登录拦截器](#3.4.3.2 秒杀服务配置登录拦截器)
[3.4.3.2.1 引入依赖](#3.4.3.2.1 引入依赖)
[3.4.3.2.2 SpringSession 相关配置](#3.4.3.2.2 SpringSession 相关配置)
[3.4.3.2.3 yml 配置](#3.4.3.2.3 yml 配置)
[3.4.3.2.4 启用Redis会话管理](#3.4.3.2.4 启用Redis会话管理)
[3.4.3.2.5 配置登录拦截器](#3.4.3.2.5 配置登录拦截器)
[3.4.5 秒杀流程](#3.4.5 秒杀流程)
[3.4.5.1 流程一(加入购物车秒杀------弃用)](#3.4.5.1 流程一(加入购物车秒杀——弃用))
[3.4.5.2 流程二(独立秒杀业务处理------推荐)](#3.4.5.2 流程二(独立秒杀业务处理——推荐))
[3.4.6 创建秒杀队列、绑定关系](#3.4.6 创建秒杀队列、绑定关系)
[3.4.7 整合rabbitmq、thymeleaf](#3.4.7 整合rabbitmq、thymeleaf)
[3.4.7.1 引入依赖](#3.4.7.1 引入依赖)
[3.4.7.2 yml配置](#3.4.7.2 yml配置)
[3.4.7.3 配置RabbitMQ序列化方式](#3.4.7.3 配置RabbitMQ序列化方式)
[3.4.8 秒杀成功页面](#3.4.8 秒杀成功页面)
[3.4.9 秒杀接口](#3.4.9 秒杀接口)
[3.4.9.1 (幂等性)限制同一用户重复秒杀](#3.4.9.1 (幂等性)限制同一用户重复秒杀)
[3.4.10 秒杀消息监听消费](#3.4.10 秒杀消息监听消费)
[3.5 秒杀总结](#3.5 秒杀总结)
[3.5.1 服务单一职责+独立部署](#3.5.1 服务单一职责+独立部署)
[3.5.2 秒杀连接加密](#3.5.2 秒杀连接加密)
[3.5.3 库存预热+快速扣减](#3.5.3 库存预热+快速扣减)
[3.5.4 动静分离](#3.5.4 动静分离)
[3.5.5 恶意请求拦截](#3.5.5 恶意请求拦截)
[3.5.6 流量错峰](#3.5.6 流量错峰)
[3.5.7 限流+熔断+降级](#3.5.7 限流+熔断+降级)
[3.5.8 队列削峰](#3.5.8 队列削峰)
1 后台添加秒杀商品
复制优惠券前端代码到 src\views\modules路径下,如下:
1.1 配置优惠券服务网关
未配置优惠券服务网关之前,如下:
网关配置如下:
gulimall-gateway/src/main/resources/application.yml
bash
- id: coupon_route
uri: lb://gulimall-coupon
predicates:
- Path=/api/coupon/**,/hello
filters:
# 去掉 api
- RewritePath=/api/?(?<segment>.*), /$\{segment}
1.2 添加秒杀场次
1.3 上架秒杀商品
场次id对应数据库中的promotion_session_id字段。
上架秒杀商品bug,在任意一个场次可以查询所有场次的上架商品。如下:点击2号场次关联商品可以看到场次1关联的商品。
解决方案:修改场次关联商品查询接口,添加查询条件场次id.
gulimall-coupon/src/main/java/com/wen/gulimall/coupon/service/impl/SeckillSkuRelationServiceImpl.java
2 定时任务
2.1 cron 表达式
2.1.1 cron表达式语法
语法:秒 分 时 日 月 周 年(Spring不支持年)
https://www.quartz-scheduler.org/documentation/
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 | YES | empty, 1970-2099 | , - * / |
2.2 cron表达式特殊字符
(1), :枚举
(cron="7,9,23 * * * * ?"):任意时刻的7,9,23秒启动这个任务;
(2)- :范围
(cron="7-20 * * * * ?"):任意时刻的7-20秒之间,每秒启动一次;
(3)* :任意
指定位置的任意时刻都可以;
(4)/ :步长
(cron="7/5 * * * * ?"):第7秒启动,每5秒一次;
(cron="*/5 * * * * ?"):任意秒启动,每5秒一次;
(5)? :(出现在日或周几的位置)为了防止日和周冲突,在周和日上如果要写通配符使用?
(cron="* * * 1 * ?"):每个月的1号,启动这个任务;
(6)L :出现在日和周的位置
last:最后一个
(cron="* * * ? * 3L"):每个月的最后一个周二,周日是1;
(7)W :
Work Day:工作日
(cron="* * * W * ?"):每个月的工作日触发;
(cron="* * * LW * ?"):每个月的最后一个工作日触发;
(8)# :第几个
(cron="* * * ? * 5#2"):每个月的第2个周4。
2.3 cron示例
Expression | Meaning |
---|---|
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 * * ? | 每天的14:00-14:59 每分钟触发一次 |
0 0/5 14 * * ? | 每天的14:00-14:59 每五分钟触发一次 |
0 0/5 14,18 * * ? | 每天的14:00-14:59 和18:00-18:59 每五分钟触发一次 |
0 0-5 14 * * ? | 每天的14:00-14:05每分钟执行一次 |
0 10,44 14 ? 3 WED | 3月的每个星期三的14:10:00和14:44:00触发一次 |
0 15 10 ? * MON-FRI | 星期一到星期五的10:15:00触发 |
0 15 10 15 * ? | 每个月的15号10:15:00触发 |
0 15 10 L * ? | 每个月的最后一天10:15:00触发 |
0 15 10 L-2 * ? | 每个月的倒数第二天10:15:00触发 |
0 15 10 ? * 6L | 每个月的最后一个星期五的10:15:00触发 |
0 15 10 ? * 6L 2002-2005 | 2002年到2005年的每个月的最后一个星期五的10:15:00触发 |
0 15 10 ? * 6#3 | 每个月的第3个星期五的10:15:00触发 |
0 0 12 1/5 * ? | 每个月的1号开始每五天12:00:00触发 |
0 11 11 11 11 ? | 十一月的11号的11:11:00 |
3 秒杀服务
秒杀具有瞬间高并发 的特点, 针对这一特点, 必须要做限流 + 异步 + 缓存(页面静态化) + 独立部署。
3.1 创建秒杀服务模块
3.1.1 pom.xml
gulimall-seckill/pom.xml
XML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.wen.gulimall</groupId>
<artifactId>gulimall-seckill</artifactId>
<version>1.0</version>
<name>gulimall-seckill</name>
<description>秒杀服务</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>2021.0.5</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>com.wen.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</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>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.1.2 application.yml配置
端口、应用名、nacos发现中心、redis
gulimall-seckill/src/main/resources/application.yml
bash
server:
port: 25000
spring:
application:
name: gulimall-seckill
cloud:
nacos:
discovery:
server-addr: 172.xx.xx.10:8848
redis:
host: 172.xx.xx.10
3.1.3 bootstrap.yml配置
nacos配置中心
gulimall-seckill/src/main/resources/bootstrap.yml
bash
spring:
cloud:
nacos:
config:
server-addr: 172.xx.xx.10:8848
3.1.4 启动类上添加注解
启动类添加Feign远程调用、服务发现、排除数据库自动配置类。
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/GulimallSeckillApplication.java
java
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
3.2 SpringBoot整合定时任务与异步任务
(使用异步任务+定时任务来完成定时任务不阻塞的功能)
3.2.1 整合定时任务
自动配置类TaskSchedulingAutoConfiguration
属性TaskSchedulingProperties
在类上使用注解开启定时任务功能,如下:
java
@Component // 注入容器中
@EnableScheduling // 开启定时任务
在需要开启定时任务的方法上使用注解,为该方法开启定时任务,根据cron表达式定时执行,如下:
java
@Scheduled(cron = "* * * ? * 1")
注意:
(1)Spring中cron由6位组成,不允许第7位的年
(2)在周几的位置,1-7代表周一到周日;MON-SUN
(3)++定时任务不应该阻塞。默认是阻塞的++。
示例:
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/scheduled/HelloSchedule.java
java
@Slf4j
@Component
@EnableScheduling // 开启定时任务
public class HelloSchedule {
@Scheduled(cron = "* * * ? * 2")
public void hello() throws InterruptedException {
log.info("hello ...");
}
}
测试结果,每秒执行一次,如下:
3.2.2 整合异步任务
整合异步任务为了解决定时任务不应该阻塞,默认是阻塞的。
自动配置类TaskExecutionAutoConfiguration
属性TaskExecutionProperties
3.2.2.1 定时任务阻塞
模拟业务处理时间较长,查看定时任务的执行情况,如下:
java
@Slf4j
@Component
@EnableScheduling // 开启定时任务
public class HelloSchedule {
@Scheduled(cron = "* * * ? * 2")
public void hello() throws InterruptedException {
log.info("hello ...");
Thread.sleep(3000);
}
}
测试结果,日志打印间隔4秒打印一次,说明定时任务阻塞,执行结果如下:
3.2.2.2 解决定时任务阻塞的方式
**方式一:**使用异步编排,可以业务以异步的方式运行,自己提交到线程池,如下:
java
CompletableFuture.runAsync(()->{
xxxService.hello();
},executor);
【不生效】**方式二:**支持定时任务线程池,设置 TaskSchedulingProperties,线程池大小默认是1,修改线程池大小,如下:
bash
spring:
task:
scheduling:
pool:
size: 5
**方式三:**异步任务,实现过程见3.2.2.3
3.2.2.3 整合异步任务步骤
- 在类上标注注解开启异步功能
java
@EnableAsync
- 在需要异步执行的方法上标注注解,开启异步任务
java
@Async
- 测试
java
@Slf4j
@Component
@EnableAsync
@EnableScheduling // 开启定时任务
public class HelloSchedule {
@Async
@Scheduled(cron = "* * * ? * 2")
public void hello() throws InterruptedException {
log.info("hello ...");
Thread.sleep(3000);
}
}
日志每秒打印一次,定时任务没有阻塞了,如下:
- 设置线程池大小
异步任务的线程池最大线程数是Integer的最大值,项目中要对其进行限制。
gulimall-seckill/src/main/resources/application.yml
bash
spring:
task:
execution:
pool:
core-size: 5
max-size: 50
3.3 秒杀商品上架
3.3.1 秒杀商品上架流程
3.3.2 时间日期处理
3.3.2.1 获取当天0点整的时间
java
/**
* 开始日期:今天 00:00:00
* @return
*/
private String startTime(){
LocalDate now = LocalDate.now();
LocalTime min = LocalTime.MIN;
LocalDateTime of = LocalDateTime.of(now, min);
return of.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
3.3.2.2 获取含今天的三天后的最后时间
java
/**
* 结束日期:含今天的三天后的最后时间 23:59:59
* @return
*/
private String endTime(){
LocalDate now = LocalDate.now();
LocalDate localDate = now.plusDays(2);
LocalDateTime of = LocalDateTime.of(localDate, LocalTime.MAX);
return of.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
测试结果,如下:
3.3.3 获取三天内要开始的秒杀活动及秒杀商品信息
gulimall-coupon/src/main/java/com/wen/gulimall/coupon/controller/SeckillSessionController.java
java
@RestController
@RequestMapping("coupon/seckillsession")
public class SeckillSessionController {
@Autowired
private SeckillSessionService seckillSessionService;
/**
* 查询最近三天要开始的秒杀活动
* @return
*/
@GetMapping("/latest3DaySession")
public R getLatest3DaySession(){
List<SeckillSessionEntity> sessionEntities = seckillSessionService.getLatest3DaySession();
return R.ok().setData(sessionEntities);
}
...
}
gulimall-coupon/src/main/java/com/wen/gulimall/coupon/service/SeckillSessionService.java
java
/**
* 秒杀活动场次
*
* @author wen
*/
public interface SeckillSessionService extends IService<SeckillSessionEntity> {
...
List<SeckillSessionEntity> getLatest3DaySession();
}
gulimall-coupon/src/main/java/com/wen/gulimall/coupon/service/impl/SeckillSessionServiceImpl.java
java
@Service("seckillSessionService")
public class SeckillSessionServiceImpl extends ServiceImpl<SeckillSessionDao, SeckillSessionEntity> implements SeckillSessionService {
@Resource
private SeckillSkuRelationService seckillSkuRelationService;
...
@Override
public List<SeckillSessionEntity> getLatest3DaySession() {
// 查询最近三天要开始的秒杀活动
List<SeckillSessionEntity> list = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));
if(CollUtil.isNotEmpty(list)) {
List<SeckillSessionEntity> collect = list.stream().map(session -> {
Long id = session.getId();
List<SeckillSkuRelationEntity> relations = seckillSkuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));
session.setRelationSkus(relations);
return session;
}).collect(Collectors.toList());
return collect;
}
return null;
}
/**
* 开始日期:今天 00:00:00
* @return
*/
private String startTime(){
LocalDate now = LocalDate.now();
LocalTime min = LocalTime.MIN;
LocalDateTime of = LocalDateTime.of(now, min);
return of.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
/**
* 结束日期:含今天的三天后的最后时间 23:59:59
* @return
*/
private String endTime(){
LocalDate now = LocalDate.now();
LocalDate localDate = now.plusDays(2);
LocalDateTime of = LocalDateTime.of(localDate, LocalTime.MAX);
return of.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
秒杀服务远程调用优惠券服务的feign接口
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/feign/CouponFeignService.java
java
/**
* 远程调用优惠服务
*
* @author w
* @date 2024/07/23 14:16
*/
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
@GetMapping("/coupon/seckillsession/latest3DaySession")
R getLatest3DaySession();
}
3.3.4 秒杀商品定时上架
3.3.4.1 使用定时任务上架最近三天需要秒杀的商品
定时任务+异步任务配置
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/config/ScheduledConfig.java
java
/**
* 定时任务配置类
* 异步任务+定时任务
*
* @author w
* @date 2024/07/23 14:02
*/
@EnableAsync // 开启异步任务
@EnableScheduling // 开启定时任务
@Configuration
public class ScheduledConfig {
}
秒杀商品的定时上架
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/scheduled/SeckillSkuScheduled.java
java
/**
* 秒杀商品的定时上架
* 每天晚上3点,上架最近三天需要秒杀的商品。
* 当天00:00:00 - 23:59:59
* 明天00:00:00 - 23:59:59
* 后天00:00:00 - 23:59:59
*
* @author w
* @date 2024/07/23 13:58
*/
@Slf4j
@Service
public class SeckillSkuScheduled {
@Resource
private SeckillService seckillService;
@Resource
private RedissonClient redissonClient;
private final String upload_lock = "seckill:upload:lock";
//todo幂等性上架
@Scheduled(cron="0 * * * * ?")
public void uploadSeckillSkuLatest3Days(){
// 1. 重复上架无需处理
log.info("上架秒杀商品的信息.....");
// 分布式锁。锁的业务执行完成,状态已更新完成。释放锁以后,其他人获取到就会拿到最新的状态。
// 加锁保证原子性,直接判断无法保证原子性
RLock lock = redissonClient.getLock(upload_lock);
lock.lock(10, TimeUnit.SECONDS);
try {
seckillService.uploadSeckillSkuLatest3Days();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 解锁
lock.unlock();
}
}
}
上架最近三天参与秒杀活动的商品
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/SeckillService.java
java
/**
* 秒杀业务层
*
* @author w
* @date 2024/07/23 14:09
*/
public interface SeckillService {
/**
* 上架最近三天参与秒杀活动的商品
*/
void uploadSeckillSkuLatest3Days();
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.java
java
@Service
public class SeckillServiceImpl implements SeckillService {
@Resource
private CouponFeignService couponFeignService;
@Resource
private ProductFeignService productFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:sku:";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; // + 商品随机码
@Override
public void uploadSeckillSkuLatest3Days() {
// 1. 数据库查询最近三天需要参与秒杀的活动
R session = couponFeignService.getLatest3DaySession();
if(session.getCode() == 0){
// 上架商品
List<SeckillSessionsWithSkus> sessionData = session.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {
});
if(CollUtil.isNotEmpty(sessionData)) {
// 缓存到redis
// 1.缓存活动信息
saveSessionInfos(sessionData);
// 2.缓存活动的关联商品信息
saveSessionSkuInfos(sessionData);
}
}
}
private void saveSessionInfos(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session->{
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
Boolean hasKey = stringRedisTemplate.hasKey(key);
List<SeckillSkuVo> relationSkus = session.getRelationSkus();
// 幂等性保证
if(Boolean.FALSE.equals(hasKey) && CollUtil.isNotEmpty(relationSkus)) {
List<String> collect = relationSkus.stream().map(item -> item.getPromotionSessionId().toString()+"_"+item.getSkuId().toString()).collect(Collectors.toList());
// 缓存活动信息
stringRedisTemplate.opsForList().leftPushAll(key, collect);
}
});
}
private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){
sessions.forEach(session -> {
// 准备hash操作
BoundHashOperations<String, Object, Object> ops = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
// 4. 商品的随机码(防止恶意攻击、公平秒杀)
String token = UUID.randomUUID().toString().replaceAll("-","");
if(!ops.hasKey(seckillSkuVo.getPromotionSessionId().toString()+"_"+seckillSkuVo.getSkuId().toString())) {
// 缓存商品
SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
// 1. sku的基本数据
R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
if (skuInfo.getCode() == 0) {
SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
seckillSkuRedisTo.setSkuInfo(info);
}
// 2. sku的秒杀信息
BeanUtil.copyProperties(seckillSkuVo, seckillSkuRedisTo);
// 3. 设置当前商品的秒杀时间信息
seckillSkuRedisTo.setStartTime(session.getStartTime().getTime());
seckillSkuRedisTo.setEndTime(session.getEndTime().getTime());
seckillSkuRedisTo.setRandomCode(token);
String jsonString = JSON.toJSONString(seckillSkuRedisTo);
ops.put(seckillSkuVo.getPromotionSessionId().toString()+"_"+seckillSkuVo.getSkuId().toString(),jsonString);
// 5.使用库存作为分布式的信号量 限流
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
// 商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
}
});
});
}
}
3.3.4.2 定时任务分布式情况下的问题
问题:分布式情况下,定时任务会执行多次,活动信息在redis中以list的方式存储,会重复添加。
解决方案:使用分布式锁。
3.3.4.3 解决同一活动同一商品重复上架(幂等性保证)
上架之前没有对上架的商品进行校验是否已上架,就会重复上架。解决方案如下:
缓存活动信息幂等性保证:
缓存活动关联商品信息幂等性保证:
3.3.5 首页展示上架的秒杀商品
3.3.5.1 配置网关
gulimall-gateway/src/main/resources/application.yml
bash
- id: gulimall_seckill_route
uri: lb://gulimall-seckill
predicates:
# 由以下的主机域名访问转发到会员服务
- Host=seckill.gulimall.com
3.3.5.2 SwitchHosts增加配置
添加秒杀服务的域名与ip映射:xxx.xxx.11.10 seckill.gulimall.com
3.3.5.3 获取当前时间可以参与秒杀的商品信息
秒杀商品信息实体
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/to/SeckillSkuRedisTo.java
java
@Data
public class SeckillSkuRedisTo {
private Long promotionId;
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 商品秒杀随机码
*/
private String randomCode;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private Integer seckillCount;
/**
* 每人限购数量
*/
private Integer seckillLimit;
/**
* 排序
*/
private Integer seckillSort;
// sku的详细信息
private SkuInfoVo skuInfo;
// 当前商品秒杀的开始时间
private Long startTime;
// 当前商品秒杀的结束时间
private Long endTime;
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/controller/SeckillController.java
java
@Controller
public class SeckillController {
@Resource
private SeckillService seckillService;
/**
* 返回当前时间可以参与的秒杀商品信息
* @return
*/
@ResponseBody
@GetMapping("/currentSeckillSkus")
public R getCurrentSeckillSkus(){
List<SeckillSkuRedisTo> skus = seckillService.getCurrentSeckillSkus();
return R.ok().setData(skus);
}
...
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/SeckillService.java
java
public interface SeckillService {
...
List<SeckillSkuRedisTo> getCurrentSeckillSkus();
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.java
java
@Service
public class SeckillServiceImpl implements SeckillService {
@Resource
private CouponFeignService couponFeignService;
@Resource
private ProductFeignService productFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:sku:";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; // + 商品随机码
...
/**
* 返回当前时间可以参与的秒杀商品信息
* @return
*/
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
// 1. 确定当前时间属于哪个秒杀场次
long time = System.currentTimeMillis();
Set<String> keys = stringRedisTemplate.keys(SESSIONS_CACHE_PREFIX + "*");
for(String key:keys) {
String replace = key.replace(SESSIONS_CACHE_PREFIX, "");
String[] s = replace.split("_");
Long startTime = Long.parseLong(s[0]);
Long endTime = Long.parseLong(s[1]);
if (time >= startTime && time <= endTime) {
// 2. 获取这个秒杀场次需要的所有商品信息
List<String> range = stringRedisTemplate.opsForList().range(key, -100, 100);
BoundHashOperations<String, String, String> ops = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
List<String> list = ops.multiGet(range);
if (list != null && list.size() > 0) {
List<SeckillSkuRedisTo> collect = list.stream().map(item -> {
SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject(item.toString(), SeckillSkuRedisTo.class);
//seckillSkuRedisTo.setRandomCode(null); 当前秒杀开始就需要随机码,预告不需要
return seckillSkuRedisTo;
}).collect(Collectors.toList());
return collect;
}
break;
}
}
return null;
}
...
}
3.3.5.4 首页代码
gulimall-product/src/main/resources/templates/index.html
html
<script type="text/javascript">
function search() {
var keyword=$("#searchText").val()
window.location.href="http://search.gulimall.com/list.html?keyword="+keyword;
}
function to_href(skuId){
location.href = "http://item.gulimall.com/"+skuId+".html";
}
$.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp){
if(resp.data.length>0){
resp.data.forEach(item=>{
$("<li onclick='to_href("+item.skuId+")'></li>")
.append("<img style='width: 130px;height: 130px' src='"+item.skuInfo.skuDefaultImg+"'/>")
.append("<p>"+item.skuInfo.skuTitle+"</p>")
.append("<span>"+item.seckillPrice+"</span>")
.append("<s>"+item.skuInfo.price+"</s>")
.appendTo("#seckillSkuContent");
})
}
});
</script>
3.3.5.5 测试
访问商城首页。
3.3.6 秒杀页面渲染
如果商品正在秒杀中,"加入购物车" 变为 "立即抢购"。
3.3.6.1 根据skuId查询商品是否参加秒杀活动
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/controller/SeckillController.java
java
@Controller
public class SeckillController {
@Resource
private SeckillService seckillService;
...
/**
* 根据skuId查询商品是否参加秒杀活动
* @param skuId
* @return
*/
@ResponseBody
@GetMapping("/sku/seckill/{skuId}")
public R getSkuSeckillInfo(@PathVariable("skuId") Long skuId){
SeckillSkuRedisTo seckillSkuRedisTo = seckillService.getSkuSeckillInfo(skuId);
return R.ok().setData(seckillSkuRedisTo);
}
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/SeckillService.java
java
public interface SeckillService {
...
/**
* 根据skuId查询商品是否参加秒杀活动
* @param skuId
* @return
*/
SeckillSkuRedisTo getSkuSeckillInfo(Long skuId);
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.java
java
@Service
public class SeckillServiceImpl implements SeckillService {
@Resource
private CouponFeignService couponFeignService;
@Resource
private ProductFeignService productFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:sku:";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; // + 商品随机码
...
/**
* 根据skuId查询商品是否参加秒杀活动
* @param skuId
* @return
*/
@Override
public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
// 1. 找到所有需要参与秒杀的商品的key
BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
// 获取所有的key
Set<String> keys = hashOps.keys();
if(keys!=null && keys.size()>0){
String regx = "\\d_" + skuId;
for (String key : keys) {
if(Pattern.matches(regx,key)) {
String json = hashOps.get(key);
SeckillSkuRedisTo skuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
// 随机码
long current = System.currentTimeMillis();
if (current >= skuRedisTo.getStartTime() && current <= skuRedisTo.getEndTime()) {
// 正在参与秒杀活动
} else {
skuRedisTo.setRandomCode(null);
}
return skuRedisTo;
}
}
}
return null;
}
...
}
3.3.6.2 查询商品详情时验证当前商品是否参与秒杀活动
远程调用秒杀服务根据skuId查询当前商品是否参与秒杀活动。
gulimall-product/src/main/java/com/wen/gulimall/product/feign/SeckillFeignService.java
java
@FeignClient("gulimall-seckill")
public interface SeckillFeignService {
@GetMapping("/sku/seckill/{skuId}")
R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);
}
商品服务秒杀信息vo,复制秒杀服务SeckillSkuRedisTo实体。
gulimall-product/src/main/java/com/wen/gulimall/product/vo/SeckillInfoVo.java
java
@Data
public class SeckillInfoVo {
private Long promotionId;
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 商品秒杀随机码
*/
private String randomCode;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private Integer seckillCount;
/**
* 每人限购数量
*/
private Integer seckillLimit;
/**
* 排序
*/
private Integer seckillSort;
// 当前商品秒杀的开始时间
private Long startTime;
// 当前商品秒杀的结束时间
private Long endTime;
}
SkuItemVo添加秒杀商品信息属性seckillInfo
gulimall-product/src/main/java/com/wen/gulimall/product/vo/SkuItemVo.java
java
@Data
public class SkuItemVo {
// 获取sku的基本信息 pms_sku_info
private SkuInfoEntity info;
private boolean hasStock = true;
// 获取sku的图片信息 pms_sku_images
private List<SkuImagesEntity> images;
// 获取spu的销售属性组合
private List<SkuItemSaleAttrVo> saleAttr;
// 获取spu的介绍
private SpuInfoDescEntity desc;
// 获取spu的规格参数信息
private List<SpuItemAttrGroupVo> groupAttrs;
// 当前商品的秒杀优惠信息
private SeckillInfoVo seckillInfo;
}
查询商品详情业务层添加查询当前sku是否参与秒杀活动。
gulimall-product/src/main/java/com/wen/gulimall/product/service/impl/SkuInfoServiceImpl.java
java
// 6.查询当前sku是否参与秒杀活动
CompletableFuture<Void> secKillFuture = CompletableFuture.runAsync(() -> {
R skuSeckillInfo = seckillFeignService.getSkuSeckillInfo(skuId);
if (skuSeckillInfo.getCode() == 0) {
SeckillInfoVo data = skuSeckillInfo.getData(new TypeReference<SeckillInfoVo>() {
});
skuItemVo.setSeckillInfo(data);
}
}, threadPoolExecutor);
// 等待所有任务都完成,不用写infoFuture,因为saleAttrFuture/descFuture/baseAttrFuture他们依赖infoFuture完成的结果
CompletableFuture.anyOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,secKillFuture).get();
3.3.6.3 商品详情页代码
gulimall-product/src/main/resources/templates/item.html
java
<div class="box-summary clear">
<ul>
<li>京东价</li>
<li>
<span>¥</span>
<span th:text="${#numbers.formatDecimal(item.info.price,3,2)}">4499.00</span>
</li>
<li style="color: red" th:if="${item.seckillInfo!=null}">
<span th:if="${#dates.createNow().getTime()<item.seckillInfo.startTime}">
商品将会在[[${#dates.format(new java.util.Date(item.seckillInfo.startTime),"yyyy-MM-dd HH:mm:ss")}]]进行秒杀
</span>
<span th:if="${#dates.createNow().getTime()>=item.seckillInfo.startTime && #dates.createNow().getTime()<=item.seckillInfo.endTime}">
秒杀价:[[${#numbers.formatDecimal(item.seckillInfo.seckillPrice,1,2)}]]
</span>
</li>
<li>
<a href="">
预约说明
</a>
</li>
</ul>
</div>
html
<div class="box-btns-two" th:if="${#dates.createNow().getTime()>=item.seckillInfo.startTime && #dates.createNow().getTime()<=item.seckillInfo.endTime}">
<a href="#" id="secKillA" th:attr="skuId=${item.info.skuId}">
立即抢购
</a>
</div>
<div class="box-btns-two" th:if="${#dates.createNow().getTime()<item.seckillInfo.startTime || #dates.createNow().getTime()>item.seckillInfo.endTime}">
<a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}">
加入购物车
</a>
</div>
3.4 秒杀
3.4.1 秒杀架构
3.4.2 秒杀(高并发)系统关注的问题
3.4.3 登录检查(配置登录拦截器)
登录后,才能进行秒杀。
3.4.3.1 商品详情页登录拦截
正在秒杀的商品,点击"立即抢购",登录了才能进行秒杀。
html
<script>
...
$("#secKillA").click(function (){
var isLogin = [[${session.loginUser!=null}]]
if(isLogin){
var killId = $(this).attr("sessionId")+"_"+$(this).attr("skuId");
var key = $(this).attr("code");
var num = $('#numInput').val();
location.href = "http://seckill.gulimall.com/kill?killId="+killId+"&key="+key+"&num="+num;
}else {
alert("秒杀请先登录");
}
});
</script>
3.4.3.2 秒杀服务配置登录拦截器
3.4.3.2.1 引入依赖
添加 redis 依赖,SpringSession相关依赖在公共模块,已引入公共模块。
gulimall-seckill/pom.xml
XML
<!-- lettuce有问题,引入jedis -->
<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>
3.4.3.2.2 SpringSession 相关配置
GulimallSessionConfig.java在公共模块gulimall-common。
3.4.3.2.3 yml 配置
登录信息存储在redis
bash
spring:
redis:
host: 172.xxx.xxx.10
session:
store-type: redis
3.4.3.2.4 启用Redis会话管理
@EnableRedisHttpSession
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/GulimallSeckillApplication.java
java
@EnableRedisHttpSession
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class GulimallSeckillApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallSeckillApplication.class, args);
}
}
3.4.3.2.5 配置登录拦截器
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/interceptor/LoginUserInterceptor.java
java
/**
* @author W
* @createDate 2024/02/27 16:58
* @description: 登录拦截器
* 从session中(redis中)获取了登录信息,封装到ThreadLocal
* 自定义拦截器需要添加到webmvc中,否则不起作用
*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
// 同一个线程共享数据
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
boolean match = antPathMatcher.match("/kill", requestURI);
if(match) {
MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null) {
// 登录成功
loginUser.set(attribute);
return true;
} else {
// 没登录,去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
return true;
}
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/config/SeckillWebConfig.java
java
@Configuration
public class SeckillWebConfig implements WebMvcConfigurer {
@Resource
private LoginUserInterceptor loginUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
}
}
3.4.5 秒杀流程
3.4.5.1 流程一(加入购物车秒杀------弃用)
(1)优点:天然的流量错峰,与正常购物流程一致,价格为秒杀价,数据模型与正常下单流程一致。
(2)缺点:秒杀流量级联映射到其他服务,比如:购物车服务、订单服务,秒杀服务高并发下,可能会拖垮购物车等服务,导致非秒杀商品无法正常加入购物车下单。
3.4.5.2 流程二(独立秒杀业务处理------推荐)
(1)优点:从用户下单到返回没有对数据库进行任何操作,只做了一些合法性校验,校验通过生成订单号并发送消息。
(2)缺点:如果订单服务挂了,无法消费消息,订单一直创建不好导致用户无法支付。
(3)解决方案:不使用订单服务处理秒杀消息,使用独立的业务进行秒杀处理,保证高并发秒杀不影响拖垮其他服务。
3.4.6 创建秒杀队列、绑定关系
gulimall-order/src/main/java/com/wen/gulimall/order/config/MyMQConfig.java
java
/**
* 商品秒杀队列
* 作用:流量削峰、监听创建订单
* @return
*/
@Bean
public Queue orderSeckillOrderQueue(){
//String name, boolean durable, boolean exclusive, boolean autoDelete,
// @Nullable Map<String, Object> arguments
return new Queue("order.seckill.order.queue",true,false,false);
}
@Bean
public Binding orderSeckillOrderQueueBinding(){
//String destination, DestinationType destinationType, String exchange, String routingKey,
// @Nullable Map<String, Object> arguments
return new Binding("order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
}
3.4.7 整合rabbitmq、thymeleaf
rabbitmq:用于秒杀校验等通过订单的创建。
thymeleaf:用于秒杀成功页面。
3.4.7.1 引入依赖
gulimall-seckill/pom.xml
XML
<!-- 模板引擎 :thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 消息队列amqp -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
3.4.7.2 yml配置
gulimall-seckill/src/main/resources/application.yml
bash
spring:
rabbitmq:
host: 172.1.11.10
port: 5672
virtual-host: /
# 开启发送端确认
publisher-confirm-type: correlated
# 开启发送端消息抵达队列的确认,默认是false
publisher-returns: true
thymeleaf:
# 关闭缓存
cache: false
3.4.7.3 配置RabbitMQ序列化方式
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/config/MyRabbitConfig.java
java
@Configuration
public class MyRabbitConfig {
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
3.4.8 秒杀成功页面
复制加入购物车的成功页代码,修改<div class="m succeed-box"></div>中的内容即可。
gulimall-seckill/src/main/resources/templates/success.html
html
<div class="m succeed-box">
<div th:if="${orderSn != null}" class="mc success-cont">
<h1>恭喜,秒杀成功,订单号:[[${orderSn}]]</h1>
<h2>正在准备订单数据,10s以后自动跳转支付 <a style="color: red" th:href="${'http://order.gulimall.com/payOrder?orderSn='+orderSn}">去支付</a></h2>
</div>
<div th:if="${orderSn == null}">
<h1>手气不好,秒杀失败,下次再来</h1>
</div>
</div>
3.4.9 秒杀接口
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/controller/SeckillController.java
java
@Controller
public class SeckillController {
@Resource
private SeckillService seckillService;
...
/**
* 秒杀:立即抢购
* @param killId 场次id_skuId
* @param key 商品随机码
* @param num 秒杀数量
* @param model
* @return
*/
@GetMapping("/kill")
public String kill(String killId, String key, Integer num, Model model){
String orderSn = seckillService.kill(killId,key,num);
model.addAttribute("orderSn",orderSn);
return "success";
}
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/SeckillService.java
java
public interface SeckillService {
...
/**
* 秒杀
* @param killId
* @param key
* @param num
* @return
*/
String kill(String killId, String key, Integer num);
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.java
java
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Resource
private CouponFeignService couponFeignService;
@Resource
private ProductFeignService productFeignService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
@Resource
private RabbitTemplate rabbitTemplate;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
private final String SKUKILL_CACHE_PREFIX = "seckill:sku:";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; // + 商品随机码
...
// TODO 上架秒杀商品的时候,每个数据都有过期时间
// TODO 秒杀后续流程,简化了收货地址等信息
// TODO 上架秒杀商品锁定相关库存,秒杀结束未秒杀完的库存恢复
@Override
public String kill(String killId, String key, Integer num) {
long l1 = System.currentTimeMillis();
// 获取当前登录用户信息
MemberRespVo memberVo = LoginUserInterceptor.loginUser.get();
// 获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
String json = hashOps.get(killId);
if (StrUtil.isBlank(json)) {
return null;
} else {
SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
// 校验合法性
// 1. 校验时间的合法性 上线可以给数据过期时间
long currentTime = System.currentTimeMillis();
Long startTime = seckillSkuRedisTo.getStartTime();
Long endTime = seckillSkuRedisTo.getEndTime();
if (currentTime >= startTime && currentTime <= endTime) {
//2. 校验随机码和商品id
String randomCode = seckillSkuRedisTo.getRandomCode();
String skuId = seckillSkuRedisTo.getPromotionSessionId() + "_" + seckillSkuRedisTo.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)) {
// 3. 验证购买数量是否合理
if (num <= seckillSkuRedisTo.getSeckillLimit()) {
// 4. 验证这个人是否已经购买过。幂等性;如果秒杀成功,就去redis占位。userId_sessionId_skuId
// SETNX 占位,没有才占位 原子性操作
String redisKey = memberVo.getId() + "_" + skuId;
long ttl = endTime - currentTime;
// 自动过期
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
// 占位成功,说明从来没有买过,分布式锁(获取信号量-1)
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
//boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
boolean b = semaphore.tryAcquire(num);
if (b) {
// 秒杀成功;
// 快速下单。发送MQ消息 10ms
String timeId = IdWorker.getTimeId();
SeckillOrderTo seckillOrderTo = new SeckillOrderTo();
seckillOrderTo.setOrderSn(timeId);
seckillOrderTo.setMemberId(memberVo.getId());
seckillOrderTo.setPromotionSessionId(seckillSkuRedisTo.getPromotionSessionId());
seckillOrderTo.setSkuId(seckillSkuRedisTo.getSkuId());
seckillOrderTo.setSeckillPrice(seckillSkuRedisTo.getSeckillPrice());
seckillOrderTo.setNum(num);
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", seckillOrderTo);
long l2 = System.currentTimeMillis();
log.info("秒杀接口耗时......"+(l2-l1));
return timeId;
}
}
}
}
}
}
long l3 = System.currentTimeMillis();
log.info("秒杀接口耗时......"+(l3-l1));
return null;
}
...
}
秒杀消息内容实体
gulimall-common/src/main/java/com/wen/common/to/mq/SeckillOrderTo.java
java
@Data
public class SeckillOrderTo {
private String orderSn; // 订单号
private Long promotionSessionId; // 场次id
private Long skuId; // 商品id
private BigDecimal seckillPrice; // 秒杀价格
private Integer num; // 购买数量
private Long memberId; // 会员id
}
3.4.9.1 (幂等性)限制同一用户重复秒杀
使用SETNX占位,没有才占位,以用户id_场次id_skuId为key,value为秒杀数量。
java
// 4. 验证这个人是否已经购买过。幂等性;如果秒杀成功,就去redis占位。userId_sessionId_skuId
// SETNX 占位,没有才占位 原子性操作
String redisKey = memberVo.getId() + "_" + skuId;
long ttl = endTime - currentTime;
// 自动过期
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
// 占位成功,说明从来没有买过,分布式锁(获取信号量-1)
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
//boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
boolean b = semaphore.tryAcquire(num);
立即抢购,秒杀测试结果,如下:
刷新浏览器,模拟重复秒杀,结果如下:
3.4.10 秒杀消息监听消费
秒杀下单监听
gulimall-order/src/main/java/com/wen/gulimall/order/listener/OrderSeckillListener.java
java
@Slf4j
@RabbitListener(queues = "order.seckill.order.queue")
@Component
public class OrderSeckillListener {
@Resource
private OrderService orderService;
/**
* 监听秒杀消息
* @param message
* @param channel
* @param seckillOrderTo
* @throws IOException
*/
@RabbitHandler
public void listen(SeckillOrderTo seckillOrderTo, Message message, Channel channel) throws IOException {
log.info("准备创建秒杀单...");
try {
// 确认收到消息
orderService.createSeckillOrder(seckillOrderTo);
// 手动调用支付宝收单
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (IOException e) {
// 重回队列
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
创建秒杀订单
gulimall-order/src/main/java/com/wen/gulimall/order/service/OrderService.java
java
public interface OrderService extends IService<OrderEntity> {
...
/**
* 创建秒杀订单
* @param seckillOrderTo
*/
void createSeckillOrder(SeckillOrderTo seckillOrderTo);
}
gulimall-order/src/main/java/com/wen/gulimall/order/service/impl/OrderServiceImpl.java
java
@Slf4j
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
...
@Override
public void createSeckillOrder(SeckillOrderTo seckillOrderTo) {
// TODO 保存订单信息
OrderEntity order = new OrderEntity();
order.setOrderSn(seckillOrderTo.getOrderSn());
order.setMemberId(seckillOrderTo.getMemberId());
order.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
// 收货地址
BigDecimal multiply = seckillOrderTo.getSeckillPrice().multiply(new BigDecimal("" + seckillOrderTo.getNum()));
order.setPayAmount(multiply);
this.save(order);
// TODO 保存订单项信息
OrderItemEntity orderItemEntity = new OrderItemEntity();
orderItemEntity.setOrderSn(seckillOrderTo.getOrderSn());
orderItemEntity.setSkuId(seckillOrderTo.getSkuId());
orderItemEntity.setRealAmount(multiply);
// TODO 获取当前spu的详细信息进行设置
R spuInfoBySkuId = productFeignService.getSpuInfoBySkuId(seckillOrderTo.getSkuId());
SpuInfoVo spuInfo = spuInfoBySkuId.getData(new TypeReference<SpuInfoVo>() {
});
orderItemEntity.setSpuId(spuInfo.getId());
orderItemEntity.setSpuName(spuInfo.getSpuName());
orderItemEntity.setSpuBrand(spuInfo.getBrandName());
orderItemEntity.setSkuQuantity(seckillOrderTo.getNum());
orderItemService.save(orderItemEntity);
}
...
}
消费结果:
3.5 秒杀总结
秒杀具有瞬间高并发的特点, 针对这一特点, 必须要做限流 + 异步 + 缓存(页面静态化) + 独立部署。
3.5.1 服务单一职责+独立部署
要求:秒杀服务即使自己扛不住压力,挂掉。不要影像别的服务。
解决方案:新建秒杀服务。
3.5.2 秒杀连接加密
目的:(1)防止恶意攻击,模拟秒杀请求,1000次/s攻击。
(2)防止链接暴露,自己工作人员,提前秒杀商品。
解决方案:这里使用商品随机码,当秒杀开始时随机码才会在商品信息中。
3.5.3 库存预热+快速扣减
秒杀读多写少。无需每次实时校验库存。我们库存预热,放到redis中。信号量控制进来秒杀的请求。
解决方案:使用定时任务将近三天需要秒杀的商品放到redis中,使用redission信号量完成秒杀库存扣减+限流。
3.5.4 动静分离
nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。使用CDN网络,分担本集群压力。
解决方案:将所有模块的静态资源放到nginx缓解集群压力。页面静态请求较多,以商品详情页为例,总共60多个请求到达后台的只有1个。
3.5.5 恶意请求拦截
识别非法攻击请求并进行拦截,网关层。
解决方案:未在网关层拦截,在秒杀模块配置登录拦截器。
3.5.6 流量错峰
使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车【每个用户速度有快有慢】,将流量分散。
解决方案:可以使用秒杀流程的第一种方案加入购物车。
3.5.7 限流+熔断+降级
前端限流+后端限流
限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
(1)前端限流:
1)点击1后才能进行下次点击;
2)验证码设计。
(2)后端限流:
1)nginx限流降级:直接负载部分请求到错误的静态页面: 令牌算法 漏斗算法;
2)网关限流;
3)redission分布式型号量;
4)RabbitMQ限流;
5)熔断:当远程服务出现异常时快速中断调用并返回错误响应,方式服务级联失败。
解决方案:Sentinel
3.5.8 队列削峰
1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。
解决方案:秒杀发送需要创建订单的MQ消息,订单服务监听秒杀队列,创建秒杀订单。