Featured image of post Java工程师 实战 高并发场景实战

Java工程师 实战 高并发场景实战

🌏Java工程师 高并发场景实战 🎯 这篇文章用于记录 高并发场景的开发实战

🎄说明

高并发场景实战 将运用于我的 第二个 个人项目(链接如下)

JavaEE电子产品商城 (秒杀场景 云存储)

🎄高并发场景概述

高并发业务是指系统在同一时间段内需要处理大量的并发请求。为了保证系统能够处理这些请求,并且不因为请求过多而导致系统崩溃或性能下降,需要采取一些有效的手段来分担压力并提高系统的可扩展性

高并发 其实主要解决两个问题,一个是并发读,一个是并发写

并发读的核心优化理念是尽量减少用户到 DB 来"读"数据,或者让他们读更少的数据, 并发写的处理原则也一样(尽量减少向DB写数据)

针对高并发系统需要做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生(例如如果当前请求太多处理不过来,可以提示用户稍后再次请求,经过短暂的调整,能保证系统在后面的用户请求中依然能正确响应)

系统架构要满足🎯高可用: 流量符合预期时要稳定,要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提

系统保证🎯数据的一致性: 就是秒杀 10 个 商品 ,那就只能成交 10 个商品,多一个少一个都不行 一旦库存不对,就要承担损失

系统要满足🎯高性能 也就是系统的性能要足够高,需要支撑大流量 不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点整个系统就快了(不要只针对一个地方进行优化,否则效果可能并不明显)

秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键 对应的方案比如🌰页面缓存方案 🌰Redis预减库存 🌰内存标记与隔离 🌰请求的削峰 ( RabbitMQ 异步请求 ) 🌰分布式Session共享

❓解决高并发业务常用的技术手段有哪些?

问一下chatGPT

  1. 使用消息队列:消息队列可以作为缓冲层,将高并发请求暂时存储在队列中,逐个进行处理,从而平滑控制系统的并发压力。
  2. 分布式缓存:通过将热点数据和频繁访问的数据存储到分布式缓存中,可以减轻数据库的压力,提高系统的读取性能和并发能力。
  3. 负载均衡:通过将请求分发到多台服务器上,使得每台服务器处理的请求数量和压力相对均衡,提高系统的并发处理能力和可靠性。
  4. 数据库优化:通过合理的数据库设计、使用索引、优化SQL查询语句等手段提高数据库的读写性能和并发能力。
  5. 水平扩展:通过增加服务器数量来水平扩展系统的处理能力,将负载分散到多台服务器上,提高系统的并发处理能力和可扩展性。
  6. 微服务架构:将系统拆分成多个小的独立服务,每个服务处理特定的功能,通过服务之间的调用和协作来处理高并发请求,提高系统的可扩展性和容错性。
  7. 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;
    }
}
Licensed under CC BY-NC-SA 4.0
最后更新于 2023年9月11日