基于Springboot+Redis+RabbitMQ消息队列实现的秒杀方案

基于Springboot+Redis+RabbitMQ消息队列实现的秒杀方案 1,项目简介 如何设计一个秒杀系统 秒杀,对我们来说,都不是一个陌生的东西

本文包含相关资料包-----> 点击直达获取<-------

基于Springboot+Redis+RabbitMQ消息队列实现的秒杀方案

1.项目简介

如何设计一个秒杀系统

秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀然而,这对于我们系统而言是一个巨大的考验。

那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高维度出发,从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。

其实,秒杀的整体架构可以概括为“稳、准、快”几个关键字。

  • 所谓“稳”,就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提

  • 然后就是“准”,就是秒杀 10 台 iPhone,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性

  • 最后再看“快”,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢

不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。

所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求:

  • 高性能 :秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化

  • 一致性 :秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知

  • 高可用 :现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对

技术栈

2.数据库设计

2.1 表结构

用户表

商品表

订单表

秒杀商品表

秒杀订单表

2.2 E-R图

3.项目实现

3.1 添加依赖

```xml


4.0.0
org.springframework.boot
spring-boot-starter-parent
2.3.1.RELEASE


com.xxxx
seckill
0.0.1-SNAPSHOT
seckill
seckill
1.8

org.springframework.boot
spring-boot-starter-thymeleaf


org.springframework.boot
spring-boot-starter-web


mysql
mysql-connector-java
runtime


com.baomidou
mybatis-plus-boot-starter
3.3.1.tmp


org.projectlombok
lombok
true


org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine

org.springframework.boot
spring-boot-maven-plugin
```

修改配置文件

```yaml spring:

thymeleaf配置

thymeleaf:

关闭缓存

cache: false

数据源配置

datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seckill? useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: root hikari:

连接池名

pool-name: DateHikariCP

最小空闲连接数

minimum-idle: 5

空闲连接存活最大时间,默认600000(10分钟)

idle-timeout: 180000

最大连接数,默认10

maximum-pool-size: 10

从连接池返回的连接的自动提交

auto-commit: true

连接最大存活时间,0表示永久存活,默认1800000(30分钟)

max-lifetime: 1800000

连接超时时间,默认30000(30秒)

connection-timeout: 30000

测试连接是否可用的查询语句

connection-test-query: SELECT 1

Mybatis-plus配置

mybatis-plus:

配置Mapper映射文件

mapper-locations: classpath :/mapper/ Mapper.xml

配置MyBatis数据返回类型别名(默认别名是类名)

type-aliases-package: com.xxxx.seckill.pojo

Mybatis SQL 打印(方法接口所在的包,不是Mapper.xml所在的包)

logging: level: com.xxxx.seckill.mapper: debug ```

3.2 搭建秒杀架构

3.2.1 架构层级

一般商家在做活动的时候,经常会遇到各种不怀好意的DDOS攻击(利用无辜的吃瓜群众夺取资源),导致真正的我们无法获得服务!所以说高防IP还是很有必要的。搞活动就意味着人多,接入SLB,对多台云服务器进行流量分发,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。基于SLB价格以及灵活性考虑后面我们接入Nginx做限流分发,来保障后端服务的正常运行。

后端秒杀业务逻辑,基于Redis 或者 Zookeeper 分布式锁,Kafka 或者 Redis 做消息队列,DRDS数据库中间件实现数据的读写分离。

3.2.2 优化思路

  • 分流 :再牛逼的机器也抵挡不住高级别的并发

  • 限流 :毕竟秒杀商品有限,防刷的前提下没有绝对的公平,根据每个服务的负载能力,设定流量极限

  • 缓存 :尽量不要让大量请求穿透到DB层,活动开始前商品信息可以推送至分布式缓存

  • 异步 :分析并识别出可以异步处理的逻辑,比如日志,缩短系统响应时间

  • 主备 :如果有条件做好主备容灾方案也是非常有必要的(参考某年锤子的活动被攻击)

  • 最后,为了支撑更高的并发,追求更好的性能,可以对服务器的部署模型进行优化,部分请求走正常的秒杀流程,部分请求直接返回秒杀失败,缺点是开发部署时需要维护两套逻辑

3.2.3 分层优化

  • 前端优化 :活动开始前生成静态商品页面推送缓存和CDN,静态文(JS/CSS)请求推送至文件服务器和CDN

  • 网络优化 :如果是全国用户,最好是BGP多线机房,减少网络延迟

  • 应用服务优化 :Nginx最佳配置、Tomcat连接池优化、数据库配置优化、数据库连接池优化

