秒杀系统项目的设计:

  • 项目使用了spring-boot集成了Mybatis,使用Druid配置mysql数据库的连接信息。
  • 4个优化:
  • 优化1:使用Redis做页面缓存+对象缓存
  • 优化2:Redis预减库存 + 内存标记减少Redis访问 + RabbitMQ队列缓冲,异步下单
  • 优化3:关于多线程下超卖问题解决
  • 优化4:页面静态化
  • 4个封装
  • 封装1:Redis通用缓存Key类封装设计
  • 封装2:分布式Session中根据Token获取用户,并将User封装注入到方法的参数中
  • 封装3:全局异常拦截器
  • 封装4:自定义注解类Access(点击限制), IsMobile(验证手机号), NeedLogin(判断是否登录)
  • 1个横向扩展
  • mysql和Redis在同一台高性能服务器上
  • 一台master,一台backup实现主从热备
  • 4台tomcat服务器轮询处理请求

秒杀系统设计 java 秒杀系统项目_Redis

4个优化

1 Redis使用

1 封装1: Redis通用缓存Key封装设计,做对象缓存,商品详情页面缓存,订单缓存,商品信息缓存

  • 背景:Redis做缓存时,可能会设置很多key,来标识我们需要存取的数据,如何能够保证key的唯一呢?
  • 设计:key加一个前缀,例如:用户相关的缓存都以User为前缀,商品所有的缓存都以Goods为前缀......。我们在key前拼接上前缀,作为redis中真正读写的key,这样就能使得key唯一且易区分
  • 实现:
  • 接口--->抽象类--->实现类:接口就是定义一些契约,抽象类来做一些共同的操作,实现类依照特定的要求来完成具体功能,这种设计也是非常常用的。
  • 接口:定义了获取过期时间和获取前缀两个方法
  • 抽象类:编写了一些共性的逻辑代码,比如获取过去时间和获取前缀的具体实现
  • 实现类:通过构造函数传递接受真正的参数,比如过期时间,前缀(key前缀的组成有两部分:第一部分是类名,第二部分是参数指定的比如id, name);
  • 效果: 简化了使用过程redis过程中Key名容易出现重复的,实现每次在Redis中存储Key时都会自动带上该类的类名className ,比如存储用户对象时的UserKey,会将Key设置为className + ":" + prefix (User:id1)

2 优化1:页面缓存+对象缓存

对象缓存
  • 将用户ID作为key,Token作为value存入缓存,用户登陆时可以不用去查数据库直接查缓存
  • 同理商品列表也可以做对象缓存
  • 需要注意缓存一致性问题,调用更新数据库的时候注意要处理缓存,这里采用的策略是User缓存先写数据库再更新缓存,而Goods缓存是只更缓存,不更MySQL,MySQL由缓存异步下单时做更新。
页面缓存
  • 设计: 1)取缓存,如果缓存里面有这个页面,直接输出到前端 2)如果缓存中没有,则手动渲染模板,结果输出,并将页面写入到Redis中,有效期为60秒(用户一分钟内看这个界面一般不会有改变,因为界面里没有展示库存这些信息)。
  • 实现:
  • 在Controller方法中,@RequestMapping注解中添加参数produces="text/html,以及添加@ResponseBody注解
  • 在Controller方法取缓存,并判断是否为空,如果这个页面缓存不为空,直接返回
  • 如果为空,则手动渲染,渲染完保存到Redis中
  • 页面缓存有效期一般比较短60秒
  • 页面缓存非常适合用在一些没有业务参数的页面,比如没有商品列表没有库存
  • 如果有业务参数,那么通过model去传递
// 手动渲染
SpringWebContext ctx = new SpringWebContext(request,
                response,
                request.getServletContext(),
                request.getLocale(),
                model.asMap(),
                applicationContext );
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", ctx);

3 集成Jedis+Redis

  • RedisPoolFactory来生成RedisPool
  • 使用Jedis连接池获取Redis对象
  • java对象序列化没有使用谷歌的protobuf(最快,序列化是二进制),而是采用Fastjson(序列化之后明文可读)

2 优化2:Redis预减库存 + 内存标记减少Redis访问 + RabbitMQ队列缓冲,异步下单

