4

clipboard.png

英文原文作者:Buck Heroux

概述

几周以前,Uber 发布了一篇关于如何构建 "如何使用GO实现极限QPS"的文章。我正好一直在用Go做关于地理空间方面的工作,期待Uber可以用GO写一些牛逼的算法来处理地理数据,然而我发现Uber低于了我的期望…

这篇文章围绕Uber如何处理地理围栏的问题来构建一个服务。地理围栏的核心问题是搜索一组边界找到哪个子集包含一个查询点。这个问题有很多标准的方法,下面是Uber选择的方案。

相比于使用r - tree或复杂的S2构建索引geofences,我们选择了一个基于业务观察的更简单方案:Uber的商业模式以城市为中心;地理围栏的使用通常也和城市紧密关联。这让我们把geofences拆分为两个层次,第一级是城市geofences(geofences定义城市边界),第二层次是在每个城市的geofences。

如果你问某个从来没接触过空间算法的人解决怎么地理围栏的问题,这可能是他们会想出来的方案。很难想象到一个市值$50b且拥有众多工程师的公司,他们的核心问题居然是在地球上找到附近的车。令人失望的是在没有具体理由的情况下只是以"其他方案太复杂"就选择忽略了其他标准解决方案。

尤其令人失望的是考虑到去年夏天Uber还收购了一部分base在科罗拉多州的必应地图工程师团队。我曾经效力过必应地图街景团队,也正是Uber收购的那支,据我所知有好多家伙在这个团队都对空间索引非常了解。我面试的一个问题就是怎么在Instagram图片上通过地理标签找到埃菲尔铁塔。

撇除偏见, 我依然十分认可 Uber 对系统制定的指标要求:特定延迟分布要求的服务99%的所有请求在100毫秒可用(查询+更新)。

地理围栏算法时间复杂度分析

从时间复杂性分析入手对比潜在解决方案是一个很好的起点。这个不需要做任何实际编码,一点点的研究,应该可以证明Uber的低效率方法。即使你不熟悉大O分析,希望也会明白证明的过程。

clipboard.png

我们首先需要解决的是点在多边形内判断的成本(通常我称之为 多边形包含查询)。维基百科的文章很好地分别介绍了两种常用算法用于多边形包含查询:ray casting 和 winding number。这两种算法需要对比每个多边形的顶点和请求的点,因此他们的效率取决于顶点的数量。如果我们用Uber的城市中心模型来拆分问题,我们建立下面的模型参数:

q := 查询点
C := 城市数量
V := 定义一个城市边界的顶点数
n := 在一个城市栅栏的多边形数量
v := 栅栏多边形的顶点

从这里我将概述一些算法引用在文章中,看看他们如何解决地理围栏问题。

暴力穷举法

遍历所有围栏并且用一个算法执行多边形包含查询,比如 ray casting 算法

在我们的模型中,问题转化为在每C个城市中,每个有n个栅栏多边形,最后比较查询点在多边形各顶点v。

Brute() -> O(Cnv)

暴力穷举加强版

Uber 在暴力穷举的基础上增加城市索引,先找到某个城市,然后在这个城市中使用暴力穷举每个地理围栏。这在城市数量快速扩张的时候,有效地修剪搜索空间。
但是城市边界各点的查询仍然会损失很大的成本,所以在这个两部方法中,我们得到:

Uber() -> O(CV) + O(nv)

R-Tree

r树数据结构是一种为多维对象带有特殊的序列定义的基于b树的算法。序列的定义方法是比较一个最小边界矩形(MBR)到另一个对象的MBR之间是否满足完全包含关系。在二维情况下的地理围栏,MBR是一个通过一组最大最小的坐标系来框定的边界框(简称bbox)。检查一个对象的bbox与另一个对象的bbox的包含关系的复杂度是常数时间O(n)。这篇在维基百科上的文章解释得非常详细,下面是一张可视化的图来解释对象的边界框。

clipboard.png

如果自己实现不靠谱,也可以直接用Go里面的 rtreego 这个包来实现,不过对于二维的情况下还需要额外消耗一些空间。

总结下r-tree搜索算法,其实就是在多个bbox同时进行多边形包含查询,r-tree搜索平均时间复杂度是 O(logMn) ,假设 M 表示用户定义的每个节点下同时检索的最多子节点数量。

M := 最多子节点数量
Rtree() -> O(logM(Cn)) + O(v)

QuadTree

四叉树是一种特殊的kd树二维索引。首先,需要将搜索空间的平面投影分成几个分区,我们称之为格网。然后将这些格网分为几个分区递归直到遇到一个定义的最大深度将树的叶子才终止。

