一位七牛的资深架构师曾经说过这样一句话:前端
Nginx+业务逻辑层+数据库+缓存层+消息队列,这种模型几乎能适配绝大部分的业务场景。程序员
这么多年过去了,这句话或深或浅地影响了个人技术选择,以致于后来我花了不少时间去重点学习缓存相关的技术。web
我在10年前开始使用缓存,从本地缓存、到分布式缓存、再到多级缓存,踩过不少坑。下面我结合本身使用缓存的历程,谈谈我对缓存的认识。sql
1. 页面级缓存数据库
我使用缓存的时间很早,2010年左右使用过 OSCache,当时主要用在 JSP 页面中用于实现页面级缓存。伪代码相似这样:后端
<cache:cache key="foobar" scope="session"> some jsp content </cache:cache>`
中间的那段 JSP 代码将会以 key="foobar" 缓存在 session 中,这样其余页面就能共享这段缓存内容。 在使用 JSP 这种远古技术的场景下,经过引入 OSCache 以后 ,页面的加载速度确实提高很快。数组
但随着先后端分离以及分布式缓存的兴起,服务端的页面级缓存已经不多使用了。可是在前端领域,页面级缓存仍然很流行。缓存
2. 对象缓存性能优化
2011年左右,开源中国的红薯哥写了不少篇关于缓存的文章。他提到:开源中国天天百万的动态请求,只用 1 台 4 Core 8G 的服务器就扛住了,得益于缓存框架 Ehcache。服务器
这让我很是神往,一个简单的框架竟能将单机性能作到如此这般,让我欲欲跃试。因而,我参考红薯哥的示例代码,在公司的余额提现服务上第一次使用了 Ehcache。
逻辑也很简单,就是将成功或者失败状态的订单缓存起来,这样下次查询的时候,不用再查询支付宝服务了。伪代码相似这样:
添加缓存以后,优化的效果很明显 , 任务耗时从原来的40分钟减小到了5~10分钟。
上面这个示例就是典型的「对象缓存」,它是本地缓存最多见的应用场景。相比页面缓存,它的粒度更细、更灵活,经常使用来缓存不多变化的数据,好比:全局配置、状态已完结的订单等,用于提高总体的查询速度。
3. 刷新策略
2018年,我和个人小伙伴自研了配置中心,为了让客户端以最快的速度读取配置, 本地缓存使用了 Guava,总体架构以下图所示:
那本地缓存是如何更新的呢?有两种机制:
客户端启动定时任务,从配置中心拉取数据。
当配置中心有数据变化时,主动推送给客户端。这里我并无使用websocket,而是使用了 RocketMQ Remoting 通信框架。
后来我阅读了 Soul 网关的源码,它的本地缓存更新机制以下图所示,共支持 3 种策略:
▍ zookeeper watch机制
soul-admin 在启动的时候,会将数据全量写入 zookeeper,后续数据发生变动时,会增量更新 zookeeper 的节点。与此同时,soul-web 会监听配置信息的节点,一旦有信息变动时,会更新本地缓存。
▍ websocket 机制
websocket 和 zookeeper 机制有点相似,当网关与 admin 首次创建好 websocket 链接时,admin 会推送一次全量数据,后续若是配置数据发生变动,则将增量数据经过 websocket 主动推送给 soul-web。
▍ http 长轮询机制
http请求到达服务端后,并非立刻响应,而是利用 Servlet 3.0 的异步机制响应数据。当配置发生变化时,服务端会挨个移除队列中的长轮询请求,告知是哪一个 Group 的数据发生了变动,网关收到响应后,再次请求该 Group 的配置数据。
不知道你们发现了没?
长轮询是一个有意思的话题 , 这种模式在 RocketMQ 的消费者模型也一样被使用,接近准实时,而且能够减小服务端的压力。
关于分布式缓存, memcached 和 Redis 应该是最经常使用的技术选型。相信程序员朋友都很是熟悉了,我这里分享两个案例。
1. 合理控制对象大小及读取策略
2013年,我服务一家彩票公司,咱们的比分直播模块也用到了分布式缓存。当时,遇到了一个 Young GC 频繁的线上问题,经过 jstat 工具排查后,发现新生代每隔两秒就被占满了。
进一步定位分析,原来是某些 key 缓存的 value 太大了,平均在 300K左右,最大的达到了500K。这样在高并发下,就很容易 致使 GC 频繁。
找到了根本缘由后,具体怎么改呢? 我当时也没有清晰的思路。 因而,我去同行的网站上研究他们是怎么实现相同功能的,包括: 360彩票,澳客网。我发现了两点:
一、数据格式很是精简,只返回给前端必要的数据,部分数据经过数组的方式返回
二、使用 websocket,进入页面后推送全量数据,数据发生变化推送增量数据
再回到个人问题上,最终是用什么方案解决的呢?当时,咱们的比分直播模块缓存格式是 JSON 数组,每一个数组元素包含 20 多个键值对, 下面的 JSON 示例我仅仅列了其中 4 个属性。
[{ "playId":"2399", "guestTeamName":"小牛", "hostTeamName":"湖人", "europe":"123" }]
这种数据结构,通常状况下没有什么问题。可是当字段数多达 20 多个,并且天天的比赛场次很是多时,在高并发的请求下其实很容易引起问题。
基于工期以及风险考虑,最终咱们采用了比较保守的优化方案:
1)修改新生代大小,从原来的 2G 修改为 4G
2)将缓存数据的格式由 JSON 改为数组,以下所示:
[["2399","小牛","湖人","123"]]
修改完成以后, 缓存的大小从平均 300k 左右降为 80k 左右,YGC 频率降低很明显,同时页面响应也变快了不少。
但过了一会,cpu load 会在瞬间波动得比较高。可见,虽然咱们减小了缓存大小,可是读取大对象依然对系统资源是极大的损耗,致使 Full GC 的频率也不低。
3)为了完全解决这个问题,咱们使用了更精细化的缓存读取策略。
咱们把缓存拆成两个部分,第一部分是全量数据,第二部分是增量数据(数据量很小)。页面第一次请求拉取全量数据,当比分有变化的时候,经过 websocket 推送增量数据。
第 3 步完成后,页面的访问速度极快,服务器的资源使用也不多,优化的效果很是优异。
通过此次优化,我理解到: 缓存虽然能够提高总体速度,可是在高并发场景下,缓存对象大小依然是须要关注的点,稍不留神就会产生事故。另外咱们也须要合理地控制读取策略,最大程度减小 GC 的频率 , 从而提高总体性能。
2. 分页列表查询
列表如何缓存是我很是渴望和你们分享的技能点。这个知识点也是我 2012 年从开源中国上学到的,下面我以「查询博客列表」的场景为例。
咱们先说第 1 种方案:对分页内容进行总体缓存。这种方案会 按照页码和每页大小组合成一个缓存key,缓存值就是博客信息列表。 假如某一个博客内容发生修改, 咱们要从新加载缓存,或者删除整页的缓存。
这种方案,缓存的颗粒度比较大,若是博客更新较为频繁,则缓存很容易失效。下面我介绍下第 2 种方案:仅对博客进行缓存。流程大体以下:
1)先从数据库查询当前页的博客id列表,sql相似:
select id from blogs limit 0,10
2)批量从缓存中获取博客id列表对应的缓存数据 ,并记录没有命中的博客id,若没有命中的id列表大于0,再次从数据库中查询一次,并放入缓存,sql相似:
select id from blogs where id in (noHitId1, noHitId2)
3)将没有缓存的博客对象存入缓存中
4)返回博客对象列表
理论上,要是缓存都预热的状况下,一次简单的数据库查询,一次缓存批量获取,便可返回全部的数据。另外,关于 缓 存批量获取,如何实现?
第 1 种方案适用于数据极少发生变化的场景,好比排行榜,首页新闻资讯等。
第 2 种方案适用于大部分的分页场景,并且能和其余资源整合在一块儿。举例:在搜索系统里,咱们能够经过筛选条件查询出博客 id 列表,而后经过如上的方式,快速获取博客列表。
首先要明确为何要使用多级缓存?
本地缓存速度极快,可是容量有限,并且没法共享内存。分布式缓存容量可扩展,但在高并发场景下,若是全部数据都必须从远程缓存种获取,很容易致使带宽跑满,吞吐量降低。
有句话说得好,缓存离用户越近越高效!
使用多级缓存的好处在于:高并发场景下, 能提高整个系统的吞吐量,减小分布式缓存的压力。
2018年,我服务的一家电商公司须要进行 app 首页接口的性能优化。我花了大概两天的时间完成了整个方案,采起的是两级缓存模式,同时利用了 guava 的惰性加载机制,总体架构以下图所示:
缓存读取流程以下:
一、业务网关刚启动时,本地缓存没有数据,读取 Redis 缓存,若是 Redis 缓存也没数据,则经过 RPC 调用导购服务读取数据,而后再将数据写入本地缓存和 Redis 中;若 Redis 缓存不为空,则将缓存数据写入本地缓存中。
二、因为步骤1已经对本地缓存预热,后续请求直接读取本地缓存,返回给用户端。
三、Guava 配置了 refresh 机制,每隔一段时间会调用自定义 LoadingCache 线程池(5个最大线程,5个核心线程)去导购服务同步数据到本地缓存和 Redis 中。
优化后,性能表现很好,平均耗时在 5ms 左右。最开始我觉得出现问题的概率很小,但是有一天晚上,忽然发现 app 端首页显示的数据时而相同,时而不一样。
也就是说: 虽然 LoadingCache 线程一直在调用接口更新缓存信息,可是各个 服务器本地缓存中的数据并不是完成一致。 说明了两个很重要的点:
一、惰性加载仍然可能形成多台机器的数据不一致
二、 LoadingCache 线程池数量配置的不太合理, 致使了线程堆积
最终,咱们的解决方案是:
一、惰性加载结合消息机制来更新缓存数据,也就是:当导购服务的配置发生变化时,通知业务网关从新拉取数据,更新缓存。
二、适当调大 LoadigCache 的线程池参数,并在线程池埋点,监控线程池的使用状况,当线程繁忙时能发出告警,而后动态修改线程池参数。
缓存是很是重要的一个技术手段。若是能从原理到实践,不断深刻地去掌握它,这应该是技术人员最享受的事情。
这篇文章属于缓存系列的开篇,更可能是把我 10 多年工做中遇到的典型问题娓娓道来,并无很是深刻地去探讨原理性的知识。
我想我更应该和朋友交流的是:如何体系化的学习一门新技术。
后续我会连载一些缓存相关的内容:包括缓存的高可用机制、codis 的原理等,欢迎你们继续关注。
关于缓存,若是你有本身的心得体会或者想深刻了解的内容,欢迎评论区留言。
做者简介:985硕士,前亚马逊工程师,现58转转技术总监
欢迎扫描下方的二维码,关注个人我的公众号:IT人的职场进阶