阅读视图

发现新文章,点击刷新页面。

分析时序数据:从 InfluxQL 到 SQL 的演变

近年来,时序数据的增长是 Data Infra 领域一个不容忽视的趋势。这主要得益于万物互联带来的自然时序数据增长,以及软件应用上云和自身复杂化后的可观测性需求。前者可以认为是对联网设备的可观测性,而可观测性主要就建构在设备或应用不断上报的指标和日志等时序数据上。

分析时序数据的演变史几乎是大数据分析演变史的复现,即一开始都是把数据存在关系型数据库上,使用 SQL 分析;而后由于规模增长的速度超过传统技术增长,经历了一个折衷技术的歧出;最终,用户在 SQL 强大的理论框架和生态支持的影响下,回到解决了规模问题的 SQL 方案上来。

对于时序数据来说,这一歧出造成了大量时序数据库自创方言。其中以 InfluxDB 在 V1 版本创造了 InfluxQL 方言,在 V2 版本创造了 Flux 方言,又在 V3 里开始主推 SQL 的演变过程最为有趣。

查询语言简介

InfluxQL

InfluxQL 是 InfluxDB V1 自创的查询语言,大体上模仿了 SQL 的结构,以下是一些 InfluxQL 查询的示例:

1
2
3
4
5
SELECT * FROM h2o_feet;
SELECT * FROM h2o_feet LIMIT 5;
SELECT COUNT("water_level") FROM h2o_feet;
SELECT "level description","location","water_level" FROM "h2o_feet";
SELECT *::field FROM "h2o_feet";

InfluxDB 设计开发的年代,实现一个数据库的技术远远没有像今天一样有大量人才掌握。因此,尽管 InfluxQL 努力靠近 SQL 的语法,但是在当时,以关系代数为支撑实现完整的 SQL 查询并添加时序扩展是比较困难的。InfluxQL 转而实现了大量专为时序数据分析设计的功能和运算符。例如,所有查询会默认返回时间列并按升序排序,所有查询必须带有 field 列才会返回结果,面向时间线粒度设计的特殊查询语法,等等。

基本上,InfluxQL 就是 InfluxDB 对以数值指标为主的时序数据分析需求的直接翻译。随着 InfluxDB 产品的发展,InfluxQL 还支持连续查询和指定保留策略,以实现某种程度的实时数据处理。

虽然 InfluxQL 在 InfluxDB V2 中也能使用,但是由于 InfluxDB V2 主推 Flux 查询语言,使用 InfluxQL 会面临一系列模型失配导致的额外挑战

Flux

Flux 是 InfluxDB V2 自创的查询语言。不同于 InfluxQL 模仿 SQL 的语法结构,Flux 的语法应该算作 DataFrame 的流派。Elixir 的开发者大概会对 Flux 的语法感到亲切,以下是 Flux 查询的示例:

1
2
3
4
5
from(bucket: "example-bucket")
|> range(start: -1d)
|> filter(fn: (r) => r._measurement == "example-measurement")
|> mean()
|> yield(name: "_result")

从设计理念上说,Flux 的目的是要支持各种数据源上的时序数据的联合分析。它允许用户从时序数据库(InfluxDB)、关系型数据库(PostgreSQL 或 MySQL),以及 CSV 文件上获取数据,然后进行分析。例如,可以用 sql.fromcsv.from 相关的语法从数据源拉取数据,替代上述示例中 from(bucket) 的部分,后接其他分析算子。

Flux 语言只能在 InfluxDB V2 中使用,V1 上不支持,V3 上被弃用。原因想必大家看完上面这个例子也可以想象:学习成本巨高。更不用说没有专业的语言开发者支持,要在扩展语法的同时修复各种设计实现问题,这几乎是不可负担的工程成本。

SQL

SQL 大家耳熟能详了。它的大名是结构化查询语言(Structured Query Language),理论基础是关系代数。

不同于从业务中生长出来的,专为业务场景定制的方言,SQL 有坚实的理论支持。从 E. F. Codd 发表了经典论文 A Relational Model of Data for
Large Shared Data Banks
之后,五十多年来积累在关系型数据库上的研究汗牛充栋。

