Java后端系统学习路线β阶段--秒杀项目复盘

100 阅读12分钟

1、秒杀项目的优点

大榜:我们快走完β阶段的路线了,目前只剩下秒杀项目的实战环节了。之前咱们一起讨论了β阶段的Java反射、Java多线程、Redis、Spring的扩展功能开发及实战共4大部分。

小汪:那今天,咱两一起把β阶段的学习打个结,接下来进入秒杀项目的实战环节。秒杀项目是什么?

大榜:秒杀项目就是根据拼多多等电商平台上的商品秒杀活动,开发了一个简版的秒杀系统,主要用来学习秒杀场景中的缓存、异步技术。

小汪:能够学习缓存和异步技术,我有点期待了,那赶紧开始讨论把。

大榜:我参考网上的秒杀系统,自己重新学习了一遍,开发了一个简版的秒杀系统,代码仓库如下:gitee.com/qinstudy/mi…

开发完这个简版的秒杀系统之后,我决定总结下这个简版的秒杀系统,看看有哪些地方值得借鉴学习。

小汪:从实战项目上积累经验,用于以后的工作中,是应该要借鉴学习下。我记得真实的秒杀系统应该有很多细节要考虑,复杂性也会非常多,不易于我们上手学习。你这个简版的秒杀系统,应该不难把,容易上手学习吗?它有哪些优点值得我们学习?

大榜:这个简版的秒杀系统,算是真实秒杀系统的原型设计,既然称之为简版,那说明足够简单,肯定好上手实战学习。它虽然简单,但有如下的优点值得我们程序员学习,可以用于日常的开发中。优点如下:

1)分布式session:因为秒杀系统一般会部署多个节点,客户端发送一个请求,经过负载均衡后该请求会被分配到服务器中的其中一个,由于不同服务器含有不同的Web服务器(例如Tomcat),不同的web服务器中并不能发现之前web服务器中保存的session信息,就会再次生成一个JSESSIONID,之前的状态就会丢失。这时就需要用到分布式session了。

2)解决了商品超卖、同一个用户多次成功秒杀的问题:秒杀系统属于高并发系统,如果秒杀系统的代码存在问题,可能会遇到商品卖超了,也可能会遇到同一个用户多次成功秒杀,这都是不正确的情况。

3)页面级优化:前端缓存应对的是静态页面资源的访问。

4)服务端优化:加缓存(本地缓存和Redis缓存)、MQ异步下单。

5)安全优化:秒杀接口地址隐藏;数据公式验证码;接口限流防刷

6)Jmeter性能压测:为了模拟高并发场景,我们会使用Jmeter性能测试工具,模拟多个用户访问来对秒杀系统进行压测。

1.1、分布式session

小汪:这个简版的秒杀系统的第一个优点是分布式session,那为什么需要用分布式session呢,直接把session放在秒杀系统应用程序的内存,不可以吗?

大榜:如果秒杀系统是单机部署,没有做集群部署,那session就可以直接放在秒杀系统的内存中,没必要做分布式session,但单机部署最怕的是单点故障,如果这个节点宕机了,整个秒杀服务将不可用。所以,为了提高秒杀服务的可用性,我们做集群部署,也就是部署多个秒杀服务系统,每个秒杀服务系统中都存储了一部分用户的session信息,如下图所示:

image.png

如上图所示,假设用户是在Tomcat1上登录的,用户登录过的信息保存到了该服务器的session中,现在这台服务器挂掉了,用户的session自然也不见了。当用户被失效转移到其他服务器上的时候,由于其他服务器发现用户没有登录,就把用户踢到了登录界面,让用户再次登录。显然,这对于用户来说,是不可接受的。

小汪:是啊,如果我是用户,已经成功登录到了Tomcat1中的秒杀系统,正准确秒杀商品呢,接着Tomcat1挂掉了,然后系统让我重新登录,那我会觉得秒杀系统出毛病了。有什么解决办法吗?

大榜:因为用户的session是有状态的信息,我们应该存储下来。解决办法如下:

1)通过网络复制用户的session信息,在集群之间做同步。但缺点是如果服务器很多,复制session的开销很大。

2)来自同一个客户端的请求,一直被转发到同一台Tomcat机器上,这样就不用session复制了。但缺点是:万一那台Tomcat挂掉了,session还是丢失了。

3)把session信息保存到MySQL那里。但缺点是MySQL太慢了。

