likes
comments
collection
share

酷家乐权限权益中台缓存架构

作者站长头像
站长
· 阅读数 22

中台诞生的背景

在酷家乐商业化体系中,设计工具各个业务线都有将自己的功能打包成商品进行商业化售卖的需求,因此服务于商业化的权限权益中台因此而生。

中台的现状

业务现状

中台在近几年满足商业化需求的演化过程中,逐渐形成了一套基于SaaS软件体系并服务于tob/c用户的产品形态,并根据自己的业务场景实现了诸如ACL/RBAC/ABAC/PBAC模型,同时可以做到一定程度的组合。其整体核心流程为酷家乐->虚体->用户的授权(写操作),以及用户鉴权(读操作)。以下是一个用户鉴权的大致模型:

酷家乐权限权益中台缓存架构

在业务逐渐演化的过程中,中台在传统的权限模型上迭代出了一个叫设置项的模块,该模块旨在帮助商家(虚体)或者用户对已买功能的开关控制等其他形式的配置。具体举个例子:用户到4s店购车并选择完毕具体的车辆后又选装一个智能驾驶包。用户在车辆交付后深入体验了智能驾驶包中的车道保持功能,觉得并非所有场景下都能适用,因此就决定在车机系统中暂时关闭了这个功能。在这个场景中,智能驾驶包商品包含了诸多的权限点集合,一同下发给了用户,此为酷家乐对用户的一次授权,而用户在深入体验完某项功能后需要在某些场景下关闭,则可以通过设置项进行关闭,设置项起到了用户对功能的二次自主控制。

系统现状

目前酷家乐的日活用户数已达百万量级,而权限权益中台作为仅次于用户中心的底层核心中台,因此其整体峰值QPS已经达到了数万级,日调用量近十亿。为了能够满足其复杂的业务并在平均几毫秒的要求下支撑数万并发量的请求,其挑战不言而喻。 目前系统采用了CQRS的架构模式,具体到服务是3个,我们暂定为arc-a,arc-b,arc-c。

酷家乐权限权益中台缓存架构 职责划分:

  • arc-a: 负责酷家乐对虚体和虚体中用户的授权,以及商家管理后台的管理能力。
  • arc-b: 负责对用户具体行为的鉴权。
  • arc-c:负责对权限定义、角色等后台管理能力。 其中本文所要讲述的缓存架构是横跨在arc-a和arc-b中的。

中台缓存架构

本文的缓存结构将以缓存的形式、存储介质、一致性和预热问题解决方案等贯穿整个架构体系的描述。具体到每一种业务场景,缓存架构的实现针对以上几个问题都会不同,后续我将针对鉴权模块和设置项计算模块的缓存架构进行详细的阐述。但是针对不同的业务场景,有些东西其实是一致的,比如:在数据层面都可以区分为后台数据和用户数据。

  • 后台数据的数据量一般是可以枚举的,并且更新的实时性要求并不是很高。比如权限点的数量是可以枚举的,其增量取决于业务线新上线功能的频度,对于权限点的属性定义反馈到所有用户层面也不一定需要实时,只需要近实时即可。因此此类数据我们可以把所有后台控制的数据全部加载到内存,并依据一定的刷新策略做到分钟级生效即可。
  • 用户数据的数据量一般是不可以枚举的,其体量无法估计。比如商家购买的vip数量以及商家给用户绑定vip后是需要立即生效的,即权限发生变化需要在秒级让用户感知。因此此类数据最终还是以数据库为主,缓存仅仅是起到部门请求的加速和抗压作用,并不能全量加载。

权限缓存架构

