0
点赞
收藏
分享

微信扫一扫

Nginx + Tomcat 负载均衡与动静分离

陆佃 2024-08-27 阅读 29

目录

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 后台添加秒杀商品

1.1 配置优惠券服务网关

网关配置如下:

- id: coupon_route
  uri: lb://gulimall-coupon
  predicates:
    - Path=/api/coupon/**,/hello
  filters:
    # 去掉 api
    - RewritePath=/api/?(?<segment>.*), /$\{segment}

1.2 添加秒杀场次

1.3 上架秒杀商品

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 NameMandatoryAllowed ValuesAllowed Special Characters
SecondsYES0-59, - * /
MinutesYES0-59, - * /
HoursYES0-23, - * /
Day of monthYES1-31, - * ? / L W
MonthYES1-12 or JAN-DEC, - * /
Day of weekYES1-7 or SUN-SAT, - * ? / L #
YearYESempty, 1970-2099, - * /

2.2 cron表达式特殊字符

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

<?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配置

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配置

spring:
  cloud:
    nacos:
      config:
        server-addr: 172.xx.xx.10:8848

3.1.4 启动类上添加注解

@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})

3.2 SpringBoot整合定时任务与异步任务

(使用异步任务+定时任务来完成定时任务不阻塞的功能)

3.2.1 整合定时任务

@Component // 注入容器中
@EnableScheduling // 开启定时任务
@Scheduled(cron = "* * * ? * 1")

注意:

(1)Spring中cron由6位组成,不允许第7位的年

(2)在周几的位置,1-7代表周一到周日;MON-SUN

(3)定时任务不应该阻塞。默认是阻塞的

@Slf4j
@Component
@EnableScheduling // 开启定时任务
public class HelloSchedule {
 
    @Scheduled(cron = "* * * ? * 2")
    public void hello() throws InterruptedException {
        log.info("hello ...");
    }
}

3.2.2 整合异步任务

整合异步任务为了解决定时任务不应该阻塞,默认是阻塞的。

3.2.2.1 定时任务阻塞
@Slf4j
@Component
@EnableScheduling // 开启定时任务
public class HelloSchedule {
    
    @Scheduled(cron = "* * * ? * 2")
    public void hello() throws InterruptedException {
        log.info("hello ...");
        Thread.sleep(3000);
    }
}

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. 设置线程池大小

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 获取三天内要开始的秒杀活动及秒杀商品信息

@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);
    }

    ...
}
/**
 * 秒杀活动场次
 *
 * @author wen
 */
public interface SeckillSessionService extends IService<SeckillSessionEntity> {

    ...

    List<SeckillSessionEntity> getLatest3DaySession();
}
@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"));
    }
}
/**
 * 远程调用优惠服务
 *
 * @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 使用定时任务上架最近三天需要秒杀的商品
/**
 * 定时任务配置类
 *      异步任务+定时任务
 *
 * @author w
 * @date 2024/07/23 14:02
 */
@EnableAsync // 开启异步任务
@EnableScheduling // 开启定时任务
@Configuration
public class ScheduledConfig {
}
/**
 * 秒杀商品的定时上架
 *      每天晚上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();
        }
    }
}
/**
 * 秒杀业务层
 *
 * @author w
 * @date 2024/07/23 14:09
 */
public interface SeckillService {
    /**
     * 上架最近三天参与秒杀活动的商品
     */
    void uploadSeckillSkuLatest3Days();
}
@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 定时任务分布式情况下的问题

3.3.4.3 解决同一活动同一商品重复上架(幂等性保证)

缓存活动信息幂等性保证:

缓存活动关联商品信息幂等性保证:

3.3.5 首页展示上架的秒杀商品

3.3.5.1 配置网关
- id: gulimall_seckill_route
  uri: lb://gulimall-seckill
  predicates:
    # 由以下的主机域名访问转发到会员服务
    - Host=seckill.gulimall.com
3.3.5.2 SwitchHosts增加配置

3.3.5.3 获取当前时间可以参与秒杀的商品信息
@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;
}
@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);
    }

    ...
}
public interface SeckillService {
    
    ...

    List<SeckillSkuRedisTo> getCurrentSeckillSkus();
}
@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 首页代码

<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查询商品是否参加秒杀活动
@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);
    }
}
public interface SeckillService {
    ...

    /**
     * 根据skuId查询商品是否参加秒杀活动
     * @param skuId
     * @return
     */
    SeckillSkuRedisTo getSkuSeckillInfo(Long skuId);
}
@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 查询商品详情时验证当前商品是否参与秒杀活动
@FeignClient("gulimall-seckill")
public interface SeckillFeignService {
    @GetMapping("/sku/seckill/{skuId}")
    R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);
}
@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;
}
@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;


}
        // 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 商品详情页代码
<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 引入依赖
<!--	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 相关配置
3.4.3.2.3 yml 配置
spring:
  redis:
    host: 172.xxx.xxx.10
  session:
    store-type: redis
3.4.3.2.4 启用Redis会话管理
@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 配置登录拦截器
/**
 * @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;
    }
}
@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 流程一(加入购物车秒杀——弃用)

3.4.5.2 流程二(独立秒杀业务处理——推荐)

3.4.6 创建秒杀队列、绑定关系

/**
     * 商品秒杀队列
     * 作用:流量削峰、监听创建订单
     * @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

3.4.7.1 引入依赖
<!-- 模板引擎 :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配置
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序列化方式 
@Configuration
public class MyRabbitConfig {

    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }
}

3.4.8 秒杀成功页面 

<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 秒杀接口

@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";
    }

}
public interface SeckillService {
    ...

    /**
     * 秒杀
     * @param killId
     * @param key
     * @param num
     * @return
     */
    String kill(String killId, String key, Integer num);
}
@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;
    }

    ...
}
@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 (幂等性)限制同一用户重复秒杀
// 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 秒杀消息监听消费

@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);
        }
    }
}
public interface OrderService extends IService<OrderEntity> {

    ...

    /**
     * 创建秒杀订单
     * @param seckillOrderTo
     */
    void createSeckillOrder(SeckillOrderTo seckillOrderTo);
}
@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 秒杀连接加密

3.5.3 库存预热+快速扣减

3.5.4 动静分离

3.5.5 恶意请求拦截

3.5.6 流量错峰

3.5.7 限流+熔断+降级

3.5.8 队列削峰

举报

相关推荐

0 条评论