尽管各家 SQL 数据库都会实现独特的扩展,有时让用户也挺摸不着头脑,但是在关系代数理论的支持下,基本的查询分析能力,每一个 SQL 数据库都能一致实现。如果在十几二十年前,或许 Data Infra 的舆论场还会出现 SQL 已死或者 NoSQL 才是未来的论调。但是在今天,毫无疑问 SQL 作为数据分析的默认选择已经王者归来。几十年来,SQL 不断地被改进和扩展,并经由一系列久经考验的实现推广,在全球范围内得到了广泛采用。

InfluxDB V3 号称实现了 SQL 查询的支持,并在该版本中推荐用户使用 SQL 分析时序数据。GreptimeDB 在技术选型上和 InfluxDB V3 不谋而合,率先自主实现了面向时序数据的 SQL 数据库,并在多个严肃生产环境当中部署使用。

抛开时序查询扩展不谈,在 GreptimeDB 上可以用标准 SQL 执行查询

1
SELECT idc, AVG(memory_util) FROM system_metrics GROUP BY idc;

SQL 的理论支持帮助新的时序数据库可靠地实现复杂的查询逻辑,以及完成日常数据管理任务。SQL 丰富的生态,也使得新的时序数据库能够快速接入到数据分析的技术栈上。例如,此前制作的输入行为分析示例,就利用 GreptimeDB 支持 MySQL 协议这点,零成本地集成到 Streamlit 上实现了可视化。

时序分析的挑战

SQL

虽然 SQL 有着理论支持强大和分析生态丰富两个核心优势,但是在传统的 SQL 数据库在处理时序数据时仍然会面临一系列的挑战,其中最突出的就是数据规模带来的挑战。

时序数据的价值密度大多数时候非常低。设备上传的信息大部分时候你都不会专门去看,应用上报自己状态健康的数据,也不需要额外留意。因此,存储时间数据的成本效率就至关重要。如何利用新时代的云共享存储降低成本,通过针对时序数据的极致压缩来减少数据本身需要的容量,都是时序数据库需要研究的课题。

此外,如何高效地从大量时序数据中提取关键信息,很多时候确实需要特定的查询扩展来优化。GreptimeDB 支持 RANGE QUERY 以帮助用户分析特定时间窗口下的数据聚合就是一个例子。

Flux

毋庸赘言,学习成本就杀死了这个方言。同样,复述一遍前文的观点,作为一个单一提供商独木难支的方言,其语言本身的健壮性,性能优化能做的投入,以及生态的开发,都面临巨大的挑战,更不用说现在这个唯一提供商还放弃了继续发展 Flux 方言。这下已死勿念了。

InfluxQL

虽然 InfluxQL 查询写起来有些像 SQL 的语法,但是其中细微的区别还是非常让人恼火的。而且,即使努力的 Cosplay SQL 的语法,InfluxQL 从根上还是一个从主要关注指标的时序分析业务需求长出来的方言。它在后续开发和维护成本上的挑战和 Flux 不会有本质的差别。

例如,InfluxQL 不支持 JOIN 查询。虽然你可以写类似 SELECT * FROM "h2o_feet", "h2o_pH" 这样的查询,但是它的含义是分别读出两个 measurement 上的数据(😅):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> SELECT * FROM "h2o_feet","h2o_pH"

name: h2o_feet
--------------
time level description location pH water_level
2015-08-18T00:00:00Z below 3 feet santa_monica 2.064
2015-08-18T00:00:00Z between 6 and 9 feet coyote_creek 8.12
[...]
2015-09-18T21:36:00Z between 3 and 6 feet santa_monica 5.066
2015-09-18T21:42:00Z between 3 and 6 feet santa_monica 4.938

name: h2o_pH
------------
time level description location pH water_level
2015-08-18T00:00:00Z santa_monica 6
2015-08-18T00:00:00Z coyote_creek 7
[...]
2015-09-18T21:36:00Z santa_monica 8
2015-09-18T21:42:00Z santa_monica 7

