一、前言

对于任何一个高并发响应系统,如何提供高效响应都是它们需要重点解决的难题。而这里面制约系统的高并发响应的瓶颈往往在于数据库的响应时间。我们知道磁盘的读写速度远远低于内存,这也是为什么数据库会成为制约系统高并发响应的瓶颈。明白这点,我们就知道要设计一套高并发响应系统,如何设计其中的数据缓存系统和利用数据缓存系统成为其中的关键。为此,我们就需要研究下当前数据缓存系统的发展现状,特别是分布式分布式高速数据缓存系统的发展现状。

二、数据库的瓶颈

2.1数据量

关系型数据库的数据量是比较小的,以我们常用的MySQL为例,单表数据条数一般应该控制在2000w以内,如果业务很复杂的话,可能还要低一些。即便是对于Oracle这些大型商业数据库来讲,其能存储的数据量也很难满足一个拥有几千万甚至数亿用户的大型互联网系统。

2.2TPS

在实际开发中我们经常会发现,关系型数据库在TPS上的瓶颈往往会比其他瓶颈更容易暴露出来,尤其对于大型web系统,由于每天大量的并发访问,对数据库的读写性能要求非常高;而传统的关系型数据库的处理能力确实捉襟见肘;以我们常用的MySQL数据库为例,常规情况下的TPS大概只有1500左右(各种极端场景下另当别论);下图是MySQL官方所给出的一份测试数据:

而对于一个日均PV千万的大型网站来讲,每个PV所产生的数据库读写量可能要超出几倍,这种情况下,每天所有的数据读写请求量可能远超出关系型数据的处理能力,更别说在流量峰值的情况下了;所以我们必须要有高效的缓存手段来抵挡住大部分的数据请求!

2.3响应时间

正常情况下,关系型数据的响应时间是相当不错的,一般在10ms以内甚至更短,尤其是在配置得当的情况下。但是就如前面所言,我们的需求是不一般的:当拥有几亿条数据,1wTPS的时候,响应时间也要在10ms以内,这几乎是任何一款关系型数据都无法做到的。
那么这个问题如何解决呢?最简单有效的办法当然是缓存!

三、缓存系统选型

3.1缓存的类型

3.1.1本地缓存

本地缓存可能是大家用的最多的一种缓存方式了,不管是本地内存还是磁盘,其速度快,成本低,在有些场合非常有效;但是对于web系统的集群负载均衡结构来说,本地缓存使用起来就比较受限制,因为当数据库数据发生变化时,你没有一个简单有效的方法去更新本地缓存;然而,你如果在不同的服务器之间去同步本地缓存信息,由于缓存的低时效性和高访问量的影响,其成本和性能恐怕都是难以接受的。

3.1.2分布式缓存

本地缓存的使用很容易让你的应用服务器带上“状态”,这种情况下,数据同步的开销会比较大;尤其是在集群环境中更是如此!

分布式缓存这种东西存在的目的就是为了提供比RDB更高的TPS和扩展性,同时有帮你承担了数据同步的痛苦;优秀的分布式缓存系统有大家所熟知的Memcached、Redis(当然也许你把它看成是NoSQL,但是我个人更愿意把分布式缓存也看成是NoSQL),还有国内阿里自主开发的Tair等。

对比关系型数据库和缓存存储,其在读和写性能上的差距可谓天壤之别;memcached单节点已经可以做到15w以上的tps、Redis、google的levelDB也有不菲的性能,而实现大规模集群后,性能可能会更高!所以,在技术和业务都可以接受的情况下,我们可以尽量把读写压力从数据库转移到缓存上,以保护看似强大,其实却很脆弱的关系型数据库。

3.1.3数据库缓存

主要指数据库的查询缓存,大部分数据库都是会提供,每种数据库的具体实现细节也会有所差异,不过基本的原理就是用查询语句的hash值做key,对结果集进行缓存;如果利用的好,可以很大的提高数据库的查询效率!

3.2选型指标

3.2.1容量

容量当然是越大越好了。每个系统在初期规划的时候,都会大致计算一下所要消耗的缓存空间,这主要取决于你要缓存的对象数量和单个对象的大小。一般来说,你可以采用对象属性在内存中的存储长度简单加和的方法来计算单个对象的体积,再乘以缓存对象的数量和预期增长(当然,这里边有一个热点数据的问题,这里就不细讨论了),大概得出需要使用的缓存空间;之后就可以按照这个指标去申请缓存空间或搭建缓存系统了。

