← 回總覽

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

📅 2026-07-22 18:00 百度Geek说 软件编程 14 分鐘 16317 字 評分: 90
后端开发 数据库 系统设计 数据工程 性能优化
📌 一句话摘要 本文复盘了百度图灵平台如何用 ClickHouse + S2 地理编码 + 流式检索,在近 10 万亿轨迹点的底库上实现任意区域秒级检索(TTFB 0.35s)的完整技术方案与避坑经验。 📝 详细摘要 文章详细介绍了百度地图情报核实图灵平台的核心检索服务 track-search 的技术实现。面对长尾路段核实需查询 180 天、近 10 万亿行历史轨迹的挑战,团队弃用离线数仓,选择 ClickHouse 作为在线检索引擎。方案核心包括:利用 S2 地理编码将空间检索转化为排序键上的整数区间扫描;将经纬度、速度等浮点数转为定点整数以节省存储并提升压缩率;采用 SSD 热层与

原创 欢迎关注的 2026-07-22 18:00 北京

!Image 1

图灵平台将离线数仓近 10 万亿轨迹点的检索,从"等几个小时"做到了 TTFB 0.35 秒——本文复盘了 ClickHouse + S2 地理编码 + 流式检索这一整套方案,既有存储选型、索引设计的原理解析,也有一线踩出的避坑经验。

。!Image 2: 图片

点击蓝字,关注我们

!Image 3: 图片

作者 |邵明瑞

导读

introduction

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

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

_全文 7903 字,预计阅读时间 7 分钟_

GEEK TALK

01

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

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

> **这段路,到底有没有车真实走过?**

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

* **主干道**车多,取几天数据就有足够轨迹,好办;

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

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

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

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

GEEK TALK

02

一组开门见山的数字

!Image 4

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

底库实测count()trajectory_all_bos   9,868,628,881,572   ≈ 9.87 万亿(近 10 万亿) 按天统计的日写入量趋势如下:

!Image 5

分阶段看:

* **1 月中下旬**:节前出行高峰,日写入 680~1020 亿,是全周期最活跃的时段。

* **2 月**:春节前后回落到 300~400 亿/天,符合假期出行下降的规律。

* **3 月**:稳定在 330~360 亿/天,是全年的基线水位。

* **4 月中起明显抬升**:从 400 亿跳到 800 亿档并持续,对应有新数据源接入放量。

* **5~7 月**:常态维持在 400~900 亿之间,且呈现明显的隔日节律,约 450 亿与约 800 亿交替出现,说明某个大数据源是按批次、非每日均匀写入的。

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

GEEK TALK

03

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

整体架构一张图:

!Image 6 **为什么选 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 }

> 180 天全量保证路网完整性,max_per_cell空间抽稀避免热门路段冗余。 **场景 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 }

> 过滤停车(<3km/h)和异常超速(>120km/h)。 **场景 C:数据源质量审核**

指定单一数据源 + GPS 精度过滤,逐源分析数据质量。 { "lng":116.4, "lat":39.9, "radius":500, "limit":1000, "src_types":[9], "max_gps_radius":20 }

> 只看 src_type=9(百度地图 SDK),GPS 精度优于 20m 的点。

挖路要全、观察要新、审核要精——三种诉求,一套引擎。 3.1 平台可视化效果

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

!Image 7

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

几个直接的观察:

* **轨迹勾勒出了真实路网。**田间机耕道、村道、环湖小路……这些绿线密集成束的地方,就是"确实有人反复走过"的路。

* **有绿线、卫星图上却看不出明显道路的位置,就是价值所在。**它可能是一条还没被采集进地图的新路,也可能是地图上标了、实际却没什么车走的"误挖"。

* **越是偏远、车少的长尾路段,越依赖这张图。**单看几天数据它们几乎是空白,正是靠 180 天累积,才把这些稀疏的轨迹显影出来。 3.2 回到最初的问题:这段路到底有没有车走过

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

!Image 8

GEEK TALK

04

检索性能实测

测 试环境:5 节点 ClickHouse 集群。 4.1 实测数据

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

* **TTFB**≈ 0.3~0.5s:前端收到第一条轨迹即可开始渲染,无需等全部算完。

* **总耗时**随数据量线性增长,但因为是流式,用户体感只跟 TTFB 相关。

GEEK TALK

05

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

