一种处理亿级聚合数据的方法
在电商平台的架构体系中,商品数据是系统正常运转的基石,随着平台的发展,商品数据很容易突破亿级。在电商运营方面,平台通常需要举行各种大促,使用各种营销工具吸引消费者,因此需要对商品进行招商、选品、投放。
在大促招商后,平台会根据活动报名记录数据进行一系列的运营,活动报名记录通常根据某些维度进行了聚合,比如卖家聚合维度、活动聚合维度。对某一聚合维度的商品报名数据进行处理之前,首先需要获取这一聚合维度下面的所有数据。
如果数据量比较小,数据可以采用单库单表存储,获取某一聚合维度下面的所有数据也是比较简单的事情。但是,报名记录数据量通常非常大,需要采用分库分表存储数据,由于数据分散存储,获取分库分表数据将变得困难,特别是需要获取指定某一聚合维度的所有数据。比如,某个大促下面所有的报名记录有几千万条,因此不能使用单表存储,需要分库分表存储,这样就是导致获取某个大促的所有报名记录变得困难。
本文讲解方法的就是,如何高性能分布式地获取按维度聚合的分库分表数据。本文用大促报名记录模型来说明本文的方法,对于其他类似的模型也可以适用。
相关数据模型介绍
目前主流的数据存储方案还是MySql,虽然使用MySql存储大数量级数据需要进行复杂的水平拆分和垂直拆分,但是,MySql拥有优异的性能、灵活的索引方式等,这非常有利于复杂的业务需求。本文讨论基于Mysql数据库,存储引擎为Innodb,分库分表采用TDDL。为了更好的讲述本文的方法,接下来简单定义几个相关的数据模型,这些数据模型是大促技术体系中的一个缩影,能代表大多数业务的数据模型。
▐ 基础商品模型
基础商品模型描述的商品的基础属性,其他数据模型通过商品ID关联基础商品数据模型,基础商品模型通过卖家ID关联卖家模型,基础商品数据模型如下所示。基础商品数据量通常非常大,需要进行分库分表存储。对于基础商品表,通常查询方式会指定商品ID获取商品的基础信息,并且不会对该表有过多的查询方式,因此基础商品表通常使用商品ID作为分表键,即满足查询需求,又能够把数据均匀分布在所有分表中。
id bigint unsigned 主键ID
gmt_create datetime 创建时间
gmt_modified datetime 修改时间
item_id bigint unsigned 商品ID
seller_id bigint unsigned 商家ID
name varchar 商品标题
price bigint unsigned 商品价格
picture_url varchar 商品图片url
▐ 活动数据模型
活动数据模型描述了一个活动的基础属性,活动数据模型如下所示。其中activity_id表示活动ID,major_id表示大促ID,一个大促下面存在多个活动,即一个大促ID可以关联多个活动ID,这种一对多的关系表示了它们之间的聚合关系,活动按照大促聚合维度进行了聚合。活动数据量通常不会非常大,因此通常采用单库单表存储。
id bigint unsigned 主键ID
gmt_create datetime 创建时间
gmt_modified datetime 修改时间
activity_id bigint unsigned 活动ID
major_id bigint unsigned 大促ID
name varchar 活动名称
start_time datetime 活动开始时间
end_time datetime 活动结束时间
▐ 报名记录数据模型
报名记录数据模型描述了商品报名某个活动的报名信息,报名记录数据模型如下所示。一个商品可以报名多个活动,一个活动的报名记录可以由很多商家的商品报名组成。通过上面的数据模型,可以发现活动报名记录按照多种聚合维度进行了聚合,比如,卖家聚合维度、活动聚合维度、大促聚合维度。业务上需要对不同聚合维度的所有数据进行处理,因此就需要有一种方法能够高性能地获取某一聚合维度下的所有数据。
id bigint unsigned 主键ID
gmt_create datetime 创建时间
gmt_modified datetime 修改时间
activity_id bigint unsigned 活动ID
item_id bigint unsigned 商品ID
seller_id bigint unsigned 卖家ID
activity_price bigint unsigned 商品活动价格
aount bigint unsigned 优惠商品件数
buyer_limit bigint unsigned 买家限购件数
其他相关方法描述
在进入本文主题之前,先描述下我了解到的解决该问题的相关方法。一些相关的方法通常采用商品ID作为活动报名记录表的分表键,这样数据可以比较均匀地分布到所有的分表中。对某一聚合维度的数据进行分布式获取时可以采用以下三种方案。
▐ 全表扫描方法
该方法思想很简单,只需对所有分表进行扫描,对每个分表进行处理时,可以采用索引提升获取数据的性能。这种方式实现简单,但是无法充分发挥分布式架构的性能,整体性能较低。
▐ 横向分片方法
根据聚合维度信息遍历所有分表得到每个分表的最大主键ID和最小主键ID,下图中min1表示分表1的最小主键ID、max1表示分表1的最大主键ID;
预估数据总量,数据总量等于每个分表中max-min相加的总和,如下图中count的计算公式;
根据预估数据总量count和固定每页大小划分成多个小任务进行分布式处理,下图中假设每页间隔大小为10,任务划如下图所示;
对每个小任务进行处理,每个小任务中包含主键范围,根据主键范围依次与每个表的最小主键和最大主键进行对比,判断该任务主键范围落在哪个表中,最后根据主键范围及其他条件获取数据。
缺点:
对于数据分布稀疏的聚合维度,该方法会导致预估数据总量偏大很多,分批任务数量非常大,实际获取的数据可能很少,获取数据效率低;
该方案需要频繁重复的查询每个表中的最小和最大主键,获取数据的性能不高,容易造成数据库连接池满问题;
该方案分片处理方式实现复杂、不易于理解。
▐ 纵向分片方法
根据聚合维度信息遍历所有分表得到每个分表的最大主键ID和最小主键ID,下图中min1表示分表1的最小主键ID、max1表示分表1的最大主键ID;
预估数据总量,数据总量等于所有分表中的最大主键减最小主键,如下图中count的计算公式;
根据预估数据总量count和固定每页大小划分成多个小任务进行分布式处理,下图中假设每页间隔大小为10,任务划如下图所示;
对每个小任务进行处理,每个小任务中包含主键范围,遍历所有分表判断任务主键范围是否落在最小主键和最大主键之间,从符合条件的表中根据主键范围及其他条件获取数据。
缺点:
对于数据分布密集的聚合维度,该方法会导致预估数据总量偏小很多,分片任务数量较少,每个任务包含大量数据,导致获取数据性能不高;
同样,该方案需要频繁重复的查询每个表中的最小和最大主键,获取数据的性能不高,容易造成数据库连接池满问题;
该方案分片处理方式实现复杂、不易于理解。
聚合维度降维方法
有了以上准备知识,本节开始讨论本文的方法。本文提出的方法采用聚合维度降维的思想,找到一条合适的降维路径将高聚合维度降低到低聚合维度,然后对最低聚合维度对数据进行分片处理。降维和分片的作用就是将大任务拆分成小任务,然后将小任务通过消息中间件进行分布式处理。
▐ 分库分表的设计
分片键的选择
分库分表的设计与业务相关性比较大,本文讨论一种比较常见的场景,适用于大多数的业务。分片键的选择直接影响数据分布、获取方式、获取的性能。分片键的选择有两大因素需要考虑,一个是数据获取方式,另一个是数据分布。数据获取方式需要根据具体的业务进行判断,比如,商品详情展示会根据商品ID查询基础商品表,圈品操作会根据活动ID或者卖家ID获取所有相关的商品。另外,数据需要比较均匀地分布到所有的分表中,这样才能保证不会因为数据倾斜导致获取数据时的性能问题。
基础商品表的分片键的选择有商品ID或者卖家ID。对于基础商品表,通常查询方式会指定商品ID获取商品的基础信息,并且不会对该表有过多的查询方式。因此,基础商品表采用商品ID作为分片键比较合适,根据商品ID对分表总数取模可以得到分表的位置。采用商品ID作为分片键可以比较均匀地将数据分布在所有表中。
报名记录表的分片键的选择有活动ID、商品ID、卖家ID。首先,可以排除活动ID作为分片键的选择,因为根据活动ID进行分库分表显然会导致数据倾斜严重。如果选择商品ID作为分表键,数据可以比较均匀分布到所有表中,但是根据聚合维度查询所有报名记录数据会出现性能问题,因为同一聚合维度的数据会分散到所有表中,获取数据时需要对所有表进行遍历,并且比较困难进行分布式处理。如果选择卖家ID作为分表键,同一个卖家的数据会聚合到同一个分表中,这样有利于对同一个卖家的数据进行获取,获取数据的效率也会非常高,但是在数据分布方面,个别超级卖家会拥有几十万以上的商品,特别是天猫超市卖家拥有超过2千万的商品,这些超级卖家都是电商平台自营的。
自定义分表键
从上面的分析可以知道,报名记录表采用商品ID或卖家ID作为分表键都不是非常合适的选择。如果同一聚合维度的数据分散到很多表中会导致获取性能不高,如果同一聚合维度的数据过度聚合到同一分表中会导致数据倾斜,同样也会导致获取性能不高。
本文介绍的方法,其中一个核心思想就是寻找一个合适的聚合维度将这个维度的数据聚合在同一分表中,既不会导致数据倾斜,有能够提高获取数据的性能。通过上面的分析可以知道卖家维度是比较合适的维度,但是,超级卖家会导致数据倾斜。因此,本文选择在卖家维度的基础上进行改进,采用自定义维度作为分表键,即在报名记录数据模型中增加一列sharding_key作为分表键。
对于普通卖家,自定义分表键sharding_key等于卖家ID。对于超级卖家,预先设置好该超级卖家存储的虚拟分表,比如,一个超级卖家所有的数据均匀分布到0-15号虚拟分表中,在业务逻辑层根据商品ID作为虚拟分表键对虚拟分表总数取模得到虚拟分表的序号,自定义分表键shardingKey就等于“#{卖家ID}_#{虚拟分表序号}”。流程图如下:
▐ 分布式获取的设计
某一聚合维度的报名记录数据总数可能超过亿级,如此庞大的数据总量如何高性能的获取,采用分布式方案是非常合适的。因此,需要将包含亿级数据的大任务拆分成小任务,然后将小任务分发给集群中所有的机器执行,本文采用消息中间件进行分发任务。
聚合维度的定义
数据之间具有一定的关联关系,多个数据可以与同一个标识相关联,而这多个数据就是根据同一个标识进行了聚合,我们把这个标识定义为一个聚合维度。
比如,一个卖家聚合维度相关的商品数据都属于同一个卖家,一个活动聚合维度相关的报名记录数据都报名了这一个活动,一个大促聚合维度相关的商品数据都参与了这个大促下面的某一个活动,一个自定义分表键聚合维度相关的报名记录数据都与这一自定义分表键相关。
最低聚合维度的定义
根据某一聚合维度可以直接在数据库具体某张表中获取到该聚合维度相关的所有数据,我们称这个聚合维度为最低聚合维度。因此,报名记录表中自定义分表键聚合维度属于最低聚合维度,因为根据一个自定义分表键聚合的报名记录数据都存储在同一个物理分表中,根据自定义分表键可以定位到具体的物理表。
聚合维度的降维
聚合维度之间也具有一定的关系,高聚合维度可以由低聚合维度组成。比如,在报名记录相关的数据模型中,一个大促聚合维度由多个活动聚合维度组成,一个活动聚合维度由多个卖家聚合维度组成,一个卖家聚合维度由一个或者多个自定义分片键聚合维度组成。从上面的关系可以得知,高聚合维度可以不断降维直到最低聚合维度,其实也就是,一个高聚合维度的大任务可以通过降维方式分解成许多最低聚合维度的小任务。一个大促聚合维度的降维过程如下所示:
如何实现高性能的降维也是比较关键的点,影响降维性能的核心应该是获取聚合维度组成部分的过程。大促ID与活动ID的关系存在活动表中,活动表是单库单表,因此只需要建立大促ID+活动ID的组合索引,查询大促ID相关的所有活动ID时就可以通过覆盖索引的方式获取,覆盖索引方式的查询性能很高。活动与报名卖家的关系存储在报名记录表中,报名记录表是根据自定义分片键作为分片键的分库分表,同样在报名记录表中建立活动ID+卖家ID的组合索引,查询活动ID关联的所有卖家ID时需要遍历所有物理表利用覆盖索引的方式获取,虽然需要遍历所有物理表,但是可以通过多线程处理,且使用覆盖索引查询方式的性能很高,因此获取活动相关卖家ID性能同样很高。而卖家ID与自定义分表键之间的关系属于配置关系,获取卖家相关的自定义分表键属于内存计算,性能非常高。
最低聚合维度的数据分片
得到最低聚合维度之后,我们可以直接获取到商品ID,但是,由于一个最低聚合维度相关的商品数据也有很多,为了提升商品数据获取性能,我们需要继续对数据进行分片,将一个小任务拆分成更多的小任务,充分利用集群分布式处理的能力。总体的数据分片思路如下所示:
最大最小主键ID获取
获取最大和最小主键ID的最简单方式是通过max和min函数,但最好不要使用这种方式,因为这种方式会因为访问数据行数过多导致性能问题。在上面讲述聚合维度的降维中提到需要建立活动ID+卖家ID的组合索引,其实可以将该索引改进一下,变成活动ID+卖家ID+自定义分表键的组合索引。这样便可以通过覆盖索引查询最大最小主键ID,性能更高,其中查询最大主键ID的SQL如下所示。
SELECT id FROM table WHERE activity_id = #{activityId} AND seller_id = #{sellerId}
AND sharding_key = #{shardingKey} ORDER BY id DESC LIMIT 1
数据分片与获取
最低聚合维度相关的所有报名记录数据都在同一个物理表中,但这并不代表最低聚合维度相关的所有报名记录按主键ID连续分布在同一个物理表中,实际情况往往是数据非连续分散在物理表中,且有时分散范围很大。通过求主键ID的最大和最小值可以预估数据总量为最大值-最小值。如果按照预估数据总量和固定任务大小方式进行划分任务,当数据分布非常分散时,将产生大量的任务,实际其中大部分任务不包含任何报名记录数据。
固定任务大小方式划分任务
假设某最低聚合维度总共包含200条数据,最小主键ID(minId)=1000、最大主键ID(maxId)=10000000,预估数据总量为maxId-minId=9999000,每个任务固定分配500大小的数据,即需要划分9999000/500=19998个任务,第一任务的主键ID范围为[1000,1500),最后一个任务的主键ID范围为[9999500,10000000]。
因此我们需要使用一种合理的算法对数据进行分片和获取,避免因为任务划分过多而导致性能下降,避免因为单个任务包含过多的数据而导致处理性能下降。
首先,我们采用动态任务大小方式进行数据分片,任务的大小会根据预估数据总量、集群机器总数、数据分布稀疏系数、初始任务大小按照一定的算法计算出来。算法计算流程如下:
然后,在数据获取方面,由于无法根据任务的主键ID范围判断任务实际包含的数据总数,为了避免一次性获取大量数据导致慢sql,我们需要采用主键ID作为游标的方式分批获取数据,流程图如下:
效果
使用本方案的产品在实际应用场景中,根据聚合维度获取数据的速度能够达到50000 QPS,在忽略业务处理耗时的情况下,该方案获取数据的速度可以达到更高。后续会在“在线数据圈选引擎”中继续进行压测验证。
作者|周忠太(默达)
编辑|橙子君
出品|阿里巴巴新零售淘系技术