许多时候我们说一款产品的设计是数据驱动的,是指许多产品方面的决策都是把用户行为量化后得出的。一个典例的例子就是注册流程的设计,如果用户需要填写的注册信息较多,一般就会分成多个页面,而产品设计师最关心的就是每个页面的流失率,从而不断的对这个流程作调整以达到信息量与流失率之间的平衡。

为了能够量化用户的行为,前提是要将各种用户事件都保存下来。其中最典型的事件包括user creation, page view和button click,但实际上还有许多其他事件,比如用户更改了状态或是录入了某些数据等等。目前有许多第三方的服务可以帮助你做这方面的统计,国内有友盟,国外有Google Analytics和Mixpanel。但如果你记录的事件数量非常庞大,或是对之后的数据分析有非常定制化的要求,那就要考虑自己构建事件分析的平台,而这个过程中最关键的一步就是如何存储用户事件。

首先我们来分析一下用户事件存储有哪些特性

  • 数据量巨大 用户在应用中产生的事件数量远远大于他们产生的数据。非常简单的一个例子,就是用户在浏览各个页面时,他们并不产生任何数据,但却产生了大量的page view事件。所以事件数据的量往往是主数据库的几十倍甚至上百倍。
  • 不一致的数据结构 虽然所有的事件都有一些公共的属性,比如事件名称,事件时间,应用的版本与操作系统等等,但有很多事件有自己特定的属性,比如用户注册事件,我们会非常关心注册的渠道,是用email注册还是用社交网络注册(比如微博,微信等),同样一个论坛贴子的查看事件,我们会想要记录贴子的ID与版块的ID。这种不一致性,给我们设计数据存储结构带来了许多麻烦。
  • 聚合式查询 我们在使用用户事件的数据时,往往不关心单个人的事件,而只关心统计结果。所以一个典型的查询模式就是访问大量的历史数据,对查询结果按某一个特定的维度聚合。

存储这类数据的方法一般可以分为三类

  • 传统关系型数据库,如MySQL, PostgreSQL
  • Hadoop HDFS + Hive
  • 数据仓库,如Amazon Redshift, Microsoft SQL Server for PDW

后两种方案有先天的技术优势,但维护成本高,并且其优势需要在数据量突破某个临界点之后才能真正显现。第一种方案看似毫无亮点,但对于创业型小团队来说,却有其价值在。因为关系数据库大家都很熟悉,对于运维来说,没有额外的维护成本。当数据量在TB以下时,如果正确地建立索引,查询速度也是非常快,并且也可以通过Sharding的方法做分布式的扩容。Glow目前正处于从MySQL存储到Redshift的转型,所以今天我们主要想分享一下用关系数据库来存储与分析用户事件的一些经验,我们会在将来的博客中介绍后两种系统(它们往往是共存的)

表结构的设计

第一个要解决的问题是,我们应该将所有的事件存在单一表中,还是每个事件存在单独的表里。两者有其各自的优势。比如后者,每个事件单独建表,表结构非常清晰,易于理解。但缺点是每次定义新事件都需要改动数据库结构。我们希望事件的定义是非常轻量的,所以在Glow我们选择了前者。前者的关键问题是,各种事件都有不同的属性集合,难道把所有事件的所有属性都放在表结构的定义中?这样很快这个事件就会有成百上千的字段,对于存储与查询来说都非常的低效。我们的做法是定义一组通用的字段用于事件属性,并在代码中定义映射关系。

我们的事件表结构大致是这样的

CREATE TABLE `EventLog` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `event_time` bigint(20) NOT NULL,
  `event_name` varchar(255) NOT NULL,
  `user_id` bigint(20),
  `platform` tinyint(4),
  `app_version` varchar(20),
  `ip_address` varchar(20),
  `device_id` bigint(20),
  `data_1` bigint(20),
  `data_2` bigint(20),
  `data_3` bigint(20),
  `data_4` bigint(20),
  `data_5` bigint(20),
  `data_6` bigint(20),
  `text_1` varchar(255),
  `text_2` varchar(255),
  `text_3` varchar(255),
  `text_4` text,
  `text_5` text,
  `text_6` text,
  PRIMARY KEY (`id`),
  KEY `idx_event_id` (`event_time`, `event_id`),
  KEY `idx_event_and_platform` (`event_time`, `event_id`, `platform`),
  KEY `idx_event_and_version` (`event_time`, `event_id`, `version`),
  KEY `idx_user` (`user_id`),
);

首先,我们会记录事件的名称event_name与时间event_time,然后是所有事件共有的属性user_id, platform, app_version, ip_address, device_id。随后的data_*text_*则是用于各个事件的特有属性。事件在代码中的定义大致如下

FORUM_NEW_TOPIC = {
    'name': 'forum new topic',
    'mapping': {
        'room_id': 'data_1',
        'subject': 'text_1',
        'content': 'text_4',
    }
}

这是论坛中发贴事件的定义,应该很容易看懂。讨论区的IDroom_id是整型,标题与贴子正文都是字符串,但正文很可能超出255长度的限制,所以被放入text_4。再看一个更有趣些的例子

