首页
看点啥
插画图片
首页 看点啥 图灵平台:万亿级轨迹数据秒级检索实战

图灵平台:万亿级轨迹数据秒级检索实战

2026-07-24 0

导读 introduction

地图情报核实要判断"某段路到底有没有车走过"。车少的长尾路段(乡道、新修路、偏远低频路)要把时间拉到半年才攒得出足够轨迹,而这批 180 天、近 10 万亿点的历史轨迹躺在离线数仓里,查一次要跑几个小时。我们用一套建在 ClickHouse 上的检索服务,把它做成在线可查——任意区域检索 TTFB 0.35s。

本文复盘我们如何用 ClickHouse + S2 地理编码 + 流式检索,在近 10 万亿轨迹点的底库上做到任意区域秒级可视化。既有存储引擎选型、地理索引设计的原理解析(第 5 节),也有一线踩出来的避坑经验(第 6 节)。适合关注海量数据检索、时空数据处理、OLAP 实战的同学。

01 业务背景:长尾路段的轨迹从哪来

这套服务是地图情报团队情报核实图灵平台的底层能力。核实平台每天要挖掘、核实地图上的各类要素——新增道路、路网变化、通行规则等,而核实时反复要回答一个问题:

轨迹就是最直接的证据。逻辑其实很简单,但难点在长尾路段——乡道、新修路、偏远低频路:

解法:把时间拉长到 180 天。 一天一辆车,半年也能攒出上百条,长尾就有了统计意义。为控制规模,入库时过滤掉了 1~4 级路(高速、国道、主干道等高等级路,本就车多不缺证据),只留低等级路——即便如此,180 天累积下来仍有近 10 万亿个轨迹点。

问题是,这批数据躺在离线数仓里,捞一次"某区域某时段的轨迹"要提任务、排队、扫全表,动辄几个小时,根本没法支撑地图上点一下就要看结果的交互式核实。核心矛盾就一个——

在近 10 万亿行的底库上,把"几个小时"压缩到"0.35 秒"。

成本上也划算:整套方案基于 ClickHouse 冷热分级(SSD 热层 + BOS 冷层),覆盖 180 天、近 10 万亿行,年成本仅约 25 万——绝大部分存量压在低价的对象存储上,比纯内存方案省得多。

这中间小时到亚秒的量级差距(约 5 个数量级),不是靠堆机器,而是靠存储引擎选型 + 索引设计 + 查询策略三者配合。下面先看数据规模,再逐层拆解。

02 一组开门见山的数字

核心事实一句话:在近 10 万亿行的底库上,对任意一块地图区域、任意 180 天时间窗做检索,用户 0.35 秒看到第一条轨迹。

底库实测 count()

trajectory_all_bos 9,868,628,881,572 ≈ 9.87 万亿(近 10 万亿)

按天统计的日写入量趋势如下:

分阶段看:

对检索服务的含义:写入量的季节波动,都不该影响读侧的检索延迟。 后面的技术设计正是要保证——无论底库今天灌进来多少,用户的检索 TTFB 都稳定在 0.35s。

03 服务定位与整体架构

先划清边界。 track-search 是情报核实图灵平台的一个底层能力,负责把离线 180 天的长尾历史轨迹变成"秒级可查"。轨迹数据的生产链路——由 Spark 从离线数仓读取 → 清洗编码(入库时过滤掉 1~4 级路、算 S2、转定点整数)→ 批量灌入 ClickHouse——在离线侧完成,不在本文范围。本文只讲读侧:track-search 是一个纯在线检索服务,把离线那批"要跑几个小时"的数据,变成"秒级可查"。

整体架构一张图:

为什么选 ClickHouse 而不是继续用离线数仓。 离线数仓为批处理吞吐设计,交互式点查要走任务调度、扫全表,延迟以小时计。ClickHouse 是列式 OLAP 引擎,配合排序键稀疏索引可以做到"只解压真正命中的数据块"——这是把小时级压到亚秒级的物理基础。技术栈只有三样:Go(GDP 框架)+ ClickHouse + S2 地理库,在线链路直接打 CK,不经过 Redis 缓存或中间存储。

三类典型应用场景,同一个检索引擎靠参数适配:

场景 A:轨迹挖路(全覆盖) 180 天全量 + 空间抽稀,用海量历史轨迹发现路网上还没被采集的新路。

{ "lng":116.4, "lat":39.9, "radius":2000, "limit":15000,"start_time":"2025-11-22 00:00:00", "end_time":"2026-05-20 23:59:00","max_per_cell":5, "enable_simplify":true }

