很多朋友很想知道神策分析(Sensors Analytics)是如何实现在每天十亿级别数据的情况下可以做到秒级导入和秒级查询,是如何做到不需要预先指定指标和维度就能实现多维查询的。今天正好在这篇文章里面,和大家交流一下我们的技术选型与具体的架构实现,希望能够对大家有所启发。

当然,脱离客户需求谈产品设计,不太现实;而脱离产品设计,纯粹谈技术选型与架构实现,也不现实。因此,我们首先会跟大家探讨一下神策分析从产品角度,是如何从客户需求抽象产品设计的,而产品设计,又是如何确定我们的技术选型。然后,我们则会从产品的整体架构出发,逐步介绍每一个模块和子系统的具体实现。

1. 客户需求决定产品设计,产品设计决定技术选型

1.1 私有化部署

在决定做神策分析这个产品的最开始,我们就准备满足这样一类客户的需求,即对数据的安全与隐私有顾虑,或者希望能够积累自己的用户行为数据资产,并且完成数据的深度应用与二次开发。

因此,这就决定我们的产品需要是一个可以私有化部署的产品,可以部署在客户的内网中,这基本也构成是我们产品的核心设计理念。

而正因为需要私有化部署,我们在设计上,也必须考虑到因为这一点而带来的一系列在运维、审计方面的技术调整;同时,为了方便客户能够基于我们的系统二次开发,充分发挥数据的价值,这就要求我们在技术选型上,尽可能选择热门的开源技术,必须保留最细粒度原始数据,同时,最好在数据处理从采集、传输到存储、查询的各个环节都对外提供普适易用的接口,降低客户的开发代价。

1.2 用户行为分析

数据分析是一个很大的领域,而我们最核心地是满足客户对于用户行为分析这一个特定领域的需求。帮助他们回答这样一些问题:用户使用产品的活跃情况和频次分布;核心流程转化情况;分析上周流失用户的行为特征等。同时,我们期望神策分析可以适应不同的行业的不同产品,而这些不同产品肯定又会有不同的技术架构。

针对这种需求,我们在技术上做了两个关键性的决策:首先,我们对分析模型进行抽象,期望用少数几个分析模型能够满足大部分需求,而剩余未满足的需求则通过自定义查询来实现,这样的好处,就是可以集中精力来优化查询速度;其次,为了减少 ETL 的代价,我们尽量简化数据模型,从而能够减少 ETL 的环节,保证神策分析产品在不同行业的适用性。

1.3 全端数据采集

随着互联网的发展越来越深入,一个用户在同一个产品上的行为,已经需要从多个不同的来源来进行采集了,这些来源包括 iOS、安卓、Web、H5、微信、业务数据库、第三方配送服务、客服系统等。不仅仅是需要采集到,还需要能够将同一个用户在不同来源的数据进行打通。

针对这种需求,神策分析决定提供全端的数据采集方案,需要包括主流的客户端平台和主流语言的 SDK,Restful 风格的数据导入 API,全埋点与可视化埋点等埋点辅助手段。同时,为了方便埋点的迭代与管理,还需要在这方面下大力气。同时,为了解决跨屏贯通一个用户的问题,还需要提供 ID-Mapping 方面的解决方案。

1.4 灵活的多维分析

相比较传统的网页或者 App 统计工具,如百度统计、Google Analytics 等,我们想解决的是用户更加灵活更加深入的分析需求。例如,分析不同地域、不同品类的销量对比;分析比较不同客服的服务质量;对比不同年龄段用户的转化情况;分渠道分析转化效果;察看使用不同功能后对留存的影响等。

针对这一需求,神策分析需要提供全功能的多维分析能力,能够满足如下技术指标:维度、指标不需要预定义;漏斗、留存、回访分析都可以任意下钻。而因为这些需求,所以现在很火的开源多维分析系统,如 Apache Kylin、Druid 等都无法满足我们的需要。同时,这也决定了我们不能够存储聚合数据,而是应该存储最细粒度的明细数据。当然,这一决策也是受到私有化部署提供数据进行二次开发的影响。