酷家乐权限权益中台缓存架构 在权限计算的整个流程中有4处地方使用了缓存相关的技术,他们分别使用了不同形式的缓存方案。接下来我将根据编号进行逐一介绍。

  1. 后台数据缓存层:主要存储权限属性,各种角色vip的权限定义等。

    • 缓存模式:预刷新(Refresh Ahead)
    • 缓存的形式/介质:因为数量有限即占用的内存不大,因此可以将所有数据全量加载到jvm堆内存中(ConcurrentHashMap实现)。
    • 预热问题:在服务启动的时候利用spring的ApplicationListener机制全量加载到内存中。
    • 一致性问题:由于此类数据的变更都是通过arc-c服务的权限管理模块进行,因此我们特意开发了一个后台数据热更新的组件,通过类似定时扫描并处理增量消息的机制在短时间内加载并更新掉内存中对应的值,保证在数秒内达到所有pod的最终一致性。
  2. 用户信息缓存层:因为酷家乐的权限模型是包含了ABAC的,比如一个权限点对于商家需要全都有,对于个人用户则按需购买。但是我们又不可能对于所有商家用户去全量下发该权限点,因此权限上需要依据用户的属性去配置成特定身份的用户全都有,因此权限的计算是强依赖用户属性的。由于权限计算的耗时和稳定性要求(包括消除下游抖动),我们把用户信息进行了内存缓存。

    • 缓存形式/介质:起初我们是选择了caffeine并根据日活来设置最大存放数量和缓存到期时间,但是我们通过监控发现,服务的老年代在不断的上涨,并且达到一定数量以后触发了G1的mixed-gc,gc完毕以后老年代几乎可以回收绝大部分的数据。所以一定是有一部分数据满足了老年代的晋升策略,并且在一段时间以后这些数据并不会被GC-ROOT引用最终可以被释放掉,因此我们想到了用户信息数据其实是满足这一类场景的。因为缓存的原因,数据一般在多次young-gc后晋升到了老年代,而由于在高并发场景下会一直触发缓存淘汰策略和缓存到期策略,在老年代中存放了被淘汰掉的无用用户信息数据,且是可以被下一次mixed-gc数据释放掉的。由于这个问题无法根除,因此我们最终选择了OHC缓存框架进行用户信息的缓存。此框架的存储地是jvm的直接内存,框架帮我们自动完成了直接内存相关的分配和释放等繁琐功能(由于序列化的原因,其读取和写入相较堆内存的操作从百纳秒级升级到了十微秒级)。框架自身强大的自定义能力再配合Protostuff序列化工具(可以简单理解为无proto的Protobuf版本),让我们可以从caffeine快速迁移到OHC框架。上线之后通过监控发现,老年代上涨速度明显降低,并且young-gc的耗时和频度也显著下降了。线上OHC框架相关的配置参数:

      字段含义
      segmentCount256ohc框架的并发粒度在分段上,有点像jdk1.7中的ConcurrentHashMap实现
      evictionlru由于我们几乎想所有日活用户信息进行了缓存,因此策略选择最简单的LUR
      hashModeCRC32C据说性能最好
      defaultTTLmillis3H缓存到期时间
    • 预热问题:因为我们把大量的用户信息缓存到了服务器层面,因此服务的重启对于下游来说可以说是一种灾难。每次服务的发布必然会导致下游用户中心相关接口流量突增数倍,带来稳定性上的隐患。因此我们在服务启动一段时间后将OHC框架中缓存的数据序列化存储到k8s的volumeMounts:persistentVolumeClaim中。随着服务启动后新的pod会在预热阶段读取共享磁盘中的文件并加载到OHC框架的缓存中。因为预热加载的数据只是缓和对下游的压力,因此到期时间并非原先的3H,而是根据用户id的尾号平均散列到30min内过期。

    • 一致性问题:使用之前介绍过的热更新组件再配合监听用户信息变更消息来做到实时更新到内存中。

  3. 用户权限缓存层:因为权限鉴权接口的定义:请求参数=用户id+待鉴权的列表,返回参数=该用户在待鉴权列表中实际拥有的权限点,并且所有中台所有权限点是全部平铺且无任何关联的。因此我们可以将用户拥有的所有权限点一次计算完毕以后,全部存储到缓存中,其好处就是一次用户的计算后续可以多次使用。

    • 缓存模式:旁路缓存(Cache Aside)
    • 缓存的形式/介质:缓存的存储形式选用了redis,并在数据结构上我们使用了map的数据结构。因为商家下账号的权限计算结果和商家本身的权限是强关联的,而商家在购买权限类商品发放到商家层级权限集合的时候,我们并不是去轮询商家下的所有账号并逐一失效缓存的,而是通过使用商家权限的版本并一同和用户权限集合一起以map的数据结构缓存到redis中。在取缓存数据的时候进行实时的比对以判断缓存是否有效,属于一种被动形式的缓存失效策略。
    • 一致性问题:此类缓存的设计和业界大多数场景都是一样的,其缓存和数据库的一致性采用cache-aside+延时双删保证一致性。因为我们在服务的整体部署上是存在多云架构的,因此延时双删的第二次删除我们采用了之前介绍的热更新组件来完成的,再利用dts服务做到多云下的缓存数据一致。
  4. 这部分的缓存设计相对于以上3种会稍微复杂一点,而我们给它定义为在客户端上的链路请求复用缓存技术。这里以一段代码来引出一类比较极端的场景来解释此设计的目的和意义:

Long userId = 1L;
for(Long apId : apIds)
{
    if(auth(userId, apId))
    {
        //todo
    }
}

因为上游使用方在代码编写或者逻辑要求下可能针对同一个用户在相同请求链路或者同代码块上做多次鉴权,而在中台侧其实是在第一次请求的时候就已经缓存住了该用户所有的权限点集合,因此后续的请求只不过是在不断通过鉴权服务来访问缓存,无效得消耗并占用了鉴权服务的线程资源和cpu资源。那么假如中台可以针对一种请求链路中(trace级),在同一个用户第一次鉴权后就可以返回链路后续所有要待鉴权的结果,然后通过类似mdc+rpc上下文数据透传技术,就可以实现鉴权请求的复用。

酷家乐权限权益中台缓存架构

针对以上相对简单的调用链路图(复杂的其实也一样),中台要实现链路级别的请求复用其实转化为了下面这个问题: 如何针对同一种请求链路去缓存住该链路下所有待鉴权的权限点?

首先的问题:怎么定义是同一种请求链路? 其实在图中基本已经给出了答案。就是从网关或者其下链路中任何第一次对鉴权服务发起调用的服务,其被调用接口的uriPattern即可以作为该链路的标志。那么后续只需要将待鉴权的权限合并到以该标志为key,合并后的权限集合为value的缓存即可。

第二个问题:权限集合又是如何怎么合并出来的呢? 假如当前arc-b没有任何该链路的缓存数据,当service-a来鉴权调用-1:apId1(代表图中的请求1,鉴权请求权限点为apId1)会在请求的header中带上service-a被请求接口的uriPattern1。此时arc-b记录了uriPattern1这个链路,请求了apId1权限并缓存。当鉴权-1请求完毕以后,service-a去请求service-b,并在调用2的rpc中将uriPattern1通过上下文带给了此服务,在该服务的鉴权sdk包中发起了第2次鉴权调用-2:apId2。此时arc-b发现uriPattern1已经存在缓存,则合并后uriPattern1:apId1,apId2��以此类推,当该图的链路全部调用完毕以后,arc-b的缓存中存在了key=uriPattern1,value=apId1,apId2,apId3的缓存结果。

第三个问题:如何做到链路级的请求复用 当用户再一次发起对uriPattern1的请求,且所有条件和前一次类似即调用链路树一致的情况下。鉴权-1的返回结果不再是仅仅返回对该用户的apId1的结果,而是会返回apId1,apId2,apId3的鉴权结果。并且在向下游透传后,在service-b的鉴权sdk中发现待鉴权的apId2已经有了鉴权结果,那么鉴权-3将不再发起。

结论: 该技术其本质上其实是在尽可能减少对鉴权请求的调用,以链路为单位降低其调用比,并且这个压缩比的上限其实是很高的。设想一下一个链路有10个服务,每个服务中有10个业务块,每个业务块由于编码问题都发起了同一个用户的10次调用,那么这条链路在极端情况下会发起1000次鉴权调用,而压缩后只需要1次,压缩比=1000:1。

