🎄说明
高并发场景实战 将运用于我的 第二个 个人项目(链接如下)
🎄高并发场景概述
高并发业务是指系统在同一时间段内需要处理大量的并发请求。为了保证系统能够处理这些请求,并且不因为请求过多而导致系统崩溃或性能下降,需要采取一些有效的手段来分担压力并提高系统的可扩展性
高并发 其实主要解决两个问题,一个是并发读,一个是并发写
并发读的核心优化理念是尽量减少用户到 DB 来"读"数据,或者让他们读更少的数据, 并发写的处理原则也一样(尽量减少向DB写数据)
针对高并发系统需要做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生(例如如果当前请求太多处理不过来,可以提示用户稍后再次请求,经过短暂的调整,能保证系统在后面的用户请求中依然能正确响应)
系统架构要满足🎯高可用: 流量符合预期时要稳定,要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提
系统保证🎯数据的一致性: 就是秒杀 10 个 商品 ,那就只能成交 10 个商品,多一个少一个都不行 一旦库存不对,就要承担损失
系统要满足🎯高性能 也就是系统的性能要足够高,需要支撑大流量 不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点整个系统就快了(不要只针对一个地方进行优化,否则效果可能并不明显)
秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键 对应的方案比如🌰
页面缓存方案
🌰Redis预减库存
🌰内存标记与隔离
🌰请求的削峰 ( RabbitMQ 异步请求 )
🌰分布式Session共享
等
❓解决高并发业务常用的技术手段有哪些?
问一下chatGPT
- 使用消息队列:消息队列可以作为缓冲层,将高并发请求暂时存储在队列中,逐个进行处理,从而平滑控制系统的并发压力。
- 分布式缓存:通过将热点数据和频繁访问的数据存储到分布式缓存中,可以减轻数据库的压力,提高系统的读取性能和并发能力。
- 负载均衡:通过将请求分发到多台服务器上,使得每台服务器处理的请求数量和压力相对均衡,提高系统的并发处理能力和可靠性。
- 数据库优化:通过合理的数据库设计、使用索引、优化SQL查询语句等手段提高数据库的读写性能和并发能力。
- 水平扩展:通过增加服务器数量来水平扩展系统的处理能力,将负载分散到多台服务器上,提高系统的并发处理能力和可扩展性。
- 微服务架构:将系统拆分成多个小的独立服务,每个服务处理特定的功能,通过服务之间的调用和协作来处理高并发请求,提高系统的可扩展性和容错性。
- CDN加速:使用内容分发网络(CDN),将静态资源分发到离用户更近的节点上,提高用户访问速度和系统的并发处理能力。
❓我会在第二个个人项目中使用哪些技术?
🎯分布式缓存(Redis)
(1)在项目中,我会将秒杀商品的信息放入Redis,以应对高并发的信息获取;
(2)在用户点击购买的时候,先从Redis的库存判断是否还有剩余,如果库存已经为0则直接返回;如果还有剩余,则进行库存预减(真真的库存操作在MySQL执行)
🎯使用消息队列(RabbitMQ)
在Redis
判断库存不为空并执行预减之后将用户的购买请求放入RabbitMQ
消息队列中,之后由MessageReceiver
来执行最后的购买操作(更新MySQL的数据)
🎄Redis缓存秒杀商品信息具体操作
🍭思路描述和代码
(1)每次请求秒杀商品信息(商品详情页面)的时候 先去看Redis中有没有这个商品的信息 有的话直接返回 没有的话执行第2步
(2)从MySQL数据库中获取秒杀商品的信息 计算出要显示的商品信息(包括 秒杀开始、结束时间、秒杀状态等信息)然后将其存入Redis以便下一次直接从Redis中获取 同时设定存储时间为60s 每60s重新设定Redis的存储信息
/**
* 20230907 获取秒杀商品信息(从Redis缓存)
* @param commId 传入商品的id
* @return SeckillCommDto 返回秒杀商品的信息
*/
@RequestMapping(value = "/getSeckillComm") // , produces = "text/html;charset=utf-8"
@ResponseBody
public SeckillCommDto getSeckillComm(@RequestParam("id") Integer commId){
System.out.println("C CommodityController M getSeckillComm()..");
// 先到redis获取数据 如果有 则直接返回数据
ValueOperations valueOperations = redisTemplate.opsForValue();
// 反序列化获取SeckillCommDto对象
SeckillCommDto rst = (SeckillCommDto) valueOperations.get("seckillCommInfo:" + commId);
// 不为null且字符串不为""则直接返回
if (rst != null && StringUtils.hasText(rst.toString())) {
return rst;
}
// 获取秒杀商品SeckillCommDto
SeckillCommDto comm = cm.getSeckillComm(commId);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date sd = comm.getStartDate();
Date ed = comm.getEndDate();
comm.setSD(sdf.format(sd));
comm.setED(sdf.format(ed));
System.out.println(comm);
/****** 设置秒杀状态和秒杀剩余时间 并返回给前端 *****/
String skStatus = null; // 0 未开始 1 进行中 2 已结束
int reSeconds = 0; // >0 未开始 =0 进行中 <0 已结束
Date nowDate = new Date(); // 当前时间
Date startDate = comm.getStartDate(); // 秒杀开始时间
Date endDate = comm.getEndDate(); // 秒杀结束时间
// ---- nowDate ----- startDate -----> x
if (nowDate.before(startDate)) { // 秒杀未开始
skStatus = "NOTSTART";
reSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) { // 秒杀已结束
skStatus = "FINISHED";
reSeconds = -1;
} else { //秒杀进行中
skStatus = "GOING";
reSeconds = 0;
}
comm.setReSeconds(reSeconds);
// 设置商品的当前库存
comm.setStockCount((Integer)redisTemplate.opsForValue().get("seckillGoods:" + commId));
// 将商品信息保存到redis 设置每60s更新一次 页面60s失效 Redis会清除该信息
valueOperations.set("seckillCommInfo:" + commId, comm, 60, TimeUnit.SECONDS);
return comm;
}
🎄Redis缓存秒杀商品的库存 实现库存预减
🎯项目初始化的时候 SeckillController
类实现InitializingBean
接口 将秒杀商品的库存存入Redis
/**
@author Liu Xianmeng
@createTime 2023/5/25 11:05
@instruction
*/
@SuppressWarnings({"all"})
@Controller
@Slf4j
public class SeckillController implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
//查询所有的秒杀商品
List<SeckillCommodity> list = sm.getAllSKComms();
//先判断是否为空
if (CollectionUtils.isEmpty(list)) return;
//遍历List 然后将秒杀商品的库存量,放入到Redis 秒杀商品库存量对应 KEY = seckillGoods:商品id
list.forEach(comm -> {
/**
* 存入Redis的键和值
* 【KEY】 seckillGoods:商品id
* 【VAL】 comm.getStockCount()
*/
redisTemplate.opsForValue()
.set("seckillGoods:" + comm.getGoodsId(), comm.getStockCount());
});
}
}
🎯当判断用户并非是重复购买 则获取分布式锁 如果库存已经为0 则直接返回 否则执行库存预减 ❓这里为什么说是库存预减?因为真正的MySQL表库存更新的操作会交给RabbitMQ异步消息来执行
/**
* 2 查询用户是否重复购买
*
* 这个判断方法可以进一步改进 例如在所有的秒杀商品中增加一个字段
* 用于标识不同活动 在判断用户是否复购时增加这个字段的判断
*
* 直接从redis中取用户的购买记录信息而不是MySQL数据库 优化查询效率
*/
log.info("查询用户是否重复购买");
Integer purchaseRecord = (Integer)redisTemplate.opsForValue().get(userId + ":" + commId);
if(purchaseRecord != null){
msg.setCode(403);
msg.setMsg("您已经购买过此商品 不能重复购买~");
return msg;
}
// 获取分布式锁
log.info("获取分布式锁");
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if(lock) {
// 释放锁准备 为了防止误删除其它用户的锁 先判断当前的锁是不是前面获取到的锁 如果相同 再释放
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
// 从redis获取商品的库存并判断 然后预减库存
Integer stock = (Integer)redisTemplate.opsForValue().get("seckillGoods:" + commId);
if(stock == 0) {
emptyStockMap.put(commId, true); // 此用户购买完成后存储就为空了
msg.setCode(401);
msg.setMsg("该商品已售罄~");
return msg;
} else {
// 库存预减
log.info("库存预减");
redisTemplate.opsForValue().set("seckillGoods:" + commId, stock - 1);
}
// 释放分布式锁
redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
/**
* 释放锁之后 用 RabbitMQ 消息队列来处理最终的购买业务
* 代码执行到这里 说明用户抢到了一个储存 只要后面的消息队列数据库操作成功 就购买成功
*/
Gson gson = new Gson();
log.info("将秒杀操作交给消息队列");
SeckillMessage seckillMessage = new SeckillMessage(userId, commId);
mqSenderMessage.senderMessage(JSONUtil.toJsonStr(seckillMessage)); // 发出去之后 由 Receiver 进行处理
msg.setCode(200);
msg.setMsg("秒杀排队中,请稍后~");
} else {
msg.setCode(409);
msg.setMsg("获取分布式锁失败,秒杀失败,请稍后重试~");
}
🍭可以继续优化的地方
(1)可以直接把秒杀商品的页面存入Redis 进一步提升响应速度
(2)重构使用微服务等
🎄RabbitMQ请求削峰具体操作
当Redis执行库存预减之后,MySQL表的更新就交给RabbitMQ的异步消息来解决了 接下来我们来看RabbitMQ的相关具体操作 需要配置RabbitMQ的各个组件 包括消息实体、生产者、队列和消费者
🎯新建SeckillMessage
类,作为消息队列传递的消息实体
package com.xmall.bean;
/**
@author Liu Xianmeng
@createTime 2023/6/1 15:44
@instruction 秒杀消息实体 用在 RabbitMQ 的优化秒杀中
*/
@SuppressWarnings({"all"})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {
private String userId; // 用户的id
private Integer goodsId; // 用户请求秒杀商品的id
}
🎯新建SecKillConfig
类 配置队列、交换机和Binding对象
/**
@author Liu Xianmeng
@createTime 2023/6/3 21:25
@instruction 秒杀配置Bean
*/
@SuppressWarnings({"all"})
@Configuration
public class SecKillConfig {
private static final String QUEUE = "seckillQueue";
private static final String EXCHANGE = "seckillExchange";
@Bean // 配置队列 Bean
public Queue queue_seckill() {
return new Queue(QUEUE);
}
@Bean // 配置交换机 Bean
public TopicExchange topicExchange_seckill() {
return new TopicExchange(EXCHANGE);
}
@Bean // 配置队列绑定交换机 Bean 这里使用RabbitMQ的topic模式 相当于路由模式的模糊匹配
public Binding binding_seckill() {
return BindingBuilder
.bind(queue_seckill())
.to(topicExchange_seckill())
.with("seckill.#"); // topic模式
}
}
🎯新建MQSenderMessage
类 作为RabbitMQ的生产者对象
/**
@author Liu Xianmeng
@createTime 2023/6/3 21:29
@instruction 生产者
*/
@SuppressWarnings({"all"})
@Service
@Slf4j
public class MQSenderMessage {
@Resource
RabbitTemplate rabbitTemplate;
//发送秒杀信息
public void senderMessage(String seckillMessage) {
log.info("发送消息:" + seckillMessage.toString());
rabbitTemplate.convertAndSend(
"seckillExchange", // 指定发送给哪个交换机
"seckill.message", // 指定发送给哪个路由
seckillMessage // 指定发送的消息实体
);
}
}
🎯新建MQReceiverMessage
类 作为RabbitMQ的消费者对象
❓为什么最终的秒杀方法没有做异常处理?因为Redis已经执行了库存的预减 所以只要程序走到这里 就说明库存一定是够的!
/**
@author Liu Xianmeng
@createTime 2023/6/4 10:38
@instruction 秒杀消费者
*/
@SuppressWarnings({"all"})
@Service
@Slf4j
public class MQReceiverMessage {
@Resource
SeckillService ss;
// 接收消息 并完成下单
@RabbitListener(queues = "seckillQueue") // 监听的队列
public void queue(String message) {
log.info("接收到的消息是-->" + message);
// 将字符串消息对象转化为JavaBean对象SeckillMessage
SeckillMessage seckillMessage = JSONUtil.toBean(message, SeckillMessage.class);
// 获取请求秒杀商品的用户id
String userId = seckillMessage.getUserId();
// 获取用户要秒杀的商品id
Integer commId = seckillMessage.getGoodsId();
// 最终秒杀方法
ss.seckill(userId, commId);
}
}
✨以上便是RabbitMQ在我的项目中的应用过程
🎄其他优化
除了Redis的缓存的应用 我们还可以在项目中使用HashMap作为🎯缓存标记
@Controller
@Slf4j
public class SeckillController implements InitializingBean {
// 如果某个商品库存已经为空 则标记到 entryStockMap 键为商品id 值为该商品是否已售罄
private HashMap<Integer, Boolean> emptyStockMap = new HashMap<>();
@Override
public void afterPropertiesSet() throws Exception {
// 查询所有的秒杀商品
List<SeckillCommodity> list = sm.getAllSKComms();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) return;
// 遍历List 然后将秒杀商品的库存量 放入到Redis
// 秒杀商品库存量对应的KEY = seckillGoods:商品id
list.forEach(comm -> {
/**
* 存入Redis的键和值
* 【KEY】 seckillGoods:商品id
* 【VAL】 comm.getStockCount()
*/
redisTemplate.opsForValue()
.set("seckillGoods:" + comm.getGoodsId(), comm.getStockCount());
// 🎯初始化库存为 0 的映射 map
emptyStockMap.put(comm.getGoodsId(), false);
});
}
}
做这样一个标记 就可以直接在内存中判断库存是否为空 从而更快地响应请求
🎄秒杀安全
⚡使用验证码防止脚本攻击
查看秒杀商品详情页面的时候生成验证码并显示
// 生成校验码
@GetMapping("/seckill/captcha/{commId}")
public void happyCaptcha(@RequestParam("userId") String userId,
@PathVariable("commId") Integer commId,
HttpServletRequest request,
HttpServletResponse response) {
System.out.println("C SeckillController M happyCaptcha commId=" + commId);
HappyCaptcha.require(request, response)
.style(CaptchaStyle.ANIM) //设置展现样式为动画
.type(CaptchaType.NUMBER) //设置验证码内容为数字
.length(6) //设置字符长度为 6
.width(220) //设置动画宽度为 220
.height(80) //设置动画高度为 80
.font(Fonts.getInstance().zhFont()) //设置汉字的字体
.build().finish(); //生成并输出验证码
//把验证码的值存放到 Redis 中, userId+goodsId, 有效时间为 100s
redisTemplate.opsForValue().set
("captcha:" + userId + ":" + commId, (String)
request.getSession().getAttribute("happy-captcha"),
100, TimeUnit.SECONDS);
log.info("获取验证码成功");
}
用户发起秒杀请求的时候对验证码进行核验
private boolean checkCaptcha(String userId, Integer commId, String caprchaCode){
String redisCaptcha = (String) redisTemplate.opsForValue()
.get("captcha:" + userId + ":" + commId);
if(redisCaptcha == null) return false;
log.info("check验证码成功");
return caprchaCode.equals(redisCaptcha);
}
⚡隐藏秒杀商品执行秒杀的真实地址
问题分析
前面我们处理高并发 是按照正常业务逻辑处理的 也就是用户正常抢购 但还需要考虑抢购安全性
当前程序抢购接口是固定的 如果泄露 会有安全隐患
比如抢购未开始或者已结束 还可以使用脚本发起抢购解决方法
隐藏抢购接口
用户抢购时 先生成一个唯一的抢购路径 返回给客户端 客户端抢购时 会携带生成的抢购路径 服务端做校验 如果校验成功 才走下一步 否则直接返回
🍭代码实现
@RequestMapping("/seckill/getSKPath")
@ResponseBody
@AccessLimit(second = 5, maxCount = 5) // 5 秒内 最多 5 次请求 否则进行限流
public Msg getSKPath(@RequestParam(value = "captchaCode", required = false) String captchaCode,
@RequestParam("commId") Integer commId,
@RequestParam("userId") String userId){
Msg msg = new Msg();
/****** 0 看验证码是否正确 ******/
boolean check = checkCaptcha(userId, commId, captchaCode);
if (!check) {//如果校验失败
msg.setCode(407);
msg.setMsg("验证码不正确 请检查您的输入是否有误~");
return msg;
}
//生成秒杀路径/值
String path = DigestUtils.md5Hex(String.valueOf(UUID.randomUUID()));
//将随机生成的路径保存到Redis, 设置一个超时时间60s
//key的设计: seckillPath:userId:goodsId
redisTemplate.opsForValue().set("seckillPath:"
+ userId + ":" + commId, path, 60, TimeUnit.SECONDS);
msg.setCode(200);
msg.setMsg(path);
log.info("获取秒杀路径成功~");
return msg;
}
真正的秒杀请求携带生成的唯一路径
// 真正的秒杀请求
@RequestMapping("/seckill/{path}/addOneAndReturnInfo")
@ResponseBody
public Msg addOneAndReturnInfo(@RequestParam("userId") String userId,
@RequestParam("commId") Integer commId,
@RequestParam("originPrice") Double originPrice,
@RequestParam("seckillPrice") BigDecimal seckillPrice,
@RequestParam("pic") String pic,
@PathVariable("path") String path,
Model model) {
System.out.println("C SeckillController M addOneAndReturnInfo()..");
//...省略
}
⚡限流防止恶意请求
自定义注解 @AccessLimit
用来限制对后端方法的访问频率
/**
@author Liu Xianmeng
@createTime 2023/6/1 14:49
@instruction 自定义注解 @AccessLimit 用来限制对后端方法的访问频率
*/
@SuppressWarnings({"all"})
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
int second(); // 规定时间段
int maxCount(); // 在规定的时间段内最多发起多少次请求
boolean needLogin() default true;
}
在拦截器中使用注解 @AccessLimit
/**
@author Liu Xianmeng
@createTime 2023/6/4 11:28
@instruction 频繁访问验证 拦截器
*/
@SuppressWarnings({"all"})
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate redisTemplate;
//这个方法完成 1 得到user对象,并放入到ThreadLoacl 2 去处理@Accesslimit
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
//这里我们就先获取到登录的user对象
String userId = getUser(request, response);
//存入到ThreadLocal
UserContext.setUser(userId);
//把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 (userId == null) {//说明用户没有登录
//返回一个用户信息错误的提示...一会再单独处理...
render(response, RespBeanEnum.USER_SESSION_ERROR);
return false;//返回
}
}
String uri = request.getRequestURI();
String key = uri + ":" + userId;
ValueOperations valueOperations = redisTemplate.opsForValue();
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);
} else { // 说明用户在刷接口
// 返回一个频繁访问的的提示...一会再单独处理...
// ACCESS_LIMIT_REACHED(500501, "访问过于频繁,请稍后重试 ~");
render(response, RespBeanEnum.ACCESS_LIMIT_REACHED);
return false; //返回
}
}
return true;
}
//方法:构建返回对象-以流的形式返回
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();
}
// 单独编写方法,得到登录的user对象-userTicket
private String getUser(HttpServletRequest request, HttpServletResponse response) {
//String ticket = CookieUtil.getCookieValue(request, "userTicket");
String ticket = request.getParameter("userId");
if (!StringUtils.hasText(ticket)) {
return null; // 说明该用户没有登录,直接返回null
}
//return userService.getUserByCookie(ticket, request, response);
return ticket;
}
}