当轨迹底库逼近 10 万亿点时,区域查询已经不能靠“经纬度范围过滤,再等数据库扫描”解决。图灵平台的实践将问题拆成三个部分:用 ClickHouse 承担大规模 OLAP 存储与过滤,用 S2 把二维地理区域转换成可索引的单元集合,再通过流式检索让结果边计算边进入可视化链路。
这套方案的重要之处不只是“查得快”,而是建立了一条从空间裁剪、时间过滤到渐进式返回的完整路径。
为什么经纬度范围查询很快会失效
最直观的区域检索通常写成下面这样:
SELECT longitude, latitude, event_time
FROM trajectory_points
WHERE event_time >= '2025-01-01 00:00:00'
AND event_time < '2025-01-02 00:00:00'
AND longitude BETWEEN 116.20 AND 116.55
AND latitude BETWEEN 39.75 AND 40.05;
SQL 没有错,但在万亿级数据上存在两个结构性问题。
一是经纬度是两个独立维度。即使查询区域很小,如果数据的物理排序没有同时贴合时间和空间,ClickHouse 仍可能读取大量无关数据块。
二是实际区域往往不是矩形。行政区、道路缓冲区和用户绘制的多边形都需要更复杂的几何判断。直接对所有候选点执行点面关系计算,会把昂贵的 CPU 运算放到查询链路最前端。
S2 的作用,是先把球面划分为层级化网格。查询多边形可以近似表示为一组 S2 Cell,轨迹点也可以在写入时映射到对应 Cell。检索因此变成两阶段过滤:
- 使用时间与 S2 Cell 快速缩小候选数据范围。
- 只对边界附近的候选点执行精确几何判断。
这里需要注意:S2 覆盖是候选集,不天然等于精确结果。网格越粗,候选点越多;网格越细,Cell 数量、索引体积和查询条件复杂度越高。
表结构决定了实际扫描量
来源摘要明确提到了 ClickHouse 与 S2 的组合,但没有给出完整建表参数。工程中可以这样实践,并根据真实查询模式调整分区、排序键和 S2 层级:
CREATE TABLE trajectory_points
(
object_id UInt64,
event_time DateTime64(3, 'UTC'),
event_date Date MATERIALIZED toDate(event_time),
longitude Float64,
latitude Float64,
s2_cell_l12 UInt64,
speed Float32,
attributes String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (s2_cell_l12, event_time, object_id)
SETTINGS index_granularity = 8192;
这份示例假设主要访问模式是“给定区域和时间范围,读取区域内轨迹点”。因此把 S2 Cell 放在排序键前部,让相邻空间单元的数据更容易集中存放。
如果业务更常见的查询是“查询某个对象最近 24 小时的完整轨迹”,则 (object_id, event_time) 可能更加合适。两种访问模式差异很大,不应期待一张表同时达到最优。数据规模足够大时,可以通过物化视图维护面向不同查询的表:
CREATE TABLE trajectory_by_object
(
object_id UInt64,
event_time DateTime64(3, 'UTC'),
longitude Float64,
latitude Float64,
s2_cell_l12 UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (object_id, event_time);
CREATE MATERIALIZED VIEW mv_trajectory_by_object
TO trajectory_by_object
AS
SELECT
object_id,
event_time,
longitude,
latitude,
s2_cell_l12
FROM trajectory_points;
分区也不能切得过细。按天甚至按小时分区看似方便,但长期运行后会形成大量分区和数据片段,增加合并、元数据管理与查询规划成本。分区用于数据生命周期管理,排序键才是控制读取范围的核心工具。
从多边形到可查询的 S2 Cell
下面是一个可运行的最小示例。它使用 Python 的 s2sphere 库,将矩形区域转换成一组 S2 Cell ID。真实系统中的任意多边形覆盖可以接入支持 S2 Polygon/RegionCoverer 的实现,并在数据库返回候选点后做精确过滤。
安装依赖:
python -m pip install s2sphere clickhouse-connect
将连接地址和矩形坐标替换为实际值后运行:
from datetime import datetime, timezone
import clickhouse_connect
from s2sphere import LatLng, LatLngRect, RegionCoverer
def cover_rectangle(min_lng, min_lat, max_lng, max_lat, level=12):
rect = LatLngRect.from_point_pair(
LatLng.from_degrees(min_lat, min_lng),
LatLng.from_degrees(max_lat, max_lng),
)
coverer = RegionCoverer()
coverer.min_level = level
coverer.max_level = level
coverer.max_cells = 4096
return [cell.id() for cell in coverer.get_covering(rect)]
cells = cover_rectangle(116.20, 39.75, 116.55, 40.05)
if not cells:
raise RuntimeError("S2 covering is empty")
client = clickhouse_connect.get_client(
host="localhost",
port=8123,
username="default",
password="",
)
rows = client.query(
"""
SELECT object_id, event_time, longitude, latitude
FROM trajectory_points
WHERE s2_cell_l12 IN %(cells)s
AND event_time >= %(start)s
AND event_time < %(end)s
AND longitude BETWEEN %(min_lng)s AND %(max_lng)s
AND latitude BETWEEN %(min_lat)s AND %(max_lat)s
LIMIT 100000
""",
parameters={
"cells": cells,
"start": datetime(2025, 1, 1, tzinfo=timezone.utc),
"end": datetime(2025, 1, 2, tzinfo=timezone.utc),
"min_lng": 116.20,
"max_lng": 116.55,
"min_lat": 39.75,
"max_lat": 40.05,
},
)
for row in rows.result_rows[:10]:
print(row)
示例中的经纬度条件不是多余的:S2 Cell 覆盖矩形边界时可能带回区域外候选点,范围条件负责二次裁剪。对于凹多边形、带孔多边形或道路缓冲区,应使用专业几何库执行最终的点面判断。
写入侧必须统一坐标系、经纬度顺序和 S2 层级。常见错误包括把 (longitude, latitude) 传给要求 (latitude, longitude) 的 API,以及一部分数据使用 WGS84、另一部分使用偏移坐标。此类错误通常不会触发异常,只会让数据落入错误网格,因此需要在入库前增加范围检查和已知地标的回归测试。
流式检索不等于一次返回全部点
即使数据库能快速找到数亿个点,浏览器也无法有效绘制它们。秒级可视化应把“首次可见时间”和“完整结果时间”分开处理。
可以这样设计服务端链路:
- ClickHouse 按块读取结果,而不是在应用内一次性构造完整数组。
- 第一批返回聚合网格、采样点或轨迹摘要,让地图尽快出现轮廓。
- 后续数据使用 NDJSON、Server-Sent Events 或 WebSocket 分批传输。
- 前端依据缩放级别设置点数预算,放大后再请求更细粒度数据。
- 服务端为查询设置最大时间跨度、Cell 数量、扫描字节数和结果行数。
下面的 HTTP 示例展示了 NDJSON 接口的预期形态。每一行都是独立 JSON 对象,客户端无需等待整个响应结束:
curl --no-buffer \
-X POST http://localhost:8080/api/trajectory/search \
-H 'Content-Type: application/json' \
-d '{
"bbox": [116.20, 39.75, 116.55, 40.05],
"start": "2025-01-01T00:00:00Z",
"end": "2025-01-02T00:00:00Z",
"max_points": 100000
}'
假设接口按批次输出,响应可以是:
{"type":"meta","query_id":"q-123","estimated_points":8500000}
{"type":"batch","level":8,"points":[[116.31,39.91,1735689600000]]}
{"type":"batch","level":12,"points":[[116.32,39.92,1735689601000]]}
{"type":"done","returned_points":100000}
max_points 是系统保护机制,不只是前端参数。任意区域检索如果没有结果预算,很容易演变成全库导出任务,并挤占在线查询资源。
上线前要验证的边界
这类系统的性能不能只看一条演示查询。落地时至少要检查以下事项:
- 用真实区域分布压测,包括城市中心、跨省大区域、狭长道路和跨日期查询。
- 观察 ClickHouse 的读取行数、读取字节数、查询耗时和峰值内存,而不只看返回行数。
- 选择 S2 层级时,同时评估覆盖 Cell 数、边界误差、写入局部性和查询参数大小。
- 对极大区域使用聚合、采样或多级细节数据,避免传输原始点。
- 给在线检索设置超时、并发、扫描量和返回量限制。
- 保留精确空间过滤步骤,尤其是涉及计费、合规或地理围栏告警时。
- 将空间查询表与按对象查询表分开评估,必要时用物化视图换取稳定延迟。
ClickHouse、S2 和流式返回分别解决存储扫描、空间候选集和用户感知延迟。真正的秒级体验来自三者共同工作:让数据库少读,让服务端早返回,让前端只画当前视口真正需要的数据。