1. 什么是缓存

  • 缓存是个相对的概念
  • 例如CPU有自己的L1 cache, 那么这个L1 Cache实际上就是内存的缓存,
  • 操作系统有自己的内存分页, 应用加载的数据有时候也会存在swap区, 那么这个相对于硬盘原始数据也是缓存
  • 通俗得说缓存的特点就是: 通过缓存拿数据肯定是要比重新加载数据来得快
  • 在互联网领域数据通常存在数据库中(MySQL), 而缓存一般是指以下两种:
    1. 应用的本地内存, 例如Java中的ConcurrentHashmap, Guava
    1. 分布式缓存数据库, 例如 Redis, Memcached等

2. 缓存使用原则

2.1 优先使用分布式缓存数据库

  • 在互联网应用中, 通常服务器会部署多个
  • 那么为了方便数据互通, 一般都是存在分布式缓存数据库中
  • 这样一份数据只需要存1次就够了, 而本地内存缓存则需要保存多份, 浪费了很多内存空间

2.2 什么时候使用本地内存缓存?

  • 达到下面几个必要条件,则可以考虑使用内存缓存
  1. 并发量极大, 分布式缓存不足以满足要求
  2. 响应时间要求极高
  3. 可以容忍较多的数据不一致情况
  4. 数据总量不会太多

3. 常见缓存架构

3.1 Read/Write Through 和 Cache Aside 设计

读取缓存方式

  • 读取缓存分两种情况
  1. 如果缓存有则直接读取缓存内容
  2. 如果缓存没有, 则先查询数据库, 然后将数据库中的内容存入缓存, 再返回读取到的内容

点击查看【processon】

更新方式1: 修改缓存 Write Through

  • 更新过数据库之后, 我们很自然的想到一个方案就是, 一并把缓存也更新了

点击查看【processon】

  • 但是这是有问题的!
  • 如果有多个线程都在修改同一条数据
  • 那就导致最终写入缓存的数据不是最新的数据, 这就导致了数据不一致的问题

更新方式2: 删除缓存设计 Cache Aside

  • 为了解决修改数据导致的缓存和数据库数据不一致问题
  • 我们在修改数据的时候, 直接删除缓存即可, 不再修改缓存中的数据

点击查看【processon】

优点

  1. 简单易于维护
  2. 使用删除缓存设计, 可以解决大部分的缓存一致性问题

    缺点

  3. 无法避免缓存和数据库一致性的问题, 一定会有

  4. 每次应用都要查询分布式缓存, 会占据大量的内网带宽


3.2 改进版Cache Aside Pattern, 二级缓存设计

  • 一层缓存, 大部分情况都够用了
  • 但是如果请求要求的延迟比较小, 并发量极大呢? 只有分布式缓存就够了吗?
  • 当并发极大, 延迟要求极高的情况, 就必须引入本地内存缓存了, 例如JVM的Map或者Guava等
  • 这里给出一个二级缓存的设计方案, 这个方案要求把null值也作为合法的value存入缓存

    读取方式:

点击查看【processon】

更新方式

  • 同3.1的删除缓存设计

优点

  1. 极大的缓解了应用和分布式缓存之间的网络带宽压力
  2. 缩短的大部分请求的处理链
  3. 此流程对业务流程无侵入, 例如Java可以自己用AOP来实现这套逻辑
  4. 能较好的解决缓存穿透,缓存击穿,缓存雪崩的问题

    缺点

  5. 设计较为复杂, 提高了系统复杂度, 需要有经验的人来实现

  6. 还是会有一种情况会造成缓存穿透的问题, 就是使用不同的key来查询请求, 要解决可以使用布隆过滤器的方式来补偿, 但会进一步增加系统复杂度
  7. JVM的缓存更新只能等待过期, 所以JVM的缓存我们尽量不要设置太长时间, 10s, 20s就足够了, 根据业务来

3.3 全量缓存设计

  • Cache Aside Pattern 有三个角色: 1. 应用; 2. 缓存; 3. 数据库;
  • 所以Cache Aside Pattern的设计, 总是会访问到数据库
  1. 既然Cache Aside Pattern访问链上游数据库一环, 就总会有机会将气你去压力打到数据库上
  2. 要一次解决数据库可能遇到的 缓存穿透, 缓存击穿, 缓存雪崩 等问题, 我们只需要将数据库拿走即可
  3. 只需要牺牲缓存数据一致性,能得到那么多好处,为什么不怎么干呢?

方案如图:
点击查看【processon】

  • 此方案把数据库, 完全剥离出请求的处理链, 防止请求的压力打到数据库

    优点:

  1. 数据库永远不会被请求并发压力影响
  2. 处理请求的效率非常高
  3. 架构简单, 不易出错, 问题排查容易

    缺点:

  4. 缓存的数据, 并不是实时的数据, 肯定会存在延迟

  5. 全量的数据, 可能会占据大量的内存空间, 使用此设计前, 需要评估

4. 缓存架构的问题

  • 我们来看看, 我们如何解决如下几个问题

    4.1 读写不一致问题

  • 我们使用3.1的读设计和3.3的写设计, 貌似万无一失

  • 但是实际上还是会发生数据不一致的问题
  • 如果读和修改操作同时发生, 有可能会将旧数据写入缓存, 而没有被删除
  • 举例:
  1. 线程1: 开始处理读请求, 没有查到缓存, 查到数据库中的旧数据
  2. 线程2: 开始处理修改请求, 直接修改数据库, 并执行删除缓存操作
  3. 线程1: 将查到的旧数据, 存入缓存
  • 结果: 缓存中是旧数据

点击查看【processon】