技术栈只有三样:**Go(GDP 框架)+ ClickHouse + S2 地理库**。在线检索链路直接打 CK,不经过 Redis 缓存或中间存储——靠 CK 自身的稀疏索引 + 本服务的查询策略扛住万亿量级。 5.1 地基:用 S2 把"区域"变成 CK 排序键上的整数

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

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

!Image 10

表按event_day分区、(event_day, s2_id_18, traj_id)排序。**这个排序键是一切性能的地基**:时间在前 → 时间范围裁剪分区;空间紧随 → S2 范围命中稀疏索引跳读。这也解释了第 4 节的现象——时间窗拉长只是多扫几个分区,不影响 S2 定位的首字节速度。 **那么"空间"是怎么塞进排序键的?靠 S2。CK 只认识s2_id_18上的大小比较,不认识"圆"或"矩形"。桥梁是 GoogleS2 库library/util/s2tool.go,841 行):它用希尔伯特曲线把地表编码成一维 cell id,空间相邻 → id 相邻**,于是任意区域都能覆盖成一组连续整数区间。下图完整演示了"网格 → 希尔伯特编号 → 整数区间 SQL"这三步:

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

> ⚠️ 必踩的坑:CK 里s2_id_18基于**GCJ-02**坐标计算,入参通常是 WGS-84,查询前必须转坐标(trajectory.go:1056),否则整体偏移几百米、召回全错: 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 → 116397428 minSpeedVal := uint16(param.MinSpeed  10)   // 65.5 km/h → 655 // 出口再还原成浮点给前端 Lng: float64(lng) / 1e6,                       // 116397428 → 116.397428 **为什么要这么做?**在万亿行的量级下,字段的存储形态直接决定磁盘占用、IO 量和查询速度。浮点转定点整数带来四个实打实的好处(下图汇总):

!Image 12

  • **省空间。经纬度小数点后 6 位(约 0.11m 精度)对轨迹已经绰绰有余。Int32只要 4 字节,覆盖 ±2147 的范围(经纬度 ±180 完全够);而Float64要 8 字节。单是经纬度两列,每行就从 16 字节降到 8 字节,砍掉一半。**乘到万亿行 × 2 列,是数十 TB 级的磁盘差异。速度用UInt16(2 字节)比Float32(4 字节)同理再省一半。
  • **压缩率更高。ClickHouse 是列式存储、按列压缩。整数列(尤其是排序后邻近值接近的定点数)用 delta / LZ4 编码的压缩比远高于浮点——浮点的尾数位近乎随机,几乎压不动。整数列常能压到浮点的几分之一,进一步放大省空间的收益。存得越小,查询要读和解压的字节就越少,直接转化为更快的检索。**
  • **比较更快、更准。范围过滤lng BETWEEN ? AND ?在整数上是单周期 CPU 指令,比浮点比较快;而且整数比较没有浮点的精度误差**——浮点的==和边界比较会因舍入产生0.1 + 0.2 ≠ 0.3这类问题,用定点整数彻底规避,边界判定精确可靠。
  • **对 S2 索引友好。**经纬度以稳定的整数形态存储,配合s2_id_18排序键,整行数据在磁盘上排列紧凑规整,granule 内的数据局部性更好,跳读时命中率更高。
代价只有一个:进出口各做一次乘除转换。但这点 CPU 开销和它省下的**数十 TB 磁盘 + 成倍的 IO/压缩收益相比,可以忽略不计。这是海量数据存储里非常典型的"定点化"取舍——用可接受的精度上限,换存储和查询的全面优势。** **这几层手段叠加下来能省多少?看线上实测。**把"定点整数化 → 列式排序 + delta 编码 → LZ4 压缩"逐级叠加,效果可以直接从线上system.parts量出来:

!Image 13

* **整表 6.42:1:未压缩37.69 TiB → 落盘 5.87 TiB,平均每行从 102 B 压到15.96 B**。

* **排序键列压得最狠event_day226×、s2_id_18202×——这两列值高度连续,delta 编码后近乎全 0,压缩比飙到 200 倍以上。这正是"把区域翻译成排序键整数区间"这个设计的额外红利:它不仅让检索能跳读,还让索引列本身几乎不占空间。**

* **定点整数的坐标列lng/lat各约 3.6~3.7×——若换成随机尾数的Float64,几乎压不动。这就是为什么"浮点转定点整数"是压缩率的地基**:它把随机的浮点尾数,变成了可被 delta/LZ4 狠狠压缩的规整整数。

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

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

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

!Image 14

