缓存与Redis

前言

人们在使用产品的时候都有一个共识,不喜欢等待。这也是为什么缓存在我们的项目中如此的重要。他可以存储一些经常被访问的数据,然后在需要的时候,以一个相对较快的速度将其返回回去。本文将以"Caching at Scale With Redis"书中内容为基准,讲述如何在Redis中用好缓存。本书主要内容有:

  • 什么是缓存
  • 为什么需要缓存
  • 基本的缓存策略
  • 缓存一致性的相关内容
    ...

什么是缓存?

通俗来说,缓存就是一个数据存储的数据库,只不过他的访问速度会比原数据库更快。当一个需要复杂计算的结果被计算出来后,他就可以直接存储在缓存中,之后的请求如果想要这个结果,那么可以直接从缓存中抽取出来,这对于时间与资源都是极大的节约。

下面是一些具体使用缓存的场景

  • CPU的多级缓存。
  • 数据库查询结果缓存
  • 磁盘缓存。
    总的来说,需要经常访问的数据,都可以放到缓存中。

使用缓存的一些基本原则

  1. 原始的操作必须"贵"。比如操作是CPU密集型,I/O密集型,或者因为在网络,磁盘,数据库中传递或访问而导致的开销较大
  2. 缓存自身必须更快,对资源开销更小。你缓存开销比原始开销都大,那你用鸡毛缓存呢。
  3. 数据最好不要频繁改动。比如对于"读多写少"的操作,对于频繁改动的数据,就会有一致性的问题,则需要额外的手段来保障其一致性。
  4. 缓存操作必须无副作用。比如你缓存数据的过程把数据更改了,这肯定是不可以的。要满足幂等性,同样的输入必须得到同样的输出。
  5. 缓存的数据被使用的几率越大越好。也就是缓存命中越高越好,你一个数据只被用一次,那缓存纯纯就是负担了。
    如果你想用好缓存,那么你就要对系统数据分布有一个充分的理解。那些高频访问的少量数据用来做缓存非常合适。

static cache and dynamic cache

首先来看看什么是static cache ,什么是dynamic cache。

这里的静态和动态是针对与缓存中的数据来说的,那么静态的数据一般就是不变的数据嘛,或者称为read/only data。对于动态的数据,就是可以变化的数据,那么就是read/write data。

当原数据变更的时候,对于static cache,他直接更新数据库,然后删除缓存中的内容,下次访问hit miss的时候重新加入数据库。对于dynamic cache,他更新数据库与缓存。

为什么需要缓存

其实我觉得缓存核心就是一句话,对于已经重复做过的事情,我们就不要浪费时间,浪费资源的再次重新做了。核心的核心就是不要浪费资源在没有意义的事情上,做重复的事情,给程序分配完全不需要的资源,

缓存带来的好处

  • 提高性能。将慢的计算,读取换取为快的读取。缩短响应的时间。
  • 提高可扩展性。新增的机器不用因那些重复的事情而浪费资源。那么机器就可以去做真正需要他的部分。
  • 资源优化。
  • 提高可靠性。比如说当数据库不可用时,缓存也可以顶一部分的请求,不至于直接全部down掉

缓存可能导致的问题

  • 缓存屏蔽原本需要的副作用。
    首先什么是副作用。它是指本次操作会对系统其他部分有影响。比如本次操作会导致系统其他部分更改。当我们去缓存有副作用的操作的时候,必须要考虑对应的副作用。比如说防止超卖,我们就要考虑在更新缓存的时候对数据库的副作用。而这个副作用某些情况下可能并不是很明显的,所以这会带来额外的复杂度。
  • 数据不一致带来的问题
    这一部分很常见,比如数据库更新和缓存更新某种意义上是无法做到完全同步的。
  • 低下的缓存性能
    缓存性能取决于以下两个方面
    1. 查询缓存所需的成本小于原始操作
    2. 缓存命中概率远远大于缓存失效
      当二者越为true,效率越高,否则越低。