SHARE_APP = {
    'name': 'share app',
    'mapping': {
        'channel': {'field': 'data_1', 
                    'enum': ['facebook', 'twitter', 'sms', 'email']},
        'message': 'text_1',
    }
}

这是分享app的事件,其中分享渠道channel是一个枚举类型,所以被映射到了data_1而不是text_1。在存储该类事件时,我们会验证事件中的channel的值是否为上述4个字串之一,并且只保存字符串的hash值。在从数据库读取该类事件时,当我们解析data_1字段的值时,会反向查找hash值对应的原始字串。在实际使用中,text_*的字段的使用率是比较低的,因为大部分的用户事件中的字符串都是枚举类型。枚举型的存储占用空间更小,查询也更快,因为整数比较要明显快于字串比较。

在Glow中,有一个事件定义文件,我们称为事件的masterfile,这个文件定义了Glow中所有的事件,由数据分析团队管理与修改。另外有一个模块专门负责将系统中接收到事件,根据masterfile,转化成正确的数据格式并存入数据库。

性能与扩容

之前也提到,用户事件的数据远多于其他的生产环境数据。当单表的数据条数过大时,无论是查询还是插入性能都会下降,那么如何扩容与保特性能呢?因为本质上这个事件数据是一个时间序列,所以第一步就是按时间维度分表。我们把每天的数据放在一张单独的表中,表的命名方式是event_log_YYYY_MM_DD。这样做有很多的好处

  1. 当前写入表的记录数量仅仅只有一天的数据量,提升插入的性能
  2. 由于大部分查询都会有一个时间范围,我们只需要查询该时间范围所涉及的表即可。
  3. 可以很方便地将历史数据表归档。

同时为了方便Ad-hoc的查询,我们可以把多个单日表合并成一个月视图或是年视图。

CREATE VIEW event_log_2014_01 AS
SELECT * from event_log_2014_01_01
UNION ALL
SELECT * from event_log_2014_01_02
...
UNION ALL
SELECT * from event_log_2014_01_31;

对于事件的写入,由于时效性并不重要,所以我们应尽量将一段时间内的事件对象缓存在内存中,然后批量一次性的写入。这样对数据库系统的负载会小很多。在实际的系统架构中,我们为用户事件的收集与写入单独起一个Service进程,通过unix socket与web服务的主进程通信。

事件数据库的分布式扩容非常容易,可以通过user_id做为hash-key来分库,也可以随机分库。然后只需简单的通过增加数据库集群中服务器的数量就可以扩容了。

分析与统计

由于我们对事件的属性做了映射与hash,同时做了按天分表以及分布式的sharding,所以直接用SQL来对数据表查询虽然可行,但并不是很方便。我们可以把数据分析常用的一些查询写成API的形式,并且把前面提到的那些复杂性都封装在API的实现中。在系统中,我们称这类API为Metrics API。在定义API接口的过程中,我们主要参考了Mixpanel的API接口定义

整个Metrics API的方法数量小于10个,以下是3个比较常用的API

def count(event_name, start_time, end_time, where=None):
    ''' 返回所有符合条件的事件的总数

        >>> events('forum new topic', '2014/12/01', '2014/12/31', where={'room_id': 1})
        1321

        2014年12月所有在讨论区1中发贴事件发生的总次数为1321。
    '''
    ...

def group(event_name, property, start_time, end_time, where=None):
    ''' 对事件按某一个属性进行分组,返回该属性在这类事件中值的分布

        >>> group('share app', 'channel', '2014/12/01', '2014/12/31')
        {
            'facebook' : 786,
            'twitter'  : 439,
            'email'    : 300,
            'sms'      : 257,
        }

        2014年12月通过各个渠道分享app的次数统计
    '''
    ...

def retention(start_time, time_unit, retention_length, born_event, retention_event, where=None):
    ''' 用户的粘性分析,将某个时间段内诞生的用户做为实验组,观察这组用户在之后的几个时间段里的活跃度
        诞生事件由born_event决定,活跃事件由retention_avent决定

        >>> retention('2014/12/01', 'week', 4, 'user created', 'app open', where={'platform': 'android'})
        {
            'cohort_size': 34032,
            'retentions': [0.54, 0.42, 0.31, 0.25]
        }

        以2014年12月1日为始的那一周(12/01 - 12/07)在Android平台上注册的用户做为一个集合,共34032个用户。
        他们中在之后第一周(12/08 - 12/14)打开app的人数占集合总数的54%
        他们中在之后第二周(12/15 - 12/21)打开app的人数占集合总数的42%
        他们中在之后第三周(12/22 - 12/28)打开app的人数占集合总数的31%
        他们中在之后第四周(12/29 - 01/04)打开app的人数占集合总数的25%
    '''
    ...

数据分析团队是Metrics API的主要用户,他们95%以上的工作都可以通过这套API来完成。开发团队则会通过并发或是缓存等方法,持续的优化API的性能。

总结

这次与大家分享了基于关系数据库的用户事件存储与分析,希望以后能将这套方案开源,但暂时还没有具体的时间计划。在本文的开始,我提过目前Glow正在向用Redshift + Hadoop + Hive的平台转型,等这部分工作完成后再和大家分享经验。