一、引言
商品数据是营销的基础,很多营销工具最终都会涉及到商品数据的处理,比如打标、修改商品的feature、调用各种下游系统,单个商品可以通过同步方式处理,实际业务上会依据一定业务规则圈定大量商品并对其进行处理,因此,卡券商品设置引擎应运而生。卡券商品设置引擎(俗称圈品)的作用是,依据一定的业务规则从数据源获取商品,筛选符合规则的商品并按照业务自定义的操作设置商品优惠。设置商品优惠主要是围绕商品中心、营销中心等多个域进行操作,圈品的一个重要能力就是保障商品优惠设置后各个域的数据一致性。商品数据经常发生变化,变化后可能会使商品不符合圈品规则,圈品另外一个重要能力就是能够监听全量的商品中心变更。卡券商品设置引擎全局视角图如下所示。
圈品三个关键要素:数据源、规则、业务处理,三要素都支持横向扩展。数据源是圈品的数据来源,不同的数据源接入方式和查询方式不同。规则用于数据过滤,只有符合规则的数据才能接下去处理。符合规则的数据在业务上需要进行一定的处理,业务处理可以自定义。
从2017年发展至今,圈品经历了4个双11以及数不清的大促和日常活动,圈品目前拥有千万级商品实时处理能力、数据一致性保障能力、监听全量商品变更能力以及平台化能力等。
本文将圈品的发展划分为两个阶段,第一个阶段,奠基了圈品的架构,第二阶段,提升了系统的稳定性和性能、增加了一致性保障能力。
二、第一阶段
2.1 概述
2.1.1 生命周期
圈品通过活动概念来进行生命周期的管理,圈品池关联了规则和业务,圈品池详情是商品的集合,商品处理完成后会保存到商品池详情中,活动、圈品池、圈品池详情模型如图2.1所示。一个活动可以关联多个圈品池,一个圈品池只属于一个活动,圈品池设置圈品规则后会按照业务自定义的动作完成商品的处理,活动过程中商品发生变化时会产生商品变更消息,圈品会通过监听商品变更消息动态处理商品,活动结束后会触发结束后的动作,生命周期如图2.2所示。首次设置圈品池规则后会触发圈品从数据源拉取全量商品进行处理,我们称之为全量圈品。在全量圈品完成后,数据源会发生变化或者商品信息发生变化,这些发生变化的商品需要重新经过圈品处理,称之为增量圈品。
图2.1 卡券商品设置引擎模型图
图2.2 卡券商品设置引擎生命周期
2.1.2 系统架构
圈品框架图如图2.3所示,圈品可以划分为四个模块,分别是数据源模块、动作模块、规则模块和业务处理模块,设置端划分为三个部分:活动设置、圈品池设置、规则设置。接下来将围绕圈品四个核心模块进行讲解。
图2.3 圈品框架图
2.2 数据源模块
数据源模块是圈品的数据源来源,下面主要分四类进行讲解,分别是商品列表、商家列表、同步库表以及商品变更消息。
这些数据源又衍生出了多种圈品方式,比如商品列表圈品方式、卖家列表圈品方式、大促现货圈品方式、营销站点圈品方式、卖家大促商品圈品方式、飞猪卖家圈品方式、新零售摊位圈品方式等等。拿大促现货圈品方式举例,每次大促营销平台招商都要招现货商品,大促现货圈品池方式可以圈指定大促的全部现货商品,也可以结合类目、商品标等其他规则过滤商品。
2.2.1 商品列表
商品列表是最简单的数据源,直接通过商品ID指定数据源的商品范围,因为是直接填商品ID的方式,网络传输限制最大支持10W商品。全量圈品过程中从圈品规则中获取全量商品进行处理,增量圈品是通过监听商品变更消息进行处理。
2.2.2 卖家列表
卖家列表数据源是卖家ID的集合,通过指定卖家ID来确定商品范围。全量圈品的过程中根据卖家ID从店铺搜索接口中获取卖家商品,增量圈品是通过监听变更消息,卖家新发布商品或者变更商品都会触发商品变更消息,通过监听商品变更消息便可以进行增量圈品。
2.2.3 同步库表
同步库表是值使用精卫同步原数据库到新的数据库供圈品使用,采用这种方式比较灵活且不会对原数据源产生影响。全量圈品是通过扫表方式获取全量的数据。增量圈品有两个渠道,第一是通过精卫监听数据库的变更,第二是监听商品变更消息。
根据数据源的特性又可以衍生出多种圈品方式,营销平台招商数据源支持大促现货圈品方式、营销平台站点方式、卖家大促商品圈品方式等,新零售商品数据源支持摊位和业务身份圈品方式。
2.2.4 商品变更消息
由于商品信息变更会导致商品不符合规则,需要对变更的商品进行增加或删除,比如小二设置圈选某个类目的商品,卖家可以对商品类目进行编辑,原来符合类目规则的商品变得不符合类目需要删除,原来不符合类目的商品现在符合类目需要进行增加。商品信息的变更都会触发商品变更消息,所以增量圈品中都有一种途径就是处理商品变更消息。
商品变更消息日常平均qps在1w左右,峰值QPS可达4w多。在这一阶段,因为每一个圈品池规则都是独立的且无法确定一个商品与商品池的关系,所以每个圈品池都处理了全量商品变更消息。假设商品变更消息QPS是1w,当前有效圈品池有5000个,那么圈品系统实际处理商品变更消息QPS是5000W。因此只有进行本地计算的规则才能支持处理商品变更消息,即使是这样,圈品系统也严重消耗机器性能,曾经圈品系统有600多台机器,CPU使用率也达到了60%以上。
2.3 规则模块
2.3.1 框架设计
圈品规则模型类图如图2.4所示,ItemPoolRule是圈品池规则类,其中relationRuleList是圈选规则,exclusionRuleList是排除规则,一个商品必须符合圈选规则且没有命中排除规则,这个商品才算符合圈品池的规则。RelationRule是具体规则内容,RuleHandler是规则处理接口,所有规则必须实现RuleHandler,如ItemTagRuleHandler——商品标规则handler、SellerRuleHandler——卖家规则handler等。
图2.4 规则模型类图
2.3.2 规则树
规则树设计如图2.5所示,每个节点表示一个规则节点,顶级规则必须是可以做为数据源的规则,如商品列表规则、卖家列表规则等。判断商品是否符合规则可以定义为:一个商品如果符合从顶级规则到某个叶子链路上所有规则节点(即从规则树中可以找到一条从顶级规则通往任意叶子节点的链路),则认为该商品符合规则。
图2.5 规则树设计
为了更好的理解,举个例子,如下图2.6所示,运营通过商品列表方式进行圈品,左边链路是圈商品列表中符合二级类目规则的商品,右边链路是圈商品列表中符合一级类目规则以及指定商品标的商品。
图2.6 规则树举例
2.3.3 顶级规则
由于该章节与“分批处理模块”章节耦合较强,因此可以先看下面章节后,再看该章节。
顶级规则即是规则也是数据源,圈品从顶级规则中获取数据源中所有的商品。商品列表圈品方式做为顶级规则时,规则内容包含商品ID,这些商品ID就是数据源的商品。卖家列表圈品方式做为顶级规则时,规则内容包含卖家ID,从顶级规则获取商品ID时,根据卖家ID调用店铺搜索接口获取商品ID。大促现货圈品方式做为顶级规则时,规则内容包含的是大促现货活动ID,从顶级规则获取商品ID时,根据活动ID从同步过来的招商现货表中拉取商品。
局限性
从“分批处理模块”章节可以知道,这一阶段圈品都是先count规则中包含商品总数,然后分页处理,这种方式存在局限性,当顶级规则变得复杂的时候,就没办法处理了。
举个稍微复杂的例子,卖家列表圈品方式规则内容包含很多个卖家时,如何处理呢?这一阶段圈品的处理方式跟图9纵向处理方式一样,找到所有卖家中拥有最大商品数量做为count,然后分页处理,每一页的处理过程中都需要循环所有卖家,当卖家数量越大时,每页包含的商品数量就越大,因此该圈品方式限制了最多只能指定300个卖家。
再举个复杂的例子,假设一个品牌团中有多个卖家,一个卖家有很多商品,现在需要圈选多个品牌团下面所有卖家的所有商品,如何做呢?这一阶段圈品还无法处理这么复杂的规则,具体做法详见第二阶段。
2.4 分批处理模块
2.4.1 分布式处理
圈品将全量商品进行分页处理拆分成很多部分,然后通过metaq进行分布式处理,流程图如图2.7所示。当触发全量圈品的时候会产生一条记录规则变化的metaq消息,规则变化消息通过规则变化动作模块进行处理。规则变化动作模块首先计算数据源中最大可能的商品数量,然后再通过分页处理分成很多部分,每一部分产生一条商品增加类型的消息,商品增加消息通过商品增加动作模块进行处理。商品增加动作模块首先从数据源拉取该部分对应的商品ID集合,然后过滤圈品池规则,最后选择对应的业务进行处理。
图2.7 分布式处理流程图
2.4.2 分页处理
分页处理首先是计算全部最大可能的商品数量,然后按照固定间隔进行分页,商品增加和商品删除消息包含的关键信息是:start、end,
拿最简单的商品列表圈品方式来举例,假设运营填入了5w个商品ID,那么分页处理可以是500个商品ID做为一页,第一页start=0、end=500,最后一页start=49500,end=50000,每一页需要处理的商品ID都是确定的。
但是数据源往往不是这么简单,拿一个稍微复杂的大促现货圈品方式举例,从招商同步的现货商品存储在64张表中,按照商品ID进行分库分表,其中大促活动ID是索引字段,如何高效获取指定大促活动的全部商品ID呢。数据量较小的时候,我们可以通过数据库count和limit分批取出,数据量大的时候使用limit就会有大翻页问题。
为了避免使用limit在大翻页时性能差的问题,圈品的处理方式如下图2.8所示,把它看成横向方式。首先通过大促活动ID,计算每张表的min(id)、max(id),总数count就等于所有表max(id)-min(id)相加,然后按间隔划分任务,实际间隔是5000,为了画图方便图中间隔是10,因此每个商品增加消息包含的信息只需要start和end。
图2.8 横向分页处理
在处理商品增加消息时,需要循环64张表中求min和max直到找到该start和end在哪张表中,然后在该表中根据start和end取出符合的商品,核心代码逻辑如下所示。
public List<CampaignItemRelationDTO> getCampaignItemRelationList(int start, int end,
Function<Integer, Long> getMaxId,
Function<Integer, Long> getMinId,
Function<CampaignItemRelationQuery, List<CampaignItemRelationDTO>> queryItems) {
List<CampaignItemRelationDTO> relationList = Lists.newArrayList();
for (int i = 0; i < 64; i++) {
//min以及max的值均走缓存,不会对db产生压力
long minId = getMinId.apply(i);
long maxId = getMaxId.apply(i);
long tableTotal = maxId - minId + 1;
if (minId <= 0 || maxId <= 0) {
continue;
}
//起始减本表内总量,如果大于0,则一定是从下一张表开始的,直接跳出循环,降低start以及end继续
if (start - tableTotal > 0) {
start -= tableTotal;
end -= tableTotal;
continue;
}
// 进入到这里,说明一定已经有一部分落在这里了,那么继续遍历取值
// 先判定是否是最后一张表,如果是,则去除需要的 ,然后返回,如果不是最后一张表,那么需要取出本张表中所需的数据,然后进行下次迭代
// 判定为最后一张表的条件是 表的起始点+pageSize < maxId,即(minId+start)+(end-start) <= maxId,简化为 minId + end <= maxId
if (minId + end <= maxId) {
//如果minId + end 还小于本表的最大值,那么说明min以及max均落入了表内,那么只取本表的数据即可
relationList.addAll(queryItems.apply(getQuery(start + minId, end + minId, i)));
break;
} else {
//走入这里,说明数据进行了跨表
//首先取出本表符合条件的全部数据,然后将起始值设置为0,然后降低
relationList.addAll(queryItems.apply(getQuery(start + minId, maxId, i)));
//新的结束值应该为pageSize-当前表中取得的数量总量,即(end-start)-(tableTotal-start),简化后得到end-tableTotal
end = (int) (end - tableTotal);
start = 0;
}
}
return relationList;
}
这种处理方式存在几个缺点:
- 对于数据集中的表来说是一种不错的方法,但对于数据稀疏型表来说就非常低效,如果数据分布很稀疏,count很大,分批处理后任务数量非常大,最后获得的商品ID也就几百个,比如,新零售圈品方式由于框架限制,也采用了一样的分页处理方式,一次全量圈品商品增加消息量可达20w,实际可能只获得了几百个商品。
- 每个消息处理都需要循环查询很多张表直到start、end所在的那张表,通过max和min判断start和end是否出自该表,频繁取max、min也会给DB造成压力,为了避免对DB的压力,又需要利用缓存max、min。
针对于第一个缺点:数据稀疏型的数据源消息数量过大,可以在不改动框架的同时进行改善,只需换个角度计算总数count,如下图2.9所示,count取的是所有表中的最大值和最小值的差,这样即使是稀疏型数据源,count值也不会很大,然后任务处理的时候根据start和end循环从所有表中取出对应的商品ID。而且这种方式也会稍微减少取max和min的次数。如果密集型数据源采用这种分页处理方式,将会导致单页数据量过大问题。
图2.9 纵向分页处理方式
针对于第二个缺点:频繁取max和min问题,上面的处理方式是用全局的眼光计算count,然后分页处理,因此无法直接定位start和end应该取自哪张表,其实,可以针对于每个表单独分页处理,消息中不仅包含start、end,还包含分表的index信息。但是这种方式依然存在对稀疏型数据源划分任务数过多的问题,而且现在圈品分批框架也不支持这种方式。
2.5 动作模块
动作模块的作用是处理圈品metaq消息,动作模块与消息类型是一一对应的,动作模块分为:规则变化、商品增加、商品删除。
2.5.1 规则变化动作
规则变化动作模块处理规则变化类型的消息,该动作主要处理流程是,调用分批处理模块进行分批,然后将每批包含的信息通过metaq发送出去,也就是产出商品增加和商品删除消息。
2.5.2 商品增加动作
商品增加动作模块处理商品增加类型的消息,动作处理流程图如下图2.10所示。
图2.10 商品增加动作处理流程图
2.5.3 商品删除动作
商品删除动作模块处理商品删除类型的消息,动作处理流程图与图2.10类似,只是最后业务处理模块调用商品删除处理的方法。
2.5 业务处理模块
业务处理模块框架类图如图2.11所示,每一种业务都需要实现TargetHandler,其中handle方法处理圈品增加,rollback方法处理圈品删除。目前已经接入的几个大业务分别是:品类券、免息券、会员卡等。
图2.11 业务处理类图
2.6 阶段总结
这一阶段,圈品从无到有,诞生于品类券,又脱胎于品类券,在业务方面,支撑了品类券、免息券、会员卡等业务,在性能方面,能处理百万级甚至千万级商品。系统是在不断发展中完善,这一阶段的圈品存在以下不足点。
2.6.1 处理商品变更消息性能问题
2.2.4中讲解了处理商品变更的必要性以及存在的问题,当有效的圈品池越来越多时,处理商品变更消息QPS越来越高,系统性能越来越差,而且很多规则需要调用HSF或者查询缓存之类的耗时操作,因此这些规则无法支持处理商品变更消息。这一阶段,承载圈品系统集群CPU一直都在50%以上,即便集群拥有600多台机器。
2.6.2 复杂顶级规则处理问题
面对复杂顶级规则,圈品没有很好的办法处理,然而在业务快速变化情况下,圈品需要有能力应对复杂规则,即使目前没有出现太复杂的顶级规则,圈品在处理卖家列表圈品方式也存在局限性。
2.6.3 系统稳定性和可控性问题
- 稳定性问题:通过2.4节可以了解到,在进行大量圈品的时候,只要触发圈品变更,规则变化消息立马会裂变出更多的圈品消息,圈品metaq消息堆积量可达到百万,由于下游系统限流导致大量异常,系统负载又高,消息处理又耗时长,metaq消息处理存在雪崩风险,有时一条消息重复处理上万次。
- 可控性问题:由于触发圈品变更时,会立马裂变出更多的消息,消息大量堆积时,圈品不能选择性处理、不能停止处理消息、不能选择性忽略消息等等,这就意味着系统发生问题的时候,没有抓手进行控制,只有眼巴巴的看着。举个实例,两条消息重复执行几万次,一个是删除该商品,一个是增加该商品,不停的给商品打标去标,商品产生大量商品变更,导致搜索引擎同步延迟,当时就只能眼巴巴看着。再举个例子,由于某一种圈品规则代码有bug会导致fullGc,然后该规则相关的圈品池产生了大量消息,由于无法选择性处理消息,导致整个圈品系统瘫痪。
2.6.4 分批处理缺陷问题
在2.4.2章节中讲到了第一阶段分页处理的缺陷,不同数据源的分页处理不能一概而论,框架应该给予更多的灵活性。
2.6.5 数据一致性问题
在进行大量圈品时,系统或下游系统异常无法避免,所以数据有可能存在不一致的情况。对于业务来说,该增加的商品没有增加,可能还能接受,如果该删除的商品没有删除,那么就很可能资损了。
三、第二阶段
3.1 概述
第二阶段,针对于第一阶段的问题进行优化,新架构图如图3.1所示,其中黄色部分是新增部分。圈品总体可以划分为六大块,分别是数据源模块、动作模块、规则模块、业务处理模块、调度模块和设置端。调度模块是新增部分中最重要的,首先新增了任务的模型,如图3.2所示,任务会先保存到DB中,scheduleX秒级定时触发调度逻辑,最后通过metaq分发任务进行分布式处理。图中红色线条表示全量圈品的流程,图中橘黄色表示增量圈品的流程。下面将分别详细介绍新增部分。
图3.1 第二阶段圈品架构图
图3.2 任务模型
3.2 商品变更消息优化
商品变更消息处理流程图如图3.3所示,第一步,建立圈品池与商品的泛化关系,第二步,通过Blink根据商品与圈品池的泛化关系过滤商品变更消息,剩下少量的商品变更消息,第三步,根据圈品池与商品的泛化关系判断哪些圈品池需要处理该商品变更消息。过滤后的商品变更消息日常平均qps在200左右,而且只有与该商品相关的圈品池才需要处理该商品变更消息,因此,具体到某些圈品池上来看,其处理商品变更消息的qps在100以内,同时系统性能消耗也大大降低,集群机器从巅峰时期700多台降低到现在300多台(由于集群还承载其他业务,实际圈品需要的机器数量可以压缩到100台以内)。
图3.3 商品变更消息流程图
商品变更消息过滤的关键点在于如何建立圈品池与商品的泛化关系,这里的思想是根据具体规则尽量大范围的圈定可能的商品。比如卖家圈品方式,当小二填写卖家列表后,这个圈品池与哪些卖家有关系就已经确定了,除此之外的卖家肯定不会跟这个圈品池发生关系,因此可以将卖家与圈品池的关系存入tair,供Blink过滤商品变更消息使用。卖家与圈品池的关系是比较通用的思路,其他圈品方式也可以转化成这种关系,比如商品列表圈品方式,当小二填入商品ID后,这些商品属于哪些卖家就确定了,除此之外的卖家的商品不会与该圈品池发生关系。
当然,卖家与圈品池的关系也有不适用的时候,比如大促活动圈品池方式,一次大促活动可能有几十万的卖家参与,而且卖家会不断的报名参加大促,因此很难获取圈品池与卖家的关系。针对大促活动圈品方式,可以建立tmc_tag与圈品池之间的关系,大促商品都有统一的tmc_tag,因此可以通过将tmc_tag与圈品池的关系存在diamonds供Blink过滤商品变更消息使用。总之,其他圈品方式根据具体规则找到圈品池与商品的泛化关系,可以通过商品上的信息和泛化关系判断商品与商品池是否存在关系。
3.3 复杂顶级规则处理
3.3.1 维度定义
第一阶段中已经解释了顶级规则是能够做为数据源的规则,为了更好支持复杂数据源规则,引入了维度的概念,然后通过降维将复杂规则变成简单规则,最后的目的是从数据源中获取所包含的商品ID。
定义1:单个商品ID为零维,即没有维度
定义2:能够直接获取多个商品ID的规则为一维,例如商品列表规则,单个卖家规则
定义3:二维规则由多个一维规则组成,例如多个卖家规则
定义4:三维规则由多个二维规则组成,更高维规则由多个比它低一维的规则组成
从上面定义可以看出,卖家列表规则既有可能是一维规则,也有可能是二维规则,当规则只包含一个卖家时为一维规则,当规则包含多个卖家时为二维规则。为了更好理解,拿上面的复杂规则来讲解,一个卖家有很多商品,一个品牌团有很多商家报名,如果现在运营设置圈多个品牌团下面所有商品,下图3.5所示是该规则降维的过程。
图3.5 规则降维过程
3.3.2 规则变化动作调整
在第一阶段,规则变化动作处理流程就是调用分批处理模块进行分批,然后将每批包含的信息通过metaq发送出去,也就是产出商品增加和商品删除消息。现在,规则变化动作处理流程调整为如下图3.6所示,首先需要判断规则是否为一维规则,只有一维规则才能直接通过分批处理,否则就要进行降维,产生的降维任务由规则降维动作进行处理。
图3.6 新规则变化动作处理流程图
3.3.3 增加规则降维动作
规则降维动作处理规则降维类型的任务,动作处理流程图如下图3.7所示。RuleHandler中自定义的降级方法指定了下一维度的规则,因此一次降维任务只能将规则降低一个维度。降维只针对做为数据源的顶级规则,因此,首先递归获取顶级规则,接着调用自定义降维方法处理顶级规则后得到更低一维度的顶级规则集合,然后使用降维后的顶级规则替换规则树中的顶级规则得到新的规则树集合,最后,将新规则树生成规则变化任务,由规则变化动作判断是否继续降维。
图3.7 规则降维动作处理流程图
3.4 新增调度模块
3.4.1 任务调度
新增任务模型如图3.2所示,任务相当于第一阶段中的圈品消息,第二阶段中任务是需要先落库,然后由调度器来进行调度的。
调度器是任务扭转的动力,所有类型的任务都会插入DB中由调度器统一调度。任务表中已完成的任务会隔一段时间清理,即使是这样,任务表也有可能存在几百万任务,而且圈品的速度很大程度由调度器决定,因此对调度器的性能要求是很高的,不仅如此,调度器应该具备更多的灵活性。
调度器处理流程图如图3.8所示,通过scheduleX秒级定时触发调度逻辑,然后通过metaq分发任务ID,其实分发任务ID也可以通过scheduleX来完成,最初的实现也就是通过scheduleX来进行任务ID的分发,最后还是改成了通过metaq来分发任务,因为scheduleX分发大量任务时存在不可接受的延迟。
讲回到图3.8,任务调度的基础是知道未完成任务的分布,为了避免统计未完成任务分布时产生慢sql,这里做了一个很重要的动作,即第一步,首先获取未完成任务所属的圈品池ID的分布,由于这里只根据状态统计圈品池ID,状态和圈品池都有索引,利用了覆盖索引,因此性能很高;第二步,随机选择十个圈品池保证任务调度分配均衡,同时减少任务统计的耗时;第三步,统计这十个圈品池的未完成任务数量的分布;第四步,根据第三步的数量统计以及系统配置,分配每个圈品池参与调度的任务数量;第五步,根据任务分配数量获取任务ID;第六步,通过metaq将任务ID分批发送到不同的机器进行处理;第七步,接收metaq消息;第八步,将任务ID提交异步处理,这里为了提升处理速度,维护了一个线程池,任务ID只需要提交到阻塞队列中;第九步,任务ID提交异步处理后,立马更新任务状态为处理中,避免任务再次被调度,处理中的任务不属于未完成的任务。
图3.8 任务调度流程图
对比第一阶段中圈品消息模式,第二阶段任务首先保存到DB,然后由调度器进行调度,调度器能够提供更多的灵活性,可以获取以下优点:
- 任务优先级可根据圈品池进行调整,部分圈品池出现问题不会影响整体;
- 任务调度速度可调整、可暂停,可以根据任务类型分配处理速度;
- 任务调度可监控、可精确统计;
- 圈品过程可查询、可追踪;
3.4.2 任务统计
一个完善的平台少不了系统可视化,任务处理进度是可视化中重要的部分。任务数量统计就少不了group by和count,任务表最大的时候可能存在上百万的数据,同步方式进行统计肯定是不行的,因此采用如下图3.9所示异步方式。利用覆盖索引方式得到圈品池ID的分布,每个圈品池的任务不会很大,因此每个圈品池分开统计将不会产生慢sql。
图3.9 任务统计思路
3.4.3 分批处理新思路
在第一阶段中提到了分批处理的缺陷问题,这里将讨论如何解决整个问题,新的分批处理方式还在开发当中,设计思路按照该章节所讲。
3.4.3.1 框架设计
圈品拥有各种各样数据源,每种数据源的特性都有不同,所以无法用一种通用的分批方式处理所有数据源。因此圈品分批处理的框架应该更加通用,让每种数据源都能自定义自己的分批处理方式。
在框架方面的调整如下图3.11所示,新增基础分批对象Pageable,考虑到和老框架到兼容,Pageable包含老框架的使用的分批参数start、end、pageSize,自定义分批对象TablePageable或其他都继承自Pageable,RuleHandler增加自定义分批方式getPageabelList,老框架的分批方式可以写在AbstractRuleHandler的getPageableList中,需要自定义分批方式的RuleHandler覆盖getPageabelList便可。
图3.11 分批处理新框架类图
3.4.3.2 分页处理思路
在第一阶段中,分页处理为了避免limit大翻页问题,采用了通过主键id进行分页的方式。在这里先讨论下为什么limit会存在大翻页问题,以及优化方案。
如下sql所示,当N值很大时,这个sql的查询效率会很差,并发查询时甚至会拖垮数据库,因为执行这个sql时需要先回表查询N+M行,然后根据limit返回M行,前面查询的N行最后被丢弃(具体讨论可参考limit为什么会慢)。一般遇到这种情况,业务上都是不允许大翻页,应该根据条件过滤,但是圈品要分批取出所有数据,所以圈品就绕不开这个问题。
SELECT * FROM table WHERE campaing_id = 1024 LIMIT N,M
在这里总结了两种解决思路,圈品为了获取所有有效的商品,因此无需考虑数据整体的分页,可以将分表独立分页处理。
id与limit组合优化
前面分析了使用limit大翻页最大的问题是查询前N(即offset)条数据所耗费的时间,在理想的情况下,id是连续自增,可以在where条件中使用id来代替offset,sql即如下所示。
SELECT * FROM table WHERE campaing_id = 1024 and id > N LIMIT M
优化思路中所说的理想情况,几乎没有场景能够达到要求,但是这也不影响该思路的应用,根据上一次翻页结果id使用limit查询下一批,如果id不连续,limit将可以跳过很多不连续id,减少查询次数。
结合圈品实际情况使用该思路,首先通过min和max得到数据在表中分布的最小值和最大值,针对稀疏型数据分批间隔可以很大(为了解决任务数量过多问题,比如间隔是2W,即end-start=2w),start和end分别是每批数据对应的开始id和结束id,然后根据id做为where条件使用limit取下一页数据,接着根据下一页最大id做为where条件使用limit取后面的数据,一直循环下去,直到id>end,对于稀疏型数据,也许循环1-2次就完成了。流程图如下图3.12所示。
图3.12 圈品分页处理优化思路
覆盖索引优化
当sql查询是完全命中索引,即返回参数和查询条件都有索引时,利用覆盖索引方式查询性能很高。先通过limit查询出对应的主键id,然后再根据主键id查询对应的数据,由于无需从磁盘中取数据,所以limit方式比之前性能要高,sql如下所示。
SELECT * FROM table AS t1
INNER JOIN (
SELECT id FROM table WHERE campaing_id = 1024 LIMIT N,M
) AS t2 ON t1.id = t2.id
“覆盖索引优化“到底能优化到什么程度呢,对此进行了一个测试,表item_pool_detail_0733包含10953646条数据,通过item_pool_id = 1129181 and status = -1条件筛选后剩下3865934条数据,item_pool_id和status建立了联合索引。
测试1
我们看下offset较小的时候,sql和执行计划如下所示,执行平均耗时83ms,可以看到在offset较小的时候,sql性能是可以的。
SQL:
SELECT * FROM `item_pool_detail_0733` WHERE item_pool_id = 1129181 and status = -1 LIMIT 1000,100
执行计划:
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
---|---|---|---|---|---|---|---|---|---|---|---|
1 | SIMPLE | item_pool_detail_0733 | ref | idx_pool_status,idx_itempoolid | idx_pool_status | 12 | const,const | 5950397 | 100.00 |
测试2
当offset较大的时候,sql如下所示,执行计划和上面是一样的,执行平均耗时6371ms,这个时候sql性能就很差了。
SQL:
SELECT * FROM `item_pool_detail_0733` WHERE item_pool_id = 1129181 and status = -1 LIMIT 3860000,100
测试3
现在使用“覆盖索引优化”思路优化测试2的sql,sql和执行计划如下所示,执行平均耗时1262ms,相比优化之前执行耗时6371ms,性能提高了5倍多,但是1s多的执行耗时对于圈品来说也是难以接受的。实际情况下,分库分表会把数据均匀分布在所有表中,因此,单表过滤后还剩下300w多数据的情况是很少的,为此,我接着测试数据量不同时该sql的性能。当LIMIT 2000000,100时,执行平均耗时697ms;当LIMIT 1000000,100时,执行平均耗时390ms;当LIMIT 500000,100时,执行平均耗时230ms;
SQL:
SELECT * FROM `item_pool_detail_0733` as t1
INNER JOIN (
SELECT id FROM `item_pool_detail_0733` WHERE item_pool_id = 1129181 and status = -1 LIMIT 3860000,100
) as t2 on t1.id = t2.id
执行计划:
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
---|---|---|---|---|---|---|---|---|---|---|---|
1 | PRIMARY | ALL | 3860100 | 100.00 | |||||||
1 | PRIMARY | t1 | eq_ref | PRIMARY | PRIMARY | 8 | t2.id | 1 | 100.00 | ||
2 | DERIVED | item_pool_detail_0733 | ref | idx_pool_status,idx_itempoolid | idx_pool_status | 12 | const,const | 5950397 | 100.00 | Using index |
通过上面的测试可以看出,使用limit查询50w数据时,性能还可以,而且圈品的数据源都是根据商品ID进行分库分表,因此,根据过滤条件过滤后剩余的数据几乎都能在50w以内。如果圈品采用这个思路优化分页处理,那么将可以完全解决第一阶段中的两个缺点问题,而且分页处理逻辑相比之前简单很多。
3.5 数据一致性保障
数据一致性保障解决方案充分复用了圈品框架,只需三步,第一,在动作模块增加一致性检查动作,第二,新增自动产出一致性检查任务的scheduleX任务,第三,在业务处理模块中增加自定义一致性检查方法。
3.5.1 新增一致性检查动作
一致性检查动作处理一致性检查任务,其处理流程图如图3.13所示。
图3.13 一致性检查动作处理流程图
3.5.2 自动产出一致性检查任务
圈品当前有效的圈品池已经超过5000,如果一次性检查5000个圈品池,那么产出的检查任务数量可能上百万,因此,需要一个定时任务不断监控任务表中未完成任务的数量,在数量较少的情况下一次选择几个圈品池产出一致性检查任务。一致性检查任务的产出依然是复用圈品规则变化处理流程。
3.5.3 业务自定义一致性检查
新业务处理类图在之前的基础之上增加自定义一致性检查方法consistencyCheck,需要自定义一致性检查的业务实现该方法便可。
图3.14 新业务处理类图
3.6性能数据
卡券商品设置引擎的性能主要通过三个指标衡量,分别是:任务调度吞吐量、商品处理速度。
3.6.1 任务调度吞吐量
一个任务可能包含几个商品也有可能包含上千个商品,取决于数据源的稀疏程度,当数据是稀疏的时候,任务数量将会很多,这个时候圈品的速度就取决于任务调度的速度。目前,任务调度的速度可以达到5w个/分钟,这还并不是最大值,还有上升的空间。
3.6.2 商品处理速度
商品的处理速度受下游系统的影响需要限流,不考虑业务处理速度,理论上处理商品速度可以达到6w TPS。