像 Google Maps 这样的地图平台跨极其不同的用例服务数十亿用户——逐向导航、商家发现、卫星影像、交通感知路由,以及 Uber 等应用的第三方集成。在 30 亿月活和 1000 万 API 伙伴下,它是现存最数据密集、最延迟敏感的系统之一。核心挑战不是存储世界路网——而是为数百万并发用户在数十毫秒内回答"此刻两点间最快路线是什么?",同时持续从数十亿位置 ping 集成新鲜交通数据。

⚡ 速览要点
  • Geohash 把 2D 编码为 1D——把 lat/lng 转成可排序字符串,在标准键值存储(BigTable/DynamoDB/Cassandra)上支持高效范围查询。
  • 预计算路线——Dijkstra 是 O((V+E)logV);3B 用户下太慢。存 section 节点间预计算路线;只有最后的微段实时计算。
  • Contraction Hierarchies——离线节点重要性排名让查询时双向 Dijkstra 跳过低重要性节点,相比朴素 Dijkstra 提速 1000×。
  • 导航用 WebSocket——双向持久连接;一个 WebSocket Manager 把用户 ID 映射到 server:port 做路由。
  • 位置 ping 至多一次——GPS 每 5s 发;丢一个包可接受,保证投递的开销不可。
  • CDC → Elasticsearch——地图变更流经 Kafka 使搜索索引最终一致。
  • 地图瓦片从 CDN 服务——按 zoom/x/y 为键的预渲染栅格瓦片;系统里最可缓存的对象。
tldr

预计算 section 节点间路线并存在 geohash 索引的键值存储(BigTable/DynamoDB/Cassandra)。用 Contraction Hierarchies 离线使查询时路由比朴素 Dijkstra 快 1000×。导航用 WebSocket。把用户位置 ping(每 5s,至多一次)喂进消息队列。瓦片从 CDN 服务。让 CDC 驱动的 Kafka 使 Elasticsearch 搜索索引与地图变更保持最新。把 ML 驱动的 ETA 精炼和热点预测推到离线。

地图 Web 应用高层架构
地图 Web 应用高层架构

第 1 步 — 澄清需求

地图平台看似宽泛。面试中把它界定成一个具体的面是一半的功劳。陈述你会覆盖什么并显式指出你不会的。

功能

非功能

面试提示

区分读路径(瓦片、路由、搜索)和写路径(地图编辑、实时交通摄取)。它们一致性要求差异很大,几乎能独立设计。早点指出这个拆分——面试官奖励它。

第 2 步 — 容量估算

Google Maps 服务大约 10 亿日活(DAU)。为三种主要流量类型——瓦片取、路线请求、位置 ping——做粗略估计。

地图瓦片流量

路线请求

位置 Ping(活跃导航)

地图数据存储

第 3 步 — API 设计

通过 API 网关暴露干净的 REST 端点。面试目的三个最重要的面是瓦片服务、路由和附近搜索。

HTTP
# 地图瓦片 — zoom/x/y 遵循标准 slippy map 方案
GET  /tiles/{zoom}/{x}/{y}.png
     → 200 image/png(从 CDN 边缘服务,TTL ~7d)

# 路线计算
POST /api/route
     { origin: {lat, lng}, destination: {lat, lng},
       mode: "driving"|"walking"|"transit",
       alternatives: true }
     → { routes: [{ polyline, distance_m, duration_s, eta }] }

# 附近搜索(半径米)
GET  /api/places/nearby?lat=37.7&lng=-122.4&radius=500&type=restaurant
     → { places: [{ place_id, name, lat, lng, rating }] }

# 地理编码(地址 → 坐标)
GET  /api/geocode?address=1600+Amphitheatre+Pkwy

# 反向地理编码(坐标 → 地址)
GET  /api/reverse-geocode?lat=37.42&lng=-122.08

# 导航会话(WebSocket 升级)
GET  /ws/navigate?route_id=abc123
     → WS 流: { type: "update", eta_s, next_maneuver, reroute? }

两个值得指出的设计选择:瓦片端点用 slippy map(XYZ)方案,使瓦片 URL 确定且最大可缓存——同一瓦片 URL 能全球缓存而无任何 query-string 变化。导航端点升级到 WebSocket,使服务器能推送 ETA 更新和重路由指令而无需客户端轮询。

第 4 步 — 地图数据:摄取与存储

初始地图数据来自政府市政地图(如 USGS、Ordnance Survey)、卫星影像提供商(Planet、Maxar)、街景影像车辆,和第三方地理数据提供商。持续更新经用户提交反馈、商家提交和持续卫星刷新到达。OpenStreetMap 志愿者大规模贡献更正。

