
本文字数:10340;估计阅读时间:26 分钟
审校:庄晓东(魏庄)
Meetup活动
ClickHouse 上海首届 Meetup 讲师招募中,欢迎讲师在文末扫码报名!
也许你已经听说过 Flight Radar,这个实时航班跟踪地图,跟踪飞机在天空中飞行非常有趣,但在这篇博客文章中,我们将向你介绍更酷炫的东西!!
ADS-B(自动相关监视广播)是一种用于广播各种航班数据的无线电协议。我们的联合创始人兼首席技术官 Alexey Milovidov 基于这些数据打造了一个交互式可视化和分析工具。他在这个过程中创造了一种全新的艺术形式!

曼哈顿上空的直升机
这个网站聚合并可视化了大量的航空交通数据。这些数据存储在 ClickHouse 数据库中,并可以实时查询。你可以使用自定义的 SQL 查询调整可视化效果,并从 50 亿条记录中钻取到单个数据记录。结果是一些非常壮观的图像!
虽然这篇博客文章主要关注演示是如何构建的,但你也可以直接跳到结尾查看一些惊人的视觉效果。
或者,访问 https://adsb.exposed,找到你所在城市的可视化效果,并在社交媒体上分享!我们将为最佳图像赠送一个 ClickHouse T 恤,并将在我们下次的社区通话中于 4 月 30 日公布获胜者。
这个演示的完整源代码可以在这里找到【https://github.com/ClickHouse/adsb.exposed?tab=readme-ov-file】。

ADS-B 数据是由安装在飞机上的“应答机”发送的。这种协议没有加密,对于数据的收集、使用和重新分发没有任何限制。大多数民航飞机都会发送这些数据,而在某些国家,甚至滑翔机、无人机和机场地面车辆也会如此。但对于军用飞机和私人轻型飞机,是否发送这些数据就不确定了。
你可以通过自己的无线电接收器(比如 SDR)在空中收集这些数据,不过你的接收器只能在一定范围内接收到数据。此外,也有一些平台可以用来分享和交换这些数据。一些平台邀请用户共享数据,但对于商业使用则可能受限。虽然飞机发送的数据基本上属于公共领域,但公司可以从这些数据中制作和许可衍生作品。
我们使用了两个数据来源:ADSB.lol(提供完整的历史数据,每天 3000 到 5000 万条记录,自 2023 年以来根据开放数据库许可证提供)和 ADSB-Exchange(仅提供每个月第一天的数据样本,每天约 12 亿条记录,覆盖范围更广)。
另一个有前景的数据来源是 airplanes.live,该作者提供了用于非商业用途的历史和实时数据。这个平台的覆盖范围和数据质量都很好,我们计划在接下来的几天内将其纳入使用。

