互联网应用的缓存实践分享

  1. 云栖社区>
  2. 博客>
  3. 正文

互联网应用的缓存实践分享

调皮仔3683 2017-08-24 11:21:36 浏览1705
展开阅读全文

简介
在当今互联网应用中,缓存作为一把尖刀利器对应用的性能起着举足轻重的作用。缓存的使用可以说无处不在,从应用请求的访问路径来看,用户user -> 浏览器缓存 -> 反向代理缓存-> WEB服务器缓存 -> 应用程序缓存 -> 数据库缓存等,几乎每条链路都充斥着缓存的使用,当然交换机,网络适配器,硬盘上也有Cache 但这不是我们要讨论的范围。今天我们讨论的“缓存”,自然就是“用空间换时间”的算法。缓存就是把一些数据暂时存放于某些地方,可能是内存,也有可能硬盘。总之,目的就是为了避免某些耗时的操作。我们常见的耗时的操作,比如数据库的查询、一些数据的计算结果,或者是为了减轻服务器的压力。其实减轻压力也是因查询或计算,虽然短耗时,但操作很频繁,累加起来也很长,造成严重排队等情况,服务器抗不住。

缓存介质
虽然从硬件介质上来看,无非就是内存和硬盘两种,但从技术上,可以分成内存、硬盘文件、数据库。

内存 将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down而重新启动,数据很难或者无法复原。

硬盘 一般来说,很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。

数据库 前面有提到,增加缓存的策略的目的之一就是为了减少数据库的I/O压力。这里所指的数据库只是简单的key-value存储结构的特殊NOSQL数据库(如BerkeleyDB和Redis),响应速度和吞吐量都远远高于我们常用的关系型数据库等。

缓存命中率
缓存命中率通常指的是缓存查询命中总数与缓存查询总数的比率,应用缓存命中率越高越好,这是衡量缓存使用是否良好的重要指标。

缓存回收策略
缓存的回收策略在分布式缓存中类型主要有如下几种

  1. 基于时间
    TTL:存活期,一条缓存自创建时间起多久后失效

TTI:空闲期,一条缓存自最后读取或更新起多久后失效

  1. 基于空间
    通常指的是设置存储空间大小,比如TAIR申请时空间大小,超过这个阈值后,会按照一定的策略算法移除数据。
  2. 基于容量
    通常指的是设置缓存条目数量大小,超过这个阈值后,会按照一定的策略算法移除数据。

缓存系统的整体回收首先根据时间进行移除过期数据,如果超过空间或者容量设置的阈值,会根据相应的算法移除数据,移除数据的算法主要有LRU、FIFO、LFU,最常用的算法是LRU,分布式缓存memcached、redis以及tair都支持该算法,本地缓存guava cache、ehcache也同样支持LRU算法。

数据淘汰算法简要介绍:

FIFO(first in first out)
先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

LFU(less frequently used)
最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。

LRU(least recently used)
最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。

缓存使用场景与分类
使用缓存的目的提高系统的整体性能,缓存的工作机制是先从缓存中读取数据,如果没有,则再从慢速设备上读取实际数据并同步到缓存。那些经常查询的数据、频繁访问的数据、热点数据、IO瓶颈数据、计算昂贵的数据、符合五分钟法则和局部性原理的数据都可以进行缓存。

在互联网应用中常见的缓存的场景主要:

数据库缓存: 随着业务量的上升,数据库存储的数据量越来越大,并发请求逐渐增大,随之而来的问题就是数据库系统的负载升高,响应延迟下降,严重的时候,甚至有可能因此而导致服务中断,这时启用缓存利器可以提高系统性能。
临时数据存储: 应用程序需要维护大量临时数据,例如计数器、分布式锁、用户session等,将临时数据存储在分布式缓存中,可以降低内存管理的开销,改进应用程序工作负载。
在互联网应用中,从应用与缓存耦合度角度缓存主要分为本地缓存、分布式缓存两大类。

本地缓存:指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。

Guava Cache、Ehcache、MapDB都可以实现JAVA堆内存本地缓存,谈堆内存其实JAVA还支持堆外内存,Ehcache 3.x、MapDB 3.x也同样支持堆外内存,堆外内存意味着把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),Netty就是使用堆外内存来管理内存,建议慎用堆外内存,使用不当容易导致OOM,关于堆外内存与堆内存接下来不做重点介绍。

分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存,像memcached、redis、tair都是分布式缓存。

缓存使用实践
缓存的使用也是讲究一定技巧性,如果使用不当会导致数据一致性问题、缓存被穿透导致应用雪崩等。
上面讲到局部性原理,简单介绍下与缓存相关的局部性原理:

时间局部性(temporal locality):数据将被多次访问
空间局部性(spatial locality):邻近数据将被访问
基于局部性原理,缓存在设计上需要考虑许多的因素:

缓存关联性(cache associativity)
写策略(writing policy)
替换策略(cache replacement)
缓存一致性(cache coherency)
cache失效可能引发的dog-piling效应(cache stampede)
.....
接下来将会根据以上几点介绍缓存使用的一些实践。

缓存与DB数据一致性
数据的更新与缓存同步,没有高科技含量,但要做好并不容易,有些场景需要做到实时一致性,有些场景需要做到最终一致性。

