谷粒商城篇章11--P311-P325--秒杀服务【分布式高级篇八】

目录

[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 表达式

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 复制代码
<?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 整合异步任务步骤
  1. 在类上标注注解开启异步功能
java 复制代码
@EnableAsync
  1. 在需要异步执行的方法上标注注解,开启异步任务
java 复制代码
@Async
  1. 测试
java 复制代码
@Slf4j
@Component
@EnableAsync
@EnableScheduling // 开启定时任务
public class HelloSchedule {
 
    @Async
    @Scheduled(cron = "* * * ? * 2")
    public void hello() throws InterruptedException {

        log.info("hello ...");
        Thread.sleep(3000);
    }
}

日志每秒打印一次,定时任务没有阻塞了,如下:

  1. 设置线程池大小

异步任务的线程池最大线程数是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消息,订单服务监听秒杀队列,创建秒杀订单。

相关推荐
qingcyb1 小时前
下载Kafka 3.0.0教程
分布式·kafka
杰信步迈入C++之路4 小时前
【RabbitMQ】快速上手
分布式·rabbitmq·ruby
Lill_bin6 小时前
JVM内部结构解析
jvm·后端·spring cloud·微服务·云原生·ribbon
妙龄少女郭德纲6 小时前
基于Spark框架实现XGBoost模型
大数据·分布式·spark
晚枫20007 小时前
kafka发送事件的几种方式
spring boot·分布式·docker·容器·kafka·intellij-idea·linq
二进制杯莫停7 小时前
初识zookeeper
分布式·zookeeper·云原生
happycao1237 小时前
kafka消息发送几种方式
分布式·kafka
xuchengxi-java7 小时前
本地不能訪問linux的kafka服務
分布式·kafka
Fan7 小时前
Kafka 下载安装及使用总结
分布式·kafka
Java码农杂谈8 小时前
浅谈Tair缓存的三种存储引擎MDB、LDB、RDB
java·redis·分布式·后端·阿里云·缓存