缓存:缓存知识结构更多Tips。

互联网公司对缓存的依赖越来越重,缓存往往是提升性能的关键手段。缓存的解决方案越来越多,各个公司也针对自身的业务场景实现了各种各样的解决方案。缓存方案需要和具体的业务场景结合才能更好的发挥缓存的价值,如果对缓存的知识结构有一个本质的了解,可以更好的让缓存为业务服务,让缓存发挥更大的价值。

缓存使用模式

缓存是为解决性能问题而生,在具体的代码编写中,最好使用函数封装,把缓存和数据库的操作提炼为模块,避免出现散弹式代码。缓存与数据库的操作关系根据同步、异步以及操作顺序可以区分为下面几类。

Cache-Aside

Cache-Aside就是业务代码中管理维护缓存。某些缓存中间件没有关联缓存和存储之间的逻辑,则只能由业务代码来完成了。读场景,从缓存中获取数据,如果没有命中,则回源到存储系统并将源数据放入缓存供下次使用。写场景,先将数据写入到存储系统,写入成功后同步将数据写入缓存。或者写入成功后将缓存数据过期,下次读取时再加载缓存。此模式的优势在于利用数据库比较成熟的高可用机制,数据库写成功,则进行缓存数据更新;如果缓存数据写失败,可以发起重试。

Cache-As-SoR

SoR是记录系统,就是实际存储原始数据的系统。Cache-As-SoR顾名思义就是把Cache当作SoR,业务代码只对Cache操作。而对于SoR的访问在Cache组件内部。传统来讲,具体又分为Read-Through、Write-Through、Write-Behind三种实现。这里进一步归纳了Refresh-Ahead模式。

Read-Through

在Read-Through模式下,当我们业务代码获取数据时,如果有返回,则先访问缓存;如果没有,则从数据库架子啊,然后放入缓存。Guava Cache即支持该模式。

Refresh-Ahead

业务代码访问数据时,仅调用cache的get操作。通过设定数据的过期时间在数据过期时,自动从数据库重新加载数据的模式。此模式相较于Read-Through模式的好处是性能高,坏处是可能获取到非数据库的最新数据。在对数据精确度有一定容忍的场景适合使用。

Write-Through

Write-Through被称为穿透写模式,业务代码首先调用Cache写,实际由Cache更新缓存数据和存储数据。

Write-Behind

在Write-Behind模式下,业务代码只更新缓存数据,什么时候更新到数据库,由缓存到数据库的同步策略决定。

缓存协议

缓存常见有Redis和Memcached协议。Redis协议为RESP(REdis Serialization Protocol)。Memcached协议主要分为两种:文本(classis ASCII)和二进制(binary)协议,一般客户端均支持文本协议和二进制协议,默认选择的是文本协议。如果选择二进制协议,采用中间件,还需要考虑中间件是否能够支持二进制协议。

Redis协议

redis通过CRLF(\r\n)进行拆包。主要包含两部分:类型和data,类型为标识data的数据格式。redis主要协议实现有pipeline,事务,pub/sub,cluster等。

  • Pipeline:将多个请求合并成一个请求进行发送,减少网络请求。在请求数据包较小并且请求次数较多的情况下,pipeline可有效提升性能;如果请求包的长度大于MTU(Maximum Transmission Unit)一般为1500byte,由于拆包,并不能有效提升性能。
  • 事务:和pipeline的相同点是可以进行网络请求的合并。不同点为事务可以保证操作的原子性;事务是执行了exec才会把所有的请求结果一起返回。
  • Pub/sub:实际上是hold住长连接,redis端会主动将消息推送到监听的客户端。
  • Cluster:服务端通过gossip协议实现分布式。集群关系由服务端维护,通过moved或者ack协议进行重定向来通知客户端访问到正确的数据节点。

Memcached协议

Memcached的协议有文本协议和Binary协议。其中,文本协议和Redis比较类似,也是通过CRLF(\r\n)来进行拆包。由于Memcached存储结构简单,只有很少的命令。

如下图所示,Binary协议简单高效,支持更多的特性,扩展性更强。