结合上图,逐层拆解: **读路径(图上半部分)——收到请求后怎么"少扫":**

  • **区域 → 整数区间。**框选区域经 S2 覆盖成若干段s2_id_18 BETWEEN ? AND ?,把空间检索变成排序键上的范围扫描。
  • **稀疏索引定位。ClickHouse 每 8192 行(一个 granule)才在primary.idx里记一个 mark。查询用排序键在稀疏索引上二分,快速定位到命中的 mark 区间——不是逐行找,而是逐块(granule)找。**
  • **granule 跳读。**只解压命中的 granule,区域外的数据块成片跳过、根本不读。万亿行里真正被解压的往往只有几千万行——这是"少扫"的核心。
  • **多盘并行扫 part。命中的数据分散在热层的多块 SSD 上(JBOD 组卷把 part 按盘打散)。一次查询靠max_threads=32max_download_threads=16同时从多块盘拉命中的 part**,IO 吞吐近似叠加——单盘扛不住 32 线程同时要数据,多盘并行才喂得饱预读(prefetch_buffer_size=32M)。各盘读出的命中块汇聚后边处理边 flush,TTFB 0.35s。
**冷热分级(图下半部分)——近 10 万亿行怎么"存得起又查得快":**

* **热层 SSD(volume priority 1):ssd_1~4本地盘组卷,存近期高频数据。数据热度天然衰减——挖路、交通观察大多看近期,所以绝大多数检索直接命中热层、本地多盘并行读**,亚秒返回。

* **冷层 BOS(volume priority 2):**历史存量下沉到 5 个 BOS 对象存储盘,容量近乎无限、单价极低。实测约 20TB 历史大头都压在这层,SSD 只留最热的一小片。

* **自动搬运:热层 SSD 用量触及水位(move_factor=0.7)时,ClickHouse 后台把最老的 part 自动下沉到 BOS;配合 180 天 TTL 滚动淘汰最旧分区。整个冷热迁移对查询透明,应用层不用搬数据、SQL 不用改。**即使查到已下沉的历史 part,也能按需从 BOS 拉取 + 并行下载,仍在可接受延迟内。

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

> 一句话概括这三层存储优化:**定点整数让数据变小(5.2)→ SSD 热层 + BOS 冷层让热数据读得快、存量存得起(5.3)→ S2 排序键让只读命中的块(5.1)。**从"存多少""存在哪、读多快""读多少"三个维度层层递进,共同支撑亚秒检索。 5.4 两级过滤:粗筛跳块 + 精筛去空隙

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

!Image 15 SELECT DISTINCT traj_id FROM trajectory_all_bos PREWHERE 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)分两步,避免一次拖出海量点。下图对比了"一步到位"与"两阶段"的差异:

!Image 16

* **Step 1**:上面的两级过滤只SELECT DISTINCT traj_id—— 数据量小、快。

* **Step 2**:拿 traj_id 集合回表取完整点,ORDER BY traj_id, point_id。这步是精确点查,不再碰空间索引。 5.6 主力接口:流式 + 高并发(TTFB 0.35s 的直接来源)

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

!Image 17 **① 流式 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 点——有抖动、有跳变、密集处冗余严重。直接把它们画到地图上是一团乱麻,且百万级点会压垮前端渲染。所以轨迹在流式吐出之前,要在服务端过一条后处理管线**,把"原始数据"加工成"成品轨迹"。

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

!Image 18

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

* grid_quota网格配额:每个网格桶先保底 1 条,剩余配额按比例补——最均匀,挖路默认用它。

* distance_decay距离衰减:中心多留、边缘少留,适合以某点为焦点的观察。

* link_quotaLink 配额:按路段分配名额,每段选置信度最高的 Top-K。

* random_sample随机采样:最简单的兜底。 **b. 跳点截断(Jump Filter)—— 相邻两点距离超过阈值(如 500m),判定为 GPS 漂移或轨迹断裂,就地断开或整条丢弃。这一步必须在降噪之前**:跳变点若先被平滑,会被抹成一段"缓慢漂移",反而更难识别。 **c. 降噪(Denoise)**—— 去除 GPS 固有的抖动毛刺,提供五种算法:

* **中值滤波**:滑动窗口取中值坐标,专治单点跳变(椒盐噪声)。

* **高斯平滑**:加权移动平均,sigma控强度。

* **卡尔曼滤波**(单向/双向):匀速运动模型预测 + 观测融合,双向版消除单向的相位滞后。

