高性能架构设计-高性能缓存

一、缓存基础

1.缓存简介

缓存提升性能的幅度,不只取决于存储介质的速度,还取决于缓存命中率。为了提高命中 率,缓存会基于时间、空间两个维度更新数据。在时间上可以采用 LRU、FIFO 等算法淘汰 数据,而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理 算法,就很容易忽略业务特性,从而导致缓存性能的下降。

(1)命中率
命中率=命中数/(命中数+没有命中数)

当某个请求能够通过访问缓存而得到响应时,称为缓存命中。缓存命中率越高,缓存的利用率也就越高。
 

(2)最大空间

缓存中可以容纳最大元素的数量。当缓存存放的数据超过最大空间时,就需要根据淘汰算法来淘汰部分数据存放新到达的数据。

⼤容量缓存是能带来性能加速的 收益,但是成本也会更⾼,⽽⼩容量缓存不⼀定就起不到加速访问的效果。⼀般来说,建议把缓存容量 设置为总数据量的15%到30%,兼顾访问性能和内存空间开销

(3)淘汰算法

缓存的存储空间有限制,当缓存空间被用满时,如何保证在稳定服务的同时 有效提升命中率?这就由缓存淘汰算法来处理,设计适合自身数据特征的淘汰算法能够有效提升缓存命中率。常见的淘汰算法有:

  • FIFO(first in first out)「先进先出」。最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。「适用于保证高频数据有效性场景,优先保障最新数据可用」
  • LFU(less frequently used)「最少使用」,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。「适用于保证高频数据有效性场景」
  • LRU(least recently used)「最近最少使用」,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。当遇到爬虫时,缓存的数据变成非热点数据。「比较适用于热点数据场景,优先保证热点数据的有效性。」

LRU策略更加关注数据的时效性,⽽LFU策略更加关注数据的访问频次。
 

(4)缓存的使用场景
  • 经常需要读取的数据
  • 频繁访问的数据 热点数据缓存
  • IO 瓶颈数据
  • 计算昂贵的数据
  • 无需实时更新的数据
  • 缓存的目的是减少对后端服务的访问,降低后端服务的压力

(5)缓存粒度控制

缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数据通用性、空间占用比、代码维护性三点进行取舍。

对于缓存的使用,需要对其存储的内容和数量严格限制,并对大小进行估算,通过文档进行维护。


 

2.缓存如何提高性能

缓存指的将数据存储在相对较高访问速度的存储介质中,以供系统处理。内存是半导体元件。对于内存而言,只要给出了内存地址,就可以直接访问该地址取出 数据。内存的访问速度很快但价格昂贵。而磁盘是机械器件。磁盘访问数据时,需要等磁盘盘片旋转到磁头下,才能读取相应的数 据。尽管磁盘的旋转速度很快,但是和内存的随机访问相比,性能差距非常大。一般来说,如果是随机读写,会有 10 万到 100 万倍左右的差距。但如果是顺序访问 大批量数据的话,磁盘的性能和内存就是一个数量级的。磁盘的最小读写单位是扇区,目前常见的磁盘扇区是 4K 个字节。操作系统一次会读写多个扇区,所以操作系统的最小读 写单位是块(Block),也叫作簇(Cluster)。当要从磁盘中读取一个数据时,操作系 统会一次性将整个块都读出来。因此,对于大批量的顺序读写来说,磁盘的效率会比随机读 写高许多。

一方面缓存访问速度快,可以减少数据访问的时间,另一方面如果缓存的数据是经过计算的处理得到的,那么被缓存的数据无需重复计算即可直接使用,因此缓存还起到减少计算时间的作用。例如,一个论坛需要在首页展示当前有多少用户同时在线,如果使用 MySQL 来存储当前用户状态,则每次获取这个总数都要“count(*)”大量数据,这样的操作无论怎么优化 MySQL,性能都不会太高。如果要实时展示用户同时在线数,则 MySQL 性能无法支撑。

