Redis 缓存
为什么需要缓存?
一个系统中的不同层之间的访问速度不一样,把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度。
计算机系统中,默认有两种缓存:
CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存
取数据。
Redis为什么适合做缓存?
1、速度要够快
2、因为缓存是有一定的容量限制的,所以要有缓存数据的淘汰策略
Redis 缓存处理请求的两种情况
1、缓存命中:Redis 中有相应数据,就直接读取 Redis,性能非常快。
2、缓存缺失:Redis 中没有保存相应数据,要从后端数据库中读取数据,性能就会变慢。
而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,我们需要把缺失的
数据写入 Redis,这个过程叫作缓存更新,能对后续的查询起加速效果。
所以使用Redis做缓存的时候,一般请求有以下几个操作:
1、先读取Redis
2、Redis中没有则读取数据库
3、将从数据库中读取到的值写入缓存
其中,要实现缓存中读取不到就到数据库中读取并写入缓存,
需要在代码中写好。
Redis缓存的类型
只读缓存
当Redis用作只读缓存时:
1、应用要读取数据,首先会到缓存中查询数据是否存在,存在则返回,不存在则会触发缓存缺失。
2、应用要写入数据,会直接发往后端数据库,由数据库进行增删改(对于删改数据,如果缓存中已有,那么会修改数据库后删除掉缓存中的数据(把缓存中的数据标记为无效,这过程是快速的),在下一次读取此数据时会触发缓存缺失)。
好处:所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险。
适用于读多写少的场景。
需要注意的是:在增删改数据库时,会删除掉缓存中的数据
读写缓存
当Redis用作读写缓存时:
1、应用要读取数据,直接到缓存中查询,存在则返回,不存在则触发缓存缺失
2、应用要写入数据,也是直接到缓存中进行操作,所以缓存中的数据是最新的
好处:
得益于Redis的高性能特性,数据的增删改操作可以在缓存中快速完成,处理结果也会快速
返回给业务应用,这就可以提升业务应用的响应速度。
坏处:
Redis是内存数据库,而最新的数据都在内存中的话,一旦发生断电或者宕机等问题,内存的数据会丢失
影响业务。
根据业务应用对数据可靠性和缓存性能的不同要求,对此Redis提供两种写入策略
1、同步直写
2、异步写回
两种写入策略
1、同步直写
写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回。
这样,即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。
因为要等数据库也写完才返回给客户端,所以会增加响应延迟
2、异步写回
所有写请求都先在缓存中处理。等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库。
优先考虑了响应延迟,但由于要等被淘汰的时候才写入数据库,遇到断电或宕机等情况还是会丢失数据
Redis 只读缓存和使用直写策略的读写缓存的区别:
表面看,都是会直接写入数据库,区别在于:
只读缓存:在有删改操作时,在更新数据库时,将缓存中的数据标记为无效。如果有请求在
更新完数据库之后进来,不会有数据一致性问题,会触发缓存缺失。
直写策略:在缓存中修改,不删除,同时同步数据库。不会出现缓存缺失,但容易出现缓存一致性问题。
以上两个如果请求在数据库更新之前进来都会存在一致性问题
对于直写策略,可以在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性
两者是在一致性和性能之间做出不同的选择
Redis数据淘汰替换
缓存大小设置
大容量缓存是能带来性能加速的收益,但是成本也会更高,而小容量缓存不一定就起不到加速访问的效
果。一般来说,建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。
但内存大小有限,随着要缓存的数据量越来越大,有限的缓存空间不可避免地会被写满。此时,解决这个问题就涉及到缓存系统的一个重要机制,即缓存数据的淘汰机制。
简单来说,数据淘汰机制包括两步:
第一,根据一定的策略,筛选出对应用访问来说“不重要”的数据;
第二,将这些数据从缓存中删除,为新来的数据腾出空间。
缓存被写满是不可避免的。最终都是要面对缓存写满时的替换操作。
缓存替换需要解决两个问题:决定淘汰哪些数据,如何处理那些被淘汰的数据。
Redis缓存淘汰策略
Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略。
是否进行数据淘汰分类:
不淘汰数据:noeviction
淘汰数据:
在设置了过期时间的数据中进行淘汰,
包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis4.0 后新增)四种。
在所有数据范围内进行淘汰,
包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis4.0 后新增)三种。
noeviction 策略
Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,一旦缓存被写满了,再有写请求来时,
Redis 不再提供服务,而是直接返回错误。
volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu策略
淘汰已经设置了过期时间的键值对。即使缓存没有写满,这些数据如果过期了,也会被删除。
无论是这些键值对的过期时间是快到了,还是 Redis 的内存使用量达到了 maxmemory 阈值,都会进行删除
volatile-ttl
按过期时间先后删除
volatile-random
随机删除已经过期的键值对
volatile-lru
使用LRU 算法筛选设置了过期时间的键值对
volatile-lfu
使用LFU 算法筛选设置了过期时间的键值对
allkeys-lru、allkeys-random、allkeys-lfu策略
使用此三种策略,无论是否设置了过期时间,只要被淘汰策略选中了,都会被删除
当然,过期时间到了也会被删除
allkeys-random
从所有键值对中随机选择并删除数据
allkeys-lru
使用 LRU 算法在所有数据中进行筛选
allkeys-lfu
使用 LFU 算法在所有数据中进行筛选
LRU 算法
LRU 算法的全称是 Least Recently Used,是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出
来,而最近频繁使用的数据会留在缓存中。
LRU算法的实现是将数据维护成一个链表,最近访问的数据会在MRU端,不常访问的数据在LRU端。淘汰数据的时候删除LRU段的数据即可。
但这种做法会有个问题就是在数据的访问时数据的位置会发生变化,所以还要对数据进行移动,会造成额外的开销。
为了优化数据移动造成额外的开销问题
Redis默认会记录每一个数据最新一次访问的时间戳,Redis 在决定淘汰的数据时,第一次会随机选出N 个数据
(配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数N),把它们作为一个候选集合。
接下来,Redis 会比较这 N 个数据的时间戳将最小的淘汰掉。
当需要再次淘汰数据时,Redis 会尝试将数据放入第一次创建的候选集中,能放进候选集的条件是时间戳必须
比候选集中记录的最小的时间戳要小,加入的数据个数达到配置的个数N时,就将候选集合中记录的时间戳
最小的数据淘汰。
如何处理要被淘汰的数据
对缓存数据来说,里面的数据可能是干净数据;也可能是被修改过的脏数据,对于被修改过的数据,要及时得将其写回数据库中,才能保证数据的一致性。
但对于 Redis 来说,一旦决定了被淘汰的数据后,即使淘汰的数据是脏数据,Redis 也不会把它们写回数据库。所以,在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被 Redis 删除,而数据库里也没有最新的数据了。
缓存和数据库的数据一致性
数据的一致性指的是:
缓存中有数据且和数据库中的一致;
缓存中没数据,数据库的值是新值;
数据不一致的情况发生在数据库要更新的场景,前面说Redis的缓存有两种模式:只读缓存和读写缓存。
在读写缓存中,如果是同步直写策略,不同时更新缓存和数据库就会造成数据不一致问题。
所以在业务应用中要使用事务机制,来保证缓存和数据库的更新具有原子性。并在出现错误时使用重试机制,
实现同步直写。
而异步写入本身就对数据的一致性要求不高,此策略是对不同场景的延伸
对于只读缓存来说,缓存不存在更新的情况,写操作进入后都是直接操作数据库。
如果是新增数据,那么再次查询会触发缓存缺失,不会有数据不一致问题。
在有数据的删改操作,就要删除缓存中的数据,这期间就有一个问题,缓存时先删除还是后删除?若是缓存删除失败呢?
先删除缓存再更新数据库
如果数据库更新不及时或者更新失败,在并发的情况下,另一个请求进来,发现缓存中没有数据,会触发缓存
缺失,此时数据库没能更新数据,最后更新缓存中的还是旧的值,别的线程读取到的也会是旧值,
数据和本应更新之后的数据出现不一致
先更新数据库后删除缓存
如果数据库更新之后,缓存删除失败或者删除不及时,在并发的情况下,另一个请求进来,发现缓存中有数据,
则缓存命中,返回数据,那么缓存数据就和数据库数据出现不一致问题
所以,无论谁先谁后,只要有一个操作失败或者在并发的情况下指令先后情况下,都会导致客户端读到旧值
先删除缓存再更新数据库优化
这情况主要是在数据库还没更新完的时候,并发情况下另一个线程就读取了数据并写入了缓存,所以可以做延迟双删操作
延时双删指的是:在更新数据库前删一次,在更新数据库之后又删一次。
但期间更新的线程要进行一段时间的休眠,因为如果线程更新完成后,另一个线程虽然读取了数据库,
但还没写入缓存,那么删除会出错。所以这段休眠时间其实就是另一个线程读取数据并写入缓存的时间。
之后更新的线程再一次删除缓存,就将写入的旧值缓存删掉,使之后的请求触发缓存缺失,从而拿到新值。
但对于一开始的那个线程来说,读到的数据依旧是旧的值
延时双删主要针对的是在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,读到旧值的情况。
删除失败——重试机制
把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka 消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
如果能够成功地删除或更新,就把这些值从消息队列中去除,以免重复操作,此时,也可以保证数据库和缓存的数据一致了。否则的话,还需要再次进行重试。如果重试超过的一定次数,还是没有成功,就需要向业务层发送报错信息了。
对于先删除缓存再更新数据库来说,删除缓存失败会导致读到旧值;更新数据库失败删除缓存成功也会读到旧值
对于先更新数据再删除缓存来说,更新数据库失败则缓存不能更新,会读到旧值;删除缓存失败也会读到旧值。
消息队列做到的重试机制只是保证数据的最终一致性,如果在消息执行期间失败的话,并发的读操作也是会
造成一定的数据不一致问题。
操作顺序 | 是否并发 | 潜在问题 | 现象 | 应对方案 |
---|---|---|---|---|
先删除缓存再更新数据库 | 否 | 缓存删除成功,数据库更新失败 | 缓存缺失也读到旧数据 | 重试数据库更新 |
先删除缓存再更新数据库 | 是 | 缓存删除成功,数据库还没更新,并发读数据 | 触发缓存缺失,缓存写入旧值,影响后续读请求 | 延迟双删 |
先更新数据库后删除缓存 | 否 | 数据库更新成功,缓存删除失败 | 读请求在缓存中读到旧的数据 | 重试缓存删除 |
先更新数据库后删除缓存 | 是 | 数据更新成功后,还没删除缓存,并发读数据 | 并发读到缓存中的旧数据 | 等缓存删除完成,期间会有数据不一致问题 |
业务中优先使用先更新数据库再删除缓存的方法,原因主要有两个:
先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
如果业务层要求必须读取一致的数据,就需要在更新数据库时,先在 Redis 缓存客户端暂存并发读请
求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。
只读缓存如果在删改操作时,如果不是删除缓存值,而是直接更新缓存的值会有什么问题?
依旧照例分析缓存更新失败/数据库更新失败等问题。
不过这里需要注意的是,如果数据库更新失败,且没有使用重试机制的话,只读缓存是不会将脏数据写回数据库的
所以,之后会对业务造成影响
问题:只读缓存的更新删除操作为什么不能想读写缓存那样使用事务进行保持原子性呢?
这里我也思考后也觉得奇怪,毕竟只读缓存会做删除缓存操作,读写缓存也会,那么同样的事务也是可以用的
所以,我认为这里的解释优点问题,或者我对书里的理解有问题。
在业务中使用事务操作,可以简单理解为@Transactional,个人认为都是可以用在只读缓存或者读写缓存中的
以上的重试机制也是可以用在读写缓存中,至于细节如何,只要将删除缓存改为更新缓存即可
ps:以上只是读写并发的情况,如果有并发写的情况下,则可以增加分布式锁保证更新顺序的一致性。
缓存异常
补充一下缓存穿透时用的布隆过滤器
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作
当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对
应的 N 个位置。查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
缓存污染
缓存污染指的是:有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。
缓存污染的解决方案
把不会再被访问的数据筛选出来并淘汰掉。这不用等到缓存被写满以后,再逐一淘汰旧数据之后,
才能写入新数据了。而哪些数据能留存在缓存中,是由缓存的淘汰策略决定的。
哪些策略可以解决缓存污染
noeviction
不进行数据淘汰,不行
volatile-random 和 allkeys-random
随机淘汰,淘汰的数据可能是会被再次访问的,不行
volatile-ttl
按过期时间淘汰,如果知道某些数据会在多少时间内会被再次访问,那么设置合理的过期时间,可以解决。
如果不知道的话,那也不行
LRU 缓存策略
LRU策略认为,一个数据被访问过,那么肯定还会被访问。
但如果只看访问时间,并不能确定是否还会被再次访问,有些数据也可能是只使用一次的
LFU 缓存策略
之前并没有记录LFU策略。
LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。
当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。
如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据
淘汰出缓存。
具体实现
Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段(24bit),用来记录数据的访问时间戳;
RedisObject我没有记录,怕引申,所以在上面我也没说,只是说记录时间戳
LFU策略其实就是将记录时间戳的lru字段分成两份
lru 字段的前 16bit,表示数据的访问时间戳;lru 字段的后 8bit,表示数据的访问次数。
鉴于8bit 记录的最大值是255。一旦到了255就不能增加了,所以可能会出现次数都是255,但时间戳比较旧但实际访问次数比较多的数据被淘汰掉的情况。LFU实现新的计数规则:
LFU 策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的
值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和
一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。