此外,虽然 InfluxDB V3 在强烈的用户呼声下支持了 InfluxQL 以帮助用户逐步迁移到新版本,但是 InfluxDB V3 主推的还是基于 SQL 的查询方案。换句话说,大胆点判断,InfluxQL 也是一个已死勿念的方言。

注意 InfluxQL 是查询方言,不包括 InfluxDB 行协议(Line Protocol)的部分。InfluxDB 行协议是一个简洁、完整、高效的数据写入接口。它几乎没有再开发和维护的成本,并且通过 Telegraf 的插件生态,能够快速跟一系列数据上报方案做集成。

如何迁移到 SQL 分析

上文提到,InfluxDB V3 仍然支持 InfluxQL 的核心原因是用户需求。诚然,InfluxDB 过去很长一段时间可说是时序数据库的代名词,并且现在仍然是 DB-Engines 上时序数据分类中最高影响力的数据库。因此,不少时序数据分析的用户现有的分析逻辑是用 InfluxQL 写成的。

这里介绍 InfluxQL 跟 SQL 的核心不同,从而说明如何从 InfluxQL 迁移到 SQL 分析。

时间列

应用逻辑迁移当中,最重要的一个区别就是 SQL 对时间列没有特殊的处理,而 InfluxQL 会默认返回时间列,且结果按时间戳升序排列。SQL 查询需要显式指定时间列以在结果集中包含时间戳,也需要手动指定排序逻辑。

数据写入时,InfluxQL 会默认自动用当前时间填充时间列,而 SQL 必须手动指定时间列的值。如果是当前时间,也需要明确写出:

1
2
3
4
-- InfluxQL
INSERT INTO "measurement" (tag, value) VALUES ('my_tag', 42);
-- SQL
INSERT INTO measurement (ts, tag, value) VALUES (NOW(), 'my_tag', 42);

InfluxQL 不支持一个 INSERT 语句插入多列,SQL 数据库通常支持一个 INSERT 语句插入多列:

1
INSERT INTO measurement (ts, tag, value) VALUES (NOW(), 'my_tag_0', 42), (NOW(), 'my_tag_1', 42);

此外,InfluxQL 查询使用 tz() 函数指定查询的时区,而 SQL 通常有其他设定时区的方式。例如,GreptimeDB 支持 MySQLPostgreSQL 设置时区的语法。

时间线

InfluxQL 有一些时间线粒度的查询语法,例如 SLIMITSOFFSET 等。

SLIMIT 会限制结果集中单个时间列返回数据的数量,例如 SLIMIT 1 意味着每个时间列最多返回一个符合过滤条件的结果。

SQL 不是专为时序数据分析设计的,因此需要一些取巧的手段,例如:

1
SELECT DISTINCT ON (host) * FROM monitor ORDER BY host, ts DESC;

这个查询返回以 host 为标签区分的时间列,每个时间列唯一一个结果:

1
2
3
4
5
6
+-----------+---------------------+------+--------+
| host | ts | cpu | memory |
+-----------+---------------------+------+--------+
| 127.0.0.1 | 2022-11-03 03:39:58 | 0.5 | 0.2 |
| 127.0.0.2 | 2022-11-03 03:39:58 | 0.2 | 0.3 |
+-----------+---------------------+------+--------+

通常,时序数据库会实现各自的语法扩展或特殊函数来支持时间列粒度的查询。

时间间隔

InfluxQL 的时间间隔语法形如 1d12m 等,SQL 的时间间隔语法有标准:

1
2
INTERVAL '1 DAY'
INTERVAL '1 YEAR 3 HOURS 20 MINUTES'

数据列和标签列

InfluxQL 从模型上就区分了数据列和标签列,只 SELECT 了标签列的查询是查不出数据的。此外,InfluxQL 支持 ::field::tag 后缀来指定数据列或标签列,并由此支持同名的数据列和标签列。

SQL 标准不区分数据列和标签列,都是普通的一列。不过在具体系统实现上,可能会对概念做一些映射。例如,GreptimeDB 的数据模型就区分了时间列、标签列和数据列,并有对应的映射规则。

GreptimeDB 的数据模型

函数名称

部分函数的名称未必相同。例如,InfluxQL 当中的 MEAN 函数对应 SQL 当中的 AVG 函数。