场景 B:实时交通观察 单天数据 + 速度过滤,看某片区域的实时通行情况。

{ "lng":116.4, "lat":39.9, "radius":1000, "limit":5000,"start_time":"2026-05-20 00:00:00", "end_time":"2026-05-20 23:59:00","min_speed":3, "max_speed":120 }

场景 C:数据源质量审核 指定单一数据源 + GPS 精度过滤,逐源分析数据质量。

{ "lng":116.4, "lat":39.9, "radius":500, "limit":1000,"src_types":[9], "max_gps_radius":20 }

挖路要全、观察要新、审核要精——三种诉求,一套引擎。

3.1 平台可视化效果

下面是实际的检索可视化界面(半径 5.5km、卫星影像底图):

图上每一条绿色的线,都是一条真实的 GPS 轨迹——是车辆、终端实际走过的路径。一次框选,就把这片区域 180 天里落下的所有轨迹铺在了卫星影像上。

几个直接的观察:

3.2 回到最初的问题:这段路到底有没有车走过

绕了一圈,正好回到第 1 节那个核实平台反复要回答的问题——"这段路,到底有没有车真实走过?" 上面这张图就是答案:绿轨迹所到之处即"有人走过";绿轨迹密集却对不上现有路网的地方,就是该挖的新路或潜在的误挖。 这套服务真正的意义,不是"查得快"本身,而是把轨迹检索变成了要素核实与挖路工艺的交互式验证工具。

04 检索性能实测

测试环境:5 节点 ClickHouse 集群。

4.1 实测数据

读这张表最该注意的一点:30 天和 180 天,TTFB 都是 0.35s——时间窗拉长 5 倍,首字节时间几乎不变。这说明检索延迟主要由空间索引 + 流式返回决定,而不是被时间范围拖累。换句话说,首屏速度不被数据总量拖累——这正是下面所有技术设计的目标。

05 技术细节:0.35s 是怎么抠出来的

技术栈只有三样:Go(GDP 框架)+ ClickHouse + S2 地理库。在线检索链路直接打 CK,不经过 Redis 缓存或中间存储——靠 CK 自身的稀疏索引 + 本服务的查询策略扛住万亿量级。

5.1 地基:用 S2 把"区域"变成 CK 排序键上的整数

万亿行全表扫不可能。核心思路一句话:把地理区域翻译成排序键上的整数区间,让 CK 用稀疏索引跳读,只解压真正命中的数据块。

轨迹表 trajectory_all_bos(分布式表),每行一个 GPS 点,关键列:

表按 event_day 分区、(event_day, s2_id_18, traj_id) 排序。这个排序键是一切性能的地基:时间在前 → 时间范围裁剪分区;空间紧随 → S2 范围命中稀疏索引跳读。这也解释了第 4 节的现象——时间窗拉长只是多扫几个分区,不影响 S2 定位的首字节速度。

那么"空间"是怎么塞进排序键的?靠 S2。 CK 只认识 s2_id_18 上的大小比较,不认识"圆"或"矩形"。桥梁是 Google S2 库(library/util/s2tool.go,841 行):它用希尔伯特曲线把地表编码成一维 cell id,空间相邻 → id 相邻,于是任意区域都能覆盖成一组连续整数区间。下图完整演示了"网格 → 希尔伯特编号 → 整数区间 SQL"这三步:

cellRanges := util.GetCellRangesByRectCompact(minLat, minLng, maxLat, maxLng)// 每个 rangeSQL:s2_id_18 BETWEEN ? AND ? 或 s2_id_18 = ?

混合层级覆盖是关键:全用 level-18 精度高但区间数爆炸(几万个),SQL 太长;全用大 cell 又召回脏数据。所以用 MinLevel 8~12 / MaxLevel 18 生成少量大区间,平衡精度与条件数。至此,"检索一个圆/多边形"就变成了 CK 最拿手的"扫几段排序键整数区间"——这是后面所有过滤、跳读能成立的前提。

gcjMinLng, gcjMinLat := util.Wgs84ToGcj02(param.MinLng, param.MinLat)

5.2 浮点转定点整数:经纬度、速度全部存 Int

注意上表里一个刻意的设计:经纬度 lng/lat 不用 Float64/Float32 存,而是乘以 1e6 存成 Int32;速度 l_speed 乘以 10 存成 UInt16。 代码里到处是这样的转换(trajectory.go:71734):

// 写入 / 查询条件构造:浮点 → 定点整数minLng := int32(param.MinLng * 1e6)// 116.397428 → 116397428minSpeedVal := uint16(*param.MinSpeed * 10) // 65.5 km/h → 655// 出口再还原成浮点给前端Lng: float64(lng) / 1e6, // 116397428 → 116.397428

