聊一聊方案中心性能优化中做的缓存设计 - 阿里技术

本篇文章主要是对方案性能优化2.0中,所做的缓存设计的过程、方案、结果做一个总结。

一、前言

对于方案中心,核心业务场景之一是物流场景下的物流费用计算。而部分业务场景下,对于物流费用计算的性能有较高要求,如ICBU网站运费模板链路,通方案中心计算快递、海拼物流费用。在接入新的流量场景的背景下(ICBU商品搜索接入运费展示、菜鸟经营中台快递运力线回迁方案中心),方案中心将会面对更高的性能压力。

此前预估如需要支持运费模板计算核心20国运费,方案中心集群qps需要达到6600,计算全量220国运费,集群qps则需要达到35600。但是优化前方案中心集群qps性能仅仅在1600左右,远远达不到要求,此前已经做了1.0的优化版本,qps提升40%,但是还达不到支撑计算20国运费的要求。2.0优化目标就是要达到足以支撑计算核心20国运费的要求。

本篇文章主要是对2.0的优化中,所做的缓存设计的过程、方案、结果做一个总结。

二、优化2.0面临什么问题

2.1 1.0优化做了什么

在1.0 的性能优化中,我们制定了降低CPU资源消耗、合理使用缓存资源等措施,整体集群性能提升在40%左右,qps集群压测能到2300,但这与目标6600还有差距。因此,针对复杂的查价流程,需要进一步在代码架构层面进行优化。

1.0 已做优化措施

  • 降低CPU资源消耗

  • 规则引擎表达式预先编译并且缓存

  • 减少大对象深度复制,快递场景可以完全不复制

  • 避免通过JSON序列化,再反序列化实现创建对象并且赋值

  • 底层元数据查询方法,避免使用第三方封装的校验框架

  • 日志打印优化,debug添加isDebug判断

  • tair使用优化

  • mget替代tair get方法使用,降低网络资源消耗

    2.2 计费流程的核心问题–嵌套循环查询



图 2.2-1 简化的计费流程

  • 匹配运力线方案 1<=n<=10,查DB or localCache
  • 计算物流方案 1<=n<=?,视服务商报价而定,如有多条方案,则计算多条。
  • 计算销售价/计算成本价,分别计算两个报价。
  • 匹配报价版本,匹配当前生效的报价版本。查DB 或 tair
  • 匹配费用项报价1<=n<=30+,视不同运力线而定。查DB 或 localCache

以上只是简化流程,依然有很深的嵌套循环。
本质上可以把DB、Tair、LocalCache定义成数据存储层,在多重嵌套循环,一次HSF请求,与数据存储层交互的次数将会极多,这是一个非常不确定的资源消耗,存在这样的不确定性,无法满足稳定的QPS性能要求。


看到一句有意思的话,高QPS的问题,都可以用“走缓存,加机器”来解决。一句很精辟的话,但是想要要走缓存,加机器,也不是简单的事情。走缓存旧得深入业务场景分析缓存模型、读写策略,加机器得考虑集群其他单点资源的瓶颈,涉及各种问题。

虽然涉及各种问题,不过这次优化2.0总体的方针还是背靠这句话“走缓存,加机器”。

2.3 计费数据涉及模型简述



图2.3-1 简化询价的数据模型表达

太详细的数据模型就不展开了,聊聊简化版的结构模型。快递询价流程为例,数据流以运力线(sku)为起始,需要依赖上图中其他方块中的数据信息完成整个计费流程,每个方块部分涉及的数据表至少2张以上,有的方块甚至需要5张表才能获取到完整的目标数据。

了解到目前计费流程关联的数据模型有哪些,继续看优化前方案中心的缓存模型。

2.4 当前缓存模型



图2.4-1 2.0优化前数据读取模型

2.4.1 Tair缓存

2.0 优化前,方案询价流程最核心使用了Tair缓存的只有一个,就是图2.3-1中查询当前报价配置这里的数据查询。在遍历sku的过程,1.0优化之前会多次RPC请求,从Tair查询sku或者资源的当前生效的报价配置, 1.0优化之后,通过mget,提前批量查询来稍作优化。

这里分析下历史原因,为什么只在这里用了Tair缓存。

  • 为什么缓存?要获取 当前报价配置,从DB查询获取,查询涉及表较多,不做缓存,那肯定顶不住。
  • 为什么用Tair?现在看来,是因为获取 当前报价配置 模型的查询复杂性,通过分布式缓存,尽可能降级查询DB次数。

优点:

  • 使用Tair缓存当前报价配置,批量读取的方式,可以一定程度缓解DB查询压力