缓存的本质是一个内存的Hash表,网站应用中,数据缓存以一对key、value的形式存储在内存Hash表中。Hash表数据读写的时间复杂度为O(1)。缓存主要用来存放读多写少、很少变化的数据,比如商品的类目信息,热门词的搜索列表信息,热门商品信息等。应用程序读取数据,先到缓存中读取,如果读取不到或数据已失效,再访问数据库并将数据写入到缓存。

网站数据访问通常遵循二八定律(80%访问落在20%数据上),因此利用Hash表和内存的高速访问特性,将这20%的数据缓存起来,可很好改善系统性能,提高数据读取速度,降低存储访问压力,提高吞吐量。以微博为例:一个明星发一条微博,可能几千万人来浏览。如果使用 MySQL 来存储微博,用户写微博只有一条 insert 语句,但每个用户浏览时都要 select 一次,即使有索引,几千万条 select 语句对 MySQL 数据库的压力也会非常大。

获取缓存的时候千万不用通过服务调用获取缓存,调用服务花费的时间远远大于获取缓存,这样意义不大


 

3.合理使用缓存

①频繁修改的数据不应该使用缓存

当数据的读写的比例很大的时候才推荐使用缓存。

②对于访问频率低的数据不应该使用缓存

因为缓存以内存为存储,内存资源宝贵,不可能将所有数据都进行缓存。

数据一致性要求的访问不应该使用缓存

一般会对缓存设置过期时间,而当缓存没到过期时间更新了数据这时可能出现数据不一致与脏读

缓存往往针对的是“资源”,当某一个操作是“幂等”的和“安全”的,那么这样的操作就可以被抽象为对“资源”的获取操作,那么它才可以考虑被缓存。有些操作不幂等、不安全,比银行转账

缓存是为了解决“开销”的问题

这个开销,可不只有时间的开销。虽然我们在很多情况下讲的开销,确实都是在时间维度上的,但它还可以是 CPU、网络、I/O 等一切资源。所以缓存的目的不仅仅是为了让系统速度更快

⑤写数据库策略

对于读写缓存来说,如果要对数据进⾏增删改,就需要在缓存中进⾏,同时还要根据采取的写回策略,决定是否同步写回到数据库中。

  • 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据⼀致;
  • 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使⽤这种策略时,

如果数据还没有写回数据库,缓存就发⽣了故障,那么,此时,数据库就没有最新的数据了。

所以,对于读写缓存来说,要想保证缓存和数据库中的数据⼀致,就要采⽤同步直写策略。不过,需要注意的是,如果采⽤这种策略,就需要同时更新缓存和数据库。所以要在业务应⽤中使⽤事务机制,来保证缓存和数据库的更新具有原⼦性,也就是说,两者要不⼀起更新,要不都不更新,返回错误信息,进⾏重试。否则就⽆法实现同步直写。

当然,在有些场景下,我们对数据⼀致性的要求可能不是那么⾼,⽐如说缓存的是电商商品的⾮关键属性或者短视频的创建或修改时间等,那么可以使⽤异步写回策略。


 

二、缓存带来的复杂性

缓存避免在高峰刷新,避免连接数占满

1.数据库缓存数据一致性——最终一致性

(1)缓存先后删除问题

不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。

先删除缓存

  • 如果先删除Redis缓存数据,然而还没有来得及写入MySQL,另一个线程就来读取
  • 这个时候发现缓存为空,则去Mysql数据库中读取旧数据写入缓存,此时缓存中为脏数据。
  • 然后数据库更新后发现Redis和Mysql出现了数据不一致的问题

后删除缓存

  • 如果先写了库,然后再删除缓存,不幸的写库的线程挂了,导致了缓存没有删除
  • 这个时候就会直接读取旧缓存,最终也导致了数据不一致情况
  • 因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题
(2)延时双删策略

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。

  • 先删除缓存
  • 再写数据库
  • 休眠500毫秒(时间的控制是玄学)
  • 再次删除缓存

public void write( String key, Object data ){ redis.delKey( key ); db.updateData( data ); Thread.sleep( 500 ); redis.delKey( key ); }b

