秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter )

秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -04

秒杀接口地址隐藏

问题分析:

  1. 前面我们处理高并发,是按照正常业务逻辑处理的,也就是用户正常抢购。
  2. 还需要考虑抢购安全性,当前程序,抢购接口是固定的,如果泄露,会有安全隐患,比如抢购未开始或者已经结束了,一些非法用户还可以使用脚本发起抢购。

解决方案:

  1. 用户抢购时,先生成一个唯一的抢购路径,返回给客户端。
  2. 然后,客户端抢购的时候,会携带生成的抢购路径,服务端同时会对这发送过来的抢购路径进行验证,如果校验成功,才走下一步,否则直接返回。

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 接口添加:两个方法

  1. 生成秒杀路径/值(唯一) 方法
  2. 对秒杀路径进行校验方法

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 实现上述设计的两个方法:

  1. 生成秒杀路径/值(唯一),同时将生成的路径,存入到 Redis 当中,同时设计以一个失效时间 60s,该时间内没访问,就失效。配合 Redis 使用。注意:Redis 当中 key 的设计: seckillPath:userId:goodsId

  1. 对秒杀路径进行校验,从 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);
    }
}





  1. 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 中的订单都清空

  1. 登录- 秒杀页面
  2. 点击立即秒杀
  3. 再次点击立即秒杀

  1. 查看数据库, 发现该账号已经生成了一个订单

后端提示显示

验证码防脚本攻击 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);
    }


}

前端获取验证码修改:

测试:

  1. 登录- 秒杀页面

  1. 先输入错误的验证码,应该返回验证码错误

  1. 输入正确的验证码,应该返回排队中

  1. 再次输入正确的验证码,点击秒杀, 应该返回 该商品每人限购一件

  1. 查看数据库, 发现该账号已经生成了一个订单

秒杀接口限流-防刷

完成接口限流-防止某个用户频繁的请求秒杀接口

比如在短时间内,频繁点击立即秒杀, 示意图

思路分析/图解

Redis 存储的 key 设计为 uri 对应的路径+userID

简单接口限流-防刷

  1. 使用简单的 Redis 计数器, 完成接口限流防刷
  2. 除了计数器算法,也有其它的算法来进行接口限流, 比如漏桶算法和令牌桶算法(参考:https://zhuanlan.zhihu.com/p/165006444))
  3. 令牌桶算法, 相对比较主流, 可以关注一下.

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);
    }


}

测试:登录- 秒杀页面

通用接口限流-防刷-封装为-一个注解搞定

  1. 自定义注解@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. 最后:

“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值