目录
[一、Cache Aside(旁路缓存)策略](#一、Cache Aside(旁路缓存)策略)
前言
缓存,是互联网分层架构中,非常重要的一个部分,通常用它来降低数据库压力,提升系统整体性能,缩短访问时间。有架构师说"缓存是万金油,哪里有问题,加个缓存,就能优化",缓存的滥用,可能会导致一些错误用法。
缓存,你真的用对了么?
一、 Cache Aside (旁路缓存)策略
旁路缓存策略是最常用的一种缓存读写策略,它适用于读请求比较多,数据更新频率不高的场景。它的基本思想是:应用程序直接访问缓存和数据库,而不通过中间层。当需要读取数据时,先从缓存中查找,如果命中则直接返回;如果未命中,则从数据库中查询,并将结果放入缓存中,然后返回。当需要更新数据时,先更新数据库,然后删除缓存。
Cache Aside 策略(也叫旁路缓存策略),这 个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策 略。
其中读策略的步骤是:
- 从缓存中读取数据,如果缓存命中,则直接返回数据;
- 如果缓存不命中,则从数据库中查询数据;
- 查询到数据后,将数据写入到缓存中,并且返回给用户。
写策略的步骤是:
- 更新数据库中的记录;
- 删除缓存记录。
你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读 取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。
那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存和数据库数据不一致。
不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。
Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时,会出现因为主从延迟所以读不到用户信息的情况。而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。
**二、**不一致解决场景及解决方案
发生写请求后(不管是先操作DB,还是先淘汰Cache),在主从数据库同步完成之前,如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。此时怎么办呢?
一、数据库主从不一致
无缓存时,数据库主从不一致问题
如上图,发生的场景是,写后立刻读:
(1)主库一个写请求(主从没同步完成)
(2)从库接着一个读请求,读到了旧数据
(3)最后,主从同步完成
导致的结果是:主动同步完成之前,会读取到旧数据。可以看到,主从不一致的影响时间很短,在主从同步完成后,就会读到新数据。
二、缓存与数据库不一致
再看,引入缓存后,缓存和数据库不一致问题。
如上图,发生的场景也是,写后立刻读
导致的结果是:旧数据放入缓存,即使主从同步完成,后续仍然会从缓存一直读取到旧数据。
可以看到,加入缓存后,导致的不一致影响时间会很长,并且最终也不会达到一致。
三、问题分析
可以看到,这里提到的缓存与数据库数据不一致,根本上是由数据库主从不一致引起的。当主库上发生写操作之后,从库binlog同步的时间间隔内,读请求,可能导致有旧数据入缓存。
思路:那能不能写操作记录下来,在主从时延的时间段内,读取修改过的数据的话,强制读主,并且更新缓存,这样子缓存内的数据就是最新。在主从时延过后,这部分数据继续读从库,从而继续利用从库提高读取能力。
选择性读主
可以利用一个缓存记录必须读主的数据。
如上图,当写请求发生时:
(1)写主库
(2)将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为"主从同步时延"
PS:key的格式为"db:table:PK",假设主从延时为1s,这个key的cache超时时间也为1s。
如上图,当读请求发生时:
这是要读哪个库,哪个表,哪个主键的数据呢,也将这三个信息拼装一个key,到cache里去查询,如果,
(1)cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询。并且把主库的数据set到缓存中,防止下一次cahce miss。
(2)cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询
以此,保证读到的一定不是不一致的脏数据。
PS:如果系统可以接收短时间的不一致,建议定时更新缓存就可以了。避免系统过于复杂。
三、缓存误用
一、多服务共用缓存实例
如上图:服务A和服务B共用一个缓存实例(不是通过这个缓存实例交互数据)
该方案存在的问题是:
1、可能导致key冲突,彼此冲掉对方的数据
可能需要服务A和服务B提前约定好了key,以确保不冲突,常见的约定方式是使用namespace:key的方式来做key。
2、不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去
3、共用一个实例,会导致服务之间的耦合,与微服务架构的"数据库,缓存私有"的设计原则是相悖的
正确的部署方式是
如上图:各个服务私有化自己的数据存储,对上游屏蔽底层的复杂性。
二、调用方缓存数据
如上图,服务提供方缓存,向调用方屏蔽数据获取的复杂性。服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(这个有问题)
该方案存在的问题是:
1、调用方需要关注数据获取的复杂性(耦合问题)
2、更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致(带入一致性问题)
3、有人说,服务可以通过MQ通知调用方淘汰数据,额,难道下游的服务要依赖上游的调用方,分层架构设计不是这么玩的(反向依赖问题)
三、缓存作为服务与服务之间传递数据的媒介
如上图:服务A和服务B约定好key和value,通过缓存传递数据服务A将数据写入缓存,服务B从缓存读取数据,达到两个服务通信的目的
多个服务关联同一个缓存实例,会导致服务耦合
(1)大家要彼此协同约定key的格式,ip地址等,耦合
(2)约定好同一个key,可能会产生数据覆盖,导致数据不一致
(3)不同服务业务模式,数据量,并发量不一样,会因为一个cache相互影响,例如service-A数据量大,占用了cache的绝大部分内存,会导致service-B的热数据全部被挤出cache,导致cache失效;又例如service-A并发量高,占用了cache的绝大部分连接,会导致service-B拿不到cache的连接,从而服务异常
四、使用缓存未考虑雪崩
常规的缓存玩法,如上图:
服务先读缓存,缓存命中则返回;缓存不命中,再读数据库
什么时候会产生雪崩?
如果缓存挂掉,所有的请求会压到数据库,如果未提前做容量预估,可能会把数据库压垮(在缓存恢复之前,数据库可能一直都起不来),导致系统整体不可服务。
如何应对潜在的雪崩?
提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。
否则,就要进一步设计。
常见方案一:高可用缓存
如上图:使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。
常见方案二:缓存水平切分
如上图:使用缓存水平切分(推荐使用一致性哈希算法进行切分),一个缓存实例挂掉后,不至于所有的流量都压到数据库上。
总结
1、服务与服务之间不要通过缓存传递数据
2、如果缓存挂掉,可能导致雪崩,此时要做高可用缓存,或者水平切分
3、调用方不宜再单独使用缓存存储服务底层的数据,容易出现数据不一致,以及反向依赖
4、不同服务,缓存实例要做垂直拆分。