其他函数,例如 COUNT / SUM / MIN 等等,许多还是相同的。

标识符

InfluxQL 的标识符很多时候需要用双引号括起来,而 SQL 则支持无引号的标识符。

值得注意的是,SQL 的标识符默认是大小写不敏感的,如果需要大小写敏感的标识符,则需要用对应的引号括起来。在 GreptimeDB 当中,默认是用双引号括起。但是在 MySQL 或 PostgreSQL 客户端链接上来的时候,会尊重对应方言的语法。

InfluxQL 标识符引号的部分使用区别示例如下:

InfluxQLSQL
WHERE(“value”) > 42where value_col > 42
GROUP BY “tag”GROUP BY tag_col
SELECT MEAN(“value”) FROM “measurement”SELECT AVG(value_col) FROM measurement

JOIN

InfluxQL 不支持 JOIN 查询,SQL 数据库的一个重要甚至是基础能力就是支持 JOIN 查询:

1
2
3
4
5
6
7
8
-- Select all rows from the system_metrics table and idc_info table where the idc_id matches
SELECT a.* FROM system_metrics a JOIN idc_info b ON a.idc = b.idc_id;

-- Select all rows from the idc_info table and system_metrics table where the idc_id matches, and include null values for idc_info without any matching system_metrics
SELECT a.* FROM idc_info a LEFT JOIN system_metrics b ON a.idc_id = b.idc;

-- Select all rows from the system_metrics table and idc_info table where the idc_id matches, and include null values for idc_info without any matching system_metrics
SELECT b.* FROM system_metrics a RIGHT JOIN idc_info b ON a.idc = b.idc_id;

以上是来自 GreptimeDB JOIN 的示例。目前时序数据库在 JOIN 查询上支持最全的应该是 QuestDB 数据库。

时间范围查询

InfluxQL 的 GROUP BY 语句支持传递一个时间间隔,以按照特定长度的时间窗口来聚合数据。

SQL 没有这样特定的查询能力,最接近的应该是 OVER ... PARTITION BY 的语法,但是这个语法还挺难理解的。

支持 SQL 的时序数据库大多会实现自己的范围查询扩展:

GreptimeDB 的 RANGE QUERY 是其中最强大的。不过其中 ALIGN / RANGE / FILL 的含义和应该出现的位置需要一点点学习成本,我应该近期会写一篇文章来讨论这个场景的需求和 RANGE QUERY 的实现。

持续聚合

InfluxQL 支持持续聚合,这在 SQL 当中是标准的物化视图(Materialized View)的需求,TimescaleDB 就使用了 MATERIALIZED VIEW 的相关语法来实现持续聚合。

不过物化视图在大部分 SQL 数据库中的实现都比较脆弱,目前仍然是一个有待探索的领域。部分时序数据库会实现自己的持续集合方案,例如 GreptimeDB 基于数据流引擎实现了持续聚合

基于 ClickHouse 的 GitHub 事件数据库

缘起

ClickHouse 社群指标模型》一文里提到了 ClickHouse 社群基于自己的软件 ClickHouse 制作社群指标的探索。由于遇到了公开数据集表模式缺列,查询执行内存限制,以及数据库只读模式限制等问题,我在过去一周里试着按照 ClickHouse 官方博客的介绍,搭建起了一个属于自有的基于 ClickHouse 的 GitHub 事件数据库。

简单介绍下结果,自建数据集确实解决了上面列举的三个问题。然而,我选择的 16 核 64 GB 内存版本实例在查询性能上还不如 Playground 的性能,只是内存占用大的聚合查询 Playground 可能由于 Quota 限制无法执行。另外,我选择的阿里云上的 ClickHouse 数据库在追上游版本和一些使用体验上还是有所欠缺。最后,日常数据同步的脚本可以在 korandoru/github-adventurer 取用。

当然,我会去做这件事,还有一个不可忽视的原因是我所在的公司本身商业模式就是云上售卖数据处理服务,我希望能够基于云上的服务搭建自己的业务,体验这个流程可能遇到的各种问题。因此,本文不是一个指导手册或者技术指南,而是实现过程中每个环节的杂谈。

