1.秒杀设计
1.1.秒杀业务
秒杀具有瞬间高并发特点,针对这一特点,必须要做限流+异步+缓存(页面静态化)+独立部署。
限流方式:
- 前端限流,一些高并发的网站直接在前端页面开始限流,例如:小米的验证码设计
- Nginx限流,直接负载部分请求到错误的静态页面:令牌算法,漏斗算法
- 网关限流,限流过滤器
- 代码中使用分布式信号量
- RabbitMQ限流,chanel.basicQos(1),保证发挥所有服务器的性能
1.2.秒杀流程
1.3.秒杀系统设计
1.3.1.秒杀(高并发)系统关注的问题
- 服务单一职责+独立部署
-
- 秒杀服务即使自己扛不住压力,宕机,不要影响别的服务
- 秒杀链接加密
-
- 防止恶意攻击,模拟秒杀请求,1000次/s 攻击
-
- 防止链接暴露,自己工作人员,提前秒杀商品
- 库存预热+快速扣减
-
- 秒杀读多写少,无需每次实时校验库存,进行库存预热,放到redis中,信号量控制进来秒杀的请求
-
- Redis集群+分片高可用
- 动静分离
-
- nginx做好动静分离,保证秒杀和商品详情页的动态请求才打到后端的服务集群
-
- 上线可以使用CDN网络,分担本集群压力
- 恶意请求拦截
-
- 识别非法攻击请求并进行拦截,比如登录拦截器
-
- 网关层处理
- 流程错峰
-
- 使用各种手段,将流量分担到大宽度的时间点,比如验证码,加入购物车
- 限流、熔断、降级
-
- 前端限流+后端限流(网关)
-
- 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
- 队列削峰
-
- 1万个商品,每个1000件秒杀(信号量)
-
- 所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可
2.秒杀服务
2.1.构建秒杀服务
配置域名映射及网关
192.168.139.10 seckill.gmall.com
spring:
cloud:
gateway:
routes:
- id: gmall_seckill_route
uri: lb://gmall-seckill
predicates:
- Host=seckill.gmall.com
2.2.添加秒杀场次
后台管理系统:优惠营销 -> 每日秒杀
2.3.秒杀场次关联秒杀商品
2.4.秒杀商品定时上架
- 定时任务扫描最近三天需要上架的秒杀商品
- 库存预热,让秒杀商品缓存到Redis中
SeckillSkuScheduled
package com.atguigu.gmall.seckill.scheduled;
import com.atguigu.gmall.seckill.service.SeckillService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 秒杀商品定时上架 {@link SeckillSkuScheduled}
* 每天凌晨3点,上架最近三天需要秒杀商品
* 当天 00:00:00 - 23:59:59
* 明天 00:00:00 - 23:59:59
* 后天 00:00:00 - 23:59:59
*
* @author zhangwen
* @email: 1466787185@qq.com
*/
@Slf4j
@Service
public class SeckillSkuScheduled {
private final String UPLOAD_LOCK = "seckill:upload:lock";
@Autowired
private SeckillService seckillService;
@Autowired
private RedissonClient redissonClient;
/**
* 秒杀商品定时上架
*
* 幂等处理:
* - 1.分布式锁
* - 2.缓存数据时判断是否已经存在key
* - key不存在就缓存
* - key存在不做处理
*/
@Scheduled(cron = "0 0 3 * * ?")
public void uploadSeckillSkuLast3Days(){
log.info("秒杀商品定时上架...");
// 幂等处理,分布式锁
// 锁的业务执行完成,状态已经更新完成,释放锁以后,其他人获取到的就是最新的状态
RLock lock = redissonClient.getLock(UPLOAD_LOCK);
lock.lock(10, TimeUnit.SECONDS);
try {
seckillService.uploadSeckillSkuLast3Days();
} finally {
lock.unlock();
}
}
}
SeckillServcieImpl
/**
* 上架最近三天的秒杀商品
*/
@Override
public void uploadSeckillSkuLast3Days() {
// 扫描最近三天需要参与的秒杀活动与商品信息
R r = couponFeignService.getLast3DaySession();
if (r.getCode() == 0) {
// 需要上架的商品
List<SeckillSessionsWithSkusVO> sessions = r.getData("data",
new TypeReference<List<SeckillSessionsWithSkusVO>>() {
});
// 缓存秒杀活动信息
saveSessions(sessions);
// 缓存秒杀活动关联的商品信息
saveSessionSkus(sessions);
} else {
log.error("远程调用 gmall-coupon 获取秒杀活动失败");
}
}
/**
* 缓存秒杀活动信息
* @param sessions
*/
private void saveSessions(List<SeckillSessionsWithSkusVO> sessions) {
if (sessions != null && sessions.size() > 0) {
sessions.stream().forEach(session -> {
Long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
Boolean hasKey = redisTemplate.hasKey(key);
if (!hasKey) {
// 缓存秒杀活动信息
List<String> skuIds = session.getRelationSkus().stream()
.map(item -> item.getPromotionSessionId() + "_" + item.getSkuId())
.collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key, skuIds);
}
});
}
}
/**
* 缓存秒杀活动关联的商品信息
* @param sessions
*/
private void saveSessionSkus(List<SeckillSessionsWithSkusVO> sessions){
if (sessions != null && sessions.size() > 0) {
sessions.stream().forEach(session -> {
BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(SECKILL_SKU_CACHE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVO -> {
String hashKey = seckillSkuVO.getPromotionSessionId() + "_" + seckillSkuVO.getSkuId();
if (!hashOps.hasKey(hashKey)) {
// 缓存商品
SeckillSkuRedisTO redisTO = new SeckillSkuRedisTO();
// 1.sku基本信息
R r = productFeignService.getSkuInfo(seckillSkuVO.getSkuId());
if (r.getCode() == 0) {
SkuInfoVO skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {
});
redisTO.setSkuInfo(skuInfo);
}
// 2.sku秒杀信息
BeanUtils.copyProperties(seckillSkuVO, redisTO);
// 3.设置当前商品秒杀时间
redisTO.setStartTime(session.getStartTime().getTime());
redisTO.setEndTime(session.getEndTime().getTime());
// 4.设置随机码,防止恶意攻击
String token = UUID.randomUUID().toString().replace("-", "");
redisTO.setRandomCode(token);
String json = JsonUtils.objectToJson(redisTO);
hashOps.put(hashKey, json);
**加粗样式**
// 5.使用秒杀商品库存作为分布式的信号量,限流
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
semaphore.trySetPermits(seckillSkuVO.getSeckillCount().intValue());
}
});
});
}
}
2.5.展示秒杀商品(首页)
index.html
//获取当前秒杀场次商品
$.get('http://seckill.gmall.com/currentSeckillSkus', function(resp){
if(resp.data.length > 0){
resp.data.forEach(function(item){
$('<li onclick="toItemPage('+item.skuId+')"> </li>')
.append($('<img src="'+item.skuInfo.skuDefaultImg+'"
style="width: 130px;height: 130px">'))
.append($('<p>'+item.skuInfo.skuTitle+'</p>'))
.append($('<span>'+item.seckillPrice+'</span>'))
.append($('<s>'+item.skuInfo.price+'</s>'))
.appendTo($('#seckillSkus'))
})
}
})
function toItemPage(skuId){
location.href = `http://item.gmall.com/${skuId}.html`
}
2.6.商品秒杀预告(详情页)
SkuInfoServiceImpl
/**
* 商品详情
* @param skuId
* @return
*/
@Override
public SkuItemVO item(Long skuId) throws Exception {
SkuItemVO skuItemVO = new SkuItemVO();
// 异步编排
CompletableFuture<SkuInfoEntity> skuInfoFuture = CompletableFuture.supplyAsync(() -> {
// 1.sku基本信息 pms_sku_info
SkuInfoEntity skuInfo = getById(skuId);
skuItemVO.setSkuInfo(skuInfo);
return skuInfo;
}, executor);
CompletableFuture<Void> saleAttrFuture = skuInfoFuture.thenAcceptAsync((res) -> {
// 2.spu销售属性组合
List<SkuSaleAttrVO> saleAttrs = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
skuItemVO.setSaleAttrs(saleAttrs);
}, executor);
CompletableFuture<Void> descFuture = skuInfoFuture.thenAcceptAsync((res) -> {
// 3.spu商品介绍
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVO.setSpuDesc(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = skuInfoFuture.thenAcceptAsync((res) -> {
// 4.spu规格参数
List<SpuAttrGroupVO> groupAttrs = attrGroupService.getAttrGroupWithAttrsBySpuId(
res.getCatalogId(), res.getSpuId());
skuItemVO.setGroupAttrs(groupAttrs);
}, executor);
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
// 5.sku图片信息 pms_sku_images
List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
skuItemVO.setImages(images);
}, executor);
CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
// 查询当前sku是否参与秒杀优惠
R r = seckillFeignService.getSkuSeckillInfo(skuId);
if (r.getCode() == 0) {
SeckillSkuVO seckillSkuVO = r.getData("data", new TypeReference<SeckillSkuVO>() {
});
skuItemVO.setSeckillInfo(seckillSkuVO);
} else {
log.error("远程调用 gmall-seckill 获取商品秒杀信息失败");
}
}, executor);
// 等待所有任务执行完
CompletableFuture.allOf(saleAttrFuture, descFuture, baseAttrFuture, imageFuture, seckillFuture).get();
// TODO 查询库存
skuItemVO.setHasStock(true);
return skuItemVO;
}
item.html
<li th:if="${skuItemVO.seckillInfo!=null}" style="color: red">
<span th:if="${#dates.createNow().getTime() <skuItemVO.seckillInfo.startTime}">
商品将会在[[${#dates.format(new java.util.Date(skuItemVO.seckillInfo.startTime), 'yyyy-MM-dd HH:mm:ss')}]]进行秒杀
</span>
<span th:if="${#dates.createNow().getTime() >= skuItemVO.seckillInfo.startTime && #dates.createNow().getTime() <= skuItemVO.seckillInfo.endTime}">
秒杀价:[[${#numbers.formatDecimal(skuItemVO.seckillInfo.seckillPrice,1,2)}]]
</span>
</li>
<div class="box-btns-two" th:if="${skuItemVO.seckillInfo!=null && (#dates.createNow().getTime() >= skuItemVO.seckillInfo.startTime && #dates.createNow().getTime() <= skuItemVO.seckillInfo.endTime)}">
<a href="#" id="toSeckill" th:attr="skuId=${skuItemVO.skuInfo.skuId},sessionId=${skuItemVO.seckillInfo.promotionSessionId},code=${skuItemVO.seckillInfo.randomCode}">立即抢购</a>
</div>
<div class="box-btns-two" th:if="${skuItemVO.seckillInfo==null || (#dates.createNow().getTime() < skuItemVO.seckillInfo.startTime || #dates.createNow().getTime() > skuItemVO.seckillInfo.endTime)}">
<a href="#" id="addToCart" th:attr="skuId=${skuItemVO.skuInfo.skuId}">加入购物车</a>
</div>
<script>
// 立即抢购
$('#toSeckill').click(function(){
let isLogin = [[${session.loginUser!=null}]]
if(isLogin){
let killId = $(this).attr('sessionId') + "_" + $(this).attr('skuId')
let code = $(this).attr('code')
let num = $('#num').val()
location.href = `http://seckill.gmall.com/seckill?killId=${killId}&key=${code}&num=${num}`
} else {
alert('秒杀商品,请先登录!')
location.href = 'http://auth.gmall.com/login.html'
}
return false
})
</script>
2.7.秒杀核心业务实现
2.7.1.核心流程
2.7.2.秒杀业务
SeckillController
/**
* 秒杀
* @param killId sessionId_skuId
* @param key 商品随机码
* @param num 秒杀数量
* @return
*/
@GetMapping("/seckill")
public String seckill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("num") Integer num,
Model model){
String orderSn = seckillService.seckill(killId, key, num);
model.addAttribute("orderSn", orderSn);
return "success";
}
SeckillServcieImpl
/**
* 秒杀
* @param killId 秒杀场次id_商品id
* @param key 随机码
* @param num 商品数量
* @return
*/
@Override
public String seckill(String killId, String key, Integer num) {
MemberVO memberVO = LoginInterceptor.threadLocal.get();
// 获取当前秒杀商品信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_SKU_CACHE_PREFIX);
String json = hashOps.get(killId);
if (StringUtils.isEmpty(json)) {
return null;
}
SeckillSkuRedisTO redisTO = JsonUtils.jsonToPojo(json, SeckillSkuRedisTO.class);
// 校验合法性
// 1.校验时间
long currentTime = System.currentTimeMillis();
if (currentTime >= redisTO.getStartTime() && currentTime <= redisTO.getEndTime()) {
// 2.校验随机码和商品id
String randomCode = redisTO.getRandomCode();
String skuId = redisTO.getPromotionSessionId() + "_" + redisTO.getSkuId();
if (randomCode.equals(key) && skuId.equals(killId)) {
// 3.校验购物数量
if (num <= redisTO.getSeckillLimit().intValue()) {
// 4.验证是否购买过
// 幂等性,只要秒杀成功,就去redis占位 SETNX,userId_sessionId_skuId
String redisKey = memberVO.getId() + "_" + skuId;
Long ttl = redisTO.getEndTime() - redisTO.getStartTime();
Boolean ifAbsent = redisTemplate.opsForValue()
.setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (ifAbsent) {
// 占位成功,说明没有购买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
// 快速尝试
boolean acquire = semaphore.tryAcquire(num);
if (acquire) {
// 秒杀成功,快速下单,发消息到MQ
String orderSn = IdWorker.getTimeId();
SeckillOrderTO seckillOrderTO = new SeckillOrderTO();
seckillOrderTO.setOrderSn(orderSn);
seckillOrderTO.setMemberId(memberVO.getId());
seckillOrderTO.setNum(num);
seckillOrderTO.setPromotionSessionId(redisTO.getPromotionSessionId());
seckillOrderTO.setSkuId(redisTO.getSkuId());
seckillOrderTO.setSeckillPrice(redisTO.getSeckillPrice());
rabbitTemplate.convertAndSend(SECKILL_ORDER_EVENT_EXCHANGE,
SECKILL_ORDER_QUEUE_ROUTING_KEY, seckillOrderTO);
return orderSn;
}
}
}
}
}
return null;
}
2.7.3.秒杀响应页面
- 秒杀完成后,跳转到success页面,显示秒杀结果
- 秒杀成功,则自动跳转到支付页面进行支付
- 秒杀成功,订单服务消费秒杀消息,进行订单处理
success.html
<div class="main">
<div class="success-wrap">
<div class="w" id="result">
<div class="m succeed-box">
<div th:if="${orderSn!=null}" class="mc success-cont">
<h3 style="margin: 20px 0px">恭喜,秒杀成功,订单号:[[${orderSn}]]</h3>
<p>
<a th:href="'http://order.gmall.com/payOrder?orderSn='+${orderSn}" id="pay">
正在准备订单数据,请您耐心等待 <span id="payTime">10</span> 秒后进行支付!
</a>
</p>
</div>
<div th:if="${orderSn==null}" class="mc success-cont">
<h3 style="margin: 20px 0px">手气不佳,秒杀失败,下次再来!</h3>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
// 倒计时跳转支付
$(function () {
let href = $('#pay').attr('href')
$('#pay').removeAttr('href')
$('#pay').attr('disabled', true)
let orderSn = [[${orderSn}]]
let count = 10
let countdown = setInterval(CountDown, 1000)
function CountDown() {
$("#payTime").text(count)
if (count == 0) {
clearInterval(countdown)
$('#pay').text('支付订单')
$('#pay').attr('href', href)
$('#pay').removeAttr('disabled')
}
count--;
}
});
</script>
3.定时任务
- Quartz:http://www.quartz-scheduler.org/
- Timer
- Spring @Scheduled
3.1.cron表达式
语法: 秒 分 时 日 月 周 年 (年,Spring不支持)
http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html
特殊字符:
- , :枚举
corn=“7,9,23 * * * * ?” :任意时刻的 7,9,23 秒启动这个任务 - - :范围
corn=“7-20 * * * * ?” :任意时刻的7-20秒之间,每秒启动一次 - *:任意
指定位置的任意时刻都可以 - / :步长
corn=“7/5 * * * * ?” :第7秒启动,每5秒一次
corn=“*/5 * * * * ?” :任意秒启动,每5秒一次 - ? :出现在日和周几的位置,为了防止日和周冲突,在日和周上如果要写通配符使用?
corn=" * * * 1 * ?" :每月的1号,而且必须是周二,然后启动这个任务 - L :出现在日和周的位置
Last:最后一个
corn=" * * * ? * 3L" :每月的最后一个周二 - W
Work Day:工作日
cron=“* * * W * ?” :每个月的工作日触发
cron=“* * * LW * ?” :每个月的最后一个工作日触发 - #:第几个
cron=“* * * ? * 5#2” :每个月的第2个周4
3.2.cron示例
http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html
3.3.Spring Boot整合定时任务
@Slf4j
@Component
@EnableScheduling //开启定时任务
public class MyScheduled {
/**
* 1.Spring中6位组成,不允许第7位的年
* 2.在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)
*/
@Scheduled(cron = "* * * ? * 5")
public void hello(){
log.info("hello ю ")
}
}
注意:
-
Spring中6位组成,不允许第7位的年
-
在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)
-
解决定时任务不阻塞,默认是阻塞的
-
- 可以让业务方法以异步的方式运行,自己提交到线程池
@Scheduled(cron = "* * * ? * 5")
public void hello(){
log.info("hello ю ");
//异步方式运行
CompletableFuture.runAsync(() Ѷ ۏ {
xxxServcie.method();
});
}
-
- 让定时任务异步执行
@Slf4j
@Component
@EnableAsync //开启异步任务
@EnableScheduling //开启定时任务
public class MyScheduled {
/**
* 1.Spring中6位组成,不允许第7位的年
* 2.在周几的位置,1-7表示周一到周日,和quartz有区别(1-7表示周日到周六)
*/
@Async
@Scheduled(cron = "* * * ? * 5")
public void hello(){
log.info("hello ... ");
}
}