Erlo

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

2025-06-03 11:29:25 发布   68 浏览  
页面报错/反馈
收藏 点赞

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

@

Redis 分布式锁探讨

分析:

我们在进行秒杀时,我们使用了一个关键的方法,找到对应的代码;

Redis 的单个 decrement方法具有原子性和隔离性,所以有效的控制了抢购。

所以在本项目中,不使用 Redis 分布式锁,也是可以控制抢购不出现超购和复购。

问题:

如果我们这里要处理的业务,不是当个 Redis 操作比如 decrement 可以完成的,而是需要多个 Redis 操作,那么就需要将多个操作组合起来,满足原子性了。

扩展:

在实际开发中,我们业务可能比较复杂综合,不是一个 Redis 操作(decrement) 就可以完成的,比如还需要进行修改操作(set),甚至还会操作 DB,文件,第三方数据源等等。

这时我们就需要扩大代码隔离性范围,可以考虑使用 Redis 分布式锁,解决。

修改:SeckillController 秒杀执行 decrement 进行一个 Redis 分布式锁处理。


    /**
     * 方法: 处理用户抢购请求/秒杀
     * 说明: 我们先完成一个 V 7.0版本,
     * - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
     * - 使用 优化秒杀: Redis 预减库存+Decrement
     * - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
     * - 优化秒杀: 加入消息队列,实现秒杀的异步请求
     * - 优化: 扩展: 采用 Redis 分布式锁,控制事务
     *
     * @param model   返回给模块的 model 信息
     * @param user    User 通过用户使用了,自定义参数解析器获取 User 对象,
     * @param goodsId 秒杀商品的 ID 信息
     * @return 返回到映射在 resources 下的 templates 下的页面
     */
    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
        System.out.println("秒杀 V 7.0 ");

        if (user == null) {//用户没有登录
            return "login";
        }
        //将user放入到model, 下一个模板可以使用
        model.addAttribute("user", user);

        //获取到goodsVo
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        //判断库存
        if (goodsVo.getStockCount()  redisScript = new DefaultRedisScript();
        redisScript.setScriptText(script);
        redisScript.setResultType(Long.class);


        // 2. 获取锁成功,查询 num 的值
        if (lock) {
            // 执行你自己的业务-这里就可以有多个操作了,都具有了原子性


            // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
            // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
            // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
            // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
            Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
            if (decrement 

测试:和上述一样使用 Jmeter 进行一个压测。

优化:这里我们将 Redis 释放锁的 Lua 脚本专门放到 resources 目录下,创建 lock.lua脚本。 增加配置执行脚本

  1. 在resources目录下创建 lock.lua脚本。

if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else return 0
end
  1. RedisConfig.java, 增加配置执行脚本

package com.rainbowsea.seckill.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * 把session信息提取出来存到redis中
 * 主要实现序列化, 这里是以常规操作
 * @author Rainbowsea
 * @version 1.0
 */

@Configuration
public class RedisConfig {

    /**
     * 增加执行脚本
     * @return DefaultRedisScript
     */
    @Bean
    public DefaultRedisScript script() {

        DefaultRedisScript redisScript = new DefaultRedisScript();
        //设置要执行的lua脚本位置, 把lock.lua文件放在resources
        redisScript.setLocation(new ClassPathResource("lock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

package com.rainbowsea.seckill.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * 把session信息提取出来存到redis中
 * 主要实现序列化, 这里是以常规操作
 * @author Rainbowsea
 * @version 1.0
 */

@Configuration
public class RedisConfig {

    /**
     * 自定义 RedisTemplate对象, 注入到容器
     * 后面我们操作Redis时,就使用自定义的 RedisTemplate对象
     * @param redisConnectionFactory
     * @return RedisTemplate
     */
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate redisTemplate = new RedisTemplate();
        //设置相应key的序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //value序列化
        //redis默认是jdk的序列化是二进制,这里使用的是通用的json数据,不用传具体的序列化的对象
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        //设置相应的hash序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        //注入连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        System.out.println("测试--> redisTemplate" + redisTemplate.hashCode());
        return redisTemplate;
    }

    /**
     * 增加执行脚本
     * @return DefaultRedisScript
     */
    @Bean
    public DefaultRedisScript script() {

        DefaultRedisScript redisScript = new DefaultRedisScript();
        //设置要执行的lua脚本位置, 把lock.lua文件放在resources
        redisScript.setLocation(new ClassPathResource("lock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

修改:SckillController 控制层当中的 秒杀代码处理位置。


    @Resource
    private RedisScript script;

    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
        System.out.println("秒杀 V 8.0 ");

        if (user == null) {//用户没有登录
            return "login";
        }
        //将user放入到model, 下一个模板可以使用
        model.addAttribute("user", user);

        //获取到goodsVo
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        //判断库存
        if (goodsVo.getStockCount()  script;
         */

        // 2. 获取锁成功,查询 num 的值
        if (lock) {
            // 执行你自己的业务-这里就可以有多个操作了,都具有了原子性


            // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
            // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
            // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
            // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
            Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
            if (decrement 
package com.rainbowsea.seckill.controller;


import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.config.AccessLimit;
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.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
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.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
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 entryStockMap = new HashMap();


    // 装配消息的生产者/发送者
    @Resource
    private MQSenderMessage mqSenderMessage;




    /**
     * 方法: 处理用户抢购请求/秒杀
     * 说明: 我们先完成一个 V 8.0版本,
     * - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
     * - 使用 优化秒杀: Redis 预减库存+Decrement
     * - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
     * - 优化秒杀: 加入消息队列,实现秒杀的异步请求
     * - 优化: 扩展: 采用 Redis 分布式锁,控制事务
     * - 优化:使用增加配置执行脚本,执行 lua 脚本
     *
     * @param model   返回给模块的 model 信息
     * @param user    User 通过用户使用了,自定义参数解析器获取 User 对象,
     * @param goodsId 秒杀商品的 ID 信息
     * @return 返回到映射在 resources 下的 templates 下的页面
     */
    @Resource
    private RedisScript script;

    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
        System.out.println("秒杀 V 8.0 ");

        if (user == null) {//用户没有登录
            return "login";
        }
        //将user放入到model, 下一个模板可以使用
        model.addAttribute("user", user);

        //获取到goodsVo
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

        //判断库存
        if (goodsVo.getStockCount()  script;
         */

        // 2. 获取锁成功,查询 num 的值
        if (lock) {
            // 执行你自己的业务-这里就可以有多个操作了,都具有了原子性


            // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
            // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
            // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
            // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
            Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
            if (decrement  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. Jmeter 压测
  2. 重置相关数据表

  1. 配置 JMeter

  1. 确保多用户的 userticket 已经正确的保存到 Redis 中

  1. 确保商品库存已经正确的加载/保存到 Redis 中, 并且没有订单生成

  1. 启动线程组,进行测试
  2. 测试结果, 不在出现超卖和复购问题

用户名是手机号: 13300000000 密码为: 123456 加密

项目启动准备说明

  1. 用户名是手机号: 13300000000 密码为: 123456 加密
  2. 启动对应的 Redis ,同时修改对应配置,密码,特别是对应的变化的 IP 地址。注意:查看 Redis 是否真正启动了。

  1. 启动修改 MySQL 数据库的地址,以及密码
  2. 启动修改对应的 RabbitMQ 消息队列的,特别是对应的变化的 IP 地址,以及对应的账户和密码。注意:查看 RabbitMQ 是否真正启动了。
  3. 对应的数据库是: seckill 我这里。
  4. 注意:这里 Jmeter 压测,需要重置数据表,同时生成 2000 用户是在: com.rainbowsea.seckill.utill.UserUtil包的这个 UserUtil 类。

最后:

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

在这里插入图片描述

登录查看全部

参与评论

评论留言

还没有评论留言,赶紧来抢楼吧~~

手机查看

返回顶部

给这篇文章打个标签吧~

棒极了 糟糕透顶 好文章 PHP JAVA JS 小程序 Python SEO MySql 确认