五分钟搞定缓存设计

970 阅读10分钟

引言

缓存在高并发系统的重要性不言而喻,通常会用来存储热点数据,减轻流量对存储层的压力,提升整个系统的性能。

那么我们在做缓存系统的架构设计时,需要考虑哪些问题呢?

缓存执行流程

从读数据的流程来看,套路总是固定的:

  • cache hit,则直接返回数据给客户端
  • cache miss,则回源,如果有数据,则更新缓存,再返回数据

而由于存在操作时序问题,以及缓存是更新还是淘汰的问题,写数据的流程却有多条路径:

  • 先更新数据库,再更新缓存
  • 先更新缓存,再更新数据库
  • 先更新数据库,再删除缓存
  • 先删除缓存,再更新数据库

双写的一致性

其实,理论上只要缓存设置的有TTL,是可以保证最终一致性的。不过,想象一下这样的场景:

同学A瞄了一眼理财通账户,发现余额只剩下100,昨天刚发了工资,赶紧转5000块进去,此时银行短信提示转账成功,同学A再瞄了一眼理财通,擦,怎么还是100,我刷...我刷...,还是只有100,此时.....

类似像这样,对数据一致性要求非常高的业务场景,单纯依靠最终一致是远远不够的。下面将对各种写流程的策略逐一进行分析。

1. 先更新数据库,再更新缓存

  • 场景一 并发问题

同时有2个请求进行更新操作,可能会出现:

(1)请求A更新了数据库

(2)请求B更新了数据库

(3)请求B更新了缓存

(4)请求A更新了缓存

此时,数据库的状态是B,而缓存的状态是A,这就导致了不一致。

  • 场景二 收益问题

倘若缓存的数据需要经过较为复杂的查询和计算,而读请求相对于写请求的量又不是特别大,这个时候更新缓存的操作就显得比较低效了。

  • 场景三 部分成功问题

如果第一步更新数据库成功,但是第二步更新缓存失败,这个时候,也会有不一致

2. 先更新缓存,再更新数据库

这个策略通常都会被否掉。首先,跟写流程1相比,并发问题和收益问题同样存在。另外如果出现部分成功,结果会更糟:若第二步更新数据库失败,此时必须回滚,或者删除缓存,使得问题的处理变得更加复杂。

结合写流程1和2,可以发现,更新缓存的操作,一旦出现不一致,就只能被动等待缓存失效,才能恢复一致性。适用于并发可能性小或者一致性要求低的场景,比如博客。

那么删除缓存就可以轻松实现强一致性吗?

3. 先更新数据库,再删除缓存

  • 场景一 并发问题

由于总是删除缓存,所以多个写并发是没问题的,不过我们再考虑一下读和写并发的场景:

(1)请求A读缓存,但是cache miss

(2)请求A读数据库,读到旧的数据

(3)请求B更新数据库

(4)请求B删除缓存

(5)请求A用旧数据更新缓存

此时,数据库的状态是B,而缓存的状态是A,出现了不一致。

  • 场景二 主从延迟问题

(1)请求A更新数据库

(2)请求A删除缓存

(3)请求B读缓存,发生cache miss

(4)请求B回源数据库,但是读的从库,读的旧数据

(5)请求B更新缓存

此时,数据库的状态是A,而缓存的状态是B,出现了不一致。

  • 场景三 部分成功的问题

如果删除缓存失败,会出现不一致。

对于这几种不一致的场景,业界通用的做法是采用两次删除法,且第二次是采用异步延迟删除策略,延迟的时间一般由存储层主从延迟决定。

4. 先删除缓存,再更新数据库

  • 场景一 并发问题

(1)请求A删除缓存

(2)请求B读缓存,发生cache miss

(3)请求B读数据库,读到旧的数据

(4)请求A更新数据库

(5)请求B用旧数据更新缓存

此时,数据库的状态是A,而缓存的状态是B,出现了不一致。

  • 场景二 主从延迟问题

这个场景和写流程3的情况完全类似,不再赘述。

我们发现,写流程4和流程3相比,都会存在并发问题和主从延迟问题。但是,相比之下,4产生脏数据的概率较大,而3产生脏数据的概率较小。

到这里,缓存执行流程的问题,就基本讲完了。再补充一下异步延迟删除的实现:

  • 方案一:消息队列

这种方案对于业务代码有一定的入侵,且引入了新的中间件。

  • 方案二:订阅存储层的binlog

这个方案使用一个独立的模块来执行异步淘汰任务,看似与业务逻辑解耦,不过需要解析binlog日志,而且还需要找到与业务缓存对应的key,实现起来可能会比较复杂。

前面讲了这么多,说白了,就是要解决双写一致性问题,那解决了一致性是不是就完了呢,当然不是,问题才刚刚开始。

