全文共 3,098 字 预计阅读 9 分钟
bg

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,来源不同,精度差两个数量级——不带 provideraccuracy_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);

四个建模决策:

决策一:① 表上同时存 geohash7h3_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_idcategory_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
Back to Blog