缺点:

  • 目前Tair缓存模型设计,没有把最核心、查询量级最大的方案和报价进行缓存,没解决真正痛点;
  • 在计费过程中仍需要根据每个方案+费用项构造相应的缓存key,需要费用项多多情况下,仍然需要多次查询tair;

    2.4.2 本地缓存

2.0 优化前,本地缓存模型的构成方式比较简单。大部分以mapper查询接口界别 查询条件 + 结果 构成带过期时间本地缓存。

这种模型的本地缓存,优点缺点都很明显。

优点:

  • 实现简单,不用对数据做新的聚合设计,调mapper接口级别缓存。于前期临时、快速解决性能问题的本地缓存方案;

缺点:

  • 大面积、细粒度使用本地缓存,集群机器本地缓存数据还不一致,易造成客户体验割裂问题(测试有时候都搞不清是bug还是缓存)
  • 粒度太细,计费流程与数据存储层的交互还是嵌套分散在深层次循环流程的内部,当缓存失效,依然会有大量DB查询(特别是循环嵌套最深的报价查询)
  • 不太能支持水平扩容(尝试过,DB扛不住)
  • 缓存数据无法预热,面对大流量场景,程序重启易出现成功率下跌(优化前每次发布基本都会发生)




图2.4-2 报价缓存失效后的查询高峰

*注:每10分钟就有一批DB查询高峰,主要因为报价的本地缓存key的构成,包含了当前时间的 整十分 字符串,如10,20,30),因此每10分钟就会需要加载新的缓存

三、新的缓存

3.1 为什么需要新的缓存设计

基于以上背景,大致对当前的缓存模型有一定认知,深层的嵌套循环,无法满足一系列要求的缓存模型(集群机器过多,缓存失效DB扛不住压力),想要做到靠 “走缓存,加机器”来满足高QPS要求,以现在的流程、缓存设计是无法做到的。

那么要怎么取做新的缓存设计?我的理解是先有成熟稳定的业务代码流程,再来谈缓存设计,在有问题的流程上做缓存设计,无疑是无用功。

但是优化前的询价代码架构,不能满足要求,因此第一步,是询价流程做重新定义,明确优化后的代码架构,把数据读取和计算逻辑分隔管理,基于此再做缓存设计。

3.2 对询价计费流程的重新定义

这里的重新定义,应该理解成是技术解决方案层面的重新定义,问题空间层面的定义是不变的,询价流程,在业务流程上还是这样基本定义。



图3.2-1 询价流程基本定义

但是在技术解决方案定义上,原来的流程数据获取与使用耦合,分散在嵌套循环的数据使用过程中。需要把数据获取与使用的过程做隔离,数据获取模块的内部,获取数据操作做聚合(减少次数,如最高频的匹配方案和匹配报价),聚合的维度是缓存设计的工作。



图3.2-2 解决方案询价流程重新定义

如上图3.2-2所示,新的流程基本构思。

1.首先,所有DB查询,都尽可能有相应的缓存数据;

2.其次,针对要缓存的数据,按照量级划分,报价和方案数据属于大量数据,需要用Tair缓存;sku相关、资源相关、spu相关的数据,理论上都是方案中心,对服务定义、服务表达所进行的配置,相对稳定不多变,整体占用内存相对小,因此可用本地缓存存储。

新的流程,在嵌套循环定价计费前,应当能批量匹配完方案和报价,从Tair获取。过程中,需要的服务定义和表达的数据,从LocalCache获取。

整体方案设计如下:

  • 前置费率匹配:小二启用运力线新报价的时候,通过精卫监听报价表变更,为每个物流方案提前匹配费用项报价,并且匹配结果保存在清洗出来的产品线路费率表中。客户侧查价时,根据from-to线路信息即可获取到该线路所有的费用项的报价,不需要在运行时逐个费用项取匹配费率,大幅减少查价运行时匹配报价的tair请求以及逻辑运算。

  • 减少DB访问:配置型数据通过方案中心本地缓存框架访问获取,数据量大的费率模型数据,从tair缓存获取。通过此方案,可以大幅减少DB访问。

  • 减少网络开销:在费用计算前组装好计费所需的数据上下文,通过批量tair查询读取费率,避免在核心流程循环访问获取费率数据,减少RPC请求次数。

图3.2-3 报价缓存失效后的查询高峰

3.3 缓存模型

3.3.1 Tair缓存模型

Tair缓存模型,这里指的是方案和报价的缓存。这里的Tair缓存模型的设计,需要满足两个关键点:可批量查和高速查。贴合业务场景来进行设计,提高匹配方案报价的效率以及命中率。



图3.3-1 Tair缓存模型设计

prefixGets

