微服務高併發秒殺系統
在做完樂優商城項目之後發現缺少秒殺未編寫,打算上手實現一下這個基本電商都需要的功能,參考https://blog.csdn.net/lyj2018gyq/article/details/84261377,https://my.oschina.net/xianggao/blog/524943下面開始編寫。
概念
什麼是秒殺?通俗一點講就是網絡商家爲促銷等目的組織的網上限時搶購活動
比如說京東秒殺,就是一種定時定量秒殺,在規定的時間內,無論商品是否秒殺完畢,該場次的秒殺活動都會結束。這種秒殺,對時間不是特別嚴格,只要下手快點,秒中的概率還是比較大的。
淘寶以前就做過一元搶購,一般都是限量 1 件商品,同時價格低到「令人發齒」,這種秒殺一般都在開始時間 1 到 3 秒內就已經搶光了,參與這個秒殺一般都是看運氣的,不必太強求
業務分析
秒殺業務的特性
- 低廉價格
- 一般是定時上架
- 時間短、瞬時併發量高,秒殺時會有大量用戶在同一時間進行搶購,瞬時併發訪問量突增 10 倍
- 庫存量少,一般秒殺活動商品量都很少,這就導致了只有極少量用戶能成功購買到商品。
- 頁面流量突增,很多用戶請求對應商品頁面,會造成後臺服務器的流量突增,同時對應的網絡帶寬增加,需要控制商品頁面的流量不會對後臺服務器、DB、Redis 等組件造成過大的壓力
系統優化
頁面緩存
將不經常改動的頁面直接緩存到redis中,然後用Thymeleaf視圖解析器將緩存的頁面直接渲染出來。
對象緩存
將經常使用的對象信息放入redis中,比如說用戶信息,抽插redis肯定比抽插數據庫快。但是,這裏面就涉及到一個數據同步問題,即如何保持redis中放入的是最新的數據。策略就是遇到數據更新的時候,先更新數據庫中的信息,然後使緩存失效,當再次拉取數據的時候就會從數據庫中獲取,第一次獲取成功後就放入緩存當中。
頁面靜態化
將頁面直接緩存到用戶的瀏覽器上,或者將頁面直接轉化爲靜態網頁。靜態化是指把動態生成的HTML頁面變爲靜態內容保存,以後用戶的請求到來,直接訪問靜態頁面,不再經過服務的渲染。而靜態的HTML頁面可以部署在nginx中,從而大大提高併發能力,減小tomcat壓力。通過Thymeleaf模板引擎來生成靜態網頁。
靜態資源優化
js/css壓縮,減少流量;多個js/css組合,減少連接數,CDN優化。
數據庫優化
減少對數據庫的訪問,可以提高性能。秒殺時因爲有大量用戶進行下訂單操作,所有可以使用消息隊列來緩解數據庫壓力。同時也可以想辦法優化對redis的訪問,設置內存標記等。
秒殺地址隱藏
功能:防止秒殺地址被刷。
思路:秒殺開始之前,先去請求接口獲取秒殺地址
- 改造接口,帶上PathVariable參數
- 添加生成地址的接口
- 秒殺收到請求,先驗證PathVariable
創建秒殺路徑
Controller
/**
* 創建秒殺路徑
* @param goodsId
* @return
*/
@GetMapping("get_path/{goodsId}")
public ResponseEntity<String> getSeckillPath(@PathVariable("goodsId") Long goodsId){
UserInfo userInfo = LoginInterceptor.getLoginUser();
if (userInfo == null){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String str = this.seckillService.createPath(goodsId,userInfo.getId());
if (StringUtils.isEmpty(str)){
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok(str);
}
service
將用戶id和秒殺商品的id先進行加密,然後放入redis中,並且設置過期時間爲60秒。
/**
* 創建秒殺地址
* @param goodsId
* @param id
* @return
*/
@Override
public String createPath(Long goodsId, Long id) {
String str = new BCryptPasswordEncoder().encode(goodsId.toString()+id);
BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX_PATH);
String key = id.toString() + "_" + goodsId;
hashOperations.put(key,str);
hashOperations.expire(60, TimeUnit.SECONDS);
return str;
}
路徑驗證
Controller,創建秒殺訂單接口,讓其先驗證路徑
/**
* 秒殺
* @param path
* @param seckillGoods
* @return
*/
@PostMapping("/{path}/seck")
public ResponseEntity<String> seckillOrder(@RequestParam("path") String path, @RequestBody SeckillGoods seckillGoods){
String result = "排隊中";
UserInfo userInfo = LoginInterceptor.getLoginUser();
//1.驗證路徑
boolean check = this.seckillService.checkSeckillPath(seckillGoods.getId(),userInfo.getId(),path);
if (!check){
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
//2.內存標記,減少redis訪問
boolean over = localOverMap.get(seckillGoods.getSkuId());
if (over){
return ResponseEntity.ok(result);
}
//3.讀取庫存,減一後更新緩存
BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX);
Long stock = hashOperations.increment(seckillGoods.getSkuId().toString(), -1);
//4.庫存不足直接返回
if (stock < 0){
localOverMap.put(seckillGoods.getSkuId(),true);
return ResponseEntity.ok(result);
}
//5.庫存充足,請求入隊
//5.1 獲取用戶信息
SeckillMessage seckillMessage = new SeckillMessage(userInfo,seckillGoods);
//5.2 發送消息
this.seckillService.sendMessage(seckillMessage);
return ResponseEntity.ok(result);
}
Service
/**
* 驗證秒殺地址
* @param goodsId
* @param id
* @param path
* @return
*/
@Override
public boolean checkSeckillPath(Long goodsId, Long id, String path) {
String key = id.toString() + "_" + goodsId;
BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX_PATH);
String encodePath = (String) hashOperations.get(key);
return new BCryptPasswordEncoder().matches(path,encodePath);
}
接口限流
功能:限定用戶在某一段時間內有限次的訪問地址。
思路:將用戶訪問地址的次數寫入redis當中,同時設置過期時間。當用戶每次訪問,該值就加一,當訪問次數超出限定數值時,那麼就直接返回。
實現:爲了具有通用性,以註解的形式調用該方法。
package com.leyou.seckill.access;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 接口限流注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
/**
* 限流時間
* @return
*/
int seconds();
/**
* 最大請求次數
* @return
*/
int maxCount();
/**
* 是否需要登錄
* @return
*/
boolean needLogin() default true;
}
添加攔截器
通過攔截器,攔截AccessLimit註解,然後進行接口限流。主要是使用redis的自增機制。
package com.leyou.seckill.interceptor;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.response.CodeMsg;
import com.leyou.common.response.Result;
import com.leyou.common.utils.JsonUtils;
import com.leyou.seckill.access.AccessLimit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.TimeUnit;
/**
* 接口限流攔截器
*/
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod){
HandlerMethod handlerMethod = (HandlerMethod) handler;
AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
if (accessLimit == null){
return true;
}
//獲取用戶信息
UserInfo userInfo = LoginInterceptor.getLoginUser();
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
if (needLogin){
if (userInfo == null){
render(response, CodeMsg.LOGIN_ERROR);
return false;
}
key += "_" + userInfo.getId();
}else {
//不需要登錄,則什麼也不做
}
String count = redisTemplate.opsForValue().get(key);
if (count == null){
redisTemplate.opsForValue().set(key,"1",seconds, TimeUnit.SECONDS);
}else if(Integer.valueOf(count) < maxCount){
redisTemplate.opsForValue().increment(key,1);
}else {
render(response,CodeMsg.ACCESS_LIMIT_REACHED);
}
}
return super.preHandle(request, response, handler);
}
private void render(HttpServletResponse response, CodeMsg cm) throws IOException {
response.setContentType("application/json;charset=UTF-8");
OutputStream out = response.getOutputStream();
String str = JsonUtils.serialize(Result.error(cm));
out.write(str.getBytes("UTF-8"));
out.flush();
out.close();
}
}
配置攔截器
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private JwtProperties jwtProperties;
@Bean
public LoginInterceptor loginInterceptor() {
return new LoginInterceptor(jwtProperties);
}
@Autowired
public AccessInterceptor accessInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
List<String> excludePath = new ArrayList<>();
excludePath.add("/list");
excludePath.add("/addSeckill");
registry.addInterceptor(loginInterceptor())
.addPathPatterns("/**").excludePathPatterns(excludePath);
registry.addInterceptor(accessInterceptor);
}
}
使用
在需要限流的方法上,直接使用註解即可。
之後使用消息隊列,調用訂單微服務執行下單操作。(地址信息暫時定死)
package com.leyou.order.listener;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.IdWorker;
import com.leyou.common.utils.JsonUtils;
import com.leyou.item.pojo.SeckillGoods;
import com.leyou.item.pojo.Stock;
import com.leyou.order.mapper.*;
import com.leyou.order.pojo.Order;
import com.leyou.order.pojo.OrderDetail;
import com.leyou.order.pojo.OrderStatus;
import com.leyou.order.pojo.SeckillOrder;
import com.leyou.order.service.OrderService;
import com.leyou.seckill.vo.SeckillMessage;
import org.springframework.amqp.core.ExchangeTypes;
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;
import org.springframework.transaction.annotation.Transactional;
import tk.mybatis.mapper.entity.Example;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 秒殺消息隊列監聽器
*/
@Component
public class SeckillListener {
@Autowired
private IdWorker idWorker;
@Autowired
private OrderService orderService;
@Autowired
private SkuMapper skuMapper;
@Autowired
private StockMapper stockMapper;
@Autowired
private SeckillOrderMapper seckillOrderMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderStatusMapper orderStatusMapper;
@Autowired
private OrderDetailMapper orderDetailMapper;
@Autowired
private SeckillMapper seckillMapper;
/**
* 接收秒殺信息
*
* @param seck
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "leyou.order.seckill.queue", durable = "true"), //隊列持久化
exchange = @Exchange(
value = "leyou.order.exchange",
ignoreDeclarationExceptions = "true",
type = ExchangeTypes.TOPIC
),
key = {"order.seckill"}
))
@Transactional(rollbackFor = Exception.class)
public void listenSeckill(String seck) {
SeckillMessage seckillMessage = JsonUtils.parse(seck, SeckillMessage.class);
UserInfo userInfo = seckillMessage.getUserInfo();
SeckillGoods seckillGoods = seckillMessage.getSeckillGoods();
//1.首先判斷庫存是否充足
Stock stock = stockMapper.selectByPrimaryKey(seckillGoods.getSkuId());
if (stock.getSeckillStock() <= 0 || stock.getStock() <= 0) {
//如果庫存不足的話修改秒殺商品的enable字段
Example example = new Example(SeckillGoods.class);
example.createCriteria().andEqualTo("skuId", seckillGoods.getSkuId());
List<SeckillGoods> list = this.seckillMapper.selectByExample(example);
for (SeckillGoods temp : list) {
if (temp.getEnable()) {
temp.setEnable(false);
this.seckillMapper.updateByPrimaryKeySelective(temp);
}
}
return;
}
//2.判斷此用戶是否已經秒殺到了
Example example = new Example(SeckillOrder.class);
example.createCriteria().andEqualTo("userId", userInfo.getId()).andEqualTo("skuId", seckillGoods.getSkuId());
List<SeckillOrder> list = this.seckillOrderMapper.selectByExample(example);
if (list.size() > 0) {
return;
}
//3.下訂單
//構造order對象
Order order = new Order();
order.setPaymentType(1);
order.setTotalPay(seckillGoods.getSeckillPrice().doubleValue());
order.setActualPay(seckillGoods.getSeckillPrice().doubleValue());
order.setPostFee(0 + "");
order.setReceiver("李四");
order.setReceiverMobile("15812312312");
order.setReceiverCity("西安");
order.setReceiverDistrict("碑林區");
order.setReceiverState("陝西");
order.setReceiverZip("000000000");
order.setInvoiceType(0);
order.setSourceType(2);
OrderDetail orderDetail = new OrderDetail();
orderDetail.setSkuId(seckillGoods.getSkuId());
orderDetail.setNum(1);
orderDetail.setTitle(seckillGoods.getTitle());
orderDetail.setImage(seckillGoods.getImage());
orderDetail.setPrice(seckillGoods.getSeckillPrice().doubleValue());
orderDetail.setOwnSpec(this.skuMapper.selectByPrimaryKey(seckillGoods.getSkuId()).getOwnSpec());
order.setOrderDetails(Arrays.asList(orderDetail));
//3.1 生成orderId
long orderId = idWorker.nextId();
//3.2 初始化數據
order.setBuyerNick(userInfo.getUsername());
order.setBuyerRate(false);
order.setCreateTime(new Date());
order.setOrderId(orderId);
order.setUserId(userInfo.getId());
//3.3 保存數據
this.orderMapper.insertSelective(order);
//3.4 保存訂單狀態
OrderStatus orderStatus = new OrderStatus();
orderStatus.setOrderId(orderId);
orderStatus.setCreateTime(order.getCreateTime());
//初始狀態未未付款:1
orderStatus.setStatus(1);
//3.5 保存數據
this.orderStatusMapper.insertSelective(orderStatus);
//3.6 在訂單詳情中添加orderId
order.getOrderDetails().forEach(od -> {
//添加訂單
od.setOrderId(orderId);
});
//3.7 保存訂單詳情,使用批量插入功能
this.orderDetailMapper.insertList(order.getOrderDetails());
//3.8 修改庫存
order.getOrderDetails().forEach(ord -> {
Stock stock1 = this.stockMapper.selectByPrimaryKey(ord.getSkuId());
stock1.setStock(stock1.getStock() - ord.getNum());
stock1.setSeckillStock(stock1.getSeckillStock() - ord.getNum());
this.stockMapper.updateByPrimaryKeySelective(stock1);
//新建秒殺訂單
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setOrderId(orderId);
seckillOrder.setSkuId(ord.getSkuId());
seckillOrder.setUserId(userInfo.getId());
this.seckillOrderMapper.insert(seckillOrder);
});
}
}
主要接口
-
添加參加秒殺的商品
-
查詢秒殺商品
-
創建秒殺地址
-
驗證秒殺地址
-
秒殺
-
秒殺的實現及其優化:
前端:秒殺地址的隱藏、使用圖形驗證碼
後端:接口限流,使用消息隊列,調用訂單微服務執行下單操作。
TODO:還需要繼續改進~~~~~~~~~~~~~!!!!!!!!!!!!!