该网站采用单个 HTML 页面实现,没有使用 JavaScript 框架,并且源代码未经过压缩,因此易于阅读。
地图渲染
我们使用 Leaflet 库来在两个图层中显示地图。背景图层使用 OpenStreetMap 的瓦片创建了典型的地理地图。而主要图层则将可视化效果叠加在背景地图上。
为了实现可视化效果,我们使用了 GridLayer 和自定义回调函数 createTile,该函数会在需要时动态生成 Canvas 元素:
L.GridLayer.ClickHouse = L.GridLayer.extend({createTile: function(coords, done) {let tile = L.DomUtil.create('canvas', 'leaflet-tile');tile.width = 1024;tile.height = 1024;render(this.options.table, this.options.priority, coords, tile).then(err => done(err, tile));return tile;}});const layer_options = {tileSize: 1024,minZoom: 2,maxZoom: 19,minNativeZoom: 2,maxNativeZoom: 16,attribution: '(c) Alexey Milovidov, ClickHouse, Inc. (data: adsb.lol, adsbexchange.com)'};
为了减少对数据库的请求次数,每个瓦片都具有 1024x1024 的高分辨率。
渲染函数使用 JavaScript 的 fetch 函数通过 ClickHouse 的 HTTP API 发送请求:
const query_id = `${uuid}-${query_sequence_num}-${table}-${coords.z - 2}-${coords.x}-${coords.y}`;const hosts = getHosts(key);const url = host => `${host}/?user=website&default_format=RowBinary` +`&query_id=${query_id}&replace_running_query=1` +`¶m_table=${table}¶m_sampling=${[0, 100, 10, 1][priority]}` +`¶m_z=${coords.z - 2}¶m_x=${coords.x}¶m_y=${coords.y}`;progress_update_period = 1;const response = await Promise.race(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));
用户可以使用页面上的表单即时编辑 SQL 查询,以调整可视化效果。这个带参数的查询接受瓦片坐标 (x, y) 和缩放级别作为参数。
查询返回图像的每个像素的 RGBA 值,采用 RowBinary 格式(1024x1024 像素,1048576 行,每行 4 字节,每个瓦片总共 4 MiB)。只要浏览器支持,查询的 HTTP 响应会采用 ZSTD 压缩。有趣的是,ZSTD 压缩在原始像素位图上的效果比 PNG 更好!(尽管这并不奇怪)
尽管图像数据通常会被多次压缩,但仍然需要在网络上传输数百兆字节,这就是为什么在网络连接较差时,服务可能会感觉很慢的原因。
let ctx = tile.getContext('2d');let image = ctx.createImageData(1024, 1024, {colorSpace: 'display-p3'});let arr = new Uint8ClampedArray(buf);for (let i = 0; i < 1024 * 1024 * 4; ++i) { image.data[i] = arr[i]; }ctx.putImageData(image, 0, 0, 0, 0, 1024, 1024);
在支持的浏览器中,使用“Display P3”色彩空间可拥有更广的色域。
我们使用三个不同级别的详细表:planes_mercator 包含 100% 的数据,planes_mercator_sample10 包含 10%,而 planes_mercator_sample100 包含 1%。加载开始时采用 1% 的样本,以便在渲染整个世界时能够立即响应。加载完第一层级的详细数据后,它继续加载下一个 10% 的层级,然后再加载 100% 的数据。这样做能够产生渐进加载的良好效果。
图像数据还会在客户端使用一个简单的 JavaScript 对象进行缓存:
if (!cached_tiles[key]) cached_tiles[key] = [];/// If there is a higer-detail tile, skip rendering of this level of detal.if (cached_tiles[key][priority + 1]) return;buf = cached_tiles[key][priority];
但唯一的不足是,经过一段时间浏览后,页面会消耗过多的内存 - 这是未来版本需要解决的问题。
数据库和查询
按照 ClickHouse 的标准来说,这个数据库相对较小。截至 2024 年 3 月 29 日,planes_mercator 表中有 444.7 亿行数据,并且持续不断地更新。它占用了 1.6 TB 的磁盘空间。
表结构如下(您可以在 setup.sql 源文件中查看):
CREATE TABLE planes_mercator(`mercator_x` UInt32 MATERIALIZED 4294967295 * ((lon + 180) 360),`mercator_y` UInt32 MATERIALIZED 4294967295 * ((1 2) - ((log(tan(((lat + 90) 360) * pi())) 2) pi())),`time` DateTime64(3),`date` Date,`icao` String,`r` String,`t` LowCardinality(String),`dbFlags` Int32,`noRegData` Bool,`ownOp` LowCardinality(String),`year` UInt16,`desc` LowCardinality(String),`lat` Float64,`lon` Float64,`altitude` Int32,`ground_speed` Float32,`track_degrees` Float32,`flags` UInt32,`vertical_rate` Int32,`aircraft_alert` Int64,`aircraft_alt_geom` Int64,`aircraft_gva` Int64,`aircraft_nac_p` Int64,`aircraft_nac_v` Int64,`aircraft_nic` Int64,`aircraft_nic_baro` Int64,`aircraft_rc` Int64,`aircraft_sda` Int64,`aircraft_sil` Int64,`aircraft_sil_type` LowCardinality(String),`aircraft_spi` Int64,`aircraft_track` Float64,`aircraft_type` LowCardinality(String),`aircraft_version` Int64,`aircraft_category` Enum8('A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'B0', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', ''),`aircraft_emergency` Enum8('', 'none', 'general', 'downed', 'lifeguard', 'minfuel', 'nordo', 'unlawful', 'reserved'),`aircraft_flight` LowCardinality(String),`aircraft_squawk` String,`aircraft_baro_rate` Int64,`aircraft_nav_altitude_fms` Int64,`aircraft_nav_altitude_mcp` Int64,`aircraft_nav_modes` Array(Enum8('althold', 'approach', 'autopilot', 'lnav', 'tcas', 'vnav')),`aircraft_nav_qnh` Float64,`aircraft_geom_rate` Int64,`aircraft_ias` Int64,`aircraft_mach` Float64,`aircraft_mag_heading` Float64,`aircraft_oat` Int64,`aircraft_roll` Float64,`aircraft_tas` Int64,`aircraft_tat` Int64,`aircraft_true_heading` Float64,`aircraft_wd` Int64,`aircraft_ws` Int64,`aircraft_track_rate` Float64,`aircraft_nav_heading` Float64,`source` LowCardinality(String),`geometric_altitude` Int32,`geometric_vertical_rate` Int32,`indicated_airspeed` Int32,`roll_angle` Float32,INDEX idx_x mercator_x TYPE minmax GRANULARITY 1,INDEX idx_y mercator_y TYPE minmax GRANULARITY 1)ENGINE = MergeTreeORDER BY (mortonEncode(mercator_x, mercator_y), time)
此架构包含了经纬度值,通过 MATERIALIZED 列自动将它们转换为 Web-Mercator 投影的坐标。这种投影被 Leaflet 软件和互联网上的大多数地图所采用。Mercator 坐标以 UInt32 的形式存储,使得在 SQL 查询中进行瓦片坐标和缩放级别的算术操作变得更加容易。
Web Mercator 使用 Morton 曲线对表坐标进行排序,并使用 minmax 索引,使得查询特定瓦片时只需读取所需的数据。
Materialized Views 用于生成不同细节级别的表:
CREATE TABLE planes_mercator_sample10 AS planes_mercator;CREATE TABLE planes_mercator_sample100 AS planes_mercator;CREATE MATERIALIZED VIEW view_sample10 TO planes_mercator_sample10AS SELECT *FROM planes_mercatorWHERE (rand() % 10) = 0;CREATE MATERIALIZED VIEW view_sample100 TO planes_mercator_sample100AS SELECT *FROM planes_mercatorWHERE (rand() % 100) = 0;
使用 ClickHouse Cloud
我们在 ClickHouse Cloud 的测试环境中部署了一个服务。这个测试环境主要用于测试新的 ClickHouse 版本和我们正在实现的新型基础设施。
举例来说,我们可以尝试不同类型和规模的机器,或者测试正在开发中的新功能,比如分布式缓存。
测试环境还利用了故障注入和混沌工程:我们会以一定概率中断网络连接,以确保服务正常运行。此外,我们会随机终止 clickhouse-server 和 clickhouse-keeper 上的不同机器,并随机调整服务的规模。这种做法有助于促进我们服务的开发和测试。
最后,请求也会负载均衡到备用服务。无论哪个服务返回得更快,都将被使用。这样我们就能够避免停机,同时仍然能够利用测试环境。
示例查询:波音与空客对比
考虑到这个备受关注的主题可视化:“波音与空客对比”。