1.5 秒级导入和秒级查询

在数据分析的某些应用场景下,对于数据导入后多长时间能够查到,其实是有一定需求的。例如,分析彩票投注截止前的最后30分钟投注情况,或者广告投放后立刻察看效果以便调整策略等。为了满足这部分需求,更好地让数据为决策服务,神策分析需要让数据能够做到秒级导入,即一条数据接收后,在秒级就能被查到。因此,我们应该有实时数据导入流,并且尽可能地减少 ETL 的复杂程度,从而降低数据导入的开销。

我们一直推崇数据驱动,推崇数据的需求方可以自助式地满足数据分析需求。因此,这就需要我们的产品能够满足秒级查询。这也就意味着我们在查询引擎的选择方面,就不能够选择 Hive,而应该考虑类似于 Impala、Spark SQL 等 MPP 架构的方案。

1.6 目前为止的技术决策

现在,我们回顾一下,由于之前我们列出的这一系列客户需求,我们基本确定了如下的一些技术决策:

  • 具有私有化部署能力,需要解决运维问题;
  • 技术选型以开源方案为主,便于复用和客户二次开发;
  • 数据模型尽量简洁,减少 ETL 代价;
  • 每天十亿级别数据量下,秒级导入,秒级查询;
  • 需要存储明细数据,采用 ROLAP 而不是 MOLAP 的方案。

那么,下面,就让我们进一步来看看,在这些技术决策之下,神策分析具体是怎么实现的。

2. 技术实现

2.1 整体架构

通常情况下,一个常见的数据平台,整个数据处理的过程,可以分为如下 5 步:

神策分析设计上也基本遵循这一原则。下图是神策分析的架构图:

我们后面依次介绍各个子系统的具体技术实现。

2.2 数据采集子系统

神策分析主要支持采集三类数据,分别是前端操作、后端日志和业务数据。

2.2.1 采集前端操作

其中前端操作,主要是用户在客户端,如 iOS App、安卓 App 和网页上的一些操作,目前主流有三种采集方案,分别是全埋点、可视化埋点和代码埋点。

其中,全埋点是指默认地不加区分地采集所有能够采集的控件操作和交互,然后再在后端筛选出需要的数据;可视化埋点则是通过可视化交互的方式,提前选择要采集哪些控件的操作,然后再在操作发生时向后端发送数据;代码埋点则是最传统和广泛使用的一种技术采集手段,是以代码调用 SDK 接口的方式,来发送要采集的用户操作。

三种采集方式,在使用上,全埋点最简单,只需要嵌入 SDK 即可,但是会带来额外的数据开销;可视化埋点不会有额外的数据开销,嵌入 SDK 后需要用可视化的方式进行控件的点选。由于国内 App 开发普遍存在不遵循开发规范的情况,导致上述两种采集方案都存在一定的兼容性问题。代码埋点则是每一个点都需要一行或者更多的代码,更新迭代时使用较为复杂。

但是反过来,代码埋点却是采集能力最强的一种采集手段。我们以用户在京东的提交订单页面,来分别描述三者在采集能力上的差异:

在这个界面上,使用三种采集方案的采集能力大概如下面所描述的那样:

  • 使用全埋点,可以知道某时某刻某人点击了某个按钮;
  • 使用可视化埋点,可以跟进一步知道某时某刻某人提交了一个订单;
  • 使用代码埋点,则可以更进一步地获取订单金额、商品名称、用户级别等自定义属性。

