bg18.地理与轨迹
你根本妹在沈阳大街...
三类业务,一条共同的成本线
| 业务 | 例子 | 怎么赚钱 | 位置数据在哪一环 |
|---|---|---|---|
| 本地生活 / 到店 | 团购、探店、门店优惠 | 商家佣金 + 广告 | 搜索与推荐的排序:离得远的店排后面 |
| 即时配送 / 到家 | 外卖、生鲜、跑腿 | 配送费 + 商家佣金 | 派单与履约:谁接单、走哪条路、预计几点到 |
| 出行 | 打车、共享单车 | 抽成 | 供需匹配:哪里缺车、给谁派、怎么定价 |
LBS(Location-Based Service,基于位置的服务):一切"结果依赖于用户当前在哪"的服务的统称。上面三类都是 LBS。
三类业务的收入公式不同,但成本公式几乎一样:
本地生活收入 = 交易额 × 佣金率
即时配送收入 = 订单数 × (配送费 + 佣金)
出行收入 = 订单数 × 客单价 × 抽成率
而三者的核心成本都长这样:
成本 = 订单数 × 单均履约成本
↑
这一项【几乎完全由空间关系决定】:
· 骑手/司机与订单的距离
· 一次能不能顺路带多单(拼单率)
· 路线是否绕远
这就是位置数据在这类业务里的地位:它不是"一个字段",它直接决定成本。 对比前几篇——15 篇的位置字段只是收货地址(一个属性),16A 里根本没有位置,17 篇的地域定向也只是一个筛选条件。只有在这一类业务里,位置从"属性"变成了"业务逻辑本身"。
用即时配送举例(以下全部是为了算式可算而设的假设值):
假设:
日订单 = 3000 万单
单均配送成本 = 6.0 元
日配送成本 = 1.8 亿元
如果通过更好的空间匹配(派单更近、拼单更多),
把单均成本降低 0.1 元:
日省 3000 万 × 0.1 = 300 万元
年省 ≈ 11 亿元
这 0.1 元来自哪里?来自派单时"找出这个骑手周围 2 公里内所有待派订单"这个查询算得又快又准。 后文 全篇都在讲这一个查询怎么才能快——因为它每秒要执行几十万次,而且它是本系列迄今为止最不适合现有存储结构的一个查询。
三种形态
① 点 (Point) ② 轨迹 (Trajectory / LineString) ③ 面 (Polygon)
一次定位上报 同一个移动主体的有序点列 一块区域
(39.9087, 116.3975) t0 (39.900, 116.390) 配送范围
一条记录 = 一个瞬间 t1 (39.901, 116.392) 商圈边界
t2 (39.903, 116.391) ← 有【顺序】 行政区
... 电子围栏
一条轨迹 = 【一个语义整体】
这三种形态的差别不是"数据类型"的差别,是"什么构成一条记录"的差别,而这直接决定了建模:
| 点 | 轨迹 | 面 | |
|---|---|---|---|
| 一条记录是什么 | 一个瞬间 | 一段过程 | 一个静态区域 |
| 记录之间有无顺序 | 无 | 有,且顺序有语义 | 无 |
| 数据量 | 极大(每人每几秒一条) | 大(但可压缩,§4.6) | 小(万级到百万级) |
| 更新频率 | 只追加 | 只追加 | 偶尔更新(商圈调整、配送范围调整)→ 这是一张维表 |
| 主要查询 | "范围内有哪些点" | "这条轨迹经过了哪里/多长/多久" | "这个点在哪个面里" |
注意最后一行:三种形态对应三种完全不同的查询,而它们分别压不同的技术点。
数据长什么样
// ① 位置上报(骑手 App / 司机 App / 用户 App)
{
"event": "location_report",
"subject_id": "rider-88231", // 谁:骑手/司机/用户/车辆
"subject_type": "rider",
"lat": 39.908700, // ★ 纬度:-90 ~ 90
"lng": 116.397500, // ★ 经度:-180 ~ 180
"coord_sys": "WGS84", // ★★ 坐标系 —— §2.3 会讲这是最大的坑
"accuracy_m": 12.5, // 定位精度(米):这个圆的半径内有 68% 把握
"speed_mps": 4.2, // 瞬时速度(米/秒)
"bearing_deg": 137.0, // 朝向(0=正北,顺时针)
"provider": "gnss", // 定位来源:gnss / wifi / cell / fused
"event_ts": 1753900800000,
"server_ts": 1753900800210
}
GNSS(Global Navigation Satellite System,全球导航卫星系统):卫星定位系统的统称。GPS 是其中之一(美国的),此外还有中国的北斗、欧洲的 Galileo、俄罗斯的 GLONASS。手机上说的"GPS 定位"通常实际是多系统混合定位。本篇统一用 GNSS 指卫星定位,用 GPS 时特指美国那一套。
provider字段为什么重要:gnss是卫星定位,室外精度好(个位数米)但室内基本没有;wifi是靠周围 Wi-Fi 热点的指纹库推算,室内可用但精度几十米;cell是靠基站,精度几百米到几公里;fused是操作系统把几种融合后的结果。同一个字段lat/lng,来源不同,精度差两个数量级——不带provider和accuracy_m就落库,后面所有的分析都无法区分"骑手真的偏航了"和"进了商场信号变差了"。
// ② 轨迹(通常不是原始上报,而是加工产物)
{
"traj_id": "trip-9f2a...",
"subject_id": "rider-88231",
"order_id": 77001, // 关联的业务单据
"start_ts": 1753900800000,
"end_ts": 1753902300000,
"points": [ // ★ 有序点列
{"ts": 1753900800000, "lat": 39.9087, "lng": 116.3975},
{"ts": 1753900805000, "lat": 39.9089, "lng": 116.3981},
... // 5 秒一个点 → 25 分钟的单有 300 个点
],
"distance_m": 3420.5, // 派生字段:轨迹总长
"compress_algo": "dp_epsilon_5m" // ★ 压缩算法与参数(§4.6);不带这个就无法解释误差
}
// ③ 围栏(维表,变更频率低)
{
"fence_id": 5001,
"fence_type": "delivery_range", // 配送范围 / 商圈 / 行政区 / 禁停区
"owner_id": 30012, // 归属:哪个门店/哪个城市
"polygon": [ // ★ 顶点按顺序排列,首尾闭合
[116.390, 39.905], [116.405, 39.905],
[116.405, 39.915], [116.390, 39.915], [116.390, 39.905]
],
"valid_from": "2026-05-01", "valid_to": "9999-12-31" // ★ SCD Type 2(15 篇)
}
为什么围栏也要做 SCD Type 2:和 17 篇是同一个道理——门店的配送范围调整过,用今天的围栏去重算上个月的"超区订单率",得到的数字是错的。凡是会变、又会被用于重算历史的维度,都必须留版本。
位置数据天生的四个毛病
12 篇讲过埋点数据天生带四个病。位置数据在那四个之外,还多四个,而且每一个都会直接算错业务指标。
毛病一:坐标系不统一——这是中国境内最大的坑。
同一个物理位置,在三套坐标系里是三组不同的数字:
WGS84 卫星定位芯片输出的原始坐标,国际通用
GCJ-02 中国国家测绘局标准(俗称"火星坐标")。
中国境内的公开地图服务必须使用
BD-09 百度在 GCJ-02 基础上又做了一次偏移
可信度边界:GCJ-02 相对 WGS84 的偏移是非线性的、随位置变化的,公开资料普遍描述其量级在数百米。我没有核实任何具体的偏移数值或转换算法的精度,下文的"几百米"只用于说明量级,不要当作可引用的数字。 具体转换请用你所在平台既有的、已验证过的转换库,不要自己写。
它造成的错误极其隐蔽,因为数据看起来完全正常:
骑手 App 上报: WGS84 (39.9087, 116.3975)
门店位置入库: GCJ-02 (39.9101, 116.4037) ← 从地图服务抓的
直接算距离 → 得到几百米的"偏差"
后果:
· "骑手已到店"的判定失败 → 骑手在店里却判成没到
· 配送距离系统性偏大 → 配送费算错、成本核算错
· 围栏判定在边界处大面积出错
· 【而且没有任何报错】,所有数字都在合理范围内
工程对策只有一条,但必须严格执行:
coord_sys是必填字段,且入湖时统一归一化到一种坐标系。 归一化到哪一种取决于业务(对内计算建议统一到 WGS84,对外展示时再转),但绝不允许库里同时存在多种坐标系的数据而不标注。这条规则的地位等同于 12 篇的"时间戳必须带时区"。
毛病二:精度不是常数,且会突然变化。
室外空旷: accuracy_m ≈ 5 (GNSS)
城市峡谷: accuracy_m ≈ 30~50 (高楼反射造成多径效应)
室内商场: accuracy_m ≈ 50~150 (退化到 Wi-Fi)
地下车库: 【没有定位】 (直接断档)
后果:一条轨迹的中间会突然出现"瞬移"——不是骑手真的瞬移了,是定位源切换了。如果不看 accuracy_m 就直接算轨迹长度,会把这些跳变全算成里程。
毛病三:采样是离散的,中间发生了什么不知道。
t0 (39.900, 116.390)
← 这 5 秒里骑手走的是直线吗?
t1 (39.901, 116.392) 不知道。可能绕了个弯,可能等了个红灯。
所有基于轨迹的计算(里程、速度、是否偏航)
都建立在【两点之间走直线】这个【近似】上。
采样率越高越准,但数据量线性增长:5 秒一个点、300 万骑手在线、一天 10 小时 → 21.6 亿条/天(3,000,000 × 720 × 10)。这是 §4.6 轨迹压缩存在的理由。
毛病四:位置数据是敏感个人信息。
这不是技术毛病,但它是硬约束,会改变技术方案:
- 精确位置通常需要明示同意才能采集,且采集目的受限;
- 原始轨迹的留存期通常要设上限,到期必须删除或不可逆脱敏;
- 分析场景应尽量用降精度的数据(如 H3 网格 ID 而非原始经纬度)。
具体的合规要求随司法辖区、行业和数据类型不同而差异很大,我不提供任何合规结论。这里只说一条对架构的影响:"原始高精度位置"和"分析用降精度位置"应当是两套存储、两套权限、两套留存策略,而不是同一张表加个权限控制。因为留存期一到,前者要能整体删除,而后者要保留。
量级
【假设值】
在线骑手/司机 = 300 万
位置上报间隔 = 5 秒
日在线时长 = 10 小时
位置点: 300 万 × (3600/5) × 10 = 216 亿条/天
↑ 与 17 篇的曝光流(200 亿)同一量级
订单/行程: 3000 万/天
围栏: 百万级(全国所有门店的配送范围 + 商圈 + 行政区)—— 【小表】
注意这个组合:一张 216 亿行/天的超大点表,和一张百万行的小围栏表,要做"点在哪个面里"的关联。
后面全篇都要用这几个词。它们是 GIS 的标准术语,业界(PostGIS、Spark 的空间扩展、各类空间库)用的是同一套命名,所以值得一次性记住:
| 术语 | 含义 | 典型函数名 |
|---|---|---|
| Point / 点 | 一个 (经度, 纬度) | ST_Point(lng, lat) |
| Polygon / 多边形 | 一块闭合区域 | ST_GeomFromText('POLYGON((...))') |
| 点在面内 | 判断点是否落在多边形里 | ST_Contains(polygon, point) / ST_Within(point, polygon) |
| 相交 | 两个几何体有公共部分 | ST_Intersects(a, b) |
| 距离 | 两点间距离 | ST_Distance(a, b) |
| 缓冲区 / buffer | 以一个几何体为中心向外扩 N 米形成的新区域 | ST_Buffer(point, 2000) |
| 外接矩形 / bounding box | 完全包住某个几何体的最小正矩形 | ST_Envelope(geom) |
ST_前缀:来自 OGC(开放地理空间联盟)的 Simple Feature Access 标准,ST= Spatial Type。PostGIS、以及大多数支持空间类型的数据库都遵循这套命名,所以函数名是可迁移的。
WKT / WKB:同一个 OGC 标准里定义的几何体序列化格式。WKT(Well-Known Text)是文本形式,如
POINT(116.3975 39.9087)、POLYGON((116.39 39.905, 116.41 39.905, ...))——注意 WKT 里的顺序是**「经度 纬度」**,和我们平时说的"纬经度"相反,这是个常见的出错点。WKB(Well-Known Binary)是它的二进制形式,更省空间。§3.3 建表时的points_wkt/polygon_wkt字段存的就是 WKT 文本。
其中 bounding box 是全篇最重要的一个概念,因为几乎所有空间索引的第一步都是"先用外接矩形粗筛":
真实形状(一个不规则的配送范围) 它的 bounding box
╭──╮ ┌──────────┐
╱ ╲___ │ ╭──╮ │
│ ╲ │╱ ╲___ │
│ │ → ││ ╲│
╲ ╱ │ ╲ ╱│
╰──────╯ └──────────┘
外接矩形【一定】包含真实形状,但会多包一些不属于它的区域。
所以: 点不在 bbox 里 → 【一定】不在真实形状里(可以安全排除)
点在 bbox 里 → 【不一定】在真实形状里(还要再精确判一次)
【官方口径】 PostGIS 的官方教程明确说明了这个设计:空间索引"unable to index the geometric features themselves and instead index the bounding boxes of the features"(无法索引几何图形本身,因此索引的是图形的外接矩形);查询因此是两遍的——先用索引回答"哪些外接矩形与查询框相交"("which is very fast"),再"only for those features returned by the first test"做精确判定。教程还给了一个直观的数字:某个查询只用外接矩形得到 49821,用 ST_Intersects 精确判定得到 26718——外接矩形粗筛多出来的那一大截,正是它必须配一个精筛的原因。
这个"粗筛 + 精筛"的两阶段结构,和 16B 向量检索的"ANN 粗筛 + 精确重排"是同一个模式:用一个便宜的、只会多不会漏的近似,把候选集从"全量"缩到"一小把",再对这一小把做昂贵的精确计算。 两个完全不同的领域收敛到同一个结构,是因为它们面对的是同一个约束——精确计算太贵,但精确计算的必要条件很便宜。
指标
| 指标 | 定义 | 它为什么难算 |
|---|---|---|
| 配送时长 | 下单 → 送达 的时间 | 简单。但要拆成"商家出餐 / 骑手取货 / 在途"三段才有用,而分段的依据是空间判定(到店、离店) |
| 准时率 | 实际送达 ≤ 承诺时间 的占比 | 承诺时间本身是模型算的(ETA,Estimated Time of Arrival,预计到达时间),口径随模型版本变 → 需要 model_version(16B、17 篇同一模式) |
| 配送距离 | 骑手实际走的路程 | ★ 不是两点直线距离。见下文 |
| 单均骑手成本 | 骑手成本 ÷ 订单数 | 拼单会让这个指标失去可加性,见下文 |
| 运力供需比 | 某区域某时段的 (待派订单数 ÷ 在线骑手数) | ★ **"某区域"怎么定义?**这是 网格化的直接动机 |
| 超区率 | 送达点落在配送范围外的订单占比 | 依赖围栏判定 + 围栏的历史版本 |
两个指标值得展开,因为它们的坑很典型:
坑一:配送距离有三个口径,差异可达一倍。
① 直线距离(欧氏/球面): 起点到终点画一条直线
→ 【严重低估】。中间有河、有围墙、有单行道
② 路网距离: 按实际道路网络算最短路径
→ 更准,但【需要调用路径规划服务】,几十亿次调用不现实
③ 轨迹距离: 把骑手实际上报的轨迹点连起来累加
→ 最接近真实,但【受采样率和 GPS 噪声影响】
噪声会让距离【偏大】——每次跳变都被当成真实位移
结论: 三个口径必须【物化成三个不同的指标名】(15 篇 的规则),
对内成本核算用哪个、对外展示用哪个,必须写死在指标定义里。
坑二:拼单破坏了"单均成本"的可加性。
骑手一趟带 3 单,总成本 12 元。
单均成本 = 4 元?—— 只有在【按这一趟分摊】时成立。
但如果换一个切片方式,比如"按商家看单均成本":
3 单来自 3 个不同商家,每个商家分到多少?
· 按订单数均分? → 忽略了距离差异
· 按里程占比分摊? → 里程本身有三个口径(坑一)
· 按"如果单独送要多少钱"分摊?→ 这是个反事实,算不出来
结论: 单均成本在【拼单维度上不可加】。
这和 16A 的 DAU 不可加是同一类问题——
【一个指标只在它被定义的那个聚合层级上有意义】,
换一个层级不能靠简单的加减乘除推导。
建模:四张表
-- ① 位置点明细:216 亿/天,本篇所有存储难题的来源
CREATE TABLE dwd_location_point (
subject_id STRING,
subject_type STRING, -- rider / driver / user / vehicle
lat DOUBLE, lng DOUBLE, -- ★ 已归一化到统一坐标系(§2.3 毛病一)
coord_sys STRING, -- 归一化后恒为 'WGS84',但字段保留以便审计
accuracy_m DOUBLE, -- ★ 不带这个就无法区分"偏航"和"信号差"
provider STRING, -- gnss / wifi / cell / fused
speed_mps DOUBLE, bearing_deg DOUBLE,
geohash7 STRING, -- ★ 空间索引列之一(§4.2)
h3_r8 BIGINT, -- ★ 空间索引列之二(§4.4)
event_ts TIMESTAMP
)
PARTITIONED BY (dt STRING, hh STRING, city_id INT);
-- ↑ 时间分区 ↑ ↑ ★ 粗粒度空间分区
-- 为什么加 city_id 见 §4.1;为什么【不能】用 hash(lat,lng) 也见 §4.1
-- ② 轨迹表:一条记录 = 一段过程
CREATE TABLE dwd_trajectory (
traj_id STRING, subject_id STRING, order_id BIGINT,
start_ts TIMESTAMP, end_ts TIMESTAMP,
points_wkt STRING, -- 压缩后的点列(WKT 文本或二进制编码)
point_count INT,
distance_m DOUBLE,
compress_algo STRING, -- ★ 'dp_epsilon_5m':算法 + 参数
bbox_min_lat DOUBLE, bbox_max_lat DOUBLE, -- ★★ 外接矩形【物化成四列】
bbox_min_lng DOUBLE, bbox_max_lng DOUBLE -- §4.7 会讲这四列为什么关键
) PARTITIONED BY (dt STRING, city_id INT);
-- ③ 围栏维表:小表,但要留版本
CREATE TABLE dim_geofence (
fence_id BIGINT, fence_type STRING, owner_id BIGINT,
polygon_wkt STRING,
bbox_min_lat DOUBLE, bbox_max_lat DOUBLE, -- ★ 同样物化外接矩形
bbox_min_lng DOUBLE, bbox_max_lng DOUBLE,
h3_cover_r7 ARRAY<BIGINT>, -- ★ 覆盖这个围栏的 H3 网格列表(§4.5)
valid_from TIMESTAMP, valid_to TIMESTAMP -- ★ SCD Type 2
);
-- ④ 网格聚合表:给"供需比""热力图"这类查询用
CREATE TABLE dws_grid_supply_demand (
h3_r8 BIGINT, -- ★ 空间维度【降级成一个普通的分类维度】
time_slot TIMESTAMP, -- 5 分钟一档
city_id INT,
online_rider_cnt INT,
pending_order_cnt INT,
supply_demand_ratio DOUBLE
) PARTITIONED BY (dt STRING);
四个建模决策:
决策一:① 表上同时存 geohash7 和 h3_r8 两套网格 id。 不是冗余——§4.2 和 §4.4 会讲它们擅长的查询不同:Geohash 适合做前缀范围扫描(能直接被列存的 min/max 用上),H3 适合做邻域展开和聚合。两个各占 8 字节左右,相对于整行的成本可以接受。
决策二:② ③ 表上把 bounding box 物化成四个 DOUBLE 列,而不是只存几何体。 这是本篇唯一一个能让现有的列存机制直接生效的技巧——四个普通的 DOUBLE 列会被 Parquet 正常统计 min/max,于是空间过滤变成了四个普通的数值范围过滤,谓词下推(04 篇链条③)立刻可用。§4.7 展开。
决策三:④ 表把空间维度"降级"成一个 BIGINT 分类维度。 一旦位置被归到 H3 网格,h3_r8 就和 city_id、category_id 没有本质区别了——它是一个可以 GROUP BY 的普通维度。所有二维的麻烦都留在了①→④这一步转换里,④ 之后的分析回到熟悉的一维世界。
决策四:① 表按 city_id 分区,而不是按经纬度哈希。 下一节专门讲这个。
它把技术栈哪一处压到极限:数据布局与索引
前四篇压的是运行时:16A 压 Flink 的 state 大小与热点,16B 压元数据与对象存储的小文件,17 压长窗口 state 的生命周期。
这一篇压的是更靠下的一层——分区、排序、索引这三件事共同依赖的那个假设。
现有的所有裁剪手段,本质上都是同一句话的不同实现:
"把数据按某个 key 排好,查询给出 key 的一个范围,
于是不在范围内的整块数据可以【不读】"
· 分区裁剪: key = 分区列, 块 = 分区目录
· min/max 下推: key = 列值, 块 = row group
· B+ 树索引: key = 索引列, 块 = 叶子页
这句话成立的前提是: 【存在一个全序,且序上相邻 ≈ 语义上相近】。
一维数据天然满足。二维经纬度【不满足】。
接下来 的五个小节各解决一个具体查询:
| 要回答的查询 | 数据形态 | 现有机制为什么不管用 |
|---|---|---|
| "这个骑手周围 2 公里内有哪些待派订单" | 点 | 二维无全序 → 分区裁剪、min/max 全部失效 |
| "这 216 亿个点分别落在哪个围栏里" | 点 × 面 | 直接 join 是 216 亿 × 百万的笛卡尔积 |
| "存 216 亿个点,还要能按轨迹整体查" | 轨迹 | 一条记录里是个变长数组 |
| "空间过滤能不能被下推到存储层" | 全部 | 几何类型没有可比较的 min/max |