比如Opaque,Memcached收到该字段后,再返回到客户端。这样客户端就有能力识别返回的数据是哪个请求发送的(实现连接的多路复用)。

缓存连接池

连接池是将连接进行复用,提升访问性能。缓存对性能要求较高,连接池的合理应用更加重要。连接池的实现主要依赖于集群方式和底层IO机制。集群方式比如:单点,sharding和cluster。底层IO比如BIO,NIO。下面介绍几个常用的缓存客户端:

Jedis客户端

Redis客户端,连接池是基于common-pool,下面看一下jedis对于单点,sharding和cluster部署结构的连接池实现。

单点连接池如下图所示,连接池里面放置的是空闲连接,如果被使用(borrow)掉,连接池就会少一个连接,连接使用完后进行返回(return),连接池会增加一个可用连接。如果没有可用连接,便会新建连接。

sharding连接池如下图所示,比如有两个redis服务器进程(redis1,redis2),对key按照sharding策略选择访问哪一个redis。相较于单点连接池,sharding连接池里面的连接为redis1和redis2两个连接。每次申请使用一个连接,实际上是拿到了两个不同的连接,然后通过sharding选择具体访问哪一个redis。该方案的缺点是会造成连接的浪费,比如需要访问redis1,但是实际上也占用redis2的连接。

Cluster连接池如下图所示,在客户端启动的时候,会从某一个redis服务上面,获取后端cluster集群上面所有的redis服务列表(比如redis1和redis2),并且对每一个redis服务建立独立的连接池。如果访问后端redis服务,会先通过CRC16计算访问的key确定slot,再通过slot选择对应的连接池(比如redis1的pool),再从对应的连接池里面获取连接,访问后端服务。

Spymemcached客户端

Memcached客户端,IO为NIO的实现,在异步系统中可以极大提升系统的性能。客户端实现了连接的多路复用,如下图所示,一个连接可以多个请求同时复用,可以通过极少的连接支持较高的访问。对于MySQL的连接,一个请求占用的连接是不能被其他请求使用的,一般需要建立大量的连接。

spymemcached多路复用的机制:key通过sharding策略选择对应的连接,每个连接有一个FIFO队列,会将当前的请求封装成Task放置到FIFO队列上面,异步NIO线程进行异步的发送与接收。多路复用机制强制依赖于在同一个连接上的请求必须顺序发送,顺序响应。

几个关注点

使用缓存组件时,需要关注集群组件方式、缓存统计;还需要考虑缓存开发语言对缓存的影响,如对于JAVA开发的缓存需要考虑GC的影响;最后还要特别关注缓存的命中率,理解影响缓存命中率的因素,及如何提高缓存命中率。

集群组件方式

集群方式主要有客户端sharding、proxy和服务集群三种方式。

  • 客户端sharding:如下图所示,key在客户端通过一致性hash进行sharding。该种方案服务端运维简单,但是需要客户端实现冬天的扩缩容等机制。

  • proxy:如下图所示。proxy实现对后端缓存服务的集群管理。Proxy同时也是一个集群。有两种方式管理proxy集群,通过LVS或者客户端实现。如果流量较大,LVS也需要考虑进行集群的管理。proxy方案运维复杂,扩展性较强,可以在proxy上面实现限流等扩展功能。

  • 服务端集群:主要是Redis的cluster机制,基于Gossip协议实现。

统计

在实际应用中,需要不断的改进缓存和性能,需要对缓存进行关键数据的监控。进程内缓存的监控项目如下表所示。

监控项说明
uptime启动总时长
total mem总分配内存量
used mem已使用内存量
free mem可用内存量
keys count缓存对象总数
total commands总计执行命令数
hits/s每秒命中数
miss/s每秒未命中数
expire/s每秒失效数量
slow query慢查询

对于进程外缓存,除监控功能进程内缓存的各种指标外,一般还需要监控cpu、进程内存使用情况、连接数等数据。

GC影响

二级缓存存在热key或者大key等难以解决的问题,可以通过本地缓存来有效地解决。对于Java的本地缓存,一般有堆内(on-heap)和堆外(off-heap)两种方案。堆内缓存的空间由JVM分配及回收,由于缓存的数据量一般较大,堆内缓存对GC的影响较大。堆外缓存是直接在Page Cache中申请内存,生命周期不由JVM管理。