为什么要这么做? 在万亿行的量级下,字段的存储形态直接决定磁盘占用、IO 量和查询速度。浮点转定点整数带来四个实打实的好处(下图汇总):

  1. 省空间。 经纬度小数点后 6 位(约 0.11m 精度)对轨迹已经绰绰有余。Int32 只要 4 字节,覆盖 ±2147 的范围(经纬度 ±180 完全够);而 Float64 要 8 字节。单是经纬度两列,每行就从 16 字节降到 8 字节,砍掉一半。 乘到万亿行 × 2 列,是数十 TB 级的磁盘差异。速度用 UInt16(2 字节)比 Float32(4 字节)同理再省一半。

  2. 压缩率更高。 ClickHouse 是列式存储、按列压缩。整数列(尤其是排序后邻近值接近的定点数)用 delta / LZ4 编码的压缩比远高于浮点——浮点的尾数位近乎随机,几乎压不动。整数列常能压到浮点的几分之一,进一步放大省空间的收益。存得越小,查询要读和解压的字节就越少,直接转化为更快的检索。

  3. 比较更快、更准。 范围过滤 lng BETWEEN ? AND ? 在整数上是单周期 CPU 指令,比浮点比较快;而且整数比较没有浮点的精度误差——浮点的 == 和边界比较会因舍入产生 0.1 + 0.2 ≠ 0.3 这类问题,用定点整数彻底规避,边界判定精确可靠。

  4. 对 S2 索引友好。 经纬度以稳定的整数形态存储,配合 s2_id_18 排序键,整行数据在磁盘上排列紧凑规整,granule 内的数据局部性更好,跳读时命中率更高。

代价只有一个:进出口各做一次乘除转换。但这点 CPU 开销和它省下的数十 TB 磁盘 + 成倍的 IO/压缩收益相比,可以忽略不计。这是海量数据存储里非常典型的"定点化"取舍——用可接受的精度上限,换存储和查询的全面优势。

这几层手段叠加下来能省多少?看线上实测。 把"定点整数化 → 列式排序 + delta 编码 → LZ4 压缩"逐级叠加,效果可以直接从线上 system.parts 量出来:

一句话:定点整数化一个设计,同时兑现了省空间、高压缩、快比较、S2 友好四重收益。 存得越小,检索要读取和解压的字节就越少——压缩不只是省钱,更直接转化为更快的检索。

5.3 存储策略:SSD 热层 + 对象存储冷层的冷热分级

数据存在什么介质上、怎么读,直接决定检索的下限——再好的索引,最终也要从某块盘把数据读出来。 但这里有个现实矛盾:180 天近 10 万亿行、单节点数十 TB,全塞进本地 SSD 太贵、全放远端对象存储又太慢。本服务的解法是冷热分级存储(策略名脱敏为 traj_tiered_policy):

-- 建表 SETTINGS(示意,策略名已脱敏)SETTINGS index_granularity = 8192, storage_policy = 'traj_tiered_policy';

这个策略把存储分成两层,按数据冷热自动流转。下面这张图完整画出了一次检索如何用稀疏索引跳读、多盘并行扫 part,以及数据如何在 SSD 热层与 BOS 冷层之间自动搬运:

结合上图,逐层拆解:

读路径(图上半部分)——收到请求后怎么"少扫":

  1. 区域 → 整数区间。 框选区域经 S2 覆盖成若干段 s2_id_18 BETWEEN ? AND ?,把空间检索变成排序键上的范围扫描。

  2. 稀疏索引定位。 ClickHouse 每 8192 行(一个 granule)才在 primary.idx 里记一个 mark。查询用排序键在稀疏索引上二分,快速定位到命中的 mark 区间——不是逐行找,而是逐块(granule)找。

  3. granule 跳读。 只解压命中的 granule,区域外的数据块成片跳过、根本不读。万亿行里真正被解压的往往只有几千万行——这是"少扫"的核心。

  4. 多盘并行扫 part。 命中的数据分散在热层的多块 SSD 上(JBOD 组卷把 part 按盘打散)。一次查询靠 max_threads=32max_download_threads=16 同时从多块盘拉命中的 part,IO 吞吐近似叠加——单盘扛不住 32 线程同时要数据,多盘并行才喂得饱预读(prefetch_buffer_size=32M)。各盘读出的命中块汇聚后边处理边 flush,TTFB 0.35s。

冷热分级(图下半部分)——近 10 万亿行怎么"存得起又查得快":

一句话:稀疏索引 + granule 跳读决定"读多少",多盘并行决定"读多快",冷热分级决定"这些数据存在哪、值不值"。

