2023-08-12
原文作者:Ressmix 原文地址:https://www.tpvlog.com/article/78

一、简介

虽然我们可以通过读写分离分库分表等各种手段来提升存储系统的性能,但在某些复杂的业务场景下,单纯依靠存储系统的性能提升是不够的,典型的场景有:

  • 需要频繁进行复杂运算得出的数据
    例如,一个论坛如果要实时展示用户同时在线数,则 MySQL 性能可能无法支撑,因为使用 MySQL 来存储当前用户状态,则每次获取这个总数都要“count(*)”大量数据,这样的操作无论怎么优化 MySQL,性能都不会太高。
  • 读多写少的数据
    绝大部分在线业务都是读多写少。例如,微博、淘宝、微信这类互联网业务,读业务占了整体业务量的 90% 以上。以微博为例:一个明星发一条微博,可能几千万人来浏览。如果使用 MySQL 来存储微博,明星写微博只有一条 insert 语句,但每个用户浏览时都要 select 一次,即使有索引,几千万条 select 语句对 MySQL 数据库的压力也会非常大。

缓存就是为了弥补存储系统在这些复杂业务场景下的不足而出现的,其基本原理是 将可能重复使用的数据放到内存中,一次生成、多次使用,避免每次使用都去访问存储系统

缓存能够带来性能的大幅提升,目前主流的开源分布式缓存有Memcache和Redis。以 Memcache 为例,单台 Memcache 服务器简单的 key-value 查询能够达到 QPS 50000 以上,其基本的架构是:

202308122217249631.png

缓存虽然能够大大减轻存储系统的压力,但同时也给架构引入了更多复杂性。架构设计时如果没有针对缓存的复杂性进行处理,某些场景下甚至会导致整个系统崩溃。

本文主要针对分布式缓存的架构设计要点作分析, 不对Memcache和Redis的使用方式和原理作介绍,读者可以参考相关专门的书籍,在后续的进阶篇中我们也会针对Redis的应用作深入讲解。

二、缓存穿透

缓存穿透,是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。

202308122217258612.png

缓存穿透的问题很明显,本来我们加缓存就是为了提升系统性能,防止请求直接打到数据库,现在缓存命中不了了,所有请求会直接去数据库查询。如果并发量很高,会瞬间让数据库崩掉。

缓存穿透通常分为两种情况: 缓存数据不存在缓存数据耗时长

2.1 缓存数据不存在

如果用户查询的时候,在缓存中找不到对应的数据,则每次都要去存储系统中再查询一遍,然后返回数据不存在。缓存在这个场景中并没有起到分担存储系统访问压力的作用。

通常情况下,未命中缓存的请求量并不会太大,但如果出现一些异常情况,例如黑客攻击,故意大量访问某些不存在的数据,则会导致大量请求直接打到后台的存储系统,进而将存储系统拖垮。

比如下图中,正常情况下,系统A会根据用户送的ID先去缓存查询数据,查询不到再去数据库查。如果黑客大量发起一些非法ID查询(比如全是负数),那么每次系统A从数据库中查询都得不到结果,这种恶意攻击场景的缓存穿透就把数据库打死了:

202308122217265943.png