3.2.2并发量

其实这里的并发量改为QPS更为贴切,因为我们的缓存不是直接面向用户的,而是面向应用的。所以我们关心的是一个缓存系统平均每秒能够承受多少的访问量。

我们之所以需要缓存系统,就是要它在关键时刻能抗住我们的数据访问量的;所以,缓存系统能够支撑的并发量是一个非常重要的指标,如果它的性能还不如关系型数据库,那我们就没有使用的必要了。

对于淘宝的系统来说,我们不妨按照下边的方案来估算并发量:
QPS = 日PV × 读写次数/PV ÷ (8 × 60 × 60)
这里我们是按照一天8个小时来计算的,这个值基于一个互联网站点的访问规律得出的。
在估算访问量的时候,我们不得不考虑一个峰值的问题,尤其是像淘宝、京东这样大型的电商网站,经常会因为一些大的促销活动而使PV、UV冲到平时的几倍甚至几十倍,这也正是缓存系统发挥作用的关键时刻;倍受瞩目的12306在站点优化过程中也大量的引入了缓存(内存文件系统)来提升性能。
在计算出平均值之后,再乘以一个峰值系数,基本就可以得出你的缓存系统需要承受的最高QPS,一般情况下,这个系数定在10以内是合理的。

3.2.3响应时间

响应时间当然也是必要的,如果一个缓存系统慢的跟蜗牛一样,甚至直接就超时了,那和我们使用MySQL也没啥区别了。

一般来说,要求一个缓存系统在1ms或2ms之内返回数据是不过分的,当然前提是你的数据不会太大;如果想更快的话,那你就有点过分了,除非你是用的本地缓存;因为一般而言,在大型IDC内部,一个TCP回环(不携带业务数据)差不多就要消耗掉0.2ms至0.5ms。

大部分的缓存系统,由于是基于内存,所以响应时间都很短,但是问题一般会出现在数据量和QPS变大之后,由于内存管理策略、数据查找方式、I/O模型、业务场景等方面的差异,响应时间可能会差异很多,所以对于QPS和响应时间这两项指标,还要靠上线前充分的性能测试来进一步确认,不能只单纯的依赖官方的测试结果。

3.2.4使用成本

优秀的系统要是能够方便部署和方便运维的,不需要高端硬件、不需要复杂的环境配置、不能有过多的依赖条件,同时还要稳定、易维护

3.2.5扩展空间

缓存系统的扩展性是指在空间不足的性情况,能够通过增加机器等方式快速的在线扩容。这也是能够支撑业务系统快速发展的一个重要因素

3.2.6容灾

我们使用缓存系统的初衷就是当数据请求量很大,数据库无法承受的情况,能够通过缓存来抵挡住大部分的请求流量,所以一旦缓存服务器发生故障,而缓存系统又没有一个很好的容灾措施的话,所有或部分的请求将会直接压倒数据库上,这可能会直接导致DB崩溃。
并不是所有的缓存系统都具有容灾特性的,所以我们在选择的时候,一定要根据自己的业务需求,对缓存数据的依赖程度来决定是否需要缓存系统的容灾特性。

3.3常见分布式缓存系统对比

3.3.1Memcached

Memcached严格的说还不能算是一个分布式缓存系统,但由于Memcached的开源,其访问协议也都是公开的,所以目前有很多第三方的客户端或扩展,在一定程度上对Memcached的集群扩展做了支持,但是大部分都只是做了一个简单Hash或者一致性Hash。
由于Memcached内部通过固定大小的chunk链的方式去管理内存数据,分配和回收效率很高,所以其读写性能也非常高。官方给出的数据,64KB对象的情况下,单机QPS可达到15w以上。

Memcached集群的不同机器之间是相互独立的,没有数据方面的通信,所以也不具备failover的能力,在发生数据倾斜的时候也无法自动调整。

Memcached的多语言支持非常好,目前可支持C/C++、Java、C#、PHP、Python、Perl、Ruby等常用语言,也有大量的文档和示例代码可供参考,而且其稳定性也经过了长期的检验,应该说比较适合于中小型系统和初学者使用的缓存系统。

3.3.2Redis

Redis被看作是Memcached的替代品。

Redis除了像Memcached那样支持普通的<k,v>类型的存储外,还支持List、Set、Map等集合类型的存储,这种特性有时候在业务开发中会比较方便。