为了方便客户的使用,神策分析对于采集前端操作提供的方案则是:

  • 以代码埋点为主,提供了 iOS、安卓和 JS 三种 SDK,特别是针对混合开发的 App,专门提供了原生 App 和 H5 之前交换数据,打通用户 ID 的接口;
  • 以可视化埋点为辅,并且努力解决国内 App 的兼容性问题;
  • 对于一些例如页面浏览、App 启动、App 进入后台等默认事件,则提供全埋点的方案,可以嵌入 SDK 之后直接采集;
  • 建议客户根据不同的使用场景,选择最合适的前端埋点方案。

同时,为了最大限度保证嵌入了我们 SDK 的客户 App 的用户体验,我们专门针对 SDK 的数据发送策略做了很多优化,包括:

  • 数据首先缓存在本地,达到一定规模或间隔时才发送;
  • 仅在 3G/4G/Wi-Fi 时发送数据,发送时会对数据进行打包、压缩;
  • 在产生首条数据、进入后台、退出程序时都会尝试发送,以尽可能兼容那些安装了 App 简单尝试后就快速删除的用户;
  • 提供了强制发送接口,也让客户自己可以控制发送策略。
2.2.2 采集后端日志

后端日志主要指产品的服务端模块在运行时打印出的日志。相比采集前端操作,采集后端日志会有如下一系列的优势:

  • 传输时效性:如前面描述的那样,为了保证用户体验,前端采集数据是不能实时向后端发送的,所以会带来传输时效性的问题,而采集后端日志就不存在这个问题;
  • 数据可靠性:前端采集需要通过公网进行数据传输,肯定会存在数据可靠性的问题,采集后端日志配合私有部署,则可以做到纯内网传输数据,这一问题大大缓解;
  • 能够获取的信息丰富程度:有很多信息,例如商品库存、商品成本、用户风险级别、用户潜在价值等,在前端都采集不到的,只能在后端采集。

正因为这一点,我们建议一个行为在前端和后端都可以采集时,优先在后端进行采集,并且为此提供了一系列的后端语言 SDK、日志采集工具和数据批量导入工具等。

2.2.3 采集业务数据

业务数据主要指一些供销存数据库数据、从第三方系统拿到的订单配送数据和客户数据等,针对这一类数据,我们提供了相应的数据导入工具,以及 RESTful 风格的导入 API,用于完成数据的导入。

2.2.4 ID-Mapping

对于前面三类不同的数据源,我们期望能够打通同一个用户在这三类数据源中的行为,并且为此提供了如下的技术手段:

  • 不同端可以自定义唯一的用户 ID,如设备 ID、Cookie ID、注册 ID等,客户可以自己定义自己选择;当然,神策分析系统内部会有唯一的 user_id;
  • 提供一次性的 track_signup 接口,将两个 ID 贯通起来,例如,可以将一个用户在浏览器上的 Cookie ID 与他在产品里面的注册 ID 贯通起来,然后这个用户以注册 ID 在手机 App 上登录时,我们依然能知道是同一个用户;
  • 我们目前采用的方案不需要回溯数据,但是应用是有限制的,即只能支持一对一的 ID-Mapping,这也是一个典型的功能与性能的折衷。

2.3 数据接入子系统

不管是采用哪种采集方式,数据都是通过 HTTP API 发送给系统的。

而在数据接入子系统部分,我们采用了 Nginx 来接收通过 API 发送的数据,并且将之写到日志文件上。使用 Nginx 主要是考虑到它的高可靠性与高可扩展性,并且在我们的应用场景下, Nginx 单机可以轻松地做到每秒接收 1 万条请求,考虑到一条请求通常都不止一条用户行为,可以认为很轻松就能做到数万 TPS。

对于 Nginx 打印到文件的日志,会由我们自己开发的 Extractor 模块来实时读取和处理 Nginx 日志,并将处理结果发布到 Kafka 中。在这个过程中,Extractor 模块还会进行数据格式的校验,属性类型的识别与相关元数据的操作。与 ID-Mapping 的处理也是在这个阶段完成的。一些字段的解析和扩展工作,如基于 IP 判断国家、省份、城市,基于 UserAgent 判断浏览器、操作系统等,也是在这个阶段完成的。前面提到了,我们不需要用户预先指定指标和维度,基本实现了 schema-free,就是在 Extractor 处理阶段,对这些列进行校验,并且完成相关的元数据操作。