针对“缓存数据不存在”的穿透场景的解决办法比较简单:

  1. 缓存空对象,并对其设置一个合适的过期时间,即如果没有查询到数据库中的数据,则直接设置一个默认值(可以是空值,也可以是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,且不会继续访问存储系统;
  2. BloomFilter。在缓存层和存储层之前,将存在的有效key用 BloomFilter 提前保存起来,做第一层拦截,非法请求Key直接拦截掉,比如上述黑客攻击场景,可以限定只有正数ID是合法的。

2.2 缓存数据耗时长

第二种情况是存储系统中虽然存在数据,但生成缓存数据需要耗费较长时间或者耗费大量资源。如果刚好在业务访问的时候缓存失效了,那么也会出现缓存没有发挥作用,访问压力全部集中在存储系统上的情况。

最典型的就是电商的商品分页,假设我们在某个电商平台上选择“手机”这个类别查看,由于数据巨大,不能把所有数据都缓存起来,只能按照分页来进行缓存。由于难以预测用户到底会访问哪些分页,因此业务上最简单的实现方式就是每次点击分页的时按分页计算和生成缓存。通常情况下这样实现是基本满足需求的,但是如果被竞争对手用爬虫来遍历的时候,系统性能就可能出现问题。

具体的场景有:

  1. 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据;
  2. 通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大;
  3. 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了;
  4. 由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。

这种情况并没有太好的解决方案,因为爬虫会遍历所有的数据,而且什么时候来爬取也是不确定的,可能是每天都来,也可能是每周,也可能是一个月来一次,我们也不可能为了应对爬虫而将所有数据永久缓存。

通用方案要么就是识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。

三、缓存雪崩

缓存雪崩,通常是指当缓存失效后引起系统性能急剧下降的情况。比如系统正常在高峰期能够抗住每秒5000次的并发请求,其中缓存承担掉4000次,剩余1000次直接落到数据库,但是由于缓存故障,导致1s内的5000个请求全部打到数据库,此时数据库就直接崩溃了。

系统一般在架构设计时,就需要针对缓存雪崩做一些防范措施:

  • 事前:分布式缓存本身要做高可用,比如Redis的主从+哨兵、Redis Cluster,避免缓存本身不可用;
  • 事中:服务本地做二级缓存(比如采用Ehcache或Guava),同时利用Hystrix做好限流和降级,避免数据库崩溃;
  • 事后:缓存做持久化,以便崩溃后快速恢复缓存数据

202308122217272514.png

另一种缓存雪崩的场景是:当缓存过期被清除后,业务系统需要重新生成缓存,而对于一个高并发的业务系统来说,几百毫秒内可能会接到成百上千个请求。如果处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,从而对存储系统造成巨大的性能压力。这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

应对这种缓存雪崩场景的常见解决方法有两种: 更新锁机制后台更新机制

3.1 更新锁机制

对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

对于分布式系统,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式系统要实现更新锁机制,需要用到分布式锁,可以采用ZooKeeper来做分布式锁的实现。

3.2 后台更新机制

后台更新机制,就是由后台线程去定时更新缓存,而不是由业务线程来更新缓存,同时缓存本身的有效期设置为永久。后台更新机制需要考虑一种特殊场景:当缓存系统内存不够时,会“淘汰”一些缓存数据,即 缓存淘汰策略

从缓存被淘汰到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。解决的方式有两种:

  1. 后台线程主动更新:除了定时更新缓存,还要频繁地去读取缓存(比如1 秒读取一次),如果发现缓存被淘汰就立刻更新缓存。这种方式实现简单,但读取间隔不能设置太长,因为如果缓存被淘汰且缓存读取间隔时间又太长,则这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验较差;
  2. 业务线程主动通知:业务线程发现缓存失效后,通过MQ通知后台线程更新缓存。此时可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,因为后台线程可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖MQ,复杂度会高一些,但缓存更新更及时,用户体验更好。

后台更新机制,相比更新锁机制要简单一些。同时也适合业务刚上线的时进行缓存预热。所谓 缓存预热 指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。

四、双写一致性

业务线程在做更新数据操作时,通常需要在数据库操作成功后,再更新缓存中的数据。那么问题来了: 如果数据库操作成功,缓存更新失败,就会出现数据不一致,这就是双写一致性问题

比如下图的库存服务,初始时数据库和缓存中的库存都是1000,当请求扣减100库存时,数据库操作成功,但是缓存操作失败了,此时缓存和数据库中的数据就不一致了:

202308122217285455.png

一般来说,如果你的系统使用缓存的场景并不是严格要求缓存+数据库必须保证数据一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况。

4.1 Cache Aside Pattern

解决双写一致性问题的基本模式就是 Cache Aside Pattern ,其基本思路如下:

  1. 读操作:先读缓存,缓存不存在则读数据库,然后将数据库结果写入缓存,同时返回响应;
  2. 写操作:先删除缓存,然后再更新数据库。

写操作为什么是删除缓存,而不是更新完数据库后再直接更新缓存呢?

原因很简单,更新缓存的代价是很高的,很多时候复杂点的缓存场景,缓存中的数据并不仅仅是简单的直接从数据库中取出来的值。比如:商品详情页的系统,当修改库存时,可能先更新某个表的一个字段,然后查询另外两个表的数据,并进行运算,最后将算出的库存更新到缓存中去。

从另一个角度讲,如果每次修改数据库的时候,都将其对应的缓存更新一下,效率是很低的。举个例子:一个缓存涉及的表字段,可能在1分钟内被修改100次,那么缓存也要更新100次,但是实际上如果这个缓存在1分钟内就被读取了1次,就会出现大量冷数据。而如果采取先删除缓存,再更新数据库的方式,那么1分钟内,缓存不过就重新计算一次而已,开销大幅度降低。

其实删除缓存,而不是更新缓存,就是一个lazy计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。

但是,Cache Aside Pattern存在两个问题:

  • 缓存旧数据
  • 高并发环境下的数据不一致性

缓存旧数据

我们先来看下“缓存旧数据”的情况:如果删除缓存成功,但是修改数据库失败了,那么就会出现数据库中是旧数据,但缓存是空的情况。此时如果有读请求过来,就会将数据库中的旧数据更新到缓存中。不过这种情况虽然是脏数据,但数据库和缓存的数据至少是一致的,所以整体没啥影响。

202308122217293066.png

高并发数据不一致

第二种情况比较复杂,我们来看下高并发情况下是如何出现数据不一致的。
1.一个更新请求过来,先删除了缓存,然后正要去修改数据库,但还没修改:

202308122217303747.png

2.此时一个读请求过来,发现缓存为空,就去查询数据库,查到了修改前的旧数据,并更新到缓存中:

202308122217310848.png

  1. 此时第一个更新请求完成了修改数据库的操作,这样就出现了数据不一致的问题(缓存中是旧数据而数据库中是新数据):

202308122217319799.png

在对同一个数据进行高并发的读写时,比如1wQPS以上的情况,只要有数据更新的请求,就可能会出现上述的数据库和缓存数据不一致的情况。

4.2 内存队列方案

Cache Aside Pattern在高并发环境下之所以出现数据不一致,最主要的原因就是读操作在发现缓存数据不存在时,既然是针对同一份数据的读写并发引起的数据不一致,那最基本的思路就是 将Cache Aside Pattern中的读操作和写操作串行化,可以串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况

我们继续以商品库存服务为例来具体看下:

  1. 首先,假设每种商品都有全局唯一的商品ID,我们需要将商品和内存队列映射起来,我们可以对商品ID进行Hash,其结果再对内存队列数N取模——CRC16(M_ID)%N,这样每一种商户就关联到了一个唯一的内存队列;
  2. 对于每个更新请求(写操作),直接入队列,然后每个队列都有一个工作线程会依次从队列中取出请求,如果发现是更新请求,就执行删除缓存、更新数据库的操作;
  3. 对于每个读请求,如果发现缓存中没有数据,也会先进入对应商品的队列中,等待工作线程处理,当工作线程发现是读请求时,就会先读数据库,然后根据结果再更新缓存。

这样一来,针对同一个商品的读/写操作就全部关联到了同一个队列中,串行化的操作可以保证不会出现数据不一致,整体流程如下图:

2023081222173258310.png

优化点

这里有一个 优化点 :一个队列中,针对同一份缓存数据的多个读请求串在一起是没意义的。也就是说对于一个高并发的业务系统,几百毫秒内可能会接到成百上千个读请求。如果处理这些请求的线程都不知道另外有一个线程正在生成缓存,那这些读请求都会去重新生成缓存,影响整个系统的效能。

因此需要做下过滤,如果发现队列中已经有某个商品的读请求了,那么就不用将这个读请求放进队列,而是去缓存中轮询下(比如200ms),如果轮询到了结果,就直接返回,如果轮询不到结果,就查数据库获取最新结果并返回。

读请求超时

上述优化点有一个大的风险点: 如果数据更新很频繁,导致了队列中积压了大量写操作在里面,那读操作在轮询不到结果就会发生大量的超时,导致大量读请求直接走数据库

比如,如果一个队列里积压了100个商品的库存修改操作,每个库存修改操作要耗费20ms完成,那么最后一个商品的读请求,可能要等待20x100ms = 2s 后才能得到数据,这时就导致了读请求的长时阻塞。

所以,务必通过一些模拟真实的测试,看看更新操作的频率是怎样的。根据之前的项目经验,一般来说数据的更新频率是很低的,所以队列中积压的更新操作应该是很少的,一般更新操作能有500QPS就不错了。 我们就以500QPS来估算下需要多少内存队列:

首先假设读操作最多允许阻塞200ms,每个写操作耗时20ms。那么200ms内一个队列中最多允许积压10个写操作,即1s内最多积压50个写操作。总QPS是500,所以10个内存队列一般就够了,这样能够保证每个读操作在200ms内返回。

假设1秒有500个写请求,即每200ms有100个写请求,放到20个内存队列里,每个队列最多也就积压5个写请求,写操作一般20ms完成,那么针对每个内存队列中的数据的读请求,也就最多200ms以内肯定能返回了。

极端情况下,写QPS扩大10倍,但是经过刚才的测算就知道单机支撑几百的写QPS没问题。既然QPS扩了10倍,那就把机器也扩10倍,10台机器,每个机器20个队列,共200个队列。

读请求并发过高

在上述更新请求的处理期间,可能会有大量读请求同时轮询等待缓存结果,如果在超时结束后,这些读请求全部去数据库查询结果,就可能导致数据库瞬间被打崩。

所以就 需要测算500QPS的写请求,在同一时间最多hang住多少读请求 。根据经验,读写请求比例大概在2:1就差不多了,也就是说500QPS的写请求,对应每秒约有1000个读请求会hang住,而单机MySQL的大概能支撑2000TPS左右。

请求路由

基于内存队列的方案,必须要保证针对同一个商品的读/写请求能够路由到同一台机器的同一个内存队列里。可以借助一些路由算法对特征值计算来实现路由,比如Nginx的hash路由等。

4.3 总结

本节,主要针对的缓存更新机制作了讲解,一般来说,如果并发量不高,或业务场景并不要求缓存和数据库的严格数据一致性的话,那么Cache Aside Pattern就足够应付了。只有当出现高并发场景且数据一致性要求非常高时,才会使用上述的内存队列方案,而且用了这个方案,会造成以下影响:

  1. 读请求和写请求串行化后,会导致系统的吞吐量会大幅降低,用比正常情况下多几倍的机器去支撑线上的一个请求;
  2. 系统的复杂度大幅度上升。

五、缓存热点

虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大。例如,某明星微博发布“我们”来宣告恋爱了,短时间内上千万的用户都会来围观。

缓存热点的解决方案就是 复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力 。以微博为例,对于粉丝数超过 100 万的明星,每条微博都可以生成 100 份缓存,缓存的数据是一样的,通过在缓存的 key 里面加上编号进行区分,每次读缓存时都随机读取其中某份缓存。

缓存副本设计有一个细节需要注意,就是不同的缓存副本不要设置统一的过期时间,否则就会出现所有缓存副本同时生成同时失效的情况,从而引发缓存雪崩效应。正确的做法是设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值。

六、总结

分布式缓存涉及的东西非常多,本文仅介绍了缓存设计中的几个关键点,更多内容读者可以去学习Redis或Memcache的实现和原理。在后续实战系列中,我们也会通过一个真实的案例去讲解分布式缓存的整体设计思路。

阅读全文