对外缓存主要的优点为:

  • 支持更大的内存。
  • 减少GC的性能开销,比如YGC,会将Eden区有引用的对象拷贝到S0或者S1,堆内缓存的数据会频繁拷贝。
  • 减轻FGC的压力及频率。FGC在old区进行数据整理,会进行内存对象的标注及迁移。由于old区空间一般较大,会导致整个处理时间较长。另外,对内缓存的数据一般存放在old区,占用的空间比较大,容易触发FGC,对外缓存则不会存在这种情况。

序列化

访问缓存,需要进行序列化和反序列化。Redis或者Memcached存储的都是字节类型的数据。如果需要存储数据到缓存中,需要先在本机进行序列化,转化为字节,通过网络传输,缓存以字节的形式进行存储。如果需要读取数据,从缓存中读取的是字节形式的数据,需要进行反序列化,将字节转换为对象。

访问本地缓存,对于JVM语言,有堆内和堆外缓存可以进行选择。由于堆内直接以对象的形式进行存储,不需要考虑序列化,而堆外是以字节类型进行存储,就需要进行序列化和反序列化。对外存储对GC的影响较小,但是序列化和反序列化的开销却不能忽略。

序列化带来的性能损耗是非常可观的,曾遇到在Redis批量获取的场景下,使用JDK对象序列化机制,每次批量获取100条数据,每条数据均需要进行反序列化的开销,导致CPU运行非常高,对应用的正常服务带来很大的影响。

序列化性能主要考虑如下:

  • 序列化的时间:对于层级比较深的对象结构或者字段比较多的对象,不同的序列化机制,序列化时间开销也有较大的差役。
  • 序列化之后包的大小:序列化后包的大小越小,网络传输越快,同时后端缓存服务在一定的存储空间内,存储的对象也越多。序列化之后,在数据量特别大的情况下一般会选择是否开启压缩,开启压缩的目的就是减少传输的包大小。
  • 序列化消耗的CPU:序列化后的数据,在获取的时候会进行反序列化,特别是批量获取数据的操作,反序列化带来的CPU消耗特别大。序列化一般需要解析对象的结构,而解析对象结构,会带来较大的CPU消耗,所以一般的序列化(比如fastJonn)均会缓存对象解析的对象结构,来减少CPU的消耗。

缓存命中率

  • 命中:可以直接通过缓存获取到需要的数据。
  • 不命中:无法直接通过缓存获取到想要的数据,需要再次查询数据或者执行其他的操作。原因可能是由于缓存中根本不存在,或者缓存已经过期。

通常来讲,缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越高),抗并发的能力越强。

由此可见,在高并发的互联网系统中,缓存的命中率是至关重要的指标。

如何监控缓存的命中率

在Memcached中,运行state命令可以查看Memcached服务的状态信息,其中cmd_get表示总的get次数,get_hits表示get的总命中次数,命中率=get_hits/cmd_get。

当然,我们也可以通过一些开源的第三方工具对整个Memcached集群进行监控,如下图所示。比较典型的包括:zabbix、MemAdmin等。

同理,在redis中可以运行info命令查看redis服务的状态信息,其中keyspace_hits为总的命中次数,keyspace_misses为总的miss次数,命中率=keyspace_hits/(keyspace_hits+keyspace_misses)。

开源工具Redis-Stat能以图表方式直观Redis服务相关的信息,同时,zabbix也提供了相关的插件对Redis服务进行监控。

