黑马点评项目技术总结

一、基于Redis实现发送短信验证码功能

1.1 基于session实现发送短信验证码功能流程

1.1.1 发送短信验证码

若用户使用手机号 + 短信验证码的方式进行登录,当用户点击获取验证码时,会向后台发送一条请求,下图就是后台对该请求的处理流程:

  1. 校验手机号
  2. 生成验证码
  3. 保存验证码到session(用于后续校验)
  4. 发送验证码到用户手机

1.1.2 短信验证码登录、注册

这一步是用户点击登录按钮发送到后端的请求,后端对该请求的处理流程,具体如下:

  1. 校验手机号(这一步是防止用户在登陆前修改手机号)
  2. 根据第一步保存到session的验证码与前端发送的验证码比对
  3. 判断用户是否已注册
  4. 将用户保存到session中,为后续其他网页的校验做准备

1.1.3 校验登陆状态

为了实现对用户的每一个页面访问进行校验的功能,此时需要用到拦截器,在拦截器中对http请求携带的cookie以及保存在session中的信息进行比较。拦截器的实现以及拦截器和过滤器的区别将在后文进行说明。
若存在用户,则将脱敏后的用户信息保存到ThreadLocal中,由于每一个session都是一个线程,因此只要不关闭页面,那么以后每次对后端的请求都可以从ThreadLocal中获取本次session的用户信息。

1.2 集群的session的共享问题

1.2.1 问题

由于session时保存在后端服务器内存中,且多台服务器无法共享session。因此这在实际的后端服务器集群模式下,会产生用户在服务器A登录保存到session登陆凭证,再次访问服务器B时无法得到登录的信息凭证的问题。

1.2.2 解决方案

为解决集群下session的共享问题,设计的方案需要满足以下几点要求:

  1. 数据共享
  2. 内存存储
  3. key-value结构

redis恰好满足上述所有的要求,因此使用redis来保存验证码信息以及用户登录的authorization。

1.2.3 具体实现

将保存到session中的验证码和用户登录的authorization保存到redis中即可,同时用户每一次对网站的访问,都会刷新登录的authorization的有效期;在用户登陆成功后,及时将登陆的验证码从redis中删除。

附:拦截器的实现

  1. 创建一个类实现HandlerInterceptor接口,重写preHandle方法,afterCompletion可根据需求自定义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class RefreshInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
    }
  2. 创建一个实现WebMvcConfigurer接口的类,重写其中的addInterceptors方法,该类中可以配置所有的拦截器。

    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
    // 注意不要忘记注解
    @Configuration
    public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoginInterceptor())
    .excludePathPatterns(
    "/user/login",
    "/user/code",
    "/blog/hot",
    "/shop/**",
    "/shop-type/**",
    "/upload/**",
    "/voucher/**"
    )
    .order(1);
    // order越小越靠前
    registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate))
    .order(0);
    }
    }

二、商户查询缓存

2.1 缓存前置

2.1.1 缓存的作用

  • 降低后端负载
  • 提高读写效率、降低延迟时间

2.1.2 缓存的成本

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

本项目对查询的店铺信息添加的缓存,降低了访问店铺的响应时间

2.2 缓存更新策略

  当数据更新到数据库时,如何保证缓存中的数据与数据库中的数据保持一致性,此时需要考虑缓存的更新策略,一般有如下三种缓存更新策略:

  • 当一致性需求低时,可以考虑使用redis自带的内存淘汰机制
  • 若需要高一致性需求,则可以使用主动更新,配合超时剔除作为兜底方法

  当使用主动更新策略时,需要注意以下三个问题

  • 数据更新时,删除缓存还是更新缓存?
    • 应该选择删除缓存,当查询数据时再将数据从数据库中读入缓存中,这样可以避免更新是对缓存无效的写操作。
  • 如何保证缓存与数据库的操作同时成功或者失败?
    • 当项目是单体项目时,将对缓存和数据库的操作放在一个事务内;
    • 若时分布式系统,则可以采用分布式事务解决方案。
  • 先操作缓存还是数据库?
    • 先操作数据库随后再删除缓存。由于对数据的操作耗时较长,当删除缓存后,有另一个线程需要访问该数据,此时会向缓存中写入还未更新的数据,导致缓存和数据库的数据不一致

2.3 缓存穿透

2.3.1 什么是缓存穿透

  客户端请求的数据在缓存和数据库中都不存在,这会使得缓存永远不生效,所有请求都会到数据库层操作,给数据库带来巨大压力。

2.3.2 解决方案

  • 缓存空对象

    • 对于缓存击穿查询的对象也保存一份空对象到缓存中,这样以后的请求就可以从缓存中查到
    • 优点:
      • 实现简单,维护方便
    • 缺点:
      • 额外的内存消耗
      • 可能造成短期的数据不一致性,当缓存中的数据是空对象时,此时恰好为插入了一条该数据到数据库中。
  • 布隆过滤

    • 在请求redis缓存前,先到布隆过滤器中查询是否有该数据,如果没有则拒绝本次请求 (算法在附录中)
    • 优点:
      • 内存占用较少
    • 缺点:
      • 实现复杂
      • 存在误判的可能,会出现不存在的数据被误判为存在
  • 增加id的复杂度,避免被猜测id的规律

  • 做好数据的基础格式校验

2.4 缓存雪崩

2.4.1 什么是缓存雪崩?

  同一时间段内缓存中的大量key同时失效或者redis服务器宕机,导致大量请求到达数据库

2.4.2 解决方案

  • 给不同的key添加随机的TTL值,可以防止redis中的key同时失效
  • 利用redis集群提高服务的可用性
  • 添加多级缓存
  • 给业务添加降级限流策略

2.5 缓存击穿

2.5.1 什么是缓存击穿

  也称热点key问题,一个被高并发访问缓存重建困难的key失效,导致大量的请求被发送到数据库,且同时进行大量的缓存重建操作

2.5.2 解决方案

  • 互斥锁
    • 第一个查询到缓存失效的线程获取到锁并对数据进行缓存重建,其他线程无法获取到锁进行休眠
    • 优点
      • 没有额外的内存损耗
      • 保证一致性
      • 实现简单
    • 缺点
      • 其他没有获取到锁的线程需要等待,效率降低
  • 逻辑过期
    • 每一条热点数据实际上在内存中永不过期,但会存储一个逻辑过期时间。当查询到逻辑过期时间到期后,会开启一个线程进行异步缓存重建,缓存重建期间对该数据的所有查询都会返回旧值。
    • 优点
      • 线程无需等待,性能较好
    • 缺点
      • 缓存重建期间,查询的数据不保证一致性
      • 异步重建缓存会造成一定的内存损耗
      • 实现复杂
  • 互斥锁的实现
    • 使用redis中的setnx即可