目录
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 Name | Mandatory | Allowed Values | Allowed Special Characters |
---|---|---|---|
Seconds | YES | 0-59 | , - * / |
Minutes | YES | 0-59 | , - * / |
Hours | YES | 0-23 | , - * / |
Day of month | YES | 1-31 | , - * ? / L W |
Month | YES | 1-12 or JAN-DEC | , - * / |
Day of week | YES | 1-7 or SUN-SAT | , - * ? / L # |
Year | YES | empty, 1970-2099 | , - * / |
2.2 cron表达式特殊字符
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);
}
...
}