让我们来看一下这个可视化的 SQL 查询:
WITHbitShiftLeft(1::UInt64, {z:UInt8}) AS zoom_factor,bitShiftLeft(1::UInt64, 32 - {z:UInt8}) AS tile_size,tile_size * {x:UInt16} AS tile_x_begin,tile_size * ({x:UInt16} + 1) AS tile_x_end,tile_size * {y:UInt16} AS tile_y_begin,tile_size * ({y:UInt16} + 1) AS tile_y_end,mercator_x >= tile_x_begin AND mercator_x < tile_x_endAND mercator_y >= tile_y_begin AND mercator_y < tile_y_end AS in_tile,bitShiftRight(mercator_x - tile_x_begin, 32 - 10 - {z:UInt8}) AS x,bitShiftRight(mercator_y - tile_y_begin, 32 - 10 - {z:UInt8}) AS y,y * 1024 + x AS pos,count() AS total,sum(desc LIKE 'BOEING%') AS boeing,sum(desc LIKE 'AIRBUS%') AS airbus,sum(NOT (desc LIKE 'BOEING%' OR desc LIKE 'AIRBUS%')) AS other,greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, total) AS max_total,greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, boeing) AS max_boeing,greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, airbus) AS max_airbus,greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, other) AS max_other,pow(total max_total, 1/5) AS transparency,255 * (1 + transparency) 2 AS alpha,pow(boeing, 1/5) * 256 DIV (1 + pow(max_boeing, 1/5)) AS red,pow(airbus, 1/5) * 256 DIV (1 + pow(max_airbus, 1/5)) AS green,pow(other, 1/5) * 256 DIV (1 + pow(max_other, 1/5)) AS blueSELECT round(red)::UInt8, round(green)::UInt8, round(blue)::UInt8, round(alpha)::UInt8FROM {table:Identifier}WHERE in_tile
查询的第一部分计算了条件 in_tile,在 WHERE 部分中用于过滤所请求瓦片中的数据。然后计算颜色:alpha、red、green 和 blue。它们通过 pow 函数进行调整以获得更好的均匀性,被夹在 0 到 255 的范围内,并转换为 UInt8。采样参数用于调整,以便于低细节级别的查询返回的图像大部分具有相同的相对颜色。我们按像素坐标 pos 进行分组,并在 ORDER BY 中使用 WITH FILL 修饰符来填充像素位置中没有数据的零。结果,我们将获得一个精确大小为 1024x1024 的 RGBA 位图。
报表
如果您使用右键选择区域或使用选择工具,它将从数据库中为所选区域生成查询结果。这是十分方便的。例如,以下是关于顶级飞机类型的查询:
const sql_types = `WITH mercator_x >= {left:UInt32} AND mercator_x < {right:UInt32}AND mercator_y >= {top:UInt32} AND mercator_y < {bottom:UInt32} AS in_tileSELECT t, anyIf(desc, desc != '') AS desc, count() AS cFROM {table:Identifier}WHERE t != '' AND ${condition}GROUP BY tORDER BY c DESCLIMIT 100`;
报告是基于航班号、飞机类型、注册(机尾号)和所有者进行的计算。您点击任何项目后,它将为主 SQL 查询应用一个过滤器。例如,点击 A388,它将显示空客 A380-800 的可视化。
此外,如果您将光标移到飞机类型上,它将自动访问维基百科 API 并尝试查找该飞机的图片。但通常在维基百科上找到的是其他内容。
已保存的查询
您可以编辑查询,然后分享链接。该查询会被转换为一个 128 位的哈希值,并保存在同一个 ClickHouse 数据库中:
async function saveQuery(text) {const sql = `INSERT INTO saved_queries (text) FORMAT RawBLOB`;const hosts = getHosts(null);const url = host => `${host}/?user=website_saved_queries&query=${encodeURIComponent(sql)}`;const response = await Promise.all(hosts.map(host => fetch(url(host), { method: 'POST', body: text })));}async function loadQuery(hash) {const sql = `SELECT text FROM saved_queries WHERE hash = unhex({hash:String}) LIMIT 1`;const hosts = getHosts(null);const url = host => `${host}/?user=website_saved_queries&default_format=JSON¶m_hash=${hash}`;const response = await Promise.race(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));const data = await response.json();return data.data[0].text;}
我们使用一个名为 website_saved_queries 的不同用户,针对这些查询设置了不同的访问控制和配额。
进度条
在页面上显示一个进度条,展示已处理数据的行数和字节数,这样更直观。
const sql = `SELECTsum(read_rows) AS r,sum(total_rows_approx) AS t,sum(read_bytes) AS b,r max(elapsed) AS rps,b max(elapsed) AS bps,formatReadableQuantity(r) AS formatted_rows,formatReadableSize(b) AS formatted_bytes,formatReadableQuantity(rps) AS formatted_rps,formatReadableSize(bps) AS formatted_bpsFROM clusterAllReplicas(default, system.processes)WHERE user = 'website' AND startsWith(query_id, {uuid:String})`;const hosts = getHosts(uuid);const url = host => `${host}/?user=website_progress&default_format=JSON¶m_uuid=${uuid}`;let responses = await Promise.all(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));let datas = await Promise.all(responses.map(response => response.json()));
我们从集群中所有服务器的 system.processes 表中进行选择。由于存在许多并行请求的瓦片和查询,其中一些已经完成,一些仍在进行中,因此它不会显示精确的进度。查询只会看到正在进行中的查询,因此总处理记录数会低于实际值。
此外,当我们加载第一级别的细节、第二级别的细节等时,进度条的颜色也会有所不同。
缓存局部性
ClickHouse Cloud 中的服务可以使用多个副本,默认情况下,请求会路由到任意健康的副本。处理大量数据的查询将在许多副本上并行执行,而较简单的查询将仅使用单个副本。数据存储在 AWS S3 中,每个副本 pod 还附加了一个本地 SSD,用于缓存,因此内存中的页面缓存也会影响最终查询时间。

以下是 Alexey 选取的一些初始图像。这只是一个开始,该网站提供了大量免费的艺术品!
丹佛机场

当我们放大到机场时,我们可以看到飞机停放的位置,甚至可以按制造商或航空公司对它们进行着色:

在德克萨斯州进行的军事训练

在伦敦,直升机在河流上空飞行

在拉斯维加斯没有河流

湾区的小型机场

美国的 F-16 空军基地

墨西哥城附近的一个奇怪的洞

一个火山

51 区

阿联酋航空工程
在迪拜机场,绿色的毛球是阿联酋航空工程的一个飞机库,用于维修空中客车。

欧洲各地的航空公司

达拉斯的小型机场


我们正为上海活动招募讲师,如果你有独特的技术见解、实践经验或 ClickHouse 使用故事,非常欢迎你加入我们,成为这次活动的讲师,与大家分享你的经验。

征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com






