• 秒杀项目的总结及面试常见问题


    项目简介

    做的是一个秒杀系统,首先是登录系统,采用的是用户的手机号作为用户名,登录的时候先去判断手机号是否存在,然后再判断密码是否正确。当进行密码判断时,为了保证用户的密码的安全性,我这里使用了md5进行两次加密操作,首先通过salt值对输入密码进行第一次加密md5加密从而防止网络传输的过程中密码被截取,然后在到达服务端后在进行第二次md5加密是为了防止数据库的信息泄露时用户信息被反推出来,之后再将客户端输入密码与数据库中的密码相对比。如果密码正确,则通过uuid随机生成一个token标记该用户,并向cookie中写入此token,redis中存储此token与用户信息的映射,当该用户再次登录时,从cookie中取出token,再去redis中取出与此token对应的用户信息。

    商品列表这里为了提高性能和减轻对mysql的压力,用到了页面缓存的技术。

    接着就是秒杀这个核心功能,首先判断用户是否为空,然后去判断库存是否充足,这里为了解决超卖问题,首先在sql语句上加入了库存大于0的判断,然后还设置了唯一索引来防止一个用户多次秒杀,并且将预库存缓存到了redis当中,当一个秒杀请求到达时判断预库存是否还足够,同时判断是否已经有了该用户的秒杀订单。这里为了优化用户体验,还用上了rabbitmq消息队列,先将该秒杀请求放入消息队列中,以此实现了异步操作就可以让用户不用等待在这里,然后接收端会从消息队列中取出消息进行处理,这里就是真正的去数据库判断库存是否足够了,并且再次判断用户是否已经秒杀过了,然后进行真正的减库存,下订单,生成订单的操作,在消费者处理消息的过程中客户端会自动轮询来判断秒杀的情况。与此同时,考虑到如果对redis的访问中有过多的无效访问的话,资源开销也是较大的,为了解决这个问题,我使用了内存标记来减少对redis的访问,即设置一个map,key是商品,value在系统初始化时设置成false,当预库存不足时设置成true。当秒杀请求到达判断预库存之前会先判断该商品的value是否为true,为true就直接返回秒杀失败。

    由于html源码是透明的,为了保护秒杀接口,避免在可以秒杀的时段之外有人通过html源码看到秒杀地址,从而实现秒杀时段外的秒杀, 我还进行了秒杀接口地址的隐藏。每一次执行真正地秒杀时都会通过uuid生成一个随机值,然后和url拼接再放到redis中,当要秒杀时会先验证地址是否合法,然后才会进入真正的秒杀页面,同时我还加上了用户验证码,也就是在获取秒杀地址的时候先通过验证码才能获得,最后为了防刷进行了限流操作,将用户的访问次数保存在了redis中,每访问一次次数减1,我这里设置的是5秒钟内点击5次的限制。

    整体的流程

    1.登录进入商品列表页面
    2.点击进入商品详情页面,静态资源缓存,Ajax获取验证码等动态信息
    3.点击秒杀按钮, 将验证码结果和商品ID传给后端,如果结果正确。动态生成随机串UUID,结合用户ID和商品ID(url)存入redis,并将path传给前端。前端获取path后,再根据path地址调用秒杀服务
    4.服务端获取请求的path参数,去查缓存是否在。
    5.如果存在,并且Redis还有库存,预减redis库存,看是否已经生成订单,没有的话就将请求入消息队列
    6.从消息队列的接收端获取消息:包括商品ID和用户ID,判断数据库库存以及是否重复秒杀,然后下单。下单过程是:减库存,下订单,生成订单。
    8.生成订单成功或者失败后,都将秒杀结果放到redis中;前端采用ajax轮询方式查询redis获取最终秒杀结果,返回给用户提示。

    项目亮点:

    1、解决分布式Session问题,服务器水平扩展时,多台服务器都可以响应。

    解决方式:分布式session问题,由于水平扩展不同服务器上tomcat容器各自存储session,如果ngnix将访问ServerA的前端请求,发送给ServerB,那么就会查不到对应session,session信息丢失,使得用户重新跳转到登录界面。集中存储到redis服务器上,建立的session信息都可以从缓存中拿到。

    如果没有引入分布式session时,因为服务部署在不同服务器上,session存储在本地tomcat服务器上,所以通过ngnix反向传输回来,不一定是传给有seesion的那台服务器,从而导致登录失败。

    解决方案:
    分布式session实现,是将session集中存储在redis中,验证直接从redis中取。

    分布式session基于token实现,生成**唯一性的uuid作为key(**com.imooc.miaosha.util.UUIDUtil

    )序列化用户信息对象存储在redis中

    2、多级缓存提高页面访问速度和并发量,减少数据库压力。利用内存标记减少redis的访问。

    解决方式:本项目大量的利用了缓存技术,包括用户信息缓存(分布式session),商品信息的缓存,商品库存缓存,订单的缓存,页面缓存。访问速度:内存>redis>数据库sql

    3、秒杀令牌对下单接口解耦,并限制令牌个数(创建秒杀大闸),一定程度缓解下单接口访问压力。

    解决方式:使用秒杀令牌token,将大量验证信息下单接口分离出来,每个秒杀都需要创建特定的秒杀令牌PromoId_itemId_userId并限定10min存活时间,一定程度防止生成过多token,导致内存占满。限制令牌个数也能够防止恶意访问接口。

    4、对库存做内存标记,减少redis压力。只在redis中更新库存,是以乐观锁方式。使用RabbitMq消息队列异步同步数据库,达到一种柔性事务,减少数据库访问压力。并且防止消息发送失败导致数据不一致性,使用事务型消息包裹创建订单操作,保证createorder(1、订单入库;2、落单减库存;3、更新交易日志。)为原子性操作,也保证消息成功投放,即使发送失败也会回滚。

    解决方式:(1)库存售罄没必要再去访问redis,直接stock的内存标记。hashmap或者guava。(2)落单减库存是先扣减,不够再回补的一种乐观锁方式。数据库sql采用where stock>0方式加上行锁。并发时会导致QPS很低,性能大幅降低。所以使用消息队列做异步更新,降低数据库压力。(使用CAP理论,BASE理论,考虑可用性和分布式情况,牺牲即刻的一致性)分布式情况下,先扣减redis导致不一致性,但是能达到最终一致性的一种柔性事务。(3)消息发送失败可能导致数据不一致性(redis更新了,数据库没更新成功),需要用事务型消息包裹创建订单操作,保证createorder(1、订单入库;2、落单减库存;3、更新交易日志。)为原子性操作,也保证消息成功投放,即使发送失败也会回滚。但是生成的交易单号要设为Propagation.REQUIRES_NEW防止重复利用订单号。也就是处于大事务中只要完成了就会更新数据。

    5、对开启事务型消息这个大事务和初始化订单流水,使用异步化操作,并且使用队列化泄洪。创建线程池,使用阻塞队列存储任务,如果我们希望所有任务都完成可以设置饱和策略为:Caller-Runs让主线程也帮助执行任务。使用Future对象异步接收执行结果。

    6、md5密码校验,秒杀接口地址的隐藏,接口限流防刷,下单验证码手动延迟请求。

    7、前后端分离,客户端使用token进行其他业务请求,减轻服务端检验用户的压力。

    什么是Token?

    Token的引入:Token是在客户端频繁向服务端请求数据,服务端频繁的去数据库查询用户名和密码并进行对比,判断用户名和密码正确与否,并作出相应提示,在这样的背景下,Token便应运而生。

    Token的定义:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。

    使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

    Token 是在服务端产生的。如果前端使用用户名/密码向服务端请求认证,服务端认证成功,那么在服务端会返回 Token 给前端。前端可以在每次请求的时候带上 Token 证明自己的合法地位

    登录功能的实现

    一、数据库设计:不做注册,直接登录,在MySQL中直接创建表;用户表包括id、nickname、password、salt、头像、注册时间、上次登录时间、登录次数等字段

    二、明文密码两次MD5处理:加密的目的:第一次是因为http是明文传输的,第二次为了防止数据库被盗

    三、JSR303参数校验+全局异常处理:通过对输入的参数LoginVo加注解@validated,然后在传入的参数mobile和password上加上注解判断,如@NotNull判断是否为空,也可以自定义局异常处理

    四、分布式Session:

    分布式session(登录信息缓存)

    分布式session的原理:把一个token映射成一个用户,这个过程中分布式session的session并没有存入容器中,而是存到单独的一个缓存中,用一个redis来单独的管理session,这就是所谓的分布式session

    核心:将信息存放到一个第三方的缓存当中

    分布式session的实现方式:登陆成功之后,给用户生成token来标识这个用户,并写到cookie当中,传递给客户端,然后客户端在随后的访问中都在cookie中上传这个token。服务端拿到这个token之后,利用token取到用户对应的session信息

    分布式session问题,由于水平扩展不同服务器上tomcat容器各自存储session,如果ngnix将访问ServerA的前端请求,发送给ServerB,那么就会查不到对应session,session信息丢失,使得用户重新跳转到登录界面。集中存储到redis服务器上,建立的session信息都可以缓存中拿到。

    实现秒杀功能

    一、数据库设计:包括商品表(goods)、商品表订单表(order_goods)、秒杀商品表(miaosha_goods)、秒杀商品订单表(miaosha_order)

    二、商品列表页:为了展示秒杀商品的详情需要goods和miaosha_goods中的信息,所以封装一个GoodsVo,包括价格、库存、秒杀起始时间

    三、商品详情页(goods_detail.html)

    四、订单详情页(order_detail.html):这里也是秒杀功能的实现;在控制层先判断库存、然后判断订单是否存在、如果都没有就下单;下单顺序为减库存、下定单、写入秒杀订单

    缓存优化技术

    一、页面缓存 步骤(这里指的是商品列表页):

    • 从redisService中取缓存
    • 若缓存中没有则手动渲染,利用thymeleaf模板
    • 然后将页面加入缓存,并返回渲染页面
    • 不宜时间太长,设置为60s即可

    二、URL缓存 步骤(指的是商品详情页):

    • 与页面缓存步骤基本一致,但是需要取缓存和加缓存时要加入参数,GoodsId
    • URL缓存与页面缓存的区别是:不同的页面有不同的详情,URL缓存使用不同的参数来区分

    三、对象缓存(指的是User对象)

    • 前面的页面缓存和URL缓存适合变化不大的,缓存时间比较短
    • 对象缓存是长期缓存,所以需要有个更新的步骤
    • 第一步是取缓存
    • 若缓存中没有则去数据库中查找,并加入缓存;如数据库中没有就报错
    • 更新用户的密码

    四、注意点

    • 需要先更新数据库,后删除缓存;顺序不能反,会导致数据不一致:若线程1先删除缓存,然后线程2读操作,发现缓存中没有,把数据库中的旧数据加入缓存,然后线程1更新数据库,就会导致缓存与数据库数据不一致

    页面静态化(前后端分离)

    特点:缓存页面至浏览器(放弃使用thymeleaf,通过css编写纯html前端代码),前后端分离降低服务器压力。

    • 页面静态化无非就是使用纯html页面+Ajax请求json数据后再填充页面
    • 若A页面跳转到B页面之前需要条件判断可以先在A页面中利用ajax请求判断后再跳转
    • 如果不需要条件判断可以直接跳转到B的静态页面,让B自己用ajax请求数据
    • 页面静态化:直接把页面缓存到浏览器上。优势:当用户访问页面时,不需要和服务端进行交互,直接从本地缓存拿到页面,极大地节省了网络流量 ;页面存的是html,动态数据通过接口从客户端获取,因此服务端只需要写接口即可 ;页面静态化使用到的技术是:html+ajax

    超卖问题

    • 发生在减库存的时候

    解决方式:1.数据库加唯一索引:防止用户重复购买

    2.SQL加库存数量判断:防止库存变成负数

    CDN优化

    CDN是内容分发网络,相当于缓存,只是部署在全国各地,当用户发起请求时,会找最近的CDN获取资源
    **总结:**并发大的瓶颈在于数据库,所以解决办法是加各种缓存:从浏览器开始,做页面的静态化,将静态页面缓存在浏览器中;请求到达网站之前可以部署一些CDN,让请求首先访问CDN;然后是页面缓存、URL缓存、对象缓存;
    加缓存的缺点:数据可能不一致,只能做一个平衡

    秒杀接口优化

    思路:减少数据库访问,使用消息队列完成异步下单,提升用户体验,削峰和降流

    1.系统初始化,把商品库存数量加载到Rdis

    2.收到请求,Redis预减库存,库存不足,直接返回,否则进入3

    3.请求入队,立即返回排队中(异步下单)

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

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

    tips:4.5两步并发完成

    具体优化方式

    1.之前的没有库存预热的步骤是:查库存-查订单-修改库存-生成订单

       //判断库存
            GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);//10个商品,req1 req2
            int stock = goods.getStockCount();
            if (stock <= 0) {
                return Result.error(CodeMsg.MIAO_SHA_OVER);
            }
            //判断是否已经秒杀到了
            MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
            if (order != null) {
                return Result.error(CodeMsg.REPEATE_MIAOSHA);
            }
            //减库存 下订单 写入秒杀订单
            OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
            return Result.success(orderInfo);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    2.系统初始化时把库存加载到数据库:MiaoshaController 继承InitializingBean实现afterPropertiesSet方法即可
    3.在上一步库存预热之后,执行步骤为:查Redis库存-判断是否存在订单-进入队列-在出队时才对数据库进行操作

            //验证path
            boolean check = miaoshaService.checkPath(user, goodsId, path);
            if(!check){
                return Result.error(CodeMsg.REQUEST_ILLEGAL);
            }
            //预减库存,若库存小于零,则直接返回秒杀失败
            long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
            if(stock < 0) {
                localOverMap.put(goodsId, true);
                return Result.error(CodeMsg.MIAO_SHA_OVER);
            }
            //判断是否已经秒杀到了
            MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
            if(order != null) {
                return Result.error(CodeMsg.REPEATE_MIAOSHA);
            }
            //入队
            MiaoshaMessage mm = new MiaoshaMessage();
            mm.setUser(user);
            mm.setGoodsId(goodsId);
            sender.sendMiaoshaMessage(mm);
            return Result.success(0);//排队中
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    4.这一步还可以有一个优化,就是内存标记,使用一个Map,将商品ID设置为false,当买空时,设为true;然后每次不是直接访问Redis进行库存查询,而是对商品ID进行条件判断

            //定义一个Map
            private HashMap localOverMap =  new HashMap();
    
            //内存标记,减少redis访问
            boolean over = localOverMap.get(goodsId);
            if(over) {
                return Result.error(CodeMsg.MIAO_SHA_OVER);
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    5.内存标记的优点是减少对Redis的访问(当商品已经卖完之后)

    SpringBoot集成RabbitMQ

    1.添加依赖;2.创建消息接受者;3.创建消息发送者

    RabbitMQ的4种交换机类型

    1、direct 直流交换机:
    根据消息的路由键routingkey,将消息以完全匹配的方式路由到指定的队列中。
    这里的匹配指的是消息本身携带的路由键和队列与交换机绑定的绑定键进行对比,完全一致才算匹配上。
    一个队列和一个交换机绑定时,可以设置多个绑定键。
    一条消息可能会被路由到多个队列中,当多个队列设置相同的绑定键时会出现这种情况。比如,队列A、队列B与交换机之间进行绑定时,都设置了名为“binding_key_com”的绑定键,一条消息进入到交换机,消息携带的路由键是“binding_key_com”,那么这条消息既会被路由到队列A,也会被路由到队列B

    2、topic 主题交换机:
    和direct交换机非常类似,根据消息的路由键routingkey,将消息以模糊匹配的方式路由到指定的队列中。

    3、fanout 扇形交换机:
    不管消息的路由键是什么,它直接将发送到该交换机的消息路由到所有与它绑定的队列中。

    4、headers 头部交换机:不常用

    RabbitMQ的工作模式

    1、简单模式:

    一个生产者(sender),一个队列(queue),一个消费者(receiver)。

    2、work queue(工作队列):

    一个生产者,一个队列,同一个队列里面的消息被多个消费者同时消费,每条消费只会被消费一次。

    3、订阅模式:

    生产者生产消息,消息经过扇形交换机或直连交换机发送到多个不同的队列,注意,这里多个队列接收的消息个数是一致的。每条消息都会被消费多次。
    适用场景:对于生产者生产的每条消息不同的系统需要对这条消息做不同的处理
    比如,商城里产生了一个订单,业务系统需要处理订单,日志系统需要记录订单日志。

    4、路由模式:
    上面的场景改进一下,比如,商城里产生了一个订单,有的订单是未付款,有的订单是已经付款的,而我的业务系统只需要处理已付款的订单,但是日志系统不管是未付款的还是已付款的,都需要记录日志。那么我们就需要根据订单的类型将消息分别路由到不同的队列中。
    这里需要用到的交换机是直连交换机,并且要求每条消息必须携带路由键routingkey,比如“未付款”或者“已付款”。其中,我的日志队列和交换机必须要有多个绑定键,它既要绑定“未付款”,也要绑定“已付款”,而业务队列和交换机只需要绑定“已付款”即可。这样的话,“已付款”的消息不仅会被路由到日志队列,也会被路由到业务队列,而“未付款”的消息只会被路由到日志队列。

    5、通配符模式:
    是上面路由模式的改进型,允许消息的路由键包含通配符,由主题交换机进行与绑定键进行模糊匹配,将消息路由到所有符合要求的队列中。

    安全优化

    一、两次MD5密码校验

    1.用户端:PASS = MD5(明文+固定Salt) 作用:防止用户的明文密码在网络上进行传输

    第一步:明文密码–>form密码

    private static final String salt = “1a2b3c4d”;

    //第一次需要给出具体的salt,因为服务器不知道具体salt

    public static String inputPassToFormPass(String inputPass) {
        String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
        System.out.println(str);
        return md5(str);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    2.服务端:PASS = MD5(用户输入 + 随机Salt) 作用:防止万一数据库被盗,反查表得到密码

    第二步:form密码–>数据库密码

    public static String formPassToDBPass(String formPass, String salt) {
        String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }
    
    • 1
    • 2
    • 3
    • 4

    第三步:经过两次转换后的密码

    public static String inputPassToDbPass(String inputPass, String saltDB) {
        String formPass = inputPassToFormPass(inputPass);
        String dbPass = formPassToDBPass(formPass, saltDB);
        return dbPass;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    二、秒杀接口地址隐藏

    思路:秒杀开始之前,先去请求接口获取秒杀地址

    1.接口改造,带上PathVasriable参数

    2.添加生成地址的接口

    3.秒杀收到请求,先验证PathVariable

    注意事项:

    1.虽然前端页面在秒杀未开始时秒杀按钮设置为不可用,但是有可能用户通过前端js代码找到秒杀地址在秒杀未开始时直接访问,秒杀接口隐藏的目的是用户通过js获取到的秒杀地址并不能让其完成秒杀功能

    2.在秒杀之前要先通过Controller中的/path路径下的类随机生成一个path,然后和用户ID一起存入Redis,在执行秒杀的时候再从Redis中取Path进行验证,然后进行秒杀

    三、数学公式验证码(通过ajax获取验证码等动态信息)

    思路:点击秒杀之前,先输入验证码,分散用户的请求,在获取Path时进行验证

    1.添加生成验证码的接口

    2.在获取秒杀路径的时候,验证验证码

    3.ScriptEngine使用

    //生成验证码
        public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
            if(user == null || goodsId <=0) {
                return null;
            }
            int width = 80;
            int height = 32;
            //create the image
            BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
            Graphics g = image.getGraphics();
            // set the background color
            g.setColor(new Color(0xDCDCDC));
            g.fillRect(0, 0, width, height);
            // draw the border
            g.setColor(Color.black);
            g.drawRect(0, 0, width - 1, height - 1);
            // create a random instance to generate the codes
            Random rdm = new Random();
            // make some confusion
            for (int i = 0; i < 50; i++) {
                int x = rdm.nextInt(width);
                int y = rdm.nextInt(height);
                g.drawOval(x, y, 0, 0);
            }
            // generate a random code
            String verifyCode = generateVerifyCode(rdm);
            g.setColor(new Color(0, 100, 0));
            g.setFont(new Font("Candara", Font.BOLD, 24));
            g.drawString(verifyCode, 8, 24);
            g.dispose();
            //把验证码存到redis中
            //计算表达式
            int rnd = calc(verifyCode);
            redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
            //输出图片
            return image;
        }
    
        //验证验证码是否回答正确的方法
        public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verifyCode) {
            if(user == null || goodsId <=0) {
                return false;
            }
            Integer codeOld = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, Integer.class);
            if(codeOld == null || codeOld - verifyCode != 0 ) {
                return false;
            }
            //验证输入正确之后,将其从redis中删除
            redisService.delete(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId);
            return true;
        }
    
        //计算的方法
        private static int calc(String exp) {
            try {
                ScriptEngineManager manager = new ScriptEngineManager();
                ScriptEngine engine = manager.getEngineByName("JavaScript");
                return (Integer)engine.eval(exp);
            }catch(Exception e) {
                e.printStackTrace();
                return 0;
            }
        }
    
        //定义一个操作数(计算方式)的char数组
        private static char[] ops = new char[] {'+', '-', '*'};
    
        /**
         * + - *
         * */
        //生成验证码的本质是一个数学公式:三个数值(num),两次计算(ops)
        private String generateVerifyCode(Random rdm) {
            int num1 = rdm.nextInt(10);
            int num2 = rdm.nextInt(10);
            int num3 = rdm.nextInt(10);
            char op1 = ops[rdm.nextInt(3)];
            char op2 = ops[rdm.nextInt(3)];
            String exp = ""+ num1 + op1 + num2 + op2 + num3;
            return exp;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80

    四、接口限流防刷

    思路:对接口做限流

    1.可以用拦截器减少对业务侵入(定义拦截器后注释在业务逻辑代码上)

    具体步骤:

    • 当一个用户访问接口时,把访问次数写入缓存,并设置有效期
    • 一分钟之内如果用户访问,则缓存中的访问次数加一,如果次数超限进行限流操作
    • 如果一分钟内没有超限,缓存中数据消失,下次再访问时重新写入缓存

    操作:使用一个通用拦截器

    • 首先写一个注解AccessLimit
    • 后面每个类只需要加注解即可设置防刷次数
    • 定义拦截器:继承HandlerInterceptorAdapter类
    •        if(handler instanceof HandlerMethod) {
                 //获取用户
                 MiaoshaUser user = getUser(request, response);
                 //将用户保存起来
                 UserContext.setUser(user);
                 HandlerMethod hm = (HandlerMethod)handler;
                 //访问字典
                 AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
                 if(accessLimit == null) {
                      return true;
                 }
                 int seconds = accessLimit.seconds();//定义秒数限制
                 int maxCount = accessLimit.maxCount();//定义单位时间内最大点击数
                 boolean needLogin = accessLimit.needLogin();
                 String key = request.getRequestURI();//路径
                 if(needLogin) {
                     if(user == null) {
                         //标示错误
                         render(response, CodeMsg.SESSION_ERROR);
                         return false;
                     }
                     key += "_" + user.getId();
                 }else {
                     //do nothing
                 }
                 AccessKey ak = AccessKey.withExpire(seconds);//获取规定时间
                 Integer count = redisService.get(ak, key, Integer.class);//获取点击次数
                 if(count  == null) {
                     redisService.set(ak, key, 1);
                 }else if(count < maxCount) {
                     redisService.incr(ak, key);
                 }else {
                     //如果单位时间内超过了规定的点击次数,则返回"不可重复点击"提示
                     render(response, CodeMsg.ACCESS_LIMIT_REACHED);
                     return false;
                 }
             }
             return true;
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38

    五、JSR303参数校验

    • 通过对输入的参数LoginVo加注解@validated,然后在传入的参数mobile和password上加上注解判断,如@NotNull判断是否为空,也可以自定义(com.imooc.miaosha.vo.LoginVo)
    • 全局异常处理(com.imooc.miaosha.util.WebConfig)

    一些错误的解决

    优化redis中的key命名。

    方案:通用缓存key封装大量的缓存引用也出现了一个问题,如何识别不同模块中的缓存(key值重复,如何辨别是不同模块的key)
    解决:利用一个抽象类,定义BaseKey(前缀),在里面定义缓存key的前缀以及缓存的过期时间从而实现将缓存的key进行封装。让不同模块继承它,这样每次存入一个模块的缓存的时候,加上这个缓存特定的前缀,以及可以统一制定不同的过期时间。

    登陆连接到Mybatis数据库之后,显示的Mysql中miaosha表的nickname为null,其原因为:WebConfig类上没有@configuration注释

    @Configuration用于定义配置类,可替换xml配置文件,被注解的类内部包含有一个或多个被@Bean注解的方法,这些方法将会被AnnotationConfigApplicationContext或AnnotationConfigWebApplicationContext类进行扫描,并用于构建bean定义,初始化Spring容器。

    @Configuration标注在类上,相当于把该类作为spring的xml配置文件中的,作用为:配置spring容器(应用上下文)

    @Configuation等价于

    面试问题总结

    画一下项目的架构图

    项目简介

    做的是一个秒杀系统,首先是登录系统,采用的是用户的手机号作为用户名,登录的时候先去判断手机号是否存在,然后再判断密码是否正确。当进行密码判断时,为了保证用户的密码的安全性,我这里使用了md5进行两次加密操作,首先通过salt值对输入密码进行第一次加密md5加密从而防止网络传输的过程中密码被截取,然后在到达服务端后在进行第二次md5加密是为了防止数据库的信息泄露时用户信息被反推出来,之后再将客户端输入密码与数据库中的密码相对比。如果密码正确,则通过uuid随机生成一个token标记该用户,并向cookie中写入此token,redis中存储此token与用户信息的映射,当该用户再次登录时,从cookie中取出token,再去redis中取出与此token对应的用户信息。

    商品列表这里为了提高性能和减轻对mysql的压力,用到了页面缓存的技术。

    接着就是秒杀这个核心功能,首先判断用户是否为空,然后去判断库存是否充足,这里为了解决超卖问题,首先在sql语句上加入了库存大于0的判断,然后还设置了唯一索引来防止一个用户多次秒杀,并且将预库存缓存到了redis当中,当一个秒杀请求到达时判断预库存是否还足够,同时判断是否已经有了该用户的秒杀订单。这里为了优化用户体验,还用上了rabbitmq消息队列,先将该秒杀请求放入消息队列中,以此实现了异步操作就可以让用户不用等待在这里,然后接收端会从消息队列中取出消息进行处理,这里就是真正的去数据库判断库存是否足够了,并且再次判断用户是否已经秒杀过了,然后进行真正的减库存,下订单,生成订单的操作,在消费者处理消息的过程中客户端会自动轮询来判断秒杀的情况。与此同时,考虑到如果对redis的访问中有过多的无效访问的话,资源开销也是较大的,为了解决这个问题,我使用了内存标记来减少对redis的访问,即设置一个map,key是商品,value在系统初始化时设置成false,当预库存不足时设置成true。当秒杀请求到达判断预库存之前会先判断该商品的value是否为true,为true就直接返回秒杀失败。

    由于html源码是透明的,为了保护秒杀接口,避免在可以秒杀的时段之外有人通过html源码看到秒杀地址,从而实现秒杀时段外的秒杀, 我还进行了秒杀接口地址的隐藏。每一次执行真正地秒杀时都会通过uuid生成一个随机值,然后和url拼接再放到redis中,当要秒杀时会先验证地址是否合法,然后才会进入真正的秒杀页面,同时我还加上了用户验证码,也就是在获取秒杀地址的时候先通过验证码才能获得,最后为了防刷进行了限流操作,将用户的访问次数保存在了redis中,每访问一次次数减1,我这里设置的是5秒钟内点击5次的限制。

    整体的流程

    1.登录进入商品列表页面
    2.点击进入商品详情页面,静态资源缓存,Ajax获取验证码等动态信息
    3.点击秒杀按钮, 将验证码结果和商品ID传给后端,如果结果正确。动态生成随机串UUID作为随机path,结合用户ID和商品ID(url)存入redis,并将path传给前端。前端获取path后,再根据path地址调用秒杀服务
    4.服务端获取请求的path参数,去查缓存是否在。
    5.如果存在,并且Redis还有库存,预减redis库存,看是否已经生成订单,没有的话就将请求入消息队列
    6.从消息队列的接收端获取消息:包括商品ID和用户ID,判断数据库库存以及是否重复秒杀,然后下单。下单过程是:减库存,下订单,生成订单。
    8.生成订单成功或者失败后,都将秒杀结果放到redis中;前端采用ajax轮询方式查询redis获取最终秒杀结果,返回给用户提示。

    项目可改进的地方

    1.对于数据的动静分离没有做的很彻底,只是用到了浏览器缓存(客户端查询并返回200),没有使用CDN等技术
    2.没有设置降级的方案:可以开发一个备用服务,假如服务器真的宕机了,直接给用户一个友好的提示返回,而不是直接卡死,服务器错误等生硬的反馈。
    3.限流做的不够完善,目前只对用户对于某个商品的访问做了限流,没有对整体的流量做限流,比如不法分子有非常多的账号,同时对一个商品发起请求可能造成我们的服务不可用。
    4.没有考虑redis穿透的情况处理方案
    5.在这个项目中是对库存和静态数据进行了预热,但是实际中有可能某个商品可能一时间快速爆火,如果没有对这些商品数据进行预热可能会使服务宕掉,需要快速发现热点数据的发现与隔离,比如某明星粉丝约定某一时刻购买某粉丝代言的产品,虽然该商品没有参加秒杀活动,但那一时刻也胜似秒杀
    6.对于部署还没有进行实际的操练和学习,可以使用Ngnix做负载均衡

    秒杀部分的流程

    1.用户在秒杀商品详情页面点击秒杀按钮
    2.客户端向服务端请求秒杀路径,主要逻辑为生成随机path值与用户id一起存入redis中,根据此path值拼凑秒杀路径。
    3.访问拼凑的秒杀路径,先验证路径中path是否在redis中存在,如果不存在直接返回错误。
    4.redis缓存做预减库存对请求做分层过滤

    为什么明文密码要进行两次md5加密

    1.客户端->服务端:pass = 明文pass + 固定salt;

    2.服务端->数据库:pass= 用户输入 + 随机salt

    如果用户登录时不做任何处理,则密码是明文的,那么密码就会在网络上传输,如果被别人截取到了,就会得到用户的密码,所以在客户端要使用一次md5加密,然后将加密后的密码发送给服务端,服务端收到用户传过来的经过md5加密后的密码后要再对密码进行一次md5加密,是将第一次md5后的密码与一个随机salt做拼接,然后将两次md5后的密码与salt一起写入数据库中。第一次md5加密是为了防止网络传输过程中用户密码被截取,第二次md5加密是为了防止数据库发生数据泄露,根据反推获取用户密码。

    分布式Session是怎么实现的

    • 用户登录后生成随机字符串token,并向cookie中写入此字符串。
    • Redis中记录token用户信息的映射
    • 当用户再次访问网页时,取出cookie中对应token,根据此token访问redis得到用户相关信息

    如何进行页面优化?

    概述:直接缓存页面至浏览器(放弃使用thymeleaf,通过纯html前端代码实现),前后端分离降低服务器压力。

    • 页面静态化无非就是使用纯html页面+Ajax请求json数据后再填充页面;
    • 若A页面跳转到B页面之前需要条件判断可以先在A页面中利用ajax请求判断后再跳转;
    • 如果不需要条件判断可以直接跳转到B的静态页面,让B自己用ajax请求数据;
    • 优势:当用户访问页面时,不需要和服务端进行交互,直接从本地缓存拿到页面,极大地节省了网络流量 ;页面存的是html,动态数据通过接口从客户端获取,因此服务端只需要写接口即可 ;

    如何进行接口优化?

    • 解决超卖问题:在sql语句中设置stock_count>0语句,保证商品不变成负数;
    • 避免重复下单:在数据库上加上唯一索引;
    • 秒杀优化:
      • 在秒杀系统启动时将库存预缓存到redis中,然后当秒杀请求到达时现在redis中进行预减库存,使用的是消息队列rabbitmq来实现异步操作,当用户的请求到达消息队列中就返回正在秒杀中,这样就能够实现异步操作,同时也能够减少对于mysql数据库的访问,防止大量的请求同时到达数据库使其承受不住。在等待过程中,消费者会判断是否已经秒杀过或者是否卖完,如果是上面两种情况则直接返回秒杀失败,这里设置了三个状态码,-1就表示秒杀失败,0就是正在秒杀(也就是客户端正在做轮询),1就是秒杀成功。当轮询到某个客户时,才会真正为它生成订单,然后订单支付以后,mqsql库存才会减1。在redis预减库存之前,还使用hashmap设置了一个内存标记,其key为商品id,value是boolean类型,且初始化为false,当买空时,设为true,每次不是直接访问redis进行库存查询,而是对商品ID进行条件判断,这样能够减少对redis的访问,节约了时间和消耗。

    如何判断商品是否已经卖完?

    • redis中的库存是否>0

    如何判断用户是否重复秒杀?

    • 判断redis中的key是否存在,用户秒杀后会在redis中设置一个展位的key来标志用户已经秒杀过

    如何解决超卖(mysql锁)

    • 超卖问题主要依靠MySQL排它锁实现的
    • 减库存时设置sql语句where stock_count > 0,代码在com.imooc.miaosha.dao.GoodsDao中
    •   @Update("update miaosha_goods set stock_count = stock_count - 1 
        where goods_id = #{goodsId} and stock_count > 0")
      
      • 1
      • 2

    如何解决重复下单(mysql唯一索引)

    • 执行减库存下订单逻辑前,判断是否在订单表中含有用户秒杀此商品的记录
    • 利用唯一索引,在订单表(miaosha_order)中创建user_id和goods_id组成的唯一索引(u_uid_gid),这样在重复插入数据的时候会插入失败,之前的减库存操作在事务中也会回滚。

    秒杀接口地址的隐藏问题

    • 为什么进行秒杀接口的地址隐藏?:虽然前端页面在秒杀未开始时秒杀按钮设置为不可用,但是有可能用户通过前端js代码(html)找到秒杀地址在秒杀未开始时直接访问,秒杀接口隐藏的目的是用户通过js获取到的秒杀地址并不能让其完成秒杀功能

    • **如何做的?:**在秒杀之前先通过uuid随机生成一个额外的path与url拼接生成秒杀路径,然后将该path和用户id一起存入redis,这样当用户访问的时候就不是直接暴露原来的地址了,而是加上了url后的地址,就实现了秒杀接口地址的隐藏。在执行秒杀的时候,如果想要访问秒杀接口,则要先从redis中取path进行验证,判断该path是否存在,存在的话才能进行下面的秒杀活动。

    接口如何限流防刷(验证码+通用拦截器限流)

    • 思路:1.对接口做限流;2.将用户对接口的访问次数缓存在redis中;3.通过拦截器来进行
    • 具体步骤:
      • 自定义一个AccessLimit注解,里面的参数是时间、最大访问次数以及是否需要登录;
      • 定义一个拦截器继承HandlerInterceptorAdapter类,再重写他的preHandle方法;
      • 使用@AccessLimit(seconds=5, maxCount=5, needLogin=true),规定单位时间内的最大访问次数。

    数学公式验证码怎么做

    • 思路:点击秒杀按钮之前,先输入验证码,分散用户的请求。1.添加生成验证码的接口;2.在获取秒杀路径的时候,验证验证码;
    • 前端的操作:首先将验证码都给隐藏起来,假如是在秒杀进行中,那么验证码就都给显示出来,加入秒杀结束了,那么又隐藏起来;
    • 后端的操作:通过ajax获取验证码等动态信息:1.先定义一个操作数的char数组,包括+、-、*等运算符;2.定义生成验证码的本质:数学公式(我设置了三个数值num,两次计算ops);3.验证验证码是否回答正确,若输入正确,则将其从redis中删除。

    消息队列的作用(异步削峰)

    • 削峰,减少同一时刻并发量
    • 入队后直接返回用户排队中消息,提高用户体验

    库存预减用的是哪个redis方法

    • 使用Jedis封装相关方法,减库存使用decr方法
    •           //预减库存,若库存小于零,则直接返回秒杀失败
                long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
                if(stock < 0) {
                    localOverMap.put(goodsId, true);
                    return Result.error(CodeMsg.MIAO_SHA_OVER);
                }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

    缓存和数据库数据一致性如何保证

    • 对于库存数据不需要保证,缓存中的库存只为了过滤请求,即使多放进来一些请求我们也可以在数据库层面保证不超卖。
    • 对于商品信息的静态数据也不需要保证数据一致性,因为不会变

    如果项目中的redis服务挂掉,如何减轻数据库的压力

    • 使用多台redis, redis集群
    1. slave从redis宕机

      配置主从复制的时候才配置从的redis,从的会从主的redis中读取主的redis的操作日志,从而达到主从复制。
      1)在redis中从库重新启动后会自动加入到主从架构中,自动完成同步数据;
      2)如果从数据库实现了持久化,可以直接连接到主的上面,只要实现增量备份(宕机到重新连接过程中,主的数据库发生数据操作,复制到从数据库),重新连接到主从架构中会实现增量同步。

    2. Master 宕机
      假如主从都没数据持久化,此时千万不要立马重启服务,否则可能会造成数据丢失,正确的操作如下:
      在slave数据上执行SLAVEOF ON ONE,来断开主从关系并把slave升级为主库
      此时重新启动主数据库,执行SLAVEOF,把它设置为从库,连接到主的redis上面做主从复制,自动备份数据。
      以上过程很容易配置错误,可以使用redis提供的哨兵机制来简化上面的操作。简单的方法:redis的哨兵(sentinel)的功能。

    假如减了库存但用户没有支付,怎么将库存还原继续进行抢购

    • 订单超时未支付则删除订单,增加库存数量,恢复Redis缓存和本地缓存的数量
    • 但是对于秒杀项目之所以采用下订单减库存而不是付款减库存不就是因为秒杀商品秒到就是赚到大概率不会不付款嘛。另外即使不付款,那就不会发货,只会少卖不会超卖对于商户也不会有什么损失吧。

    系统瓶颈在哪?

    • 数据库
    • 服务端网络,CPU和内存等硬件资源
    • 对于服务端网络带宽可以向isp购买,服务器端硬件资源的话可以尽可能的加
    • 另外可以减少耗费CPU和内存的操作,比如编码操作,序列化操作,频繁创建大对象的操作,防止出现内存泄漏

    为什么使用redis?

    • 速度快,基于内存的操作,类似于map,查找和操作的时间复杂度O(1)
    • 数据结构简单,数据结构都是专门设计的
    • 采用单线程,避免线程间的切换而消耗CPU,不用考虑各种所得问题,没有加锁和释放所得操作,没有看呢出现死锁情况。
    • 多路复用I/O,非阻塞I/O
    • redis有自己的VM机制,一般系统调用会消耗时间。

    哪里用到了redis?

    • 记录用户的登录状态;记录热点数据;预减库存;存放售罄标志位;

    redis有哪些数据结构?

    • string hash set list zset

    为什么不用多线程?

    • 不涉及锁和加锁
    • 没有线程之间的切换而消耗CPU

    单线程的缺点?

    • 耗时命令的并发降低
    • 无法发挥多核CPU的性能,但是可以是多多个redis来解决。

    那么redis没有线程安全问题吗?

    • 多个redis的复合操作,依然需要锁,而且可能是分布式锁。

    除了你项目里面的优化,你还有什么优化策略吗?(同上一个问题)

    使用了大量缓存,那么就存在缓存击穿和缓存雪崩以及缓存一致性等问题

    先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦

  • 相关阅读:
    Spring Boot 中的 TransactionTemplate 是什么,如何使用
    Mybatis-plus使用update()/updateById()将字段更新为null或者空值时候不起作用
    基于神经网络的预测控制,神经网络预测系统应用
    Actors 基于消息驱动的异步编程模型
    寒假训练——第一周(STL)
    关于尚硅谷禹神Vue视频四十二级v-cloak,delay_server服务器服务器的替代方案
    【计算机网络学习之路】网络基础1
    营收下滑,腾讯游戏还能保持「王者」地位吗?
    机械制造企业如何借助ERP系统,做好供应商管理?
    RabbitMQ基础
  • 原文地址:https://blog.csdn.net/m0_67393413/article/details/126066786