影响缓存命中率的几个因素

  • 业务场景和业务需求:缓存适合“读多写少”的业务场景,反之,使用缓存的意义其实并不大,命中率会很低。业务需求决定了对时效性的要求,直接影响到缓存的过期时间和更新策略。时效性要求越低,就越适合缓存。在相同key和相同请求数的情况下,缓存时间越长,命中率会越高。互联网应用的大多数场景下都是很适合使用缓存的。
  • 缓存的设计:通常情况下,缓存的粒度越小,命中率会越高。当缓存单个对象的时候(例如:单个用户信息),只有当该对象对应的数据发生变化时,我们才需要更新缓存或者移除缓存。而当缓存一个集合的时候(例如:所有用户数据),其中任何一个对象对应的数据发生变化时,都需要更新或移除缓存。
  • 缓存容量和基础设施:缓存的容量有限,容易引起缓存失效和被淘汰(目前多数的缓存框架或中间件都采用了LRU算法)。同时,缓存的技术选型也是至关重要的,比如采用应用内置的本地缓存就比较容易出现单机瓶颈,而采用分布式缓存则容易扩展。所以需要做好系统容量规划,并考虑是否可扩展。此外,不同的缓存框架或中间件,其效率和稳定性也是存在差异的。
  • 其他因素:当缓存节点发生故障时,需要避免缓存失效并最大程度降低影响,这种特殊情况也是架构师需要考虑的。业内比较典型的做法就是通过一致性Hash算法,或者通过节点冗余的方式来实现高可用。

可能会有这样的理解误区:既然业务需求对数据时效性要求很高,缓存的时间比较短,容易失效,缓存命中率无法有效保证,那么系统就别使用缓存了。其实这忽略了一个重要因素——并发。通常来讲,在相同缓存时间和key的情况下,并发越高,缓存的收益会越高,即便缓存时间很短。

提高缓存命中率的方法

应用尽可能的通过缓存直接获取数据,并避免缓存失效。这需要在业务需求,缓存粒度,缓存策略,技术选型等各个方面去通盘考虑并做权衡。尽可能的聚焦在高频访问且时效性要求不高的热点业务上,通过缓存预加载(预热)、增加存储容量、调整缓存粒度、更新缓存等手段来提高命中率。

管理缓存

在使用缓存过程中,如果缓存数据没有命中就会存在缓存穿透,如果穿透率较高,我们需要分析穿透原因并予以解决,选择不同的缓存策略、缓存淘汰算法,避免攻击性穿透、并发更新穿透等情况,同时让缓存数据的失效过程尽可能平滑。

缓存穿透

我们在项目中使用缓存通常都是先检查缓存中是否存在,如果存在直接返回缓存内容,如果不存在就回源,然后再将结果进行缓存。这个时候如果我们查询的某一个数据在缓存中一直不能存在,就会造成每一次请求都会回源,这样缓存就失去了意义,在流量大时,回源系统的压力就会非常大。那这种问题有什么好办法解决呢?要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。有一个比较巧妙的做法是,可以将这个不存在的key预先设定一个值。比如key和“&&”。在返回这个&&值的时候,我们的应用就可以认为这是不存在的key,应用就可以决定是否需要回源。

缓存穿透的第二个场景:网站并发访问高,一个缓存如果失效,可能出现多个进程同时查询DB,同时设置缓存的情况,如果并发确实很大,这也可能造成回源系统压力过大。这里的方案是对缓存查询加锁,如果KEY不存在,就加锁,然后回源,将结果进行缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者回源查询。这种情况和刚才说的预先设定值的问题有些类似,只不过利用锁的方式,会造成部分请求等待。

进一步的方案:双key,主key生成一个附属key来标识数据修改过期时间,然后快到的时候去重新加载,如果觉得key多可以把结束时间放到主key中,附属key起到锁的功能。这种方案的缺点是会产生双份数据,而且需要同时控制附属key与key之间的关系,操作上有一定复杂度。

mutex的解决方案,新浪微博的杨卫华先生提出过一种思路(参见微博cache设计谈),大意思路是:

  1. 热点key过期,则增加key_mutex。
  2. 从数据库中load key的数据放入缓存。
  3. 添加成功,则删除key_mutex。
  4. 返回key的值给上层应用。

几个可以思考的点:这段逻辑放到cacheclient,还是cache server?如若放到client,则可能有上百台机器在访问,增加key_mutex的逻辑无法跨集群加锁,如果在cache server就简单了,但是需要考虑如何把代码plugin in 到 cache server。

第二个思考点是,如果增加key_mutex,是否要sleep和retry,sleep多长时间较好。这些都需要实际环境测试才有效准确的方案。

缓存失效

