学习目标:
所谓“秒杀、抢购”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。
(1)库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图啥?人气!
(2)时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动;
(3)购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购
列表页
详情页
排队页
下单页
支付页
秒杀商品表seckill_goods
我们先把秒杀模块搭建好,秒杀一共有三个模块,秒杀微服务模块service-activity,负责封装秒杀全部服务端业务;秒杀前端模块web-all,负责前端显示业务;service-activity-client api接口模块
提供秒杀商品基础代码
搭建方式如service-order
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>gmall-service</artifactId>
<groupId>com.atguigu.gmall</groupId>
<version>1.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>service-activity</artifactId>
<dependencies>
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>service-user-client</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>service-order-client</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>rabbit-util</artifactId>
<version>1.0</version>
</dependency>
<!--caffeine本地缓存-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.7.0</version>
</dependency>
</dependencies>
<build>
<finalName>service-activity</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package com.atguigu.gmall;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Set;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class ActivityApp {
public static void main(String[] args) {
ConfigurableApplicationContext applicationContext = SpringApplication.run(ActivityApp.class, args);
RedisTemplate redisTemplate = applicationContext.getBean(RedisTemplate.class);
//添加关闭服务器钩子 服务一旦停止,本地缓存数据无;但是分布式缓存中依然有数据
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("清理分布式缓存秒杀数据");
Set<String> keys = redisTemplate.keys("seckill:" + "*");
redisTemplate.delete(keys);
}));
}
}
在resources目录下新建:bootstrap.properties
spring.application.name=service-activity
spring.profiles.active=dev
spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml
搭建service-activity-client,搭建方式如service-order-client
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>service-activity-client</artifactId>
<version>1.0</version>
</dependency>
缓存数据实现思路:service-task模块统一管理我们的定时任务,为了减少service-task模块的耦合度,我们可以在定时任务模块只发送mq消息,需要执行定时任务的模块监听该消息即可,这样有利于我们后期动态控制,例如:每天凌晨一点我们发送定时任务信息到mq交换机,如果秒杀业务凌晨一点需要导入数据到缓存,那么秒杀业务绑定队列到交换机就可以了,其他业务也是一样,这样就做到了很好的扩展。
上面提到我们要控制库存数量,不能超卖,那么如何控制呢?在这里我们提供一种解决方案,那就我们在导入商品缓存数据时,同时将商品库存信息导入队列{list},利用redis队列的原子性,保证库存不超卖
库存加入队列实施方案
1,如果秒杀商品有N个库存,那么我就循环往队列放入N个队列数据
2,秒杀开始时,用户进入,然后就从队列里面出队,只有队列里面有数据,说明就一点有库存(redis队列保证了原子性),队列为空了说明商品售罄
在service-task模块发送消息
在gmall-service
模块下新增子模块: service-task . 搭建方式如service-mq
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>gmall-service</artifactId>
<groupId>com.atguigu.gmall</groupId>
<version>1.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>service-task</artifactId>
<dependencies>
<!--rabbitmq消息队列-->
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>rabbit-util</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
<build>
<finalName>service-task</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
说明:引入rabbit-util依赖定时发送消息
package com.atguigu.gmall;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)//取消数据源自动配置
@EnableDiscoveryClient
public class TaskApp {
public static void main(String[] args) {
SpringApplication.run(TaskApp.class, args);
}
}
bootstrap.properties
spring.application.name=service-task
spring.profiles.active=dev
spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml
定义凌晨一点mq相关常量-
/**
* 定时任务
*/
public static final String EXCHANGE_DIRECT_TASK = "exchange.direct.task";
public static final String ROUTING_TASK_1 = "seckill.task.1";
//队列
public static final String QUEUE_TASK_1 = "queue.task.1";
SpringTask提供的任务不能用在分布式项目中,固抛弃
package com.atguigu.gmall.task.scheduled;
import com.atguigu.gmall.common.constant.MqConst;
import com.atguigu.gmall.common.service.RabbitService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* @author: atguigu
* @create: 2023-03-17 10:13
*/
@Slf4j
@Component
@EnableScheduling //开启定时任务
public class ScheduledTask {
@Autowired
private RabbitService rabbitService;
/**
* cron表达式 出处:定时任务框架Quartz
* 定时任务逻辑:
* 每日凌晨1点执行 秒杀商品预热消息发送
* 开发阶段:每隔30秒触发一次
*/
//@Scheduled(cron = "0 0 1 * * ?")
@Scheduled(cron = "0/30 * * * * ?")
public void sendImpSeckGoodsMsg() {
log.info("定时任务秒杀商品预热消息发送");
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_1, "");
}
}
XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。
特性
准备数据库环境
搭建调用中心服务 配置文件修改数据库连接信息
在service-task
模块中导入依赖
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.0</version>
</dependency>
提供执行器配置文件
spring.application.name=service-task
spring.profiles.active=dev
spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml
server.port=8207
### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin
### 执行器通讯TOKEN [选填]:非空时启用;
xxl.job.accessToken=default_token
### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
xxl.job.executor.appname=service-task-executor
### 执行器注册 [选填]:优先使用该配置作为注册地址,为空时使用内嵌服务 ”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
xxl.job.executor.address=
### 执行器IP [选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用;地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
xxl.job.executor.ip=192.168.41.45
### 执行器端口号 [选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
xxl.job.executor.port=9999
### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### 执行器日志文件保存天数 [选填] : 过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
xxl.job.executor.logretentiondays=30
执行器组件,配置内容说明
package com.atguigu.gmall.task.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: atguigu
* @create: 2023-08-15 14:15
*/
@Configuration
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${xxl.job.executor.address}")
private String address;
@Value("${xxl.job.executor.ip}")
private String ip;
@Value("${xxl.job.executor.port}")
private int port;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
提供定时任务逻辑
package com.atguigu.gmall.task.job;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* @author: atguigu
* @create: 2023-08-15 11:32
*/
@Slf4j
@Component
public class TaskJob {
/**
* spring定时任务-适用于单体项目中简单定时任务;任务无法停止,任务执行周期
* cron表达式:秒 分 时 日 月 周 [年]
*/
//@Scheduled(cron = "0/5 * * * * ?")
public void stringTask(){
System.out.println("任务执行了");
}
/**
* 每日凌晨1点执行任务,发送消息通知秒杀服务缓存预热
*/
@XxlJob("seckillImport")
public void seckillImport(){
log.info("[定时任务]发送秒杀商品入库消息");
}
}
在调度中心页面新增执行器,关闭本地防火墙
在调度中心页面新增任务
在service-activity模块绑定与监听消息,处理缓存逻辑,更新状态位
//秒杀商品前缀
public static final String SECKILL_GOODS = "seckill:goods";
public static final String SECKILL_ORDERS = "seckill:orders";
public static final String SECKILL_ORDERS_USERS = "seckill:orders:users";
public static final String SECKILL_STOCK_PREFIX = "seckill:stock:";
public static final String SECKILL_USER = "seckill:user:";
//用户锁定时间 单位:秒
public static final int SECKILL__TIMEOUT = 60 * 60;
在service-activity
秒杀微服务中监听消息,批量导入秒杀商品到Redis完成缓存预热
package com.atguigu.gmall.activity.receiver;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.rabbit.config.MqConst;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author: atguigu
* @create: 2023-09-22 14:32
*/
@Slf4j
@Component
public class ActivityReceiver {
@Autowired
private SeckillGoodsService seckillGoodsService;
/**
* 监听定时任务发送秒杀预热消息;执行秒杀商品放入缓存
*
* @param msg
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
value = @Queue(value = MqConst.QUEUE_TASK_1, durable = "true"),
key = MqConst.ROUTING_TASK_1
))
public void processSeckill2Cache(String msg, Message message, Channel channel) {
if (StringUtils.isNotBlank(msg)) {
log.info("[秒杀服务]监听秒杀商品预热消息");
seckillGoodsService.processSeckill2Cache();
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
}
package com.atguigu.gmall.activity.service.impl;
import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.mapper.SeckillGoodsMapper;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.constant.RedisConst;
import com.atguigu.gmall.common.util.DateUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.BoundListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.Date;
import java.util.List;
import java.util.Stack;
/**
* 业务实现类
*
* @author atguigu
* @since 2023-09-22
*/
@Service
public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods> implements SeckillGoodsService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 查询秒杀表中当日满足秒杀商品列表;将秒杀商品放入缓存中:
* * 分布式缓存Redis Hash(秒杀商品信息) List(商品库存)
* * 本地缓存?
*/
@Override
public void processSeckill2Cache() {
//1.查询秒杀表中当日满足秒杀商品列表:当天、审核通过、库存数大于0
LambdaQueryWrapper<SeckillGoods> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SeckillGoods::getStatus, "1");
queryWrapper.gt(SeckillGoods::getStockCount, 0);
String today = DateUtil.formatDate(new Date());
queryWrapper.apply("DATE_FORMAT(start_time, '%Y-%m-%d') = '" + today + "'");
List<SeckillGoods> seckillGoodsList = this.list(queryWrapper);
//2.将商品信息跟商品库存放入分布式缓存Redis 以及 商品状态位放入本地缓存
if (!CollectionUtils.isEmpty(seckillGoodsList)) {
//2.1 创建当日秒杀商品hash结构绑定操作对象
String seckillHashKey = RedisConst.SECKILL_GOODS;
BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillHashKey);
//2.2 遍历秒杀商品集合 判断秒杀商品中是否包含该商品,如果包含不处理,反之放入缓存
for (SeckillGoods seckillGoods : seckillGoodsList) {
String hashKey = seckillGoods.getSkuId().toString();
if (seckillHashOps.hasKey(hashKey)) {
continue;
}
seckillHashOps.put(hashKey, seckillGoods);
//2.3 遍历过程中为当前商品生成库存List结构 放入元素(代表库存)
String stockKey = RedisConst.SECKILL_STOCK_PREFIX + hashKey;
BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockKey);
Integer stockCount = seckillGoods.getStockCount();
for (int i = 0; i < stockCount; i++) {
stockListOps.leftPush(hashKey);
}
//2.4 TODO 遍历过程中为当前商品“状态位”放入本地缓存
}
}
}
}
由于我们的秒杀服务时集群部署service-activity的,我们面临一个问题?RabbitMQ 如何实现对同一个应用的多个节点进行广播呢?
RabbitMQ 只能对绑定到交换机上面的不同队列实现广播,对于同一队列的消费者他们存在竞争关系,同一个消息只会被同一个队列下其中一个消费者接收,达不到广播效果;
我们目前的需求是定时任务发送消息,我们将秒杀商品导入缓存,同事更新集群的状态位,既然RabbitMQ 达不到广播的效果,我们就放弃吗?当然不是,我们很容易就想到一种解决方案,通过redis的发布订阅模式来通知其他兄弟节点,这不问题就解决了吗?
过程大致如下:
应用启动,多个节点监听同一个队列(此时多个节点是竞争关系,一条消息只会发到其中一个节点上)
消息生产者发送消息,同一条消息只被其中一个节点收到收到消息的节点通过redis的发布订阅模式来通知其他兄弟节点
接下来配置redis发布与订阅
参考资料:
https://www.runoob.com/redis/redis-pub-sub.html
package com.atguigu.gmall.activity.redis;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Configuration
public class RedisChannelConfig {
/*
docker exec -it bc92 redis-cli
subscribe seckillpush // 订阅 接收消息
publish seckillpush admin // 发布消息
*/
/**
* 注入订阅主题
* @param connectionFactory redis 链接工厂
* @param listenerAdapter 消息监听适配器
* @return 订阅主题对象
*/
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
//订阅主题 消费者定订阅
container.addMessageListener(listenerAdapter, new PatternTopic("seckillpush"));
//这个container 可以添加多个 messageListener
return container;
}
/**
* 返回消息监听器
* @param receiver 创建接收消息对象
* @return
*/
@Bean
MessageListenerAdapter listenerAdapter(MessageReceive receiver) {
//这个地方 是给 messageListenerAdapter 传入一个消息接受的处理器,利用反射的方法调用“receiveMessage”
//也有好几个重载方法,这边默认调用处理器的方法 叫handleMessage 可以自己到源码里面看
return new MessageListenerAdapter(receiver, "receiveSeckillGoodState");
}
@Bean //注入操作数据的template
StringRedisTemplate template(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
监听消息
package com.atguigu.gmall.activity.redis;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author: atguigu
* @create: 2023-09-22 16:39
*/
@Slf4j
@Component
public class MessageReceive {
@Autowired
private Cache<String, String> seckillGoodsCache;
/**
* 各个JVM节点监听到Redis话题中消息后执行业务:状态放入本地JVM缓存中
*
* @param state ""24:1""
*/
public void receiveSeckillGoodState(String state) {
if (StringUtils.isNotBlank(state)) {
log.info("[秒杀服务]监听到Redis话题中消息:{}", state);
state = state.replaceAll("\"", "");
String[] split = state.split(":");
if (split != null && split.length == 2) {
seckillGoodsCache.put(split[0], split[1]);
}
}
}
}
CacheHelper类本地缓存类(不考虑使用)
package com.atguigu.gmall.activity.util;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 系统缓存类
*/
public class CacheHelper {
/**
* 缓存容器
*/
private final static Map<String, Object> cacheMap = new ConcurrentHashMap<String, Object>();
/**
* 加入缓存
*
* @param key
* @param cacheObject
*/
public static void put(String key, Object cacheObject) {
cacheMap.put(key, cacheObject);
}
/**
* 获取缓存
* @param key
* @return
*/
public static Object get(String key) {
return cacheMap.get(key);
}
/**
* 清除缓存
*
* @param key
* @return
*/
public static void remove(String key) {
cacheMap.remove(key);
}
public static synchronized void removeAll() {
cacheMap.clear();
}
}
说明:
1,RedisChannelConfig 类配置redis监听的主题和消息处理器
2,MessageReceive 类为消息处理器,消息message为:商品id与状态位,如:1:1 表示商品id为1,状态位为1
使用Caffeine替换本地缓存
package com.atguigu.gmall.activity.config;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
/**
* @author: atguigu
* @create: 2023-09-22 15:58
*/
@Configuration
public class CacheConfig {
/**
* 用于缓存商品状态缓存对象
* Key:商品ID Val:0(已售罄)1(秒杀中)
*
* @return
*/
@Bean
public Cache<String, String> seckillGoodsCache() {
return Caffeine.newBuilder()
.maximumSize(200)
.expireAfterWrite(17, TimeUnit.HOURS)
.build();
}
}
监听已经配置好,接下来我就发布消息,更改秒杀监听器{ SeckillReceiver },如下
完整代码如下
/**
* 查询秒杀表中当日满足秒杀商品列表;将秒杀商品放入缓存中:
* * 分布式缓存Redis Hash(秒杀商品信息) List(商品库存)
* * 本地缓存?
*/
@Override
public void processSeckill2Cache() {
//1.查询秒杀表中当日满足秒杀商品列表:当天、审核通过、库存数大于0
LambdaQueryWrapper<SeckillGoods> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SeckillGoods::getStatus, "1");
queryWrapper.gt(SeckillGoods::getStockCount, 0);
String today = DateUtil.formatDate(new Date());
queryWrapper.apply("DATE_FORMAT(start_time, '%Y-%m-%d') = '" + today + "'");
List<SeckillGoods> seckillGoodsList = this.list(queryWrapper);
//2.将商品信息跟商品库存放入分布式缓存Redis 以及 商品状态位放入本地缓存
if (!CollectionUtils.isEmpty(seckillGoodsList)) {
//2.1 创建当日秒杀商品hash结构绑定操作对象
String seckillHashKey = RedisConst.SECKILL_GOODS;
BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillHashKey);
//2.2 遍历秒杀商品集合 判断秒杀商品中是否包含该商品,如果包含不处理,反之放入缓存
for (SeckillGoods seckillGoods : seckillGoodsList) {
String hashKey = seckillGoods.getSkuId().toString();
if (seckillHashOps.hasKey(hashKey)) {
continue;
}
seckillHashOps.put(hashKey, seckillGoods);
//2.3 遍历过程中为当前商品生成库存List结构 放入元素(代表库存)
String stockKey = RedisConst.SECKILL_STOCK_PREFIX + hashKey;
BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockKey);
Integer stockCount = seckillGoods.getStockCount();
for (int i = 0; i < stockCount; i++) {
stockListOps.leftPush(hashKey);
}
//2.4 TODO 遍历过程中为当前商品“状态位”,将商品状态位发送到Redis话题,将来所有秒杀服务都会订阅Redis话题
redisTemplate.convertAndSend("seckillpush", hashKey + ":1");
}
}
}
说明:到目前我们就实现了商品信息导入缓存,同时更新状态位的工作
YAPI接口地址:
SeckillGoodsController
package com.atguigu.gmall.activity.controller;
import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.result.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author: atguigu
* @create: 2023-07-01 10:53
*/
@RestController
@RequestMapping("/api/activity")
public class SeckillGoodsController {
@Autowired
private SeckillGoodsService seckillGoodsService;
/**
* 查询当日参与秒杀商品
*
* @return
*/
@GetMapping("/seckill/findAll")
public Result getSeckillGoods() {
List<SeckillGoods> list = seckillGoodsService.getSeckillGoods();
return Result.ok(list);
}
/**
* 查询秒杀商品详情
*
* @param skuId
* @return
*/
@GetMapping("/seckill/getSeckillGoods/{skuId}")
public Result getSecGoodsById(@PathVariable("skuId") Long skuId) {
SeckillGoods seckillGoods = seckillGoodsService.getSecGoodsById(skuId);
return Result.ok(seckillGoods);
}
}
package com.atguigu.gmall.activity.service;
import com.atguigu.gmall.activity.model.SeckillGoods;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
/**
* 业务接口类
* @author atguigu
* @since 2023-06-30
*/
public interface SeckillGoodsService extends IService<SeckillGoods> {
/**
* 查询当日参与秒杀商品
* @return
*/
List<SeckillGoods> getSeckillGoods();
/**
* 查询秒杀商品详情
* @param skuId
* @return
*/
SeckillGoods getSecGoodsById(Long skuId);
}
package com.atguigu.gmall.activity.service.impl;
import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.mapper.SeckillGoodsMapper;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.constant.RedisConst;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 业务实现类
*
* @author atguigu
* @since 2023-06-30
*/
@Service
public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods> implements SeckillGoodsService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 查询当日参与秒杀商品
*
* @return
*/
@Override
public List<SeckillGoods> getSeckillGoods() {
BoundHashOperations<String, String, SeckillGoods> seckillHashOps = getSeckillGoodsHashOpes();
return seckillHashOps.values();
}
/**
* 返回操作秒杀商品对象
*
* @return
*/
private BoundHashOperations<String, String, SeckillGoods> getSeckillGoodsHashOpes() {
String seckillKey = RedisConst.SECKILL_GOODS;
BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillKey);
return seckillHashOps;
}
/**
* 查询秒杀商品详情
*
* @param skuId
* @return
*/
@Override
public SeckillGoods getSecGoodsById(Long skuId) {
BoundHashOperations<String, String, SeckillGoods> hashOpes = getSeckillGoodsHashOpes();
String hashKey = skuId.toString();
Boolean flag = hashOpes.hasKey(hashKey);
if (flag) {
return hashOpes.get(hashKey);
}
return null;
}
}
远程调用Feign API接口
package com.atguigu.gmall.activity.client;
import com.atguigu.gmall.activity.client.impl.ActivityDegradeFeignClient;
import com.atguigu.gmall.common.result.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(value = "service-activity", path = "/api/activity", fallback = ActivityDegradeFeignClient.class)
public interface ActivityFeignClient {
/**
* 查询当日参与秒杀商品
*
* @return
*/
@GetMapping("/seckill/findAll")
public Result getSeckillGoods();
/**
* 查询秒杀商品详情
* @param skuId
* @return
*/
@GetMapping("/seckill/getSeckillGoods/{skuId}")
public Result getSecGoodsById(@PathVariable("skuId") Long skuId);
}
服务降级类
package com.atguigu.gmall.activity.client.impl;
import com.atguigu.gmall.activity.client.ActivityFeignClient;
import com.atguigu.gmall.common.result.Result;
import org.springframework.stereotype.Component;
/**
* @author: atguigu
* @create: 2023-07-01 10:59
*/
@Component
public class ActivityDegradeFeignClient implements ActivityFeignClient {
@Override
public Result getSeckillGoods() {
return null;
}
@Override
public Result getSecGoodsById(Long skuId) {
return null;
}
}
在 web-all 项目中添加控制器
package com.atguigu.gmall.web.controller;
import com.atguigu.gmall.activity.client.ActivityFeignClient;
import com.atguigu.gmall.common.result.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
/**
* @author: atguigu
* @create: 2023-07-01 11:00
*/
@Controller
public class SeckillHtmlController {
@Autowired
private ActivityFeignClient activityFeignClient;
/**
* 秒杀商品列表页
*
* @param model
* @return
*/
@GetMapping("/seckill.html")
public String seckillListHtml(Model model) {
Result result = activityFeignClient.getSeckillGoods();
model.addAttribute("list", result.getData());
return "seckill/index";
}
/**
* 秒杀详情页
*
* @param skuId
* @return
*/
@GetMapping("/seckill/{skuId}.html")
public String seckillItemHtml(Model model, @PathVariable("skuId") Long skuId) {
Result result = activityFeignClient.getSecGoodsById(skuId);
model.addAttribute("item", result.getData());
return "seckill/item";
}
}
列表
页面资源: \templates\seckill\index.html
<div class="goods-list" id="item">
<ul class="seckill" id="seckill">
<li class="seckill-item" th:each="item: ${list}">
<div class="pic" th:@click="|detail(${item.skuId})|">
<img th:src="${item.skuDefaultImg}" alt=''>
</div>
<div class="intro">
<span th:text="${item.skuName}">手机</span>
</div>
<div class='price'>
<b class='sec-price' th:text="'¥'+${item.costPrice}">¥0</b>
<b class='ever-price' th:text="'¥'+${item.price}">¥0</b>
</div>
<div class='num'>
<div th:text="'已售'+${item.num}">已售1</div>
<div class='progress'>
<div class='sui-progress progress-danger'>
<span style='width: 70%;' class='bar'></span>
</div>
</div>
<div>剩余
<b class='owned' th:text="${item.stockCount}">0</b>件</div>
</div>
<a class='sui-btn btn-block btn-buy' th:href="'/seckill/'+${item.skuId}+'.html'" target='_blank'>立即抢购</a>
</li>
</ul>
</div>
说明:
1,立即购买,该按钮我们要加以控制,该按钮就是一个链接,页面只是控制能不能点击,一般用户可以绕过去,直接点击秒杀下单,所以我们要加以控制,在秒杀没有开始前,不能进入秒杀页面
SeckillHtmlController
/**
* 秒杀下单排队页面(请求1:预下单请求发起 请求2:定时查询状态)
*
* @param model
* @param skuId
* @param bugCode
* @return
*/
@GetMapping("/seckill/queue.html")
public String queueHtml(Model model, @RequestParam("skuId") Long skuId, @RequestParam("skuIdStr") String bugCode) {
model.addAttribute("skuId", skuId);
model.addAttribute("skuIdStr", bugCode);
return "/seckill/queue";
}
<div class="product-info">
<div class="fl preview-wrap">
<!--放大镜效果-->
<div class="zoom">
<!--默认第一个预览-->
<div id="preview" class="spec-preview">
<span class="jqzoom"><img th:jqimg="${item.skuDefaultImg}" th:src="${item.skuDefaultImg}" width="400" height="400"/></span>
</div>
</div>
</div>
<div class="fr itemInfo-wrap">
<div class="sku-name">
<h4 th:text="${item.skuName}">三星</h4>
</div>
<div class="news">
<span><img src="/img/_/clock.png"/>品优秒杀</span>
<span class="overtime">{{timeTitle}}:{{timeString}}</span>
</div>
<div class="summary">
<div class="summary-wrap">
<div class="fl title">
<i>秒杀价</i>
</div>
<div class="fl price">
<i>¥</i>
<em th:text="${item.costPrice}">0</em>
<span th:text="'原价:'+${item.price}">原价:0</span>
</div>
<div class="fr remark">
剩余库存:<span th:text="${item.stockCount}">0</span>
</div>
</div>
<div class="summary-wrap">
<div class="fl title">
<i>促 销</i>
</div>
<div class="fl fix-width">
<i class="red-bg">加价购</i>
<em class="t-gray">满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品</em>
</div>
</div>
</div>
<div class="support">
<div class="summary-wrap">
<div class="fl title">
<i>支 持</i>
</div>
<div class="fl fix-width">
<em class="t-gray">以旧换新,闲置手机回收 4G套餐超值抢 礼品购</em>
</div>
</div>
<div class="summary-wrap">
<div class="fl title">
<i>配 送 至</i>
</div>
<div class="fl fix-width">
<em class="t-gray">满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品</em>
</div>
</div>
</div>
<div class="clearfix choose">
<div class="summary-wrap">
<div class="fl title">
</div>
<div class="fl">
<ul class="btn-choose unstyled">
<li>
<a href="javascript:" v-if="isBuy" @click="queue()" class="sui-btn btn-danger addshopcar">立即抢购</a>
<a href="javascript:" v-if="!isBuy" class="sui-btn btn-danger addshopcar" disabled="disabled">立即抢购</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
思路:页面初始化时,拿到商品秒杀开始时间和结束时间等信息,实现距离开始时间和活动倒计时。
注意:当前系统时间从后端单独时间服务器获取当前时间.作为判断商品秒杀开始依据.
活动未开始时,显示距离开始时间倒计时;
活动开始后,显示活动结束时间倒计时。
倒计时代码片段
init() {
// debugger
// 计算出剩余时间
var startTime = new Date(this.data.startTime).getTime();
var endTime = new Date(this.data.endTime).getTime();
var nowTime = new Date().getTime(); //存在问题的 实际当前系统时间,从时间服务器获取统一时间标准
var secondes = 0;
// 还未开始抢购
if(startTime > nowTime) {
this.timeTitle = '距离开始'
secondes = Math.floor((startTime - nowTime) / 1000);
}
if(nowTime > startTime && nowTime < endTime) {
this.isBuy = true
this.timeTitle = '距离结束'
secondes = Math.floor((endTime - nowTime) / 1000);
}
if(nowTime > endTime) {
this.timeTitle = '抢购结束'
secondes = 0;
}
const timer = setInterval(() => {
secondes = secondes - 1
this.timeString = this.convertTimeString(secondes)
}, 1000);
// 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
this.$once('hook:beforeDestroy', () => {
clearInterval(timer);
})
},
时间转换方法
convertTimeString(allseconds) {
if(allseconds <= 0) return '00:00:00'
// 计算天数
var days = Math.floor(allseconds / (60 * 60 * 24));
// 小时
var hours = Math.floor((allseconds - (days * 60 * 60 * 24)) / (60 * 60));
// 分钟
var minutes = Math.floor((allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60)) / 60);
// 秒
var seconds = allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60) - (minutes * 60);
//拼接时间
var timString = "";
if (days > 0) {
timString = days + "天:";
}
return timString += hours + ":" + minutes + ":" + seconds;
}
在进入秒杀功能前,我们加一个下单码,只有你获取到该下单码,才能够进入秒杀方法进行秒杀
避免:用户提前知悉秒杀地址,通过恶意刷单软件进行非法下单。下单码生成条件:本地缓存中商品状态必须“1”;当前用户购买商品在销售时间内。
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/531
SeckilBizController
package com.atguigu.gmall.activity.controller;
import com.atguigu.gmall.activity.service.SeckillBizService;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.common.util.AuthContextHolder;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* @author: atguigu
* @create: 2023-08-16 10:51
*/
@RestController
@RequestMapping("/api/activity/seckill/auth")
public class SeckilBizController {
@Autowired
private SeckillBizService seckillBizService;
/**
* 为用户秒杀指定商品生成抢购码
*
* @param skuId
* @return
*/
@ApiOperation("为用户秒杀指定商品生成抢购码")
@GetMapping("/auth/getSeckillSkuIdStr/{skuId}")
public Result getSeckillSkuIdStr(HttpServletRequest request, @PathVariable("skuId") Long skuId) {
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
throw new RuntimeException("请先登录");
}
String buyCode = seckillBizService.getSeckillSkuIdStr(userId, skuId);
return Result.ok(buyCode);
}
}
SeckillBizService
package com.atguigu.gmall.activity.service;
public interface SeckillBizService {
/**
* 为用户秒杀指定商品生成抢购码
*
* @param userId
* @param skuId
* @return
*/
String getSeckillSkuIdStr(String userId, Long skuId);
}
SeckillBizServiceImpl
package com.atguigu.gmall.activity.service.impl;
import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.service.SeckillBizService;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.util.MD5;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
* @author: atguigu
* @create: 2023-08-16 10:55
*/
@Service
public class SeckillBizServiceImpl implements SeckillBizService {
@Autowired
private Cache<String, String> seckillGoodsCache;
@Autowired
private SeckillGoodsService seckillGoodsService;
@Autowired
private SeckillGoodsService seckillGoodsService;
/**
* TODO:将盐放入配置文件,动态读取动态刷新
*/
private static final String salt = "atguigu..";
/**
* 为用户秒杀指定商品生成抢购码
*
* @param userId
* @param skuId
* @return
*/
@Override
public String getSeckillSkuIdStr(String userId, Long skuId) {
//1.验证本地缓存中商品状态位
String status = seckillGoodsCache.getIfPresent(skuId.toString());
if (StringUtils.isBlank(status)) {
throw new RuntimeException("商品不存在");
}
if ("0".equals(status)) {
throw new RuntimeException("商品已售罄");
}
if ("1".equals(status)) {
//2.判断商品是否在销售时间内
//2.1 查询Redis中秒杀商品信息获取开始、结束时间
SeckillGoods seckillGoods = seckillGoodsService.getSecGoodsById(skuId);
if (seckillGoods != null) {
Date startTime = seckillGoods.getStartTime();
Date endTime = seckillGoods.getEndTime();
//2.2 判断时间是否符合要求
Date now = new Date();
if (now.getTime() > startTime.getTime() && now.getTime() < endTime.getTime()) {
//3.按照指定加密规则生成抢购码 规则:md5(userId+skuId+salt)
String buyCode = MD5.encrypt(userId + skuId + salt);
return buyCode;
} else {
throw new RuntimeException("秒杀商品不在销售时间内!");
}
}
}
throw new RuntimeException("生成抢购码有误!");
}
}
说明:只有在商品秒杀时间范围内,才能获取下单码,这样我们就有效控制了用户非法秒杀,下单码我们可以根据业务自定义规则,目前我们定义为当前用户id MD5加密。
页面获取下单码,进入秒杀场景
queue() {
seckill.getSeckillSkuIdStr(this.skuId).then(response => {
var skuIdStr = response.data.data
window.location.href = '/seckill/queue.html?skuId='+this.skuId+'&skuIdStr='+skuIdStr
})
},
前端js完整代码如下
<script src="/js/api/seckill.js"></script>
<script th:inline="javascript">
var item = new Vue({
el: '#item',
data: {
skuId: [[${item.skuId}]],
data: [[${item}]],
timeTitle: '距离开始',
timeString: '00:00:00',
isBuy: false
},
created() {
this.init()
},
methods: {
init() {
// debugger
// 计算出剩余时间
var startTime = new Date(this.data.startTime).getTime();
var endTime = new Date(this.data.endTime).getTime();
var nowTime = new Date().getTime();
var secondes = 0;
// 还未开始抢购
if(startTime > nowTime) {
this.timeTitle = '距离开始'
secondes = Math.floor((startTime - nowTime) / 1000);
}
if(nowTime > startTime && nowTime < endTime) {
this.isBuy = true
this.timeTitle = '距离结束'
secondes = Math.floor((endTime - nowTime) / 1000);
}
if(nowTime > endTime) {
this.timeTitle = '抢购结束'
secondes = 0;
}
const timer = setInterval(() => {
secondes = secondes - 1
this.timeString = this.convertTimeString(secondes)
}, 1000);
// 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
this.$once('hook:beforeDestroy', () => {
clearInterval(timer);
})
},
queue() {
seckill.getSeckillSkuIdStr(this.skuId).then(response => {
var skuIdStr = response.data.data
window.location.href = '/seckill/queue.html?skuId='+this.skuId+'&skuIdStr='+skuIdStr
})
},
convertTimeString(allseconds) {
if(allseconds <= 0) return '00:00:00'
// 计算天数
var days = Math.floor(allseconds / (60 * 60 * 24));
// 小时
var hours = Math.floor((allseconds - (days * 60 * 60 * 24)) / (60 * 60));
// 分钟
var minutes = Math.floor((allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60)) / 60);
// 秒
var seconds = allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60) - (minutes * 60);
//拼接时间
var timString = "";
if (days > 0) {
timString = days + "天:";
}
return timString += hours + ":" + minutes + ":" + seconds;
}
}
})
</script>
web-all
模块中SeckillHtmlController
/**
* 秒杀排队页面
*
* @param model
* @param skuId
* @param skuIdStr
* @return
*/
@GetMapping("/seckill/queue.html")
public String seckillQueueHtml(Model model, @RequestParam("skuId") Long skuId, @RequestParam("skuIdStr") String skuIdStr) {
model.addAttribute("skuId", skuId);
model.addAttribute("skuIdStr", skuIdStr);
return "/seckill/queue";
}
页面
页面资源: \templates\seckill\queue.html
<div class="cart py-container" id="item">
<div class="seckill_dev" v-if="show == 1">
排队中...
</div>
<div class="seckill_dev" v-if="show == 2">
{{message}}
</div>
<div class="seckill_dev" v-if="show == 3">
抢购成功
<a href="/seckill/trade.html" target="_blank">去下单</a>
</div>
<div class="seckill_dev" v-if="show == 4">
抢购成功
<a href="/myOrder.html" target="_blank">我的订单</a>
</div>
</div>
Js部分
<script src="/js/api/seckill.js"></script>
<script th:inline="javascript">
var item = new Vue({
el: '#item',
data: {
skuId: [[${skuId}]],
skuIdStr: [[${skuIdStr}]],
data: {},
show: 1,
code: 211,
message: '',
isCheckOrder: false
},
mounted() {
const timer = setInterval(() => {
if(this.code != 211) {
clearInterval(timer);
}
this.checkOrder()
}, 3000);
// 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
this.$once('hook:beforeDestroy', () => {
clearInterval(timer);
})
},
created() {
this.saveOrder();
},
methods: {
saveOrder() {
seckill.seckillOrder(this.skuId, this.skuIdStr).then(response => {
debugger
console.log(JSON.stringify(response))
if(response.data.code == 200) {
this.isCheckOrder = true
} else {
this.show = 2
this.message = response.data.message
}
})
},
checkOrder() {
if(!this.isCheckOrder) return
seckill.checkOrder(this.skuId).then(response => {
debugger
this.data = response.data.data
this.code = response.data.code
console.log(JSON.stringify(this.data))
//排队中
if(response.data.code == 211) {
this.show = 1
} else {
//秒杀成功
if(response.data.code == 215) {
this.show = 3
this.message = response.data.message
} else {
if(response.data.code == 218) {
this.show = 4
this.message = response.data.message
} else {
this.show = 2
this.message = response.data.message
}
}
}
})
}
}
})
</script>
说明:该页面直接通过controller返回页面,进入页面后显示排队中,然后通过异步执行秒杀下单,提交成功,页面通过轮询后台方法查询秒杀状态
秒杀的主要目的就是获取一个下单资格,拥有下单资格就可以去下单支付,获取下单资格后的流程就与正常下单流程一样,只是没有购物车这一步,总结起来就是,秒杀根据库存获取下单资格,拥有下单资格进入下单页面(选择地址,支付方式,提交订单,然后支付订单)
步骤:
1,校验抢购码,只有正确获得抢购码的请求才是合法请求
2,校验状态位state,
State为null,说明非法请求;
State为0说明已经售罄;
State为1,说明可以抢购
状态位是在内存中判断,效率极高,如果售罄,直接就返回了,不会给服务器造成太大压力
3,前面条件都成立,将秒杀用户加入队列,然后直接返回
4,前端轮询秒杀状态,查询秒杀结果
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/515
/**
* 秒杀
*/
public static final String EXCHANGE_DIRECT_SECKILL_USER = "exchange.direct.seckill.user";
public static final String ROUTING_SECKILL_USER = "seckill.user";
//队列
public static final String QUEUE_SECKILL_USER = "queue.seckill.user";
记录哪个用户要购买哪个商品!
@Data
public class UserRecode implements Serializable {
private static final long serialVersionUID = 1L;
private Long skuId;
private String userId;
}
SeckilBizController
/**
* 秒杀入队,将用户秒杀请求放入MQ队列中
*
* @param request
* @param skuId
* @param buyCode
* @return
*/
@ApiOperation("秒杀请求入队")
@PostMapping("/auth/seckillOrder/{skuId}")
public Result seckillRequest2Queue(HttpServletRequest request, @PathVariable("skuId") Long skuId, @RequestParam("skuIdStr") String buyCode) {
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
throw new RuntimeException("请先登录");
}
seckillBizService.seckillRequest2Queue(userId, skuId, buyCode);
return Result.ok();
}
SeckillBizService
/**
* 秒杀入队,将用户秒杀请求放入MQ队列中
*
* @param userId
* @param skuId
* @param buyCode
* @return
*/
void seckillRequest2Queue(String userId, Long skuId, String buyCode);
SeckillBizServiceImpl
@Autowired
private RabbitService rabbitService;
/**
* 秒杀入队,将用户秒杀请求放入MQ队列中
*
* @param userId
* @param skuId
* @param buyCode
* @return
*/
@Override
public void seckillRequest2Queue(String userId, Long skuId, String buyCode) {
//1.验证提交抢购码是否正确
//1.1 按照生成抢购码规则再次生成抢购码
String serverBuyCode = MD5.encrypt(userId + skuId + salt);
//1.2 跟用户提交进行比较
if (!serverBuyCode.equals(buyCode)) {
throw new RuntimeException(ResultCodeEnum.ILLEGAL_REQUEST.getMessage());
}
//2.验证本地缓存中商品状态位是否为1
String status = seckillGoodsCache.getIfPresent(skuId.toString());
if (StringUtils.isBlank(status)) {
throw new RuntimeException("商品不存在");
}
if ("0".equals(status)) {
throw new RuntimeException("商品已售罄");
}
if ("1".equals(status)) {
//3.验证用户是否重复下单(重复购买)
//3.1 构建用户秒杀订单Key
String secUserOrderKey = RedisConst.SECKILL_ORDERS_USERS;
//3.2 创建绑定hash操作对象 key:当日秒杀订单记录 hashKey:用户ID+商品ID hashVal:订单ID
BoundHashOperations<String, String, Long> seckUserOrderHashOps = redisTemplate.boundHashOps(secUserOrderKey);
//3.3 判断用户是否已经提交订单
String hashKey = userId + ":" + skuId.toString();
if (seckUserOrderHashOps.hasKey(hashKey)) {
throw new RuntimeException("请勿重复下单!");
}
//4.将用户秒杀意向封装秒杀请求对象(用户ID,商品ID,商品数量),将秒杀请求放入消息队列
UserRecode userRecode = new UserRecode();
userRecode.setUserId(userId);
userRecode.setSkuId(skuId);
userRecode.setNum(1);
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_SECKILL_USER, MqConst.ROUTING_SECKILL_USER, userRecode);
}
}
思路:
1,首先判断产品状态位,我们前面不是已经判断过了吗?因为产品可能随时售罄,mq队列里面可能堆积了十万数据,但是已经售罄了,那么后续流程就没有必要再走了;
2,判断用户是否已经下过订单,这个地方就是控制用户重复下单,同一个用户只能抢购一个下单资格,怎么控制呢?很简单,我们可以利用setnx控制用户,当用户第一次进来时,返回true,可以抢购,以后进入返回false,直接返回,过期时间可以根据业务自定义,这样用户这一段咋们就控制注了
3,获取队列中的商品,如果能够获取,则商品有库存,可以下单。如果获取的商品id为空,则商品售罄,商品售罄我们要第一时间通知兄弟节点,更新状态位,所以在这里发送redis广播
4,将订单记录放入redis缓存,说明用户已经获得下单资格,秒杀成功
5,秒杀成功要更新库存
/**
* 监听秒杀队列中请求,完成秒杀核心业务处理
*
* @param userRecode
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_SECKILL_USER, durable = "true"),
value = @Queue(value = MqConst.QUEUE_SECKILL_USER, durable = "true"),
key = MqConst.ROUTING_SECKILL_USER
))
public void processSeckillRequest(UserRecode userRecode, Message message, Channel channel) {
if (userRecode != null) {
log.info("[秒杀服务]监听秒杀请求{}", userRecode);
seckillBizService.processSeckillRequest(userRecode);
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
SeckillBizService
/**
* 处理用户秒杀请求
*
* @param userRecode 包含:userId,skuId,num
*/
void processSeckillRequest(UserRecode userRecode);
秒杀订单实体类
package com.atguigu.gmall.model.activity;
@Data
public class OrderRecode implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private SeckillGoods seckillGoods;
private Integer num;
private String orderStr;
}
雪花算法配置类
package com.atguigu.gmall.activity.config;
import com.atguigu.gmall.common.util.SnowFlake;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: atguigu
* @create: 2023-06-25 15:27
*/
@Configuration
public class SnowFlakeConfig {
@Bean
public SnowFlake snowFlake(){
return new SnowFlake(1,1,1);
}
}
SeckillBizServiceImpl
/**
* 监听秒杀队列中请求,完成秒杀核心业务处理
*
* @param userRecode 包含:userId,skuId,num
*/
@Override
public void processSeckillRequest(UserRecode userRecode) {
//1.消息幂等性处理(set nx成功正在排队-有机会获取秒杀资格)
String requeueKey = "seckill:requeue:" + userRecode.getUserId() + ":" + userRecode.getSkuId();
//TODO 动态计算保存时间,秒杀商品结束时间-当前时间
Boolean requeue = redisTemplate.opsForValue().setIfAbsent(requeueKey, userRecode.getSkuId().toString(), 10, TimeUnit.HOURS);
if (!requeue) {
log.error("【秒杀服务】用户重复进行排队:{}", userRecode);
return;
}
//2.验证本地缓存中商品状态位
String status = seckillGoodsCache.getIfPresent(userRecode.getSkuId().toString());
if (null == status || "0".equals(status) || "2".equals(status)) {
log.error("【秒杀服务】商品已售罄");
return;
}
//3.验证用户是否已经下过单(禁止用户重复下单)
String secUserOrderKey = RedisConst.SECKILL_ORDERS_USERS;
//3.2 创建绑定hash操作对象 key:当日秒杀订单记录 hashKey:用户ID+商品ID hashVal:订单ID
BoundHashOperations<String, String, Long> seckUserOrderHashOps = redisTemplate.boundHashOps(secUserOrderKey);
//3.3 判断用户是否已经提交订单
String hashKey = userRecode.getUserId() + ":" + userRecode.getSkuId().toString();
if (seckUserOrderHashOps.hasKey(hashKey)) {
return;
}
//4.验证库存(利用Redis命令原子性-List 避免超卖)
String seckillStockListKey = RedisConst.SECKILL_STOCK_PREFIX + userRecode.getSkuId();
BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(seckillStockListKey);
String stock = stockListOps.rightPop();
//如果没库存-发送Redis发布订阅消息,通知所有秒杀节点更新本地缓存商品状态位
if (StringUtils.isBlank(stock)) {
redisTemplate.convertAndSend("seckillpush", userRecode.getSkuId() + ":0");
return;
}
//5.为获取秒杀资格用户产生临时订单-存入Redis-Hash 获取到秒杀资格
OrderRecode tempOrder = new OrderRecode();
tempOrder.setUserId(userRecode.getUserId());
tempOrder.setNum(userRecode.getNum());
tempOrder.setSeckillGoods(seckillGoodsService.getSecGoodsById(userRecode.getSkuId()));
//5.1 生成将来用户保存秒杀订单:订单编号 SEC+年日月+雪花算法
String today = DateUtil.formatDate(new Date());
String outTradeNo = "SEC" + today + snowFlake.nextId();
tempOrder.setOrderStr(outTradeNo);
String tempOrderKey = RedisConst.SECKILL_ORDERS;
String tempOrderHashKey = userRecode.getUserId() + ":" + userRecode.getSkuId();
BoundHashOperations<String, String, OrderRecode> tempOrderHashOps = redisTemplate.boundHashOps(tempOrderKey);
tempOrderHashOps.put(tempOrderHashKey, tempOrder);
//5.2 TODO 发送延迟消息通知订单服务延迟关闭秒杀订单-秒杀服务判断订单状态进行恢复秒杀库存
//6.MQ异步通知秒杀服务-更新秒杀库存(数据最终一致)
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_SECKILL_STOCK, MqConst.ROUTING_SECKILL_STOCK, userRecode);
}
MqConst中增加常量
/**
* 秒杀减库存
*/
public static final String EXCHANGE_DIRECT_SECKILL_STOCK = "exchange.direct.seckill.stock";
public static final String ROUTING_SECKILL_STOCK = "seckill.stock";
//队列
public static final String QUEUE_SECKILL_STOCK = "queue.seckill.stock";
监听秒杀减库存:
ActivityReceiver
/**
* 监听扣减秒杀库存消息,更新Redis中库存数量,以及MySQL中库存数量
*
* @param userRecode
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_SECKILL_STOCK, durable = "true"),
value = @Queue(value = MqConst.QUEUE_SECKILL_STOCK, durable = "true"),
key = MqConst.ROUTING_SECKILL_STOCK
))
public void processStockDeduct(UserRecode userRecode, Message message, Channel channel) {
if (userRecode != null) {
log.info("[秒杀服务]监听扣减库存{}", userRecode);
seckillBizService.processStockDeduct(userRecode);
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
SeckillBizService
/**
* 更新秒杀库存
* @param userRecode
*/
void processStockDeduct(UserRecode userRecode);
SeckillBizServiceImpl
/**
* 更新秒杀库存
* 需要做幂等性处理,需要事务管理
*
* @param userRecode
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void processStockDeduct(UserRecode userRecode) {
//1.幂等性处理
String skuId = userRecode.getSkuId().toString();
String requeueKey = "seckill:stock:" + userRecode.getUserId() + ":" + skuId;
Boolean requeue = redisTemplate.opsForValue().setIfAbsent(requeueKey, skuId.toString(), 10, TimeUnit.HOURS);
if (!requeue) {
return;
}
//2.更新Redis中Hash秒杀商品库存
//2.1 查询指定商品库存List长度-商品剩余库存
String stockKey = RedisConst.SECKILL_STOCK_PREFIX + skuId;
BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockKey);
Long stockCount = stockListOps.size();
//2.2 更新Redis中商品库存
String seckillHashKey = RedisConst.SECKILL_GOODS;
BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillHashKey);
SeckillGoods seckillGoods = seckillHashOps.get(skuId);
seckillGoods.setStockCount(stockCount.intValue());
seckillHashOps.put(skuId.toString(), seckillGoods);
//3.更新数据库秒杀商品库存
seckillGoodsService.updateById(seckillGoods);
}
思路:
判断用户是否在缓存中存在
判断用户是否抢单成功
判断用户是否下过订单
判断状态位
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/507
SeckilBizController
/**
* 处理前端定时查询秒杀结果
* @param skuId
* @return
*/
@ApiOperation("检查用户秒杀结果")
@GetMapping("/auth/checkOrder/{skuId}")
public Result checkSeckillStatus(HttpServletRequest request, @PathVariable("skuId") Long skuId){
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
throw new RuntimeException("请先登录");
}
return seckillBizService.checkSeckillStatus(userId, skuId);
}
SeckillBizService接口
/**
* 处理前端定时查询秒杀结果
* @param skuId
* @return
*/
Result checkSeckillStatus(String userId, Long skuId);
SeckillBizServiceImpl
/**
* 处理前端定时查询秒杀结果
*
* @param skuId
* @return
*/
@Override
public Result checkSeckillStatus(String userId, Long skuId) {
//1.先判断当前用户秒杀指定商品是否正在排队(秒杀请求是否被程序处理中)
String requeueKey = "seckill:requeue:" + userId + ":" + skuId;
//1.1 如果在排队中,优先查询用户是否产生秒杀订单-从秒杀订单hash中判断 响应218
Boolean requeue = redisTemplate.hasKey(requeueKey);
if (requeue) {
String orderHashKey = RedisConst.SECKILL_ORDERS_USERS;
BoundHashOperations<String, String, Long> orderHashOps = redisTemplate.boundHashOps(orderHashKey);
String hashKey = userId + ":" + skuId;
Boolean hasOrder = orderHashOps.hasKey(hashKey);
if (hasOrder) {
Long seckillOrderId = orderHashOps.get(hashKey);
return Result.build(seckillOrderId, ResultCodeEnum.SECKILL_ORDER_SUCCESS);
}
//1.2 如果在排队中,没有产生秒杀订单,再判断用户是否产生临时订单-从临时订单hash中判断 响应215
String orderTempHashKey = RedisConst.SECKILL_ORDERS;
BoundHashOperations<String, String, OrderRecode> orderTempHashOps = redisTemplate.boundHashOps(orderTempHashKey);
Boolean hasTempOrder = orderTempHashOps.hasKey(hashKey);
if (hasTempOrder) {
OrderRecode orderRecode = orderTempHashOps.get(hashKey);
return Result.build(orderRecode, ResultCodeEnum.SECKILL_SUCCESS);
}
//1.3 没有以上两个hash都没有用户记录,返回排队中 响应211
return Result.build(null, ResultCodeEnum.SECKILL_RUN);
}
//2.如果用户未排队且商品状态位已售罄 响应213
String status = seckillGoodsCache.getIfPresent(skuId.toString());
if (!requeue && "0".equals(status)) {
return Result.build(null, ResultCodeEnum.SECKILL_FINISH);
}
return Result.build(null, ResultCodeEnum.SECKILL_RUN);
}
该页面有四种状态:
1,排队中
2,各种提示(非法、已售罄等)
3,抢购成功,去下单
4,抢购成功,已下单,显示我的订单
抢购成功,页面显示去下单,跳转下单确认页面
<div class="seckill_dev" v-if="show == 3">
抢购成功
<a href="/seckill/trade.html" target="_blank">去下单</a>
</div>
我们已经把下单信息记录到redis缓存中,所以接下来我们要组装下单页数据
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/523
SeckilBizController
/**
* 汇总秒杀确认页面相关数据
*
* @param request
* @param skuId
* @return
*/
@ApiOperation("汇总秒杀确认页面相关数据")
@GetMapping("/auth/trade")
public Result seckillTradeData(HttpServletRequest request, @RequestParam("skuId") Long skuId) {
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
throw new RuntimeException("请先登录");
}
Map<String, Object> mapResult = seckillBizService.seckillTradeData(userId, skuId);
return Result.ok(mapResult);
}
SeckillBizService
/**
* 汇总秒杀确认页面相关数据
* @param userId
* @param skuId
* @return
*/
Map<String, Object> seckillTradeData(String userId, Long skuId);
SeckillBizServiceImpl
/**
* 汇总秒杀确认页面相关数据
*
* @param userId
* @param skuId
* @return ${userAddressList}:地址列表
* ${detailArrayList}:订单明细列表
* ${totalNum}:总数
* ${totalAmount}:总金额
*/
@Override
public Map<String, Object> seckillTradeData(String userId, Long skuId) {
Map<String, Object> mapResult = new HashMap<>();
//1.远程调用用户微服务获取收件地址列表
List<UserAddress> userAddressList = userFeignClient.findUserAddressListByUserId(Long.valueOf(userId));
if (!CollectionUtils.isEmpty(userAddressList)) {
mapResult.put("userAddressList", userAddressList);
}
//2.查询Redis中当前用户产生临时订单数据-得到秒杀商品信息
String orderTempHashKey = RedisConst.SECKILL_ORDERS;
BoundHashOperations<String, String, OrderRecode> orderTempHashOps = redisTemplate.boundHashOps(orderTempHashKey);
String hashKey = userId + ":" + skuId;
Boolean hasTempOrder = orderTempHashOps.hasKey(hashKey);
if (hasTempOrder) {
//2.1 查询秒杀商品信息
OrderRecode orderRecode = orderTempHashOps.get(hashKey);
//2.2 将秒杀商品信息转为订单明细
OrderDetail orderDetail = new OrderDetail();
SeckillGoods seckillGoods = orderRecode.getSeckillGoods();
orderDetail.setSkuId(seckillGoods.getSkuId());
orderDetail.setSkuName(seckillGoods.getSkuName());
orderDetail.setImgUrl(seckillGoods.getSkuDefaultImg());
orderDetail.setSkuNum(orderRecode.getNum());
orderDetail.setOrderPrice(seckillGoods.getCostPrice());
List<OrderDetail> orderDetailList = Arrays.asList(orderDetail);
mapResult.put("detailArrayList", orderDetailList);
mapResult.put("totalAmount", seckillGoods.getCostPrice());
mapResult.put("totalNum", orderRecode.getNum());
}
return mapResult;
}
ActivityFeignClient
package com.atguigu.gmall.activity.client;
import com.atguigu.gmall.activity.client.impl.ActivityDegradeFeignClient;
import com.atguigu.gmall.common.result.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.Map;
@FeignClient(value = "service-activity", path = "/api/activity", fallback = ActivityDegradeFeignClient.class)
public interface ActivityFeignClient {
/**
* 汇总秒杀确认页面相关数据
*
* @param skuId
* @return
*/
@ApiOperation("汇总秒杀确认页面相关数据")
@GetMapping("/auth/trade")
public Result<Map> seckillTradeData(@RequestParam("skuId") Long skuId);
}
ActivityDegradeFeignClient服务降级方法
@Override
public Result<Map> seckillTradeData(Long skuId) {
return null;
}
SeckillHtmlController
/**
* 秒杀订单确认页面渲染
* @param model
* @param skuId
* @return
*/
@GetMapping("/seckill/trade.html")
public String seckillTradeHtml(Model model, @RequestParam("skuId") Long skuId){
Result<Map> result = activityFeignClient.seckillTradeData(skuId);
model.addAllAttributes(result.getData());
return "/seckill/trade";
}
该页面与正常下单页面类似,只是下单提交接口不一样,因为秒杀下单不需要正常下单的各种判断,因此我们要在订单服务提供一个秒杀下单接口,直接下单
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/795
OrderApiController
/**
* 提交秒杀订单
*
* @param orderInfo
* @return
*/
@ApiOperation("提交秒杀订单")
@PostMapping("/inner/seckill/submitOrder")
public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo) {
return orderInfoService.submitSeckillOrder(orderInfo);
}
OrderInfoService
/**
* 保存秒杀订单
* @param orderInfo
* @return
*/
Long submitSeckillOrder(OrderInfo orderInfo);
OrderInfoServiceImpl
/**
* 保存秒杀订单
*
* @param orderInfo
* @return
*/
@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
//1.根据订单编号(秒杀订单编号)查询订单记录
LambdaQueryWrapper<OrderInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(OrderInfo::getOutTradeNo, orderInfo.getOutTradeNo());
int count = this.count(queryWrapper);
if (count == 0) {
//2.如果订单不存在则新增秒杀订单
Long orderId = this.saveOrderInfo(orderInfo, "2");
return orderId;
}
return null;
}
/**
* 保存订单及订单明细
*
* @param orderInfo
* @param orderType 订单类型 1:商城订单 2:秒杀订单
* @return
*/
@Override
public Long saveOrderInfo(OrderInfo orderInfo, String orderType) {
//1.构建订单对象,保存订单
//1.1 订单状态(待支付状态)
orderInfo.setOrderStatus(ProcessStatus.UNPAID.name());
orderInfo.setProcessStatus(ProcessStatus.UNPAID.name());
//1.2 订单业务相关时间
Date now = new Date();
orderInfo.setOperateTime(now);
//设置取消订单 一小时未支付订单,将订单关闭(基于延迟消息实现延迟关单)
Calendar calendar = Calendar.getInstance();
if ("1".equals(orderType)) {
calendar.add(Calendar.HOUR, 1);
//1.3 生成订单唯一编号(全局唯一) 形式:SPH+年月日+雪花算法
String today = DateUtil.formatDate(now).replaceAll("-", "");
String outTradeNo = "SPH" + today + snowFlake.nextId();
orderInfo.setOutTradeNo(outTradeNo);
} else if ("2".equals(orderType)) {
calendar.add(Calendar.MINUTE, 5);
}
orderInfo.setExpireTime(calendar.getTime());
//设置订单允许退货 7天内
calendar.add(Calendar.DAY_OF_WEEK, 7);
orderInfo.setRefundableTime(calendar.getTime());
//1.4 其他普通字段
orderInfo.sumTotalAmount();
List<OrderDetail> orderDetailList = orderInfo.getOrderDetailList();
if (!CollectionUtils.isEmpty(orderDetailList)) {
String skuNames = orderDetailList.stream().map(OrderDetail::getSkuName).collect(Collectors.joining(","));
if (skuNames.length() > 100) {
skuNames = skuNames.substring(0, 100);
}
orderInfo.setTradeBody(skuNames);
orderInfo.setImgUrl(orderDetailList.get(0).getImgUrl());
}
this.save(orderInfo);
Long orderId = orderInfo.getId();
//2.构建订单明细集合,批量保存订单明细
if (!CollectionUtils.isEmpty(orderDetailList)) {
for (OrderDetail orderDetail : orderDetailList) {
orderDetail.setSourceId(1L);
if("1".equals(orderType)){
orderDetail.setSourceType("MALL");
}else{
orderDetail.setSourceType("SECKILL");
}
orderDetail.setOrderId(orderId);
}
orderDetailService.saveBatch(orderDetailList);
}
return orderId;
}
OrderFeignClient远程调用API接口方法
package com.atguigu.gmall.order.client;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.order.client.impl.OrderDegradeFeignClient;
import com.atguigu.gmall.order.model.OrderInfo;
import io.swagger.annotations.ApiOperation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@FeignClient(value = "service-order", path = "/api/order", fallback = OrderDegradeFeignClient.class)
public interface OrderFeignClient {
/**
* 提交秒杀订单
*
* @param orderInfo
* @return
*/
@ApiOperation("提交秒杀订单")
@PostMapping("/inner/seckill/submitOrder")
public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo);
}
服务降级方法
@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
return null;
}
YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/491
SeckilBizController
/**
* 提交秒杀订单
*
* @param orderInfo
* @return
*/
@ApiOperation("提交秒杀订单")
@PostMapping("/auth/submitOrder")
public Result submitSeckillOrder(HttpServletRequest request, @RequestBody OrderInfo orderInfo) {
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
throw new RuntimeException("请先登录");
}
orderInfo.setUserId(Long.valueOf(userId));
Long orderId = seckillBizService.submitSeckillOrder(orderInfo);
return Result.ok(orderId);
}
SeckillBizService
/**
* 提交秒杀订单
* @param orderInfo
* @return
*/
Long submitSeckillOrder(OrderInfo orderInfo);
SeckillBizServiceImpl
@Autowired
private OrderFeignClient orderFeignClient;
/**
* 提交秒杀订单
*
* @param orderInfo
* @return
*/
@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
//1.远程调用订单服务保存秒杀订单 得到订单ID
//1.1 查询用户临时下单记录 获取秒杀订单订单编号
List<OrderDetail> orderDetailList = orderInfo.getOrderDetailList();
if (!CollectionUtils.isEmpty(orderDetailList)) {
Long skuId = orderDetailList.get(0).getSkuId();
String orderTempHashKey = RedisConst.SECKILL_ORDERS;
String hashKey = orderInfo.getUserId() +":"+ skuId.toString();
BoundHashOperations<String, String, OrderRecode> orderTempHashOps = redisTemplate.boundHashOps(orderTempHashKey);
Boolean hasTempOrder = orderTempHashOps.hasKey(hashKey);
if (hasTempOrder) {
OrderRecode orderRecode = orderTempHashOps.get(hashKey);
//设置秒杀订单编号
orderInfo.setOutTradeNo(orderRecode.getOrderStr());
Long orderId = orderFeignClient.submitSeckillOrder(orderInfo);
//2.在Redis中新增用户下单记录
String orderHashKey = RedisConst.SECKILL_ORDERS_USERS;
BoundHashOperations<String, String, Long> orderHashOps = redisTemplate.boundHashOps(orderHashKey);
Boolean hasOrder = orderHashOps.hasKey(hashKey);
if (!hasOrder) {
orderHashOps.put(hashKey, orderId);
}
//3.删除Redis中用户临时订单
orderTempHashOps.delete(hashKey);
return orderId;
}
}
throw new RuntimeException("订单提交失败!");
}
说明:下单成功后,后续流程与正常订单一致
秒杀过程中我们写入了大量redis缓存,我们可以在秒杀结束或每天固定时间清除缓存,释放缓存空间;
实现思路:假如根据业务,我们确定每天18点所有秒杀业务结束,那么我们编写定时任务,每天18点发送mq消息,service-activity模块监听消息清理缓存
service-task发送消息
/**
* 定时任务
*/
public static final String ROUTING_TASK_18 = "seckill.task.18";
//队列
public static final String QUEUE_TASK_18 = "queue.task.18";
service-task
模块ScheduledTask增加定时任务
ScheduledJob
/**
* 每天下午18点发送秒杀清理消息
* 开发阶段手动触发任务任务执行即可
*/
@XxlJob("sendCleanCacheMsg")
public void sendCleanCacheMsg() {
log.info("秒杀清理消息定时执行了");
String jobParam = XxlJobHelper.getJobParam();
rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_18, jobParam);
}
Service-activity接收消息
ActivityReceiver
/**
* 监听定时清理秒杀缓存消息
*
* @param msg
* @param message
* @param channel
*/
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
value = @Queue(value = MqConst.QUEUE_TASK_18, durable = "true"),
key = MqConst.ROUTING_TASK_18
))
public void processCleanCache(String msg, Message message, Channel channel){
if (StringUtils.isNotBlank(msg)) {
log.info("[秒杀服务]监听清理秒杀缓存消息:{}", msg);
seckillBizService.processCleanCache();
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
SeckillBizService
/**
* 清理秒杀缓存
*/
void processCleanCache();
/**
* 清理当日秒杀缓存数据
*/
@Override
public void processCleanCache() {
//1.清理分布式缓存Redis中 key以"seckill:"全部删除
Set keys = redisTemplate.keys("seckill:*");
redisTemplate.delete(keys);
//2.清理本地JVM缓存 TODO 发送Redis订阅消息,通知所有秒杀节点全部清理本地缓存
seckillGoodsCache.invalidateAll();
}
说明:清空redis缓存,同时更改秒杀商品活动结束
秒杀下单,提交成功,页面通过轮询后台方法查询秒杀状态
需求:在银行还款日前几天,短信通知所有的贷款用户提醒。
/**
分片广播任务:
执行器集群部署时,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
“分片广播” 以执行器为维度进行分片,支持动态扩容执行器集群从而动态增加分片数量,协同进行业务处理;在进行大数据量业务操作时可显著提升任务处理能力和速度。
“分片广播” 和普通任务开发流程一致,不同之处在于可以获取分片参数,获取分片参数进行分片业务处理。
*/
@XxlJob("shardJob")
public void shardJob() {
//total:总分片数,执行器集群的总机器数量;
int shardTotal = XxlJobHelper.getShardTotal();
//index:当前分片序号(从0开始),执行器集群列表中当前执行器的序号;
int shardIndex = XxlJobHelper.getShardIndex();
log.info("当前执行器开始任务了,总分片数:{}, 当前分片索引:{}", shardTotal, shardIndex);
}
持久层执行SQL-跟xxl-job分片任务传递的分片参数。编写SQL查询当前节点需要处理任务
# 共计存在执行器数量:3 每个执行器负责处理一部分数据 各个执行器筛选任务记录条件= (任务ID%总分片数)=当前执行器索引
#第一个执行器执行任务SQL
SELECT * from user_job where `status`=0 and MOD(id,3) = 0 limit 10;
#第二个执行器执行任务SQL
SELECT * from user_job where `status`=0 and MOD(id,3) = 1 limit 10;
#第三个执行器执行任务SQL
SELECT * from user_job where `status`=0 and MOD(id,3) = 2 limit 10;