导读 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 万亿)
按天统计的日写入量趋势如下:

分阶段看:
-
1 月中下旬:节前出行高峰,日写入 680~1020 亿,是全周期最活跃的时段。
-
2 月:春节前后回落到 300~400 亿/天,符合假期出行下降的规律。
-
3 月:稳定在 330~360 亿/天,是全年的基线水位。
-
4 月中起明显抬升:从 400 亿跳到 800 亿档并持续,对应有新数据源接入放量。
-
5~7 月:常态维持在 400~900 亿之间,且呈现明显的隔日节律,约 450 亿与约 800 亿交替出现,说明某个大数据源是按批次、非每日均匀写入的。
对检索服务的含义:写入量的季节波动,都不该影响读侧的检索延迟。 后面的技术设计正是要保证------无论底库今天灌进来多少,用户的检索 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 天全量 + 空间抽稀,用海量历史轨迹发现路网上还没被采集的新路。
json
{ "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:实时交通观察 单天数据 + 速度过滤,看某片区域的实时通行情况。
json
{ "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 精度过滤,逐源分析数据质量。
json
{ "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、卫星影像底图):

图上每一条绿色的线,都是一条真实的 GPS 轨迹------是车辆、终端实际走过的路径。一次框选,就把这片区域 180 天里落下的所有轨迹铺在了卫星影像上。
几个直接的观察:
-
轨迹勾勒出了真实路网。 田间机耕道、村道、环湖小路......这些绿线密集成束的地方,就是"确实有人反复走过"的路。
-
有绿线、卫星图上却看不出明显道路的位置,就是价值所在。 它可能是一条还没被采集进地图的新路,也可能是地图上标了、实际却没什么车走的"误挖"。
-
越是偏远、车少的长尾路段,越依赖这张图。 单看几天数据它们几乎是空白,正是靠 180 天累积,才把这些稀疏的轨迹显影出来。
3.2 回到最初的问题:这段路到底有没有车走过
绕了一圈,正好回到第 1 节那个核实平台反复要回答的问题------"这段路,到底有没有车真实走过?" 上面这张图就是答案:绿轨迹所到之处即"有人走过";绿轨迹密集却对不上现有路网的地方,就是该挖的新路或潜在的误挖。 这套服务真正的意义,不是"查得快"本身,而是把轨迹检索变成了要素核实与挖路工艺的交互式验证工具。

04 检索性能实测
测试环境:5 节点 ClickHouse 集群。
4.1 实测数据

读这张表最该注意的一点 :30 天和 180 天,TTFB 都是 0.35s ------时间窗拉长 5 倍,首字节时间几乎不变。这说明检索延迟主要由空间索引 + 流式返回 决定,而不是被时间范围拖累。换句话说,首屏速度不被数据总量拖累------这正是下面所有技术设计的目标。
-
TTFB ≈ 0.3~0.5s:前端收到第一条轨迹即可开始渲染,无需等全部算完。
-
总耗时随数据量线性增长,但因为是流式,用户体感只跟 TTFB 相关。
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"这三步:

sql
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),否则整体偏移几百米、召回全错:
css
gcjMinLng, gcjMinLat := util.Wgs84ToGcj02(param.MinLng, param.MinLat)
5.2 浮点转定点整数:经纬度、速度全部存 Int
注意上表里一个刻意的设计:经纬度 lng/ lat 不用 Float64/ Float32 存,而是乘以 1e6 存成 Int32;速度 l_speed 乘以 10 存成 UInt16。 代码里到处是这样的转换(trajectory.go:71、734):
go
// 写入 / 查询条件构造:浮点 → 定点整数
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 量和查询速度。浮点转定点整数带来四个实打实的好处(下图汇总):

-
省空间。 经纬度小数点后 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 量出来:

-
整表 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):
ini
-- 建表 SETTINGS(示意,策略名已脱敏)
SETTINGS index_granularity = 8192, storage_policy = 'traj_tiered_policy';
这个策略把存储分成两层,按数据冷热自动流转。下面这张图完整画出了一次检索如何用稀疏索引跳读、多盘并行扫 part,以及数据如何在 SSD 热层与 BOS 冷层之间自动搬运:

结合上图,逐层拆解:
读路径(图上半部分)------收到请求后怎么"少扫":
-
区域 → 整数区间。 框选区域经 S2 覆盖成若干段
s2_id_18 BETWEEN ? AND ?,把空间检索变成排序键上的范围扫描。 -
稀疏索引定位。 ClickHouse 每 8192 行(一个 granule)才在
primary.idx里记一个 mark。查询用排序键在稀疏索引上二分,快速定位到命中的 mark 区间------不是逐行找,而是逐块(granule)找。 -
granule 跳读。 只解压命中的 granule,区域外的数据块成片跳过、根本不读。万亿行里真正被解压的往往只有几千万行------这是"少扫"的核心。
-
多盘并行扫 part。 命中的数据分散在热层的多块 SSD 上(JBOD 组卷把 part 按盘打散)。一次查询靠
max_threads=32、max_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 精筛去空隙"的完整过程:

sql
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 两阶段查询:先找轨迹,再取点
点查接口 TrajS2Search(trajectory.go:532)分两步,避免一次拖出海量点。下图对比了"一步到位"与"两阶段"的差异:

-
Step 1 :上面的两级过滤只
SELECT DISTINCT traj_id------ 数据量小、快。 -
Step 2 :拿 traj_id 集合回表取完整点,
ORDER BY traj_id, point_id。这步是精确点查,不再碰空间索引。
5.6 主力接口:流式 + 高并发(TTFB 0.35s 的直接来源)
前端拖拽/缩放走的是 TrajS2SearchStream(trajectory.go:974),四个优化叠加压出 0.35s。下图完整画出了"range 拆分 → 空间交错分组 → 并发查询 → 达标即止 → 流式吐出"的全过程:

① 流式 NDJSON ------ 先出首屏 结果进 chan(缓冲 8000),主协程边读边写,每 50 条主动 flush:
arduino
w.Write(append(line, '\n')) // 一行一条轨迹
if canFlush && flushCount >= 50 {
flusher.Flush() // 不等缓冲满,立即推给客户端
}
用户秒级见首屏,不必等全量算完------这是 TTFB 与总耗时解耦的根本原因。
② 空间交错分组并发 ------ 均匀吃满集群
go
const maxRangeSpan = uint64(80000000000) // 大 range 拆成最多 4 段
maxGroups := 6 // 按半径动态 6/8/12/16 组
if radius > 7000 { maxGroups = 16 } ...
// range 交错分配 (i % numGroups) 到各组,每组 × 2 张表并发查询
交错分配让每个并发查询覆盖的空间区域大小相当,避免某查询命中热点、其他空跑。
③ 达标即取消 ------ 够用即止
javascript
if atomic.LoadInt64(&trajCount) > int64(targetTrajs) {
break // 拉够目标量,其余查询随 context 取消
}
不追求查全,够 targetTrajs(3万~30万,按 limit 动态)就停,省集群算力。
④ 均匀采样 + CK 深度调优
ini
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 万条才触发。海量轨迹在热门路段高度重叠,全画既冗余又拖慢渲染。抽稀保证空间均匀覆盖的前提下砍量,四种策略按场景选:
-
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),据此标注"新路候选"(远离已有路网)或"误挖"(贴合已有路网)。这一步直接服务于挖路工艺的验证。
下面这张图把五步管线连同"原始乱麻 → 成品轨迹"的效果、以及顺序设计的原因,整合在一起:

为什么这套后处理不丢给前端做? 三个原因:① 原始点直接渲染是乱麻,前端没有能力做卡尔曼、DP 这类算法;② 服务端加工后前端拿到的即"成品",渲染零负担;③ 全部在流式管线里完成------边查边处理边吐,后处理与 CK 查询并行,不额外增加 TTFB,用户依然 0.35s 见首屏。
5.8 连接层与兜底
go
// 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),保证表结构变更期间不停服。
06 复盘:万亿量级读服务的关键决策与避坑
一个上小时级离线查询压到亚秒级在线检索的项目,回头看,真正起决定作用的不是某一行代码,而是几个方向性的关键决策 、围绕它们的性能优化 ,以及一路踩过的坑。这里按这三条线做完整复盘,先用一张全景图收束:

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

贯穿这张表的一条主线:降扫描量优先于加机器。 5 节点集群能扛住近 10 万亿行,靠的不是堆硬件,而是让每个请求只碰它真正需要的那几千万行。
6.3 避坑经验
这几个坑都是真金白银踩出来的,拎出来单独说,希望后来者绕开:
-
坑一:坐标系不一致,召回全错。 CK 里
s2_id_18基于 GCJ-02 计算,而入参多是 WGS-84。不转坐标就检索,结果整体偏移几百米,且不报错 ------最难查的那种。务必在查询前Wgs84ToGcj02(trajectory.go:1056)。 -
坑二:为"聚合加速"建的摘要表,明细场景用不上。 项目早期上过一套
SummingMergeTree+ 物化视图的摘要表,想把扫描量从万亿降到百亿。但本服务要的是轨迹点明细 (经纬度、速度、路网匹配),摘要表只有聚合值、拿不到明细,回主表还得再查一遍,写放大成本还高。最终弃用摘要表 ,回归主表排序键本身解决。教训:预聚合只对聚合查询有价值,别为明细检索建摘要表。 -
坑三:把"没写完的分区"当成"数据回落"。 统计每日写入量时,当天分区还在持续写入,
count()出来可能只有几万------这是进行时状态,不是业务真实下降。做数据监控/展示时必须剔除未完成分区,否则会得出"某天数据暴跌"的错误结论。 -
坑四:上游 schema 演进会让查询突然失败。 上游写入的表结构会加列改列,硬编码全字段的查询会在某天
Scan报错。解法是降级兜底 :先按全字段查,失败自动退回精简字段集重查(trajectory.go:1529),让服务在表结构变更期间不停服。
一句话总结:万亿级读服务的性能,不靠单查询极限优化,而靠"选对存储引擎 + 让每个请求只碰它真正需要的那几千万行 + 让集群被均匀、可中止地使用"这一整套配合。
07 写在最后:这套方法能迁移到哪
本文讲的是轨迹检索,但抽掉业务外壳,内核是一套**"海量时空/点数据的在线检索"通用范式**,可以迁移到很多相似场景:
-
任何"空间 + 时间"的海量点查------POI 检索、围栏内设备筛选、物流轨迹回放、LBS 热力分析,都能套用"S2/GeoHash 降维 + 排序键跳读 + 流式返回"这套组合。
-
任何"离线数仓扛不动在线交互"的场景------当 Hive/离线数仓上的分析型查询被要求做成秒级交互时,"迁移到 ClickHouse 这类 OLAP 引擎 + 把过滤条件对齐到排序键"是一条被验证过的路径。
-
任何被结果集大小拖慢体感的接口------流式返回让 TTFB 与总量解耦,是提升大结果集查询体感的通用手段,不限于地理场景。
最后强调一点:近 10 万亿行是"数据的规模",不是"架构的瓶颈"。 这个量级由业务决定(180 天 × 低等级路的全量轨迹),而不是这套架构能承受的上限。前面所有设计------排序键稀疏索引跳读、S2 降维、冷热分级、流式并发------的共同特征是:单请求的开销只与"命中的数据量"相关,几乎不随"底库总量"增长。 时间窗从 30 天拉到 180 天、TTFB 都稳定在 0.35s,就是这一点最直接的证据。所以哪怕底库再翻几倍、涨到几十万亿行,检索延迟也不会跟着线性劣化;真要扩容,加节点、扩 SSD 热层、调大并发度都是水平可扩展的常规手段。换句话说,这套架构的天花板远高于当前的数据规模------瓶颈在数据本身有多少,而不在于能不能查得动。
回到轨迹本身,它最终服务的是百度地图路网的持续更新 ------让"哪里有人在走、哪条挖出来的路是对的"这件事,从等几个小时变成随点随看。当基础设施的检索速度快到可以支撑交互式探索时,它改变的不只是响应时间,而是整个工艺的工作方式。 这也是我们做这套服务最大的体会。