一般在数据库查询压力比较大,高并发要求比较高,读写速度要求比较高的场景下,我们会引入redis缓存。因为redis缓存本身基于内存进行操作,相对于基于磁盘的数据库来说,在速度并发上面有一个数量级以上的差距。
但是redis缓存的引入同样会增加系统复杂性以及带来一系列问题,比如:
- 缓存和数据库数据一致性问题
- 缓存失效问题,伴生的缓存预热,缓存击穿,缓存雪崩等问题
- 缓存高可用问题,避免redis缓存系统失效导致整个系统不可用
- …
本文会基于这些问题展开分析这些问题出现的原因以及解决方案。
缓存和数据库一致性问题
只有数据库存在并发写时,以及系统严格要求缓存和数据库数据保持一致时才会考虑这个问题。其出现的原因是数据库和缓存的更新操作是两个动作,不是原子性的,所以在并发场景下会出现数据一致性问题。
根据数据库和缓存两个系统的读写策略,主要分下面三种,可以一定程度上保证数据一致性:
- Cache Aside Pattern(旁路缓存模式)
- Read/Write Through Pattern(读写穿透)
- Write Behind Pattern(异步缓存写入)
Cache Aside Pattern(旁路缓存模式)
Cache Aside Pattern是使用比较多的读写策略,适用于读请求较多的场景。该策略要求数据以DB中的结果为基准,并要求业务自行维护DB和Cache数据的读写策略,该策略定义的读写规则如下:
- 写数据时
- 先更新DB
- 然后直接删除cache
- 读数据时
- 从 cache 中读取数据,读取到就直接返回
- cache中读取不到的话,就从 DB 中读取数据返回
- 再把数据放到 cache 中
上面的规则并不总是保证DB和cache数据一致性,比如:
- request1: get a=6 from db -> stop -> update a=6 to cache
- request2: update a=10 to db -> delete cache
- request3: get from cache: a=6, but db: a=10
请求1先获取数据,但是在请求2更新数据并删除cache以后才更新cache,此时cache中的值和数据库不一致,是旧的值。这种case出现的概率非常低,因为更新cache的操作是很快的,但这同时也要求从DB中取出来后立即更新cache,如果实际业务中取出数据后还进行一番操作后才更新cache则会容易出现该case。
在复杂的应用场景中,可能会出现多次查询数据库,然后将多次查询结果进行某些计算后再将计算结果更新到缓存,举例如下:
- request1: get a=6 from db -> get b=7 from db -> get c=8 from db -> update sum=a+b+c=21 to cache
- request2: update a=10 to db -> delete cache
这种场景下因为从db取数据到更新到cache的流程比较长,就容易出现数据不一致问题。
这种case可以考虑给取a,b,c的时候加上事务,在a,b,c同时读取完成以后再更新db,这样可以一定程度避免数据不一致问题的出现。另外也可以拆分缓存,将a,b,c的值分别缓存,不过这会极大增加复杂性。
考虑先删除cache,再更新DB:这种策略会极大增大数据不一致性的可能性,因为更新db的操作比较慢,很可能出现request1删除了cache,此时request2发现cache失效然后从数据库读取数据后更新cache,此时request1还未更新数据,这样导致cache中的数据是旧数据。
Cache Aside Pattern 的缺陷以及解决办法:
- 冷启动问题(首次请求数据一定不在 cache),解决办法:可以将热点数据可以提前放入cache 中
- 频繁的写操作会导致cache中的数据频繁被删除,影响缓存命中率,解决办法:更新DB同时更新cache
Read/Write Through Pattern(读写穿透)
Read/Write Through Pattern在实际应用中并不常见,因为它要求cache系统提供将数据同步到DB的功能。这种策略将cache视为主要数据存储,从中读取数据并将数据写入其中,而由cache系统来负责将数据读取和写入 DB。
- 写数据时
- 先查 cache,cache 中不存在,直接更新 DB
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)
- 读数据时
- 从 cache 中读取数据,读取到就直接返回
- 读取不到的话,先从 DB 加载,写入到 cache 后返回响应
该策略实际只是在 Cache-Aside Pattern 之上进行了封装。只是将客户端对cache的写入操作移动到了cache系统。所以同样有冷启动问题,收起请求数据一定不在cache中,同样的可以提前预热来解决,将热点数据提取放入cache中。
Write Behind Pattern(异步缓存写入)
Write Behind Pattern 和 Read/Write Through Pattern 一样都是由 cache 服务来负责 cache 和 DB 的读写。但是 Write Behind Pattern 跟进一步,直接向客户端屏蔽掉DB的实现细节,读取和更新都只操作cache,由cache系统异步批量的方式来更新 DB。
这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。
Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制也会用到了这种策略。
缓存失效问题
如果缓存中没有数据,那么就会退化为直接查询数据库,在首次请求时、缓存中数据过期或失效时、缓存无效时,都会导致缓存系统失效,如果请求量很大就容易造成数据库压力过大甚至崩溃,进而引起一系列问题。
缓存预热
首次请求时,比如在游戏或者系统刚上线时,缓存中都没有数据,就会导致大量请求都去查询数据库,会导致数据库压力激增甚至宕机,此时就需要缓存预热,即提前将热点数据加载到缓存系统中。可以有如下预热方案:
- 系统启动时,将热点数据加载到缓存,该方案会导致启动变慢
- 使用脚本手动将数据预热到缓存
- 使用定时任务刷新缓存
缓存穿透问题
如果用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致每次用户查询时,在缓存中找不到,都要去数据库再查询一遍,然后返回空,这就是缓存穿透问题,同时也是缓存命中率问题。
解决方法:如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。
空结果也进行缓存,下次相同的请求可以直接返回空结果,从而避免当查询的值为空时引起的缓存穿透。同时也可以单独设置个缓存区域存储空值,对要查询的key进行预先校验,然后再放行给后面的正常缓存处理逻辑。
缓存雪崩问题
原有缓存失效(过期),新缓存未到期间。所有请求都去查询数据库,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩问题。
在并发量不大的情况下,可以考虑加锁排队缓解数据库压力,这样会造成阻塞,限制系统吞吐量。
另外就是合理设置缓存的失效时间,避免出现大量热点数据在同一时间段内失效的场景。或者使用缓存失效的标记,在失效时更新缓存。
redis系统的高可用
引入redis缓存系统,就要注意redis缓存系统失效时的处理方式,即要保证redis的高可用,在某台机器伤的redis挂掉或者机器坏掉时还可以使用。
redis本身支持主从架构以及集群架构,在出现单点系统故障时仍然保证可用。
主从(master-slave)架构中,一主多从,主负责写,并且将数据复制到其它的 slave 节点,从节点负责读。所有的读请求全部走从节点。一个 slave 挂掉了,是不会影响可用性的,还有其它的 slave 在提供相同数据下的相同的对外的查询服务。master node 在故障时,自动检测,并且将某个 slave node 自动切换为 master node 的过程,叫做主备切换。这个过程,实现了 Redis 的主从架构下的高可用。