引起这个问题的主要原因还是高并发的时候,平时我们设定一个缓存的过期时间时,可能有一些会设置1分钟,5分钟,并发很高可能会出现在某一个时间同时生成了很多的缓存,并且过期时间都一样,这个时候就可能引发过期时间到后,这些缓存同时失效,请求全部转发到DB,DB可能会压力过重。那如何解决这些问题呢?

其中的一个简单方案就是将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1~5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

如果缓存集中在一段时间内失效,DB的压力凸显。这没有完美解决办法,但可以分析用户行为,尽量让失效时间点均匀分布。

上述是缓存使用过程中经常遇到的并发穿透、并发失效问题。一般情况下,我们解决这些问题的方法是,引入空值、锁和随机缓存过期时间的机制。

淘汰算法

为更有效利用内存,并维持缓存中对象数量不会过多,缓存组件应该具有灵活的淘汰策略。目前常见的淘汰策略如下表所示。

策略变种说明
LRULRU最近最少使用
LRU-K最近使用过K次
Two qucues算法有两个缓存队列,一个是FIFO队列,一个是LRU队列
Multi Queues根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级
LFULFU根据数据的历史访问频率来淘汰数据
LFU*只淘汰访问过一次的数据
LFU-Aging除了访问次数外,还要考虑访问时间
LFU*-AgingLFU*和LFU-Aging的合成体
Window-LFUWindow-LFU并不记录所有数据的访问历史,而只是记录过去一段时间内的访问历史
FIFOFIFO先进先出
Second Chance如果被淘汰的数据之前被访问过,则给其第二次机会
Clock通过一个环形队列,避免将数据在FIFO队列中移动

下面描述一下LRU和LRU-K算法。

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的概率也更高”。

最常见的实现是使用一个链表保存缓存数据,详细算法实现如下图所示。

由上图可知:

  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃。

但是这样的实现固然简单,但是有固有的缺点,当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。

另一种比较科学的实现是LRU-K。LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数到达K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。详细实现如下图所示。

由上图可知:

  1. 数据第一次被访问,加入到访问历史列表。
  2. 如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰。
  3. 当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序。
  4. 缓存数据队列被再次访问后,重新排序。
  5. 需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。

LRU-K具有LRU的优点,同时能够避免LRU的缺点,实际应用中LRU-2是综合各种因素后最优的选择,LRU-3或者更大的K值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。LEU-K虽然降低了“缓存污染”带来的问题而且命中率比LRU要高,但是也有一定的代价,由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多;当数据量很大的时候,内存消耗会比较客观。LRU-K需要基于时间进行排序(可以需要淘汰时再排序,也可以即时排序),CPU消耗比LRU要高。还存在有其他的一些算法实现,比如Two queues(Q2),Multi Queue(MQ)等等。

缓存可用性

为提高缓存系统的可用性,在部分缓存节点异常时仍可保证数据的访问,我们可以采用主备方案,用备用Cache层来承接已故障的主cache节点;也可以采用cluster集群方案,每个cluster节点拥有多个slave节点,slave节点不仅可以分担master节点的访问压力,在master节点异常时,还可以选择一个slave节点晋级为新的master。

主备方案

缓存的主要目标是提升性能,理论上缓存数据丢失或者缓存不可用,不会影响数据正确性,数据库里还有数据呢!但是由于缓存的存在,挡住了大部分访问,如果缓存服务器挂掉,则存在大量的请求穿透到数据库,这对数据库访问是灾难性的,有可能数据库完全不能承受这样的压力而宕机。因此,有些网站通过缓存热备等手段提高可用性,如下图所示。

如上图所示,微博通过M-S模式来解决高可用问题,但带来的影响是数据一致性问题。

cluster方案

Redis Cluster方案,一个Redis Cluster由多个Cluster节点组构成。不同节点组服务的数据无交集,即每一个节点组对应数据的一个分片。节点内部分为主备两类节点,对应前述的master和slave节点,两者数据准实时一致,通过异步化的主备复制机制保证。一个节点组有且仅有一个master节点,同时有0到多个slave节点。只有master节点对用户提供写服务,读服务可以由master或者slave提供。在master节点异常时,还可以从slave节点中选择一个节点晋级为新的master节点。