当然由于篇幅有限,有些细节没有呈现出来,比如以下一些问题:

  1. 是否所有链路都要缓存,因为大部分请求链路其实可能只要一次鉴权,那就不必使用数据链路透传,该如何识别。
  2. 如何设计透传鉴权的缓存字符串结果来减少数据链路透传的占用的带宽。

配置项缓存架构

酷家乐权限权益中台缓存架构

该图是目前配置项结果计算的主体取数逻辑。在计算一个配置项结果的的过程中会获取所有层级的配置项值,然后根据优先级进行最后取值的计算(实际上会更加复杂一些,比如用户信息和鉴权结果也会参与其中)。配置项和权限数据在分层的概念上是一致的,都存在后台(默认)数据层和用户可以编辑(用户自定义)层。其中配置项在用户可以编辑的层级上会细分为用户层和商家层,用户层的值在参与计算的时候会普遍高于商家层级的值计算。

酷家乐权限权益中台缓存架构 因为配置项的值是用json字符串存储的,配置项的层级又很多,因此一个用户无法存储所有配置项最后值的结果,需要实时计算。 在用户信息和配置项在默认层级的值缓存和权限缓存架构一致,这里不再赘述。不同点只出现在图中的1,2层上。

  1. 用户自定义配置项缓存层:该缓存的粒度是一个用户对于一个配置项在某一层级的值缓存。因为需要过期以及数量过大,因此采用redis的方式进行缓存,并通过cache-aside的方式来做到缓存数据的一致性。
  2. 用户自定义配置项视图层:和传统缓存的概念有一点不一样,它依然使用mysql进行直接读取,而非保存到redis或者jvm中。它诞生的原因是需要快速知道一个用户对于全量的配置项在用户自定义层级是否有值(存在值的配置项比例小于20%),而在获取一个用户批量计算配置项结果的时候起到关键作用。设想一下假如不存在这个数据,需要一次性计算一个用户其2000个配置项在用户层级的结果需要先用mget从redis中获取,对于获取不到的再通过select in的方式去mysql中获取。对于mget的请求对于redis其实是比较消耗cpu的,并且因为随着数量的增加耗时阻塞问题会逐渐凸显,不可以做到水平扩展。同样select in对于mysql也是一样的。
    • 缓存形式: 假如我们需要快速知道某个数据是否存在,天然就想到用类似hash的存储结构来进行判断。用户配置项值视图层在jvm层面使用bitset来对请求中的配置项列表进行快速过滤,在mysql层面使用bitset->字节数组>base64字符串的形式进行存储。那这里会存在一个问题,这个字符串是否会很长?其实经过计算发现并不会。首先配置项的id是自增的,其增量和功能上线的频度有关系。第二bitset中存储的最大id和base64后的字符串长度是6:1的比例,假如需要存储6W个配置项是否有值则需要1w个字符存储,而6W个配置项在当前速率下可能需要几十年,因此mysql存储的这个视图层长度不必担心。
    • 一致性问题:用户在添加或者修改某个配置项值的时候是需要在同事务中校验视图层在mysql中保存的结果的。如果只是修改,视图层不必进行更新因为视图只记录了是否存在的信息,因此只有添加才需要刷新视图层的数据。那么这个视图层的数据会频繁变动吗?其实不然,因为用户添加配置项值的频率很低,并且用户对于功能的使用情况是存在局部性原理的,哪怕功能在不断迭代增加对于某个用户的视图结果并不会快速膨胀。
    • 整体效果:接口在批量计算一个用户其配置项结果的时候先通过视图层过滤出此配置项集合中有结果的配置项id,然后再经过redis和mysql的查询,其速度将大幅增加。该缓存层级在上线后mysql和redis的cpu峰值使用率降低了10%,接口耗时降低25%。

参考文献

[1]Java堆外缓存OHC在马蜂窝推荐引擎的应用 [2]OHC Java堆外缓存详解与应用 

转载自:https://juejin.cn/post/7371662058645405723
评论
请登录