谈谈缓存(上)

谈谈缓存(上)

最近看了一些关于缓存的东西,七零八落的,所以在这里做个总结。一开始想着用一篇文章把缓存相关的内容都梳理下,写着写着发现要写的东西太多了,于是就把整篇文章拆成了上下两篇。在上篇中主要介绍缓存的一些概念和用法。下篇针对缓存使用中可能会遇到的一些问题给出解决方案。

缓存相信大家都不陌生了,比如浏览器、CDN、返向代理,等等。它们通过将一些资源(通常是静态资源)部署在离用户较近的地方,当用户再次请求这些内容时,就可以就近取材,从而达到提高用户访问速度的目的。而我们今天要说的是在服务器端开发中使用到的缓存技术。

在所有的网站架构演化的历程中,当网站遇到性能瓶颈时,无一例外的首要解决方案就是使用缓存。缓存之所以能够大大提高网站性能,原因在于两方面:

  1. 通过将数据存储在较高访问速度的存储介质上,可以减少数据访问的时间。

  2. 一些需要通过计算处理才能得到的数据,对这样的数据进行缓存,能够减少计算时间。

但是,缓存在帮助我们提高系统性能的同时,也给我们的业务带来了一定的复杂性,要用好缓存并不是一件容易的事情。在正式使用之前,我们首先要对缓存中涉及的几个概念有一定了解。

缓存的几个概念

命中率

如果采用公式来解释命中率,就是:命中率 = 命中数 /(命中数 + 没有命中数)。命中率越高,也就表明缓存的使用率越高。作为缓存的一个非常重要的参考指标,在生产环境下,通常就是通过这个指标来判断缓存是否工作良好。

缓存容量

缓存容量也就是缓存中最多能够存放多少的元素。当实际存放的大小超过这个值时,就需要将缓存中已有的一些数据清除掉,然后将新的数据加进来。缓存的容量一般有两种度量方式:

  1. 基于空间,也就是设置一个固定的存储空间,比如100M、200M这样。
  2. 基于容量,设置最多能够存储的条目是多少,比如最多缓存100条或1000条数据。

无论采用哪种方式,当要缓存的数据量远远大于缓存容量时,就会频繁的触发淘汰操作,这在一定程度上也影响到了命中率的大小。

淘汰算法

上面我们说了当缓存容量不够时,就要踢出一部分旧的数据,那具体哪些旧数据应该被踢出呢?这就是淘汰算法要做的事情了。不同的淘汰策略,对命中率也有一定的影响。常见的淘汰算法有下面三种:

  1. FIFO(First In First Out)

    先进先出算法,也就是最先放到缓存的数据最先被移除。

  2. LRU(Least Recently Used)

    最近最少使用算法,最后一次使用时间距离现在最久的那个数据最先被移除。

  3. LFU(Least Frequently Used)

    最不常用算法,就是以某段时间内的使用次数作为依据,将使用频率最低的那个数据移除。

在实际开发中,LRU算法应该是最常使用的。

过期策略

淘汰算法说的是当存储空间不够时,我们应该对数据做什么样的操作。因为缓存的容量是有限的,所以,即使是在缓存容量没满时,我们也要确定是希望放入缓存的数据永久有效还是在一段时间内有效。如果是在一段时间内有效,那么又会分为两种策略:

  1. TTL(Time To Live)

    存活期策略,缓存数据从创建开始,允许其在缓存中存活的时间。比如8点的时候创建了缓存,设定的缓存时间是1小时,那么到9点的时候该数据就会过期。

  2. TTI(Time To Idle)

    空闲期策略,即缓存数据多久没被访问后就被移除缓存的时间。还是以上面为例,8点的时候创建了缓存,如果一直没有访问,那么在9点的时候该数据就会过期。但假如在8点30分的时候有过一次访问,那么该数据就会延后到9点30分过期。如果一直有访问,那么该数据就一直不会过期。

缓存的类型

了解了上面介绍的几个概念后,就要做选型了,目前我们可以选择的缓存类型有很多,每个都有优点也有缺点,具体使用哪个,也要结合具体的业务综合分析。

