技术内参 | 神策分析架构演进:“变”与“不变” 中的思索与创新
作者:付力力,神策数据联合创始人&技术 VP
毕业于北京理工大学软件工程专业,2008 年至 2013 年期间历任百度新产品研发部、网页搜索部、基础架构部工程师。2013 年 9 月年至 2014 年 8 月担任豌豆荚数据部门资深研发工程师。2014 年 9 月至 2015 年 4 月担任黄金钱包技术合伙人。2018 年 8 月,荣登“2018 福布斯中国 30 岁以下精英榜”。
2015 年 9 月正式发布了神策分析 1.0 版本,在随后的 3 年里,我们的产品研发团队一直在不断地进行版本迭代,到目前为止一共发布了 12 个大版本。
相比于最初的 1.0 版本,现在的神策分析无论是在产品体验还是在底层架构上都已经发生了很大的变化:
从最初只能使用 3 个单薄的基础分析功能,到现在支持 10 个分析模型联合构建的场景化分析能力;从最初只能支持每天数万日活的小 App,到现在可以轻松应对一天产生数百亿的数据量巨型 App。
而另一方面,3 年内,神策分析里也有很多地方没有改变:
例如,从第一版的设计里就确定了模型的 Event-User,该模型现在依然是整个神策分析里最基础和重要的概念。
在这篇文章里,我给大家介绍神策分析最近在底层架构上一些比较大的设计改进,同时也会分享我们在这些架构设计中关于"变"与"不变"的思考。
我们之前在很多场合都对神策分析的底层架构做过详细的介绍,这个架构的主要特点之一是:
神策所有的分析结果都是从明细数据实时查询得出,而不是基于大多数分析系统所使用的预计算技术,之所以这么设计,因为我们希望系统数据分析能力的上限在于数据本身。
换句话说,我们期望只要是从已经采集的数据里可以分析得到的结论,神策都希望可以帮助我们的客户很容易的实现。
从结果看来,这种架构设计的好处是非常显著的:
它大大简化了整个系统的数据流,我们不需要为不同的分析模型来维护复杂的聚合表,并在数据回溯的时候保持这些数据之间的一致性(大多数类似的数据系统里要么抛弃数据回溯的能力,要么放弃数据一致性)。
受益于这种架构,我们在很短的时间内推出众多灵活的分析模型,并且这些分析模型之间可以通过分群等方式来进行自由的组合查询。
同时,配合我们开发的查询缓存机制,这套架构也可以在报表等相对固定的数据分析需求上得到比较好的使用体验。
当然,这种设计的另外一个结果是,神策分析很明确地抛弃了对高 QPS 查询需求的直接支持(例如不应该尝试在商品详情页里直接从神策分析获取这个商品本周的销量)。
不过,整体上我们认为,牺牲一个非必要的特性来换取数倍的分析灵活性以及一个简单可维护的架构,是一个非常划算的选择。
在最初的设计选型上我们选择 Impala,一方面是因为 Impala 已经是一个相对比较成熟的 MPP 架构的查询引擎,而且对 SQL 有着比较良好的支持。另外一方面则是因为我们的研发团队在 Impala 的使用和二次开发上有着比较多的经验。
其中,是否支持 SQL 是一个很重要的选型依据。虽然 SQL 是一种有着几十年历史、至今也没有太多变化的古老工具,但是到目前为止它依然是对表格数据进行操作的最佳选择,在易用性和灵活性之间做到了比较好的平衡。
更重要的是,我们当初经过简单的调研发现,只使用 SQL 就可以很好的实现一个用户行为分析系统的大部分需求,除此之外,还可以通过 UDF/UDAF/UDAnF 等增加扩展能力,则几乎可以满足所有常见需求。
事实上,在神策分析比较早期的版本里,所有的分析模型都是用标准 SQL 直接实现的。
随着我们产品功能的增加,我们为了满足越来越复杂的分析模型和更高的性能指标,也对 Impala 做了很多改造。
不过,在这个过程中,SQL 自身的描述能力和 Impala 执行架构的局限性也逐渐暴露出来,例如我们很难像 Spark 的 DAG 模型一样来灵活的控制 SQL 的查询计划,导致一些复杂查询的性能不佳,以及在一些组合分析的场景下没有办法很容易的复用查询的中间结果。
因此,我们开始基于 Impala 构建一个全新的查询引擎。通过对已有的各种分析模型计算过程的理解,我们发现它们几乎都可以被抽象为如下的计算过程:
▹筛选出特定时间范围内的特定 Event 数据,如果查询还涉及到 User/Item,那么还需要再次进行 Join 操作,最终得到: List<Event>
▹对 List<Event> 按照 Event 中的用户 ID 进行 Shuffle,并按时间排序,最终得到每个用户 ID 的有序 Event 序列:(User Id, List<Event>)
▹对(User Id, List<Event>) 中的每个 UserId 的 List<Event> 应用具体的分析模型规则,例如漏斗、留存等,得出每个用户 ID 的中间计算结果,如下:(User Id, IntermediateResult)
▹对 (User Id, IntermediateResult) 进行最后一次聚合,得到最终的结果
不难看出,上述计算过程中最核心的难点在于如何快速的得到 (User Id, List<Event>) ,这中间可能涉及重排序和大数据量的 Shuffle 等操作。对于需要 Join User/Item 表的查询,Join 本身的性能也可能会成为瓶颈。
我们基于 Impala 原有的执行框架,在底层存储和查询逻辑上做了一系列的优化,最终实现的分析引擎相比于原有的方式在复杂查询的执行性能上有 10x 的提升,同时由于开发方式的简化,也直接加速了我们对各种复杂分析模型的迭代速度。
在后续的文章中,我们会详细介绍这个面向用户行为分析的查询引擎的具体优化细节。
模型扩展:从 Event-User 到 Event-Item-User
在神策分析最初的设计阶段,我们就确定了以 Event-User 为核心的逻辑数据模型,可以说,Event-User 模型是整个神策分析架构的基础。
3 年以来,神策分析在数百家不同行业的客户的实践结果也充分证明了这个模型的适应能力。
所有的数据模型本质上都是对现实世界的抽象,而在抽象之后必然会损失一些对现实世界的还原能力。
所以 Event-User 模型虽然在电商、金融、在线教育、互联网娱乐、企业服务等不同的行业上都发挥了很好的价值,但是随着客户需求的不断深入,尤其是在和具体行业业务的深入融合中,我们也逐渐发现了这个模型的一些缺点。
例如在 Event-User 模型中,出于性能和可解释性等各方面的考虑,Event 是被设计为不可变的。从逻辑上看似乎没有问题,因为 Event 代表的是历史上已经发生过的事件,一般来说不应该需要进行更新。
但是,在实际的应用过程中,并不一定是这么理想的状态。
例如,在很多客户进行埋点采集的过程中,他们会发现某些 Event 在最初的阶段并不能很容易的采集到完整的数据。
比如一个电商客户,在客户端 App 里采集"商品加入购物车"事件时,只能采集到商品的 ID、名称等基本信息,而对于后续分析需要的更多维度,例如商品的分类、促销的活动信息等等,则不一定能很容易的采集到(通常这些信息都是客户端在业务中没有使用到的,如果想要采集,则需要对服务端 API、客户端内部的信息传递都做比较大的修改)。
又或者是等到真正需要分析的时候,才发现当初的采集是不完备的,这个时候想再把历史数据补上就是一件非常困难的事情。
还有另外一种比较常见的场景。某个在线教育的 App 中会有很多和课程相关的事件,例如对课程的浏览、购买、学习等,而关于课程的一些基本信息中会有许多是不断变化的,如课程的分类、定价等等。
在 Event 里记录的,应当是 Event 发生的时刻这个课程的状态,例如一个购买课程的事件,我们可以记录下来当时课程的分类、价格属性,作为 Event 的一部分。而课程的分类、定价后续可能会随着业务的需要随时调整,如果业务方希望按照最新的(或者某个特定阶段的)课程分类或者定价来分析用户的历史行为,则是一个难以完成的需求。
从技术上来看,解决上述问题的方案并不复杂。很多熟悉数据仓库的朋友可能会发现,这些其实是在传统数据仓库里比较典型的维度表的问题,可以使用经典的雪花模型或者星型模型来轻松解决。
但是,我们并不希望引入这么复杂的模型,毕竟神策分析的设计目标并不是一个通用的数据仓库。虽然灵活性是神策分析最核心的设计目标之一,但也是建立在"用户行为分析"这个目标的基础之上的。
我们期望的一个理想方式是:对数据模型增加一点有限的复杂性,但是可以给整个系统带来十倍甚至百倍的灵活性提升。
为了满足上述需求,我们在新版的神策分析中对 Event-User 模型进行了扩展,引入了 Item 的概念。这里的所谓 Item,在严格意义上是指一个和用户行为相关联的实体,可能是一个商品、一个视频剧集、一部小说等等。
如果不严格约束的话,理论上它也可以存储其它任意的扩展维度信息。
在具体的技术实现上,我们允许客户定义多个不同的 Item 实体,例如电商有商品、配送点等不同的实体。
在使用前,客户要定义这些实体,并且把这些实体的数据通 SDK 发送到神策分析系统中,自动建立起一个或者多个 Item 表。然后,出于不同性能要求和业务需求的考虑,对于 Item 表的使用我们提供了不同的两种方式。
第一种方式,客户在进行 Event 埋点时,可以选择要进行关联导入的 Item 信息。
例如有一个"商品加入购物车"的事件,这个事件里只采集了"商品 ID",但是同时因为我们事先已经定义好了"商品 Item",那么通过"商品 ID"则直接可以先把 Event 和"商品 Item"进行关联,再把"商品 Item"的某一些属性作为 Event 的一部分进行直接导入。
使用这种方式,可以在最大程度满足业务分析的情况下简化客户端对数据采集的工作,同时在查询性能方面也不会有任何下降。
第二种方式,更类似于传统数据仓库的维度表。
我们在埋点时不做任何变动,而是在需要进行查询的时候,把 Item 表加入进来。
这种方式会有更好的灵活性,因为可以在 Event 发生之后对数据进行扩展,也可以支持随时使用最新的 Item 数据进行分析,但是另外一方面,这么做并不能很好的保留事件发生当时的某些状态,而且由于需要在查询的时候进行实时的数据 Join,也不可避免的会降低查询性能。
在把 Event-User 模型扩展为 Event-Item-User 模型之后,神策分析对复杂业务场景有了更好的支持,无论是在埋点工作的简化还是在分析能力的提升上都有非常直接的帮助。
后续我们也将继续在简化 Item 数据的接入和使用上做出更多的改进。
从 2 年前的神策分析 1.4 版本开始,我们引入了用户分群功能。从架构层面,我们主要做了两件事情:一是把分群的概念引入了我们的数据模型中,二是提供灵活、便利的定义分群规则的方式。
对于第一点,我们把分群看作是用户属性的一部分,只不过这个属性是根据用户已有的行为特征计算出来的,是一个衍生属性。所以在数据模型上,分群其实是对 Event-User 模型中 User 部分的一个扩展。
当然,在物理存储上,由于分群具有频繁更新、整体删除等特点,因此并不会直接和原有的用户属性信息存储在一起,而是采用独立存储的方式。
对于第二点,一方面,我们提供了一套描述规则,允许客户直接从 UI 上定义比较复杂的分群:在某段时间做过某个 Event 几次,或者完成了某个连续的 Event 序列等。
更重要的是,我们把所有已有分析模型的用户列表功能都看作为是分群规则定义的一部分,这种方式使得客户可以很容易的把各个分析模型的结果进行组合,产生 1+1>2 的效果。
整体上来看,神策分析 1.4 在引入分群的概念之后,架构上几乎没有做任何大的改动,就可以让所有的分群和普通的用户属性一样在任何的分析模型里直接使用。
这个也是完全得益于前文提到的实时分析架构,以及具有良好扩展能力的 Event-User 模型。
随着客户对神策分析的使用场景越来越复杂,我们的客户对分群功能也提出了更多的需求。
一个比较显著的问题是:现在的神策的每个分群只能保存一个最新的结果,而不能查看历史的状态。
比如在一个电商产品里,我们可以很容易的建立一个"日购买金额>=300"的用户分群,但是这个分群每天都会自动刷新,并且会丢掉前一天的状态。
如果我们想分析这个用户分群在时间轴上的变化趋势,或者考虑一个更复杂的场景,想分析"日购买金额>=300"的这个用户群体在当天购买的商品品类的分布情况,用现在的分群功能都是没办法直接实现的。
为了实现上述功能,我们在即将发布的 1.13 版本也对用户分群功能做了一次大的改进。
首先在数据模型上,我们扩展了分群的模型定义,加入了时间维度。即每个分群不只是代表这个分群的群体在某一时刻的状态,而是可以保存每天、每周等不同时间点下的状态。
其次,我们也进一步增强了分群的描述能力,除了增强了在 UI 上进行定义的功能之外,还允许用户直接上传分群好的结果(例如某个线下活动的用户列表),或者是从一个 SQL 结果导出成一个分群,避免让分群的能力受限于已有的规则定义。
因为分群的过程,其实也是一个很典型的用户行为分析的计算逻辑,这样就很自然的把整个神策系统内对于用户行为的分析都统一到了一个计算模块上来完成。
如何准确地标识用户一直是用户行为数据系统中的一大难题。在过去的 3 年里,我们在客户端 SDK、服务端架构、数据接入的解决方案支持上做了持续的优化,解决了很多普遍的问题。
传统的网站或者 App 分析工具,通常以 Cookie 或设备号作为用户(其实是设备)的标识,同时这些分析工具大部分也并不支持跨端的分析,所以关于用户标识导致的各类问题并不突出。
但是在今天的用户行为分析场景中,准确的跨端标识用户变成了一个非常迫切的需求。尤其是在微信生态的情况下,一个自然人用户在 App、小程序、H5、公众号之间反复跳转,完成一系列行为是非常常见的场景,如果不能做到准确的标识用户,很多数据分析的需求将会无法准确完成。
这里的“有限”主要体现在一个注册用户在未登录状态下只能跟一个设备进行绑定。很显然,在很多场景下这种关联并不能很好的满足需求。
最典型的场景是,如果一个老用户更换了新的设备,那么他在这个新设备上未登录状态下的操作将会被识别为一个全新的用户,从而对某些分析结果的准确性产生影响。
因此,我们在最近的 1.13 版本提供了一个注册用户跟任意多个设备进行关联的机制。在这个新的机制下,一个注册用户可以使用多个设备进行登录,并且他在这些设备上注册/登录前后的行为都会被准确的识别到同一个用户身上,从而能在神策分析里更准确的还原一个用户的行为序列。
当然,这个在新的关联机制也并不是提供无限的灵活性。考虑这样一个场景:一个设备先后被多个注册用户登录使用,那这个设备上产生的匿名行为(即非登录状态下产生的行为)只会被关联到第一个在这个设备上登录的注册用户。
虽然在技术上我们也可以很容易的实现用户和设备之间的多重绑定,但是考虑到实际的应用场景并不常见,而且提供这种机制之后一定会给客户带来的更多理解上的复杂性,我们还是决定把新的关联机制限定在一个注册用户多个设备的场景下。
▹ 7 月 1 日之前在设备 A 上注册、登录并使用 App
▹ 7 月 2 日开始在设备 B 上使用 App
▹ 7 月 5 日在设备 B 上使用之前的帐号进行登录,并继续使用
我们可以看到,在 7 月 5 日之前,神策分析并不知道使用设备 B 跟设备 A 背后都是用户 X 在操作,也就是说在这之前计算用户数都会是 2,同时在计算留存、漏斗等数据时也都会当作两个不同的用户。
而一旦到 7 月 5 日用户 X 登录了,神策分析可以知道之前的行为其实都是同一个人 X 产生的,那么这个时候再看 7 月 5 日之前的用户数也会变成了 1。
这种数据的变化在某些场景下可能会变得更加难以理解,我们假设一个比较极端的情况,如果上面的用户 X 是在一年之后才在设备 B 上进行登录,那么这一年内设备 B 所产生的行为是否都应该视作用户 X 产生的?现实情况下可能是,也可能不是,只凭借这些信息很难做出准确的判断。
本质上,新的用户标识体系是实现了对历史数据的修正,同时由于神策分析又是一个完全基于明细数据进行实时查询的分析系统,因此数据分析的结果跟着发生变化也是很自然的事情。
正如我们在上文的 Event-User 模型扩展中提到的,虽然 Event 代表的是已经发生的事件,但是依然会有一些信息在 Event 发生的当时是无法得到的。
比如在上面的例子中,7 月 2 日当天我们并不知道在设备 B 上使用的也是用户 X,只能在 3 天之后再对这个数据进行修正。我们在一定程度上破坏了 Event 的不变性,但是也带来了更高的数据准确性。
不过,除了技术上的难点,历史数据的变化还会给数据的可解释性造成比较大的影响:很多人都会对昨天甚至更早的数据报表会发生变化产生困惑。
因此,如何在提高数据准确性的同时降低客户对数据的理解难度,会是我们后面的重点方向。