5.4 两级过滤:粗筛跳块 + 精筛去空隙

区域大时 S2 区间会覆盖到不属于目标的空隙。查询用两级过滤兼顾快与准(trajectory.go:682)。下图画出了"S2 区间粗筛(含空隙)→ bbox 精筛去空隙"的完整过程:

SELECT DISTINCT traj_idFROM trajectory_all_bosPREWHERE s2_id_18 BETWEEN ? AND ?-- 粗筛:命中稀疏索引,成块跳过远处 granule(快)WHERE lng BETWEEN ? AND ?-- 精筛:bbox 去掉区间内的空隙误命中(准)AND lat BETWEEN ? AND ?AND event_day BETWEEN ? AND ?LIMIT ?

PREWHERE 而非 WHERE:CK 优先用它过滤,先读最少的列判断要不要读其余列,进一步省 IO。

5.5 两阶段查询:先找轨迹,再取点

点查接口 TrajS2Searchtrajectory.go:532)分两步,避免一次拖出海量点。下图对比了"一步到位"与"两阶段"的差异:

5.6 主力接口:流式 + 高并发(TTFB 0.35s 的直接来源)

前端拖拽/缩放走的是 TrajS2SearchStreamtrajectory.go:974),四个优化叠加压出 0.35s。下图完整画出了"range 拆分 → 空间交错分组 → 并发查询 → 达标即止 → 流式吐出"的全过程:

① 流式 NDJSON —— 先出首屏结果进 chan(缓冲 8000),主协程边读边写,每 50 条主动 flush:

w.Write(append(line, 'n'))// 一行一条轨迹if canFlush && flushCount >= 50 {flusher.Flush()// 不等缓冲满,立即推给客户端}

用户秒级见首屏,不必等全量算完——这是 TTFB 与总耗时解耦的根本原因。

② 空间交错分组并发 —— 均匀吃满集群

const maxRangeSpan = uint64(80000000000)// 大 range 拆成最多 4 段maxGroups := 6 // 按半径动态 6/8/12/16 组if radius > 7000 { maxGroups = 16 } ...// range 交错分配 (i % numGroups) 到各组,每组 × 2 张表并发查询

交错分配让每个并发查询覆盖的空间区域大小相当,避免某查询命中热点、其他空跑。

③ 达标即取消 —— 够用即止