Metabase 连接 ClickHouse 数据库

从做这个数据集的出发点开始谈起,ClickHouse 官方文档里有详细的介绍如何连接 Metabase 的文档。然而,实操过程中碰到了两大问题。

  1. 前面提到的权限问题。Metabase 的 ClickHouse Driver 会向服务器发送设置查询参数的请求,由于 Playground 的服务器是 READONLY=1 的配置,因此 Metabase 无法连接。

这个问题是我陆陆续续花了一周时间准备自建数据集的出发点。技术上要么是 Driver 本身避免对这类语句的使用,因为在查询里带上查询相关的 SETTINGS 还是可以的;要么是 Playground 自己开启 READONLY=2 来允许这类查询。我也在上游提出了请求,这个确实只能由数据库管理员评估操作。

Is it possible to set read_only=2 for playground dataset?

  1. Metabase 的插件问题。上一点提到,Metabase 想要连接 ClickHouse 是需要专门的 Driver 的。metabase-clickhouse-driver 是由社群成员提供的,而 Metabase 云服务只支持官方的插件,这就导致哪怕我想花钱采购 Metabase 云服务,也因为无法连接数据库只能作罢。

这其实侧面揭示了云服务的一个缺点,也就是用户的选择权实际上被云厂商所剥夺,云厂商提供什么能力,用户就只能用什么能力。回到中台战略的年代,那些得不到中台关照的小型业务往往只能自己挣扎求存。

面对这种形势,一方面是凸显出云中立的技术厂商的价值,以及原厂能够最大限度地提供新版支持和旧版兼容的优势,另一方面也让我想起了之前看过的一篇文章 Local-first software 里面提出的“你拥有自己的数据,而不是云厂商”的理念。

诚然,云服务能够在很多场景下避免投入开发的成本,也能提供相对优质的服务。但是如果你真的很看重拥有自己的数据,或者对于核心数据,不妨考虑一下数据所有权的问题。当然,反过来说,到底自己能不能做得比云厂商更可靠,也是需要慎重斟酌的,在特定的核心领域里做出投入重点保障,可能也是必要的成本。

话说回来,最终我是用了本地的 Metabase 实例 + 配置 Driver 跑起来了整体应用。借助 Metabase 的 BI 能力,探索出了诸如分时最活跃的参与者这样的一些指标。不过我的可视化功底非常差,做出来的图表不好意思见人。如果实在想看,可以打开这条推文

购买云上 ClickHouse 数据库实例

没有找到御三家的 ClickHouse 服务,国内云厂商倒是不少。由于其他云厂商不允许外网访问,看起来技术支持也非常值得怀疑,于是选择了阿里云 ClickHouse 社区兼容版

总的来说,还算能用,也确实解决了开篇提出的三个问题,在 ClickHouse.com 出云服务并且试用稳定之前,可能还是会勉强续费。实际使用过程里有这么些问题和体验。

  1. 工单客服还是不错的。

大周末的值班,跟我这种要命的夜间生物一起排查问题…我绝对不鼓励加班,过程里也表达了对同行的理解。只能说做服务业的,大家都不容易。整体解决问题的速度和能力值得赞同。

  1. 外网访问需要技巧绕过。

由于众所周知的原因,本地机器 IP 不固定,服务部署的环境 IP 也不固定。我也不需要对这份数据集做特别极致的安全保护,于是尝试允许任意 IP 鉴权访问。不过,阿里云的产品显然有不一样的想法。它禁止了 0.0.0.0 的配置,又把 127.0.0.1 映射成仅允许本地访问。

不过广大人民群众的智慧是可靠的,这条推文里 @ImperiusDs 大佬想出了 127.0.0.1/1 的绕过方案,真是个天才。我希望阿里云不要不识好歹把这个方案也禁了,那我只能提前放弃续费了。

  1. 内核版本不足。

众所周知,ClickHouse 冲版本非常快,以至于 Yandex 当初自己都跟不上上游版本。或许业务对版本的要求是稳定就行,但是我只是一个个人业务,版本不足会带来一些实际的问题。

