引言
缓存在高并发系统的重要性不言而喻,通常会用来存储热点数据,减轻流量对存储层的压力,提升整个系统的性能。
那么我们在做缓存系统的架构设计时,需要考虑哪些问题呢?
缓存执行流程
从读数据的流程来看,套路总是固定的:
- 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就会散列到同一个实例上。
到这里,缓存系统设计中遇到的几个核心问题,就讲完了。