58 | 性能设计篇之“缓存”

前面分享了《分布式系统设计模式》系列文章的前两部分——弹力设计篇和管理设计篇。今天开始这一系列的最后一部分内容——性能设计篇,主题为《性能设计篇之“缓存”》。
基本上来说,在分布式系统中最耗性能的地方就是最后端的数据库了。一般来说,只要小心维护好,数据库四种操作(select、update、insert 和 delete)中的三个写操作 insert、update 和 delete 不太会出现性能问题(insert 一般不会有性能问题,update 和 delete 一般会有主键,所以也不会太慢)。除非索引建得太多,而数据库里的数据又太多,这三个操作才会变慢。
绝大多数情况下,select 是出现性能问题最大的地方。一方面,select 会有很多像 join、group、order、like 等这样丰富的语义,而这些语义是非常耗性能的;另一方面,大多数应用都是读多写少,所以加剧了慢查询的问题。
分布式系统中远程调用也会消耗很多资源,因为网络开销会导致整体的响应时间下降。为了挽救这样的性能开销,在业务允许的情况(不需要太实时的数据)下,使用缓存是非常必要的事情。
从另一个方面说,缓存在今天的移动互联网中是必不可少的一部分,因为网络质量不一定永远是最好的,所以前端也会为所有的 API 加上缓存。不然,网络不通畅的时候,没有数据,前端都不知道怎么展示 UI 了。既然因为移动互联网的网络质量而导致我们必须容忍数据的不实时性,那么,从业务上来说,在大多数情况下是可以使用缓存的。
缓存是提高性能最好的方式,一般来说,缓存有以下三种模式。

Cache Aside 更新模式

这是最常用的设计模式了,其具体逻辑如下。
失效:应用程序先从 Cache 取数据,如果没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从 Cache 中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
这是标准的设计模式,包括 Facebook 的论文《Scaling Memcache at Facebook》中也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下 Quora 上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
那么,是不是这个 Cache Aside 就不会有并发问题了?不是的。比如,一个是读操作,但是没有命中缓存,就会到数据库中取数据。而此时来了一个写操作,写完数据库后,让缓存失效,然后之前的那个读操作再把老的数据放进去,所以会造成脏数据。
这个案例理论上会出现,但实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且有一个并发的写操作。实际上数据库的写操作会比读操作慢得多,而且还要锁表,读操作必须在写操作前进入数据库操作,又要晚于写操作更新缓存,所有这些条件都具备的概率并不大。
所以,这也就是 Quora 上的那个答案里说的,要么通过 2PC 或是 Paxos 协议保证一致性,要么就是拼命地降低并发时脏数据的概率。而 Facebook 使用了这个降低概率的玩法,因为 2PC 太慢,而 Paxos 太复杂。当然,最好还是为缓存设置好过期时间。

Read/Write Through 更新模式

我们可以看到,在上面的 Cache Aside 套路中,应用代码需要维护两个数据存储,一个是缓存(cache),一个是数据库(repository)。所以,应用程序比较啰嗦。而 Read/Write Through 套路是把更新数据库(repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的 Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或 LRU 换出),Cache Aside 是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through 套路和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由 Cache 自己更新数据库(这是一个同步操作)。
下图自来 Wikipedia 的 Cache 词条。其中的 Memory,你可以理解为就是我们例子里的数据库。

Write Behind Caching 更新模式

Write Behind 又叫 Write Back。一些了解 Linux 操作系统内核的同学对 write back 应该非常熟悉,这不就是 Linux 文件系统的 page cache 算法吗?是的,你看基础知识全都是相通的。所以,基础很重要,我已经说过不止一次了。
Write Back 套路就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的 I/O 操作飞快无比(因为直接操作内存嘛)。因为异步,Write Back 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道 Unix/Linux 非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间、空间换时间一个道理。有时候,强一致性和高性能,高可用和高性能是有冲突的。软件设计从来都是 trade-off(取舍)。
另外,Write Back 实现逻辑比较复杂,因为它需要 track 有哪些数据是被更新了的,需要刷到持久层上。操作系统的 Write Back 会在仅当这个 Cache 需要失效的时候,才会把它真正持久起来。比如,内存不够了,或是进程退出了等情况,这又叫 lazy write。
在 Wikipedia 上有一张 Write Back 的流程图,基本逻辑可以在下图中看到。

