• redis 缓存设计


    1. 前言

    学习redis 缓存,可以是为了技术面试;可以是为了应用实践,在开发设计过程中引入缓存,提高性能。比如常见的面试题:

    2. 什么是缓存预热、击穿、穿透和雪崩

    2.1 缓存预热

    缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

    解决方式:Java中可以使用 @PostConstruct 初始化白名单数据

    2.2 缓存雪崩

    2.2.1 什么是缓存雪崩

    缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。

    2.2.2 什么情况下会发生缓存雪崩

    redis 主机挂了, Redis全盘崩溃,偏硬件运维
    redis 中有大量key 同时过期大面积失效,偏软件开发

    2.2.3 如何预防和解决

    1)redis 中 key 设置为永不过期 or 过期时间错开
    2)redis 缓存集群实现高可用

    1. 主从 + 哨兵
    2. Redis 集群
    3. 开启Redis 持久化机制 aof / rdb,尽快恢复缓存集群

    3)多缓存结合预防雪崩:ehcache 本地缓存 + redis缓存
    4)服务降级:Hystrix 或者 sentinel 限流 & 降级

    2.3 缓存穿透

    2.3.1 缓存穿透是什么

    缓存穿透 就是请求去查询一条数据,先查redis,redis里面没有,再查mysql,mysql里面无,都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库压力暴增。
    在这里插入图片描述

    2.3.1 如何预防和解决

    缓存穿透的发生一般有这两种情况:

    1. 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
    2. 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

    应对缓存穿透的方案,常见的方案有三种。

    1. 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
    2. 设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
    3. 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

    2.4 缓存击穿

    我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。

    2.4.1 什么是缓存击穿

    如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
    在这里插入图片描述

    2.4.2 如何预防和解决

    可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案:

    • 互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
    • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

    2.5 小总结

    缓存主要存在的问题是 缓存雪崩、缓存击穿、缓存穿透。
    在这里插入图片描述
    针对缓存不同的触发场景,解决方案也不同。
    在这里插入图片描述

    3. 说说常见的缓存更新策略?

    常见的缓存更新策略共有3种:

    1. Cache Aside(旁路缓存)策略;
    2. Read/Write Through(读穿 / 写穿)策略;
    3. Write Back(写回)策略;

    实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。

    3.1 Cache Aside(旁路缓存)策略

    Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。
    在这里插入图片描述
    写策略的步骤:
    1)先更新数据库中的数据,再删除缓存中的数据。

    读策略的步骤:
    1)如果读取的数据命中了缓存,则直接返回数据;
    2)如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

    注意,写策略的步骤的顺序不能倒过来,即不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。

    举个例子,假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
    在这里插入图片描述
    最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。

    为什么「先更新数据库再删除缓存」不会有数据不一致的问题?

    继续用「读 + 写」请求的并发的场景来分析。

    假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
    在这里插入图片描述
    最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。 从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高

    因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

    Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

    1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
    2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

    3.2 Read/Write Through(读穿 / 写穿)策略

    Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。

    1)Read Through 策略
    先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

    2)Write Through 策略
    当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:

    1. 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
    2. 如果缓存中数据不存在,直接更新数据库,然后返回;

    下面是 Read Through/Write Through 策略的示意图:
    在这里插入图片描述
    Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。

    3.3 Write Back(写回)策略

    Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

    实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。

    Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。

    Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。

    但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。

    这里贴一张 CPU 缓存与内存使用 Write Back 策略的流程图:
    在这里插入图片描述

    4. 如何保证缓存和数据库数据的一致性?

    4.1 先更新数据库,还是先更新缓存?

    在这里插入图片描述
    由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题:

    1. 先更新数据库,再更新缓存;
    2. 先更新缓存,再更新数据库;

    下面详细分析

    1)先更新数据库,再更新缓存
    举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
    在这里插入图片描述
    A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。

    此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象

    2)先更新缓存,再更新数据库

    那换成「先更新缓存,再更新数据库」这个方案,还会有问题吗?

    依然还是存在并发的问题,分析思路也是一样。

    假设「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
    在这里插入图片描述
    A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了, 将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。

    此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。

    所以,无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。

    4.2 先更新数据库,还是先删除缓存?

    写策略的步骤:

    1. 更新数据库中的数据;
    2. 删除缓存中的数据。

    读策略的步骤:

    1. 如果读取的数据命中了缓存,则直接返回数据;
    2. 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

    「写策略」的时候,到底该选择哪种顺序呢?

    1. 先删除缓存,再更新数据库;
    2. 先更新数据库,再删除缓存。

    1)先删除缓存,再更新数据库
    假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
    在这里插入图片描述
    最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。

    可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题

    2)先更新数据库,再删除缓存
    继续用「读 + 写」请求的并发的场景来分析。

    假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
    在这里插入图片描述
    最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。

    从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高

    因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

    而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

    所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。

    为了确保万无一失,还给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。

    4.3 小总结

    「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。

    所以,如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况。

    但是这个方案前面我们也分析过,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。

    所以我们得增加一些手段来解决这个问题,这里提供两种做法:

    1. 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
    2. 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。

    对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。

    延迟双删实现的伪代码如下:

    #删除缓存
    redis.delKey(X)
    #更新数据库
    db.update(X)
    #睡眠
    Thread.sleep(N)
    #再删除缓存
    redis.delKey(X)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。

    所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。

    但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

    因此,还是比较建议用「先更新数据库,再删除缓存」的方案。

    5. 面试题及解析

    5.1 为什么是删除缓存,而不是更新缓存呢?

    删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。

    比如商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。 从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。

    系统设计中有一个思想叫 Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是更新缓存,就是懒加载思想的一个应用。

    5.2 如何保证两个操作都能执行成功?

    背景描述

    为了提升数据访问的性能,引入 Redis 作为 MySQL 缓存层,但是这件事情并不是那么简单,因为还要考虑 Redis 和 MySQL 双写一致性的问题。

    经过一番周折,最终选用了「先更新数据库,再删缓存」的策略,原因是这个策略即使在并发读写时,也能最大程度保证数据一致性。

    为了避免数据更新不及时,还搞了个兜底的方案,就是给缓存加上了过期时间。

    本以为就这样不会在出现数据一致性的问题,结果将功能上线后,老板还是收到用户的投诉「说自己明明更新了数据,但是数据要过一段时间才生效」,客户接受不了。

    「先更新数据库, 再删除缓存」其实是两个操作,这次客户投诉的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,而数据库是最新值。

    好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。

    所以新的问题来了,如何保证「先更新数据库 ,再删除缓存」这两个操作能执行成功?

    问题解析

    这次用户的投诉是因为在删除缓存(第二个操作)的时候失败了,导致缓存还是旧值,而数据库是最新值,造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。

    举个例子,来说明下。

    应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。
    在这里插入图片描述
    那么,后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。

    其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。

    问题原因知道了,该怎么解决呢?有两种方法:

    1. 重试机制。
    2. 订阅 MySQL binlog,再操作缓存。

    1)重试机制

    我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

    1. 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
    2. 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

    举个例子,来说明重试机制的过程。
    在这里插入图片描述
    2)订阅 MySQL binlog,再操作缓存

    「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

    于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

    Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

    下图是 Canal 的工作原理:
    在这里插入图片描述
    所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存

    5.3 如何设计一个缓存策略,可以动态缓存热点数据呢?

    由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。

    热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。

    以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:

    1. 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;
    2. 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;
    3. 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。

    在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

  • 相关阅读:
    二叉树链式结构的实现及简单操作(画图超详细解释)
    Python学习笔记(22)-Python框架22-PyQt框架使用(常用控件)
    网络层(IP数据报格式、ARP协议)
    go-cli
    个人交易者如何开始使用股票量化策略接口?
    Docker开启远程访问+idea配置docker+dockerfile发布java项目
    【大数据 - Doris 实践】数据表的基本使用(三):数据模型
    cocos:ccpDistance函数的简单运用以及实现眼球随着手指在眼眶中转动的功能
    越细粒度的锁越好吗?产生死锁怎么办?
    Go中IO包简单使用
  • 原文地址:https://blog.csdn.net/qq_41893274/article/details/133253697