* **限速滤波**:按最大合理速度剔除瞬移点。 **d. 平滑 + 简化(Smooth + Simplify)—— EMA 指数平滑让线条自然,再用道格拉斯-普克(Douglas-Peucker)**算法在保持形状的前提下删掉冗余点。一条几百点的轨迹常能简化到几十点,传输和渲染都更轻。 **e. 噪声段过滤 + 路网匹配**—— 丢掉过短的碎段;对保留的轨迹做路网匹配,算出每条轨迹到最近路网的平均距离(avgDist),据此标注"新路候选"(远离已有路网)或"误挖"(贴合已有路网)。这一步直接服务于挖路工艺的验证。

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

!Image 19 **为什么这套后处理不丢给前端做?三个原因:① 原始点直接渲染是乱麻,前端没有能力做卡尔曼、DP 这类算法;② 服务端加工后前端拿到的即"成品",渲染零负担;③全部在流式管线里完成**——边查边处理边吐,后处理与 CK 查询并行,不额外增加 TTFB,用户依然 0.35s 见首屏。 5.8 连接层与兜底 // bootstrap/init.go:158 dsn := "...?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),保证表结构变更期间不停服。

GEEK TALK

06

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

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

!Image 20 6.1 三个关键决策 **决策一:弃离线数仓、选 ClickHouse。这是全局的地基。离线数仓为批处理吞吐设计,交互式点查必须走调度、扫全表,延迟天生以小时计——不是调优能救的,是架构不匹配。ClickHouse 列式 + 排序键稀疏索引,天然适合"大范围过滤、小范围命中"的点查。选型选对,后面所有优化才有意义。** **决策二:用 S2 把地理问题降维成整数区间问题。数据库不擅长空间几何,但极擅长排序键上的范围扫描。S2 用希尔伯特曲线把二维坐标编码成保持空间局部性的一维整数,等于把"检索一个圆/多边形"翻译成了"扫几段整数区间"。这一步把一个空间检索难题,变成了 ClickHouse 最拿手的事。** **决策三:全链路流式,而非查完再返。面对最大百万级的结果集,"算完再吐"注定慢。改成边查边吐后,TTFB 与总数据量解耦——用户的体感只取决于第一屏多快出来。这个决策直接定义了产品的"快"。** 6.2 性能优化清单

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

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

* **坑一:坐标系不一致,召回全错。CK 里s2_id_18基于GCJ-02计算,而入参多是 WGS-84。不转坐标就检索,结果整体偏移几百米,且不报错**——最难查的那种。务必在查询前Wgs84ToGcj02trajectory.go:1056)。

* **坑二:为"聚合加速"建的摘要表,明细场景用不上。项目早期上过一套SummingMergeTree+ 物化视图的摘要表,想把扫描量从万亿降到百亿。但本服务要的是轨迹点明细(经纬度、速度、路网匹配),摘要表只有聚合值、拿不到明细,回主表还得再查一遍,写放大成本还高。最终弃用摘要表,回归主表排序键本身解决。教训:预聚合只对聚合查询有价值,别为明细检索建摘要表。**

* **坑三:把"没写完的分区"当成"数据回落"。**统计每日写入量时,当天分区还在持续写入,count()出来可能只有几万——这是进行时状态,不是业务真实下降。做数据监控/展示时必须剔除未完成分区,否则会得出"某天数据暴跌"的错误结论。

* **坑四:上游 schema 演进会让查询突然失败。上游写入的表结构会加列改列,硬编码全字段的查询会在某天Scan报错。解法是降级兜底**:先按全字段查,失败自动退回精简字段集重查(trajectory.go:1529),让服务在表结构变更期间不停服。

> 一句话总结:**万亿级读服务的性能,不靠单查询极限优化,而靠"选对存储引擎 + 让每个请求只碰它真正需要的那几千万行 + 让集群被均匀、可中止地使用"这一整套配合。**

GEEK TALK

07

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

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

* **任何"空间 + 时间"的海量点查**——POI 检索、围栏内设备筛选、物流轨迹回放、LBS 热力分析,都能套用"S2/GeoHash 降维 + 排序键跳读 + 流式返回"这套组合。

* **任何"离线数仓扛不动在线交互"的场景**——当 Hive/离线数仓上的分析型查询被要求做成秒级交互时,"迁移到 ClickHouse 这类 OLAP 引擎 + 把过滤条件对齐到排序键"是一条被验证过的路径。

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

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

END 推荐阅读

!Image 22: 图片

一键三连,好运连连,bug不见👇

查看原文 → 發佈: 2026-07-22 18:00:00 收錄: 2026-07-23 02:00:45

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。