4)把session信息保存到Redis集群中,这样就保证了只要Redis集群不宕机,即使Tomcat1挂掉,用户也不会被踢到登录界面了。将用户的session信息保存到Redis集群,示意图如下所示:

image.png

目前,大多数公司都采用第4种解决方案,也就是将session信息保存到Redis集群,多个秒杀系统节点共享Redis种的session信息。为了保证session信息不丢失,我们利用Redis的持久化特性将session信息做持久化存储。

小汪:这个简版的秒杀项目的第2个优点是:解决了商品超卖、同一个用户多次秒杀成功的Bug。能具体说说吗?

大榜:咱们一个一个来说。先说下商品超卖的问题,也就是秒杀活动之后,商品数据库中的数量为负数了。

1.2、解决商品超卖、同一个用户多次成功秒杀的问题

小汪:这个简版的秒杀项目的第2个优点是:解决了商品超卖、同一个用户多次秒杀成功的Bug。能具体说说吗?

1.2.1、解决商品超卖:也就是库存为负数的问题

大榜:咱们一个一个来说。先说下商品超卖的问题,也就是秒杀活动之后,商品数据库中,秒杀商品表中的数量为负数了。如下图所示:

image.png

miaosha_goods表示秒杀商品表,表中的stock_count列表示秒杀商品的库存数量,上图中,经过秒杀活动后,该值为-9,被减为负数了,也就是说商品买超了。

小汪:为什么商品库存会减为负数呢?

大榜:为了给你讲清楚,为什么库存数量会减为负数,我做下对比比较。首先,看下减为负数的SQL语句:

update product set stock=stock-1 where id=123;

上面的SQL语句,没有stock > 0的判断,会导致商品库存被减为负数。所以我们需要加上库存数量大于0的判断,库存不会被减为负数的SQL语句如下:

update product set stock=stock-1 where id=123 and stock_count > 0;

上面的SQL语句中,我们依靠数据库的事务特性(原子性),来解决卖超问题。当数据库中该商品的库存大于0时,才去执行减库存操作。代码如下:

/**
     * ("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId}")
     * 上面update语句,没有设置库存大于0的条件,会将秒杀商品表的库存减为负数,即卖超问题。上面的SQL语句,存在此种情况,只剩下1个商品,同时来了req1、req2,两个请求都进行秒杀,最后将库存减为负数(卖超了)
     * @param g
     * @return
     */
    // 下面的SQL语句是原子操作,当库存大于0,才去执行减库存操作,解决卖超问题
    @Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
    int reduceStock(MiaoshaGoods g);

小汪:在SQL语句中加上stock_count > 0这个条件,保证了该条语句必须在库存大于0的条件下,才会执行减库存操作,这样就保证了商品库存数量不会被减为负数了。

1.2.2、解决同一个用户,多次秒杀成功的Bug

大榜:这个简版的秒杀系统中,还解决了同一个用户多次秒杀成功的Bug。如果同一个用户能够多次秒杀成功,就可以出现所有的秒杀商品被这个用户都秒杀到了,这对于其他用户而言是不公平的,所以秒杀系统需要确保每个用户只能秒杀成功一次。

小汪:嗯嗯,这个没问题。我想问一下,为什么会出现同一个用户,多次秒杀成功的Bug呢?

大榜:因为我们建立的order_info表,表记录中是允许存在同一个用户id秒杀到多件商品,这在高并发的场景可能会出现。如下图所示:

image.png

由上图可知,同一个用户(user_id为1300000000),秒杀抢到了3个iphoneX,这就出现了同一个用户id秒杀到多件商品。

小汪:如果我想要复现同一个用户id秒杀到多件商品,我需要用JMeter来模拟高并发的场景把。

大榜:你说得对,需要用JMeter来模拟高并发场景。

小汪:对了,如何解决同一个用户多次秒杀成功的Bug呢?

大榜:解决方案为:利用数据库的唯一索引,在秒杀订单表中建立一个唯一索引,以用户id、商品id共同作为标识,来确保一个用户只能秒杀成功该商品一次,这样就解决了同一个用户多次秒杀成功的Bug。用户id和商品id共同建立的唯一索引,如下图所示:

image.png

小汪:以用户id、商品id共同作为唯一索引后,数据库的表记录中就不会存在同一个用户秒杀到多件商品了,否则数据库会报错:重复的记录。

1.3、页面级优化

大榜:嗯嗯,是滴了。第3个优点是页面级优化,主要是对前端页面进行相关优化。这个简版的秒杀系统是前后端一体化的项目,用户通过浏览器请求秒杀系统,秒杀系统接收到请求后将html页面返回给浏览器。这种方式的缺点是,秒杀系统需要将html页面通过网络返回给浏览器,传输的数据量大,导致响应较慢。

