使用缓存来加速应用程序的访问速度,是几乎所有高性能系统都会采用的方法。
但缓存真的那么好吗?架构师在构建高性能系统时,是不是必须增加缓存组件?缓存是不是多多益善?
《一代宗师》里本山大叔说过这样的一段话:
“一门里,有人当面子,就得有人当里子。面子不能沾一点儿灰尘。流了血,里子得收着,收不住,漏到了面子上,就是毁派灭门的大事。”
如果缓存是里面的角色,那么绝对是面子。
虽然缓存带来了性能的提升,但给系统带来的多少复杂性。缓存穿透,缓存击穿,缓存雪崩,缓存污染这些问题不绝于耳。这些问题不是操作系统兜着,就是程序员。所以缓存是24K纯金的面子。人人都好面子,还得有个能轻松应对的里子。
历史悠久
看现在软件世界里面,尤其互联网系统,处处都有缓存。但其实缓存的历史很久远。在硬件时代就已经出现了。
且看存储金字塔结构:
受限于存储介质的存取速率和成本,现代计算机的存储结构呈现为金字塔型。越往塔顶,存取效率越高、但成本也越高,所以容量也就越小。得益于程序访问的局部性原理,这种节省成本的做法也能取得不俗的运行效率。从存储器的层次结构以及计算机对数据的处理方式来看,上层一般作为下层的Cache层来使用(广义上的Cache)。比如寄存器缓存CPU Cache的数据,CPU Cache L1~L3层视具体实现彼此缓存或直接缓存内存的数据,而内存往往缓存来自本地磁盘的数据。
在Linux的IO体系里面也有它的身影:
如图中的PageCache,应用程序在写文件的时候,操作系统会先把数据写入到 PageCache 中,数据在成功写到 PageCache 之后,对于用户代码来说,写入就结束了。
然后,操作系统再异步地把数据更新到磁盘的文件中。应用程序在读文件的时候,操作系统也是先尝试从 PageCache 中寻找数据,如果找到就直接返回数据,找不到会触发一个缺页中断,然后操作系统把数据从文件读取到 PageCache 中,再返回给应用程序。
在数据写到 PageCache 中后,它并不是同时就写到磁盘上了,这中间是有一个延迟的。操作系统可以保证,即使是应用程序意外退出了,操作系统也会把这部分数据同步到磁盘上。但是,如果服务器突然掉电了,这部分数据就丢失了。
Cache的同步方式有两种,即Write Through(写穿)和Write back(写回)。从名字上就能看出这两种方式都是从写操作的不同处理方式引出的概念。
对应到Linux的PageCache上所谓Write Through就是指write操作将数据拷贝到Page Cache后立即和下层进行同步的写操作,完成下层的更新后才返回。
而Write back正好相反,指的是写完Page Cache就可以返回了。Page Cache到下层的更新操作是异步进行的。
Linux下Buffered IO默认使用的是Write back机制,即文件操作的写只写到Page Cache就返回,之后Page Cache到磁盘的更新操作是异步进行的。因此才会出现数据丢失问题。
可以看出读写缓存的这种设计,它天然就是不可靠的,是一种牺牲数据一致性换取性能的设计。
但在硬件时代,相对程序员的好处是,自己不用考虑,操作系统已经实现这些机制了。
为什么需要缓存
回到开篇的问题,缓存真的是必不可少的组件吗?缓存带来的系统复杂性与提升的系统性能投入产出比合理吗?
如果CPU强劲,IO没有开销,还需要缓存吗?反过来,引入缓存到底是为了提升性能,还只是为了补齐CPU与IO的短板。是主攻还是防守?是不是个面子工程。
所以综观一下,引入缓存,主要是两方面的理由:
1.缓解CPU压力:把运算结果存储起来,节省CPU算力2.缓解IO压力:把原本对网络、磁盘等慢介质的读写访问变为对内存等快介质的访问。
而这两方面,核心不是为了性能,性能的提升只是个副作用。
也就是如果能通过提升硬件,增强CPU和IO本身性能,是更好的解决方案。
譬如云,如果不是硬件受工艺与成本的限制,谁会花大成本搞云呢。加机器多省事。
在互联网场景下,大多数时候是读缓存,但也有像消息队列,读写量几乎是均衡的,如何应对缓存带来复杂性,是每一次系统架构时引入缓存组件时都需要考虑的问题。
数据一致性
使用缓存组件,我们需要重点关注一些技术指标,如“吞吐量”和“命中率”。而在使用过程中面对的问题,相对缓存穿透、击穿、雪崩,还是更关注一下缓存污染,也就是数据一致性问题。之前写过《百万QPS系统的缓存实践》[1],但里面还有一些场景没有详细阐述。尤其在当前标配的分布式架构下,CAP理论大棒肆无忌惮的挥舞。时时都要考虑数据一致性。
当然,虽然我们通过一些手段解决缓存的一致性问题,但也仅仅是缓解,并没法根治,它是个面子工程,不然也不会有像Paxos、Raft等一系列强一致性算法出现。
缓存数据一致性的问题,主要是两方面:
一是并发:并发操作引起的数据不一致
二是原子性:操作DB与操作缓存要么都成功,要么都失败
在《百万QPS系统的缓存实践》中已经指出,Cache-Aside pattern是通识,也就是先操作数据库,再删除缓存。
并发
在理论上,会有一种并发问题:
1.缓存中 X 不存在(数据库 X = 1)2.线程 A 读取数据库,得到旧值(X = 1)3.线程 B 更新数据库(X = 2)4.线程 B 删除缓存5.线程 A 将旧值写入缓存(X = 1)
这个场景需要cache miss、读请求 + 写请求并发、更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
但这种现象的概率太低了,数据库写操作时长远远大于读操作的。
原子性
原子性是指,与数据库事务原子性语义相同,数据库与缓存操作怎么捆绑在一起,要么都成功,要么都失败。
如果要强一致,那就需要像二阶段提交协议,还有近年出现的Paxos、Raft。但那就更复杂了,简单的方案可以有几种选择:
1.操作缓存失败,直接回滚数据库,返回失败:想想真如果这么干,首先系统可用性大大降低,其次代码是什么样,挑战极大。2.重试:用户无感,系统重试。可失败后马上重试大概率也是失败,重试多少次,重试过程中,一直占用资源。3.异步重试:类似于Write Back 模式。
但此处的Write Back不能限于内存,一旦重试过程中,服务重启,必然造成数据丢失。因此需要第三方服务来支撑。显然消息队列很适合这种场景
消息队列可靠保障:只要成功写入,不会丢失;并且能成功投递,直到消费成功。
有没有简单点的方案,毕竟引入消息队列有点重。
也有,就是『订阅数据库变更日志,再操作缓存』
通过监听binglog,当数据库变更时,去删除对应的缓存。这样应用程序无需再去主动操作缓存。
怎么订阅binglog呢?也有现成的开源组件,如canal。
到目前为止,还算完美解决了一致性问题。但在互联网行业中,数据库大多采用一主多从的部署方案。也就是在引入缓存前,能增加硬件方式解决时优先考虑增加硬件。
在系统中只要采用数据库主从架构,就有主从本身一致性问题,引入缓存又增加了复杂性。
1.线程 A 更新主库 X = 2(原值 X = 1)
2.线程 A 删除缓存
3.线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
4.从库「同步」完成(主从库 X = 2)
5.线程 B 将「旧值」写入缓存(X = 1)
怎么解决呢?类似于解决主从本身问题,可以等待slave同步完数据,延迟再删除一次缓存。
只是这个延迟间隔,需要依据master slave同步的间隔。
总结
总结一下本文的重点:
1、引入缓存会带来复杂性,甚至负面作用。尽量通过升级硬件来避免不必要的风险。
2、缓存需要考虑“吞吐量”、“命中率”等属性,还需要应对穿透、击穿、雪崩、不致性等问题。
3、Cache-Aside模式可以几乎完美解决单体架构下并发带来的问题。
4、在主从数据库模式下,Cacahe-Aside模式需要延迟双删方案,解决一致性问题。
5、CAP时时在发威,性能与一致性需要权衡。
References
[1]
《百万QPS系统的缓存实践》: https://www.zhuxingsheng.com/blog/cache-practice-of-million-qps-system.html
[2]
聊聊Linux IO: https://www.0xffffff.org/2017/05/01/41-linux-io/