Redis cluster的节点结构如下图所示。

该示例下,key-value数据全集被分成了5份,即5个slot(实际上Redis cluster总共有16384个slot,每个节点服务一部分slot,这里以5个slot为例)。A和B分别为两个master节点,对外提供数据的读写服务,分别负责1/2/3三个slot和4/5两个slot。

数据一致性

大家都了解,依据CAP原理,在高可用性、一致性和分区容忍性三者只能取其二。而使用缓存最大的好处就是提升性能,减少数据库访问的压力。如何保障数据一致性是一个不得不面对的问题。这里的一致性包括缓存数据与数据库数据的一致性,亦包括多级缓存数据之间的一致性。在绝大部分场景下,追求最终一致性。

最终一致性

大部分情况下对于缓存数据与数据库数据的一致性没有绝对强一致性要求,那么在写缓存失败的情况下,可以通过补偿动作进行,达到最终一致性。

我们来看看Facebook是如何做到的。Facebook是通过更新数据库(如下图所示的Storage)之后,把需要删除的key给到McSqueal,然后异步删除缓存数据的模式。这样下一次get请求时,如果没有数据,则从数据库里查询同时更新到Memcached集群。

对于时间敏感的数据可以设置很短的过期时间(失效时间),这样一旦超过失效时间,就可以从数据库重新加载。

保持最终一致性的方法有很多,再举一例,京东采用了通过canal更新缓存原子性的方法,如下图所示。

几个关注点:

  • 更新数据时使用更新时间戳或者版本对比,如果使用Redis可以利用其单线程机制进行原子化更新;
  • 使用如canal订阅数据库binlog,此处把MySQL看成发布者,binlog是发布的内容,canal(canal是阿里巴巴MySQL数据库binlog的增量订阅&消费组件)堪称消费者,canal订阅binlog然后更新到Redis。
  • 将更新请求按照相应的规则分散到多个队列,然后每个队列的进行单线程更新,更新时拉取最新的数据保存;更新之前获取相关的锁再进行更新。

强一致性

可以使用InnoDB memcached插件结合MySQL来解决缓存数据与数据库数据一致性问题。下图是其应用架构图。

在安装配置中就能发现,cache_policies表定义了缓存策略,包含如下选择:

  • innodb_only:只使用InnoDB作为数据存储。
  • cache-only:只使用传统的Memcached引擎作为后端存储。
  • caching:二者皆使用,如果在Memcached里找不到,就查询InnoDB。

那么在数据库读操作的时候,使用caching模式,则使用了Memcached缓存。在数据库写操作层面,在存储引擎中实现缓存数据和存储数据一致性。经过评测,读性能比直接读数据库提升1倍以上,当然,性能不如单纯的内存Memcached存储模式。

热点数据处理

设计缓存时,使用sharding或者cluster模式,来将不同的key,sharding到不同的机器上面,避免所有请求访问同一台机器导致性能瓶颈。但是对于同一个key的访问都是在同一个缓存服务,如果出现热key,很容易出现性能瓶颈。一般的解决方案为:主从,预热和本地缓存等。

数据预热

提前把数据读入到缓存的做法就是数据预热处理。数据预热处理要注意一些细节问题:

  • 是否有监控机制确保预热数据都写成功了!曾经遇到部分数据成功而影响高峰期业务的案例;
  • 数据预热配备回滚方案,遇到紧急回滚时便于操作。对于新建cache server集群,也可以通过数据预热模式来作一番手脚。如下图所示,先从冷集群中获取key,如果获取不到,则从热集群中获取。同时把获取到key put到冷集群。

  • 预热数据量的考量,要做好容量评估。在容量允许的范围内预热全量,否则预热访问量高的。
  • 预热过程中需要注意是否会因为批量数据库操作或慢sql等引发数据库性能问题。

非预期热点策略

对于非预期热点问题,一般建立实时热点发现系统来发现热点,如下图所示。