大体上来看,可以将缓存分为本地缓存和分布式缓存,其中的每一类又可以做进一步的细分。我们先来看看本地缓存。

本地缓存

所谓本地缓存就是缓存和应用是在同一个进程里的,这类缓存因为没有网络开销,请求速度非常快。

以Java为例,我们可以定义Map<String, Object>类型的实例变量或是静态变量,这就是最简单的一种本地缓存实现。

public class SomeCacheUtils {
    private static Map<String, Object> someMap = new HashMap<>();
    
    // 省略一些方法 ...
}

这种方式因为跟应用共用Java的堆内存空间,所以能够缓存的数据量不会很大,并且还要考虑GC的问题,当堆内存不足时,要能够及时的回收这部分内存。在实际开发时,我们只要使用造好的轮子 - Gauva Cache就可以了。示例代码如下:

public class SomeCacheUtils {
    private static Cache<String, Object> someCache = CacheBuilder.newBuilder()
        .concurrencyLevel(5)
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .maximumSize(10000)
        .build();
    
    // 省略一些方法 ...
}

本地缓存使用起来方便,但容量有限,因此我们更倾向于缓存那些量小且变化不频繁的数据。比如一个购物网站的类别信息,就可以在系统启动的时候一次性加载到内存中。当数据发生变化时,我们一般有三种方式来进行缓存的更新:

  1. 重启服务,能解决问题,但是在生产环境非常不推荐这么做。

  2. 设置一个定时任务,按照一定的时间间隔去同步数据,但是需要忍受一定程度的不一致。

  3. 通过ZooKeeper主动推送变动,这也是最优的一种方案。

在实际操作时,还要注意多线程同步的问题,为了尽量降低对线上服务的影响,一般会使用读写锁,在更新的数据准备完毕,即将进行缓存更新时,这个时候再加上一把写锁,而不是先加锁再去准备数据。

分布式缓存

本地缓存除了存在单机容量的问题,在集群多节点的时候还存在数据一致性的问题,也就是在某一个时间点,节点A和节点B上缓存的数据是不同的。此外,本地缓存的另一个问题是,当缓存没有命中时,就要回源到数据库,由于集群中每个实例缓存的数据是一样的,也就意味着每个实例都要回源,这无形中增大了数据库的访问压力。

分布式缓存就很好的解决了这几个问题。

常用的分布式缓存包括Memcache和Redis,在实际应用中,根据具体的业务可以选择其中之一或混合使用。

Memcache

memcache只支持基础的key-value键值对类型的数据存储,在启动的时候,我们可以通过 -m 参数来指定可用内存大小。但是在启动后,并不是立马分配出去指定大小的内存,而是在有需要的时候再申请。每次申请的大小是 1M,我们称为一个 slab,在这一个slab中,又被平均的划分为了若干个 chunk。每个chunk中都保存了一个item结构体、一对key和value。

虽然在同一个slab中chunk的大小是相等的,但是在不同的slab中chunk的大小并不一定相等。按照chunk大小的不同,可以把slab分为很多种类(class),默认情况下memcached把slab分为40类(class1~class40),在class 1中,chunk的大小为80字节,由于一个slab的大小是固定的1048576字节(1M),因此在class1中最多可以有13107个chunk(也就是这个slab能存最多13107个小于80字节的key-value数据)。

除此以外,memcache的内存管理还采取了预分配方式,也就是向memcache中添加一个item时候,它首先会根据item的大小,来选择最合适的slab class。假如item的大小为190字节,默认情况下class 4的chunk大小为160字节显然不合适,class 5的chunk大小为200字节,大于190字节,因此该item将放在class 5中(显然这里会有10字节的浪费是不可避免的)。计算好所要放入的chunk之后,memcache会再去检查该类大小的chunk还有没有空闲的,如果没有,将会申请1M(1个slab)的空间并划分为该种类chunk。

我们在使用memcache时,通常需要注意以下几点:

  1. 对于key-value信息,最好不要超过1m的大小。

  2. 信息长度最好相对是比较均衡稳定的,这样能够保障最大限度的使用内存。

  3. memcached采用的LRU清理策略,合理设置过期时间,提高命中率。