3.2.4 全链路压测

  • 分析需压测业务场景涉及系统

  • 协调各个压测系统资源并搭建压测环境

  • 压测数据隔离以及监控(响应时间、吞吐量、错误率等数据以图表形式实时显示)

  • 压测结果统计(平均响应时间、平均吞吐量等数据以图表形式在测试结束后显示)

  • 优化单个系统性能、关联流程以及整个业务流程

整个压测优化过程就是一个不断优化不断改进的过程,事先通过测试不断发现问题,优化系统,避免问题,指定应急方案,才能让系统的稳定性和性能都得到质的提升。

3.3 分布式锁

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行

  • 高可用的获取锁与释放锁

  • 高性能的获取锁与释放锁

  • 具备可重入特性

  • 具备锁失效机制,防止死锁

  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

分布式锁

  • 基于数据库实现分布式锁

  • 基于缓存(Redis等)实现分布式锁

  • 基于Zookeeper实现分布式锁

3.4 测试

秒杀未开始

秒杀进行中

秒杀结束

实现代码

OrderServiceImpl.java

```java /* *

* 服务实现类 *

* * @author zhoubin * @since 1.0.0 / @Service public class OrderServiceImpl extends ServiceImpl implements IOrderService { @Autowired private ISeckillGoodsService seckillGoodsService; @Autowired private IGoodsService goodsService; @Autowired private OrderMapper orderMapper; @Autowired private ISeckillOrderService seckillOrderService;

/**
* 秒杀
* @param user
* @param goods
* @return
*/
@Override
@Transactional
public Order seckill(User user, GoodsVo goods) {
    //秒杀商品表减库存
    SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq(
                "goods_id", goods.getId()));
    seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
    seckillGoodsService.updateById(seckillGoods);

    //生成订单
    Order order = new Order();
    order.setUserId(user.getId());
    order.setGoodsId(goods.getId());
    order.setDeliveryAddrId(0L);
    order.setGoodsName(goods.getGoodsName());
    order.setGoodsCount(1);
    order.setGoodsPrice(seckillGoods.getSeckillPrice());
    order.setOrderChannel(1);
    order.setStatus(0);
    order.setCreateDate(new Date());
    orderMapper.insert(order);

    //生成秒杀订单
    SeckillOrder seckillOrder = new SeckillOrder();
    seckillOrder.setOrderId(order.getId());
    seckillOrder.setUserId(user.getId());
    seckillOrder.setGoodsId(goods.getId());
    seckillOrderService.save(seckillOrder);

    return order;
}

} ```

SeckillController.java

```java /* *

* 前端控制器 *

* * @author zhoubin * @since 1.0.0 / @Controller @RequestMapping("/seckill") public class SeckillController { @Autowired private IGoodsService goodsService; @Autowired private ISeckillOrderService seckillOrderService; @Autowired private IOrderService orderService;

@RequestMapping("/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
    if (user == null) {
        return "login";
    }

    model.addAttribute("user", user);

    GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);

    //判断库存
    if (goods.getStockCount() < 1) {
        model.addAttribute("errmsg", RespBeanEnum.EMPTY_STOCK.getMessage());

        return "seckillFail";
    }

    //判断是否重复抢购
    SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq(
                "user_id", user.getId()).eq("goods_id", goodsId));

    if (seckillOrder != null) {
        model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());

        return "seckillFail";
    }

    Order order = orderService.seckill(user, goods);
    model.addAttribute("order", order);
    model.addAttribute("goods", goods);

    return "orderDetail";
}

} ```

秒杀成功进入订单详情注意查看库存是否正确扣减,订单是否正确生成

库存不足

重复抢购

至此,简单的秒杀功能逻辑就完成了,下面进入优化阶段。

3.5 优化

压测秒杀接口

创建用户,使用工具类往数据库插入5000用户,并且调用登录接口获取token,写入config.txt。

UserUtil.java

