聊聊系统设计中的缓存

好久没更新了,本来想更新《前端是不是真的死了》,但是正好工作中发生了一些讨论,所以就改成先更新缓存了。
本文适宜对象:不太常设计缓存的各类工程师。

背景故事

今日的一个场景是:有一段国家信息数据,结构大概是:[{ region: 'CN', code: 12345, text: '中国' }] 这样的一个国家数组(实际字段不太一样),而在此之前这段信息存储在了一个提供给前端的外部接口中,你是一个提供给前端的 BFF,想基于这些数据进行二次处理。

// 一段伪代码
// 调用一个外网接口
if regionList, err := fetchRegionList(); err != nil {
    handle(regionList) // 对 region 进行处理
}

那么你会怎么去优化呢?(撇去联系底层服务提供内网接口这种沟通性工作)

从缓存说起

在这一例子中去分析,首先我们就会觉得,每次都外网调用,看上去还是不变的数据,是不是我们可以上个「缓存」,在有内容的时候直接读「缓存」,没有「缓存」的时候再去拿原始数据呢?

很棒,你想到了如何优化耗时较长的引用问题,那我们怎么去设计「缓存」呢?

提到缓存,大部分新人的第一反应估计就是「缓存嘛,说的不就是 redis」吗?

但实际上,当我们存储数据的时候,我们至少可以细分成三个层级:「本地缓存」、「redis」、「DB」,撇开 DB 不提,人人都知道它是拿来存储数据的,那么剩下的两级缓存,我该在什么情况下去使用。

回到这个场景中,如果我们用 redis,很明显,我们会需要进行:

  1. 一次网络 IO
  2. 一个 redis 资源

1 大家都能理解,那么上游有多少次请求、就会有多少次请求打到 redis,而 2 也不是个玩笑话,毕竟我相信大部分公司仍然在「降本提效」的路上。

对于这个有限数据集来说,用本地缓存可以靠少量内存来解决这个问题,它没有额外的网络 IO,不会对下游服务造成额外压力,充分满足了降本提效美学的核心思想。

缓存只是用吗

有了这个点,问题真的解决了吗?实际上,对于缓存来说,我们考虑的更多的是缓存的「写」和「更新」,怎么去解决数据一致性的问题才是缓存设计中的大头。99.9% 的 case 都是会更新的,只是有「多长保质期」的区别罢了。

在上文的设计中,很明显,我们没有考虑过期问题,永不过期是缓存设计中最糟糕的设计。缓存通常都是存储在内存中的,很明显内存是个有限级,糟糕的缓存设计配合永不过期,很快你就能得到缓存打满的快乐。

一个合理的缓存应该是:一个合理的数据结构+一个合理的淘汰策略。

缓存如何设计

在所有的程序设计中,我们考虑问题的第一步肯定都是分析我们的场景,比如上文我们分析「场景」和「更新频率」得出了一个结论:「我们用不着使用 redis」。

对于缓存来说,我们应该考虑以下几点并做好缓存失效的防御机制。:

  • 缓存的根本原因:是因为体量大还是因为下游慢
  • 缓存的命中率多少:我们到底需不需要缓存
  • 时效性多少:缓存怎么更新
  • 缓存的 QPS 是多少:热 key 问题

缓存的根本原因

缓存并不是快的代名词,他只是把建立在 DB 上的磁盘 IO 变成了建立在内存上的 IO,同时多了一层缓存副本,上面我们也提到了「降本提效」这四个字,缓存本身存储也是一份额外的开销,同时也是增加了链路的复杂性。(如果有不理解「链路复杂性」这五个字的同学欢迎留言,人多的话可以额外加餐)。我们要想清楚上缓存的根本目的,通常有两点:

  1. 我的 QPS 太大了,我的下游扛不住这么大的 QPS,需要做一些手段去干预流量向下透传,这是最常见的使用场景。
  2. 我的下游响应速度太慢,或者稳定性太差,影响了我自身的服务质量,且数据是通用的(不缓存用户相关数据),那么这个时候缓存可以加速我的服务。

缓存命中率

引用之前我的《前端 SSR 系统设计思路谈》中的一段话:

