秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -04
- Github:China-Rainbow-sea/seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
- Gitee:seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
文章目录
秒杀接口地址隐藏
问题分析:
- 前面我们处理高并发,是按照正常业务逻辑处理的,也就是用户正常抢购。
- 还需要考虑抢购安全性,当前程序,抢购接口是固定的,如果泄露,会有安全隐患,比如抢购未开始或者已经结束了,一些非法用户还可以使用脚本发起抢购。
解决方案:
- 用户抢购时,先生成一个唯一的抢购路径,返回给客户端。
- 然后,客户端抢购的时候,会携带生成的抢购路径,服务端同时会对这发送过来的抢购路径进行验证,如果校验成功,才走下一步,否则直接返回。
RespBeanEnum 增加一下提示信息的枚举信息
package com.rainbowsea.seckill.vo;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
/**
* 响应的信息枚举
*/
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
// 通用
SUCCESS(200, "SUCCESS"),
ERROR(500, "服务端异常"),
// 登录
LOGIN_ERROR(500210, "用户id 或 密码错误"),
BING_ERROR(500211, "参数绑定异常~~"),
MOBILE_ERROR(500212, "手机号码格式不正确"),
MOBILE_NOT_EXIST(500213, "手机号码不存在"),
PASSWROD_UPDATE_FAIL(500214, "密码更新失败"),
// 秒杀模块-返回的信息
ENTRY_STOCK(500500, "库存不足"),
REPEAT_ERROR(500501, "该商品每人限购一件"),
REQUEST_ILLEGAL(500502, "请求非法"),
SESSION_ERROR(500503, "用户信息有误"),
SKL_KILL_WATT(500504, "排队中...");
private final Integer code;
private final String message;
}
OrderService 接口添加:两个方法
- 生成秒杀路径/值(唯一) 方法
- 对秒杀路径进行校验方法
package com.rainbowsea.seckill.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.vo.GoodsVo;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service
* @createDate 2025-04-26 20:48:19
*/
public interface OrderService extends IService<Order> {
/**
* 秒杀
* @param user
* @param goodsVo
* @return Order 账单信息
*/
Order seckill(User user, GoodsVo goodsVo);
/**
* 生成秒杀路径/值(唯一)
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @return String 返回唯一路径
*/
public String createPath(User user,Long goodsId);
/**
* 对秒杀路径进行校验
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @param path 校验的秒杀路径
* @return boolean 秒杀路径正确,返回 true ,否则返回 false
*/
public boolean checkPath(User user,Long goodsId,String path);
}
OrderServiceImpl 实现上述设计的两个方法:
- 生成秒杀路径/值(唯一),同时将生成的路径,存入到 Redis 当中,同时设计以一个失效时间 60s,该时间内没访问,就失效。配合 Redis 使用。注意:Redis 当中 key 的设计: seckillPath:userId:goodsId
- 对秒杀路径进行校验,从 Redis 当中获取,注意:Redis 当中 key 的设计: seckillPath:userId:goodsId 。获取的方式
package com.rainbowsea.seckill.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rainbowsea.seckill.mapper.OrderMapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillGoods;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillGoodsService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.utill.MD5Util;
import com.rainbowsea.seckill.utill.UUIDUtil;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service实现
* @createDate 2025-04-26 20:48:19
*/
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
implements OrderService {
@Resource
private SeckillGoodsService seckillGoodsService;
@Resource
private OrderMapper orderMapper;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private RedisTemplate redisTemplate;
/**
* 秒杀商品,减少库存,V2.0 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】 ,
* 添加上 @Transactional,注解进行一个MySQL 默认的事务隔离级别
*
* @param user
* @param goodsVo
* @return Order
*/
@Transactional
@Override
public Order seckill(User user, GoodsVo goodsVo) {
// 查询后端的库存量进行减一
SeckillGoods seckillGoods = seckillGoodsService
.getOne(new QueryWrapper<SeckillGoods>()
.eq("goods_id", goodsVo.getId()));
// 完成一个基本的秒杀操作【这快不具原子性】,后面在高并发的情况下,还会优化
//seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
//seckillGoodsService.updateById(seckillGoods);
//分析
// 1. MySQL 在默认的事务隔离级别 【REPEATABLE-READ】 下
// 2. 执行 update 语句时,会在事务中锁定要更新的行
// 3. 这样可以防止其它会话在同一行执行 update,delete
// 说明: 只要在更新成功时,返回 true,否则返回 false
// column 必须是,数据库表当中的字段,不可以随便写
boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0)); // gt 表示大于
if (!update) { // 如果更新失败,说明已经没有库存了
return null;
}
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 这里随便设置了一个初始值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
// 保存 order 账单信息
orderMapper.insert(order);
// 生成秒杀商品订单~
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
// 这里秒杀商品订单对应的 order_id 是从上面添加 order 后获取到的
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
// 保存 seckillOrder
seckillOrderService.save(seckillOrder);
// 将生成的秒杀订单,存入到 Redis,这样在查询某个用户是否已经秒杀了这个商品时
// 直接到 Redis 中查询,起到优化效果
// key表示:order:userId:goodsId Value表示订单 seckillOrder
redisTemplate.opsForValue().set("order:" + user.getId() + ":" +
goodsVo.getId(),
seckillOrder);
return order;
}
/**
* 生成秒杀路径/值(唯一)
*
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @return String 返回唯一路径
* 同时将生成的路径,存入到 Redis 当中,同时设计以一个失效时间 60s,该时间内没访问,就失效
*/
@Override
public String createPath(User user, Long goodsId) {
// 生成秒杀路径/值唯一
String path = MD5Util.md5(UUIDUtil.uuid());
// 将随机生成的路径保存到 Redis,同时设置一个超时时间 60s,
// 60s 不访问,这个秒杀路径就失效
// Redis 当中 key 的设计: seckillPath:userId:goodsId
redisTemplate.opsForValue().set("seckillPath:"
+ user.getId() + ":" + goodsId, path, 60, TimeUnit.SECONDS);
return path;
}
/**
* 对秒杀路径进行校验
*
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @param path 校验的秒杀路径
* @return boolean 秒杀路径正确,返回 true ,否则返回 false
*/
@Override
public boolean checkPath(User user, Long goodsId, String path) {
if (user == null || goodsId < 0 || !StringUtils.hasText(path)) {
return false;
}
// 从 Redis 当中获取该用户秒杀该商品的路径
String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:"
+ user.getId() + ":" + goodsId);
// 判断这两个路径是否相同,相同说明正确,不相同说明错误
return path.equals(redisPath);
}
}
- SeckillController 控制层,设计从秒杀接口地址隐藏,增加对秒杀接口的地址进行校验。同时这里我们不需要 Model 返回给下一页信息的模板信息了,可以去掉该 Model 参数了。这里我们重新设计,返回的是一个 RespBean 返回信息。
package com.rainbowsea.seckill.controller;
import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.rabbitmq.MQSenderMessage;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBean;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
// 如果某个商品库存已经为空, 则标记到 entryStockMap
@Resource
private RedisTemplate redisTemplate;
// 定义 map- 记录秒杀商品
private HashMap<Long, Boolean> entryStockMap = new HashMap<>();
// 装配消息的生产者/发送者
@Resource
private MQSenderMessage mqSenderMessage;
/**
* 获取秒杀路径
*
* @param user 用户信息
* @param goodsId 秒杀商品ID
* @return RespBean 返回信息,携带秒杀路径 path
*/
@RequestMapping("/path")
@ResponseBody
public RespBean getPath(User user, Long goodsId) {
// 我们的设计的商品 gooodsId 是一定大于 0 的
if (user == null || goodsId < 0) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V 6.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
* - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
* - 优化秒杀: 加入消息队列,实现秒杀的异步请求
* - 秒杀接口地址隐藏
* 这里就不需要 model 返回给下一个模板信息了。
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @param path 秒杀路径
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/{path}/doSeckill")
@ResponseBody
public RespBean doSeckill(@PathVariable("path") String path, User user, Long goodsId) {
System.out.println("秒杀 V 6.0 ");
System.out.println("从客户端发来的 path = " + path);
//检查秒杀生成的路径是否和服务器一致,校验用户携带的 path 是否正确
boolean b = orderService.checkPath(user, goodsId, path);
if (!b) {//如果生成的路径不对,就返回错误页面.
return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
}
if (null == user) { //用户没有登录
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 不需要使用 model 返回给下一个模板信息了。
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() < 1) { // 没有库存,不可以购买
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
// 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsId Value表示订单 seckillOrder),
// 获取对应的秒杀订单,如果有,则说明该
// 用户已经桥抢购了,每人限购一个
SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
// 对map进行判断[内存标记],如果商品在 map 已经标记为没有库存,则直接返回,无需进行 Redis 预减
if (entryStockMap.get(goodsId)) {
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
// 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
// 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
// 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明这个商品已经没有库存了,返回
// 说明当前秒杀的商品,已经没有库存
entryStockMap.put(goodsId, true);
// 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
/*
抢购,向消息队列发送秒杀请求,实现了秒杀异步请求
这里我们发送秒杀消息后,立即快速返回结果【临时结果】- “比如排队中...”
客户端可以通过轮询,获取到最终结果
创建 SeckillMessage
*/
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
// 将 seckillMessage 对象封装为 JSON 格式的 String 让RabbitMQ 生产者发送出去
// 被消费者接受消费
mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
System.out.println("秒杀 V 6.0 ");
return RespBean.error(RespBeanEnum.SKL_KILL_WATT);
}
/**
* InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
* 该方法是在类的所有属性,都是初始化后,自动执行的
* 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
// 获取所有可以秒杀的商品信息
List<GoodsVo> list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
// 初始化 map
// 如果 goodsId: false 表示有库存
// 如果 goodsId: true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
}
前端设计,秒杀接口的隐藏:
测试:和上述测试是一样的;重置 t_seckill_goods 表, 并把生成的订单/包括 redis 中的订单都清空
- 登录- 秒杀页面
- 点击立即秒杀
- 再次点击立即秒杀
- 查看数据库, 发现该账号已经生成了一个订单
后端提示显示
验证码防脚本攻击 happyCaptcha
问题分析:
在一些抢购活动中,可以通过验证码的方式,防止脚本攻击,比如:12306。
解决方案:
使用验证码:happyCaptcha
happyCaptcha 官方文档地址:https://gitee.com/ramostear/Happy-Captcha
设计思路分析示意图
这里我们采用链式调用的方式
加入 happyCaptcha 的 jar 包
<!-- happyCaptcha 验证码-->
<dependency>
<groupId>com.ramostear</groupId>
<artifactId>Happy-Captcha</artifactId>
<version>1.0.1</version>
</dependency>
RespBeanEnum.java ,增加错误类型,枚举
添加:SeckillController.java 类当中,添加一个获取验证码的请求:
生成验证码
- 注意:HappyCaptcha 执行该方法后,会自动默认将验证码放入到 Session 当中。对应 HappyCaptcha验证码的Key为“happy-captcha”。
- 手动清理Session中存放的验证码,HappyCaptcha验证码的Key为“happy-captcha”。
- 这里我们考虑到项目的分布式,如果将验证码存入到 Session 当中,如果采用分布式,不同机器可能
- 登录访问的该验证码就不存在,不同的机器当中,就像我们上面设置的共享 Session 的问题是一样的
- 所以这里我们同时也将 HappyCaptcha验证码的存储到 Redis 当中。Redis 当中验证码的key设计为:captcha:userId:goodsId
- 同时设置超时时间 100s,过后没登录就,该验证码失效
/**
* 生成验证码
* 注意:HappyCaptcha 执行该方法后,会自动默认将验证码放入到 Session 当中。对应 HappyCaptcha验证码的Key为“happy-captcha”。
* 手动清理Session中存放的验证码,HappyCaptcha验证码的Key为“happy-captcha”。
* 这里我们考虑到项目的分布式,如果将验证码存入到 Session 当中,如果采用分布式,不同机器可能
* 登录访问的该验证码就不存在,不同的机器当中,就像我们上面设置的共享 Session 的问题是一样的
* 所以这里我们同时也将 HappyCaptcha验证码的存储到 Redis 当中。Redis 当中验证码的key设计为:captcha:userId:goodsId
* 同时设置超时时间 100s,过后没登录就,该验证码失效
*
* @param request
* @param response
*/
@GetMapping("/captcha")
public void captcha(User user,
Long goodsId
, HttpServletRequest request,
HttpServletResponse response) {
HappyCaptcha.require(request, response)
.style(CaptchaStyle.IMG) //设置展现样式为图片
.type(CaptchaType.NUMBER) //设置验证码内容为数字
.length(5) //设置字符长度为5
.width(220) //设置动画宽度为220
.height(80) //设置动画高度为80
.font(Fonts.getInstance().zhFont()) //设置汉字的字体
.build().finish(); //生成并输出验证码
// 从 Session 当中把验证码的值,保存 Redis当中【考虑项目分布式】,同时设计验证码 100s 失效
// Redis 当中验证码的key设计为:captcha:userId:goodsId
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId,
(String) request.getSession().getAttribute("happy-captcha"),
100, TimeUnit.SECONDS);
}
OrderService 添加校验,验证码的方法
package com.rainbowsea.seckill.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.vo.GoodsVo;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service
* @createDate 2025-04-26 20:48:19
*/
public interface OrderService extends IService<Order> {
/**
* 秒杀
* @param user
* @param goodsVo
* @return Order 账单信息
*/
Order seckill(User user, GoodsVo goodsVo);
/**
* 生成秒杀路径/值(唯一)
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @return String 返回唯一路径
*/
public String createPath(User user,Long goodsId);
/**
* 对秒杀路径进行校验
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @param path 校验的秒杀路径
* @return boolean 秒杀路径正确,返回 true ,否则返回 false
*/
public boolean checkPath(User user,Long goodsId,String path);
/**
* 验证用户输入的验证码是否正确
* @param user 用户信息对象
* @param goodsId 秒杀商品ID
* @param captcha 需要验证的验证码
* @return boolean 通过返回 true,验证失败返回 false
*/
boolean checkCaptcha(User user,Long goodsId,String captcha);
}
OrderServiceImpl.java 实现校验验证码的方法
package com.rainbowsea.seckill.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.rainbowsea.seckill.mapper.OrderMapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillGoods;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillGoodsService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.utill.MD5Util;
import com.rainbowsea.seckill.utill.UUIDUtil;
import com.rainbowsea.seckill.vo.GoodsVo;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* @author huo
* @description 针对表【t_order】的数据库操作Service实现
* @createDate 2025-04-26 20:48:19
*/
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order>
implements OrderService {
@Resource
private SeckillGoodsService seckillGoodsService;
@Resource
private OrderMapper orderMapper;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private RedisTemplate redisTemplate;
/**
* 秒杀商品,减少库存,V1.0 没有进行复购处理
*
* @param user
* @param goodsVo
* @return Order
*/
//@Override
//public Order seckill(User user, GoodsVo goodsVo) {
//
// // 查询后端的库存量进行减一
// SeckillGoods seckillGoods = seckillGoodsService
// .getOne(new QueryWrapper<SeckillGoods>()
// .eq("goods_id", goodsVo.getId()));
//
// // 完成一个基本的秒杀操作【这快不具原子性】,后面在高并发的情况下,还会优化
// seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
// seckillGoodsService.updateById(seckillGoods);
//
// Order order = new Order();
// order.setUserId(user.getId());
// order.setGoodsId(goodsVo.getId());
// order.setDeliveryAddrId(0L); // 这里随便设置了一个初始值
// order.setGoodsName(goodsVo.getGoodsName());
// order.setGoodsCount(1);
// order.setGoodsPrice(seckillGoods.getSeckillPrice());
//
// // 保存 order 账单信息
// orderMapper.insert(order);
//
// // 生成秒杀商品订单~
// SeckillOrder seckillOrder = new SeckillOrder();
// seckillOrder.setGoodsId(goodsVo.getId());
// // 这里秒杀商品订单对应的 order_id 是从上面添加 order 后获取到的
// seckillOrder.setOrderId(order.getId());
// seckillOrder.setUserId(user.getId());
//
// // 保存 seckillOrder
// seckillOrderService.save(seckillOrder);
//
// return order;
//}
/**
* 秒杀商品,减少库存,V2.0 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】 ,
* 添加上 @Transactional,注解进行一个MySQL 默认的事务隔离级别
*
* @param user
* @param goodsVo
* @return Order
*/
@Transactional
@Override
public Order seckill(User user, GoodsVo goodsVo) {
// 查询后端的库存量进行减一
SeckillGoods seckillGoods = seckillGoodsService
.getOne(new QueryWrapper<SeckillGoods>()
.eq("goods_id", goodsVo.getId()));
// 完成一个基本的秒杀操作【这快不具原子性】,后面在高并发的情况下,还会优化
//seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
//seckillGoodsService.updateById(seckillGoods);
//分析
// 1. MySQL 在默认的事务隔离级别 【REPEATABLE-READ】 下
// 2. 执行 update 语句时,会在事务中锁定要更新的行
// 3. 这样可以防止其它会话在同一行执行 update,delete
// 说明: 只要在更新成功时,返回 true,否则返回 false
// column 必须是,数据库表当中的字段,不可以随便写
boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0)); // gt 表示大于
if (!update) { // 如果更新失败,说明已经没有库存了
return null;
}
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L); // 这里随便设置了一个初始值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
// 保存 order 账单信息
orderMapper.insert(order);
// 生成秒杀商品订单~
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
// 这里秒杀商品订单对应的 order_id 是从上面添加 order 后获取到的
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
// 保存 seckillOrder
seckillOrderService.save(seckillOrder);
// 将生成的秒杀订单,存入到 Redis,这样在查询某个用户是否已经秒杀了这个商品时
// 直接到 Redis 中查询,起到优化效果
// key表示:order:userId:goodsId Value表示订单 seckillOrder
redisTemplate.opsForValue().set("order:" + user.getId() + ":" +
goodsVo.getId(),
seckillOrder);
return order;
}
/**
* 生成秒杀路径/值(唯一)
*
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @return String 返回唯一路径
* 同时将生成的路径,存入到 Redis 当中,同时设计以一个失效时间 60s,该时间内没访问,就失效
*/
@Override
public String createPath(User user, Long goodsId) {
// 生成秒杀路径/值唯一
String path = MD5Util.md5(UUIDUtil.uuid());
// 将随机生成的路径保存到 Redis,同时设置一个超时时间 60s,
// 60s 不访问,这个秒杀路径就失效
// Redis 当中 key 的设计: seckillPath:userId:goodsId
redisTemplate.opsForValue().set("seckillPath:"
+ user.getId() + ":" + goodsId, path, 60, TimeUnit.SECONDS);
return path;
}
/**
* 对秒杀路径进行校验
*
* @param user 用户对象
* @param goodsId 对应秒杀商品ID
* @param path 校验的秒杀路径
* @return boolean 秒杀路径正确,返回 true ,否则返回 false
*/
@Override
public boolean checkPath(User user, Long goodsId, String path) {
if (user == null || goodsId < 0 || !StringUtils.hasText(path)) {
return false;
}
// 从 Redis 当中获取该用户秒杀该商品的路径
String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:"
+ user.getId() + ":" + goodsId);
// 判断这两个路径是否相同,相同说明正确,不相同说明错误
return path.equals(redisPath);
}
/**
* 验证用户输入的验证码是否正确
*
* @param user 用户信息对象
* @param goodsId 秒杀商品ID
* @param captcha 需要验证的验证码
* @return boolean 通过返回 true,验证失败返回 false
*/
@Override
public boolean checkCaptcha(User user, Long goodsId, String captcha) {
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return false;
}
// 从 Redis 取出验证码,注意:怎么存的key,就怎么取
String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);
return captcha.equals(redisCaptcha);
}
}
在控制层:SckillController.java , 增加对客户端携带的验证码的校验
package com.rainbowsea.seckill.controller;
import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.rabbitmq.MQSenderMessage;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBean;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import com.ramostear.captcha.HappyCaptcha;
import com.ramostear.captcha.common.Fonts;
import com.ramostear.captcha.support.CaptchaStyle;
import com.ramostear.captcha.support.CaptchaType;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
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.ResponseBody;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
// 如果某个商品库存已经为空, 则标记到 entryStockMap
@Resource
private RedisTemplate redisTemplate;
// 定义 map- 记录秒杀商品
private HashMap<Long, Boolean> entryStockMap = new HashMap<>();
// 装配消息的生产者/发送者
@Resource
private MQSenderMessage mqSenderMessage;
/**
* 获取秒杀路径
*
* @param user 用户信息
* @param goodsId 秒杀商品ID
* @return RespBean 返回信息,携带秒杀路径 path
* -v 2.0 增加了 happyCaptcha 验证码
*/
@RequestMapping("/path")
@ResponseBody
public RespBean getPath(User user, Long goodsId, String captcha) {
// 我们的设计的商品 gooodsId 是一定大于 0 的
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 增加一个业务逻辑-校验用户输入的验证码是否正确
boolean check = orderService.checkCaptcha(user, goodsId, captcha);
if (!check) {
return RespBean.error(RespBeanEnum.CAPTCHA_ERROR);
}
String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V 6.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
* - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
* - 优化秒杀: 加入消息队列,实现秒杀的异步请求
* - 秒杀接口地址隐藏
* - happyCaptcha 验证码
* 这里就不需要 model 返回给下一个模板信息了。
*
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @param path 秒杀路径
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/{path}/doSeckill")
@ResponseBody
public RespBean doSeckill(@PathVariable("path") String path, User user, Long goodsId) {
System.out.println("秒杀 V 6.0 ");
System.out.println("从客户端发来的 path = " + path);
//检查秒杀生成的路径是否和服务器一致,校验用户携带的 path 是否正确
boolean b = orderService.checkPath(user, goodsId, path);
if (!b) {//如果生成的路径不对,就返回错误页面.
return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
}
if (null == user) { //用户没有登录
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 不需要使用 model 返回给下一个模板信息了。
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() < 1) { // 没有库存,不可以购买
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
// 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsId Value表示订单 seckillOrder),
// 获取对应的秒杀订单,如果有,则说明该
// 用户已经桥抢购了,每人限购一个
SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
// 对map进行判断[内存标记],如果商品在 map 已经标记为没有库存,则直接返回,无需进行 Redis 预减
if (entryStockMap.get(goodsId)) {
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
// 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
// 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
// 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明这个商品已经没有库存了,返回
// 说明当前秒杀的商品,已经没有库存
entryStockMap.put(goodsId, true);
// 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
/*
抢购,向消息队列发送秒杀请求,实现了秒杀异步请求
这里我们发送秒杀消息后,立即快速返回结果【临时结果】- “比如排队中...”
客户端可以通过轮询,获取到最终结果
创建 SeckillMessage
*/
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
// 将 seckillMessage 对象封装为 JSON 格式的 String 让RabbitMQ 生产者发送出去
// 被消费者接受消费
mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
System.out.println("秒杀 V 6.0 ");
return RespBean.error(RespBeanEnum.SKL_KILL_WATT);
}
/**
* InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
* 该方法是在类的所有属性,都是初始化后,自动执行的
* 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
// 获取所有可以秒杀的商品信息
List<GoodsVo> list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
// 初始化 map
// 如果 goodsId: false 表示有库存
// 如果 goodsId: true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
/**
* 生成验证码
* 注意:HappyCaptcha 执行该方法后,会自动默认将验证码放入到 Session 当中。对应 HappyCaptcha验证码的Key为“happy-captcha”。
* 手动清理Session中存放的验证码,HappyCaptcha验证码的Key为“happy-captcha”。
* 这里我们考虑到项目的分布式,如果将验证码存入到 Session 当中,如果采用分布式,不同机器可能
* 登录访问的该验证码就不存在,不同的机器当中,就像我们上面设置的共享 Session 的问题是一样的
* 所以这里我们同时也将 HappyCaptcha验证码的存储到 Redis 当中。Redis 当中验证码的key设计为:captcha:userId:goodsId
* 同时设置超时时间 100s,过后没登录就,该验证码失效
*
* @param request
* @param response
*/
@GetMapping("/captcha")
public void captcha(User user,
Long goodsId
, HttpServletRequest request,
HttpServletResponse response) {
HappyCaptcha.require(request, response)
.style(CaptchaStyle.IMG) //设置展现样式为图片
.type(CaptchaType.NUMBER) //设置验证码内容为数字
.length(5) //设置字符长度为5
.width(220) //设置动画宽度为220
.height(80) //设置动画高度为80
.font(Fonts.getInstance().zhFont()) //设置汉字的字体
.build().finish(); //生成并输出验证码
// 从 Session 当中把验证码的值,保存 Redis当中【考虑项目分布式】,同时设计验证码 100s 失效
// Redis 当中验证码的key设计为:captcha:userId:goodsId
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId,
(String) request.getSession().getAttribute("happy-captcha"),
100, TimeUnit.SECONDS);
}
}
前端获取验证码修改:
测试:
- 登录- 秒杀页面
- 先输入错误的验证码,应该返回验证码错误
- 输入正确的验证码,应该返回排队中
- 再次输入正确的验证码,点击秒杀, 应该返回 该商品每人限购一件
- 查看数据库, 发现该账号已经生成了一个订单
秒杀接口限流-防刷
完成接口限流-防止某个用户频繁的请求秒杀接口
比如在短时间内,频繁点击立即秒杀, 示意图
思路分析/图解
Redis 存储的 key 设计为 uri 对应的路径+userID
简单接口限流-防刷
- 使用简单的 Redis 计数器, 完成接口限流防刷
- 除了计数器算法,也有其它的算法来进行接口限流, 比如漏桶算法和令牌桶算法(参考:https://zhuanlan.zhihu.com/p/165006444))
- 令牌桶算法, 相对比较主流, 可以关注一下.
SckillController.java 添加上接口限流
存储到 Redis 当中,key uri + “:” + user.getId()
// 增加业务逻辑: 加入 Redis 计数器,完成对用户的限流防刷
// 比如: 5 秒内访问次数超过 5 次,我们就认为是刷接口
// 这里老师先把代码写在方法中,后面我们使用注解提高使用的通用性
// uri 就是 localhost:8080/seckill/path 当中的 /seckill/path
/**
* 获取秒杀路径
*
* @param user 用户信息
* @param goodsId 秒杀商品ID
* @return RespBean 返回信息,携带秒杀路径 path
* -v 3.0 增加了 happyCaptcha 验证码
* - 增加 Redis 计数器,完成对用户的限流防刷
*/
@RequestMapping("/path")
@ResponseBody
public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) {
// 我们的设计的商品 gooodsId 是一定大于 0 的
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 增加业务逻辑: 加入 Redis 计数器,完成对用户的限流防刷
// 比如: 5 秒内访问次数超过 5 次,我们就认为是刷接口
// 这里老师先把代码写在方法中,后面我们使用注解提高使用的通用性
// uri 就是 localhost:8080/seckill/path 当中的 /seckill/path
String uri = request.getRequestURI();
ValueOperations valueOperations = redisTemplate.opsForValue();
// 存储到 Redis 当中,key uri + ":" + user.getId()
String key = uri + ":" + user.getId();
Integer count = (Integer) valueOperations.get(key);
if (count == null) { // 说明还没有 key,就初始化: 值为 1,过期时间为 5 秒
valueOperations.set(key, 1, 5, TimeUnit.SECONDS);
} else if (count < 5) { // 说明正常访问
valueOperations.increment(key); // -1
} else { // > 5 说明用户在刷接口
return RespBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED);
}
// 增加一个业务逻辑-校验用户输入的验证码是否正确
boolean check = orderService.checkCaptcha(user, goodsId, captcha);
if (!check) {
return RespBean.error(RespBeanEnum.CAPTCHA_ERROR);
}
String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
package com.rainbowsea.seckill.controller;
import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.rabbitmq.MQSenderMessage;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBean;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import com.ramostear.captcha.HappyCaptcha;
import com.ramostear.captcha.common.Fonts;
import com.ramostear.captcha.support.CaptchaStyle;
import com.ramostear.captcha.support.CaptchaType;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
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.ResponseBody;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
// 如果某个商品库存已经为空, 则标记到 entryStockMap
@Resource
private RedisTemplate redisTemplate;
// 定义 map- 记录秒杀商品
private HashMap<Long, Boolean> entryStockMap = new HashMap<>();
// 装配消息的生产者/发送者
@Resource
private MQSenderMessage mqSenderMessage;
/**
* 获取秒杀路径
*
* @param user 用户信息
* @param goodsId 秒杀商品ID
* @return RespBean 返回信息,携带秒杀路径 path
* -v 3.0 增加了 happyCaptcha 验证码
* - 增加 Redis 计数器,完成对用户的限流防刷
*/
@RequestMapping("/path")
@ResponseBody
public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) {
// 我们的设计的商品 gooodsId 是一定大于 0 的
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 增加业务逻辑: 加入 Redis 计数器,完成对用户的限流防刷
// 比如: 5 秒内访问次数超过 5 次,我们就认为是刷接口
// 这里老师先把代码写在方法中,后面我们使用注解提高使用的通用性
// uri 就是 localhost:8080/seckill/path 当中的 /seckill/path
String uri = request.getRequestURI();
ValueOperations valueOperations = redisTemplate.opsForValue();
// 存储到 Redis 当中,key uri + ":" + user.getId()
String key = uri + ":" + user.getId();
Integer count = (Integer) valueOperations.get(key);
if (count == null) { // 说明还没有 key,就初始化: 值为 1,过期时间为 5 秒
valueOperations.set(key, 1, 5, TimeUnit.SECONDS);
} else if (count < 5) { // 说明正常访问
valueOperations.increment(key); // -1
} else { // > 5 说明用户在刷接口
return RespBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED);
}
// 增加一个业务逻辑-校验用户输入的验证码是否正确
boolean check = orderService.checkCaptcha(user, goodsId, captcha);
if (!check) {
return RespBean.error(RespBeanEnum.CAPTCHA_ERROR);
}
String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V 6.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
* - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
* - 优化秒杀: 加入消息队列,实现秒杀的异步请求
* - 秒杀接口地址隐藏
* - happyCaptcha 验证码
* 这里就不需要 model 返回给下一个模板信息了。
*
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @param path 秒杀路径
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/{path}/doSeckill")
@ResponseBody
public RespBean doSeckill(@PathVariable("path") String path, User user, Long goodsId) {
System.out.println("秒杀 V 6.0 ");
System.out.println("从客户端发来的 path = " + path);
//检查秒杀生成的路径是否和服务器一致,校验用户携带的 path 是否正确
boolean b = orderService.checkPath(user, goodsId, path);
if (!b) {//如果生成的路径不对,就返回错误页面.
return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
}
if (null == user) { //用户没有登录
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 不需要使用 model 返回给下一个模板信息了。
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() < 1) { // 没有库存,不可以购买
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
// 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsId Value表示订单 seckillOrder),
// 获取对应的秒杀订单,如果有,则说明该
// 用户已经桥抢购了,每人限购一个
SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
// 对map进行判断[内存标记],如果商品在 map 已经标记为没有库存,则直接返回,无需进行 Redis 预减
if (entryStockMap.get(goodsId)) {
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
// Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
// 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
// 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
// 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) { // 说明这个商品已经没有库存了,返回
// 说明当前秒杀的商品,已经没有库存
entryStockMap.put(goodsId, true);
// 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.ENTRY_STOCK);
}
/*
抢购,向消息队列发送秒杀请求,实现了秒杀异步请求
这里我们发送秒杀消息后,立即快速返回结果【临时结果】- “比如排队中...”
客户端可以通过轮询,获取到最终结果
创建 SeckillMessage
*/
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
// 将 seckillMessage 对象封装为 JSON 格式的 String 让RabbitMQ 生产者发送出去
// 被消费者接受消费
mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
System.out.println("秒杀 V 6.0 ");
return RespBean.error(RespBeanEnum.SKL_KILL_WATT);
}
/**
* InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
* 该方法是在类的所有属性,都是初始化后,自动执行的
* 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
// 获取所有可以秒杀的商品信息
List<GoodsVo> list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
// 初始化 map
// 如果 goodsId: false 表示有库存
// 如果 goodsId: true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
/**
* 生成验证码
* 注意:HappyCaptcha 执行该方法后,会自动默认将验证码放入到 Session 当中。对应 HappyCaptcha验证码的Key为“happy-captcha”。
* 手动清理Session中存放的验证码,HappyCaptcha验证码的Key为“happy-captcha”。
* 这里我们考虑到项目的分布式,如果将验证码存入到 Session 当中,如果采用分布式,不同机器可能
* 登录访问的该验证码就不存在,不同的机器当中,就像我们上面设置的共享 Session 的问题是一样的
* 所以这里我们同时也将 HappyCaptcha验证码的存储到 Redis 当中。Redis 当中验证码的key设计为:captcha:userId:goodsId
* 同时设置超时时间 100s,过后没登录就,该验证码失效
*
* @param request
* @param response
*/
@GetMapping("/captcha")
public void captcha(User user,
Long goodsId
, HttpServletRequest request,
HttpServletResponse response) {
HappyCaptcha.require(request, response)
.style(CaptchaStyle.IMG) //设置展现样式为图片
.type(CaptchaType.NUMBER) //设置验证码内容为数字
.length(5) //设置字符长度为5
.width(220) //设置动画宽度为220
.height(80) //设置动画高度为80
.font(Fonts.getInstance().zhFont()) //设置汉字的字体
.build().finish(); //生成并输出验证码
// 从 Session 当中把验证码的值,保存 Redis当中【考虑项目分布式】,同时设计验证码 100s 失效
// Redis 当中验证码的key设计为:captcha:userId:goodsId
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId,
(String) request.getSession().getAttribute("happy-captcha"),
100, TimeUnit.SECONDS);
}
}
测试:登录- 秒杀页面
通用接口限流-防刷-封装为-一个注解搞定
- 自定义注解@AccessLimit, 提高接口限流功能通用性 , 减少冗余代码, 同时也减少业务代码入侵.
ThreadLocal 的作用,可以实现在同一个线程数据共享,从而解决多线程数据共享问题。
ThreadLocal 可以给当前线程关联一个数据(普通变量,对象,数组) set 方法
ThreadLocal 可以像 Map 一样存取数据,key 为当前线程,get 方法
每一个 ThreadLocal 对象,只能为当前线程关联一个数据,如果要为当前线程数据,就需要使用多个 ThreadLocal 对象实例。
创建 @AccessLimit
,作为自定义注解
package com.rainbowsea.seckill.config;
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 {
int second(); // 时间范围
int maxCount(); // 访问的最大次数
boolean needLogin() default true; // 是否登录
}
SeckillController 控制层上,使用上我们自定义的注解,进行一个接口限流
/**
* 获取秒杀路径
*
* @param user 用户信息
* @param goodsId 秒杀商品ID
* @return RespBean 返回信息,携带秒杀路径 path
* -v 4.0 增加了 happyCaptcha 验证码
* - 增加 Redis 计数器,完成对用户的限流防刷
* - 通用接口限流-防刷-封装为-一个注解搞定
* second = 5, maxCount = 5 说明是在 5 秒内可以访问的最大次数是 5 次
* needLogin = true 表示用户是否需要登录,true 表示用户需要登录
*/
@RequestMapping("/path")
@ResponseBody
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request) {
// 我们的设计的商品 gooodsId 是一定大于 0 的
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
// 增加一个业务逻辑-校验用户输入的验证码是否正确
boolean check = orderService.checkCaptcha(user, goodsId, captcha);
if (!check) {
return RespBean.error(RespBeanEnum.CAPTCHA_ERROR);
}
String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
创建 UserContext 作为,用来存储拦截器获取的 user 对象,存储到 ThreadLocal 线程当中。
每个线程都有自己的 ThreadLocal ,把共享数据存放到这里,保证线程安全
package com.rainbowsea.seckill.config;
import com.rainbowsea.seckill.pojo.User;
/**
* 用来存储拦截器获取的 user对象.
*/
public class UserContext {
// 每个线程都有自己的 ThreadLocal ,把共享数据存放到这里,保证线程安全
private static ThreadLocal<User> userHolder = new ThreadLocal<>();
public static User getUser() {
return userHolder.get();
}
public static void setUser(User user) {
userHolder.set(user);
}
}
创建 AccessLimitInterceptor,自定义的拦截器,在项目访问的时候就被拦截了最开始的位置。
package com.rainbowsea.seckill.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.utill.CookieUtil;
import com.rainbowsea.seckill.vo.RespBean;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.concurrent.TimeUnit;
/**
* 自定义的拦截器
*/
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
// 装配需要的组件/对象
@Resource
private UserService userService;
@Resource
private RedisTemplate redisTemplate;
/**
* 拦截器最前面执行
*
* @param request
* @param response
* @param handler
* @return boolean 是否放行
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
// 这里我们就先获取到登录的 user 对象
User user = getUser(request, response);
// 存入到 ThreadLocal
UserContext.setUser(user);
// 把handler 转成 HandlerMethod
HandlerMethod hm = (HandlerMethod) handler;
// 获取到目标方法的注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if (accessLimit == null) { // 如果目标方法没有 @AccessLimit 注解,说明接口并没有处理限流防刷
return true;
}
// 获取注解的值
int second = accessLimit.second();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
if (needLogin) { // 说明用户必须登录才能访问目标方法/接口
if (user == null) { // 说明用户没有登录
// 返回一个用户信息错误的提示,
this.render(response, RespBeanEnum.SESSION_ERROR);
return false; // 返回
}
}
// 增加业务逻辑: 加入 Redis 计数器,完成对用户的限流防刷
// 比如: 5 秒内访问次数超过 5 次,我们就认为是刷接口
// 这里老师先把代码写在方法中,后面我们使用注解提高使用的通用性
// uri 就是 localhost:8080/seckill/path 当中的 /seckill/path
String uri = request.getRequestURI();
ValueOperations valueOperations = redisTemplate.opsForValue();
// 存储到 Redis 当中,key uri + ":" + user.getId()
String key = uri + ":" + user.getId();
Integer count = (Integer) valueOperations.get(key);
if (count == null) { // 说明还没有 key,就初始化: 值为 1,过期时间为 5 秒
valueOperations.set(key, 1, second, TimeUnit.SECONDS);
} else if (count < maxCount) { // 说明正常访问
valueOperations.increment(key); // -1
} else { // > 5 说明用户在刷接口
// 返回一个用户信息错误的提示,
this.render(response, RespBeanEnum.ACCESS_LIMIT_REACHED);
return false; // 返回
}
}
// 最后,都没啥限制,就放行
return true;
}
/**
* 构建返回对象-以流的形式返回,上述 preHandle 的返回信息给前端
*
* @param response
* @param respBeanEnum
*/
private void render(HttpServletResponse response, RespBeanEnum respBeanEnum) throws IOException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
PrintWriter out = response.getWriter();
// 构建 RespBean
RespBean error = RespBean.error(respBeanEnum);
out.write(new ObjectMapper().writeValueAsString(error));
out.flush();
out.close();
}
/**
* 获取到 Cookie 当中存储的 User 对象,注意:key 是 "userTicket" 不可以随便写
*
* @param request
* @param response
* @return User
*/
private User getUser(HttpServletRequest request, HttpServletResponse response) {
String ticket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(ticket)) {
return null; // 说明该用户没有登录,直接返回 null
}
return userService.getUserByCookieByRedis(ticket, request, response);
}
}
WebConfig,将我们自定义的 AccessLimitInterceptor 拦截器,注册拦截器当中,才有效。
package com.rainbowsea.seckill.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import java.util.List;
/**
* @author RainbowSea
* @version 1.0
*/
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
//装配
@Resource
private UserArgumentResolver userArgumentResolver;
// 装配自己编写的自定义拦截器
@Resource
private AccessLimitInterceptor accessLimitInterceptor;
// 将自定义的拦截器装配进去才有效果
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessLimitInterceptor);
}
//静态资源加载
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
//这里加入我们自定义的解析器到 HandlerMethodArgumentResolver列表中
//这样自定义的解析器工作
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
修改:UserArgumentResolver 自定义的一个解析器,解析 User 对象
// 修改User 对象直接从 UserContext.getUser() 当前获取即可。
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
// 下面计数器优化的时候通过拦截器获取了 user,存入了 threadlocal,
// 直接从 threadlocal里拿
return UserContext.getUser();
}
package com.rainbowsea.seckill.config;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.UserService;
import com.rainbowsea.seckill.utill.CookieUtil;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author Rainbowsea
* @version 1.0
* UserArgumentResolver: 自定义的一个解析器
*/
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
//装配 UserService
@Resource
private UserService userService;
//判断你当前要解析的参数类型是不是你需要的?
@Override
public boolean supportsParameter(MethodParameter parameter) {
//获取参数是不是user类型
Class<?> aClass = parameter.getParameterType();
//如果为t, 就执行resolveArgument
// 如果其他的 对象类,可以使用 if 语句进行一个判断
return aClass == User.class;
}
// 修改User 对象直接从 UserContext.getUser() 当前获取即可。
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
// 下面计数器优化的时候通过拦截器获取了 user,存入了 threadlocal,
// 直接从 threadlocal里拿
return UserContext.getUser();
}
}
测试:
6. 最后:
“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”