第一个,client 每次链接的时候都会有 warning 提示。这个还算好的了,只是比较烦人。

第二个,无法使用时间窗口等新功能。有些分析还是能用上会比较好,不是不能接受,稍微麻烦点。

第三个,不能支持跨域访问,这个就要命了。上游在去年底的时候才以 Add CORS support 解决了这个问题。没有跨域访问支持,我在制作开源小镇网站的时候就没办法用自己的数据集了。可以说为了解决 Metabase 连接,我要用自己的数据集,网站上固化 Dashboard 展示,又得换回 Playground 的数据源,一来一去让我感觉花的钱真的是血亏。

为此,一方面我给阿里云提了工单,另一方面给 ClickHouse 上游也提了我三个具体 blocker 的问题清单,希望两者之间有一个能够解决问题,我就切到能解决问题的那一方去。自己维护一个 ClickHouse 服务器还是太要命了,不予考虑。

最后介绍一下价格。不得不说云上的存储是真的便宜,扩容了 300GB 的存储每个月只多花几十块钱。目前的配置是单机 16 核 64GB 内存 + 500GB 云盘,每个月两千多块钱。这还是一笔不小的开销,目前的计划是利用 Metabase 尽量探索出有价值的指标,在网站上直接查 Playground 的数据出图表,一段时间后废弃数据集算逑。

GitHub 事件全量数据概览

ClickHouse 官方博客写成的时候,数据是从 2011 年到 2020 年,压缩前数据集大概 1.2TB 大小,压缩后导出文件不到 100GB 大小。压缩比还是很夸张的。

我制作的数据集从 2015 年到 2022 年实时更新,总事件数 47 亿条,运行时占用磁盘空间 400GB 左右,原数据量没有记录,应该也是几个 TB 大小。

很明显,GitHub 的增长是飞快的,每年事件数也呈明显上升趋势。实际下载数据时候也能感受到逐年向前的数据下载压力。

Count Events by Year

由于 GitHub Events 只有公开仓库的数据,所以 public 之前私下开发的活动是不被记录的。此外,大家都知道 GitHub 的可靠性非常感人,实际上有一些时间段的数据是缺失的。

  • 2016-01-28 01:00:00 ~ 02:00:00 数据缺失
  • 2016-10-21 18:00:00 ~ 19:00:00 数据缺失
  • 2018-10-21 23:00:00 ~ 2018-10-22 02:00:00 数据缺失
  • 2019-05-08 12:00:00 ~ 14:00:00 数据缺失
  • 2019-09-12 08:00:00 ~ 2019-09-13 06:00:00 数据缺失
  • 2020-03-05 22:00:00 ~ 23:00:00 数据缺失
  • 2020-06-10 12:00:00 ~ 22:00:00 数据缺失
  • 2020-08-21 09:00:00 ~ 2020-08-23 16:00:00 数据缺失
  • 2020-10-30 损坏一条数据 id=14032425374
  • 2021-08-25 17:00:00 ~ 2020-08-27 23:00:00 数据缺失
  • 2021-09-11 损坏一条数据 id=17943409164
  • 2021-10-22 05:00:00 ~ 23:00:00 数据缺失
  • 2021-10-23 02:00:00 ~ 23:00:00 数据缺失
  • 2021-10-24 03:00:00 ~ 23:00:00 数据缺失
  • 2021-10-26 00:00:00 ~ 2021-10-29 18:00:00 数据缺失

除了这些整段的数据缺失以外,部分数据缺失也是可能的。CNCF 的 devstats 项目有一系列的补偿逻辑来修复数据,这里不做展开。

总的来说,GitHub 事件数据集没有单个事务级别的完整性,也就是因果性无法完全保证,只适合做一些粗略的倾向分析和大数统计,无法做特别精确的因果分析。

GitHub 事件的数据模型

ClickHouse 官方博客 How to choose the structure for the data? 章节已经讲清楚了数据模型的选型。前文提到的 devstats 和 PingCAP 的 OSSInsight 都是关系型数据库打底,基本是多个具体表分发处理不同事件类型,查询时走 JOIN 查询来出结果的。ClickHouse 作为列存数据库,则更加倾向于一张大表多个列储存所有数据,也是因为这种模式,才能做到数据极高的压缩比和查询时的过滤效率。