小汪:榜哥,你可以采用前后端分离,将秒杀系统分为前端系统和后端系统,前后端分离的意思是前后端之间通过 RESTful API 传递 JSON 数据进行交流,后端是不涉及页面本身的内容。

大榜:是的,可以将前端静态资源缓存在浏览器中,这样用户每次请求时,可以直接从浏览器缓存中查找,响应速度大大提高了。

而且,前端用前端的服务器(Nginx),后端用后端的服务器(Tomcat),当我开发前端内容的时候,可以把前端的请求通过前端服务器转发给后端(称为反向代理),这样就能实时观察结果,并且不需要知道后端怎么实现,而只需要知道接口提供的功能,两边的开发人员就可以各司其职啦。

1.4、服务端优化

小汪:第4个优化时是秒杀系统服务端优化,也就是后端的优化,具体是什么呢?

大榜:后端优化的核心思想是通过加缓存、MQ异步下单,减少对数据库的访问。在这个简版的秒杀系统中,后端优化的思路如下:

最终目的:减少数据库访问
1、系统初始化,把商品库存数量加载到Redis;【缓存预热】
2、收到请求,Redis预减库存;当库存不足,直接返回,否则进入步骤3;
3、请求入队列,立即返回排队中;【类似于异步下单】
4、请求出队列,当减库存成功,才创建订单;若减库存失败,则不创建订单
5、客户端轮询,是否秒杀成功。

我主要做了3个优化:Redis预减库存减少对数据库访问、本地内存标记减少Redis访问、RabbitMQ队列异步下单。先说说第一个Redis预减库存来减少对数据库访问。

1.4.1、Redis预减库存减少对数据库访问

小汪:那Redis预减库存减少对数据库访问,到底是干啥的?

大榜:在并发读中,优化读的场景可以允许一定的脏数据,导致少量原本无库存的下单请求被误以为有库存,可以在写数据的时候再保证最终一致性,写的一致性主要是通过数据库来保证。减库存SQL语句的原子操作、秒杀订单表中设置用户id和商品id共同建立的唯一索引。

其实现思路就是通过Redis预先减库存,然后在真正减库存的时候才去访问数据库,这样能够极大地减少对数据库的访问。代码如下:

// 预减库存:当不是某个用户的重复秒杀,才去预减库存,预减库存是有条件的
Long stock = redisService.decr(GoodsKey.goodStock, "" + goodsId);
if(stock < 0) {
    // 维护一个秒杀结束标志:当该商品的库存小于0时,将该商品设置为秒杀完毕,即设置该商品的内存标记为true
    localOverMap.put(goodsId, true);
    return Result.error(CodeMsg.MIAO_SHA_OVER);
}
// 真正减库存的时候才去访问数据库并调用减库存的SQL逻辑。

其中,redisService.decr方法,就是通过Redis预先减库存,当预减库存后的库存数量小于0,则直接返回秒杀失败;当预减库存后的库存数量大于0,才会去访问数据库并调用减库存的SQL逻辑。

小汪:是啊,通过Redis预减库存,确实可以减少对MySQL数据库的访问。第二个是本地内存标记减少对Redis的访问,它是什么意思呢?

1.4.2、本地内存标记减少Redis访问

大榜:本地内存就是秒杀系统的内存,比如我们程序中经常使用的HashMap对象就是本地内存。

小汪:那为什么使用本地内存标记后,可以提高程序的QPS呢?

大榜:因为秒杀系统与Redis是通过网络来通信,会有一定的网络通信开销,但如果我们直接使用本地内存,就不会存在网络开销,所以相比于使用Redis,使用本地内存的速度更快。

小汪:那本地内存标记减少对Redis的访问,是如何实现的呢?

大榜:首先我们需要在秒杀活动开始之前,将秒杀商品做标记,设置为false,表示商品列表中的商品秒杀活动没有结束。然后,当秒杀活动开始后,判断秒杀标记是否为true,若为true,

则表示秒杀完毕,程序直接返回秒杀完毕。实现代码如下:

1)首先,将秒杀商品做标记,设置为false。代码如下:

// 维护一个秒杀结束标志。key为商品id,value为秒杀结束的标志,true表示秒杀已经结束;false表示秒杀进行中。
    private final Map<Long, Boolean> localOverMap = new ConcurrentHashMap<>(10);