```java /* * 生成用户工具类 * * @author zhoubin * @since 1.0.0 / public class UserUtil { private static void createUser(int count) throws Exception { List users = new ArrayList<>(count); //生成用户

    for (int i = 0; i < count; i++) {
        User user = new User();
        user.setId(13000000000L + i);
        user.setLoginCount(1);
        user.setUsername("user" + i);
        user.setRegisterDate(new Date());
        user.setSalt("1a2b3c");
        user.setPassword(MD5Util.inputPassToDbPass("123456", user.getSalt()));
        users.add(user);
    }
    System.out.println("create user");
    //插入数据库
    Connection conn = getConn();
    String sql = "insert into t_user(login_count, username, register_date, salt, password, id)values(?,?,?,?,?,?)";
    PreparedStatement pstmt = conn.prepareStatement(sql);
    for (int i = 0; i < users.size(); i++) {
        User user = users.get(i);
        pstmt.setInt(1, user.getLoginCount());
        pstmt.setString(2, user.getUsername());
        pstmt.setTimestamp(3, new Timestamp(user.getRegisterDate().getTime()));
        pstmt.setString(4, user.getSalt());
        pstmt.setString(5, user.getPassword());
        pstmt.setLong(6, user.getId());
        pstmt.addBatch();
    }
    pstmt.executeBatch();
    pstmt.close();
    conn.close();
    System.out.println("insert to db");
    //登录,生成token
    String urlString = "http://localhost:8080/login/doLogin";
    File file = new File("C:\\Users\\Administrator\\Desktop\\config.txt");
    if (file.exists()) {
        file.delete();
    }
    RandomAccessFile raf = new RandomAccessFile(file, "rw");
    file.createNewFile();
    raf.seek(0);
    for (int i = 0; i < users.size(); i++) {
        User user = users.get(i);
        URL url = new URL(urlString);
        HttpURLConnection co = (HttpURLConnection) url.openConnection();
        co.setRequestMethod("POST");
        co.setDoOutput(true);
        OutputStream out = co.getOutputStream();
        String params = "mobile=" + user.getId() + "&password=" + MD5Util.inputPassToFormPass("123456");
        out.write(params.getBytes());
        out.flush();
        InputStream inputStream = co.getInputStream();
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        byte buff[] = new byte[1024];
        int len = 0;
        while ((len = inputStream.read(buff)) >= 0) {
            bout.write(buff, 0, len);
        }
        inputStream.close();

        bout.close();
        String response = new String(bout.toByteArray());
        ObjectMapper mapper = new ObjectMapper();
        RespBean respBean = mapper.readValue(response, RespBean.class);
        String userTicket = ((String) respBean.getObj());
        System.out.println("create userTicket : " + user.getId());
        String row = user.getId() + "," + userTicket;
        raf.seek(raf.length());
        raf.write(row.getBytes());
        raf.write("\r\n".getBytes());
        System.out.println("write to file : " + user.getId());
    }
    raf.close();
    System.out.println("over");
}

private static Connection getConn() throws Exception {
    String url = "jdbc:mysql://localhost:3306/seckill?
    useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
    String username = "root";
    String password = "root";
    String driver = "com.mysql.cj.jdbc.Driver";
    Class.forName(driver);
    return DriverManager.getConnection(url, username, password);
}

public static void main(String[] args) throws Exception {
    createUser(5000);
}

} ```

生成的config.txt

测试结果

可以看出已经出现了库存超卖的情况

3.5 接口优化

思路:减少数据库访问

  • 系统初始化,把商品库存数量加载到Redis

  • 收到请求,Redis预减库存。库存不足,直接返回。否则进入第3步

  • 请求入队,立即返回排队中

  • 请求出队,生成订单,减少库存

  • 客户端轮询,是否秒杀成功

Redis操作库存

SeckillController.java

```java / *

* 前端控制器 *

* * @author zhoubin * @since 1.0.0 */ @Controller @RequestMapping("/seckill") public class SeckillController implements InitializingBean { @Autowired private IGoodsService goodsService; @Autowired private ISeckillOrderService seckillOrderService; @Autowired private IOrderService orderService; @Autowired private RedisTemplate redisTemplate; @Autowired private MQSender mqSender; private Map EmptyStockMap = new HashMap<>(); /

* 秒杀
*
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSeckill(User user, Long goodsId) {
    if (user == null) {
        return RespBean.error(RespBeanEnum.SESSION_ERROR);
    }
    /*GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
    //判断库存
    if (goods.getStockCount() < 1) {
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
    }
    //判断是否重复抢购
    // SeckillOrder seckillOrder = seckillOrderService.getOne(new
    QueryWrapper<SeckillOrder>().eq("user_id",user.getId()).eq("goods_id", goodsId));
    String seckillOrderJson = (String)redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
    if (!StringUtils.isEmpty(seckillOrderJson)) {
        return RespBean.error(RespBeanEnum.REPEATE_ERROR);
    }
    Order order = orderService.seckill(user, goods);
    if (null != order) {
        return RespBean.success(order);
    }
    ValueOperations valueOperations = redisTemplate.opsForValue();
    //判断是否重复抢购
    String seckillOrderJson = (String) valueOperations.get("order:" + user.getId() + ":" + goodsId);
    if (!StringUtils.isEmpty(seckillOrderJson)) {
        return RespBean.error(RespBeanEnum.REPEATE_ERROR);
    }
    //内存标记,减少Redis访问
    if (EmptyStockMap.get(goodsId)) {
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
    }
    //预减库存
    Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
    if (stock < 0) {
        EmptyStockMap.put(goodsId,true);
        valueOperations.increment("seckillGoods:" + goodsId);
        return RespBean.error(RespBeanEnum.EMPTY_STOCK);
    }
    // 请求入队,立即返回排队中
    SeckillMessage message = new SeckillMessage(user, goodsId);
    mqSender.sendsecKillMessage(JsonUtil.object2JsonStr(message));
    return RespBean.success(0);
}
/**

* 系统初始化,把商品库存数量加载到Redis
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
    List<GoodsVo> list = goodsService.findGoodsVo();
    if (CollectionUtils.isEmpty(list)) {
        return;
    }
    list.forEach(goodsVo -> {
        redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),
        goodsVo.getStockCount());
        EmptyStockMap.put(goodsVo.getId(), false);
    });
}

} ```