Redis源生支持持久化存储,但是根据很多人的使用情况和测试结果来看,Redis的持久化性能不是很理想,官方也不推荐过度依赖Redis持久化存储功能。就性能来讲,在全部命中缓存时,Redis的性能接近memcached,但是一旦使用了持久化之后,性能会迅速下降,甚至会相差一个数量级。

3.3.3淘宝Tair

Tair是淘宝自主开发并开源的一款的缓存系统,是一套真正意义上的分布式并且可以跨多机房部署,同时支持内存缓存和持久化存储的解决方案。

Tair实现了缓存框架和缓存存储引擎的独立,在遵守接口规范的情况下,可以根据需求更换存储引擎,目前支持mdb(基于memcached)、rdb(基于Redis)、kdb(基于kyoto cabinet,持久存储,目前已不推荐使用)和rdb(基于gooogle的levelDB,持久化存储)几种引擎

由于基于mdb和rdb,所以Tair能够间距两者的特性,在并发量和响应时间上,接近二者的裸系统

在扩展性和容灾方面,Tair自己做了增强;通过使用虚拟节点Hash(一致性Hash的变种实现)的方案,将key通过Hash函数映射到到某个虚拟节点(桶)上,然后通过中心服务器(configserver)来管理虚拟节点到物理节点的映射关系。这样,Tair不但实现了基于Hash的首次负载均衡,同时又可以通过调整虚拟节点和物理节点的映射关系来实现二次负载均衡,这样有效的解决了由于业务热点导致的访问不均衡问题以及线性扩容时数据迁移麻烦;此外,Tair的每台缓存服务器和中心服务器(configserver)也有主备设计,所以其可用性也大大提高。

3.4缓存的设计与策略

3.4.1缓存对象设计

3.4.1.1缓存对象粒度

对于本地磁盘或分布是缓存系统来说,其缓存的数据一般都不是结构化的,而是半结构话或是序列化的;这就导致了我们读取缓存时,很难直接拿到程序最终想要的结果;这就像快递的包裹,如果你不打开外层的包装,你就拿不出来里边的东西.如果包裹里的东西有很多,但是其中只有一个是你需要的,其他的还要再包好送给别人;这时候你打开包裹时就会很痛苦——为了拿到自己的东西,必须要拆开包裹,但是拆开后还要很麻烦的将剩下的再包会去;等包裹传递到下一个人的手里,又是如此.

所以,这个时候粒度的控制就很重要了;到底是一件东西就一个包裹呢,还是好多东西都包一块呢?前者拆起来方便,后着节约包裹数量。映射到我们的系统上,我们的缓存对象中到底要放哪些数据?一种数据一个对象,简单,读取写入都快,但是种类一多,缓存的管理成本就会很高;多种数据放在一个对象里,方便,一块全出来了,想用哪个都可以,但是如果我只要一种数据,其他的就都浪费了,网络带宽和传输延迟的消耗也很可观。
这个时候主要的考虑点就应该是业务场景了,不同的场景使用不同的缓存粒度,折衷权衡;不要不在乎这点性能损失,缓存一般都是访问频率非常高的数据,各个点的累积效应可能是非常巨大的!

当然,有些缓存系统的设计也要求我们必须考虑缓存对象的粒度问题;比如说Memcached,其chunk设计要求业务要能很好的控制其缓存对象的大小;淘宝的Tair也是,对于尺寸超过1M的对象,处理效率将大为降低.
像Redis这种提供同时提供了Map、List结构支持的系统来说,虽然增加了缓存结构的灵活性,但最多也只能算是半结构化缓存,还无法做到像本地内存那样的灵活性。

粒度设计的过粗还会遇到并发问题。一个大对象里包含的多种数据,很多地方多要用,这时如果使用的是缓存修改模式而不是过期模式,那么很可能会因为并发更新而导致数据被覆盖;版本控制是一种解决方法,但是这样会使缓存更新失败的概率大大增加,而且有些缓存系统也不提供版本支持(比如说用的很广泛的Memcached)。

3.4.1.2 缓存对象结构

同缓存粒度一样,缓存的结构也是一样的道理。对于一个缓存对象来说,并不是其粒度越小,体积也越小;如果你的一个字符串就有1M大小,那也是很恐怖的.