所有地图修改经 Kafka 通过变更数据捕获(CDC)处理,使下游消费者——搜索索引器、路线预计算器、瓦片渲染器、分析服务——能异步反应地图变更而不把摄取路径耦合到下游写延迟。

数据模型:路网图

路网建模为有向加权图。每个交叉口是一个节点;两个交叉口间的每个道路段是一条有向边,属性包括距离、限速、平均行驶时间、道路等级(高速、主干、本地)和转向限制。

SQL
CREATE TABLE nodes (
  node_id       BIGINT PRIMARY KEY,
  lat           DOUBLE NOT NULL,
  lng           DOUBLE NOT NULL,
  geohash       VARCHAR(12),          -- 预计算,已索引
  node_type     VARCHAR(20)           -- intersection | endpoint | poi
);

CREATE TABLE edges (
  edge_id       BIGINT PRIMARY KEY,
  from_node     BIGINT REFERENCES nodes(node_id),
  to_node       BIGINT REFERENCES nodes(node_id),
  distance_m    INT   NOT NULL,
  speed_limit   SMALLINT,            -- km/h
  road_class    SMALLINT,            -- 0=高速 .. 5=本地
  base_weight   FLOAT,               -- 静态行驶时间秒
  current_weight FLOAT               -- 交通调整,~5 分钟刷新
);

CREATE TABLE places (
  place_id      BIGINT PRIMARY KEY,
  name          VARCHAR(255),
  lat           DOUBLE,
  lng           DOUBLE,
  geohash       VARCHAR(12),
  category      VARCHAR(64),
  rating        FLOAT,
  address       TEXT
);

第 5 步 — 空间索引:Geohash、Quadtree 与 S2

核心空间索引挑战是在数据库规模高效回答"给我这个边界框内的所有路网节点/地点"。三个互补结构驱动现代地图系统的地理空间查询:

Geohash

Geohash 把一个 2D (lat, lng) 坐标编码成一个 1D 可排序 Base32 字符串。每个额外字符大约把精度提升四倍:6 字符 geohash 覆盖 ~1.2 km × 0.6 km;9 字符哈希覆盖 ~5 m × 5 m。关键洞见是附近位置共享一个公共前缀——所以 geohash 列上的范围扫描取出空间上附近的记录而无任何特殊空间数据库扩展。这使它与任何支持高效前缀查询的键值存储(BigTable、DynamoDB、Cassandra)兼容。

pseudocode
-- 用 geohash 前缀的附近地点查询
FUNCTION nearby_places(lat, lng, radius_m):
  precision = geohash_precision_for_radius(radius_m)  -- 如 1km 用 6 字符
  center_hash = geohash_encode(lat, lng, precision)
  neighbor_hashes = geohash_neighbors(center_hash)    -- 8 个相邻 cell
  candidates = SELECT * FROM places
               WHERE geohash LIKE ANY(center_hash, neighbor_hashes)
  RETURN filter_by_haversine_distance(candidates, lat, lng, radius_m)

邻居查询很关键:搜索半径能跨 geohash cell 边界,所以你必须总查中心 cell 加它的 8 个直接邻居,然后对候选应用精确的 Haversine 距离过滤。

Quadtree

Quadtree 递归把地图细分成四个象限,直到每个叶 cell 含少于一个阈值数量的对象(通常 50–100)。它对动态密度自适应索引理想:一个乡村县和市中心曼哈顿最终落在很不同的细分深度。Quadtree 用于驱动缩放级别感知渲染——低缩放时,一个节点代表整个区域;高缩放时它解析到单条道路或建筑。Quadtree 通常保在瓦片服务器内存里,并周期性从底层数据库重建。

Google S2 Geometry

S2 把球面映射到一个立方体的面,然后用希尔伯特曲线索引产生一个空间填充的 1D cell 层级。像 geohash,S2 cell 能存在任何有序键值存储里;不像 geohash,S2 cell 每级别面积更均匀且在极点正确工作。Google Maps 内部依赖 S2 做许多空间操作——若被问到,值得作为 geohash 的生产级替代提及。

Quadtree 空间索引结构
Quadtree 空间索引结构

第 6 步 — 地图瓦片渲染与投递

地图瓦片是平台的视觉层。一个瓦片(tile)是一个 256×256(retina 上 512×512)图像,代表特定缩放级别的固定地理边界框。slippy map XYZ 方案定义瓦片坐标:缩放级别 z、列 x、行 y——使每个瓦片 URL 全球唯一且确定,这是支持激进 CDN 缓存的关键属性。

瓦片生成管道

为什么瓦片胜过矢量流