如果我们把地球的墨卡托投影和标签的每一个格网、一个标识符添加到父标签,我们可以利用四叉树结构构建快速地理空间搜索,像必应地图那样创建一个瓦片系统。必应地图将每个格网标签称为“QuadKeys”,他们直接可转化这些索引到地图瓦片上。

clipboard.png

S2

为什么我还在谈论四叉树? 因为在文章中提到的“复杂”的S2算法只是一个四叉树的实现。必应地图瓦片系统和S2的主要区别是 S2 投影是通过立方体投影贴图,因此每个格网都有近视的表面积。这个格网也用在空间扩散曲线来存储格网中的空间位置标签。这是一篇有更深层次的解释文章:S2 最初是用C++完成的,同时绑定到了许多其他语言上,Go也是其中之一(在geo库中)。诚然,在s2.Polygon中缺乏核心的s2.Region的实现,那么s2.RegionCoverer 方法也不能在边界框之外使用。这里有两个例子,可以看到扁平覆盖各级和RegionCoverer生成多层次覆盖。

clipboard.png

回到我们的分析上来看,如果我们为每个功能和格网的查询得到了一组格网标签,那么我们可以在两个多边形上在常数时间内缩小搜索空间,然后做一个多边形包含检查。我们将为带有同一个网格标签的多边形数量添加一个新的参数 T,它是和区域缩放级别(zoom-level)是相关联的常数项。

T := 格网标签
QTree() -> O(T) + O(v)

有很多方法我们可以利用S2网格或者QuadKeys内部覆盖我们的边界然后在一个常数时间内完成多边形包含查询。权衡是否跳过某些多边形包含查询是所有衍生算法的关键,因为遍历所有情况会很快吃光内存。我们可以通过比如布隆过滤器或者一些前置层来减少内存的使用。或许我们可以稍后再深入细节。

对比

经过刚才的枚举,我们对算法复杂度有下面的一个估计:

q := 查询点
C := 城市个数
V := 定义一个城市边界的顶点数
n := 在一个城市栅栏的多边形数量
v := 栅栏多边形的顶点
Brute() -> O(Cnv)              
Uber()  -> O(CV) + O(nv)       
Rtree() -> O(logM(Cn)) + O(v)  
Qtree() -> O(T) + O(v)       

现在参数有点过多,这样分析看起来不是很清晰,如果我们去掉一部分参数,我们可以更简单高效地把每种算法说清楚。当我们迷失在传统的算法复杂度分析中,我认为直接做一些实验或许更加直观。

估算

原文说,城市降维的方法,可以把搜索空间从几万减少到数百。我们可以推断他们有上百个城市在搜索空间内。形成一个边界的一个城市的点的数量会有很大的波动,我见过用曼哈顿距离的情况下,距离会在从一千到六千的范围内波动。假设他们选择简单的定义或者用普克法(Douglas-Peucker)简化城市边界到100个节点。

clipboard.png

每个城市有多少又包含多少栅栏多边形呢?从相同的逻辑作为城市的数量和我们知道纽约有167个社区,100年再次看起来像是正确的数量级。看着栅栏的顶点,提出社区像布鲁克林的威廉斯堡拥有几百点,但用户定义多边形几乎肯定有简单形状的几点。让我们来猜一猜,同样沿用 100作为 v的参数值。考虑到v是多边形包含查询本身和每个算法都有,我不担心把它正确。我认为我们至少在正确的数量级。

C := 100 // 城市个数
V := 100 // 一个城市边界的顶点数
n := 100 // 一个城市边界的围栏数
v := 100 // 一个围栏的顶点数
M := 50  // rtree 参数
T := 4   // 每个格网覆盖多边形数
Brute() -> 1   * 10^6
Uber()  -> 2   * 10^4
Rtree() -> 2.3 * 10^2
Qtree() -> 4   * 10^2

如果我们的逻辑是合理的,我们应该得到两倍的效率得以提高,Uber算法使用了一个标准的空间索引。

clipboard.png

参考资料

英文原文地址:https://medium.com/@buckhx/un...

更优阅读体验可直接访问原文地址:https://segmentfault.com/a/11...
作为分享主义者(sharism),本人所有互联网发布的图文均遵从CC版权,转载请保留作者信息并注明作者 Harry Zhu 的 FinanceR专栏:https://segmentfault.com/blog...,如果涉及源代码请注明GitHub地址:https://github.com/harryprince。微信号: harryzhustudio
商业使用请联系作者。


HarryZhu
2.2k 声望2.2k 粉丝

引用和评论

0 条评论