缓存雪崩

缓存层承载着大量的请求,一方面提高了系统的读写能力,另一方面也起到了保护存储层的作用。但是当出现以下几种情况的时候,大量的请求会直接穿透到后端数据库,给存储层造成巨大压力,甚至会直接把存储层打挂,导致系统整体不可用。

  • 大量缓存数据同时失效
  • 缓存层部分节点出现异常
  • 缓存容量预估不足,导致缓存被打爆

后果很严重,不过办法还是有的。

1. 保持缓存层高可用

在上一篇文章《一文弄懂Redis》,有专门讲到Redis高可用的实现,包括Sentinel和集群模式,有兴趣的同学可以去看一下。

2. 熔断、降级

在高并发的分布式系统中,当某个资源出现异常时,访问该资源的服务进程很容易会因为阻塞而出现问题,而且一个环节出问题容易引发连锁反应。缓存层、存储层以及下游服务等组件,都可以视为这样的资源。

在分布式系统,当某种资源不可用时,采取熔断、降级的措施会很有用,虽然简单粗暴了点,但是关键时刻能保命。线上曾经出现过这样一个故障,某个基础服务的存储层出了异常,导致该基础服务各接口延迟增大,几乎是同时,有七八个一级服务都出现了大量接口超时失败的告警,个别服务进程由于大量接口阻塞而引起协程堆积,并进一步导致内存暴涨,最后触发OOM,就这样,一个基础服务存储层的异常导致了APP级的故障。

3. 优化缓存过期时间

一般可以用随机化的方法对key进行散列,可避免大量key的过期时间过于集中。

4. 回源限流

避免大量的请求同时回源,重建缓存,可以使用全局的限流算法进行控制,比如令牌桶、漏桶算法。也可以使用异步重建缓存的方式,比如使用专门的线程池来重建缓存,因为线程池的大小是固定的,这样也能起到限流的作用。

缓存穿透

当请求的数据在缓存和存储层中都不存在时,这些请求每次都必然落到存储层,这就是所谓的缓存穿透。解决方法主要有两种:

1. 缓存空数据

这个方法的优点是实现起来非常简单,缺点是需要占用比较多的缓存空间,尤其是当发生恶意攻击,空数据会更多。一般我们可以通过设置较小的TTL来解决,但是更短的TTL也意味着更频繁的回源。

2. BloomFilter

布隆过滤器,一种非常巧妙的算法,它由一系列hash函数和一个超大的bitmap来实现。可用于检测某个元素是否在集合中,优点是时间和空间效率都非常好,缺点是有一定的误判率,增加额外的代码维度量。

比如已知集合{A,B,C},有一个hash函数组{h1, h2, h3}。首先是初始化,具体方法是,遍历集合,利用hash函数组将各hash函数映射的bit置为1。那怎么判断元素W的存在性呢?很简单,用hash函数组跑一遍,如果每个hash值命中的bit都是1,那么认为w就存在于集合中,显然由于hash冲突,这样的判定会有一定的误差,而且整个bitmap的装载因子越大,误差也就会越大。另外,一般来说,BF只能增加元素,不宜删除元素,适用于数据集相对固定的场景。

缓存击穿

描述的场景:热点key失效引起大量的请求同时回源并更新缓存,一方面是给存储层加重负载,另一方面效率低下。

解决方案:一般是对每个key的回源做归并处理,不管这个key同时有多少个请求,只回源一次。具体做法有很多,比如,可以给每个需要回源的key分配一个Mutex,拿到Mutex的线程方可回源,其它的线程全部wait,直到回源线程释放锁并唤醒。

缓存无底洞

Facebook的工作人员反应2010年已达到3000个memcached节点,储存数千G的缓存。他们发现一个问题–memcached的连接效率下降了,于是添加memcached节点,添加完之后,并没有好转。称为“无底洞”现象。

这个问题在网上有很多文章都进行了详尽的分析,这里再简单描述下:

  • 起因

(1)缓存key通过hash进行分片,实例越多分散的越厉害,造成key的分布与业务无关

(2)业务进程发起批量查询,如mget,需要从多个实例操作,产生多次网络IO

  • 危害

(1)网络IO增加,业务进程延迟增大

(2)缓存节点连接增多,处理效率降低

  • 解决方案

(1)串行IO,改批量为依次逐个查询,编程简单,但整体延迟较大

(2)并行IO,每个key独立请求,整体延迟取决于最慢的节点,但编程较为复杂

(3)使用hashTag分片,业务进程将批量查询的key预先用tag模式定义,这样就可以保证全部落到同一个实例。比如"{rank}Day1","{rank}Day2","{rank}Day3"...这样的一批key就会散列到同一个实例上。

到这里,缓存系统设计中遇到的几个核心问题,就讲完了。

参考资料