1. 什么是缓存
- 缓存是个相对的概念
- 例如CPU有自己的L1 cache, 那么这个L1 Cache实际上就是内存的缓存,
- 操作系统有自己的内存分页, 应用加载的数据有时候也会存在swap区, 那么这个相对于硬盘原始数据也是缓存
- 通俗得说缓存的特点就是: 通过缓存拿数据肯定是要比重新加载数据来得快
- 在互联网领域数据通常存在数据库中(MySQL), 而缓存一般是指以下两种:
- 应用的本地内存, 例如Java中的ConcurrentHashmap, Guava
- 分布式缓存数据库, 例如 Redis, Memcached等
2. 缓存使用原则
2.1 优先使用分布式缓存数据库
- 在互联网应用中, 通常服务器会部署多个
- 那么为了方便数据互通, 一般都是存在分布式缓存数据库中
- 这样一份数据只需要存1次就够了, 而本地内存缓存则需要保存多份, 浪费了很多内存空间
2.2 什么时候使用本地内存缓存?
- 达到下面几个必要条件,则可以考虑使用内存缓存
- 并发量极大, 分布式缓存不足以满足要求
- 响应时间要求极高
- 可以容忍较多的数据不一致情况
- 数据总量不会太多
3. 常见缓存架构
3.1 Read/Write Through 和 Cache Aside 设计
读取缓存方式
- 读取缓存分两种情况
- 如果缓存有则直接读取缓存内容
- 如果缓存没有, 则先查询数据库, 然后将数据库中的内容存入缓存, 再返回读取到的内容
更新方式1: 修改缓存 Write Through
- 更新过数据库之后, 我们很自然的想到一个方案就是, 一并把缓存也更新了
- 但是这是有问题的!
- 如果有多个线程都在修改同一条数据
- 那就导致最终写入缓存的数据不是最新的数据, 这就导致了数据不一致的问题
更新方式2: 删除缓存设计 Cache Aside
- 为了解决修改数据导致的缓存和数据库数据不一致问题
- 我们在修改数据的时候, 直接删除缓存即可, 不再修改缓存中的数据
优点
3.2 改进版Cache Aside Pattern, 二级缓存设计
- 一层缓存, 大部分情况都够用了
- 但是如果请求要求的延迟比较小, 并发量极大呢? 只有分布式缓存就够了吗?
- 当并发极大, 延迟要求极高的情况, 就必须引入本地内存缓存了, 例如JVM的Map或者Guava等
- 这里给出一个二级缓存的设计方案, 这个方案要求把null值也作为合法的value存入缓存
读取方式:
更新方式
- 同3.1的删除缓存设计
优点
- 极大的缓解了应用和分布式缓存之间的网络带宽压力
- 缩短的大部分请求的处理链
- 此流程对业务流程无侵入, 例如Java可以自己用AOP来实现这套逻辑
-
缺点
设计较为复杂, 提高了系统复杂度, 需要有经验的人来实现
- 还是会有一种情况会造成缓存穿透的问题, 就是使用不同的key来查询请求, 要解决可以使用布隆过滤器的方式来补偿, 但会进一步增加系统复杂度
- JVM的缓存更新只能等待过期, 所以JVM的缓存我们尽量不要设置太长时间, 10s, 20s就足够了, 根据业务来
3.3 全量缓存设计
- Cache Aside Pattern 有三个角色: 1. 应用; 2. 缓存; 3. 数据库;
- 所以Cache Aside Pattern的设计, 总是会访问到数据库
- 既然Cache Aside Pattern访问链上游数据库一环, 就总会有机会将气你去压力打到数据库上
- 要一次解决数据库可能遇到的 缓存穿透, 缓存击穿, 缓存雪崩 等问题, 我们只需要将数据库拿走即可
- 只需要牺牲缓存数据一致性,能得到那么多好处,为什么不怎么干呢?
方案如图:
点击查看【processon】
4. 缓存架构的问题
- 线程1: 开始处理读请求, 没有查到缓存, 查到数据库中的旧数据
- 线程2: 开始处理修改请求, 直接修改数据库, 并执行删除缓存操作
- 线程1: 将查到的旧数据, 存入缓存
- 结果: 缓存中是旧数据
方案1: 设置缓存过期时间
- 我们给缓存设计一个过期时间, 这样即使数据不一致, 问题也持续不了太长时间
- 当我们的应用满足如下几个条件的时候, 可以使用这个方案
- 业务上可以容忍少量的数据不一致
- 业务上可以容忍一段时间的数据不一致
- 而且实际上, 缓存的过期时间并不需要设置太长,设置个1分钟半分钟15秒都能起到非常好的作用
- 因为
- 不设置缓存, 单位时间内查询数据库的次数为 N 次, N是请求的数量
- 设置了缓存, 单位时间内查询数据库的次数约为 (K/P)次 , K是单位时间查询的key的数量, P是过期时间, 已经和N没有关系了, 大大降低了并发的压力
方案2: 分布式读写锁(不推荐)
- 当我们读请求的时候上分布式读锁, 读锁是共享的
- 当我们写请求的时候上分布式写锁, 写锁是排它的
- 只有读锁的时候, 允许读锁重复锁, 不允许写锁上锁, 写锁排队
- 有写锁的时候, 不允许读锁上锁, 不允许写锁上锁, 读写锁都排队
- 具体设计, 可以参考MySQL的共享/排它锁设计
优点:
方案3: 延迟删除缓存操作
- 发生缓存和数据库数据不一致的原因, 归根结底就是, 并发读写, 在极短的时间内同时发生了读和写操作
- 那么彻底解决的方案就很容易设计出来
- 把删除缓存操作延迟进行
- 这样即使读操作读到了旧数据, 一个延迟操作也会把旧数据删除掉
至于这个延迟时间, 我建议只要比查询和修改操作耗时高一个数量级以上
延迟方式1:
-
延迟方式2:
-
延迟方式3:
修改事件推送进延迟队列, 监听延迟队列删除缓存
其实以上几个延迟删除缓存的方式都会增加系统的复杂度, 使用的时候慎重考虑, 以免给自己引入一个大坑
4.2 缓存穿透问题
- Cache Aside设计方案中会发生: 当缓存中没有, 数据库中也没有的情况
- 此时大量的请求依然会访问到数据库, 造成数据库压力巨大
- 以下提供几种解决方案, 大家可以根据实际情况选择解决方案
-
方案1: 将null值保存在缓存中
这个方案非常的朴素, 直接将null值保存在缓存中, 我们就可以防止大量的无效查询打到数据库上
优点:
-
缺点:
如果请求会有大量不同的key查询, 会占用大量的缓存空间保存null值
方案2: 布隆过滤器
- 这个方案是将已有的数据, 先刷入布隆过滤器
-
优点:
利用较小的内存空间, 起到了无效请求的筛选功能, 代码运行速度非常快
缺点:
需要提前刷入全量数据, 启动较慢
- 如果遇到数据新增的情况, 还好, 可以直接在布隆过滤器中追加数据,
- 但是如果遇到数据修改和数据删除的情况, 布隆过滤器就无法进行修改
- 如果有数据修改和数据删除的场景, 必须要重新刷新布隆过滤器, 这样就非常的低效
4.3 缓存击穿问题
- Cache Aside设计方案中会发生: 查询的数据, 缓存没有而数据库有
-
方案1: 加锁查库
当我们请求向数据库查询同样的key的时候, 需要加分布式锁, 这样,只需要查询一次就够了
优点:
-
缺点:
当大量请求的key不同的时候, 依然会有大量请求打到数据库上
方案2: 热点数据常驻缓存
4.4 缓存雪崩问题
- 当我们已经缓存的key集体过期的时候, 这时有大量的请求访问, 会造成这大量的请求一起打到数据库上, 这就叫缓存雪崩
方案1: 随机过期时间
- 在读取完数据库后存入缓存的时候, 我们给缓存过期时间的基础上额外增加一个随机值
- 例如: 基础过期时间为T0 = 30s, 那么我们可以给每个key设置的过期时间为 T=T0+RAND(10)
-
优点:
-
缺点:
在理论上还是有可能大量的key集中过期, 仅仅是理论上