数据的结构决定着你读取的方式,举个很简单的例子,集合对象中,List和Map两种数据结构,由于其底层存储方式不同,所以使用的场景也不一样;前者更适合有序遍历,而后者适合随机存取;回想一下,你是不是曾经在程序中遇到过为了merge两个list中的数据,而不得不循环嵌套?

所以,根据具体应用场景去为缓存对象设计一个更合适的存储结构,也是一个很值得注意的点。

3.4.2 缓存更新策略

缓存的更新策略主要有两种:被动失效和主动更新,下面分别进行介绍.

3.4.2.1 被动失效

一般来说,缓存数据主要是服务读请求的,并设置一个过期时间;或者当数据库状态改变时,通过一个简单的delete操作,使数据失效掉;当下次再去读取时,如果发现数据过期了或者不存在了,那么就重新去持久层读取,然后更新到缓存中;这即是所谓的被动失效策略。

但是在被动失效策略中存在一个问题,就是从缓存失效或者丢失开始直到新的数据再次被更新到缓存中的这段时间,所有的读请求都将会直接落到数据库上;而对于一个大访问量的系统来说,这有可能会带来风险。所以我们换一种策略就是,当数据库更新时,主动去同步更新缓存,这样在缓存数据的整个生命期内,就不会有空窗期,前端请求也就没有机会去亲密接触数据库。

3.4.2.2 主动更新

前面我们提到主动更新主要是为了解决空窗期的问题,但是这同样会带来另一个问题,就是并发更新的情况.
在集群环境下,多台应用服务器同时访问一份数据是很正常的,这样就会存在一台服务器读取并修改了缓存数据,但是还没来得及写入的情况下,另一台服务器也读取并修改旧的数据,这时候,后写入的将会覆盖前面的,从而导致数据丢失;这也是分布式系统开发中,必然会遇到的一个问题。解决的方式主要有三种:

1.锁控制:这种方式一般在客户端实现(在服务端加锁是另外一种情况),其基本原理就是使用读写锁,即任何进程要调用写方法时,先要获取一个排他锁,阻塞住所有的其他访问,等自己完全修改完后才能释放;如果遇到其他进程也正在修改或读取数据,那么则需要等待。锁控制虽然是一种方案,但是很少有真的这样去做的,其缺点显而易见,其并发性只存在于读操作之间,只要有写操作存在,就只能串行。

2.版本控制:这种方式也有两种实现,一种是单版本机制,即为每份数据保存一个版本号,当缓存数据写入时,需要传入这个版本号,然后服务端将传入的版本号和数据当前的版本号进行比对,如果大于当前版本,则成功写入,否则返回失败;这样解决方式比较简单;但是增加了高并发下客户端的写失败概率.还有一种方式就是多版本机制,即存储系统为每个数据保存多份,每份都有自己的版本号,互不冲突,然后通过一定的策略来定期合并,再或者就是交由客户端自己去选择读取哪个版本的数据。很多分布式缓存一般会使用单版本机制,而很多NoSQL则使用后者.

3.4.3 数据对象序列化

由于独立于应用系统,分布式缓存的本质就是将所有的业务数据对象序列化为字节数组,然后保存到自己的内存中。所使用的序列化方案也自然会成为影响系统性能的关键点之一。

一般来说,我们对一个序列化框架的关注主要有以下几点:

1.序列化速度:即对一个普通对象,将其从内存对象转换为字节数组需要多长时间;这个当然是越快越好

2.对象压缩比:即序列化后生成对象的与原内存对象的体积比

3.支持的数据类型范围:序列化框架都支持什么样的数据结构;对于大部分的序列化框架来说,都会支持普通的对象类型,但是对于复杂对象(比如说多继承关系、交叉引用、集合类等)可能不支持或支持的不够好

4.易用性:一个好的序列化框架必须也是使用方便的,不需要用户做太多的依赖或者额外配置

对于一个序列化框架来说,以上几个特性很难都做到很出色,这是一个鱼和熊掌不可兼得的东西(具体原因后面会介绍),但是终归有自己的优势和特长,需要使用者根据实际场景仔细考量。序列化工具对比请参考另一篇《序列化工具对比》.

参考

  1. https://www.cnblogs.com/siqi/p/5096317.html
  2. https://segmentfault.com/a/1190000003985468
  3. https://blog.csdn.net/damacheng/article/details/42846549
  4. https://engineering.linkedin.com/kafka/benchmarking-apache-kafka-2-million-writes-second-three-cheap-machines