问题:这个500毫秒怎么确定的,具体该休眠多久时间呢?

  • 需要评估自己的项目的读数据业务逻辑的耗时。
  • 这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
  • 当然这种策略还要考虑redis和数据库主从同步的耗时。
  • 最后的的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百ms即可。比如:休眠1秒。

设置缓存过期时间是关键点

  • 理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案
  • 所有的写操作以数据库为准,只要到达缓存过期时间,缓存删除
  • 如果后面还有读请求的话,就会从数据库中读取新值然后回填缓存

方案缺点

结合双删策略+缓存超时设置,这样最差的情况就是:

  • 在缓存过期时间内发生数据存在不一致
  • 同时又增加了写请求的耗时。
(3)异步更新缓存(基于Mysql binlog的同步机制)

可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中。当应⽤没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进⾏删除或更新。如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时也可以保证数据库和缓存的数据⼀致了。否则的话,还需要再次进⾏重试。如果重试超过的⼀定次数,还是没有成功,就需要向业务层发送报错信息了。

  • 涉及到更新的数据操作,利用Mysql binlog 进行增量订阅消费
  • 将消息发送到消息队列
  • 通过消息队列消费将增量数据更新到Redis上
  • 操作情况
    • 读取Redis缓存:热数据都在Redis上
    • 写Mysql:增删改都是在Mysql进行操作
    • 更新Redis数据:Mysql的数据操作都记录到binlog,通过消息队列及时更新到Redis上

2.缓存穿透(少量可接受)

对于像电商中的商品系统、搜索系统这类与用户关联不大的系统,基本不会产生

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

(1)被访问的存储数据不存在

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