Kafka 是一个广泛使用的高可用的分布式消息队列,作为数据接入与数据处理两个流程之间的缓冲,同时也作为近期数据的一个备份。另外,这个阶段也对外提供访问 API,客户可以直接从 Kafka 中将数据引走,进入自己的实时计算流。

2.4 数据模型

在介绍数据导入模块与数据存储模块之前,我们需要先讨论一下神策分析的数据模型设计。

前面已经提到,神策分析主要解决的是用户行为分析这么一个特定领域的数据分析需求,并且期望尽量简化数据模型以降低 ETL 代价。最终,我们选择了业内非常流行的 Event + User 模型,可以覆盖客户的绝大部分分析需求,并且对于采集到的数据不需要有太多的 ETL 工作。

Event 主要是描述用户做了什么事情。每一条 Event 数据对应用户的一次事件,由 用户 ID、Event 名称、自定义属性三部分组成。Event 名称主要是对 Event 的一个分类,例如“PageView”、“Search”、“PayOrder”等等。我们在客户端会默认采集设备 ID 或者 Cookie ID 作为用户 ID,客户也可以自己设置一个合理的用户 ID。而我们最多可以支持一万个自定义属性,并且开发者并不需要事先告之系统,系统会自动从接收的数据中解析发现新的字段并且进行相应的处理。Event 数据以追加为主,不可修改,这也符合事件这一概念的实际物理意义。不过,为了方便后续系统的运维以及客户的使用,我们特别为 Event 数据提供了有限的数据删除能力,这一点会在后续存储部分更详尽地描述。

User 则主要描述用户是个什么样的人。它用 用户 ID 与自定义属性两部分组成。自定义属性是年龄、所在地、Tag等。在神策分析中,它的来源主要有三类,一类是使用者自己采集并且通过接口告之系统的,例如用户的注册信息;一类是基于使用者的第一方数据挖掘得到的用户画像数据;还有一类则是通过第三方供应商得到的用户在第三方行为所体现出来的属性和特质。在神策分析中,User 数据,是每一行对应一个用户,并且可以任意修改的。

2.5 数据导入与存储

正如前面所说,由于神策分析自身的产品需求,如导入时效性、不需要预先指定指标和维度等,目前比较热的开源 OLAP 系统如 Apache Kylin 和 Druid,我们并不能拿来使用。而最后,我们选择的是存储最细粒度数据,在每次查询时都从最细粒度数据开始使用 Impala 进行聚合和计算,而为了实现秒级查询,我们在存储部分做了很多优化,尽可能减少需要扫描的数据量以便加快数据的查询速度。

具体来说,虽然存储都是构建在 HDFS 之上,但是为了满足秒级导入和秒级查询,我们将存储分为 WOS(Write Optimized Store)和 ROS(Read Optimized Store)两部分,分别为写入和读取进行优化,并且 WOS 中的数据会逐渐转入 ROS 之中。

对于 ROS,我们选择了 Parquet,这样一个面向分析型业务的列式存储格式。并且,根据我们面临的业务的具体查询需求,对数据的分区方式做了很细致的优化。首先,我们是按照事件发生的日期和事件的名称,对数据做 Partition;同一个 Partition 内,会有多个文件,文件大小尽量保持在 512 MB 左右;每个文件内部先按照 userid 的 hash 有序,再按照 userid 有序,最后则按照事件发生的时间有序;会有一个单独的索引文件记录每个 user_id 对应数据的文件 offset。另外,与大多数列式存储一样,Parquet 对于不同类型的列,本身有着很好的压缩性能,也能大大减少扫描数据时的磁盘吞吐。简单来说,利用列式存储,只扫描必要的列,利用我们自己的数据分区方案,则在这个基础之上进一步只扫描需要的行,两者一起作用,共同减少需要扫描的数据量。