Redis

Redis是一个内存型数据库,最大的特点是除了能够存储键值对外,还能够存储5种(string、hash、list、set、sorted set)不同类型的值,并且具有持久化到磁盘的能力。

另外,Redis使用了单线程的I/O复用模型,并且自己封装了一个简单的AeEvent事件处理框架。对于单纯只有I/O操作来说,单线程可以将速度优势发挥到最大,但是对于一些计算场景,单线程模型实际会严重影响整体吞吐量,需要我们特别注意。

由于Redis丰富的数据结构支持,所以它与memcache相比,具有更广的应用场景,比如下面几个场景:

  1. 排行榜及相关问题

    排行榜(leader board)按照得分进行排序。ZADD命令可以直接实现这个功能,而ZREVRANGE命令可以用来按照得分来获取前100名的用户,ZRANK可以用来获取用户排名。

  2. 计数问题

    进行各种数据统计的用途是非常广泛的,比如想知道什么时候封锁一个IP地址。INCRBY命令通过原子递增保持计数;GETSET用来重置计数器;过期属性用来确认一个关键字什么时候应该删除。

  3. Pub/Sub

    Redis的pub/sub功能提供了SUBSCRIBE、UNSUBSCRIBE和PUBLISH命令,它们被设计的非常轻量,发布的消息发送后就消失了,redis不会对其持久化,消息订阅者也将只能得到订阅之后的消息,通道中此前的消息将无从获得。

缓存的使用模式

在选定了缓存类型,确定了缓存容量、淘汰算法以及过期策略后,就要正式使用了。具体怎么用呢,这里面就又涉及到几个问题,比如我们是先操作数据库还是先操作缓存呢?是应该更新缓存还是淘汰缓存呢?

从大的方向上说,根据关注点的不同,可以将使用模式分为Cache-AsideCache-As-SoR,这里的SoR(system-of-record)我们可以简单理解成数据库。Cache-Aside就是业务围绕缓存写,由业务代码直接维护缓存。而Cache-As-SoR则恰恰相反,业务所有的操作都是对Cache进行的,然后Cache再委托SoR进行真实的读写。其中,Cache-As-SoR 又可以细分为 Read-throughWrite-throughWrite-behind 三种。

Cache-Aside

我们分别在读场景和写场景下讨论该种模式的处理流程。

读场景

首先,从缓存中读取数据,如果没有命中,则回源到DB。之后,再将数据放入缓存,供下次读取使用。用伪代码表示如下:

val = cache.getIfPresent(key)
if val is null
	val = loadFromDb(key)
	cache.put(key, val)
return val

整体上来说,读场景下的操作还是比较简单的,而写场景下的操作就会相对复杂一些了。

写场景

在这里,我们先抛出两个问题:

  1. 应该更新缓存还是淘汰缓存?
  2. 先操作数据库还是先操作缓存?

针对第一个问题,两者的区别是,淘汰缓存会增加一次缓存不命中,但假如更新缓存需要做复杂的计算,那么我们更倾向于使用操作简单的后者,因为增加一次缓存不命中的副作用很小。因此,我们可以 将过期缓存作为一种通用的处理方式

对于第二个问题,假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现数据库中是新数据,Cache中是旧数据,从而导致数据不一致。

假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则会引发一次Cache miss。

貌似第二种方式要好一些。

但是,我们知道,在分布式环境下,数据的读写都是并发的,在数据库层面并发的读写并不能保证完成顺序。因此,可能的一种情况是,淘汰缓存之后,还没有写库完成之前,另外一个线程读出了脏数据,脏数据又入了缓存,此时出现了缓存与数据库中的数据不一致的问题。

解决这个问题的一种方案是串行化,根据id映射到具体的某个服务,再将这个服务内的数据库连接池修改为按id取连接。是不是听起来就很复杂,而且如果这个服务宕掉了,映射关系是不是也要一起改呢?我个人并不是很认可使用这种方案,换个角度来想,从缓存维度的角度看,可以将缓存分为两种:

  1. 用户维度的缓存,比如用户信息,大多数场景都是只有他自己在操作,所以基本上不需要考虑并发的情况。

  2. 公用的缓存数据,比如一个购物网站的商品信息,这个时候需要考虑并发的影响。