RabbitMQ秒杀

MQReceiver.java

```java

/* * @author zhoubin * @since 1.0.0 / @Service @Slf4j public class MQReceiver { @Autowired private IGoodsService goodsService; @Autowired private RedisTemplate redisTemplate; @Autowired private IOrderService orderService; @RabbitListener(queues = "seckillQueue") public void receive(String msg) { log.info("QUEUE接受消息:" + msg); SeckillMessage message = JsonUtil.jsonStr2Object(msg, SeckillMessage.class); Long goodsId = message.getGoodsId(); User user = message.getUser(); GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId); //判断库存 if (goods.getStockCount() < 1) { return; } //判断是否重复抢购 QueryWrapper ().eq("user_id", user.getId()).eq("goods_id", goodsId)); String seckillOrderJson = (String) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId); if (!StringUtils.isEmpty(seckillOrderJson)) { return; } orderService.seckill(user, goods); } } ```

测试

项目启动,Redis预加载库存

秒杀成功,数据库及Redis库存数量正确

优化Redis操作库存

上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到原子性。我们采用锁去解决。

分布式锁

进来一个线程先占位,当别的线程进来操作时,发现已经有人占位了,就会放弃或者稍后再试。

线程操作执行完成后,需要调用del指令释放位子。

上面例子,如果业务非常耗时会紊乱。举例:第一个线程首先获得锁,然后执行业务代码,但是业务代码耗时8秒,这样会在第一个线程的任务还未执行成功锁就会被释放,这时第二个线程会获取到锁开始执行,在第二个线程开执行了3秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,他释放的第二个现成的锁,释放之后,第三个线程进来。

解决方案:

  • 尽量避免在获取锁之后,执行耗时操作

  • 将锁的value设置为一个随机字符串,每次释放锁的时候,都去比较随机字符串是否一致,如果一致,再去释放,否则不释放

  • 释放锁时要去查看所得value,比较value是否正确,释放锁总共三个步骤,这三个步骤不具备原子性

参考文献

  • 基于SpringBoot的电商秒杀系统的设计与实现(西安电子科技大学·李宜镓)
  • 基于微服务架构之电商秒杀系统的设计与实现(华东师范大学·闫保正)
  • 分布式环境中任务下发系统的设计与实现(南京大学·蔡慧)
  • 基于Redis技术的分布式缓存电商平台设计与实现(南昌大学·欧阳文臣)
  • 基于Spring技术的大型视频网站后台上传系统的设计与实现(南京大学·徐悦轩)
  • 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)
  • 电子商城系统中订单模块与秒杀模块的设计与实现(南京大学·徐士川)
  • 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)
  • 分布式消息推送系统的设计与实现(华南理工大学·阮伟标)
  • 基于SSM框架的短信接入系统的设计与实现(华中科技大学·姚浩宇)
  • 基于Spring Cloud的埋点管理与分析系统的设计与实现(华中科技大学·王雅琪)
  • 基于微服务架构的秒杀系统服务端设计与实现(东南大学·徐冉)
  • 基于RocketMQ的消息服务系统设计与实现(华中科技大学·张文杰)
  • 基于可定制化规则新闻抓取系统的设计与实现(北京邮电大学·周昂)
  • 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)

本文内容包括但不限于文字、数据、图表及超链接等)均来源于该信息及资料的相关主题。发布者:毕设货栈 ,原文地址:https://m.bishedaima.com/yuanma/35449.html

相关推荐

发表回复

登录后才能评论