无论是京东通过Nginx+Lua来做应用内缓存,或者是别的local cache方案;亦无论分布式缓存tair还是redis,对于实时热点发现系统的实现策略大同小异。通过实时统计访问分布式缓存的热点key,把对应的key推到本地缓存中,满足离用户最近的原则。当然,使用本地缓存,业务上要容忍本地缓存和分布式缓存的非完全一致。比如秒杀场景,查看详情浏览的库存数量,而最终是以成功下单的为准。

多级缓存模式

类似于秒杀这类场景,一旦某个热点触发了一台机器的限流阈值,那么这台机器Cache的数据都将无效,进而间接导致Cache被击穿,请求落地应用层数据库出现雪崩现象。这类问题需要与具体Cache产品结合才能有比较好的解决,一个通用的解决思路就是在Cache的client端做本地cache,当发现热点数据时直接Cache在client里,而不要请求到Cache的Server,具体下图所示。

以京东的解决方案为例,对于分布式缓存,需要在Nginx+Lua应用中进行应用缓存来减少Redis集群的访问冲击。即首先查询应用本地缓存,如果命中直接缓存,如果没有命中则接着查询Redis集群、回源到Tomcat,然后将数据缓存到应用本地。

应用Nginx的负载机制采用:正常情况采用一致性哈希,如果某个请求类型访问量突破了一定的阈值,则自动降级为轮询机制。另外对于一些秒杀活动之类的热点我们是可以提前知道的,可以把相关数据预先推送到应用Nginx并将负载轮询机制降级为轮询。

数据复制模式

在Facebook有一招,就是通过多个key_index(key:xxx#N)来解决数据的热点读问题。解决方案是所有热点key发布到所有web服务器;每个服务器的key有对应别名,可以通过client端的算法路由到某台服务器;做删除动作时,删除所有的别名key。可简单总结为一个通用的group内一致模型。把缓存集群划分为若干分组(group),在同组内,所有的缓存服务器,都发布热点key的数据。

对于大量读操作而言,通过client端路由策略,随意返回一台机器即可;而写操作,有一种解法是通过定时任务来写入,Facebook采取的是删除所有别名key的策略。如何保障这一个批量操作都成功?

  • 容忍部分失败导致的数据版本问题。
  • 只要有写操作,则通过定时任务刷新缓存;如果涉及3台服务器,则都操作成功代表该任务表的这条记录成功完成使命,否则会重试。

注意事项Tips

在缓存使用中,存在一些误用缓存的例子。比如把缓存当作存储来用;在DB基本上负载很低的情况下,为了用缓存而用缓存等。本文最后来介绍一下缓存使用中要注意的一些事项Tips。

慎把缓存当存储

关键链路中,如果无法容忍缓存不可用带来的致命危机,那么还是应该把缓存仅作为提升性能的手段,如果缓存不可用,可以访问数据库兜底。当然,缓存本身的高可用是另外一个话题。

缓存就近原则

缓存所有的策略均是优先访问离自己最近的数据。下面看集中常见的就近原则的访问方式:

CPU缓存

CPU访问缓存如下表所示。

从CPU到大约需要的CPU周期大约需要的时间
主存约120-240 cycles约60-120ns

QPI总线传输

(between sockets,noet drawn)

 约20ns
L3 cache约40-45 cycles约15ns
L2 cache约10 cycles约3ns
L1 cache约3-4 cycles约1ns
寄存器1 cycles约1ns

可以看到离CPU越近,访问速度最快。

客户端缓存

客户端缓存又称为本地缓存,本地缓存能比远程缓存获得较高的性能,本地缓存使用过程中如果数据不仅仅是读,还有写,那么要解决写数据同步给集群其他节点的本地缓存的问题。

CDN缓存

用户端优化的常见手段便是CDN,静态资源一般通过CDN缓存提升访问性能。CDN的全称是Content Delivery Network,即内容分发网络。其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。CDN系统能够实时的根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。CDN也是基于离用户越近,性能越高。

并发控制手段

保证并发控制的一些常用高性能手段有:乐观锁、Latch、mutex、CAS等;多版本的并发控制MVCC通常是保证一致性的重要手段;Latch是处理数据库内部机制的一种策略。缓存设计和并发控制的关系如下表所示。

 版本号mutexCAS
Memcached通过version支持自己实现支持
RedisN自己实现通过watch命令支持

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值