在本章中,我们将要设计一个简版的谷歌地图。在我们进行系统设计之前,让我们先了解一下谷歌地图的基本信息。谷歌在2005年启动了"谷歌地图项目",并完成了一个网页版的网络地图服务。它支持了许多功能,例如卫星图像、街道地图、实时交通状况以及路线规划。
谷歌地图可以帮助用户找到合适的路线并导航到目的地。截至2021年3月,谷歌地图每天有十亿的活跃用户,覆盖全球99%的区域,并以每天2500万次的更新频率来确保位置的准确性和实时性。鉴于谷歌地图的复杂性,确定我们要支持的功能非常重要。请注意,本章中使用的地图切片来自Stamen Design,数据来自OpenStreetMap。
第一步 - 理解问题并确定设计范围
面试官和候选人之间的交流可能是这样的:
候选人:我们预期的每日活跃用户有多少?
面试官:10亿DAU。候选人:我们应该关注哪些功能?方向、导航和预计到达时间(ETA)?
面试官:让我们专注于位置更新、导航、ETA和地图渲染。候选人:道路数据有多大?我们可以假设我们有访问权限吗?
面试官:好问题。是的,让我们假设我们从不同的来源获得了道路数据。这是TB级别的原始数据。候选人:我们的系统是否应该考虑交通状况?
面试官:是的,交通状况对于准确的时间估算非常重要。候选人:那么不同的出行方式,比如开车、步行、公交等等?
面试官:我们应该能够支持不同的出行方式。候选人:是否支持多站点导航?
面试官:允许用户定义多个停靠点是好的,但我们不用关注。候选人:商业场所和照片呢?我们预期有多少照片?
面试官:我很高兴你提出并考虑了这些。我们不需要设计这些。
在本章的剩余部分,我们专注于三个关键功能。我们需要支持的主要设备是手机。
- 用户位置更新
- 导航服务,需要考虑预计到达时间(ETA)
- 地图渲染
非功能性需求和约束
- 准确性:用户不应该得到错误的指引
- 平滑导航:在用户侧,用户应该体验到非常丝滑的地图渲染
- 数据和电池使用:客户端应该尽可能少地使用数据和电池。这对于移动设备非常重要
- 通用的可用性和可扩展性要求
在深入设计之前,我们将简要介绍一些关于谷歌地图的基本概念和术语。
地图基础概念
定位系统
地球是一个绕其自转轴旋转的球体。最顶端是北极,最底部是南极。
纬度(Lat):表示我们南北方向的位置 经度(Long):表示我们东西方向的位置 |
---|
从3D投影到2D
将地球上的一个点从3D空间转换到2D平面的过程称为“地图投影”(Map Projection)。现在有多种不同的投影方式,每种方式都有其自身的优势和局限性。其中几乎所有的方式都会扭曲实际的几何形状,我们可以结合下图看一下示例。
谷歌地图选择了一种在Mercator基础上改进的方案"Web Mercator"。
地理编码(Geocoding)
地理编码是将生活中的地址转换为“地理坐标”的过程。例如,“1600 Amphitheatre Parkway, Mountain View, CA” 被地理编码为一对经纬度信息(纬度37.423021, 经度-122.083739)。
以相反的角度来看,从一对经纬度转换为实际人类可读的地址,称为 "反向编码"。
地理编码的一种方法是“interpolation”。这种方法利用了来自不同来源的数据,例如把街道网络映射到地理坐标空间的地理信息系统(Geographic Information Systems, GIS)。
地理哈希(Geohashing)
Geohashing将地理区域编码为一串简短的字母和数字,它的核心思路是将地球表示为一个二维平面,并递归地将平面中的网格划分为多个子网格,这些子网格可以是正方形或矩形。对于每一个网格而言,我们都可以使用0~3之间的数字递归的表示为一个数字串。
让我们假设最初的平面大小为 20000 km * 10000 km。经过第一次划分后,我们将拥有4个大小为10000km5000km的网格。我们按照图3所示将它们表示为00、01、10和11。随后,我们进一步将每个子网格划分为4个网格,并使用相同的命名策略。现在每个最小子网格的大小为 5000km2500km。我们按照该策略不停递归地划分网格,直到每个子网格都符合预期的阈值。
Geohashing 有许多实际的作用,在我们本次的设计中,我们使用Geohashing进行地图切片。
地图渲染
我们本文不会详细地介绍地图渲染的实现过程,但大家还是需要了解一下地图渲染的基础信息。地图渲染的一个基本概念是切片(tiling),在我们实际的设计中,整个地图不会被当成一个大的自定义图像进行渲染,而是把世界划分多个更小的切片。客户端只下载用户所在区域的相关切片,并将它们像马赛克一样把多个小切片拼接在一起进行整体的显示。
用户可以在客户端查看不同缩放级别的地图,这些级别下会有不同粒度的切片。客户端会根据用户当前地图视口的缩放级别选择合适的切片,这可以提供适当的地图细节并且不会消耗过多的带宽。举个例子,当用户要在客户端查看整个地球的地图,我们不希望客户端下载成千上万的高缩放级别的切片,因为所有细节都将被浪费。相反,客户端最好只下载一个最低缩放级别的切片,该切片用单个 256x256 像素的图像就可以表示整个世界。
导航算法的道路数据处理
大多数路由算法都是 Dijkstra 或 A* 寻路算法的变体。算法的具体选择是一个复杂的话题,我们在本文不会深入讨论。值得一提的一点是,这些算法都是在一个图结构上运行,如图所示,路的交叉口是图中的点,路本身是图的边。
这些算法的寻路性能表现通常对图的大小极其敏感。用单个图表示整个世界的道路网络会消耗过多内存,并且可能因为数据量太大而导致算法无法有效运行。为了这些算法可以在我们的设计中有效,我们需要将图分解成更小的可管理的单元。
我们之前在地图渲染中介绍过切片概念,一种类似的拆分方式就是把整个世界的道路网格切片化。我们可以通过类似于Geohashing的拆分技术将世界划分为多个小网格,每个小网格都可以转换成一个小的图结构,该结构由网格区域内的节点(intersections)和边(roads)组成。我们把这些小网格称为路由切片(routing tiles)。每个路由切片持有它连接到的所有其他切片的引用。路由算法可以通过遍历这些相互连接的路由切片,进而拼接成更大的道路图。
将道路网络分解为按需加载的路由切片之后,一次地图寻路只消耗一小部分的路由切片,且仅在需要时动态加载其他切片,因此路由算法可以显著降低内存消耗,从而提高路径寻找性能。
在上图中,我们把这些小网格称为路由切片。路由切片与地图切片类似,都是覆盖特定地理区域的网格。但是地图切片是PNG图片,而路由切片是包含切片所覆盖区域的道路数据的二进制文件。 |
---|
分层路由切片
高效的导航也需要具备不同细节信息的道路数据。例如,对于跨国导航,使用非常详细的街道级路由切片会导致路由算法很慢,此外,这些高精度的路由切片拼接到一起可能会消耗太多的内存。
一般情况下,会有三套具有不同细节信息的路由切片。在最细粒度的级别,路由切片很小,只包含本地道路。在第二个级别下,切片会更大一些,只包含连接不同地区的干道。最低精度的级别,切片覆盖更大片的区域,只包含城市和州之间的主要高速公路。在每个粒度下,都会有符合当前粒度的连接不同切片的边。例如,从本地街道A到高速公路 F 的入口,小切片中的节点(街道A)到大切片中的节点(高速公路F)会有一个引用。下图给出了不同粒度的路由切片的示例。
成本粗估
现在我们已经了解了地图的基础概念,让我们进行一次粗略的估算。由于设计的重点是手机移动端,数据使用量和电池消耗是需要考虑的两个重要因素。
在我们深入的估算之前,先提供一些英制/公制转换供参考:
- 1英尺( foot ) = 0.3048米
- 1公里( km ) = 0.6214英里
- 1 km = 1,000米
存储空间
我们需要存储三种类型的数据。
- 世界地图:下表展示了一个详细结果。
- 元数据:鉴于每个地图切片的元数据大小可以忽略不计,我们可以在我们的计算中跳过元数据。
- 道路信息:面试官告诉我们,道路数据有TB级别。我们将这个数据集转换成路由切片,这也可能是TB级别的大小。
世界地图
我们在「地图基础概念」部分已经介绍了地图切片的概念。每一个缩放级别下,都有一系列的地图切片集合。为了评估整个地图切片的图像集合的存储空间,先估算最高缩放级别下最大切片集的大小会更加简单一些。在缩放级别21下,大约有4.3万亿切片(参考下表)。假设每个切片是一个256x256像素的PNG图像,每个图像大小约为100 KB,那么缩放级别21下的整个集合将消耗大约4.4万亿x100 KB = 440PB。
在下中,我们展示了每个缩放级别下的切片数量。
缩放级别 | 切片数量 |
---|---|
0 | 1 |
1 | 4 |
2 | 16 |
3 | 64 |
4 | 256 |
5 | 1 024 |
6 | 4 096 |
7 | 16 384 |
8 | 65 536 |
9 | 262 144 |
10 | 1 048 576 |
11 | 4 194 304 |
12 | 16 777 216 |
13 | 67 108 864 |
14 | 268 435 456 |
15 | 1 073 741 824 |
16 | 4 294 967 296 |
17 | 17 179 869 184 |
18 | 68 719 476 736 |
19 | 274 877 906 944 |
20 | 1 099 511 627 776 |
21 | 4 398 046 511 104 |
然而,世界上约90%的表面是少有人居住的区域,如海洋、沙漠、湖泊和山脉。这些区域的图片可以被高度压缩,因此我们可以保守地将存储空间缩减80-90%。这意味着存储空间将减少到44到88 PB的范围。让我们简单选择整数50 PB进行后面的设计。
接下来,让我们预估每个较低缩放级别的存储空间。在每个较低的缩放级别,南北和东西方向的切片数量都会减半,切片的总量会减少4倍,对应的存储大小也将减少4倍。随着每个较低缩放级别的存储大小都减少4倍,总大小的数学计算变化为一个序列:50 + 50/4 + 50/16 + 50/64 + … = ~67 PB。这只是一个粗略的估计,但对我们而言,已经知道所有的地图切片存储需要大约100 PB的存储空间。
服务器吞吐量
为了预估服务器的吞吐量,我们首先需要明确支持的功能接口,接口大致分为两类:
- 第一类是导航请求。这些由用户在客户端发起,以启动导航功能。
- 第二类是位置更新请求。这些是由客户端在导航期间,用户移动时发送的。用户位置数据可以被下游服务广泛应用于多种场景。例如,位置数据是是实时交通数据的一个重要信息来源。我们将在后续部分深入探讨位置数据的使用情况。
现在我们可以分析导航请求的服务器吞吐量。假设我们有10亿日活,每个用户每周使用导航的时长约为35分钟,这相当于每周需要处理的总时长为350亿分钟或每天50亿分钟。
我们需要记录用户使用导航过程中的位置信息,一个简单的方法是每秒都发送GPS坐标,这样的话每天会有3000亿次(50亿分钟*60)请求,平均下来每秒有300万次的请求(3000亿/10^5 = 300万)。但是,客户端每秒发送一次GPS位置的更新不是必要的。我们可以在客户端批量处理这些位置信息,并以更低的频率(例如,每15秒或30秒)发起请求,从而减少写入的QPS。现实场景中,位置更新的频率可能取决于用户移动的速度。比如用户出现了堵车,客户端可以稍微降低GPS位置的更新频率。在我们的设计中,我们假设GPS更新是批量的,然后每15秒发送到服务器。通过这种方法,QPS将会减少到20万(300万/15)。
我们进一步假设峰值QPS是平均流量的五倍,那么位置更新的峰值QPS 将会达到 20万*5 = 100万。
第二步 - 高层设计
现在我们已经对Google Maps有了更多了解,那么让我们先提出一个高层的设计。
高层设计
高层设计主要支持三个功能。接下来让我们逐一介绍:
- 位置服务 - Location Service
- 导航服务 - Navigation Service
- 地图渲染 - Map rendering
位置服务
位置服务负责记录用户的位置更新,架构如下图所示。
我们基础的设计需要客户端每 t 秒发送一次位置更新请求,其中 t 是可配置的间隔。定期更新位置会有几个好处:
- 首先,我们可以基于位置和时间来优化我们的系统。例如,我们可以使用这些数据来监控实时交通、检测新道路或关闭已有道路、分析用户行为便于实现个性化等。
- 其次,我们可以基于位置的更新数据,在近实时的情况下为用户提供更准确的预计到达时间(ETA),并在必要时规划路线绕过拥堵路段。
那么我们真的需要将每个位置更新的请求都立即发送到服务端吗?答案可能不是。位置历史记录可以在客户端缓存,并以更低的频率批量发送到服务端。如下图所示,客户端每秒钟缓存一次位置,每隔15秒批量请求服务端更新一次,这将大大减少了所有客户端发送的总吞吐量。
对于谷歌地图这样的系统,即使位置更新是批量请求的,写入量仍然非常高。因此,我们需要一个适合高写入量、支持高度可扩展性的数据库,例如Cassandra。我们可能也需要依赖Kafka这样的流处理引擎作进一步的处理。我们将在后续章节详细讨论这一点。
这里适合使用哪种通信协议?具有 keep-alive 选项的 HTTP 是一个很好的选择。下面给出了一个可供参考的HTTP请求信息。
POST /v1/locations
Parameters
locs: JSON encoded array of (latitude, longitude, timestamp) tuples.
导航服务
这个模块主要负责在点A到点B之间找到一条相对快速的合理路线。在这个场景下,我们可以容忍一点延迟,返回导航路线的时间不必非常快,但要确保结果的准确性。
用户通过LB发送HTTP请求,请求的参数包括起点和终点信息。API的示例如下所示:
GET /v1/nav?origin=1355+market+street,SF&destination=Disneyland
返回的导航结果的示例如下:
{
"distance": {"text":"0.2 mi", "value": 259},
"duration": {"text": "1 min", "value": 83},
"end_location": {"lat": 37.4038943, "Ing": -121.9410454},
"html_instructions": "Head <b>northeast</b> on <b>Brandon St</b> toward <b>Lumin Way</b><div style="font-size:0.9em">Restricted usage road</div>",
"polyline": {"points": "_fhcFjbhgVuAwDsCal"},
"start_location": {"lat": 37.4027165, "lng": -121.9435809},
"geocoded_waypoints": [
{
"geocoder_status" : "OK",
"partial_match" : true,
"place_id" : "ChIJwZNMti1fawwRO2aVVVX2yKg",
"types" : [ "locality", "political" ]
},
{
"geocoder_status" : "OK",
"partial_match" : true,
"place_id" : "ChIJ3aPgQGtXawwRLYeiBMUi7bM",
"types" : [ "locality", "political" ]
}
],
"travel_mode": "DRIVING"
}
到目前为止,我们还没有考虑导航路线和交通情况的变化。这些问题将后续讨论ETA的部分阐述。
地图渲染
我们前面已经讲到,各个缩放级别的所有地图切片集合共需要100PB的存储空间。客户端存储整个数据集是不现实的,必须基于用户位置和缩放级别从服务器获取相应的切片数据。
那么客户端拉取地图切片的时机包含哪些?下面给出了几个示例:
- 用户在客户端上缩放或平移地图视图来探索周边
- 导航期间,用户移动到当前地图切片之外的相邻切片
我们需要处理大量的数据,接下来让我们关注怎么有效地从服务端获取这些切片信息。
策略1 - 动态生成
主要思路是服务端根据用户的位置和缩放级别,动态构建地图切片。鉴于无限多的位置和缩放级别的组合,动态生成有一些明显的缺陷:
- 给服务器集群带来了巨大的负载
- 由于地图切片是动态生成的,因此很难使用缓存
策略2 - 预生成
另一个策略是为每个缩放级别都提供一组预生成的地图切片。切片是静态的,每个切片覆盖一个固定的矩形网格区域,并且都使用像geohashing这样的拆分方案。因此,每个切片都可以通过geohash唯一表示。当客户端需要一个地图切片时,会先根据用户的缩放级别确定要使用的地图切片集,然后,通过将用户当前位置转换为适当缩放级别下的geohash来计算匹配切片的静态 url 链接。
这些静态的预生成切片会保存在CDN上,如下图所示。
上图中,用户端向CDN发出HTTP请求以获取切片。如果CDN以前还没有存储过特定的切片资源,它将从源服务器获取资源数据,并将其在cdn本地缓存,随后将切片信息返回给用户。在后续各种不同用户的请求中,只要请求刚才缓存的切片资源,都会返回cdn缓存的资源而无需再次请求源服务器。
这种方案具备更好的可扩展性和性能,一方面,地图切片的静态属性意味着切片数据可缓存,另一方面,如下图所示,切片数据会从距离用户最近的CDN接入点 (Point Of Presence, POP) 获取,用户的使用体验也将得到加强。
保持用户的低流量消耗是很重要的,让我们粗略计算下在典型的导航场景下需要使用的数据量。请注意,以下的计算过程没有考虑客户端缓存,由于用户每天的路线可能相似,因此客户端缓存数据之后,用户使用的流量可能会大大降低。
假设用户以 30km/h 的速度移动,在单张切片覆盖 200m**200m 的缩放级别下 (一个区块可以用256px ** 256px 的图像表示,平均图像大小为100KB)。对于1km*1km 大小的区域,我们需要25张切片,这意味着需要 2.5MB (25 * 100KB) 的数据量级。因此,如果速度是 30km/h,我们每小时需要75MB (30 * 2.5MB) 的数据,或者每分钟 1.25MB 的数据 |
---|
接下来,让我们预估下CDN的存储使用量级。在我们的用户规模下,成本是一个重要的考虑因素。
如前文所述,我们每天提供50亿分钟的导航时长。这转换为 50亿 * 1.25 MB = 每天62.5亿 MB。因此,平均每秒需要提供 62500MB (62.5亿 / 10^5s) 的地图数据。接入CDN之后,这些图像将从世界各地的POP节点输出。假设有200个POP,每个POP平均每秒只需要提供 312.5 M (62500 / 200)。 |
---|
还有一个重要问题没有提及,客户端怎么知道地图切片的 url ?首先,我们使用上面的策略2来加载切片。在这个策略下,每一个缩放级别用固定的切片集来表示,每个图片都是一个静态资源,并且基于固定的网格位置生成。
我们使用geohash的编码方式,这会保证每个网格都有一个唯一的geohash。基于用户当前位置(纬度和经度)和缩放级别,映射到地图切片的geohash是非常高效且容易计算的,这部分工作可以在客户端完成。得到切片的url路径之后,客户端可以从CDN上获取任何静态的切片资源,如谷歌总部的图像切片的URL cdn.map-provider.com/tiles/9q9hv…。
在客户端计算geohash效果很好,但是,在不同平台的所有客户端中这些hash算法都是硬编码写死的。更改客户端的逻辑是一个耗时且有些风险的操作,我们必须确保hash算法是我们计划中长期使用的方法,并且不太可能改变。如果由于某种原因我们需要切换到另一种编码方法,这将需要大量的工作,风险也会很高。
这里有另一个值得考虑的选择。为了解决客户端硬编码hash算法的问题,我们可以引入一个服务作为中介,它的主要功能是基于缩放级别和当前位置构造切片的URL,是一个非常轻量级的服务。我们可以和面试官讨论这个有意思的话题,这种方式的工作流如下图所示。
当用户移动到新位置或使用新的缩放级别时,地图切片服务(Map Tile Service, MTS)确定需要哪些切片资源,并返回一组切片URL。
- 用户调用MTS来获取切片URL,请求被发送到负载均衡器
- 负载均衡器将请求转发给MTS
- MTS将 客户端的位置和缩放级别作为输入,返回9个切片的URL。这些切片包括要渲染的切片和八个相邻的切片
- 移动客户端从CDN下载切片资源。
我们将在后续设计部分更详细的讨论切片的预计算。
第三步 - 深入设计
在这一节中,我们首先讨论数据模型,再更详细地讨论位置服务、导航服务和地图渲染。
数据领域
我们处理四种类型的数据:路由切片、用户位置数据、地理编码数据和预计算的地图切片。
路由切片
如前文所述,我们从不同的来源和权威机构获取了原始的几TB的道路数据集。用户不断地使用地图程序,过程中程序会不断收集用户的位置数据,因此数据集也会随着时间的推移而得到进一步的完善。
这个数据集包含大量的道路信息及其相关元数据,如名称、县、经度和纬度。这些信息不是图结构,所以大多数的路由算法无法使用。我们通过一个定期离线任务,将这个数据集转换成我们前面介绍的路由切片。此外,该服务还会定期运行以获取道路数据的新变化,我们将这个服务称为“路由切片处理服务”。
路由切片处理服务的输出是路由切片。如前文所述,有三组不同分辨率的切片,每个切片包含一个图节点和多个边,代表切片覆盖区域内的交叉口和道路。它还包含其道路连接到的其他切片的引用。这些切片共同形成一个可以被路由算法使用的相互连接的道路网络。
路由切片处理服务应该将这些切片存储在哪里?大多数图数据在内存中表示为邻接列表。所有的邻接列表都放在内存中切片太多了。我们可以将节点和边作为数据库中的行存储,但我们只是使用数据库作为存储,这似乎是一种昂贵的存储数据位的方式。我们也不需要数据库功能来处理路由切片。
应该在哪里存储这些切片?大多数图数据在内存中表示为邻接列表,但是由于切片过多,无法在服务内存中保留整个邻接列表的完整集合。一种简单方式是我们将节点和边存储在数据库中作为一个数据行,但我们仅仅只是把它当作存储,不需要任何其他的数据库功能,这似乎是存储数据的一种昂贵方式。
更有效的存储方式是使用像S3这样的对象存储,并在路由服务中尽量缓存这些切片。我们可以使用很多高性能的软件程序将邻接列表序列化为二进制文件,再关联这些切片的geohash值,从而可以将其保存在对象存储中。这也提供了一个通过经纬度对来定位切片的快速查找机制。
我们接下来将很快讨论最短路径服务如何使用这些路由切片。
用户位置数据
用户的位置数据很有价值。我们利用它来更新我们的道路数据及路由切片。此外,位置数据不仅可以用来构建实时和历史的交通数据,也可以被多个数据流处理服务消费用于更新地图数据。
对于用户位置数据,我们需要一个能够很好地处理写密集型且可以水平扩展的数据库。Cassandra可能是一个不错的选择。下面是数据的单行示例:
user_id | timestamp | user_mode | driving_mode | location |
---|---|---|---|---|
101 | 1635740977 | active | driving | (20.0, 30.5) |
地理编码数据库
这个数据库存储实际的地点及其对应的经纬度。我们有频繁的读操作和不频繁的写操作,因此我们可以使用诸如Redis这样的键值数据库进行快速读取。我们使用时会将起点或终点转换为经纬度对,再传入给路线导航服务。
预计算的世界地图图像
当请求特定区域的地图信息时,我们需要获取附近道路并计算一个代表该区域及其所有道路和相关细节的图像。这些计算将是繁重和重复的,所以一次性计算然后缓存图像可能会很有帮助。我们在不同的缩放级别上预先计算图像,并将它们存储在CDN上,CDN由像Amazon S3这样的云存储支持。以下是这样一个图像的示例:
服务
我们在上面已经讨论了数据领域,让我们仔细看看一些最重要的服务:位置服务、地图渲染服务和导航服务。
位置服务
在高层设计中,我们讨论了位置服务的工作原理。在这一部分,我们重点关注这项服务的数据库设计以及用户位置的详细使用方法。
在下图中,我们用KV存储来保存用户位置信息。
考虑到我们每秒有100万个位置更新请求,我们需要一个支持快速写入的数据库。NoSQL键值数据库或列式数据库会是一个不错的选择。此外,用户的位置在不断发生变化,位置数据具备一定的时效性,当前的位置数据将会在下一次更新之后过期。因此,我们可以优先考虑可用性而不是一致性。CAP定理指出,我们可以在一致性、可用性和分区容错性中选择两个属性。鉴于我们的约束条件,我们会选择可用性和分区容错性。Cassandra具备强大的可用性保证,是一个非常适合我们场景的数据库选择。
在KV存储中,键是(user_id, timestamp)的组合,值是经纬度对。其中 user_id 是主键,timestamp是聚簇键。使用user_id作为分区键的优势在于,我们可以快速读取特定用户的最新位置。所有具有相同分区键的数据都存储在一起,按时间戳排序,这能够确保在检索特定用户在特定时间范围内的位置数据是具备高效的性能。下面是一个表格示例。
key (user_id) | timestamp | lat | long | user_mode | navigation_mode |
---|---|---|---|---|---|
51 | 132053000 | 21.9 | 89.8 | active | driving |
我们如何使用位置数据?
用户位置数据能够支持很多场景,是至关重要的信息。我们可以基于这些数据来检测“新道路”和最近“停用道路”。我们不仅将其作为提高我们地图准确性的输入条件,也是实时交通数据的重要参考数据。
为了满足这些场景需求,除了将当前用户位置写入我们的数据库外,我们还将在消息队列如Kafka中记录此信息。Kafka是一个专为实时数据流设计,具备低延迟、高吞吐量的数据流平台。下图显示了在这些场景下Kafka的一般使用方式。
各个有需求的服务可以从Kafka消费位置数据流的信息。例如,实时交通服务消费位置变更消息并更新实时交通数据库;路由切片处理服务通过检测新路或关闭道路并更新对象存储中受影响的路由切片来改善世界地图,其他服务也可以利用流数据用于不同的场景和目的。
地图渲染
在这一部分,我们深入研究预计算的地图切片和地图渲染优化。它们主要受到Google Design工作的启发。
预计算切片
如前所述,在不同的缩放级别下有不同的预计算的地图切片集,以便支持根据客户端视图大小和缩放级别为用户提供适当粒度的地图信息。Google Maps使用21个缩放级别,这也是我们本文支持的级别粒度。
级别0是最大范围的缩放级别,整个地图由一个大小为 256256 像素的切片表示。随着缩放级别的递增,地图切片的数量在南北和东西方向上都翻了一番,而每个切片保持在 256256 像素。如下图所示,在缩放级别1,有 2x2 个切片,总分辨率为 512512 像素。在缩放级别2,有 4x4 个切片,总分辨率为 10241024 像素。每次递增,整个切片集合的像素数量是上一个级别的4倍,这也意味着切片集合包含了越来越多的细节。这允许客户端根据用户选择的缩放级别以最佳的粒度来渲染地图,而不会下载过多细节的切片,消耗过多的带宽。
优化:向量化
随着WebGL的发展和成熟,一个可能的改进是将把发送图像 优化为 发送“向量信息”(路径和多边形)。客户端基于这些向量信息绘制道路和形状。切片向量化的一个明显优势是向量数据比图像压缩得更好。节省的带宽是巨大的。另一个隐含的好处是向量切片提供了更好的缩放体验。使用图像时,客户端从一个级别缩放到另一个级别时,图片会被拉伸并看起来像素化,视觉效果相当突然。使用向量化图像时,客户端可以适当地缩放每个元素,提供更平滑的缩放体验。
导航服务
接下来,让我们深入了解导航服务。该服务负责找到最快的路线,流程设计图如下所示。
接下来让我们逐一地过一遍系统中的每个组件。
地理编码服务
首先,我们需要有一个服务来将地址解析为经纬度对。地址可以有不同的格式,它可以是一个地点的名称或一个详细的文本化地址。
以下是来自谷歌编码API的一个请求和响应的示例。
请求:
响应:
{
"results" : [
{
"formatted_address" : "1600 Amphitheatre Parkway, Mountain View, CA 94043, USA",
"geometry" : {
"location" : {
"lat" : 37.4224764,
"lng" : -122.0842499
},
"location_type" : "ROOFTOP",
"viewport" : {
"northeast" : {
"lat" : 37.4238253802915,
"lng" : -122.0829009197085
},
"southwest" : {
"lat" : 37.4211274197085,
"lng" : -122.0855988802915
}
}
},
"place_id" : "ChIJ2eUgeAK6j4ARbn5u_wAGqWA",
"plus_code": {
"compound_code": "CWC8+W5 Mountain View, California, United States",
"global_code": "849VCWC8+W5"
},
"types" : [ "street_address" ]
}
],
"status" : "OK"
}
导航服务调用此服务对起点和终点进行编码,然后将经纬度传递给下游以找到最佳路线。
路线规划服务
该服务主要基于当前交通和道路状况,计算出一条建议路线,优化的主要指标是时长。它会与接下来讨论的几个服务进行交互。
最短路径服务
最短路径服务接收起点和终点的经纬度,并返回前k条最短路径,而不考虑当前的交通情况。这项计算仅依赖于道路信息,且道路切片很少变化,因此缓存路线可能是意义的。
最短路径服务中运行A*路径查找算法的变体,处理对象为对象存储中的路由切片。算法的简要介绍:
- 算法接收起点和终点的经纬度,并将经纬度转换为地理哈希,然后使用它们来加载路由切片的起点和终点
- 算法从从起始的路由切片开始,遍历图,并按需从对象存储(或其本地缓存,如果之前已加载)中获取相邻的切片。值得注意的是,同一区域内,不同级别的图块之间存在连接关系。这就是如何“进入”仅包含高速公路的较大级别图块的方式。算法会根据需要通过补充更多相邻图块(或不同分辨率的图块)来继续扩大搜索范围,直到找到一组最佳路线
下图给出了切片遍历选择的概念示例。
ETA服务
一旦得到了一系列可能的最短路径,路线规划服务就会对每条可能的路线调用ETA服务以获取预计时间。为此,ETA服务采用了机器学习算法,基于当前交通情况和历史数据预测ETA。
其中一个挑战是,我们不仅需要关注实时交通数据,还需要预测10分钟或20分钟后的交通状况。这个挑战需要在算法层面解决,并不会在本节讨论。
排名服务
路线规划服务 获取ETA预测后,它将这些信息传递给排名器,并基于用户可能定义的过滤器进行处理。一些过滤器的示例包括避开收费道路或避开高速公路等。排名服务最终会将可能的路线从最快到最慢进行排序,并将前k个结果返回给导航服务。
更新服务
这些服务接入Kafka位置更新流,并对一些重要数据异步更新,比如交通数据和路由切片。
路由切片处理服务负责将新道路和关闭道路转换为不断更新的一组路由切片,有助于最短路径服务更加准确。
交通更新服务从活跃用户发送的位置更新流中提取交通状况,并输出到实时交通数据库,有助于ETA服务能够提供更准确的估计。
改进:自适应ETA和重新规划路线
当前设计不支持自适应ETA和重新规划路线。为了解决这一问题,我们的服务需要跟踪所有活跃中的导航用户,并在交通状况发生变化时持续更新他们的ETA。这里我们需要关注几个重要问题:
- 我们如何跟踪活跃的导航中用户?
- 我们需要在数百万导航路线中高效定位受交通变化影响的用户,采用什么样的存储方式?
让我们从一个简单的解决方案开始。在下图中,user_1的导航路线由路由切片r_1、r_2、r_3、......、r_7表示。
数据库中存储着活跃导航用户及路线信息,示例如下:
user_1: r_1, r_2, r_3, …, r_k
user_2: r_4, r_6, r_9, …, r_n
user_3: r_2, r_8, r_9, …, r_m
…
user_n: r_2, r_10, r_21, ..., r_l
假设在路由切片2(r_2)发生了一个交通事故。为了找出受影响的用户,我们需要逐行扫描并检查路由切片2是否存在于我们的路由切片列表中(见下面的例子)。
user_1: r_1, r_2, r_3, …, r_k
user_2: r_4, r_6, r_9, …, r_n
user_3: r_2, r_8, r_9, …, r_m
…
user_n: r_2, r_10, r_21, ..., r_l
假设表中的行数为n,导航路线的平均长度为m。寻找所有受交通变化影响的用户的时间复杂度为O(n * m)。
我们能使这个遍历过程更快吗?让我们尝试另一种思路。对于每个活跃用户,我们保持当前路由切片,以及包含它的下一个分辨率级别的路由切片,并递归找到下一个分辨率级别的路由切片,直到我们在该切片中找到用户的目的地,整个过程如下图所示。随后,我们可以得到数据库表中的一行记录:
user_1, r_1, super(r_1), super(super(r_1)), …
为了找出用户是否受到交通变化的影响,我们只需要检查一个路由切片是否在数据库中一行的最后一个路由切片内。如果发生事故的切片不在最后一个切片内,则用户未受影响,否则用户将受到影响。这样我们就可以快速过滤掉许多用户。
这种方法没有说明交通畅通以后会发生什么。例如,如果路由切片2变通畅,用户可以回到旧路线,用户如何知道是否有必要重新规划路线?一个想法是跟踪导航用户的所有可能路线,定期重新计算ETA,并在找到具有更短ETA的新路线时通知用户。
交付协议
在导航时,路线条件可能发生变化,服务需要一种可靠的方式将数据推送到移动客户端。对于服务器到客户端的传送协议,我们的选项包括push、长轮询、WebSocket和SSE。
- Push不是一个很好的选择,因为有效载荷大小非常有限(iOS为4,096字节),且不支持Web应用程序。
- WebSocket一般会被认为比长轮询更好,因为它对服务器的占用非常小
- 我们可以先排除掉push和长轮询,主要在WebSocket和SSE之间做选择。虽然两者都可以满足诉求,但我们更倾向于使用WebSocket,因为它支持双向通信,且部分场景的功能可能需要双向实时通信。
现在我们已经汇总了设计的每一部分,下图中即为我们的最终设计。
第四步 - 总结
在本章中,我们设计了一个简化的谷歌地图应用程序,主要功能包括位置更新、ETA、路线规划和地图渲染。如果你对系统功能的扩展能力感兴趣,一个潜在的改进可能是为企业客户提供多停靠点导航能力。例如,对于给定的一组目的地,我们必须找到访问它们所有的最佳顺序,并根据实时交通状况提供适当的导航。这对于DoorDash、Uber、Lyft等送货服务可能有帮助。
久经风雨见云帆!现在可以给自己鼓鼓气,真棒!
更多信息请关注我~