一些缓存策略

有句话有点意思

There are three hard things in building software: maintaining cache consistency and off-by-one errors.

如果你看不懂,不妨问问AI。

我们将数据如何通过缓存,将缓存策略分为大的两部分,一部分是Inline cache,另一部分是Cache-aside pattern

Inline Cache

他包含read-through , write-through , read/write through。数据库直接连接缓存。简单来说,当数据查询命中缓存的时候,他直接返回,当缓存未命中的时候,去查数据库,然后把数据放回缓存中。

Cache-aside pattern

应用全权负责读写逻辑。缓存和数据库的访问是完全独立的。当缓存失效的时候,应用程序 直接从数据库中读取,然后应用程序 将该数据放到缓存中。和inline不同的是,这里的缓存和数据库完全不相连,由应用程序作为中间周转的枢纽。

缓存一致性与如何保持缓存一致性

当缓存中的数据和数据库(认为真正有效)中的数据不相同的时候,称为数据不一致。

保持缓存一致性的算法:

cache invalidation

当缓存数据和真实数据不一致的时候,我们直接将缓存中的数据删掉。在下一次读取的时候,会强制从数据库中读取最新的数据。当然,这种办法会让缓存的优势荡然无存。

write through caches

应用操作缓存 进行数据的更新,然后缓存 再同步的将更新刷回数据库。这种操作的缺点是需要双写,写入数据库操作会拖慢整个流程

write-behind / write-back cache strategies

这种形式算是上边那种形式的升级,他不用同步刷回数据库,而是去异步的操作。当然了,对于缓存的权衡,就是在数据一致性和效率之间的权衡。你没办法去得到一个兼顾二者的完美方案。我们只能根据场景来权衡。这里举一个例子。比如说redis此时的数据为20,他的RDB数据为18,数据库中为13。由于更新缓存,那么如果缓存down了由RDB恢复,则会导致数据丢失。并且如果有其他的地方去读数据库中的内容,那么会导致出现读到旧数据的问题。

缓存驱逐策略(Cache eviction)

由于我们的缓存不能存放所有的内容,随着缓存的使用,我们无法存储所有的内容,所以我们需要有缓存驱逐策略来驱逐那些不常用的缓存。这部分内容和操作系统中讲的缓存策略思路是相通的,如果你有基础,相信你理解无压力,如果你不懂,那么建议你先去看看操作系统相关知识内容。常见的缓存驱逐策略有:

LRU(Least Recently Used)最近最少使用

在时间上来看,最近使用的数据在不就的将来也会被使用,而不用的数据将来可能也不会使用。所以我们使用最近最少使用策略来作为驱逐策略。当然,这也会有问题,如果有大量的新数据传入,那么有些常用的数据可能会因此被驱逐。

LFU(Least Frequently Used) 最近最不频繁使用

从使用上来看,最近被使用的数据,将来也可能会被使用。不用的数据将来同样可能也不会被使用。所以出现了LFU。当然,这也有问题。如果有大量新数据传入,他们会因为使用频率较低而被驱逐。

你可能想问,有没有方法解决LFU和LRU的问题,同时结合二者的优点呢?有的兄弟,有的,可以去查一查 caffeine的W-TinyLFU

Oldest-stored eviction

没什么好说的,很暴力,直接将最老的干掉。 他的问题就是会导致常用数据被干掉

Time-to-live(TTL) eviction

给一个时间,到时间了干掉。redis的expire就是干这个的,也是蛮好用的。具体的用处比如session

cache persistence

这个也很暴力,如果缓存满了,那么不接受任何额外的数据,对于一些小规模的数据可能会游泳

Cache thrashing

这种情况指的是数据被驱逐后,又重新需要他。那么这会导致一个问题,即A被驱逐,A又被重新需要,则A取代B的位置,但是B又要被重新使用。这种被称为cache thrashing,缓存颠簸。