if atomic.LoadInt64(&trajCount) > int64(targetTrajs) {break// 拉够目标量,其余查询随 context 取消}

不追求查全,够 targetTrajs(3万~30万,按 limit 动态)就停,省集群算力。

④ 均匀采样 + CK 深度调优

LIMIT %d BY s2_id_18-- 每格最多 N 条,空间均匀不偏科SETTINGS max_threads=32, max_block_size=131072, max_memory_usage=32G, max_download_threads=16, prefetch_buffer_size=32M

perCellLimit 按半径动态调(小半径高缩放每格多拉 1000 条,大范围每格 100 条防总量爆炸)。

5.7 流式后处理:抽稀、降噪、简化都在服务端做

从 CK 查出来的是原始 GPS 点——有抖动、有跳变、密集处冗余严重。直接把它们画到地图上是一团乱麻,且百万级点会压垮前端渲染。所以轨迹在流式吐出之前,要在服务端过一条后处理管线,把"原始数据"加工成"成品轨迹"。

这条管线的完整流程如下:

管线分五步,顺序是刻意设计的:

a. 空间抽稀(Thinning) —— 结果 ≥ 1 万条才触发。海量轨迹在热门路段高度重叠,全画既冗余又拖慢渲染。抽稀保证空间均匀覆盖的前提下砍量,四种策略按场景选:

b. 跳点截断(Jump Filter) —— 相邻两点距离超过阈值(如 500m),判定为 GPS 漂移或轨迹断裂,就地断开或整条丢弃。这一步必须在降噪之前:跳变点若先被平滑,会被抹成一段"缓慢漂移",反而更难识别。

c. 降噪(Denoise) —— 去除 GPS 固有的抖动毛刺,提供五种算法:

d. 平滑 + 简化(Smooth + Simplify) —— EMA 指数平滑让线条自然,再用**道格拉斯-普克(Douglas-Peucker)**算法在保持形状的前提下删掉冗余点。一条几百点的轨迹常能简化到几十点,传输和渲染都更轻。

e. 噪声段过滤 + 路网匹配 —— 丢掉过短的碎段;对保留的轨迹做路网匹配,算出每条轨迹到最近路网的平均距离(avgDist),据此标注"新路候选"(远离已有路网)或"误挖"(贴合已有路网)。这一步直接服务于挖路工艺的验证。

下面这张图把五步管线连同"原始乱麻 → 成品轨迹"的效果、以及顺序设计的原因,整合在一起:

为什么这套后处理不丢给前端做? 三个原因:① 原始点直接渲染是乱麻,前端没有能力做卡尔曼、DP 这类算法;② 服务端加工后前端拿到的即"成品",渲染零负担;③ 全部在流式管线里完成——边查边处理边吐,后处理与 CK 查询并行,不额外增加 TTFB,用户依然 0.35s 见首屏。

5.8 连接层与兜底

// bootstrap/init.go:158dsn := "...?dial_timeout=10s&max_execution_time=180" + "&connection_open_strategy=in_order&compress=lz4" // lz4 省带宽db.SetMaxOpenConns(50); db.SetMaxIdleConns(20) // 扛并发

Schema 降级兜底:上游写入的表结构会演进,查询先按全字段查,Scan 失败退回精简字段集重查(trajectory.go:1529),保证表结构变更期间不停服。

06 复盘:万亿量级读服务的关键决策与避坑

一个上小时级离线查询压到亚秒级在线检索的项目,回头看,真正起决定作用的不是某一行代码,而是几个方向性的关键决策、围绕它们的性能优化,以及一路踩过的坑。这里按这三条线做完整复盘,先用一张全景图收束:

6.1 三个关键决策

决策一:弃离线数仓、选 ClickHouse。 这是全局的地基。离线数仓为批处理吞吐设计,交互式点查必须走调度、扫全表,延迟天生以小时计——不是调优能救的,是架构不匹配。ClickHouse 列式 + 排序键稀疏索引,天然适合"大范围过滤、小范围命中"的点查。选型选对,后面所有优化才有意义。

决策二:用 S2 把地理问题降维成整数区间问题。 数据库不擅长空间几何,但极擅长排序键上的范围扫描。S2 用希尔伯特曲线把二维坐标编码成保持空间局部性的一维整数,等于把"检索一个圆/多边形"翻译成了"扫几段整数区间"。这一步把一个空间检索难题,变成了 ClickHouse 最拿手的事。

决策三:全链路流式,而非查完再返。 面对最大百万级的结果集,"算完再吐"注定慢。改成边查边吐后,TTFB 与总数据量解耦——用户的体感只取决于第一屏多快出来。这个决策直接定义了产品的"快"。

6.2 性能优化清单

贯穿这张表的一条主线:降扫描量优先于加机器。 5 节点集群能扛住近 10 万亿行,靠的不是堆硬件,而是让每个请求只碰它真正需要的那几千万行。

6.3 避坑经验

这几个坑都是真金白银踩出来的,拎出来单独说,希望后来者绕开:

07 写在最后:这套方法能迁移到哪

本文讲的是轨迹检索,但抽掉业务外壳,内核是一套**"海量时空/点数据的在线检索"通用范式**,可以迁移到很多相似场景:

最后强调一点:近 10 万亿行是"数据的规模",不是"架构的瓶颈"。 这个量级由业务决定(180 天 × 低等级路的全量轨迹),而不是这套架构能承受的上限。前面所有设计——排序键稀疏索引跳读、S2 降维、冷热分级、流式并发——的共同特征是:单请求的开销只与"命中的数据量"相关,几乎不随"底库总量"增长。 时间窗从 30 天拉到 180 天、TTFB 都稳定在 0.35s,就是这一点最直接的证据。所以哪怕底库再翻几倍、涨到几十万亿行,检索延迟也不会跟着线性劣化;真要扩容,加节点、扩 SSD 热层、调大并发度都是水平可扩展的常规手段。换句话说,这套架构的天花板远高于当前的数据规模——瓶颈在数据本身有多少,而不在于能不能查得动。

回到轨迹本身,它最终服务的是百度地图路网的持续更新——让"哪里有人在走、哪条挖出来的路是对的"这件事,从等几个小时变成随点随看。当基础设施的检索速度快到可以支撑交互式探索时,它改变的不只是响应时间,而是整个工艺的工作方式。 这也是我们做这套服务最大的体会。

喜欢(0)

上一篇

Inkling 975B 说明“开放权重“与“普通开发者本地运行“已经分离,内容重点应是部署容量和运行时边界

Inkling 975B 说明“开放权重“与“普通开发者本地运行“已经分离,内容重点应是部署容量和运行时边界

下一篇

2026电商AI客服系统综合实力榜:五家主流品牌深度横评

2026电商AI客服系统综合实力榜:五家主流品牌深度横评
猜你喜欢