服务端缓存设计
Feb 9, 2021 15:30 · 1965 words · 4 minute read
在软件开发中缓存有负面作用:
- 开发的角度,引入缓存会提高系统复杂度,要考虑缓存失效、更新、一致性;
- 运维角度,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;
- 安全角度,可能泄露某些数据。
引入缓存的两个理由:
- 缓解 CPU 压力
- 缓解 I/O 压力(空间换时间)
优先考虑通过增强硬件性能来满足需求。
设计缓存时需要考量:
- 吞吐量 OPS(每秒操作数)
- 命中率
- 扩展性
- 最大容量
- 失效时间
- 失效事件
- 命中率统计
- 分布式支持
- 进程内缓存
- 分布式缓存
进程内缓存
线程安全措施会降低吞吐量。
- 设计数据结构避免数据竞争
- 存在竞争风险时的数据同步
- 利用锁实现悲观同步
- 使用 CAS 实现乐观同步
- 避免伪共享
避免数据竞争
缓存中最主要的数据竞争来源于同时读写数据(比如读取数据时,要同时更新数据的最近访问时间和访问计数器的状态)。
两种思路:
- 同步处理机制:即在访问数据时就完成缓存淘汰、统计、失效等状态变更操作,通过分段加锁等优化手段来尽量减少数据竞争。
- 异步日志提交机制:有点像数据库,把对数据的读写过程看作是日志的提交过程。
- 异步提交的日志,将原本在 Map 内的锁转移到了日志的追加写操作上。
- 日志可以通过**环形缓冲区**这种数据结构来记录。
命中率 & 淘汰策略
缓存设计只考虑自动淘汰,人工淘汰由业务层代码决定。
-
FIFO (First In First Out):淘汰最先被缓存的数据
简单,但是基本不能用。越是频繁被用到的数据,往往越早地被存入,很可能会大幅降低命中率。
-
LRU (Least Recent Used):淘汰最久未被访问过的数据
- 通过 HashMap + 链表实现。
- 比 FIFO 合理,尤其适合用来处理短时间内被频繁访问的热点数据。
-
LFU (Least Frequently Used):淘汰最不经常使用的数据
为每个数据维护一个访问计数器,但是开销很大。
-
TinyLFU (Tiny Least Frequently Used)
- 利用统计学来评估全体数据的特征,借助 Count–min sketch 算法用相对小得多的记录频率和空间,来近似地找出缓存中的低价值数据。
- 基于“滑动时间窗”的热度衰减算法,每隔一段时间,便会把计数器的数值减半,解决“旧热点”数据难以清除的问题。
-
W-TinyLFU (Windows-TinyLFU)
- 解决稀疏突发访问(绝对频率较小,但突发访问频率很高的数据,比如定时任务场景)。
- 把新记录暂时放入一个名为 Window Cache 的前端 LRU 缓存里面,让它们在 Window Cache 中累积热度,如果能通过 TinyLFU 的过滤器,再进入名为 Main Cache 的主缓存中存储。
淘汰算法的效率:https://github.com/ben-manes/caffeine/wiki/Efficiency-zh-CN
扩展功能
- 加载器
- 淘汰策略
- 事件通知
- 并发级别
- 容量控制
- 统计信息
- 持久化
分布式缓存
读多写少的数据适合做复制式缓存;读写都多的数据适合做集中式缓存。
- 复制式缓存:每个节点一份副本,完全一样
- 读取数据时直接从当前节点的进程内存中返回。
- 数据发生变化时要将变更同步到集群的每个节点中,维护开销极大。
- 已被淘汰
- 集中式缓存:与使用缓存的应用程序分处在独立的进程空间中,通过网络读写
- AP(弱一致性)
- Redis
- CP(强一致性)
- ZooKeeper、etcd,实际上也不会当作缓存来使用
- AP(弱一致性)
透明多级缓存(Transparent Multilevel Cache)
多级缓存
使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则就到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存;而如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。
代码侵入性太大,处理不当会导致各个节点一级、二级缓存里的数据互相不一致。
“透明”
一种常见的设计原则,就是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。当数据发生变动时,在集群内发送推送通知(简单点的话可以采用 Redis 的 PUB/SUB,求严谨的话可以引入 ZooKeeper 或 etcd 来处理),让各个节点的一级缓存自动失效掉相应数据。
有赞透明多级缓存方案:https://tech.youzan.com/tmc/
缓存风险与应对手段
-
穿透:查询的数据在数据库中都不存在
- 在一定时间内对返回为空的键值照样进行缓存。
- 缓存之前设置一个布隆过滤器(有一定的额外开销,但面对恶意攻击时效果很好)
-
击穿:缓存中的热点数据失效后请求都打到数据库中,导致压力增大
- 加锁,以请求该数据的键为锁,同时只有一个请求可以流入到真实的数据源中,其他线程阻塞或重试。
- 进程内缓存使用互斥锁
- 分布式缓存使用分布式锁
- 在应用程序中手动管理热点数据而不是由缓存自动淘汰。
- 加锁,以请求该数据的键为锁,同时只有一个请求可以流入到真实的数据源中,其他线程阻塞或重试。
-
雪崩:更多不同的数据一起失效,这些数据的请求都击穿缓存
- 可能由缓存服务崩溃后重启造成,通过建设分布式缓存集群提高可用性。
- 使用透明多级缓存架构
- 缓存的过期时间设置为一个时间段内的随机时间,预防缓存在同一时间集体过期。
-
污染:缓存中的数据与真实数据源中的数据不一致
遵循缓存更新的设计模式:
-
Cache Aside(常用)
先更新数据库,成功后,让缓存失效(不是更新缓存)
-
Read Through
-
Write Through
-
Write Behind Caching
-