我们是不是可以对 SSR 进行缓存,无论是页面级的缓存还是组件级的缓存——对于一个通用的方案来说,这是一个比较张口就来的解决方案。确实,他能有效的减少 CPU 的开销,但是无论如何,「缓存」这个设计一定得针对具体的业务模型去决定的:我的业务是否对实时性有一定要求;我的缓存粒度是什么,超时时间是多少;甚至我做了缓存之后,我的缓存命中率究竟是多少,一味的只是说上缓存是没有任何意义的。——更何况缓存同时会影响你的整个开发模型,可能会引入额外的开发成本,也同样不是银弹。

假设我真的满足了「根本原因」,但是你发现缓存利用率太差,存了却没有被有效利用,等于没上。

这里分为两类设计思路:

  1. 先行评估:这个业务数据的通用性到底是怎么样的、缓存时效性要求怎么样(这个后文会具体说),设计好 key。这决定了他的命中率。
  2. 先上试试:在一些优化尝试中,我们可能也会决定先莽再说,在上了缓存之后还是需要观测缓存命中率,不行就是浪费钱,只能写进 KPI:我上了缓存,但是完全没用上——建议重新评估。

缓存时效性

上文我们提到了「没有超时时间的缓存就是耍流氓」,那么怎么设计我们的超时时间,代码又该怎么写呢?

缓存设计中,我们首先要设计一个合理的缓存生命周期,最简单的想法是「过期了就去重新回源」,只要衡量业务数据的生命周期,对于一个非强实时性要求的数据来说,一般分钟级别都是可以被接受的。但问题是如果过期了再去取,可能会存在「缓存击穿」的问题,如果过期生命周期正好一致,甚至有可能遇到「缓存雪崩」。这里不对「缓存击穿」和「缓存雪崩」再进行介绍,Google 太多。可见缓存时效性才是缓存设计中的一大核心要义。

这里的主要思路有:

  1. 长缓存 +job 更新,对于 DB 数据更新的同步,比较常见的操作是消费 binlog 数据刷新,这种设计中通常缓存时间都会分配的很长,甚至是永不过期的
  2. 分布式锁,利用锁合并读数据库请求,只用一个线程去读取,剩下的等待锁释放去拿缓存数据

缓存怎么存

假设我们选完了数据结构,考虑完了缓存超时时间,那缓存就完事了吗?——对于 redis 来说,他同样也是个服务,只是比 DB 耐操一些,同样也会扛不住,这里就需要我们在 QPS 预估的基础上去进行热 key 分析,关于怎么做热 key 监控,通常不是业务方要考虑的问题,可以 Google 一下具体实现,这里我们主要要对热 key 进行分析,做合理的策略操作,上文举的其实就是一个「选择缓存层级」的例子——选择本地缓存,适合少量数据但极端热门的场景。本地缓存往往是进程级别的,所以在单机多核上会存在多个副本,也不排除单个进程更新失败的可能性。

当然,对于大量业务热 key,可能就不够合适了,我们可以考虑分集群读写,这里不止是冷热分离,也可以对热 key 再进行集群分片。

缓存避雷

除了以上说的点以外,对于 redis 来说,我们还要防止大 key 问题,大 key 的读写会导致集群压力,成为十足的风险点。

缓存的想象力

刚刚上面提到的大部分 case 都以 redis 为例,但实际上,不仅我们有 redis 和服务器本地缓存,如果真的是一个国家列表要给前端消费,我们也可以让前端获取 json 文件,并走端缓存或者 CDN 缓存。

从这个角度切入,我们又有了新的缓存选择方案——缓存的控制度:不变的接口数据,比如我可以是一个带版本的配置数据;或者所谓的永恒不变的数据,压力完全不会传递到服务端。

总结

对于缓存的介绍先到这里,之后有机会可能会再用这个例子介绍一下「代码存储」、「配置中心」、与「redis 读取」,是怎么去权衡和选择的(但文章应该不会太长)。

这里本来想写的文章是「后端缓存」,但是对于我们系统设计中,无论你是前端工程师还是后端工程师,更多的是一个成本转移的过程,也就是在全链路上下游权衡利弊来决定把压力放在哪一层,怎么去做,透不透传,因此没有把结果局限在「后端」这个领域。希望与大家一起探讨关于缓存的一些用法和思考。

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: 知识

添加新评论