​
/**
     * 项目启动时,调用该方法,做下面2件事
     * 1、将秒杀商品的库存放入到redis缓存中,也就是我们常听说的 缓存预热。
     * 2、将商品列表中的所有秒杀商品,做了标记,表示商品列表中的商品秒杀活动没有结束。
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> goodsVos = goodsService.listGoodsVo();
        if(goodsVos == null){
            return;
        }
        for (GoodsVo goodsVo : goodsVos) {
            redisService.set(GoodsKey.goodStock, "" + goodsVo.getId(), goodsVo.getStockCount());
            localOverMap.put(goodsVo.getId(), false);
        }
    }

然后,在秒杀时,判断秒杀标记是否为true,若为true,则表示秒杀完毕,程序直接返回秒杀完毕。代码如下:

// 使用内存标记方法,减少对Redis的访问
        Boolean over = localOverMap.get(goodsId);
        if (over == null) {
            // 当localOverMap中不存在该商品id的键时,得到的over为null,我们直接返回该秒杀商品不存在,用于提示用户
            return  Result.error(CodeMsg.MIAO_SHA_GOODS_NOT_EXIST);
        } else if (over) {
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
​
// 预减库存:当不是某个用户的重复秒杀,才去预减库存,预减库存是有条件的
        Long stock = redisService.decr(GoodsKey.goodStock, "" + goodsId);
        if(stock < 0) {
            // 维护一个秒杀结束标志:当该商品的库存小于0时,将该商品设置为秒杀完毕,即设置该商品的内存标记为true
            localOverMap.put(goodsId, true);
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }

小汪:嗯呢,有了秒杀是否结束的这个内存标记后,当内存标记为true后,后续用户的秒杀请求,后端都会直接返回秒杀结束,大大减少了与Redis的访问。

1.4.3、RabbitMQ消息队列异步下单

大榜:第3个是RabbitMQ队列实现异步下单,它的思想是将秒杀和下单解耦,实现异步。

小汪:为什么要使用队列来实现异步下单呢?

大榜:因为队列缓冲方式更加通用,它适用于内部上下游系统之间调用请求不平缓的场景,由于内部系统的服务质量要求不能随意丢弃请求,所以使用消息队列能起到很好的削峰和缓冲作用。

实现思路是这样的:首先将预减库存成功的秒杀请求,发送到消息队列中,并返回用户秒杀排列中;然后消费者从这个消息队列中取秒杀请求消息进行消费。

发送到消息队列的代码如下:

// 入队:进入RabbitMQ的消息队列。也可以采用JDK自带的阻塞队列,来实现生产者-消费者模型。
        MiaoshaMessage miaoshaMessage = new MiaoshaMessage(user, goodsId);
        sender.sendMiaoshaMessage(miaoshaMessage);
// 返回排队中。0表示排队中;小于0,表示秒杀失败;大于0,则表示用户秒杀成功!
        return Result.success(0);
​
​
public void sendMiaoshaMessage(MiaoshaMessage miaoshaMessage) {
        String message = RedisService.beanToString(miaoshaMessage);
        log.info("打印:发送消息:{}", message);
        amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, message);
    }

如上所示,sender.sendMiaoshaMessage方法是将预减库存成功的秒杀请求发送到消息队列“MIAOSHA_QUEUE”中。

消费者监听消息队列的代码:

/**
     * 请求出队,生成订单,减库存
     * 将秒杀和下订单这两个流程解耦,当用户秒杀成功后,将用户和商品id存入MQ队列;
     * 消费者去该队列去数据,然后减数据库中的库存、下订单。
     *
     * todo 若用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,需要回退库存。
     *  若用户在15分钟之内支付成功,则将order_info表的该商品的订单状态修改为已支付。
     * 解决方案:使用RabbitMQ的死信队列
     * @param message
     */
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receiveMiaosha(String message){
        log.info("miaosha.queue队列,监听消息:{}" + message);
        MiaoshaMessage miaoshaMessage = RedisService.stringToBean(message, MiaoshaMessage.class);
​
        MiaoshaUser miaoshaUser = miaoshaMessage.getUser();
        long goodsId = miaoshaMessage.getGoodsId();
        // 根据商品id,查询秒杀商品表中的商品对象
        GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
​
        // 查询数据库中该秒杀商品的库存数量
        int stock = goodsVo.getStockCount();
        if (stock <= 0) {
            return;
        }
​
        // 判断是否已经秒杀到了,防止某个用户重复成功秒杀
        MiaoshaOrder miaoshaOrder = orderService.getMiaoshaOrderByUserIdGoodsId(
                miaoshaUser.getId(), goodsId);
        if (miaoshaOrder != null) {
            log.error("用户重复秒杀,用户id:{};秒杀商品id:{}", miaoshaUser.getId(), goodsId);
            return;
        }
        // 减库存、下订单,写入秒杀订单
        miaoshaService.miaosha(miaoshaUser, goodsVo);
        log.info("秒杀成功,用户id:{};秒杀商品id:{}", miaoshaUser.getId(), goodsId);
        // 如何告诉前端,提示该用户秒杀成功了呢?具体如下:
        // 后端提供一个秒杀结果查询的接口,如“/miaosha/result”,前端每隔1秒钟,来轮询该接口,判断用户是否秒杀成功!
        // 若该接口返回 -1,表示秒杀失败;0排列中;其他,则返回秒杀的订单号。
​
    }

