gitbook/李智慧 · 高并发架构实战课/docs/492306.md
2022-09-03 22:05:03 +08:00

168 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 09 | 交友系统设计:哪种地理空间邻近算法更快?
你好,我是李智慧。
交友与婚恋是人们最基本的需求之一。随着互联网时代的不断发展,移动社交软件已经成为了人们生活中必不可少的一部分。然而,熟人社交并不能完全满足年轻人的社交与情感需求,于是陌生人交友平台悄然兴起。
我们决定开发一款基于地理位置服务LBS的应用为用户匹配邻近的、互相感兴趣的好友应用名称为“Liao”。
Liao面临的技术挑战包括面对海量的用户如何为其快速找到邻近的人可以选择的地理空间邻近算法有哪些Liao如何在这些算法中选择出最合适的那个
## 需求分析
Liao的客户端是一个移动App用户打开App后上传、编辑自己的基本信息然后系统推荐算法根据其地理位置和个人信息为其推荐位置邻近的用户。用户在手机上查看对方的照片和资料如果感兴趣希望进一步联系就向右滑动照片如果不感兴趣就向左滑动照片。
如果两个人都向右滑动了对方,就表示他们互相感兴趣。系统就通知他们配对成功,并为他们开启聊天功能,可以更进一步了解对方,决定是否建立更深入的关系。
Liao的功能用例图如下。
![图片](https://static001.geekbang.org/resource/image/ea/f3/ea2b93d940d97a069661b2eba296abf3.jpg?wh=1920x1276)
**用户规模分析**
Liao的目标用户是全球范围内的中青年单身男女预估目标用户超过10亿系统按10亿用户进行设计。
## 概要设计
Liao的系统架构采用典型的**微服务架构**设计方案,用户通过网关服务器访问具体的微服务,如下图。
![图片](https://static001.geekbang.org/resource/image/e0/63/e00ce4d11671abb596561c5103636363.jpg?wh=1920x1276)
由上图可知,首先,用户所有请求都通过统一的**网关服务器**处理。网关服务器负责限流、防攻击、用户身份识别及权限验证、微服务调用及数据聚合封装等而真正的业务逻辑则通过访问微服务来完成。Liao的关键微服务有用户微服务、图片微服务、配对微服务、聊天微服务、推荐微服务、邻近算法微服务等。Liao的网关预计将承担每天百亿次规模的访问压力。
**用户微服务**管理用户的个人信息、兴趣爱好以及交友偏好等此外也负责用户登录服务只有登录用户才能访问系统。因为需要存储十亿条用户数据所以用户数据库采用分片的MySQL数据库。
**图片微服务**用于管理用户照片提供用户照片存储及展示的功能。Liao需要存储的图片数大约几百亿张。我们使用Nginx作为图片服务器图片服务器可以线性扩容每写满一台服务器及其Slave服务器就继续写入下一台服务器。服务器IP、图片路径则记录在用户数据库中。同时购买CDN服务缓存热门的用户照片。
**配对****微****服务**负责将互相喜欢的用户配对,通知用户,并加入彼此的通讯录中。用户每次右划操作都调用该微服务。系统设置一个用户每天可以喜欢(右划)的人是有上限的,但是,对于活跃用户而言,长期积累下来,喜欢的人的数量还是非常大的,因此配对微服务会将数据发送给一个流式大数据引擎进行计算。
**推荐微服务**负责向用户展示其可能感兴趣的、邻近的用户。因此,一方面,推荐微服务需要根据用户操作、个人兴趣、交友偏好调用协同过滤等推荐算法进行推荐,另一方面必须保证推荐的用户在当前用户的附近。
## 详细设计
详细设计主要关注邻近位置算法,也就是,如何根据用户的地理位置寻找距其一定范围内的其他用户。
我们可以通过Liao App获取用户当前经、纬度坐标然后根据经、纬度计算两个用户之间的距离距离计算公式采用半正矢公式
![图片](https://static001.geekbang.org/resource/image/68/f4/68e7b8215ab87f9b6c631ee9bc0bbbf4.png?wh=562x100)
其中 r 代表地球半径,$\\small \\varphi$表示纬度,$\\small \\lambda$表示经度。
但是当我们有10亿用户的时候如果每次进行当前用户匹配都要和其他所有用户做一次距离计算然后再进行排序那么需要的计算量至少也是千亿级别这样的计算量是我们不能承受的。通常的空间邻近算法有以下4种我们一一进行分析最终选择出最合适的方案。
#### **SQL邻近算法**
我们可以将用户经、纬度直接记录到数据库中纬度记录在latitude字段经度记录在longitude字段用户当前的纬度和经度为XY如果我们想要查找和当前用户经、纬度距离D之内的其他用户可以通过如下SQL实现。
```sql
select * from users where latitude between X-D and X+D and longtitude between Y-D and Y+D;
```
这样的SQL实现起来比较简单但是如果有十亿用户数据分片在几百台服务器上SQL执行效率就会很低。而且我们用经、纬度距离进行近似计算在高纬度地区这种近似计算的偏差还是非常大的。
同时“between X-D and X+D”以及“between Y-D and Y+D”也会产生大量中间计算数据这两个betwen会先返回经度和纬度各自区间内的所有用户再进行交集and处理如下图。
![图片](https://static001.geekbang.org/resource/image/31/96/31a93a5d2b66f96378996d82bb849596.jpg?wh=1920x904)
我们的用户量非常大,而计算邻近好友又是一个非常高频的访问,同时,分片数据库进行集合计算需要在中间代理服务器或应用程序服务器完成计算,因此,这样的交集计算带来计算负载压力是我们的系统完全不能承受的。所以这个方案可以被放弃。
#### 地理网格邻近算法
为了减少上述交集计算使用的中间数据量,我们将整个地球用网格进行划分,如下图。
![图片](https://static001.geekbang.org/resource/image/28/f8/2842ed8c7169608aee44927a627848f8.jpg?wh=1920x714)
事实上我们划分的网格远比图中示意的要密集得多赤道附近经、纬度方向每10公里一个网格。
这样每个用户必然会落入到一个网格中我们在用户表中记录用户所在的网格IDgridID然后借助这个字段进行辅助查找将查找范围限制在用户所在的网格gridIDx0及其周围8个网格gridIDx1 ~ gridIDx8可以极大降低中间数据量SQL如下。
```sql
select * from users where latitude between X-D and X+D and longtitude between Y-D and Y+D and gridID in (gridIDx0,gridIDx1,gridIDx2,gridIDx3,gridIDx4,gridIDx5,gridIDx6,gridIDx7,gridIDx8);
```
这条SQL要比上面SQL的计算负载压力小得多但是对于高频访问的分片数据库而言用这样的SQL进行邻近好友查询依然是不能承受的同样距离精度也不满足要求。
但是基于这种网格设计思想,我们发现,我们可以不通过数据库就能实现邻近好友查询:我们可以**将所有的网格及其包含的用户都记录在内存中**。当我们进行邻近查询时只需要在内存中计算用户及其邻近的8个网格内的所有用户的距离即可。
我们可以估算下所有用户经、纬度都加载到内存中需要的内存量:$\\small 1G\\times3\\times4B=12GB$用户ID、经度、纬度都采用4个字节编码总用户数1G。这个内存量是完全可以接受的。
实际上通过恰当地选择网格的大小我们不停访问当前用户位置周边的网格就可以由近及远不断得到邻近的其他用户而不需要再通过SQL来得到。那么如何选择网格大小如何根据用户位置得到其所在的网格又如何得到当前用户位置周边的其他网格呢我们看下实践中更常用的动态网格和GeoHash算法。
#### 动态网格算法
事实上,不管如何选择网格大小,可能都不合适。因为在陆家嘴即使很小的网格可能就包含近百万的用户,而在可可西里,非常大的网格也包含不了几个用户。
因此,我们希望能够动态设定网格的大小,如果一个网格内用户太多,就把它分裂成几个小网格,小网格内如果用户还是太多,继续分裂更小的网格,如下图。
![图片](https://static001.geekbang.org/resource/image/a3/21/a35b51bcfe35de5a90a147172aa52021.jpg?wh=1920x1063)
这是一个四叉树网格结构开始的时候整个地球只有一个网格当用户增加超过阈值500个用户的时候就分裂出4个子树4个子树对应父节点网格的4个地理子网格。同时将用户根据位置信息重新分配到4个子树中。同样如图中所示如果某个子树中的用户增加超过了阈值该子树继续分裂成4个子树。
因此我们可以将全球用户分配在这样一个4叉树网格结构中所有的用户都必然在这个4叉树的叶子节点中而且每个节点内包含的用户数不超过500个。那么陆家嘴的网格可能就会很小而可可西里的网格就会很大太平洋对应的网格可能有几千公里。
当给定当前用户的经、纬度查询邻近用户的时候首先从根节点开始查找如果根节点就是叶子节点那么直接遍历根节点中的所有用户计算距离即可。如果根节点不是叶子节点那么根据给定的经、纬度判断其在网格中的位置左上、右上、右下、左下4个位置顺序对应4个子树根据网格位置访问对应的子树。如果子树是叶子节点那么在叶子节点中查找如果不是叶子节点继续上面的过程直到叶子节点。
上面的过程只能找到当前用户所在网格的好友如何查找邻近网格的其他用户呢事实上我们只需要将4叉树所有的叶子节点顺序组成一个双向链表每个节点在链表上的若干个前驱和后继节点正好就是其地理位置邻近的节点。
动态网格也叫4叉树网格在空间邻近算法中较为常用也能满足Liao的需求。但是编程实现稍稍有点麻烦而且如果网格大小设计不合适导致树的高度太高每次查找需要遍历的路径太长性能结果也比较差。我们再看下性能和灵活性更好的GeoHash算法。
#### GeoHash算法
除了动态网格算法GeoHash事实上是另外一种变形了的网格算法同时也是Redis中Geo函数使用的算法。GeoHash是将网格进行编码然后根据编码进行Hash存储的一种算法。
经、纬度数字的不同精度意味着经、纬度的误差范围比如保留经、纬度到小数点后第1位那么误差范围最大可能会达到11公里在赤道附近。也就是说小数点后1位精度的经、纬度其覆盖范围是一个11km \* 11km的网格。
那么我们用小数点后1位精度的经、纬度做key网格内的用户集合做value就可以构建一个Hash表的<key, value>对。通过查找这个KV对及其周围8个网格的KV对计算这些value内所有用户和当前用户的距离就可以找到邻近11公里内的所有用户。
实践中redis的GeoHash并不会直接用经、纬度做key而是采用一种基于Z阶曲线的编码方式将二维的经、纬度转化为一维的二进制数字再进行base32编码具体过程如下。
首先,分别针对经度和纬度,求取当前区间(对于纬度而言,开始的区间就是\[-90, 90\], 对于经度而言,开始区间就是\[-180, 180\]的平均值将当前区间分为两个区间。然后用用户的经、纬度和区间平均值进行比较用户经、纬度必然落在两个区间中的一个如果大于平均值那么取1如果小于平均值那么取0。继续求取当前区间的平均值进一步将当前区间分为两个区间。如此不断重复可以在经度和纬度方向上得到两个二进制数。这个二进制数越长其所在的区间越小精度越高。
下图表示经、纬度<42.60411, -5.59041>的二进制编码过程最终得到纬度12位编码经度13位编码。
![图片](https://static001.geekbang.org/resource/image/f5/e2/f507bf873cb55401667e4dddc30fe5e2.png?wh=535x407)
![图片](https://static001.geekbang.org/resource/image/72/ff/72bacbfce48d93bf4c7ebc70c6c2f9ff.png?wh=554x433)
得到两个二进制数后,再将它们合并成一个二进制数。合并规则是,从第一位开始,奇数位为经度,偶数位为纬度,上面例子合并后的结果为 01101 11111 11000 00100 00010 共25位二进制数。
将25位二进制数划分成5组每组5个二进制数对应的10进制数是0-31采用Base32编码可以得到一个5位字符串Base32编码表如下。
![图片](https://static001.geekbang.org/resource/image/8d/7e/8d8cf88e9a9e5645b2e7cfe9d5c2057e.png?wh=517x130)
编码计算过程如下。
![图片](https://static001.geekbang.org/resource/image/36/cb/367b8eb29043415710a01a2cf752e0cb.png?wh=204x133)
最后得到一个字符串“ezs42”作为Hash表的key。25位二进制的GeoHash编码其误差范围大概2.4公里,即对应一个$\\small 2.4km\\times2.4km$的网格。网格内的用户都作为value放入到Hash表中。
一般说来通过选择GeoHash的编码长度实现不同大小的网格就可以满足我们邻近交友的应用场景了。但是在Redis中需要面对更通用的地理位置计算场景所以Redis中的GeoHash并没有用Hash表存储而是用跳表存储。
Redis使用52位二进制的GeoHash编码误差范围0.6米。Redis将编码后的二进制数按照Z阶曲线的布局进行一维化展开。即将二维的经、纬度上的点用一条Z型曲线连接起来Z阶曲线布局示例如下图。
![图片](https://static001.geekbang.org/resource/image/67/2b/67fd93a5157f207721a1c493bf70d12b.png?wh=800x787)
事实上所谓的Z阶曲线布局本质其实就是基于GeoHash的二进制排序。将这些经过编码的2进制数据用跳表存储。查找用户的时候可以快速找到该用户沿着跳表前后检索得到的就是邻近的用户。
#### Liao的最终算法选择
Liao的邻近算法最终选择使用Hash表存储的GeoHash算法经度采用13bit编码纬度采用12bit编码即最后的GeoHash编码5个字符每个网格$\\small 4.9km\\times4.9km\\approx 25km^{2}$,将整个地球分为$\\small 2^{25}\\approx3300万$个网格去掉海洋和几乎无人生存的荒漠极地需要存储的Hash键不到500万个采用Hash表存储。Hash表的key是GeoHash编码value是一个List其中包含了所有相同GeoHash编码的用户ID。
查找邻近好友的时候Liao将先计算用户当前位置的GeoHash值5个字符然后从Hash表中读取该Hash值对应的所有用户即在同一个网格内的用户进行匹配将满足匹配条件的对象返回给用户。如果一个网格内匹配的对象数量不足计算周围8个网格的GeoHash值读取这些Hash值对应的用户列表继续匹配。
## 小结
算法是软件编程中最有技术挑战性,也最能考验一个人编程能力的领域。所以很多企业面试的时候特别喜欢问算法类的问题,即使这些算法和将来的工作内容关系不大,面试官也可以凭借这些问题对候选人的专业能力和智力水平进行评判,而且越是大厂的面试越是如此。
架构和算法通常是一个复杂系统的一体两面架构是关于整体系统是如何组织起来的而算法则是关于核心功能如何处理的。我们专栏大多数案例也都体现了这种一体两面很多案例设计都有一两个核心算法比如短URL生成与预加载算法、缩略图生成与推荐算法、本篇的空间邻近算法以及下一篇要讲的倒排索引与PageRank算法都展现了这一点。
一个合格的架构师除了要掌握系统的整体架构,也要能把握住这些关键的算法,才能在系统的设计和开发中做到心中有数、控制自如。
## 思考题
本文的设计聚焦在邻近算法上所以忽略了常规的TPS、带宽负载、存储空间等性能指标你能否估计下这些性能指标
欢迎在评论区分享你的思考,或者提出对这个设计文档的评审意见,我们共同进步。