总结

没有什么最好的缓存驱逐策略,只有看自己的场景需要什么缓存驱逐策略才是最重要的。一切的一切都要从实际出发。一般使用前两个,或者用oldest那个。

warm cache vs cold cache

  • cold cache : 最开始的时候,没有任何数据或者只有一些静态数据,所以最开始的请求只能访问数据库。
  • warm cache:随着用户的访问,越来越多的数据被放到缓存中,越来越多的数据被命中,这被称为暖数据。
    当我们预先将一些数据填入cache的时候,我们将其称为cache warm up .

Redis

首先,Redis基于内存运行。Redis is often used as a cache frontend for some other, slower but more permanent data store, such as an SQL database.Redis同样会有持久化策略来在down掉后去cache warm up . 在通常的程序中,基本上都会使用Cache aside的策略,即由应用程序去管理缓存和数据库间的缓存失效问题。

关于缓存驱逐策略,他使用的也是上边所讲的思路。同时,Redis 的 LRU / LFU 淘汰并不是"精确版本",而是一个"近似算法" ,目的就是在 内存占用实现复杂度 之间做取舍。

高级缓存策略与模式

Caches have lots of capabilities, features, and use cases that go beyond simply storing key-value pairs. This chapter discusses some of these more advanced aspects of caching.

缓存持久化与恢复

有两种缓存类型,一种是persistence cahce,他在crash后有恢复的能力与之相反的是volatile cache,比如本地缓存,他们一旦断电,就彻底寄掉了。Redis中既可以实现前者,又可以实现后者。我们主要来看他的持久化策略

  1. AOF -> append only file 追加写入型
  2. RDB -> redis database Point-in-time backups ,也就是快照类型的备份
  3. 二者的组合

AOF

这是一种典型的WAL的思想。通过顺序写记录操作的方式来实现较为快速的同步。因为他记录的是操作,所以他崩溃恢复后的数据和崩溃前的数据相差较少,较为精确。在崩溃恢复的时候,通过AOF来进行重建。你可以通过使用APPENDONLY yes的方式来使用log file。当然了,我们的log file 也要进行刷盘,虽然OS底层对文件内容的刷盘仍然为WAL思路。我们可以通过APPENDFSYNC对其进行选项。

  • APPENDFSYNC no:让操作系统去管理刷盘时机,这种方式的数据安全性可能较差。
  • APPENDFSYNC everysec:每秒刷一次
  • APPENDFSYNC always:立刻刷盘
    如果要追求强一致,请使用最后一种,如果允许一段时间的不一致,可以选择前两种。

那么AOF的缺点呢?对于这种线形增加的,体积膨胀是不得不考虑的,任何操作他都要记录,体积膨胀就会导致恢复时间增加。且第三种立刻刷盘,会导致OS的以batch增加失效,会导致写放大,即多次高频IO。

那么怎么解决呢。对于一些以前的操作,我们是不需要的,所以我们来用BGREWRITEAOF来清理之前不用的部分,即对AOF文件进行瘦身。这个过程具体为

  1. Redis fork子进程
  2. 子进程把当前KV转换为最简命令
  3. 子进程将临时文件写入 temp aof文件,主进程把新的操作写入buf缓冲区
  4. 子进程写入完后,主进程将buf中的内容追加到子进程中。然后替换旧的AOF
    这同样会带来问题。(如果你不懂OS,建议先去学OS的相关概念再来看下面这段话。)
    首先这里也有上文所述的大量写的问题。其次,对于fork,操作系统基本上都会使用写时复制(COW)这一技术,即在最开始并不给子开辟其对应的内存空间,而是父子共用一个相同的物理内存,只是会将父进程的页表复制给子进程。读取的时候不做处理。当某一个进程想要写入数据的时候,出现page fault来去处理,内核会将原来的物理页面复制到另一个位置,然后让这个进程的PTE去指向这个位置。这就是写 的时候 复制。那么会有什么问题?对于大量的写,会导致不断的复制页面。那么来看Redis,如果父进程大量的写入,则会导致很多的页复制,COW 复制出的匿名页把 cgroup limit 撑爆 → OOM Killer