1 效果:

  • 获取商品列表这个业务
  • 原有QPS:2,348
  • Redis后QPS:16155
  • 秒杀商品这个业务:
  • Redis:5,992.33
  • Redis+RabbitMQ QPS: 11,775.789

2 redis预减库存,rabbitmq异步下单

  • 原来的流程需要做1)判断用户登录状态 2)判断库存 3)判断是否秒杀到 4)真正的秒杀-减库/存创建订单
  • 原有QPS1350
  • 优化同步下单改成异步下单
  • 1 系统初始化时,把商品库存数量加载到Redis(实现InitializingBean接口重写afterPropertiesSet)
  • 2 收到请求,Redis预减库存,库存不足直接返回
  • 3 请求入队,立即返回排队中,入队的是秒杀信息对象(id+name)的转化成的字符串
  • 4 请求出队(异步),生成订单之后要保存在redis中,方便判断是否重复下单
  • 5 客户端轮询,是否秒杀成功
  • 进一步优化 内存标记
  • 之前每次预减库存都要查一次redis,虽然Redis很快,但也是有消耗的
  • 于是使用在内存中使用一个localOverMap,key为goodsId,已经卖完了,就不用再去查Redis了

补充:RabbitMQ的4种使用方法

1 Direct模式 交换机Exchange

  • 使用一个队列QUEUE,点对点模式,一端存一端取
  • 它包含一个生产者、一个消费者和一个队列。生产者向队列里发送消息,消费者从队列中获取消息并消费。

2 Topic模式 交换机Exchange

  • 多个队列,支持通配符配置key将消息发送到匹配的队列中
  • 首先将消息发送到一个exchange中
  • 多个队列绑定到一个中心节点exchage中,exchange根据通配符将消息发送到不同的队列
  • 这种模式较为复杂,简单来说,就是每个队列都有其关心的主题,所有的消息都带有一个"标题"(RouteKey),Exchange会将消息转发到所有关注主题能与RouteKey模糊匹配的队列。
  • 这种模式需要RouteKey,一般要提前绑定Exchange与Queue。
  • 如果Exchange没有发现能够与RouteKey匹配的Queue,则会抛弃此消息。

3 Fanout模式 交换机Exchange

  • fanout模式比较简单,广播式的,无视routingkey直接发送给所有的queue

4 Header模式 交换机Exchange

  • 根据设置的头部信息去发送消息

3 优化3: 关于多线程下超卖问题解决

  • 当仅剩一个商品时,多个线程同时下单,导致库存减到了0;
  • 解决: 插入数据库之前sql语句加个and条件 当库存>0时才会真正去下单
    因为数据库每次更新都会作为一个事务,当这个线程获取了商品表时,会对商品表上锁,不会让两个线程同时做更新操作,
  • 防止一个用户重复下单(只能秒杀一次的情况下)(因为是在多线程情况下,一个用户可能两个商品预减库存都能成功,并且两个线程都还没有创建订单,他们会同时创建订单,导致超卖)
  • 解决: 在订单表中创建唯一索引即用户id-商品id,保证商品不会被重复下单
    其实也还可以通过验证码等方式下单(但会影响下单体验)

4 优化4:页面静态化

  • 背景:原来是使用获取动态获取页面信息,即请求一个页面获取一个页面的html
  • 设计:将页面转成纯HTML静态页面,通过js和ajax获取和渲染动态数据,实现客户端缓存html页面,只需要向服务端请求动态数据
  • 实现:
  • 在html文件中编写大量的js代码获取动态数据!
  • 比如botton 的onclick()