虽然 Parquet 是一个查询性能很好的列式存储,但是它是不能实时追加的,因此在 WOS 部分,我们选择了 Kudu。在向 Kudu 中写入数据时,我们选择了类似于 0/1 切换的方案,即同一时间只写入一张表,当这张表的写入达到阈值时,就写入新表中,而老表则会开始转为 Parquet 格式。

由于这样一个转换过程,不可避免地会带来 Parquet 的碎文件问题,因此也需要专门解决。

下图比较详细地展示了这样一个转化的过程:

在这个导入过程中,有如下几个关键的工作模块:

  • KafkaConsumer:一个常驻内存的 MapReduce 程序 (只有 Mapper),负责实时从 Kafka 中订阅数据,并且写入到 Kudu 中。
  • KuduToParquet:一个不定时启动的 MapReduce 程序,在 Kudu 单个表写入达到阈值并且不再被写入时,将它转成多个 Parquet 文件,并且移动到对应的 Partition 中。
  • LoaderDemon:一个后台调度程序,完成一些元数据操作;
  • Merger:一个定时的 MapReduce 任务,定期合并 Parquet 中每个 Partition 内的碎文件。

2.6 数据查询子系统

在数据查询部分,我们是通过 WebServer 这个模块,接收客户通过我们的 UI 界面或者通过 API 发起的查询请求,WebServer 并不做额外的处理,而是将这些查询请求直接转发给 QueryEngine 模块。QueryEngine 模块则是将查询请求翻译成 SQL,并在 Impala 中发起查询。Impala 会访问 Kudu 与 Parquet 数据共同构成的 View,完成对应的聚合与聚散。

特别提一下,在查询引擎部分,我们选择 Impala 而不是 Spark SQL,并不是出于性能或者稳定性的考虑,仅仅是因为我们团队之前有比较多的基于 Impala 的工作经验。

为了保证秒级的查询性能,我们除了在存储部分做文章以外,还在查询部分做了很多的优化,这些优化包括:

  • 界面上提供的查询模型有限,但是能满足客户绝大部分需求,因此专门针对这些查询模型做有针对性的优化;
  • 使用 UDF/UDAF/UDAnF 等聚合函数替代 Join,提高查询效率,特别一提的是,由于 Impala 不支持 UDAnF,这里我们修改了 Impala 的源代码;
  • 比较精细的缓存,只对有变化的数据才进行重查;
  • 提供按用户抽样的功能,客户可以通过抽样数据快速尝试不同的猜想,并最终在全量数据上获取准确结果。

2.7 元数据与监控

我们主要使用 MySQL、ZooKeeper 来存储元数据,主要包括类似于 Schema、维度字典、数据概览、漏斗、分群、预测的配置、任务调度、权限等信息。我们还用到了 Redis 来存储查询缓存信息。

我们有 Monitor 这样一个常驻内存的模块,对系统的各个部分进行语义监控,并且进行异常状态的修复。当然,作为一个商业系统,我们对于 license 的处理也是放在这个模块之中的。

同时,为了减少运维的代价,对一些不常用的功能,虽然不提供界面但是也工具化了,包括数据清理工具、版本升级工具、性能分析工具、多项目管理工具等。

2.8 用户分群与用户行为预测

用户分群与用户行为预测,我们单独放在最后面进行探讨。

神策分析对用户分群的定义,是根据用户以往的行为,给用户打标签。例如,找到那些“上个月有购买行为的用户”,“最近三个月有登录的用户”等。它是用户画像的一部分,但肯定不是用户画像的全部。

而与之相对的,用户行为预测则是根据用户过往的行为,预测将来做某个行为的概率。

这一部分的具体实现,我们会在其它的文章中做相应的介绍。