我所采取的建表模式和官方博客的模式略有不同,这也是我选择自建数据集的关键原因之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
CREATE TABLE default.github_events
(
`file_time` DateTime,
`event_id` UInt64,
`actor_id` UInt64,
`repo_id` UInt64,
`event_type` Enum8('CommitCommentEvent' = 1, 'CreateEvent' = 2, 'DeleteEvent' = 3, 'ForkEvent' = 4, 'GollumEvent' = 5, 'IssueCommentEvent' = 6, 'IssuesEvent' = 7, 'MemberEvent' = 8, 'PublicEvent' = 9, 'PullRequestEvent' = 10, 'PullRequestReviewCommentEvent' = 11, 'PushEvent' = 12, 'ReleaseEvent' = 13, 'SponsorshipEvent' = 14, 'WatchEvent' = 15, 'GistEvent' = 16, 'FollowEvent' = 17, 'DownloadEvent' = 18, 'PullRequestReviewEvent' = 19, 'ForkApplyEvent' = 20, 'Event' = 21, 'TeamAddEvent' = 22),
`actor_login` LowCardinality(String),
`repo_name` LowCardinality(String),
`created_at` DateTime,
`updated_at` DateTime,
`action` Enum8('none' = 0, 'created' = 1, 'added' = 2, 'edited' = 3, 'deleted' = 4, 'opened' = 5, 'closed' = 6, 'reopened' = 7, 'assigned' = 8, 'unassigned' = 9, 'labeled' = 10, 'unlabeled' = 11, 'review_requested' = 12, 'review_request_removed' = 13, 'synchronize' = 14, 'started' = 15, 'published' = 16, 'update' = 17, 'create' = 18, 'fork' = 19, 'merged' = 20),
`comment_id` UInt64,
`body` String,
`path` String,
`position` Int32,
`line` Int32,
`ref` LowCardinality(String),
`ref_type` Enum8('none' = 0, 'branch' = 1, 'tag' = 2, 'repository' = 3, 'unknown' = 4),
`creator_user_login` LowCardinality(String),
`number` UInt32,
`title` String,
`labels` Array(LowCardinality(String)),
`state` Enum8('none' = 0, 'open' = 1, 'closed' = 2),
`locked` UInt8,
`assignee` LowCardinality(String),
`assignees` Array(LowCardinality(String)),
`comments` UInt32,
`author_association` Enum8('NONE' = 0, 'CONTRIBUTOR' = 1, 'OWNER' = 2, 'COLLABORATOR' = 3, 'MEMBER' = 4, 'MANNEQUIN' = 5),
`closed_at` DateTime,
`merged_at` DateTime,
`merge_commit_sha` String,
`requested_reviewers` Array(LowCardinality(String)),
`requested_teams` Array(LowCardinality(String)),
`head_ref` LowCardinality(String),
`head_sha` String,
`base_ref` LowCardinality(String),
`base_sha` String,
`merged` UInt8,
`mergeable` UInt8,
`rebaseable` UInt8,
`mergeable_state` Enum8('unknown' = 0, 'dirty' = 1, 'clean' = 2, 'unstable' = 3, 'draft' = 4),
`merged_by` LowCardinality(String),
`review_comments` UInt32,
`maintainer_can_modify` UInt8,
`commits` UInt32,
`additions` UInt32,
`deletions` UInt32,
`changed_files` UInt32,
`diff_hunk` String,
`original_position` UInt32,
`commit_id` String,
`original_commit_id` String,
`push_size` UInt32,
`push_distinct_size` UInt32,
`member_login` LowCardinality(String),
`release_tag_name` String,
`release_name` String,
`review_state` Enum8('none' = 0, 'approved' = 1, 'changes_requested' = 2, 'commented' = 3, 'dismissed' = 4, 'pending' = 5)
)
ENGINE = ReplacingMergeTree
ORDER BY (event_type, repo_name, created_at, event_id)

可以看到,表格列添加了 event_id / actor_id / repo_id 三列,这是因为需要 event_id 去重,后两者可以较好的应对用户和仓库改名的情况。

