一、缓存的使用

为了提升系统性能,我们一般都会将部分数据放入缓存,加速访问。

适合放入缓存的数据:

  1. 即时性、数据一致性要求不高的数据(如分类信息、Cookie)
  2. 访问量大且更新频率不高的数据(读多写少)

举例:电商类应用,商品分类、商品列表、物流状态信息等适合加缓存并加一个失效时间

二、缓存失效问题(缓存穿透、击穿、雪崩)

1. 缓存穿透

描述:缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

风险:利用不存在的数据进行攻击,数据库压力瞬间增大,最终导致崩溃。

解决

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

  2. null结果缓存,并加入短暂过期时间。这样可以防止攻击用户反复用同一个id暴力攻击;

  3. 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

2. 缓存击穿

描述:缓存击穿是指缓存中的一些热点Key数据失效,这时由于瞬时并发特别高,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

风险:数据库压力过大甚至down机。

解决

  1. 设置热点数据永远不过期;
  2. 加互斥锁:大量并发只让一个请求去查询数据库,其他请求等待,查到数据放入缓存后释放锁,其他人获取锁后先查缓存,缓存就会有数据,不用去查DB; 最好能根据Key加锁。

​ 加锁流程: 查询缓存 -> 加锁(设置过期时间) -> 查询缓存 -> 查询数据库 -> 放入缓存 -> 释放锁 -> 返回结果

3.缓存雪崩

描述:缓存雪崩是指缓存中大批量数据设置相同的过期时间,导致缓存在每一时刻同时失效,请求全部转到DB,引起数据库压力过大甚至down机。

和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

风险:数据库压力过大甚至down机。

解决

  1. 缓存数据的过期时间基础上增加一个随机时间,防止同一时间大量数据过期现象发生。
  2. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  3. 设置热点数据(频繁使用的数据)永远不过期。

三、缓存数据一致性问题

1.双写模式

定义:数据库写完数据(新增或修改)、写缓存。

问题:并发修改的时候可能会出现暂时性的脏数据问题,但是在数据稳定、缓存过期之后又可以得到最新的正确数据。双写模式只能保证数据最终一致性

例子:写数据库(线程1) -> 写数据库(线程2) -> 写缓存(线程2) -> 写缓存(线程1)

解决:加锁(通常加读写锁),写数据库和写缓存整个流程加上锁。并且所有缓存有过期时间。

2.失效模式

定义:数据库写完数据(新增或修改)、删除缓存中的数据。

问题:并发修改的情况下也会出现脏数据问题

解决:加锁(通常加读写锁)

3.解决缓存一致性问题

无论是双写模式还是失效模式,高并发情况下都有可能导致缓存的不一致问题。

我们应该通过系统设计来解决。

  1. 放入缓存的数据不应该是实时性、一致性要求很高的数据,所以给缓存加上过期时间,保证最一致性就行。
  2. 实时性、一致性要求高的数据,可以直接查数据库。实在要求放缓存可以采用canal订阅binlog的方式。

采用canal订阅binlog的方式可以完美解决缓存不一致问题,但会增加系统设计的复杂性。

四、缓存工具

本地缓存:Caffeine 、 Guava Cache

分布式缓存:redis、memcached

五、分布式锁

常见实现方案: zookeeperredis

1、zookeeper

https://www.jianshu.com/p/51b8280117ca

大致思想: 每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

优点:

  • 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题
  • 实现较为简单

缺点:

  • 性能上不如使用缓存实现的分布式锁,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能
  • 需要对Zookeeper的原理有所了解

排他锁

  • 定义锁:通过Zookeeper上的数据节点来表示一个锁。
  • 获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况。
  • 释放锁:以下两种情况都可以让锁释放:
      1. 当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除;
         2. 正常执行完业务逻辑,客户端主动删除自己创建的临时节点;
    

共享锁

  • 定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号的临时顺序节点

  • 获取锁:客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号 节点

  • 判断读写顺序:大概分为4个步骤:

    1. 创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听

    2. 确定自己的节点序号在所有子节点中的顺序

    3. 对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待 。

      对于写请求:如果自己不是序号最小的节点,那么等待。

    4. 接收到Watcher通知后,重复步骤1)

  • 释放锁:与排他锁逻辑一致

2、redis

redis分布式锁一般使用高级封装框架Redisson

redis官方文档: https://redis.io/topics/distlock

1、加锁:使用SETNX原子性加锁,设置过期时间、并为每个竞争锁的节点加上唯一标识(UUID、token)

2、解锁:使用redis官方提供的lua脚本原子性解锁

基于缓存实现分布式锁总结

优点:

  • 性能好

缺点:

  • 实现中需要考虑的因素太多
  • 通过超时时间来控制锁的失效时间并不是十分的靠谱