RDB

他就像快照一样,记录当前的数据存储状态。a backup copy of the current contents in the cache。那么这种操作的效率就很高。
SAVE:创造一个dump.rdb的文件。
BGSAVE: 开一个background job来创造快照。

他的优点就是文件比较小,不用瘦身,所以重启恢复速度较快。

缺点就是恢复的时候数据丢失可能性比较高,并且他也要用fork,并且他的fork也有问题,如果数据量很大,那么take a snapshot会导致fork的过程同样耗时。

总结

当你选择强一致性和数据保障,用AOF+总是刷盘。否则用RDB

Cache 可拓展性(Scaling)

Caches are hugely important to building large, highly scalable applications. They improve application performance and reduce resource requirements, thus enabling greater overall application scalability.

本章节主要讨论,当缓存需要去扩容的时候,我们应该怎么去做。首先看我们会遇到的基本的问题。一个是storage limit , 另一个是resource limit。

对于存储限制,很明显,存储满了就必须要进行拒绝策略。

对于资源限制,也就是常见的网络限制或者说CPU限制。当有过多的请求的时候,网络带宽,或者Cpu被干满,比如说序列化和反序列化。比如说热点key,大量请求打到某一服务器redis实例,就会导致该服务不可用。

扩容方法

  • 水平扩容
  • 竖直扩容

竖直扩容

简单来说,用更强的硬件

水平扩容

使用redis集群。这样同时可以提高reliability,进而提高availability。

In other words, vertical scaling means increasing the size and computing power of a single instance or node, while horizontal scaling involves increasing the number of nodes or instances.

扩容方式

读副本:

增加新的副本,当有读取请求的时候去读取这个副本中的数据。当主有写入的时候,他会向其他的副本发送消息。从而达到一致。

![[主从.png]]

对于这种方式,他对于写入的性能没有提升,但是可以提升读取的性能。并且当主节点挂掉之后,其他的从节点可以代替成为主。

分片(sharding )

分片就是把缓存数据拆成多份,按 key 分配到不同服务器上,每台只负责一部分,从而提高性能、容量和资源利用率。但是对应的分配算法也不是很好实现。并且一个坏的分配算法可能导致性能下降。

多主

每个主都有所有的数据。每个节点即可读,又可写。写操作会立刻同步到所有的节点。负载均衡器将请求分配给所有的节点。但是如果两个主的内容不同,则会有write conflict 的发生。同时,一个节点的更新无法立刻同步到其他的节点。所以会有data lag的发生

比较

特点 读副本 分片 Active-Active
数据存放 主节点全量,副本全量 每片只存部分数据 每个节点都存全量
读写能力 主写副读 每片独立读写 所有节点均可读写
存储上限 受单节点限制 随分片数线性增加 受单节点限制(全量复制)
一致性 最终一致 分片内一致 最终一致
故障影响 主节点故障需切换 单分片故障影响部分数据 任一节点故障,其余仍可服务

缓存一致性

数据如何变得不一致?

  1. 底层数据改变而缓存中的数据未改变
  2. 缓存数据更新延迟
  3. 缓存节点之间存在的数据不一致

底层数据改变而缓存中的数据未改变

这种就很常见,比如说数据库中的内容改变,但是缓存并未改变。

缓存更新延迟

当数据库通知缓存要去更新数据的时候,这个通知也会有时间延迟。你可以通过设置一个过期时间的方式来进行一定程度上的渐少数据不一致性。但是无法消除不一致

缓存节点之间存在的数据不一致

对于多个节点之间的同步而言,如果数据存的很大,那么同步的时间会被拉长,从而导致数据不一致的时间被拉长。