真实案例-高并发系统的缓存设计思路

前言

今天的分享来自我职业生涯中的一个真实项目,在本文中就称之为R项目吧。对于一些不方便直接透露的东西,用比较通用的词汇来代替,尽可能完整地还原当时在业务场景与技术方案选择上的思考。
R项目服务了国内半数以上的安卓手机用户。对用户来说,R提供的是用户比较感兴趣的一些便捷服务。对企业来说,巨大的流量是商业化的最佳战场,意味着不菲的收入,关系着企业以及员工的钱袋子。

背景&问题

  • 这是一个高并发读的服务场景,没有写入。
  • 对响应时间要求苛刻,否则将严重影响用户体验。后来内网压测时的平均响应时间在10ms以下。
  • 根据客户端参数,进行一些复杂的规则匹配与计算,然后将匹配到的数据返回。
  • 客户端参数包括:群组、版本、分类、关键词、编号、特征数组
  • 后台配置的规则由以上参数排列组合而成,编号需要支持范围匹配,特征数组需要求交集等等。
  • 运营后台对规则的增删改需要在五分钟内生效。

参数的组合太多,规则的组合也很多,这看似是一个时间复杂度O(n^2)的查询场景。10ms响应时间的限制由不得我们笛卡尔积似地慢慢匹配。

业界通用方案:缓存

缓存在很多互联网系统中都是很正常的存在,虽然具体用法会因为业务场景五花八门,但疏通同归,目标只有一个:尽最大可能把每一次请求变成时间复杂度为O(1)的查询。

在设计方案时,整体上我想到了以下几点:

  1. MVVM架构思想:运营库与线上库隔离解耦,配置数据库为Model层,配置平台输出给线上库的数据属于View层,线上接口输出给客户端的数据属于View-Model层。View-Model层在数据结构上自然体现客户端展示逻辑。
  2. 缓存应放在离用户最近的地方:对响应时间苛刻的服务端来说可采用进程内缓存。带来的是分布式数据一致性问题,当前场景下采用最终一致性策略,时间控制在五分钟内。
  3. 如何避免穿透、击穿、雪崩问题:多级缓存策略。整体分为进程内一级缓存与redis二级缓存,所有请求到一级缓存为止。通过异步刷新、检查的方式同步二级缓存数据到进程内。运营平台可以随时向redis写入数据。

序列图如下:


R项目多级缓存策略.png

缓存key的生成策略

原则:key的粒度要掌握好,必须能覆盖大部分请求,在内存成本与响应速度之间做好权衡(trade-off)

补充业务背景信息:由于群组、版本、编号、分类、关键词的个数是有限的,一个大的人群特征通常也相同。

群组+版本+编号+分类+关键词+特征组合起来后生成MD5值,该值作为缓存key。

过期&刷新时间策略

原则:根据数据生效时间的限制,使各个部分的时间满足一个或多个不等式。

  • 一级响应缓存过期时间:T1(1分钟)
  • 一级预计算缓存刷新时间:T2(1分钟,决定了数据生效的最小时间)
  • 一级预计算缓存过期时间:T3(4分钟,决定了数据生效的最大时间),达到key数量上限时LRU淘汰。
  • 定时同步一级预计算缓存时间:每T4分钟(4分钟),从redis检查一次新上线的数据,添加到一级预计算缓存中。

为了保证数据的更新在5分钟内生效,几个时间的设置应满足以下关系:
T1+T4<=5
T1+T3<=5
T1+T2<5,对于持续高频命中一级预计算缓存的key(每个T2时间区间内都有一次命中),数据更新后的生效时间是会提前的,所需时间是:T1+T2。T1+T2<5。

避免没有必要的计算

原则:通过一套规则来预判是否需要进行下一步计算。注定没有结果的计算就不需要缓存任何数据,避免内存浪费。

补充业务背景信息:关键词的总个数有限,但至少数十万。运营配置能覆盖的是少数头部关键词。其它的关键词不会有任何结果。

因此,将配置库中的所有关键词放到内存中,形成一套索引。以O(1)时间即可判断一个关键词是否需要有后续计算。

进程内缓存开发库:Caffeine

现在,我们已经确认了一个适用于实际场景的多级缓存方案。尽可能地降低了响应时间,避免了CPU和内存资源的浪费。接下来就要看看有没有现成的技术组件可以帮助我们快速实现方案,毕竟造轮子是有成本的,找一个优秀的轮子有助于快速实现商业价值。

Redis是业界比较通用的选择,数据量相对较多的二级预计算缓存放在Redis里。既能满足容量要求,又能满足加载速度的要求,让新的配置更快生效。在这个项目中对Redis的要求并不高,一个单点足以。但系统对Redis的qps达到单点上限时,进行水平扩容即可。

进程内缓存使用的是Caffeine。

Caffeine的数据驱逐策略

它支持基于大小、时间、引用三种驱逐策略,我们选择基于时间的策略。该策略下又分为三种定时驱逐策略:

  1. expireAfterAccess(long,TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么该缓存将一直不过期。
  2. expireAfterWrite(long,TimeUnit):在最后一次写入缓存后开始计时,在指定的时间后过期。
  3. expireAfter(Expiry):自定义策略,过期时间由Expiry实现自定义计算。

Caffeine的驱逐会阻塞查询操作(使用了锁),因此要尽量可能少地发生驱逐,因此一级预计算缓存选择expireAfterAccess的驱逐策略,一级响应缓存采用expireAfterWrite策略,确保热点数据不会被提前淘汰。
对于已经过期的数据,Caffeine并不会在到期后立即删除,而是再次请求时异步检查数据是否已过期,过期则删除。

当缓存的数据达到设置的maxsize后,如何进行数据淘汰?

Caffeine将缓存的数据分为三部分(三个队列):

  1. Eden 新生队列,容量为size的1%,记录的是新生的数据,防止突发流量时,由于新数据没有访问频率而被淘汰。
  2. Probation 缓刑队列,真正进行数据淘汰的地方,容量为size的79.2%。Eden满了之后,Eden中访问频率较低的数据会进入Probation队列,Probation满了之后会淘汰访问频率低的数据。
  3. Protected 保护队列,容量为size的19.8%。当Probation中的某个数据被访问后,这个数据将会进入Protected队列。当Protected满了或Probation中没有数据了,依然会将访问频率低的数据放入Probation队列。
    Probation队列在进行数据淘汰时,是先比较头部和尾部数据使用频率,频率低则淘汰。当尾部数据频率小于5时,直接淘汰尾部数据。Caffeine的作者认为设置这样一个预热值,更有利于提升命中率。

结语

以上内容从业务背景出发,引出缓存设计的一些方法论(原则),最后对进程内缓存库Caffeine进行了介绍。如果你有一些疑问,欢迎留言讨论。微信/知乎可搜索码神手记同名账号,分享关注,共同进步。

你可能感兴趣的:(真实案例-高并发系统的缓存设计思路)