针对第一种,因为不需要考虑并发,所以先淘汰缓存再写数据库是最优解。而对于第二种,先写数据库再淘汰缓存的复杂度更低,至于极小概率的缓存不一致问题也要分两方面看。首先,如果我们能够容忍一定程度的不一致,那么这个问题就不需要做太多的处理,只要设置一个合理的过期时间就可以了。其次,我们也可以考虑使用canal订阅binlog来进行增量更新缓存。

总结一下,在更新缓存时,我们可以采取下面的策略:

  • 可以直接淘汰缓存,而不是更新缓存,这样实现最简单,副作用也小。
  • 对于用户唯独的数据,如果不存在并发的问题,可以先淘汰缓存再写数据库。
  • 对于公共的数据,要先写数据库再淘汰缓存,根据对数据不一致性的容忍度,可以选择性使用canal订阅binlog做增量更新。
  • 如果写数据库是存在事务的,要把写缓存放到事务之外,避免因为网络抖动等原因使得写缓存的响应时间很慢,进而导致数据库事务的阻塞。

Cache-As-SoR

这种模式下,我们在业务逻辑中看到的只有对Cache的操作,而直接操作DB的代码是看不到的。

Read-through

业务代码首先读取Cache,如果Cache不命中则由Cache回源到DB。使用该模式需要配置一个CacheLoader,告诉Cache如何回源到DB加载数据。我们以Guava为例:

LoadingCache<Integer, List<Category>> cache = CacheBuilder.newBuilder()
    .softValues()
    .maximumSize(1000)
    .expireAfterWrite(2, TimeUnit.MINUTES)
    .build(new CacheLoader<Integer, List<Category>>() {
        @Override
        public List<Category> load(final Integer parentId) {
            return categoryService.get(parentId);
        }
    });

这样做的好处是:

  1. 让业务代码变得更加简洁,缓存查询代码和DB回源代码不用交织在一起,可以很好的消除重复代码。

  2. 在高访问量下可以,可以在内部指定某一个请求去回源,避免了Dog-pile Effect的问题。

在缓存系统中,缓存总有失效的时候,比如我们经常使用的 Memcache 和 Redis ,都会设置超时时间;而一旦缓存到了超时时间失效之后,如果此时再有大量的并发向数据库发起请求,就会造成服务器卡顿甚至是系统宕机。这就是 Dog Pile Effect 。

Write-through

业务代码首先调用Cache写数据,然后由Cache负责写缓存写DB。使用该种模式时,一般需要配置一个CacheWriter来回写DB。Guava Cache没有没有提供对此种模式的支持,我们这里就不再演示了。

Write-Behind

又叫回写模式,write-through 是同步写缓存和DB的,而 write-behind 是异步写。由于是异步写,所以我们就可以玩出更多的花样,比如实现批量写、合并写、延时和限流等操作。

一般来说,在大多数情况下我们使用的都是Cache-Aside模式,原因就在于它足够简单,也更易于理解。Cache-As-SoR 模式虽然在一定程度上能简化业务代码,但实现起来略显复杂,如果没有明显的优势,我个人更倾向于简单、易懂。

其实还有一个上面没有谈到的问题是缓存的过期与不过期,只不过我们大部分使用缓存的时候都会加上一个过期时间,所以很容易忽略这个问题。我们上面在介绍本地缓存时,举了一个商品类别的例子,这也是缓存不过期的一种应用。对于这种一致性不是特别高,数据量也不是很大的场景,就可以使用不过期缓存,然后通过定期任务全量同步缓存。此外,一些数据访问频率很高的场景,或者是缓存空间足够,都可以考虑不过期缓存。当缓存满了,可以考虑使用LRU机制来驱逐老的缓存数据。而大多时候我们使用过期缓存机制也并没有什么问题。

好了,我们上篇内容就说这么多,在下篇中,会谈谈在使用缓存的过程中可能会遇到的问题,以及一些解决方案。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值