目录
1 后台添加秒杀商品
1.1 配置优惠券服务网关
1.2 添加秒杀场次
1.3 上架秒杀商品
2 定时任务
2.1 cron 表达式
2.2 cron表达式特殊字符
2.3 cron示例
3 秒杀服务
3.1 创建秒杀服务模块
3.1.1 pom.xml
3.1.2 application.yml配置
3.1.3 bootstrap.yml配置
3.1.4 启动类上添加注解
3.2 SpringBoot整合定时任务与异步任务
3.2.1 整合定时任务
3.2.2 整合异步任务
3.2.2.1 定时任务阻塞
3.2.2.2 解决定时任务阻塞的方式
3.2.2.3 整合异步任务步骤
3.3 秒杀商品上架
3.3.1 秒杀商品上架流程
3.3.2 时间日期处理
3.3.2.1 获取当天0点整的时间
3.3.2.2 获取含今天的三天后的最后时间
3.3.3 获取三天内要开始的秒杀活动及秒杀商品信息
3.3.4 秒杀商品定时上架
3.3.4.1 使用定时任务上架最近三天需要秒杀的商品
3.3.4.2 定时任务分布式情况下的问题
3.3.4.3 解决同一活动同一商品重复上架(幂等性保证)
3.3.5 首页展示上架的秒杀商品
3.3.5.1 配置网关
3.3.5.2 SwitchHosts增加配置
3.3.5.3 获取当前时间可以参与秒杀的商品信息
3.3.5.4 首页代码
3.3.5.5 测试
3.3.6 秒杀页面渲染
3.3.6.1 根据skuId查询商品是否参加秒杀活动
3.3.6.2 查询商品详情时验证当前商品是否参与秒杀活动
3.3.6.3 商品详情页代码
3.4 秒杀
3.4.1 秒杀架构
3.4.2 秒杀(高并发)系统关注的问题
3.4.3 登录检查(配置登录拦截器)
3.4.3.1 商品详情页登录拦截
3.4.3.2 秒杀服务配置登录拦截器
3.4.3.2.1 引入依赖
3.4.3.2.2 SpringSession 相关配置
3.4.3.2.3 yml 配置
3.4.3.2.4 启用Redis会话管理
3.4.3.2.5 配置登录拦截器
3.4.5 秒杀流程
3.4.5.1 流程一(加入购物车秒杀——弃用)
3.4.5.2 流程二(独立秒杀业务处理——推荐)
3.4.6 创建秒杀队列、绑定关系
3.4.7 整合rabbitmq、thymeleaf
3.4.7.1 引入依赖
3.4.7.2 yml配置
3.4.7.3 配置RabbitMQ序列化方式
3.4.8 秒杀成功页面
3.4.9 秒杀接口
3.4.9.1 (幂等性)限制同一用户重复秒杀
3.4.10 秒杀消息监听消费
3.5 秒杀总结
3.5.1 服务单一职责+独立部署
3.5.2 秒杀连接加密
3.5.3 库存预热+快速扣减
3.5.4 动静分离
3.5.5 恶意请求拦截
3.5.6 流量错峰
3.5.7 限流+熔断+降级
3.5.8 队列削峰
1 后台添加秒杀商品
复制优惠券前端代码到 src\views\modules路径下,如下:
1.1 配置优惠券服务网关
未配置优惠券服务网关之前,如下:
网关配置如下:
gulimall-gateway/src/main/resources/application.yml
- 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 表达式
Cron - 在线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 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
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
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
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
3.2 SpringBoot整合定时任务与异步任务
(使用异步任务+定时任务来完成定时任务不阻塞的功能)
3.2.1 整合定时任务
自动配置类TaskSchedulingAutoConfiguration
属性TaskSchedulingProperties
在类上使用注解开启定时任务功能,如下:
@Component // 注入容器中
@EnableScheduling // 开启定时任务
在需要开启定时任务的方法上使用注解,为该方法开启定时任务,根据cron表达式定时执行,如下:
@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
@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 定时任务阻塞
模拟业务处理时间较长,查看定时任务的执行情况,如下:
@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 解决定时任务阻塞的方式
方式一:使用异步编排,可以业务以异步的方式运行,自己提交到线程池,如下:
CompletableFuture.runAsync(()->{
xxxService.hello();
},executor);
【不生效】方式二:支持定时任务线程池,设置 TaskSchedulingProperties,线程池大小默认是1,修改线程池大小,如下:
spring:
task:
scheduling:
pool:
size: 5
方式三:异步任务,实现过程见3.2.2.3
3.2.2.3 整合异步任务步骤
1. 在类上标注注解开启异步功能
@EnableAsync
2. 在需要异步执行的方法上标注注解,开启异步任务
@Async
3. 测试
@Slf4j
@Component
@EnableAsync
@EnableScheduling // 开启定时任务
public class HelloSchedule {
@Async
@Scheduled(cron = "* * * ? * 2")
public void hello() throws InterruptedException {
log.info("hello ...");
Thread.sleep(3000);
}
}
日志每秒打印一次,定时任务没有阻塞了,如下:
4. 设置线程池大小
异步任务的线程池最大线程数是Integer的最大值,项目中要对其进行限制。
gulimall-seckill/src/main/resources/application.yml
spring:
task:
execution:
pool:
core-size: 5
max-size: 50
3.3 秒杀商品上架
3.3.1 秒杀商品上架流程
3.3.2 时间日期处理
3.3.2.1 获取当天0点整的时间
/**
* 开始日期:今天 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 获取含今天的三天后的最后时间
/**
* 结束日期:含今天的三天后的最后时间 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
@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
/**
* 秒杀活动场次
*
* @author wen
*/
public interface SeckillSessionService extends IService<SeckillSessionEntity> {
...
List<SeckillSessionEntity> getLatest3DaySession();
}
gulimall-coupon/src/main/java/com/wen/gulimall/coupon/service/impl/SeckillSessionServiceImpl.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
/**
* 远程调用优惠服务
*
* @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
/**
* 定时任务配置类
* 异步任务+定时任务
*
* @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
/**
* 秒杀商品的定时上架
* 每天晚上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
/**
* 秒杀业务层
*
* @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
@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
- 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
@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
@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
public interface SeckillService {
...
List<SeckillSkuRedisTo> getCurrentSeckillSkus();
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.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
<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
@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
public interface SeckillService {
...
/**
* 根据skuId查询商品是否参加秒杀活动
* @param skuId
* @return
*/
SeckillSkuRedisTo getSkuSeckillInfo(Long skuId);
}
gulimall-seckill/src/main/java/com/wen/gulimall/seckill/service/impl/SeckillServiceImpl.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
@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
@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
@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
// 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
<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>
<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 商品详情页登录拦截
正在秒杀的商品,点击“立即抢购”,登录了才能进行秒杀。
<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
<!-- 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
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
@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
/**
* @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
@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
/**
* 商品秒杀队列
* 作用:流量削峰、监听创建订单
* @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
<!-- 模板引擎 :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
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
@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
<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
@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
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
@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
@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为秒杀数量。
// 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
@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
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
@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消息,订单服务监听秒杀队列,创建秒杀订单。