缓存设计的重点

缓存更新的模式基本如前面所说,不过这还没完,缓存已经成为高并发高性能架构的一个关键组件了。现在,很多公司都在用 Redis 来搭建他们的缓存系统。一方面是因为 Redis 的数据结构比较丰富。另一方面,我们不能在 Service 内放 Local Cache,一是每台机器的内存不够大,二是我们的 Service 有多个实例,负载均衡器会把请求随机分布到不同的实例。缓存需要在所有的 Service 实例上都建好,这让我们的 Service 有了状态,更难管理了。
所以,在分布式架构下,一般都需要一个外部的缓存集群。关于这个缓存集群,你需要保证的是内存要足够大,网络带宽也要好,因为缓存本质上是个内存和 IO 密集型的应用。
另外,如果需要内存很大,那么你还要动用数据分片技术来把不同的缓存分布到不同的机器上。这样,可以保证我们的缓存集群可以不断地 scale 下去。关于数据分片的事,我会在后面讲述。
缓存的好坏要看命中率。缓存的命中率高说明缓存有效,一般来说命中率到 80% 以上就算很高了。当然,有的网络为了追求更高的性能,要做到 95% 以上,甚至可能会把数据库里的数据几乎全部装进缓存中。这当然是不必要的,也是没有效率的,因为通常来说,热点数据只会是少数。
另外,缓存是通过牺牲强一致性来提高性能的,这世上任何事情都不是免费的,所以并不是所有的业务都适合用缓存,这需要在设计的时候仔细调研好需求。使用缓存提高性能,就是会有数据更新的延迟。
缓存数据的时间周期也需要好好设计,太长太短都不好,过期期限不宜太短,因为可能导致应用程序不断从数据存储检索数据并将其添加到缓存。同样,过期期限不宜太长,因为这会导致一些没人访问的数据还在内存中不过期,而浪费内存。
使用缓存的时候,一般会使用 LRU 策略。也就是说,当内存不够需要有数据被清出内存时,会找最不活跃的数据清除。所谓最不活跃的意思是最长时间没有被访问过了。所以,开启 LRU 策略会让缓存在每个数据访问的时候把其调到前面,而要淘汰数据时,就从最后面开始淘汰。
于是,对于 LRU 的缓存系统来说,其需要在 key-value 这样的非顺序的数据结构中维护一个顺序的数据结构,并在读缓存时,需要改变被访问数据在顺序结构中的排位。于是,我们的 LRU 在读写时都需要加锁(除非是单线程无并发),因此 LRU 可能会导致更慢的缓存存取的时间。这点要小心。
最后,我们的世界是比较复杂的,很多网站都会被爬虫爬,要小心这些爬虫。因为这些爬虫可能会爬到一些很古老的数据,而程序会把这些数据加入到缓存中去,而导致缓存中那些真实的热点数据被挤出去(因为机器的速度足够快)。对此,一般来说,我们需要有一个爬虫保护机制,或是我们引导这些人去使用我们提供的外部 API。在那边,我们可以有针对性地做多租户的缓存系统(也就是说,把用户和第三方开发者的缓存系统分离开来)。

小结

好了,我们来总结一下今天分享的主要内容。首先,缓存是为了加速数据访问,在数据库之上添加的一层机制。然后,我讲了几种典型的缓存模式,包括 Cache Aside、Read/Write Through 和 Write Behind Caching 以及它们各自的优缺点。
最后,我介绍了缓存设计的重点,除了性能之外,在分布式架构下和公网环境下,对缓存集群、一致性、LRU 的锁竞争、爬虫等多方面都需要考虑。下篇文章中,我们讲述异步处理。希望对你有帮助。
也欢迎你分享一下你接触到的缓存方式有哪些?怎样权衡一致性和缓存的效率?
文末给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。
弹力设计篇
认识故障和弹力设计
隔离设计 Bulkheads
异步通讯设计 Asynchronous
幂等性设计 Idempotency
服务的状态 State
补偿事务 Compensating Transaction
重试设计 Retry
熔断设计 Circuit Breaker
限流设计 Throttle
降级设计 degradation
弹力设计总结
管理设计篇
分布式锁 Distributed Lock
配置中心 Configuration Management
边车模式 Sidecar
服务网格 Service Mesh
网关模式 Gateway
部署升级策略
性能设计篇
缓存 Cache
异步处理 Asynchronous
数据库扩展
秒杀 Flash Sales
边缘计算 Edge Computing
(0)