如果要做到强一致性,可以采取以下方案:

数据库更新后,删除缓存。
这种方式的优点是实现简单,缺点是删除缓存后,如果有多个查询请求并发过来,都发现缓存中没数据,都会将请求落到数据库上,导致数据库压力瞬间增加。

数据库更新后,更新缓存。
这是对删除方式的改进,但也有缺点,写入前要多一次查询,在部分场景下是没法使用的,比如分页查询场景,各种请求参数组合很多,应用无法知道有多少种key,自然无法主动写入,只能等缓存失效。

以上两种实时同步缓存机制,先操作数据库然后操作缓存,因异构数据存储无法通过事务保证一致。当然缓存涉及到网络IO开销,如果连接分布式缓存超时也需要考虑,否则会出现事务超时,导致应用线程挂起。

如果要做到最终一致性,可以采取以下方案:

MQ异步刷新、定时刷新 采用MQ异步消息机制刷新,如果更新失败要有适当的补偿机制。所有需要更新的对象存储到一张定时任务表,定时任务扫描任务表异步更新。这两种更新机制不能保证查询缓存同DB的一致性,但是能够保证最终一致性。

自动失效 合理设置缓存失效时间,需根据业务场景设置每个缓存的失效时间,一致性要求越高,自然失效时间也要越短。

缓存并发
缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?我们会想到类似"锁"的机制(可重入锁),在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。

缓存被穿透
在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。

我们在应用中使用缓存的时候,很可能就是使用的如下代码所表示的逻辑的方式。 先获取缓存中的数据,如果为空则查询数据库或者其他方式获取数据,然后再存入缓存,返回数据,如下伪代码。

data=cache.get(key);
if(data=null || !isValid(data)){
   sql="SELECT ......";
     data=db.query(sql);
   //data可能为null
     if(data != null){
        cache.set(key,data,expire);
      }
 }
return data;

相信大多数人会认为这段代码没有问题,很多人也是这么去写的。

问题:
当key的内容在数据库也不存在时,那么上面代码中的data始终为null,缓存中也始终没有数据,如果这个key的请求突然变得很大(很多情况下都会发生,比如查询请求不存在的数据),那么将会有大量的请求绕过缓存,直接到了后端数据库,对数据库的IOQPS造成过大的冲击,最后很可能导致系统崩溃。

解决方案:

  1. 缓存空对象
    解决这个问题的办法就是当数据库查询到null时,我们也应该把null进行相应的缓存。

比如数据库返回的是一个list,那么我们可以存入一个空的list来处理cache.set(key,new List(),expire)。
同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。

  1. 单独做过滤处理
    对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据。

比较常用的方案是通过Bloom Filter提前拦截,Bloom Filter是一个空间效率很高的随机数据结构,它由一个位数组和一组hash映射函数组成。Bloom Filter可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率。因此Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。

像Guava Cache、Google的bigtable都有类似bloomfilter实现,关于bloomfilter具体可以参考:https://www.javacodegeeks.com/2012/11/bloom-filter-implementation-in-java-on-github.html

以上两种方案均可以保障的缓存被穿透问题,第一种方案更简单,但需要额外占一些缓存空间。第二种方案复杂一些,但是占用缓存空间少。

热点缓存

比如key XXX对应的数据访问量特别大,但是XXX在缓存中是有失效时间的。一旦缓存失效,会有N多线程并发的去请求数据库,然后更新缓存,这个时候会导致系统压力过大。通常有这么几种解决方法:

  1. 加锁,同时只允许一个线程去查询数据库并更新缓存
  2. 缓存不加失效时间,但后台有个异步线程定期的去更新它
  3. 引入类似于Hystrix的熔断机制,只允许一定量的请求去请求数据库并更新缓存

比如缓存使用memcached、redis,可以考虑如上方案针对热点数据。如果使用tair缓存的话,tair提供了hotkey的解决方案,主要原理开启本地LocalCache功能,每次写操作会自动强制删除 Localcache 里存在的 key,读操作后会自动从 Localcache 里读取, Localcache 中不存在则从服务端获取,成功后存储到 Localcache 里。在 Hotkey 防御系统中,客户端要开启 hot-running 模式,该模式下只能缓存带热点标记的 key,Localcache 中非热点的 key 将逐步被淘汰。即一旦开启客户端的该模式,会强制改变 Localcache 的工作模式。

数据缓存模式通常有懒加载和预加载两种模式,但是对于热点缓存最好采取预加载模式,笔者曾经遇到应用系统刚发布完就直接挂掉的案例,一些热点数据查询在高并发场景下还没有加载进来,请求流量就涌入进来。

缓存大对象

某些场景下我们想要把一些大对象缓存起来,因为产生一次大对象的代价很大,我们需要产生一次,尽可能的多次使用,从而提升QPS。

通常的解决方案是对数据进行压缩,压缩可以在客户端进行压缩,缓存服务端也可以压缩,像memcached的话需要在客户端进行压缩,可以采用gzip压缩算法进行压缩,像tair的话如果value超过一定的阈值服务端会自动对value进行压缩。

网友评论

登录后评论
0/500
评论