同时,表引擎选择的是 ReplacingMergeTree 引擎而非 MergeTree 引擎,并多制定了 event_id 作为排序键,同时也是 ReplacingMergeTree 的去重基准。

数据集的具体制作

可以在 korandoru/github-adventurer GitHub 仓库上获取制作数据集的所有相关脚本。

目前,我通过 GitHub Actions 每个小时增量从 GHArchive 拉取新数据插入到数据库里。对于全量的导入,只需要把 download.sh 的逻辑替换成全量下载即可。由于没有云服务器可以直接用,购买云服务器和带宽我血亏了几百块钱以后发现还不如本地机器来的靠谱,所以最后是用自己的机器分批次导入全量数据的。GHArchive 数据下载不用梯子就可以访问,速度也很快。

增量数据下载上游的脚本是全量存储了所有 .json.gz 的原始文件,所以可以直接以当前时间为基准框出前后十二个小时的数据集文件名后过滤。我没有那么大的存储空间,只能从当前最新的数据往后连续数一天了。

具体的脚本逻辑不逐行讨论,只说几个值得一提的点。

  1. 导入 GitHub 事件数据的过程确实就是一个 ETL 的过程,为此我直接把分阶段的处理文件叫做 extract / transform / (up)load 了。

这当然不止是因为强迫症。全量导入数据的时候,下载完成以后从 flatten.sh 入口驱动批量导入,由于网路和数据质量等原因有些数据可能导入失败,这个时候就只需要从错误输出里找到失败的数据,传参给 transform.sh 重新导入即可。

这里也可以以小见大看出软件开发过程里模块化的好处来,能够分离关注点避免同时处理多个事情对人的生理挑战进而引入缺陷,每个阶段的逻辑可以单独复用而无需重复编写。

  1. 处理 JSON 数据时用的是 jq 这个实用工具,强烈推荐。

不过 jq 处理的速度也不算快。数据导入的时候我按照本地核数做数据并发,10 个并发的情况下最后数据库导入效率也就每秒 15MB 左右,升配了也上不去,属于本地处理的瓶颈了。

  1. ClickHouse 对数据输入和输出的格式支持做得非常到位。

JSONCompactEachRow 输入和 HTTP 的 FORMAT JSON 输出,跟后端程序还有网页对接起来不要太爽。导入数据再也不用跟复杂的 INSERT 语句打架了。

  1. dotenv 对统一开发环境和生产环境部署有很大的帮助。

可以看到,自建数据集需要配置自己购买的数据库实例。很多应用都会包含敏感的配置信息,过去都是八仙过海处理不同环境下的配置。无论是不同的处理逻辑,还是命令行传参,都不是那么舒服。

如今,dotenv 越来越多得到用户的青睐,不是没有道理的。因为理念足够简单,要么从 .env 文件读取配置,要么从环境变量读取,所以绝大部分集成开发环境和语言都支持和 dotenv 标准协同工作。本地走一个 .env 文件并从 VCS 里 ignore 掉,生产环境配置环境变量,就能解决逻辑不一致和命令行传参仍然有泄露风险的问题。

当然,dotenv 对复杂数据类型的支持一般。对于复杂的配置需求,还是需要专门的配置系统来处理。但是连接到配置系统的地址和用户信息又可以用 dotenv 管起来。总之和 jq 一样,属于新一代生产力利器。

还有一些 bash shell 和 GitHub 的小技巧就不一一罗列了,从 korandoru/github-adventurer 的源码里都能看出端倪。

回顾一下这轮工作的成果,自建数据集确实解决了一开始列举的三个问题。但是阿里云版 ClickHouse 并不能解决我遇到的所有问题,我现在需要同时连接两个数据库完成 Metabase BI 探索和网页端查询固化的工作。如果 ClickHouse.com 上线了云服务,我会再尝试一下。另外,现在对云服务的使用也算一回生二回熟了,如果有精力我可以看看用 cockroachdb 平替 PostgreSQL 以每种 event 一张表的形式来组织数据。目前至少也有一些用户详情的元数据想存到一个关系型数据库上的需求。

❌