相关推荐

  • 高并发场景下,到底先更新缓存还是先更新数据库?

    在大型系统中,为了减少数据库压力通常会引入缓存机制,一旦引入缓存又很容易造成缓存和数据库数据不一致,导致用户看到的是旧数据. 为了减少数据不一致的情况,更新缓存和数据库的机制显得尤为重要,接下来带领大 ...

  • 性能优化:关于缓存的一些思考

    利用缓存做性能优化的案例非常多,从基础的操作系统到数据库.分布式缓存.本地缓存等.它们表现形式各异,却有着共同的朴素的本质:弥补CPU的高算力和IO的慢读写之间巨大的鸿沟. 和架构选型类似,每引入一个 ...

  • redis概述

    缓存是如何实现高性能的 当第一次查询的时候,缓存里面没有数据,回去数据库查,然后存入缓存,当再去查询的时候,或者是查询相同数据的时候,就不用去数据库里面查了,直接去缓存里面找,大大提高了效率,如果这个 ...

  • 家庭网络设计篇——明确需求很重要

    值得买数码2021-05-16 11:29:30 ...我被催更了,那么我今天就把这篇写完... 家庭的网络需求确定 首先,你要自己明白自己的网络需求是啥,举个栗子 栗子1:我想蹲坑时候,不管在哪个卫 ...

  • 微型扬声器量化设计篇.pdf

    SHAT 东莞成谦音响科技有限公司 2011年2月 扬声器主要参数综合设计和分析 (微型扬声器)量化设计 R&D 电声总工程师:马鲁建 什么是量化设计? 以电声理论为基础, 以计算机专 用软 ...

  • 收藏殿堂 | 新概念——老坑翡翠现代镶嵌作品欣赏(新设计篇)

    从今年一月份以来曾经多次介绍了用现代镶嵌设计重塑老坑翡翠之美.适合现代青春女性.知识女性.高端女性的饰品,那些饰品颇有"忽如一夜春风来,千树万树梨花开"之感,令人耳目一新! 爱美之 ...

  • 「设计篇」100个新中式入口设计

    中式建筑即中国传统建筑,其形成和发展具有悠久的历史.由于幅员辽阔,各处的气候,人文,地质等条件各不相同,而形成了中国各具特色的建筑风格. 新中式就是中式建筑元素和现代建筑手法的结合运用 从而产生的一种 ...

  • 上汽 | 插电式混合动力汽车碰撞安全性能设计开发

    摘要 在对插电式混合动力汽车的发动机.燃油箱和动力电池.电机.高压电路等关键零部件进行相关的碰撞安全性能开发时,不仅需要考虑传统燃油车的碰撞安全标准要求,同时还要考虑电动汽车动力电池安全相关的碰撞标准 ...

  • Power BI财务报表分析:报告设计篇

    PowerBI财务报表分析系列之: 报告设计篇 单个的可视化对象的制作很简单,不过一页报表不止一个可视化对象所组成,很多报告也不止一页报表,如何将多个可视化对象.多个页面组合到一起,形成一个整体.统一 ...

  • 公务员面试之形象设计篇,真的很走心

    很多同学对面试穿什么衣服,要不要扎马尾辫,能不能带首饰等问题很关心,其实没必要想那么多,带着一颗平常心就可以了. 总体而言,一个原则:漂亮大方,你觉得怎么好看就怎么来,不要相信辅导班那一套.正装的唯一 ...

  • 京津冀科技辅导员培训(第三天-设计篇)

    11月21是学习的第三天,今天的学习任务主要是3D打印设计与实践,使用的软件工具是西门子刚刚发布的"Solid Edge ST10",这款工具在今年四月份南京参加绿色能源电动车项目 ...