服务端缓存设计

Feb 9, 2021 15:30 · 1965 words · 4 minute read Cache Architecture

在软件开发中缓存有负面作用:

  • 开发的角度,引入缓存会提高系统复杂度,要考虑缓存失效、更新、一致性;
  • 运维角度,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上;
  • 安全角度,可能泄露某些数据。

引入缓存的两个理由:

  1. 缓解 CPU 压力
  2. 缓解 I/O 压力(空间换时间)

优先考虑通过增强硬件性能来满足需求。

设计缓存时需要考量:

  1. 吞吐量 OPS(每秒操作数)
  2. 命中率
  3. 扩展性
    • 最大容量
    • 失效时间
    • 失效事件
    • 命中率统计
  4. 分布式支持
    • 进程内缓存
    • 分布式缓存

进程内缓存

线程安全措施会降低吞吐量。

  • 设计数据结构避免数据竞争
  • 存在竞争风险时的数据同步
    • 利用锁实现悲观同步
    • 使用 CAS 实现乐观同步
  • 避免伪共享

避免数据竞争

缓存中最主要的数据竞争来源于同时读写数据(比如读取数据时,要同时更新数据的最近访问时间和访问计数器的状态)。

两种思路:

  1. 同步处理机制:即在访问数据时就完成缓存淘汰统计失效等状态变更操作,通过分段加锁等优化手段来尽量减少数据竞争。
  2. 异步日志提交机制:有点像数据库,把对数据的读写过程看作是日志的提交过程
    • 异步提交的日志,将原本在 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,实际上也不会当作缓存来使用

透明多级缓存(Transparent Multilevel Cache)

多级缓存

使用进程内缓存做一级缓存,分布式缓存做二级缓存,如果能在一级缓存中查询到结果就直接返回,否则就到二级缓存中去查询,再将二级缓存中的结果回填到一级缓存;而如果二级缓存也查询不到,就发起对最终数据源的查询,将结果回填到一、二级缓存中去。

代码侵入性太大,处理不当会导致各个节点一级、二级缓存里的数据互相不一致。

“透明”

一种常见的设计原则,就是变更以分布式缓存中的数据为准,访问以进程内缓存的数据优先。当数据发生变动时,在集群内发送推送通知(简单点的话可以采用 Redis 的 PUB/SUB,求严谨的话可以引入 ZooKeeper 或 etcd 来处理),让各个节点的一级缓存自动失效掉相应数据。

有赞透明多级缓存方案:https://tech.youzan.com/tmc/

缓存风险与应对手段

  • 穿透:查询的数据在数据库中都不存在

    • 在一定时间内对返回为空的键值照样进行缓存。
    • 缓存之前设置一个布隆过滤器(有一定的额外开销,但面对恶意攻击时效果很好)
  • 击穿:缓存中的热点数据失效后请求都打到数据库中,导致压力增大

    • 加锁,以请求该数据的键为锁,同时只有一个请求可以流入到真实的数据源中,其他线程阻塞或重试。
      • 进程内缓存使用互斥锁
      • 分布式缓存使用分布式锁
    • 在应用程序中手动管理热点数据而不是由缓存自动淘汰。
  • 雪崩:更多不同的数据一起失效,这些数据的请求都击穿缓存

    • 可能由缓存服务崩溃后重启造成,通过建设分布式缓存集群提高可用性。
    • 使用透明多级缓存架构
    • 缓存的过期时间设置为一个时间段内的随机时间,预防缓存在同一时间集体过期。
  • 污染:缓存中的数据与真实数据源中的数据不一致

    遵循缓存更新的设计模式:

    • Cache Aside(常用)

      先更新数据库,成功后,让缓存失效(不是更新缓存

    • Read Through

    • Write Through

    • Write Behind Caching

缓存更新的套路:https://coolshell.cn/articles/17416.html