之前考虑的使用mget可能会受限于key分片问题导致查询缓存性能不稳定。对于方案/报价查询,改用prefixPut,prefixGets进行批量缓存存取,一个主key,多个子key的情况下,以获得更好的批量读取性能。通过prefixGets,可以高效批量获取一次查价中多个sku的方案的缓存数据 或者 报价的缓存数据。

prefixPut

prefixPut发生在查询不到报价、或者方案的时候,进行惰性加载

缓存key设计

  • 主key:对于方案/报价,都可以用SPU维度区分主key,如上图所示。

  • 方案查询子key:

  • 对于国际快递,匹配线路方案的核心条件,只有一个destinationCountry,以destinationCountry作为缓存key,可以缓存每次对应目的国的方案查询结果。

  • 同时在查方案的时候,已经指定了sku_id和生效的报价version_id,因此设计查询方案缓存key由 sku_id+version_id+destinationCountry组成。如上图所示。另外需要注意使用version_id作为缓存key一部分,可以对数据做较长时间的缓存,避免了频繁失效要重新查询方案数据,并且将Tair缓存的实时性控制,转移为对version_id的实时性控制。

    每次需要更换缓存,只需有构造新的version_id的缓存key来查询即可。

  • 报价查询子key:

  • 查价流程,先根据destinationCountry查询方案缓存, 得到方案线路列表,一条线路信息包含【destinationCountry、warehouseCode】。匹配报价的查询条件是warehouseCode + destinationCountry,相似地,设计查询报价的缓存key由 sku_id+version_id+destinationCountry+warehouseCode组成。

  • 为什么不直接用destinationCountry?报价数据对象相对而言比较大,受限于业务场景与value大小限制,缓存粒度拆分相对需要更小.

  • 同样地,可以对数据做较长时间的缓存,避免了频繁失效要重新查询方案数据,并且将Tair缓存的实时性控制,转移为对version_id的实时性控制。

    value设计

  • 方案value: 没啥特殊的,结构比较简单



图3.3-2 方案缓存模型value

  • 报价value: 结构类似方案,核心是多了报价信息result【JSON结构数据】



图3.3-3 报价缓存模型value

  • 报价记录value结构

当小二启用运力线新报价的时候,通过精卫监听报价表变更,为每个物流线路提前匹配每个费用项报价,并且匹配结果保存在清洗出来的产品线路费率表中。客户侧查价时,根据from-to线路信息查询产品线路费率表,即可获取到该线路所有的费用项的报价,不需要在运行时逐个费用项取匹配费率,并且在此时把查询结果,作为报价记录的value缓存起来,后续查询命中缓存即可获取到该线路的所有的报价信息。

3.3.2 本地缓存模型

询价计费时依赖的配置型数据,分成3大类,按照sku、resource、spu做聚合缓存。

key设计

key以 类型+ 大类的主键构成:sku+sku_id, resource+resource_id, spu+spu_id

value设计



图3.3-4 本地缓存模型value

通过这样的聚合模型设计,询价过程中,通过用skuID可以在本地缓存中检索到任意想要的服务表达定义相关的数据。另外这里缓存的实时性和写逻辑控制,在后面展开。

聚合模型每个子属性更新,需要更新整个模型的数据,这里为什么考虑要做聚合,而不采用每个表的数据都单独一个key缓存的实现方式呢?

  • 每个表的单独key,缓存结构零散,需要管理更多的Key。
  • 每个表的单独key,缓存结构零散,在读取缓存的业务层,不同业务场景诉求下,需要实现比较复杂的关联组装逻辑。
  • 配置数据并不多变,少更新,聚合模型更新读取DB的次数有限,较少。

综上,相关的配置数据聚合管理的好处大于缺点。

四、缓存读写

4.1 本地缓存

4.1.1 缓存预热

本地缓存预热,程序启动时,根据程序内置逻辑定义的本地缓存Key集合,提前加载缓存到应用内存,保证提供服务时,缓存已经加载。

4.1.2 缓存更新

简单来说就是通过监听精卫,借助广播能力,通知集群更新本地缓存。这里的缓存是一直存在于堆内存,不会失效,只会广播刷新。每次刷新缓存,按照图3.3-4 本地缓存模型value描述的聚合模型,每次更新最小粒度为一个ConfigDTO。

4.1.3 解决了什么

  • 解决应用服务器本地缓存方案缓存实时性问题,实现应用服务器集群本地缓存方案的准实时刷新。

  • 通过广播数据实体变更,触发本地缓存刷新,解决应用服务器集群多节点本地缓存不一致的问题。例如之前经常出现,因为本地缓存问题,查询方案多次不一致的问题。

  • 数据启动时预热,解决了之前每次发布,程序重启都会出现服务成功率下跌的问题。

  • 对于已缓存数据,在数据使用的业务流程中,可完全屏蔽数据库查询,对水平扩容友好。可以解决扩容时,DB瓶颈问题。

    4.2 tair缓存