通常情况下,业务上读取不存在的数据的请求量并不会太大,但如果出现一些异常情况,例如被黑客攻击,故意大量访问某些读取不存在数据的业务,有可能会将存储系统拖垮。

  • 缓存空值(推荐),如果查询存储系统的数据没有找到,则直接设置一个默认值(可以是空值,也可以是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存储系统。但是如果后续这个请求有新值了需要把原来缓存的空值删除掉(所以一般过期时间可以稍微设置的比较短)。
  • 通过布隆过滤器。查询缓存之前先去布隆过滤器查询下这个数据是否存在。如果数据不存在,然后直接返回空。这样的话也会减少底层系统的查询压力。
  • 缓存没有直接返回。这种方式的话要根据自己的实际业务来进行选择。比如固定的数据,一些省份信息或者城市信息,可以全部缓存起来。这样的话数据有变化的情况,缓存也需要跟着变化。实现起来可能比较复杂。


 

(2)缓存数据生成耗费大量时间或者资源

如果刚好在业务访问的时候缓存失效了,那么也会出现缓存没有发挥作用,访问压力全部集中在存储系统上的情况。

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

具体的场景有:

分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。

通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。

竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。

这种情况并没有太好的解决方案,因为爬虫会遍历所有的数据,而且什么时候来爬取也是不确定的,可能是每天都来,也可能是每周,也可能是一个月来一次,我们也不可能为了应对爬虫而将所有数据永久缓存。通常的应对方案要么就是识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。


 

3.缓存雪崩

缓存雪崩是指当缓存失效(过期)后引起系统性能急剧下降的情况。当缓存过期被清除后,业务系统需要重新生成缓存,因此需要再次访问存储系统,再次进行运算,这个处理步骤耗时几十毫秒甚至上百毫秒。而对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求。由于旧的缓存已经被清除,新的缓存还未生成,并且处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,都会去访问存储系统,从而对存储系统造成巨大的性能压力。这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

(1)更新锁

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

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

(2)后台更新

由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存。后台定时机制需要考虑一种特殊的场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。解决的方式有两种:

  • 后台线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但读取时间间隔不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验一般。
  • 业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,后台线程收到消息后更新缓存前可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖消息队列,复杂度会高一些,但缓存更新更及时,用户体验更好。

后台更新既适应单机多线程的场景,也适合分布式集群的场景,相比更新锁机制要简单一些。后台更新机制还适合业务刚上线的时候进行缓存预热。缓存预热指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。

(3)灰度发布

当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。

(4)多级缓存

不同级别缓存时间过时时间不一样,即使某个级别缓存过期了,还有其他缓存级别 兜底。比如我们Redis缓存过期了,还有本地缓存。这样的话即使没有命中redis,有可能会命中本地缓存。


 

4.缓存击穿

缓存击穿是指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db,属于常见的“热点”问题。这个的话可以用缓存雪崩的几种解决方法来避免:

  • 缓存永不过期。Redis中保存的key永久不失效,这样的话就不会出现大量缓存同时失效的问题,但是这种做法会浪费更多的存储空间,一般应该也不会推荐这种做法。
  • 异步重建缓存。这样的话需要维护每个key的过期时间,定时去轮询这些key的过期时间。例如一个key的value设置的过期时间是30min,那我们可以为这个key设置它自己的一个过期时间为20min。所以当这个key到了20min的时候我们就可以重新去构建这个key的缓存,同时也更新这个key的一个过期时间。
  • 互斥锁重建缓存。这种情况的话只能针对于同一个key的情况下,比如你有100个并发请求都要来取A的缓存,这时候可以借助redis分布式锁来构建缓存,让只有一个请求可以去查询DB其他99个(没有获取到锁)都在外面等着,等A查询到数据并且把缓存构建好之后其他99个请求都只需要从缓存取就好了。原理就跟我们java的DCL(double checked locking)思想有点类似。


 

5.缓存热点

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

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

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

(1)如何识别热点key

  • 凭经验判断哪些是热Key;
  • 客户端统计上报;
  • 服务代理层上报

(2)如何解决热key问题?

  • Redis集群扩容:增加分片副本,均衡读流量;
  • 将热key分散到不同的服务器中;
  • 使用二级缓存,即JVM本地缓存,减少Redis的读请求。

(3)热点key重建优化

使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

  • 减少重建缓存的次数
  • 数据尽可能一致。
  • 较少的潜在危险
  • 互斥锁:此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如图所示。
  • 永远不过期
    • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
    • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

(4)拦截非法的查询请求

可以使用验证码、IP限制等手段限制恶意攻击,并用敏感词过滤器等拦截不合理的非法查询。
 

6.无底洞优化

为了满足业务需要可能会添加大量新的缓存节点,但是发现性能不但没有好转反而下降了。用一句通俗的话解释就是,更多的节点不代表更高的性能,所谓“无底洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式缓存中批量操作是一个难点。

无底洞问题分析:

①客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。

②网络连接数变多,对节点的性能也有一定影响。

如何在分布式条件下优化批量操作?我们来看一下常见的IO优化思路:

  • 命令本身的优化,例如优化SQL语句等。
  • 减少网络通信次数。
  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

这里我们假设命令、客户端连接已经为最优,重点讨论减少网络操作次数。下面我们将结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明。

①串行命令:由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实现起来比较简单。

②串行IO:Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以算出slot值,同时Smart客户端会保存slot和节点的对应关系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间=node次网络时间+n次命令时间,网络次数是node的个数,整个过程如下图所示,很明显这种方案比第一种要好很多,但是如果节点数太多,还是有一定的性能问题。

③并行IO:此方案是将方案2中的最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为O(1),这种方案会增加编程的复杂度。

④hash_tag实现:Redis Cluster的hash_tag功能,它可以将多个key强制分配到一个节点上,它的操作时间=1次网络时间+n次命令时间。

三、缓存应用模式

写后立刻读,脏数据库入缓存:在主从数据库同步完成之前,如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。(此时应该避免写后立刻读)

1.Cache-Aside(解决了并发数据脏读问题)

数据获取策略:大多数缓存,比如拦截过滤器中的缓存,基本上都是按照这种方式来配置和使用的。

①数据读取情形

  • 应用先去查看缓存是否有所需数据;
  • 如果有,应用直接将缓存数据返回给请求方;
  • 如果没有,应用执行原始逻辑,例如查询数据库得到结果数据;
  • 应用将结果数据写入缓存。


 

②数据更新策略:

  • 应用先更新数据库;
  • 应用再令缓存失效(不论数据库是否更新成功)

数据更新的这个策略,通常来说,最重要的一点是必须先更新数据库,而不是先令缓存失效,即这个顺序不能倒过来。原因在于,如果先令缓存失效,那么在数据库更新成功前,如果有另外一个请求访问了缓存,发现缓存数据库已经失效,于是就会按照数据获取策略,从数据库中使用这个已经陈旧的数值去更新缓存中的数据,这就导致这个过期的数据会长期存在于缓存中,最终导致数据不一致的严重问题。

数据库更新以后,需要令缓存失效,而不是更新缓存为数据库的最新值。

如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B,如果结果是 B 的更新晚于 A,那么数据库中的最终值是 B。但是,如果在数据库更新后去更新缓存,而不是令缓存失效,那么缓存中的数据就有可能是 A,而不是 B。因为数据库虽然是“更新为 A”在“更新为 B”之前发生,但如果不做特殊的跨存储系统的事务控制,缓存的更新顺序就未必会遵从“A 先于 B”这个规则,这就会导致这个缓存中的数据会是一个长期错误的值 A。

如果是更新缓存为数据库最新值,而不是令缓存失效,为什么会产生问题:

如果是令缓存失效,这个问题就消失了。因为 B 是后写入数据库的,那么在 B 写入数据库以后,无论是写入 B 的请求让缓存失效,还是并发的竞争情形下写入 A 的请求让缓存失效,缓存反正都是失效了。那么下一次的访问就会从数据库中取得最新的值,并写入缓存,这个值就一定是 B。

虽然说catch aside可以被称之为缓存使用的最佳实践,但与此同时,它引入了缓存的命中率降低的问题,(每次都删除缓存自然导致更不容易命中了),因此它更适用于对缓存命中率要求并不是特别高的场景。如果要求较高的缓存命中率,依然需要采用更新数据库后同时更新缓存的方案。在更新数据库后同时更新缓存,会在并发的场景下出现数据不一致,那我们该怎么规避呢?方案也有两种。

  • 引入分布式锁。在更新缓存之前尝试获取锁,如果已经被占用就先阻塞住线程,等待其他线程释放锁后再尝试更新。但这会影响并发操作的性能。
  • 设置较短缓存时间。设置较短的缓存过期时间能够使得数据不一致问题存在的时间也比较长,对业务的影响相对较小。但是与此同时,其实这也使得缓存命中率降低,又回到了前面的问题里...

2.Read-Through

这种情况下缓存系统彻底变成了它身后数据库的代理,二者成为了一个整体,应用的请求访问只能看到缓存的返回数据,而数据库系统对它是透明的。

有的框架提供的内置缓存,例如一些 ORM 框架,就是按这种 Read-Through 和 Write-Through 来实现的。

数据获取策略

  • 应用向缓存要求数据;
  • 如果缓存中有数据,返回给应用,应用再将数据返回;
  • 如果没有,缓存查询数据库,并将结果写入自己;
  • 缓存将数据返回给应用。


 

3.Write-Through

和 Read-Through 类似,但 Write-Through 是用来处理数据更新的场景。

数据更新策略:

  • 应用更新数据库成功;
  • 如果缓存中有对应数据,先更新该数据;
  • 缓存告知应用更新完成。

缓存系统需要自己内部保证并发场景下,缓存更新的顺序要和数据库更新的顺序一致。比如说,两个请求分别要把数据更新为 A 和 B,那么如果 B 后写入数据库,缓存中最后的结果也必须是 B。这个一致性可以用乐观锁等方式来保证。


 

4.Write-Back

对于 Write-Back 模式来说,更新操作发生的时候,数据写入缓存之后就立即返回了,而数据库的更新异步完成。这种模式在一些分布式系统中很常见。

这种方式带来的最大好处是拥有最大的请求吞吐量,并且操作非常迅速,数据库的更新甚至可以批量进行,因而拥有杰出的更新效率以及稳定的速率,这个缓存就像是一个写入的缓冲,可以平滑访问尖峰。另外,对于存在数据库短时间无法访问的问题,它也能够很好地处理。

但是它的弊端也很明显,异步更新一定会存在着不可避免的一致性问题,并且也存在着数据丢失的风险(数据写入缓存但还未入库时,如果宕机了,那么这些数据就丢失了)。


 

四、缓存方式

1.本地缓存/进程缓存(同一进程)

本地缓存的话是应用和缓存都在同一个进程里面,获取缓存数据的时候纯内存操作,没有额外的网络开销,速度非常快。它适用于缓存一些应用中基本不会变化的数据,比如(国家、省份、城市等)。

本地缓存与业务系统耦合在一起,应用之间无法直接共享缓存的内容。需要每个应用节点单独的维护自己的缓存。每个节点都需要一份一样的缓存,对服务器内存造成一种浪费。本地缓存机器重启、或者宕机都会丢失。

(1)一致性问题解决

第一种方案,可以通过单节点通知其他节点。这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一起,特别是节点较多时,网状连接关系极其复杂。

第二种方案,可以通过MQ通知其他节点。这种方案虽然解除了节点之间的耦合,但引入了MQ,使得系统更加复杂。

前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。

第三种方案,为了避免耦合,降低复杂性,干脆放弃了“实时一致性”,每个节点启动一个timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其他节点通过timer更新数据之间,会读到脏数据。

(2)分类

  • Guava:由Google团队开源的Java核心增强库,涵盖集合、并发原语、缓存、IO、反射等多种工具。其性能和稳定性有保障,广泛应用于各类项目中。
  • Caffeine:基于Java 8实现的新一代缓存工具,可视作Guava Cache的升级版。虽然功能上两者相似,但Caffeine在性能表现和命中率上全面超越Guava Cache,卓越的表现令人瞩目。
  • Ehcache:一个纯Java的进程内缓存框架,以其快速和轻量著称。同时,它支持内存和磁盘的二级缓存,让它在功能上更加丰富,且扩展性极强。


 

(3)什么时候使用——尽量不用

情况一,只读数据,可以考虑在进程启动时加载到内存。

此时也可以把数据加载到redis / memcache,进程外缓存服务也能解决这类问题。

情况二,极其高并发的,如果透传后端压力极大的场景,可以考虑使用进程内缓存。

例如,秒杀业务,并发量极高,需要站点层挡住流量,可以使用内存缓存。

情况三,一定程度上允许数据不一致业务。

例如,有一些计数场景,运营场景,页面对数据一致性要求较低,可以考虑使用进程内页面缓存。


 

2.客户端缓存

  • 页面缓存:页面自身对某些元素进行缓存、服务端将静态页面或者动态页面进行缓存给客户端使用
  • 浏览器端缓存:将服务器的资源缓存到本地从而减轻服务器的负担,加快加载速度
  • App缓存


 

3.服务端缓存(分布式缓存)

redis天然支持高可用,memcache要想要实现高可用,需要进行二次开发,不过缓存不一定需要实现高可用缓存场景,很多时候,是允许cache miss;缓存挂了,很多时候可以通过DB读取数据

(1)不同种类的服务端缓存

①redis

有持久化需求或者对数据结构和处理有高级要求的应用

适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统(如新浪微博的计数和微博发布部分系统,对数据安全性、读写要求都很高)

②memcache

纯KV,数据量非常大,并发量非常大的业务,使用memcache或许更适合。更适合存储一些配置信息

动态系统中减轻数据库负载,提升性能;做缓存,适合多读少写,大数据量的情况,value最大1m(如人人网大量查询用户信息、好友信息、文章信息等)
 

③Tair

单节点的性能比较方面,redis是性能比tair高大概1/5

在分布式集群支持方面tair支持副本,支持多种集群结构,如:一机房一个集群、双 机房单集群单份、双机房独立集群、双机房单集群双份、双机房主备集群;


(2)底层实现机制

①内存分配

memcache使用预分配内存池的方式管理内存,能够省去内存分配时间。

redis则是临时申请空间,可能导致碎片。

从这一点上,mc会更快一些。

②虚拟内存使用

memcache把所有的数据存储在物理内存里。

redis有自己的VM机制,理论上能够存储比物理内存更多的数据,当数据超量时,会引发swap,把冷数据刷到磁盘上。

从这一点上,数据量大时,mc会更快一些。

③网络模型

memcache使用非阻塞IO复用模型,redis也是使用非阻塞IO复用模型。

但由于redis还提供一些非KV存储之外的排序,聚合功能,在执行这些功能时,复杂的CPU计算,会阻塞整个IO调度。

从这一点上,由于redis提供的功能较多,mc会更快一些。

④线程模型

memcache使用多线程,主线程监听,worker子线程接受请求,执行读写,这个过程中,可能存在锁冲突。

redis使用单线程,虽无锁冲突,但难以利用多核的特性提升整体吞吐量。

从这一点上,mc会快一些。

(3)对比选择

  • 性能上:
    • Memcached单个key-value大小有限,一个value最大只支持1MB,而Redis最大支持512MB。
    • 在100k以上的数据中,Memcached性能要高于Redis。


 

  • 内存空间和数据量大小:
    • MemCached可以修改最大内存,采用LRU算法。
    • Redis增加了VM的特性,突破了物理内存的限制。
  • 操作便利上:
    • MemCached数据结构单一,仅用来缓存数据。
    • 而Redis支持更加丰富的数据类型,也可以在服务器端直接对数据进行丰富的操作,这样可以减少网络IO次数和数据体积。
  • 可靠性上:
    • MemCached不支持数据持久化,断电或重启后数据消失,但其稳定性是有保证的。
    • Redis支持数据持久化和数据恢复,允许单点故障,但是同时也会付出性能的代价。
  • 应用场景:
    • Memcached:动态系统中减轻数据库负载,提升性能;做缓存,适合多读少写,大数据量的情况(如人人网大量查询用户信息、好友信息、文章信息等)。
    • Redis:适用于对读写效率要求都很高,数据处理业务复杂和对安全性要求较高的系统(如新浪微博的计数和微博发布部分系统,对数据安全性、读写要求都很高)。

4.多级缓存

将本地缓存改成分布式缓存有效解决了缓存不一致问题和单机容量限制问题。但是,如果在系统中频繁地使用分布式缓存,网络IO交互次数的增加,可能并不能达到性能提成的目的。因此,我们可以将本地缓存与集中式缓存结合起来使用,取长补短,实现效果最大化。

  • 对于一些变更频率比较高的数据,采用集中式缓存,这样能够确保所有节点在数据变化后实时更新,从而保证数据的一致性。
  • 对于一些极少变更的数据(如系统配置项)或者是一些对短期一致性要求不高的数据(如用户昵称、签名等)则采用本地缓存,可以显著降低对远程集中式缓存的网络IO次数,提高系统性能。

5.缓存的误用

(1)把缓存作为服务与服务之间传递数据的媒介
  • 服务1和服务2约定好key和value,通过缓存传递数据
  • 服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通信的目的

该方案存在的问题是:

  • 数据管道,数据通知场景,MQ更加适合
  • 多个服务关联同一个缓存实例,会导致服务耦合
(2)使用缓存未考虑雪崩
  • 服务先读缓存,缓存命中则返回
  • 缓存不命中,再读数据库

提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。否则,就要进一步设计。

常见方案一:高可用缓存

使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。

常见方案二:缓存水平切分

使用缓存水平切分,一个缓存实例挂掉后,不至于所有的流量都压到数据库上。

(3)调用方缓存数据
  • 服务提供方缓存,向调用方屏蔽数据获取的复杂性(√)
  • 服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(×)

该方案存在的问题是:

  • 调用方需要关注数据获取的复杂性
  • 更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致
  • 或许服务可以通过MQ通知调用方淘汰数据,但是下游的服务不应该依赖上游的调用方
     
(4)多服务共用缓存实例

该方案存在的问题是:

  • 可能导致key冲突,彼此冲掉对方的数据
  • 不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去
  • 共用一个实例,会导致服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的

(5)将缓存当数据库

虽然一些缓存比如redis支持持久化,但其本质上依旧是不稳定的,所以不能只将数据存储到缓存中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值