转自:同花顺微信公众号
随着手机、平板等设备的日益发展,移动端炒股的门槛越来越低,随时随地的查看行情、进行股票交易早已成为亿万股民每天日常生活的一部分。
同花顺作为国内第一家互联网金融信息服务行业上市公司,凭借其优质稳定的服务吸引了庞大的用户群体,截止至2019年,同花顺的注册用户已经超过了3.8亿,日活跃用户超过千万。用户的每一次指尖在屏幕上触碰滑动,不可胜数的数据穿越空间的屏障在用户眼前准确展现,弹指之间,这样的数据交换便已演绎无数回。
用户量大意味着高并发,作为同花顺的一枚后端小开发,从入职那一刻起便面临着这样一道难题:「如何让我们的服务承载起千万用户所带来的请求量?」
前言
最近产品经理向本开发展示了他的创意头脑,要求在每支A股的分时图之下展示该股的龙虎榜、港资流向、融资流向以及大宗交易等信息。
本来这几个数据属于不同的模块,现在需要将他们汇总在一起向用户展示,使得用户可以更高效地获取资金方面的数据信息。
得知需求的我开始了思考 —— 接口中每次从各模块取数据效率太低,肯定不现实;A股目前也就三千多支,而且这几类数据基本都是每天盘后更新或者T+1的,所以设置几个定时任务到点去各模块接口存起来,接口输出为json格式, mongodb 天然地支持 json 格式并且支持字段更新,而且网传性能优异,于是用 mongodb 作为存储的方案拍案出世。
随后数据部门给出了页面访问量的统计数据,高峰时期qps将近1w,得知此数据的我内心「咯噔」了一下,赶紧按之前想好的思路实现了基本功能进行压测,可压测结果只能达到 qps 900+,远不及预期,看来产品经理给我出了道难题。
几番断点调试和测速之下,最终定位到是 mongodb 层面拖了后腿,看来是时候祭出缓存这一杀手锏了。
01为什么要用缓存
在千万级用户的服务中,用户数据必然卷帙浩繁,选用数据库对数据进行落地是必然选项。而将数据库的操作直白地暴露在接口中,即使是做好了各类的索引优化,在数据量巨大和高并发的情况之下,数据库会成为性能瓶颈。
以MySQL为例,MySQL的数据都是存储在表中的,查找数据时需要对表格进行全局查询或者按照索引查询,而这些操作必然会涉及磁盘检索操作,而磁盘的顺序查找是比较慢的。何况,业务程序和MySQL服务之间数据以TCP/IP协议进行交互,其操作耗时又同网络情况搭上关系,所以往往这一块会拖累整体的性能。
那么在业务程序和MySQL服务之间搭建起一层缓存,将热点查询的数据冗余起来,业务程序优先读取缓存,那么必然将提高查询读取并且释放了数据库的压力,从而达到了提高性能的目的。
02缓存的类型
上面说到,缓存其实可以看做一种数据的冗余,那么从层次的角度来看,可以将缓存分成 客户端缓存、服务端缓存、中间代理缓存 三层;
换个角度,如果从数据的操作行为来看,又可以分为 读缓存 和 写缓存。
按层次分类
客户端缓存
客户端缓存,顾名思义,就是将缓存数据保存在客户端,客户端在发起请求之前先检查本地是否有缓存,如果有则直接使用,否则发起请求,再根据请求内容做好缓存。
客户端缓存的使用的主要目的就是减少对服务端的请求量,从而缓解服务端压力。
手机应用程序可以通过自己实现缓存逻辑来存储接口数据,并且做好过期控制,所以下面仅针对浏览器进行讨论;
静态资源
静态资源是指 html文件、js脚本、css样式文件或者纯文本,它们通常不会经常性地发生变动。
以 Chrome 浏览器为例,默认情况下,开发者选项的 Network 页卡下的 Disable cache 不选中,意味着在使用浏览器的过程中,其内核就帮我们自动地做好了上述的这套逻辑,手机上的浏览器客户端亦是同理。但是在网页内,针对这套自发性的缓存逻辑的缓存失效时间没有很好的方式去控制,服务端可以通过设定HTTP响应头来进行控制;
通常情况下,我们会将静态资源放在 CDN 上,让客户端访问就近的资源服务服务器上的资源,一方面提高了资源访问效率,另外一方面也缓解了源服务器的网络IO压力。
这里我们可以拿PHP7.1的接口和静态文本使用ab压测工具进行测试对比,接口为简单的echo操作,两个接口输出内容一致,web容器采用 nginx 。在请求总量为20000的情况下,并发数和qps数据的情况如下表:
这个简单的压测可以看出,文本的qps可以一直稳定在7500以上,而且出错率都为0;而用PHP实现的接口在并发数越高的情况下出错率也不断上升。由此可见,静态资源在性能和稳定性上有很大的优势。
动态数据
动态数据往往指经常发生变动的数据,因变量可能是请求参数、当前时间等等,这些一般都和具体的业务逻辑有关。
出于安全考虑,浏览器不允许网页内的js对设备的存储和内存进行操作,但是在单次页面生命周期中,js可以将数据以变量的形式保存在浏览器分配给页面的内存之中来实现缓存的目的。而当页面跳转或者页面刷新,前一页面的生命周期结束,所有的js变量随之释放,缓存自然也就失效;
2014年,HTML5标准的制定诞生了 localStorage 和 sessionStorage,从而赋予了前端跨页面生命周期保存数据的能力,其简介摘录如下:
服务端缓存
相比于客户端单一的缓存形式,服务端的缓存更加层次分明,大致上可以分成 底层、服务层、业务层、三层。
底层
这里的底层指的是 硬件 或者 操作系统 级别的缓存。
硬件
-
硬盘 所有的机械硬盘或者固态硬盘都设计有高速的缓冲区域,容量远小于硬盘标称容量,用来应对瞬发的高速写入,而后缓冲区会把数据再写入存储区,这会让使用者看起来磁盘运行很快;当缓冲区被占满之后,硬盘的写入速度便会下降;
-
CPU CPU的一级缓存、二级缓存甚至三级缓存是介于CPU和内存之间的容量较小的高速缓存,它缓存了CPU近期频繁读取的少量数据,而CPU速度远高于内存速度,CPU优先从高速缓存读取近期数据可以减少了CPU的等待时间,从而提高效率;
其他的硬件也都会自己的缓存设计,在此只举典型的两例。
操作系统
操作系统的 page cache 机制将经常读取的文件数据块缓存到内存,系统在读取文件内容时会有优先读取 page cache 中的相关内容,从而提高读取性能。当前 page cache 的大小可使用指令 free -m 进行查看,输出示例如下:
$ free -m
total used free shared buffers cached
Mem:102419183283400
-/+ buffers/cache:191832
Swap:000
buffers/cache 一栏为当前系统的 cache 和 buffer 的使用量和剩余量。
服务层
一些基础服务通常自身就有缓存设计。
使用过 MongoDB 的同学肯定会知道,MongoDB 是内存大户,在默认情况下,它会逐渐耗掉机器 50% 的内存来缓存热点数据,因此它能够提供相当高的读写性能。
又例如 MySQL 的 Query Cache,针对 select 类型的 query 操作会缓存住近一次的查询结果,后续相同的查询操作会直接返回这个缓存结果,直至有操作将数据更新。
业务层
一般业务会根据实际需要将各种来源的数据进行汇总加工产出,如果汇总和加工过程耗费较多的算力或者时间,则会将加工后的数据以数据库或者文件的形式落地起来,这时便已经使用了缓存。当请求过来时,业务从数据库或者文件将处理结果直接返回,节省了加工过程的资源开销。
随着业务访问量的上升,数据库或者文件的瓶颈就会体现,这时就需要开发人员选择合适的缓存方案,比如 Redis 或者 本地内存等。
中间代理缓存
中间代理通常是反向代理,反向代理位于服务器之间,一个请求的接收和响应都会经由此层,如果在反向代理层做好缓存并被请求命中,那么请求就会被立即响应,不必再经由服务器处理。
通常来讲,中间代理会针对 静态资源类型 的请求做好缓存处理,而 接口类型 的请求则会透传到上游服务器。
常见的反向代理服务有 nginx 和 squid 等,它们都可以实现请求过滤和缓存的功能。
下图从左到右简单地展示了上述的多层关系,双箭头表示两层之间存在数据交换并且设计有缓存。
上图中实例被外部依赖的程度逐渐减弱,数据库作为诸多业务的数据引擎支持,必须要保证其稳定运行,多级缓存的设计让最终到达数据库的请求数大大减少,可以较为有效地维护好数据库的稳定,同时可对外部提供稳定的服务支持。
按行为分类
读缓存
所谓读缓存,就是主要用来提高读取速度的缓存。
上面所介绍的大多数场景针对读多写少的情况,日常的开发过程中往往读多写少的场景占据大多数。这类缓存的创建和销毁时机往往需要仔细地考量,后续的章节将会进行介绍。
写缓存
所谓写缓存,就是用来缓解低速设备写入压力的缓存。
上面所举的磁盘的例子就是一个很典型的写缓存的应用,操作系统对于文件的写操作也都有缓冲机制,而写缓存恰好也是缓冲的一种实现。
在一些业务中,往往需要通过写缓存实现缓冲来降低上游业务的性能开销。
举个例子,假设公司要在某个节日进行运营活动,活动的参与门槛很低,参与的用户都能够获得一份虚拟礼品,赠礼品的操作比较复杂和耗时,而且允许礼品赠送存在延迟。
最简单实现需求的方式,就是在用户参与活动的时候就同步赠送礼品,但是一旦出现高并发的情况,赠礼品相关的模块肯定会挂掉,所以必须采用异步的方式,将参与用户放进高速的待处理队列中去,另外起程序实例去读取这个高速队列依次对用户进行赠送礼品操作。这个高速队列就可以成缓存,利用了缓存写入速度快的特征来实现接口的快速应答。这类的告诉队列可以采用 Redis 或者 Httpsqs 来实现。
一下为上述实例业务关键部分的草图,采用异步设计,还可以按照性能情况多运行几个赠礼程序实例来缩短赠礼延迟。
03缓存数据确定原则
通过上面的介绍,我们知道缓存的厉害之处,接下来一起来探讨一下哪些数据适合做缓存。
热点数据
热点数据即经常被访问的数据,大量的请求集中于此,所以必然是做缓存的重中之重。
-
热点数据可以在业务需求讨论中确定,比如确定页面投放位置之后,可以评估出访问量,进而估计数据的访问频次,再结合现有的设备资源情况考虑是否做缓存;
-
分析访问日志,分析统计出热点数据的请求后再有针对性地加上缓存处理;
-
往往数据库的IO情况可以体现出热点数据的所在,针对慢请求处理好缓存可以有效地缓解数据库压力。
高产出消耗数据
一些数据需要经过复杂计算的,或者加工耗时长久的数据往往都需要进行缓存,此类数据的缓存也可以大大地节省宝贵的计算机算力资源;
静态资源
在多层级的结构设计之中,静态资源请求若一直向后透传会造成内部网络资源的浪费,一般采用两种方式应对:
-
反向代理服务器处理静态资源(被动);
-
CDN存储静态资源(主动);
04缓存创建的时机
确定好目标数据之后,缓存创建方式的选择也相当重要,一般来讲可以分为 主动创建 和 被动创建 两类。
主动创建
主动创建缓存是指在数据产出的时候立即做好缓存,使得请求不再透传到数据产出服务。
比如,高产出消耗数据在产出之后立马存进数据库,那么请求都会在数据库层面即可响应,数据产出服务仅被调用一次。
使用主动创建的缓存的优点在于即使上游业务挂掉,缓存层的存在可以确保后续业务功能的正常,缺点则是需要另起程序实例定期地更新缓存,在数据量多的情况下,这将是一个耗时的过程。
被动创建
被动创建缓存是指数据在被访问的时候,先检查缓存是否有效,若有效则直接响应,否则向上游请求,将上游业务数据做好缓存之后再做出响应,流程图如下:
被动创建缓存优点在于逻辑简单,可以在业务层面之外进行搭建,扩展性比较好,缺点在于缓存数据的时效性往往做不到和业务要求一致,即上游业务数据更新,必须要等到已经做好的缓存失效了缓存才会更新,所以这个失效时间需要根据业务实际需要斟酌决定。
05成也缓存,败也缓存
通过以上介绍,多层级的缓存设计将请求过滤,使得到达缓慢设备的请求大大减少,从而使得系统稳定;写缓存的使用对缓慢设备的写入操作也提供了缓冲保护功能。
但是凡事皆有两面性,缓存毕竟是一种数据的冗余,在它提高我们应对高并发能力的同时也额外占据了我们服务器的资源,而且没有控制好缓存的使用也会造成很严重的问题。
资源损耗
缓存很强,但是不能滥用,将所有的数据都放进缓存中,或者没有设计好合适的缓存失效、淘汰机制,对资源反而会造成很大的浪费。
比如使用到 Redis 作为缓存的时候就必须考虑到数据的过期时间,因为作为内存型数据服务,内存的上限往往是比较少的,泛滥数据不断增加而不清空,很快内存资源就会消耗殆尽。
再比如以文件形式做的缓存,磁盘的空间和inode都是有限的,放任缓存不清理,缓存最终将会吃光所有资源,从而造成致命的问题。
在这方面我们可是吸取了不少的教训,所以对于缓存资源的使用必须要有监控和预警的机制,同时将缓存清理的操作作为程序设计上必须要素,加入到团队代码 review 的 check list 中去。
缓存更新
上面说到,缓存的更新时间需要结合业务需要确定,而在开发的过程当中这一点往往又会被疏忽,下面举一个简单的示例。
在多层级的缓存设计中,服务器端的缓存时间都是可以根据开发编写代码来控制的,但是在很多情况下,开发会忽略对HTTP响应头的处理,使得客户端将一些静态资源长期地进行缓存,比如将html页面,在这种情况下,即使后端更新了服务器上的html文件,但是客户端还是按照旧版html渲染页面,从而造成问题。
解决方案:
实际上响应头可以控制客户端对于静态数据的缓存时间,nginx 可以使用 expires 的配置很方便地加上相关响应头,相关介绍可参见nginx官方文档,配置示例如下:
# 配置demo目录下的所有txt文本的过期时间为1分钟
location ~^/demo/.*\.txt {
expires 1m;
}
接口响应则会自动加上控制过期的 Header
缓存一致性
在绝大多数情况下,为了保证服务的高可用,服务是部署在多台服务器上的,如果使用本地内存或者文件实现缓存,因为创建缓存的时机不同,缓存的内容会有不一致的可能,从而影响到业务。
解决方案:
在不改变缓存实现的情况下,主动创建型的缓存方式可以设立单点实例产出数据,再将数据分发给各个节点做好缓存,从而改善缓存不一致的问题,之所以是改善而非解决,是因为分发数据是一个过程,总有节点先拿到新数据,有节点后拿到,期间则会有短时的不一致。
引入缓存服务也可以解决此问题,比如 Redis 服务就能保证数据的一致并且高效,Redis 集群更是保证了缓存服务的高可用。但是相较本地文件和内存而言,需要额外承担网络层面的损耗。
缓存穿透
缓存穿透指的请求不存在的数据,缓存层面针对空数据不做缓存处理,使得请求穿透到数据库层面。在这类请求量大的时候,数据库就会挂掉。
解决方案:
-
使用过滤器来过滤掉此类请求,通常可以采用 布隆过滤器;
-
修改缓存层,使之支持缓存空值情况并设置有效时间;
缓存击穿(缓存并发)
缓存击穿或者缓存并发是指热点数据缓存在并发被请求的过程当中失效(或者是热点数据缓存还未创建时就迎来并发),瞬时有大量的请求透传到上游服务,使得上游服务挂掉,又称「dog-pile effec」;
解决方案
-
设置热点数据永不过期;
-
使用锁机制限制同一时间只有1个请求进行上游数据并制作缓存,其他请求则等待缓存创建完成再进行响应;
使用 nginx + fpm 结构的同学可能会比较头疼,因为 fpm 各进程之间不能很好地进行数据共享,只能通过轮询的方式对缓存进行检查。这里可以安利 lua-resty-lock 这个解决方案,使用 nginx 的共享内存作为缓存,GitHub上即有它的使用说明。
亲测一个查询 mongodb 数据的 php-fpm 接口,单点情况下压测qps达1000,而加上 lua-resty-lock 之后,qps可以提高到 8000 以上,性能提升十分明显。
缓存雪崩
缓存雪崩是指在同一时间有大量的缓存失效,使得此时有大量的请求穿透到数据库,使得数据库挂掉。
解决方案
在设置缓存失效时间的时候引入随机数,使失效时间分散。
06解决难题
通过对缓存的了解,本文引子这一章节中的场景非常适合使用多级缓存来进行处理,分析如下:
1、存在热点数据和冷门数据:数据以股票为单位进行汇总,所以一段时间内热门股票的数据肯定经常被访问到,所以热门股票数据肯定是缓存的重点;
2、数据需要更新,所以缓存必然需要设定过期时间;开盘期间的一瞬间必然大量用户涌入,数据可能还未有缓存保护,故这里需要注意缓存并发问题;
3、数据更新频率不频繁;
方案
-
将接口做成伪静态,通过HTTP响应头设置好过期时间,使得客户端缓存和 squid 层发挥效用;
-
在程序接口和反向代理之中新增一层缓存层,用 nginx + lua 实现,通过 lua-resty-lock 保证同一时刻同一支股票最多只有一个请求穿透缓存层,从上游拿到数据之后将缓存写到 shared_dict 之中,以此避免缓存并发问题,该过程的伪代码如下:
使用上述方案之后,测试环境进行压测,单点压测性能有了近乎10倍的增长,线上只需再进行节点的横向扩展,就能满足业务的性能需求;
效果
项目紧张地上线,灰度发布有条不紊地执行。
-
放量 10%:按照1w的qps评估,此阶段的qps应当会有1k左右,但是通过在缓存层的统计上来看,qps约在 400,可见 伪静态和客户端缓存已经发挥作用;
-
放量 20%:此阶段的qps预估2k左右,缓存层实际qps约在 650,此时统计到 mongodb 的最高每秒查询次数在 200 左右;
-
放量 50%:此阶段的qps预估5k左右,缓存层实际qps约在 800,说明这种架构之下qps和用户量并非呈现线性关系。mongodb 的最高每秒查询 仍然为 200 左右,说明此时缓存命中率相当高了;
-
放量 80%:此阶段的qps预估8k左右,缓存层实际qps约在 1100;
-
全部放开:此阶段的qps预估10k左右,缓存层实际qps约为 1300;
内层QPS和用户量的关系表现如下图
这个案例说明,在多级缓存的结构之下,越是内层的设备,其qps和用户量越不呈现线性关系,随着用户量的增加,qps的增长速度会放缓。可以解释为:在用户量增多的情况下,外层缓存的命中率逐渐提高,所以透入内层的请求并不线性增加。
展望
本方案也有两点缺陷:
-
在缓存未生成时,请求还是会透过各层到达 mongodb,如果此时 mongodb 繁忙,接口响应速度将会缓慢,影响用户体验;
-
缓存并发的控制依赖于 lua 控制的锁,在阻塞等待期间的请求越多,缓存层面的压力就会越大;
当用户量增长到 mongodb 不允许直接承受压力时,或者缓存层因为锁导致连接拥堵过多,则需要将缓存方式更改为主动缓存,这样性能将会有跟进一步的提升。
总结
本文主要探讨了高并发场景下的缓存使用,从缓存的概念、缓存类型、缓存数据原则和使用缓存带来的问题及其解决方案 几个方面来介绍缓存。
缓存在整个系统中是介于高速设备和低速设备之间的中间层,是一种数据的冗余,这样的数据冗余是需要消耗系统资源的,所以需要有针对缓存的定时清理机制。
从层次上看待缓存,一个系统中的缓存是以多层级的形式存在的,从最接近的客户端层面,到最底层的硬件或者操作系统层面,每一层之间都存在缓存,每一层的缓存都为下一层过滤掉了请求,使得到达缓慢设备的请求大量减少,从而达到保护目的,这也是缓存应对高并发问题的关键。
从行为上看待缓存,缓存除了被读之外还可以作为写入缓冲,极大地缓解了缓慢设备瞬时大量写入的情况。
在缓存数据确定原则和缓存创建时机两节,本文介绍了缓存数据的基本确定方法和创建形式,创建形式分为主动型和被动型,各有优缺点,需要根据业务实际情况选择。
缓存作为数据冗余,在系统中也是一种双刃剑的存在,本文在最后介绍了常见的缓存问题并提出了对应的解决方案。
在介绍完缓存的概念之后,文章在最后一章给出了引子部分场景的解决方案,并简单说明了使用缓存之后的效果。
全部评论
(1) 回帖