上面的代码中,监听“MIAOSHA_QUEUE”消息队列,然后减库存、下订单。通过消息队列的方式,就可以实现秒杀请求、下订单的解耦,而且海量的请求来了,首先是入队处理,起到了流量削峰的作用。

1.5、安全优化

小汪:你上面介绍的服务端的优化,我听懂了。我感觉秒杀系统的安全性也很重要,一不留神就被黑客攻击了,系统可能就挂掉了。你这个简版的秒杀系统中,有哪些安全措施吗?

大榜:当然有了,安全措施有秒杀接口地址隐藏、数据公式验证码、接口限流防刷。思想:Redis中设置键值对和过期时间,键:请求url和用户id组成;值:访问的次数(10次)。当一分钟之内,Redis中的该key对应的值大于10,则返回接口访问频繁请稍后再试。

2、后续优化事项

小汪:这是一个简版的秒杀系统,已经做了很不错了,可以拿来学习和实践高并发的场景。榜哥,你觉得这个秒杀系统,后续还有哪些改进点呢?

大榜:如果要说清楚改进点,我们需要从Web系统的请求路径讲起,看看请求路径上会经过哪些步骤,这些步骤有没有改进的空间。

对于一个Web系统,在用户使用这个系统的过程中,请求从浏览器出发,在域名服务器的指引下找到系统的入口,经过网关、负载均衡器、缓存、服务集群等一系列设施,最后触及到末端存储于数据库服务器中的信息,然后逐级返回到用户的浏览器之中。这其中要经过很多技术部件,这些部件主要有客户端缓存、域名解析、传输链路、内容分发网络、负载均衡、服务端缓存等等。如果从一个架构师的角度出发,要想打造一个超大流量并发读写、高性能、高可用的秒杀系统,需要在整个用户请求路径上,也就是从浏览器到服务端遵循几个原则,最终保证用户请求的数据少、请求数量尽量少、路径短、依赖尽量少,并且不要有单点,最终实现秒杀系统的设计。所以说,改进点主要有如下几点:

1)CDN(内容分发网络)加速:部署CDN,利用CDN的缓存加速访问。

2)限流:包括对同一个用户、同一个IP限流;对接口限流;加验证码来限流;提高业务门槛来限流。

3)分布式部署:部署多个秒杀系统节点、数据库服务器的高可用部署、Redis服务器的高可用部署、RabbitMQ服务器的高可用部署。

4)容错性设计:设置兜底保护方案,例如我们的系统最高支持 1万 QPS 时,可以设置 8000 来进行限流兜底保护。

小汪:如果秒杀系统的用户量没那么大,我们就没有必要使用你上面的这些改进点了,毕竟经费和运维成本都太高了。

大榜:你说得很对。开发一个系统,我们应该要遵循的原则:就是在能满足需求的前提下,最简单的系统就是最好的系统。如果我们以后要设计一个软件的架构,就是要让它在够用的前提下尽可能简单,实现简单、控制简单、维护简单,因为大道至简嘛!

3、小结

通过小汪和大榜的对话讨论,我们对简版的秒杀系统的优点进行了复盘,并对后续的改进点做了说明。通过对秒杀系统的学习实践,小汪明白了对于一个秒杀系统的设计,就是要尽可能减少对数据库的访问,可以是加缓存、MQ消息队列异步下单。推而广之,对于高并发的系统,也是如此。

大榜开发的简版秒杀系统,代码仓库如下:gitee.com/qinstudy/mi…

4、参考内容

自己实现的秒杀实战系统