服务预渲染栅格瓦片是地图系统里杠杆最高的缓存决策。市中心曼哈顿的一个瓦片每天被请求数百万次——渲染一次、处处缓存。矢量瓦片流式(发原始地理数据做客户端渲染)减少带宽并支持动态样式,但代价是客户端计算和降低的可缓存性。Google Maps 用混合:高缩放级别(街道细节)用矢量瓦片、低缩放用栅格。

第 7 步 — 路由算法深入

标准图遍历(BFS)对路由不工作,因为道路段有可变权重(距离、限速、交通)。单源最短路径的正确算法是 Dijkstra(或带启发式更快收敛的 A*)。然而,查询时跨完整全球路网跑 Dijkstra 有 O((V+E)logV) 时间复杂度——对数十亿并发用户太昂贵。生产答案是一个分层预计算策略。

算法对比

算法复杂度用例结论
BFSO(V+E)无权图错——忽略道路权重
DijkstraO((V+E)logV)单源最短路径正确但全球规模太慢
A*O((V+E)logV) 最佳情况更快启发式引导的单对本地路由好,非全球
Floyd-WarshallO(V³)全对最短路径仅小子图的离线预计算
Contraction HierarchiesO(V log V) 预处理;O(√V) 查询路网生产选择——比 Dijkstra 快 1000×

Contraction Hierarchies (CH)

Contraction Hierarchies 是生产地图路由的主导算法。核心想法:离线给每个节点分配一个重要性排名(基于边差、所需 shortcut 数和道路等级),然后迭代"收缩"最不重要的节点,把它们的关联边对替换为保留最短路径距离的直接 shortcut 边。预处理产生一个高速节点有最高排名的分层图。

查询时,双向 Dijkstra 同时从源和目的运行,但每个搜索只松弛在层级里向上的边——完全跳过低重要性节点。这个修剪把搜索空间从数百万节点减到数千,在大陆级路网上交付 10 ms 内的查询时间。

pseudocode
-- CH 双向查询(简化)
FUNCTION ch_route(source, target):
  forward_dist  = {source: 0, 其他全部: ∞}
  backward_dist = {target: 0, 其他全部: ∞}
  forward_pq    = MinHeap([(0, source)])
  backward_pq   = MinHeap([(0, target)])
  best = ∞

  WHILE 任一队列非空:
    -- 从试探距离较小的方向扩展
    IF forward_pq.min() <= backward_pq.min():
      (d, u) = forward_pq.pop()
      FOR edge (u → v) WHERE rank(v) > rank(u):  -- 仅向上
        IF forward_dist[u] + w(u,v) < forward_dist[v]:
          forward_dist[v] = forward_dist[u] + w(u,v)
          forward_pq.push((forward_dist[v], v))
        best = min(best, forward_dist[v] + backward_dist[v])
    ELSE:
      -- 对称向后扩展
      ...
  RETURN best

预计算策略

除 CH 外,系统也在地理层面用存储换速度:采样的 section 边界节点(想:高速 section 的上下匝道,或城区的街区入口点)间的路线被完全预计算并存在键值路由存储里。导航随后解析为跨 section 的表查找,而非对大部分路线的实时图遍历。只有第一和最后的微段(街区级)需要实时 Dijkstra/A*。

当一个交通事件造成显著 ETA 变化,影响递归地向上冒泡穿过 section 层级,只更新受影响的路线段而非全局重算。本地路上的一个变化更新那条路的 section 缓存;只有 section 的最佳路径改变时更新才传播到下一 section 层。

第 8 步 — 实时导航

导航会话需要服务器和客户端间双向、低延迟通信——WebSocket 是这里正确的协议。一个 WebSocket Manager 维护已连接用户到其 WebSocket 服务器和端口的键值映射,使导航跟踪服务能触达任何活跃会话,无论哪个 WebSocket 服务器托管它。

三个事件触发导航更新:

flow
用户开始导航
  → 导航服务分配 route_id,打开 WS 连接
  → WebSocket Manager 记录: user_id → ws_server:port

每 5 秒:
  客户端发: { user_id, lat, lng, speed, heading }
    → 位置队列(Kafka,至多一次)
      ├── 交通聚合器: 更新道路段权重
      └── 导航跟踪器: 检测偏离 / ETA 变化

检测到偏离时:
  导航跟踪器 → 路由服务(从新位置 CH 查询)
    → 结果经 WebSocket Manager 推回 → 客户端 WS
      { type: "reroute", new_polyline, new_eta_s }

第 9 步 — 用户位置跟踪

活跃用户每 5 秒传输他们的 GPS 位置(启用位置共享时)。这些包流入一个把生产者与消费者解耦的 Kafka 消息队列。投递保证是至多一次:单个丢失的位置包可接受,因为下一个 5 秒后到达,且保证投递的成本(重传、ACK、去重)对此用例远超好处。