function doMiaosha(path){
    $.ajax({
        url:"/miaosha/"+path+"/do_miaosha",
        type:"POST",
        data:{
            goodsId:$("#goodsId").val()
        },
        success:function(data){
            if(data.code == 0){
                //window.location.href="/order_detail.htm?orderId="+data.data.id;
                getMiaoshaResult($("#goodsId").val());
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });
}

资源静态化补充: 静态资源优化+CDN优化

  • JS/CSS压缩,减少流量
  • 多个JS/CSS组合,减少连接数
  • CDN服务器就近访问

5 分布式Session封装

1 封装2:根据Token获取用户封装注入到方法的参数中

  • 背景:除了登录功能需要用户信息,还有很多页面其实也是需要用户信息,比如商品详情页
  • 以前:以前是在每个Controller方法里通过request,response对象一行一行代码解析Cookie获取Tooken,然后获取User对象。
  • 设计: 使用拦截器通过Token获取User对象并自动的注入到方法的参数中。
  • 具体做法是
  • 编写了WebConfig,继承WebMvcConfigurerAdapter,重写addArgumentResolvers实现自定义参数处理器,将userArgumentResolver类添加到参数处理器中。
  • addArgumentResolvers是SpringBoot框架回调Controller方法时,将Controller参数里面赋值
  • 编写解析自定义类UserArgumentResolver,UserArgumentResolver实现HandlerMethodArgumentResolver类,重写supportsParameter和resolveArgument

2 什么是Session

  • 1 服务端在用户登录成功之后生成一个类似于SessionID的东西来保存用户,比如Token,来标识这个用户,写入到Cookie中,传给客户端
  • 2 客户端在随后的访问请求中都在cookie中上传这个token
  • 3 服务端拿到这个Token后,根据Token获取Token对应的用户的信息

3 Session的作用

Session 的主要作⽤就是通过服务端记录⽤户的状态。 典型的场景是购物⻋,当你要添加商品到 购物⻋的时候,系统不知道是哪个⽤户操作的,因为 HTTP 协议是⽆状态的。服务端给特定的⽤ 户创建特定的 Session 之后就可以标识这个⽤户并且跟踪这个⽤户了。 Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。相对来说 Session 安全 性更⾼。如果使⽤ Cookie 的⼀些敏感信息不要写⼊ Cookie 中,最好能将 Cookie 信息加密然 后使⽤到的时候再去服务器端解密。

4 我们是怎样使用Session的

  • 有一种解决办法是Session同步,将第一台机器的Session同步到第二天机器,但是机器数量多了这个同步的过程就会非常恐怖
  • 因此一般会使用分布式Session,我们将用户信息存放在第三方的Redis服务器中,使用Token作为Redis的key去获取用户

4 封装3:全局异常拦截器

  • 背景:想在前端中看到异常信息,就要在业务代码中逐个逐个处理返回这个异常信息。(比如登录失败也就是出现异常,前端页面并不知道发生了什么错误)
  • 设计: 设计全局异常类,当遇到异常的时候直接抛出异常即可,然后返回true或fasle,不再是返回一个异常或者true。实现将异常处理和业务代码隔离开
  • 实现:
  • 创建全局异常类GlobalException,继承自RuntimeException
  • 使用原有的@ExceptionHandler注解拦截异常类,在将全局异常处理类GlobalExceptionHandler使用@ControllerAdvice交给容器去管理
  • 在全局异常类处理器中,当遇到的异常是GlobalException,那么返回异常Result信息

5 封装4:自定义注解Access, IsMobile, NeedLogin

1 使用自定义注解的方式实现点击限流拦截器,Access, NeedLogin

  • 背景:点击限流功能代码写在Controller代码中,看起来很复杂
  • 设计:使用自定义注解的方式实现点击限流拦截器,与业务代码隔离开来
  • 实现:
  • 1 新建一个@interface注解类,类中定义了使用这个注解时的参数,其实相当于一个Bean对象
  • 2 编写对应的Handler类AccessInteceptor,需要继承自HandlerInterceptorAdapter,重写preHandle方法,这个方法中包含了request,response还有handler对象
  • 3 可以从preHandle方法的handler参数中拿到SpringBoot扫描出来的注解,还有注解的参数
  • 4 response中拿到user用户,将user用户放在ThreadLocal中,ThreadLocal是与当前线程有关的,而每次秒杀的一个过程应该都是在一个线程内进行
  • 5 注册拦截器,在WebConfig类中注册AccessInteceptor

2 Jsr303参数验证器IsMobile , NeedLogin 注解

  • 框架帮我们定义好了NotNull, Length, Pattern校验器
  • 我们自己重新实现了一个IsMobile的验证器