本文介绍关于缓存的常用设计模式。以及如何保证缓存的一致性进行分类讨论。
还会介绍关于缓存失效的常见问题,以及针对缓存失效的解决方法。
在高并发的环境下,比如春节抢票大战,一到放票的时间节点,分分钟大量用户以及黄牛的各种抢票软件流量进入12306,这时候如果每个用户的访问都去数据库实时查询票的库存,大量读的请求涌入到数据库,瞬间Db就会被打爆,cpu直接上升100%,服务马上就要宕机或者假死。即使进行了分库分表也是无法避免的。为了减轻db的压力以及提高系统的响应速度。一般都会在数据库前面加上一层缓存,甚至可能还会有多级缓存。
想要在压力测试中提高接口的吞吐量,就不得不说到缓存这一优化方案。
缓存又分进程内缓存和分布式缓存两种:
那么那些数据适合放入缓存?
常用技巧
首先,我们得清楚“数据的一致性”具体是啥意思。其实,这里的“一致性”包含了两种情况:
参考:Microsoft Design Patterns: Cache-Aside pattern
一般我们更新缓存会使用旁路缓存(Cache Aside Pattern)的方式,按需将数据存入缓存,缓存中并不存储所有数据。具体逻辑如下:
整体流程图
伪代码
data = cache.load(id); //从缓存加载数据
if (data == null) {
data = db.load(id); //从数据库加载数据
cache.put(id, data); //保存到 cache 中
}
return data;
Java Spring代码
在Spring中可以使用框架中的缓存抽象,可使用@Cacheable
注解,如下实现,当getRecordForSearch()方法被调用的时候,如果缓存中存在对应key的数据,那就会自动的从缓存中获取(此时方法体不会被执行),当缓存中不存在key对应数据的时候,会执行方法体从数据库中查询数据并设置到缓存中去。
@Cacheable("default", key="#search.keyword")
public Record getRecordForSearch(Search search)
default 为分区名,key支持spEL表达式,普通字符串必须加单引号,为redis中的键。
这个注解默认不开启锁,使用sync可以开启锁,但是锁的实现方式是使用 synchronized
代码块实现的单机锁,在分布式下是锁不住所有节点的。
@Cacheable("default", key="#search.keyword", sync=true)
数据更新
如果数据被更新,我们还需要使用其他策略来修改缓存区的数据。流程一定都是先修改数据库中的数据,之后再来操作缓存里的数据。这里有三种常见的方式。
失效模式,让缓存失效
该情况下,当请求需要更新数据库数据的时候,缓存中的值需要被删除掉(删除掉就表示旧值不可用了),当下次该key被再次查询到就去数据库中查出最新的数据。
顺序问题:那我们应该先删除缓存,再修改数据库呢,还是应该先修改数据库,再删除缓存呢?
▶ 假如我们先删除缓存,再修改数据库。
试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放入缓存中,然后更新操作更新了数据库。于是缓存中的数据还是老数据,导致缓存中的数据是脏的,而且之后缓存中一直是脏数据。
▶ 假如我们先修改数据库,再删除缓存。
比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
但,这个情况理论上会出现,不过,实际上出现的概率可能非常低。
因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
失效模式,在Spring中可以使用@CacheEvict
注解,实现如下:
@CacheEvict("default", key="#search.keyword")
public Record updateRecordForSearch(Search search)
双写模式,让缓存更新
缓存数据也可以在数据库更新的时候被更新,从而在一次操作中让之后的查询有更快的查询体验和更好的数据一致性。
顺序问题:那我们应该先更新缓存,再修改数据库呢,还是应该先修改数据库,再更新缓存呢?
▶ 假如我们先更新缓存,再修改数据库。
写+写并发:线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致。
▶ 假如我们先修改数据库,再更新缓存。
写+写并发:与上一条类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致。
在Spring中可以使用@CachePut
注解,注意函数返回值一定要是存入缓存中的对象。实现如下:
@CachePut("default", key="#search.keyword")
public Record updateRecordForSearch(Search search)
订阅模式,订阅数据库binlog日志
canal
canal [kə'næl]
,译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。
早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务 trigger 获取增量变更。从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。
基于日志增量订阅和消费的业务包括
canal 工作原理
Flink CDC
Flink CDC也是阿里的开源技术,这篇官方样例分别使用MySQL和Postgres中的两张表,在其表数据变动后,实时通过流的方式将最新数据写入ES中。
过程中只需要用到Flink SQL,无需一行Java代码,即可实现。
方案总结
上述无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。
总结:
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。
所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
核心思想:应用需要操作数据时只与缓存组件进行交互;缓存里的数据不会过期。
Read-Through
Read-Through和Cache-Aside很相似,不同点在于程序不需要再去管理从哪去读数据(缓存还是数据库)。
相反它会直接从缓存中读数据,该场景下是缓存去决定从哪查询数据。当我们比较两者的时候这是一个优势因为它会让程序代码变得更简洁。
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
Write-Through
Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。
适用场景:读少写多
存在的问题:异步或间隔一定时间的批量回写会导致数据延迟或数据丢失的情形出现。
Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
在wikipedia上有一张write back的流程图,基本逻辑如下:
大并发读下,可能会产生以下几个缓存失效问题。
指的是我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决 原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
风险 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃。
解决 null结果缓存,并加入短暂过期时间。
解决 加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查询缓存,就会有数据,不用去db。
使用分布式锁来解决