位置流并行服务多个下游消费者:

交通数据融合

原始 GPS ping 有噪声且稀疏。交通聚合器应用一个地图匹配(map-matching)步骤:用隐马尔可夫模型把原始 GPS 坐标投影到最可能的道路段(GPS 读数是真实路网位置的"噪声观测")。匹配的读数随后按道路段在 5 分钟窗口上平均以产生可靠的当前行驶时间。这些平均被写回 edges 表的 current_weight 字段,Contraction Hierarchy 查询引擎在查询时读它。

第 10 步 — 搜索与发现

地点名搜索由一个支持模糊匹配和 type-ahead 的 Elasticsearch 集群驱动。索引从两个流填充:直接地点名数据和地图 CDC 事件。地图浏览以适应缩放级别的优先级过滤显示节点——缩小只显主要地标;放大揭示细粒度细节。

地理编码管道

地理编码(地址字符串 → lat/lng)是一个多阶段 NLP 管道:

  1. 解析——把地址分词成组件(门牌号、街道、城市、邮编、国家)。
  2. 规范化——"St." → "Street"、"Ave" → "Avenue";处理缩写和国际格式。
  3. 候选检索——用模糊匹配查 Elasticsearch 匹配街道 + 城市组合以处理错别字。
  4. 排序——按匹配质量、热度(这个地址被查多频繁)和地理上下文(偏好靠近用户当前位置的结果)给候选评分。
  5. 插值——沿匹配街道段线性插值导出门牌号位置。

反向地理编码(lat/lng → 地址)反向跑管道:用 geohash 查找找最近道路段,然后经插值确定最近地址。

第 11 步 — 扩展:缓存、分片与 CDN

瓦片缓存

地图瓦片是系统里最可缓存的对象。缓存层级从客户端向外工作:

路由缓存

预计算的 section 到 section 路线结果存在分布式键值存储(Redis 集群或 BigTable),按 (section_node_a, section_node_b, mode) 为键。从旧金山到洛杉矶的路线请求解析为少数 section 查找而非实时 CH 查询。当交通数据使给定 section 对的最短路径成本变化超过阈值,缓存失效。

数据库分片

第 12 步 — 容错

第 13 步 — ML 与分析服务

分析服务消费位置和行为数据以驱动几个高级功能:

关键取舍

决策选择接受的取舍
路线计算预计算 + CH + 存储大存储和预处理成本;路线在交通缓存刷新前略陈旧(~5 分钟)
位置投递至多一次(Kafka)单个丢失的 5 秒 ping 影响可忽略;省大量基础设施复杂度
空间索引Geohash(主)+ quadtree(渲染)Geohash cell 边界边界情况需查 9 个邻居 cell;需小的后过滤
地图数据一致性CDC 最终一致新路可能需 30–60 秒出现在搜索/路由;对地图数据可接受
瓦片策略预渲染栅格瓦片地图变化时重渲染成本;比矢量瓦片样式更不灵活
交通新鲜度5 分钟聚合窗口实时速度不如每秒准;防止边权上的写放大
总结

地图系统生死取决于它们的空间索引和预计算策略。Geohash 把 2D 地理桥接到 1D 键值查找;Contraction Hierarchies 以离线预处理为代价使路由查询比 Dijkstra 快 1000×;预渲染瓦片使系统里最可缓存的对象近乎免费服务。其他一切——CDC 管道、WebSocket 导航、至多一次位置 ping、交通融合——都关于让这些预计算答案保持最新并快速交付。

🎯 面试速答

为什么不为每个路线请求实时跑 Dijkstra?跨全球路网 O((V+E)logV) 在 3B 用户下不可行——用 Contraction Hierarchies 离线预处理一个层级,然后查询时双向 Dijkstra 只探索一小部分节点并在 <10 ms 返回。
为什么 geohash 胜过纯 quadtree?Geohash 把 2D 坐标编码成 1D 可排序字符串,在标准键值存储上支持高效范围查询而无需特殊空间索引支持。Quadtree 对缩放自适应渲染密度更好。
为什么位置 ping 用至多一次?单个丢失的 5 秒 GPS 包对导航质量影响可忽略;保证投递的开销(重试、ACK、去重)在 10M 写/秒远超好处。
交通变化时怎么处理陈旧路由?交通聚合器在 5 分钟窗口刷新道路段权重;当最短路径成本变化超过阈值时预计算 section 缓存失效,只为受影响 section 对触发定向重算。
地图瓦片怎么保持快?XYZ slippy map 方案使每个瓦片 URL 确定,使热门缩放级别近 100% CDN 命中率。只有缓存未命中碰源对象存储。

← 上一篇
设计电商平台