分布式缓存
1. 缓存的应用场景
- 高频访问的数据:限于磁盘 I/O 的瓶颈,对于高频访问的数据,需要缓存起来提高性能,降低下游数据库的压力冲击;
- 复杂运算的结果:对于需要耗费 CPU、经过复杂运算才能获得的结果,需要缓存来,做到“一劳长时间逸”,如 count(id)统计论坛在线人数;
- 读多写少:每次读都需要 select 甚至 join 很多表,数据库压力大,由于写得少,容易做到数据的一致性,非常适合缓存的应用;
- 一致性要求低:由于缓存的数据来源于数据库,在高并发时数据不一致性就比较凸显,不一致的问题可以解决但代价不菲;
2. 缓存读写策略
2.1 旁路缓存模式
(1)介绍
Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。
写:
- 先更新 db
- 然后直接删除 cache
读:
- 从 cache 中读取数据,读取到就直接返回
- cache 中读取不到的话,就从 db 中读取数据返回
- 再把数据放到 cache 中
(2)常见问题
- 问题一:在写数据的过程中,可以先删除 cache ,后更新 db 么?
这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。这个过程可以简单描述为:请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新 - 问题二:在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?
理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。这个过程可以简单描述为:请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
(3)缺陷
缺陷一:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据可以提前放入 cache 中。缺陷二:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率
解决办法:- 数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题
- 可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小
2.2 读写穿透模式
(1)介绍
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 db 的功能。
写(Write Through):
- 先查 cache,cache 中不存在,直接更新 db
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)
读(Read Through):
- 从 cache 中读取数据,读取到就直接返回
- 读取不到的话,先从 db 加载,写入到 cache 后返回响应
(2)缺陷
- 缺陷一:首次请求数据一定不在 cache 的问题
解决办法:同旁路缓存模式。
2.3 异步缓存写入模式
(1)介绍
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
3. 缓存常见问题
3.1 缓存雪崩 Cache Avalanche
缓存雪崩是指当大量缓存同时过期或缓存服务宕机,所有请求的都直接访问数据库,造成数据库高负载,影响性能,甚至数据库宕机,它和缓存击穿的区别在于失效 key 的数量。
针对 Redis 服务不可用的情况:
- 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
- 限流,避免同时处理大量的请求。
针对热点缓存失效的情况:
- 设置不同的失效时间比如随机设置缓存的失效时间。
- 缓存永不失效(不太推荐,实用性太差)。
- 设置二级缓存。
缓存雪崩和缓存击穿有什么区别?
缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。
3.2 缓存穿透 Cache Penetration
缓存穿透是指数据库中没有符合条件的数据,缓存服务器中也就没有缓存数据,导致业务系统每次都绕过缓存服务器查询下游的数据库,缓存服务器完全失去了其应用的作用。如果黑客试图发起针对该 key 的大量访问攻击,数据库将不堪重负,最终可能导致崩溃宕机。
解决措施:
(1)参数校验
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。
(2)缓存无效key
如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下:SET key value EX 10086
。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
另外,这里多说一嘴,一般情况下我们是这样设计 key 的:表名:列名:主键名:主键值
。
(3)布隆过滤器(Bloom Filter)
需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
3.3 缓存击穿 Cache Breakdown
缓存击穿是指当某一 key 的缓存过期时大并发量的请求同时访问此 key,瞬间击穿缓存服务器直接访问数据库,让数据库处于负载的情况,缓存击穿一般发生在高并发的互联网应用场景。
解决措施:
(1)设置热点数据永不过期或者过期时间比较长。
(2)针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
(3)请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。
缓存穿透和缓存击穿有什么区别?
- 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。
- 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。
3.4 缓存一致性
以旁路缓存模式来说,需要增加 cache 更新重试机制(常用) :如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。
参考文档
(1)https://javaguide.cn/database/redis/3-commonly-used-cache-read-and-write-strategies.html