缓存存放的是临时数据,相比主存,缓存往往速度更快,容量更小。

缓存往往是多级的,由内而外,cpu cache -> 内存 -> ssd -> hdd(单机) -> 服务器/多节点/oss/nas等

只要有缓存就会有一致性问题

写缓存

写缓存有两种策略,write back和write through。

write back情况下,缓存不会立刻更新到主存,这让write back是单对象的行为。如果是多对象操作缓存,write back必然存在缓存不可见问题。例如page cache,一个进程的写入对另一个进程可能不可见。

一般选择的写缓存策略是write through。 相当于没有写缓存。

读缓存

为了一致性,读写一般使用同一个缓存。读缓存需要考虑的就是缓存失效问题。 读缓存需要处理,如果写操作导致大量客户端读缓存失效,产生的缓存穿透问题。

nfs3 close-and-open一致性表示,文件close后,写缓存刷到后台,文件open后,本地读缓存全部失效,需要从后台重新拿。此外,nfs3通过ctime表示缓存有效性,每次读操作nfs3 都会向后台执行getattr,如果ctime和缓存一致,则直接读本地,否则读后台。

在有lease功能后,lease 的版本id,起到ctime的作用。数据写入服务端会lease++,客户端每次读操作会从后台获取lease,如果lease 和本地一致,则直接读本地,否则本地读缓存失效,读后台。

缓存在生效前需要先预热下,也就是预读

缓存的更新

缓存本质是KV map,需要加锁维护。缓存可能遇到大量请求同时更新,更新时的加锁可能严重影响性能。

缓存更新有两种

  1. 缓存key存在,value失效了需要更新,这种不需要加锁,很快速
  2. 缓存key被淘汰了/不存在,需要插入,这种更新需要加锁,比较重

缓存还可以根据状态更新,典型的是cpu cacheline的MSI协议(Modified, Shared和Invalid)。这个要求每个cache line能感知到其他cache line发生写操作,同时能通知其他cache line写内存。例如cpu1能感知到cpu2发生了写cache操作,于是更新自己状态为unvalid,并让cpu2 刷到内存。java的valitale 关键字就是通过让cpu将对变量的修改直接刷内存

使用范围很小。

缓存穿透,缓存雪崩

读缓存场景,如果数据被写入会导致所有客户端读缓存失效,瞬时压力可能给到服务器。

如果只是写操作导致的缓存失效,则缓存穿透也可以理解
如果是过期引起的缓存失效,使用LRU同时防止缓存大规模同时过期

主存前面可以加队列和流控来缓解压力

流控可以考虑权重公平流控,后台统计每个client的请求数量,当总数量达到阈值,通过权重计算得到每个client的能放进去的请求数量(最小值和最大值,也不能没请求)

4k对齐

场景的缓存多是对齐的,例如page cache是4K对齐

write through策略,每笔写入都会写到后台,即使不是4K写。
客户端读缓存也设置成4K对齐的,使page cache可以命中

自定义fuse客户最好自行管理缓存

文件元数据缓存

主要的元数据缓存

  1. inode缓存,缓存inode 元数据,atime, ctime, mtime, link, mode, size等信息,这个缓存会经常更新
  2. inode 数据缓存,
  3. dentry缓存,缓存目录项存的文件名,方便readdir,文件创建删除时需要更新

ganesha 元数据缓存,
genasha每个目录项会缓存目录中子文件name->filehandle映射,目的是方便lookup。如果lookup某个name缓存未命中, 会持有目录写锁 访问后台,拿到(name, filehandle)后释放目录写锁。
这样的原因是不给后台太多压力,防止多个请求同时打向后台(加了写锁只有一个lookup请求能打到后台),问题是产生了lookup串行化影响性能。

缓存应该有预读的功能,即缓存失效后尽量从后台拿到局部性多的数据。