方案1: 设置缓存过期时间

  • 我们给缓存设计一个过期时间, 这样即使数据不一致, 问题也持续不了太长时间
  • 当我们的应用满足如下几个条件的时候, 可以使用这个方案
  1. 业务上可以容忍少量的数据不一致
  2. 业务上可以容忍一段时间的数据不一致
  • 而且实际上, 缓存的过期时间并不需要设置太长,设置个1分钟半分钟15秒都能起到非常好的作用
  • 因为
  • 不设置缓存, 单位时间内查询数据库的次数为 N 次, N是请求的数量
  • 设置了缓存, 单位时间内查询数据库的次数约为 (K/P)次 , K是单位时间查询的key的数量, P是过期时间, 已经和N没有关系了, 大大降低了并发的压力

方案2: 分布式读写锁(不推荐)

  • 当我们读请求的时候上分布式读锁, 读锁是共享的
  • 当我们写请求的时候上分布式写锁, 写锁是排它的
  • 只有读锁的时候, 允许读锁重复锁, 不允许写锁上锁, 写锁排队
  • 有写锁的时候, 不允许读锁上锁, 不允许写锁上锁, 读写锁都排队
  • 具体设计, 可以参考MySQL的共享/排它锁设计
    优点:
  1. 解决了读写导致的缓存不一致问题
  2. 只有读操作的时候, 几乎没有性能损失

    缺点:
  3. 系统复杂度骤增, 可能导致后期难以维护, 因为开发人员素质良莠不齐

  4. 大量写操作会严重阻塞业务
  5. 这种设计实际上就是再造一遍MySQL自带缓存的轮子, 意义不大

方案3: 延迟删除缓存操作

  • 发生缓存和数据库数据不一致的原因, 归根结底就是, 并发读写, 在极短的时间内同时发生了读和写操作
  • 那么彻底解决的方案就很容易设计出来
  • 把删除缓存操作延迟进行
  • 这样即使读操作读到了旧数据, 一个延迟操作也会把旧数据删除掉
  • 至于这个延迟时间, 我建议只要比查询和修改操作耗时高一个数量级以上

    延迟方式1:
  • 监听binlog, 延迟删除缓存

    延迟方式2:
  • 将修改操作记录落盘, 然后定时任务读取处理修改操作

    延迟方式3:
  • 修改事件推送进延迟队列, 监听延迟队列删除缓存

其实以上几个延迟删除缓存的方式都会增加系统的复杂度, 使用的时候慎重考虑, 以免给自己引入一个大坑


4.2 缓存穿透问题

  • Cache Aside设计方案中会发生: 当缓存中没有, 数据库中也没有的情况
  • 此时大量的请求依然会访问到数据库, 造成数据库压力巨大
  • 以下提供几种解决方案, 大家可以根据实际情况选择解决方案
  • 不要为了炫技而强行加技术, 不解决也有可能是最佳的解决方案

    方案1: 将null值保存在缓存中

  • 这个方案非常的朴素, 直接将null值保存在缓存中, 我们就可以防止大量的无效查询打到数据库上

    优点:
  • 实现简单, 稳定性高

    缺点:
  • 如果请求会有大量不同的key查询, 会占用大量的缓存空间保存null值

方案2: 布隆过滤器

  • 这个方案是将已有的数据, 先刷入布隆过滤器
  • 每次请求, 只处理通过布隆过滤器的请求

    优点:
  • 利用较小的内存空间, 起到了无效请求的筛选功能, 代码运行速度非常快

    缺点:
  • 需要提前刷入全量数据, 启动较慢

  • 如果遇到数据新增的情况, 还好, 可以直接在布隆过滤器中追加数据,
  • 但是如果遇到数据修改和数据删除的情况, 布隆过滤器就无法进行修改
  • 如果有数据修改和数据删除的场景, 必须要重新刷新布隆过滤器, 这样就非常的低效

4.3 缓存击穿问题

  • Cache Aside设计方案中会发生: 查询的数据, 缓存没有而数据库有
  • 此时大量的请求会访问到数据库, 造成数据库压力巨大

    方案1: 加锁查库

  • 当我们请求向数据库查询同样的key的时候, 需要加分布式锁, 这样,只需要查询一次就够了

    优点:
  • 在大量相同key访问的情况下, 完美解决问题

    缺点:
  • 当大量请求的key不同的时候, 依然会有大量请求打到数据库上

方案2: 热点数据常驻缓存

  • 我们使用定时任务, 将热点数据, 全量定时刷新到缓存中

    优点:
  • 完全解决了缓存击穿的问题

    缺点:
  • 可能内存占用会很大, 不过既然是热点数据, 这部分内存占用也是值得的


4.4 缓存雪崩问题

  • 当我们已经缓存的key集体过期的时候, 这时有大量的请求访问, 会造成这大量的请求一起打到数据库上, 这就叫缓存雪崩

方案1: 随机过期时间

  • 在读取完数据库后存入缓存的时候, 我们给缓存过期时间的基础上额外增加一个随机值
  • 例如: 基础过期时间为T0 = 30s, 那么我们可以给每个key设置的过期时间为 T=T0+RAND(10)
  • 这样就可以防止key集中在一个时间点过期的问题

    优点:
  • 简单稳定高效

    缺点:
  • 在理论上还是有可能大量的key集中过期, 仅仅是理论上

方案2: 缓存永不过期

  • 直接定时将所有数据加载到缓存中, 请求不访问数据库

    优点:
  • 简单稳定高效

    缺点:
  • 缓存占用空间较大

  • 当修改数据的时候, 缓存无法及时修改

PPT

互联网分布式应用缓存设计.key