什么是缓存
在计算机中, 读写内存数据的速度远远小于 cpu 处理数据的速度, 从而拖慢了整体的处理速度。为了解决这一问题, 在 cpu 和内存之间增加一层缓存, 从而解决两者处理速度不匹配的问题, 提升整体的处理速度。
为什么增加缓存可以提升处理速度?
在下面的流程图中, 不使用缓存的情况下每次操作需要 max(0.1, 100) = 100 ns。使用缓存的情况下, 如果命中缓存每次操作需要 max(0.1, 0.5) = 0.5ns; 如果缓存不命中则需要 max(0.1, 100) = 100ns。因此, 缓存命中率越高, 程序运行越快。
既然缓存那么快, 为什么还使用内存?
这里其实是成本问题, 更大的缓存空间往往意味着更高的成本。因此, 我们在考虑缓存时, 其实就携带着一个前置条件, 空间有限, 成本有限。从处理速度来看, cpu > 内存 > SSD > 硬盘 > 网络。
对上面的问题进行抽象:为了处理速度不配问题, 引入缓存, 从而提升整体的处理的速度。因此, 只要遇到类似的问题, 我们就可以考虑使用缓存。目前, 大家已经将缓存应用到各个环节。例如, 浏览器缓存、客户端缓存、CDN 缓存、DNS 缓存、数据库缓存等。
使用场景
在上一节中了解到, 缓存本质上解决的是速度不匹配问题, 提升系统整体的吞吐。缓存命中率越高, 其效果越好。极端场景下, 如果缓存命中率为 0, 则没有必要引入缓存。在本文中, 我们重点讨论服务端缓存。我们先看下一个带有缓存组件的处理过程, 这里的 service B 可以是存储、rpc 调用、http 调用。因此, 在引入缓存之前就需要先思考以下问题:
- 请求响应慢, 能否直接通过对 service B 进行扩容或优化来处理
- service B 的响应是否可以进行缓存「一些推荐算法, 即使请求相同放回的数据也不同」
- 业务上是否存在经常访问的数据「保证缓存能命中」
在具体的服务端开发中, 最常见的场景就是在访问存储「mysql、oracle」之前加一层缓存。核心是解决高并发场景下由于存储响应过慢而被打垮的问题。当然, 也有些业务场景明确要求需要在 xxms 内进行响应。这时, 引入缓存可以有效的提升服务的响应时长和并发能力。
常见问题
在服务设计时, 我们经常会说复杂度并不会减少。引入缓存, 可以有效的提升服务的吞吐能力, 但也带来了额外的复杂度。下面就介绍一下引入缓存带来的额外问题, 以及应对方案。
淘汰策略
上节说到, 由于缓存的成本较高。这就意味着缓存的空间有限, 不能无限的写入。因此, 可以制定对应的缓存淘汰策略来释放空间。这里我们需要先思考清楚, 缓存最重要的指标就是命中率, 也就是尽可能的保证服务用到的数据都已经在缓存中。那么, 就需要根据具体的业务场景选择淘汰算法, 来保证缓存能有较高的命中率。
LRU「Least Recently Used, 最近最少使用」
优先淘汰最久未被使用访问过的数据。适用数据项被集中时间访问的场景且后续不对再访问。如下图左侧所示, 数据 a、b、c、d 仅在一段时间内持续被访问, 随后不再访问。缺点是对于一些热点数据, 如果间隔被访问, 则导致命中率下降。如下图右侧所示, a 长时间被访问, 再次被访问是确无法命中。
LRU 算法在实现时, 通常适用 hashMap 加 linkList 实现。访问数据时, 通过 hashMap 快速判断指定的 key 是否存在, 如果存在, 更新 key 在 linkList 的位置, 移到链表的头部。
LFU「Least Frequently Used, 最不经常使用」
LFU 基于的思想是:一定时期内被访问次数最少的数据,在将来被访问到的几率也是最小的。与 LRU 相比, LRU 是基于最近访问的时间进行淘汰, LFU 是基于最近访问的次数进行淘汰。LFU 主要适用于数据访问复合正态分布的数据, 能提高缓存命中率。然而缺点是早期的数据更容易被缓存下来, 新加入的数据容易被剔除。此外, 由于需要维护数据的访问次数, 也带来了一定的算法复杂度。
LFU 算法在实现时, 也可以使用双 hashMap 加 linkList 实现。一个 hashMap 用于寻找 key, 一个 hashMap 用于寻找指定次数对应的 key。
FIFO「Fist In Fist Out, 先进先出」
有限淘汰最先缓存的数据。这类主要适用于数据有严格的访问顺序, 先访问的数据不会再次被访问到。缺点是对于一些高频访问的数据会被淘汰。
算法实现可以使用 hashMap 和 arr 实现。hashMap 用于查找 key, arr 用于控制先进先出数据。
TTL「Time To Live, 过期时间」
在写入缓存时给定一个 ttl, 来标识 key 上面时候过期。这样业务方可以自主的控制每个 key 的过期时间。缺点就是, 对于一些场景, 业务上也无法明确给出对应的 ttl。
算法也比较简单, 可以启动一个异步任务, 分批扫描 key, 如果过期, 则将 key 删除即可。
一致性
缓存其实就是把部分热点数据存储到处理速度较快的存储介质中。如果缓存中的数据未能及时的做出更新, 就会出现数据不一致的的问题, 从而产生业务逻辑错误。如下图所示, 当更新一条数据时, 如何保证读到的是最新数据呢?
分布式事务
最直观的想法就是在更新数据库时同步更新缓存。为了避免数据库更新成功而缓存更新失败的情况, 则需要使用分布式事务。这样我们显然把问题给复杂化了, 可以从业务场景考虑一下, 我们是否有强诉求需要保证数据的一致性。
Binlog 同步
既然无法保证强一致性, 那是否可以保证最终一致性呢。或尽可能将不一致的时间缩短且保证最终一致性。可以考虑合理利用数据库的 binlog 机制, 消费 binlog 来更新缓存。
更新数据库更新缓存
通过使用 binlog 的形式, 引入了额外的组件, 增加了系统复杂度。如果, 业务场景上可以容忍极端情况下的数据不一致问题, 那可以将系统简单化。数据的更新以 db 为准, db 操作成功则认为数据更新成功。当数据库更新完之后, 可以进行更新缓存。对于更新失败的场景可以简单进行重试。
更新数据库删除缓存
相较于更新缓存, 有些人倾向于删除缓存。相较于更新缓存, 删除缓存有较小的 io 操作。
缓存双删
其实无论是更新缓存还是删除缓存都无法解决边界 case 下「极端情况」, 数据不一致的问题。我们知道, 当读数据时, 数据读不到回 set 缓存。这就意味着读操作会更新缓存, 数据写入的时候也会更新缓存, 那么在极端场景下读操作写入的缓存会将新数据覆盖。可以看下面两个流程:
从上面流程可以看到, 极端场景下当读请求读到 v1 版本数据到 set v1 到缓存之间发生缓存的更新操作, 则会发生覆盖问题。通常情况下, set v1 到缓存也就几 ms 左右。因此, 可以使用缓存双删的形式, 来处理这种边界 case。当然, 删除缓存也会导致读请求回源到 db, 增加 db 的负载。
自动过期
如果有些业务场景对一致性非常不敏感。例如:有些业务场景, 更新完半个小时之后才能完成全部的业务流转, 才真正有读请求过来。那么, 我们就无需考虑缓存的更新和删除问题。直接给缓存设置一个半小时的过期时间, 让其自动过期回源即可。
多级缓存的一致性问题
在上面的场景中, 我们主要讨论了集中缓存和存储的一致性问题。然后, 有些业务场景, 为了抗大量的流量, 会选择使用进程内缓存作为一级缓存。那么, 如何保证进程内缓存数据的一致性呢?
- 消息广播
每次启动 local cache 时需要订阅变更事件的消息, 进行广播消费, 及时更新缓存。
- 请求 hash
使用 local cache 会导致相同的 key 分布在不同的实例上, 很难及时进行更新。那我们可以换种思路, 将相同的 key 分布在同一台机器上, 这样我们只需要调用一下 update 方法即可。最简单的方式就是请求时增加 hash, 将相同 key 请求到同一台机器上。当然, 这样就需要调用方接入 hash 算法。同时, 对于热点 key, 请求会集中在同一台服务上。
- 过期时间
同样的, 我们可以设置较小的过期时间, 来进行容忍一定时间的不一致。当然, 较小的过期时间会带来命中率较低的问题。
- 定时更新
为了保证较高的命中率, 还可以考虑使用异步定时更新的方式, 及时缓存较新的数据。
缓存击穿
缓存击穿是指:对于一些热 key 读取时, 缓存没有命中 , 从而导致大量相同请求回源, 拖垮 db。这里可以看到, 造成缓存击穿的问题在于缓存没有命中导致大量请求回源 db。因此, 解决缓存击穿的问题就是要提高命中率和大量请求回源的问题。那么常见的方案有:
- 缓存预热, 提前将热点数据进行缓存
- 缓存续期, 启动异步任务, 及时对要过期的缓存进行续期或者设置热点数据不过期
- 设置多级缓存, 层层兜底, 减少到 db 的请求量
- 回源时进行限流, 例如, 对于相同的 key, 同时只能有一个请求到 db, 其他请求等该请求处理完去缓存读取
缓存穿透
与缓存击穿类似, 缓存穿透也是指大量请求到 db, 导致服务打垮。区别在于:当发生缓存穿透时, 即使请求到 db 也无法回塞到缓存。例如:db 中并不存在请求的数据, 从而导致每次请求都回源。可以考虑:
- 缓存空值, 如果 db 中查不到数据, 可以缓存空数据, 同时设置较小的过期时间, 能有避免相同请求重复回源
- 使用布隆过滤器, 通过布隆过滤器先前置判断数据是否已经存在, 如果存在则请求 db
- 如果是恶意请求, 即无论如何 db 中都不会存在数据, 可以考虑前置将恶意请求过滤
缓存雪崩
与缓存击穿相比, 缓存雪崩的问题在于大量的 key 过期而导致请求全部到 db, 拖垮服务。那么我们的处理错误就和缓存击穿处理类似, 核心是解决大量 key 同时过期问题:
- 给 key 设置动态的过期时间, 避免大量 key 同时过期
- 缓存续期, 启动异步任务, 及时对要过期的缓存进行续期或者设置热点数据不过期
- 设置多级缓存, 层层兜底, 减少到 db 的请求量
- 回源时进行限流, 设置访问 db 的 qps
热 key
热 key 是指有些 key 访问量特别的大, 可能几十万 qps。只要缓存无法命中, 就会瞬间拖垮 db。那么, 我们解决问题的思路就是避免无法命中:
- 对于热 key, 可以前置设置到缓存中, 同时不设置过期时间
- 如果使用了 localcache, 则需要进行缓存预热, 前置将缓存加载到内存中
- 将热 key 进行复制, 例如:key -> key1, key2, key3 从而将请求分布的不同节点
- 回源时进行限流, 例如, 对于相同的 key, 同时只能有一个请求到 db, 其他请求等该请求处理完去缓存读取
大 key
大 key 是指有些缓存项数据量太大, 超过 10kb, 从而影响了缓存本身的性能。以 redis 为例, 如果 key 过大, 会导致其他请求阻塞。解决这类问题时, 则需要考虑将大 key 变小:
- 对 key 进行压缩, 从而减少 io
- 将大 key 进行拆分, 这样就可以将 key 存储到不同的实例上, 从而避免单点性能问题
总结
设计一个好的缓存策略还是相当困难的, 我们只能根据具体的业务需求来选择不同的缓存策略。在引入缓存缓存之前, 我们需要先考虑清楚是否一定要引入缓存。常见的判断标准有:读请求流量特别大, 如果不使用缓存会将 db 打挂; 接口性能要求特别高, db 查询可能要 5-10ms, 而使用缓存只需要 1-3ms; 对于一些请求, 会有大量重复的计算, 耗费过多资源, 也可以考虑使用缓存。
确定引入缓存之后, 我们则需要考虑一致性问题, 缓存淘汰策略问题。如果我们的缓存资源「redis」充足, 可以考虑不设置过期时间, 将数据全部塞到 redis 中。如果资源有限, 则需要根据具体的业务场景设置合理的过期时间和淘汰策略。
对于一致性问题, 通常来说, 先更新数据库再更新缓存的方式已经能满足大多数业务场景。为了得到更极致的性能, 也可以选择再加一层 localcache, 可以选择将其设置较短的过期时间, 从而避免主动更新 localcache。
对于一些并发非常高的场景, 则需要处理好缓存击穿的问题。首先, 就是保护好 db, 对于 db 访问一定要做好限流「相同 key 只能访问一次、分布式限流」。如果资源允许的情况下, 可以设置缓存不过期, 缓存预热。
此外, 我们要对缓存做好相关的监控, 明确的了解当前缓存的命中率情况, 回源到 db 的情况。