缓存

为什么使用

收益:

成本:

缓存方案设计考虑点

  1. 什么数据应该缓存
  2. 什么时机触发缓存和以及触发方式是什么
  3. 缓存的层次和粒度( 网关缓存如 nginx,本地缓存如单机文件,分布式缓存如redis cluster,进程内缓存如全局变量)
  4. 缓存的命名规则和失效规则
  5. 缓存的监控指标和故障应对方案
  6. 可视化缓存数据如 redis 具体 key 内容和大小

数据特征对缓存设计的影响

特征

性能评估模型:$$AMAT = Thit + MR * MP$$

吞吐量

使用OPS值(每秒操作数,Operations per Second,ops/s)来衡量,反映了对缓存进行并发读、写操作的效率

在并发读写的场景下, 避免竞争是最关键的

命中率

某个请求能够通过访问缓存而得到响应时,称为缓存命中率

缓存命中率越高,缓存的利用率也就越高

最大空间

缓存的利用空间是有限的

当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据

分布式支持

缓存可分为“进程内缓存”和“分布式缓存”两大类

使用多级缓存同时得到两种类型的优点:

sequenceDiagram    participant A as 应用程序    participant B as 进程内缓存 (Caffeine)    participant C as 分布式缓存 (Redis)    participant D as 数据源    A ->> B: 一级缓存查询    alt 缓存命中        B ->> A: 返回数据    else 缓存未命中        A ->> C: 二级缓存查询        alt 缓存命中            C ->> A: 返回数据            A ->> B: 回填数据        else 缓存未命中            A ->> D: 数据源查询            D ->> A: 返回数据            A ->> C: 回填数据            A ->> B: 回填数据        end    end

在JVM进程内一级的缓存若过大 可能会造成GC压力过大 此时使用堆外内存分配能有效提升性能

集中式缓存高可用

  1. 客户端方案:在客户端完成缓存分片、负载均衡等操作
  2. 中间代理层:读写请求都是经过代理层完成的。代理层是无状态的,主要负责读写请求的路由功能,并且在其中内置了一些高可用扩展,Facebook 的Mcrouter,Twitter 的Twemproxy,豌豆荚的Codis
  3. 服务端方案:一般就是缓存中间件自带的,[Redis的哨兵](/中间件/数据库/redis/哨兵.html),[Redis的集群](/中间件/数据库/redis/集群.html)

扩展功能

更新策略

当缓存使用量超过了预设的最大值时候 FIFO(先进先出) LRU(最久未使用) LFU(最少使用) 等算法用来剔除部分数据 数据一致性最差(因为数据的过期完全取决于缓存) 但基本没有维护成本

针对LRU的一些缺点,出现了一些算法,这些算法在某些条件下往往有更好的表现:

超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除 段时间窗口内(取决于过期时间长短)存在一致性问题 维护成本不高 只需要设置一个过期时间

应用方对于数据的一致性要求高,需要在真实数据更新后,立即主动更新缓存数据 一致性很高 但是维护成本也是最高的

缓存粒度

究竟是缓存全部属性还是只缓存部分重要属性呢 从三个维度判断:

位置

读写策略

旁路

sequenceDiagram  客户端 ->> 数据库: 更新数据库  客户端 ->> 缓存: 删除缓存  客户端 ->> 缓存: 查询缓存未命中  opt 异步    客户端 ->> 数据库: 查询数据库    客户端 ->> 缓存: 回写缓存  end

读穿写穿

flowchart TB  请求 --> 写请求  写请求 --> |是| 写缓存命中  写缓存命中 --> |是| 写缓存  写缓存命中 --> |否| 写数据库  写缓存 --> 写数据库  写请求 --> |否| 读缓存命中  读缓存命中 --> |是| 返回数据  读缓存命中 --> |否| 数据库加载数据到缓存  数据库加载数据到缓存 --> 返回数据

写回

在写入数据时只写入缓存,并且把缓存块标记为“脏”的。而脏块只有被再次使用时才会将其中的数据写入到后端存储中

这种策略不能被应用到常用的数据库和缓存的场景中,主要是因为一旦缓存机器掉电,就会造成原本缓存中的脏块数据丢失,是底层中如磁盘或者页缓存使用的

缓存风险

缓存雪崩

在高并发的情况下,由于于数据没有被缓存中或者缓存都采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部发到数据库,数据库瞬时压力过重

解决方案:

概括:

热点key

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题:

如果这个key的计算不能在短时间完成,那么在这个 key 在效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞

解决方案:

缓存穿透

指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,请求穿透到了数据库,然后返回空。这样就会导致每次查询不存在的数据都会绕过缓存去查询数据库

解决:

  1. 把空结果,也给缓存起来,这样下次同样的请求就可以直接返回空了,即可以避免当查询的值为空时引起的缓存穿透。这种方案对空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 同时也会有一定的数据不一致性
  2. 也可以使用布隆过滤器直接对这类请求进行过滤。这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景

缓存一致性

缓存中的数据与真实数据源中的数据不一致的现象

解决:

保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。或者直接使用 CDC 同步的方式,直接同步数据库,这样不仅能解耦业务代码,也能拥有最终一致性

缓存无底洞

随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作

解决:

方案优点缺点网络IO
串行命令编程简单,如果少量keys,性能可以满足要求大量keys请求延迟严重O(keys)
串行IO编程简单,少量节点时性能满足要求大量node延迟严重O(nodes)
并行IO延迟取决于最慢的节点编程复杂O(max_slow(nodes))
hash_tag性能最高维护成本高,容易出现数据倾斜O(1)

客户端缓存

浏览器缓存

应用缓存

分为手机APP和Client以及是否遵循http协议

在没有联网的状态下可以展示数据

流量消耗过多

静态化

全量静态化

将网站的所有页面预先生成静态页面,对于小型网站,页面不多,可以采用这个方式

graph TD    A[浏览电商网站] -->|请求| B[Nginx]    B -->|响应|A    C[预先静态化好的页面] -->|html| B    D[MySQL] --> E[页面静态化系统]    E -->|html| C

按需静态化

当数据发生变更,往MQ推送一条消息,消费者消费数据并进行渲染

graph TD    客户 --> |请求|nginx    nginx --> |html|客户    nginx --> F    F[redis 分布式缓存] --> G[缓存服务]    H[MQ]    H --> G    I[商品服务信息] --> |变更消息| H    J[店铺服务信息] --> |变更消息| H    K[广告服务信息] --> |变更消息| H    G --> |调用接口获取变更后的数据| I

优化策略

  1. 增量更新:只更新发生变化的页面,减少全量更新的开销。
  2. 分片静态化:将页面划分为多个部分,分别进行静态化,提高更新效率。
  3. 批量更新:将多次数据变更合并为一次静态化操作,减少频繁更新的成本

运维监控

  1. 页面生成时间
  2. 缓存命中率