微服务高并发秒杀系统
在做完乐优商城项目之后发现缺少秒杀未编写,打算上手实现一下这个基本电商都需要的功能,参考https://blog.youkuaiyun.com/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:还需要继续改进~~~~~~~~~~~~~!!!!!!!!!!!!!