查询没啥输的,按照 tair缓存模型设计 的key-value,进行prefixGets,prefixPut。需要注意的是,key设计的粒度、报价value大小限制。

4.2.1 缓存预热

这里需要做预热的场景,基本就只有新运力线上线了,一般日常还是没问题的。新运力上线,目前要求是分批灰度,等Tair缓存命中率上去了,继续开启灰度,这是比较保守的做法。

4.1.2 缓存更新

前面有提到,Tair缓存数据的实时性控制,是依靠version_id的实时性控制,方案或者报价的version_id通过本地缓存准实时更新,能够保证version_id的准实时性,从而保证每次查询Tair缓存数据的实时正确性。因为每次获取到的version_id是最新的,拼接出来的Key自然也是查询最新的缓存的Key

4.3 缓存读写总结



图4.3-1 缓存读写总结

  • 配置型数据:稳定,量不大,查价计费时需要经常读取的数据。例如运力线配置、运力线资源、报价版本、费用项、运力资源关联关系等。
  • 方案报价型数据:量大,无法本地缓存,具备版本特性,可以长时间存储在tair。



五、缓存脏数据处理

5.1 本地缓存

尽管精卫很强大,但也不是100%保证没有意外,为避免脏数据产生,因此会采用定时任务刷新的方式来定时更新本地缓存。

5.2 tair缓存

前面的设计有提到,目前的方案/报价缓存子key,是带版本号的,只要版本号正确,就不存在缓存脏数据的问题,而版本号数据实时性,依赖于本方案中的本地缓存实现,二者相互结合,保证查询Tair缓存数据的正确性。另外使用版本号作为缓存key还可以对数据做较长时间的缓存,避免了频繁失效要重新查询报价数据。

六、单点资源瓶颈

6.1 Tair瓶颈

对整个应用集群来说,支撑更大的流量,绕不开单点资源瓶颈,水平扩容更加绕不开单点资源瓶颈。不巧,最近在接入更大的流量场景的时候,就遇到Tair瓶颈问题



图6.1-1 切流Tair出现瓶颈监控视图

图6.1-2 缓存穿透后DB的QPS视图

可以看到出现大量的Tair限流,解决处理方向有几种,简单说一下

方向1

很简单,如果是原本Tair的限流阈值很低,那么可以申请扩容。需要注意的是,申请扩容的容量评估,需要结合我们查询缓存方式来评估,鹰眼上看的仅仅是对Tair发起RPC请求的统计,服务端限流统计是按照真正的key个数统计的。例如使用到prefixGet,那么就按Skey个数统计。

方向2

如果扩容不能满足,那么就需要回到代码中,看看有没有什么不必要的Tair查询,进行优化。

方向3

针对热点Key做一层本地缓存,如果应用服务器的热点本地缓存中包含key,那么就不需要查询Tair了,可以直接返回结果,降低对Tair的压力。热点key的识别可依赖Tair内嵌的LocalCache功能,或者我们自己实现,动态配置热点Key。

方向4

使用RDB。对持久化有需求,并且缓存QPS确实很高,如果当前使用的是LDB,那么可以考虑使用RDB,LDB成本比较高,没那么多资源。RDB成本相对较低,可以有更多资源。

方案中心怎么做

这次遇到Tair瓶颈,方案中心是先从简单的方向1、2入手。

首先申请扩容,一开始评估预计QPS,按照的鹰眼平台展示的来估,因为方案中心使用了prefixGet,因此估少了,扩容完成后发现还是限流。

无奈,但也没继续申请扩容,而是到业务、代码中,分析可以减少的查询Tair的点。

最后发现,起始每次查买家侧快递查价(大流量场景),除了指定目的国以外,还会指定方案的仓库。这样第一次只根据目的国查询方案的时候,可能会包含多个仓库的方案(一般方案是 仓库-目的国),做了很多无效tair查询。可以先根据指定仓库筛选方案,然后再拿剩余的方案,构造key,批量查Tair报价,可以大大减少key数倍的个数,最后稳定支持切流。这种就是基于业务场景做优化的措施。

七、总结

7.1 数据

通过本地缓存配置型数据 + tair缓存方案报价型数据的组合,缓存命中的场景下,查价计费链路已经可以实现无DB查询。目前线上稳定支持水平扩容,按照压测数据预估,单机支持180QPS,单集群50台机器支撑9000+qps

结合新的缓存组合,代码路径实现调整如下:

  • 获取N个运力线方案版本+报价版本: 查询本地缓存
  • 批量获取N个运力线方案: 查询tair or DB
  • 批量获取N个方案的报价: 查询tair or DB
2