空间搜索是针对多维空间数据进行的搜索,这听起来是一句废话。但是,这重点强调的是空间数据,而且数据是**多维(2D, 3D …)**的。同时, 空间搜索有别于传统的网页文本搜索(google, baidu等),传统搜索引擎针对的是海量的文本,分词之后,通过倒排等技术来进行快速的匹配。
空间搜索的数据则是点、线、多边形,多维空间等等几何体。如何能高效准确的搜索出想要的空间数据是空间搜索需要解决的。
无形之中,空间搜索已经存在于我们生活中的方方面面。也许,出门你可能要打个车,看看附近有哪些司机师傅可以接单。自驾要搜索一下沿途的加油站,如果是电车你可能想找一下充电桩。抑或是你今天休息宅在家里,想找个餐厅,定个外卖。所有的这些都离不开空间搜索支持。
现在比较流行的RDBMS数据库 MySQL、PostgreSQL 都原生支持 B+ 树。这种数据结构能高效的查询。MongoDB、Redis等NoSQL数据库原生的支持了空间索引能力。业界对于空间搜索的需求是巨大的。
简单回顾一下,文本搜索似乎也是要解决这2个问题。也许文本搜索能给空间搜索带来一些启发。
文本索引是先切分为一个个的token词,然后针对每个token建立一个倒排链,也就是我们熟称的倒排索引。基本思想很简单,如下图所示:
文本倒排索引建立之后,查询起来就特别快了。比如搜索”跳槽“,可以通过倒排链很快的得到1和4这两篇文档包含这个搜索词。这里需要注意的是搜索召回的时候只要包含这个搜索词,原则上就是我们想要的内容。
回过头来,我们来看一下,空间搜索的一个典型场景:
比如使用手机查找当前屏幕显示范围内的所有餐厅,或者离我2KM内(圆形)的餐厅,如下图所示。
怎么来实现这个功能呢?
首先,想到的就是暴力法,扫描数据库内的所有餐厅,计算这个餐厅是不是在当前屏幕范围内,或者是不是离我当前位置2KM,然后把符合条件的餐厅过滤出来。
这个方案的确是可行的,但是显然是及其低效的。如果这个数据库餐厅的量级不断扩大,或者屏幕范围不断增大,这个计算耗时将是完全不可忍受的。另外还需要额外计算是否符合过滤条件(在不在范围内)。
既然如此低效,那我们可以如何改进呢?
低效的原因,在于全量数据扫描,如果有一种办法可以减小数据扫描量就可以了,在常见的关系数据库内,自然想到的就是加索引(index)。这就是通用的分治思想,诸如2分查找, 二叉搜索树, B+树等。这些都适用于1D数据的处理,针对地理位置点,这是一个经度和纬度构成的2D数据。
直观上来说,就是要把餐厅按照一定的规则归在不同的分块内,如果某个分块不符合过滤条件(比如附近2KM) , 那么整个分块内的所有餐厅也就没有必要再参与计算。通过分块,大大减少了计算量。
以上减少了扫描量,但是仍然是需要计算的,能不能做到像文本搜索一样,以常量O(1)的时间复杂度,高效的查询呢。这就牵涉出这样一个问题,如果能提前计算出附近2KM的分块是什么,那么直接使用文本搜索的倒排索引形式就可以常量复杂度解决问题。
为了能提前计算出附近2KM的分块,这对分块大小是有要求的,如果这个分块大小是100KMX100KM, 那有可能这个分块的内的数量仍旧是不可忍受的,还是会退化为O(n)的复杂度。但是如果分块太小,比如1mX1m,可能会导致满足条件的分块太多,从而导致整体性能的下降。 所以这是一个双刃剑。
所好的是,我们可以站在巨人的肩膀上,有很多前辈已经做了很多的研究和尝试,我们可以从中汲取营养,得到灵感。
综合以上提出的解决方案,实际上空间搜索的发展也是基本按照这样的思路来不断推进的。想做空间索引,至少以下几步都是不可缺少的。如下图所示:
地球做为一个球体,她是3D的,虽然不是完全的纯球体。有人说他是一个椭圆形,由于自转的原因,赤道附近凸起,两极收敛的。也有人说,地球是一个梨形的,而且可能是个坏梨。
实际上,从海平面来说,地球就应该是像大家常见到的太空拍摄的图,蔚蓝色的,虽然实际上,可能并没有那么好看而已。大致可以认为地球是一个有点椭圆的圆形。
地球是一个3D是不争的事实,那第一步就是要考虑,我们是基于地球直接做分块呢,还是说基于更多认同的经纬度这一2D平面做分块呢。空间搜索这块,实际上不同的实现都有。
如果是基于经纬度2D,那么就牵涉到如何把3D地球映射为2D平面,这就是地球投影研究的范畴。
如果是直接基于3D球形做切分,那就要考虑如何在球面上做分块。其实这些事情远在计算机技术发明之前都有很多研究人员投入,那是地理信息科学研究的基础。可以参考基于地心投影,外接多边体方案的实现,这种方案通常称为测地线网格系统。
关于分块的大小,不同的业务场景其实是不一样的,有没有一种办法可以兼而有之, 有的。那就是分层,不同层有不同的块大小,这样在不同的距离上,选取适当的层来计算就可以了。当然,最好层之间是有包含关系最好,能够方便的在不同层之间转换,这也是全球网格系统研究的范畴,美滋滋。
分块不仅要考虑大小,还要考虑分块形状,块选取什么形状还是很有讲究的,当然我们自然想到的其实就是长方形、或者正方形。还有很多形状可以选择,但是并不是所有的图形都可以完全覆盖2D平面。
既然分块要有层次关系, 显然具有自相似性是最好的选择,这样无论你在任何一层来看,除了个数,大小不一样,形状应该都是一样的。那就要考虑,如何通过切分来划分为更精确的小块。简单来说,长方形就可以,可以不断的4等分,9等分等等。说的更有通用性,这是分形几何研究的范畴。
下面,我来分别的展开讲一下,空间搜索需要关注的各个部分。
早期的空间搜索都是基于经纬度来处理的,毕竟这个广泛得到了人们的认同。
以常见的世界地图举例,其实我们默认接受了(经度、维度)这一笛卡尔坐标系,把地球通过投影,变成了一个2D的平面。无论我们使用的腾讯地图、百度地图,还是各种打车软件,其实都是基于这个平面地图来展开的。
然而平面地图在处理空间搜索的时候是有一定的局限性的,这个局限性在于投影过程中导致的形状和大小的失真,由于失真的存在,可能导致数据分布不均,距离计算错误一系列问题。
地球是一个3D的,意味着在转为2D的平面地图之后,从经度,纬度上肯定是有变形的。这里暂不考虑地球本身就不是一个标准的球体,以及山川、湖泊、盆地的影响。直觉常会让人迷惑,比如下图。
视觉上,格陵兰岛似乎比中国还要大,但是查询各种资料,发现它才216万平方公里大。中国大天朝可是有960万平方公里大。为什么会出现这种问题,这就是说地图在转换为2D过程中出现了变形,导致不同纬度在2D平面上的面积不一致,有误差。
如果把格陵兰岛移动到和中国同纬度,那看起来就正常多了。比例也是对的。实际上,不同的转换方案可能导致的变形也是不一样的,这里的例子只是因为我们常用的墨卡托投影导致的一个副作用。
把地球从3D变为2D,这就是常说的降维, 在应用到地理信息系统时,有一个专有名词叫做地球投影。了解地球投影技术是理解空间搜索的一个基本。
以上,了解到了地球投影,以及它的劣势:失真。 那么下面我们来看一下,如何直接在地球3D上做分块, 这就引出了全球地理网格系统(DGGS)。DGGS研究范围,涵盖了如何分层,如何球面分块等等领域,下面来展开讲一下。
###全球地理网格系统(DGGS)
地理信息科学中的全球网格系统所要做的事情与空间搜索想做的事情不谋而合,或者说空间搜索的发展,直接借鉴了很多地理科学的研究成果。在地理信息科学(GIS)中,讨论如何划分网格(Cell)来表示地球, 从而可以让计算机来识别和高效查询已经有很多年的历史,成果丰硕。这使得空间搜索有了很好的理论基础。
单纯从DGGS发展来说,主要分为以下几种分类:
离散全局网格中最常见的一类是将网格中心点放置在经度/纬度子午线和平行线上,或者使用经度/纬度子午线和平行线形成矩形像元的边界的网格。从名字上也可以看出,这个是基于经纬度平面地图的网格系统,并且不是分层的结构。
上图是针对美国UTM投影基础上,把美国按照6°的经度间隔进行分割的示意图。
相对于非分层网络而言。所谓分层,就是把地球逻辑上划分为N个层,每个层的比例尺是不一样的,用于来在不同的精度上来划分世界。
比如下图,显示了英国大不列颠岛的不同精度上的网格分布(150km比例尺、75km比例尺、37.5km比例尺)。
随着层次越高,比例越小,网格可以表示的区域也更加的精确。需要注意分层带来的好处,也就是图中绿色部分展示的效果,通过不同层次的结合,在更精细的比例上,同样是可以使用更粗的层次上来覆盖(Cover)的。这在空间索引,以及查询过程中,都能大大的优化存储量和查询性能。
普通网格系统,都是基于投影后的2D平面来切分网格的,且不论使用何种地球投影方案,这都会带来不同纬度的网格实际面积是不一致的,并且有可能面积相差很大。比如在赤道附近1°的距离,比靠近南北极的1°相差简直天差地别。
测地线网格系统,则是直接把地球按照测地线来作为一个3D球体来直接划分区域的。划分不受经纬度的影响,每个区域大小基本一致(地球不是纯球体)。在具体实现上,如何对地球表面的切分呢?
通常使用地球外接多面体,从地心投影的形式来划分:
基本上,一共有这5种正多面体可以来投影:正四面体、正六面体(正方体)、正八面体、正十二面体、正二十面体。
可以注意到的是,这些多面体都是正多面体(柏拉图体)。每个面的大小是一致的,完全符合网格切分的要求。显然面越多,面内部各自之间的区域失真就更低(面积小,地球起伏更低)。所以,常见的测地线网格系统多以正二十面体作为切分方案。如下图所示:
无论是2D平面网格,还是直接在球体上做的切分,最终都是针对平面做的形状选择。测地线网格系统是先映射到了正多面体上。而每个正多面体的面,都可以近似作为平面来进行。
形状的选择,首先要考虑的是,什么图形可以完全覆盖平面。其实这和装修贴瓷砖是一个道理,选择什么样的瓷砖形状可以无缝密铺平面。实际上2015年华盛顿大学的数学系副教授卡西·曼、他的妻子珍妮弗及学生冯德劳发现了新的15种五边形可以密铺整个平面。这距离上一次发现类似效果的五边形已经有了30年。因此如何密铺,研究成果是很多的。
可以直接拿到的成果是:
任何三角形都可以密铺平面
因为2个三角形何在一起就是一个平行四边形,平行四边形可以铺满。
任何凸四边形(正方形、矩形等)都可以密铺平面
四边形内角和是360°,如果把四个四边形不同的角合在一起,那么就是360°。所以可以铺满。
正n边型中,只有正三角形,正方形,正六边形可以密铺平面。
正n边型的内角和是(n-2)*180°
, 那么一个内角的大小就是n-2/n * 180°
。 如果能铺满平面,内角的整数倍就应该是360°。得出n只能是3,4,6。
考虑到,要求网格必须有自相似性,以及数学计算复杂度。空间搜索的形状基本都采用矩形或正多边形。直观理解上,矩形和正方形是最容易接受的。
网格分层,要求网格形状最好是有自相似性以及精确包含。同时,倒排索引的要求,要求不同层之间的网格编码最好能有共同前缀,有共同前缀才能更高效的搜索。
提到了共同前缀,大家自然的可以想到Trie树,具备良好的前缀特性。其实任何树结构都具备前缀属性。树的层序遍历,可以方便的获取其子节点。双向树通过存储其父节点,可以查找他的父节点。
空间搜索也是这样的需求,那么如何给不同层的网格赋予ID,同时赋予其层次关系。
可以从第0层开始,每一层不断的往下N等分,给等分后的网格,按规则赋予编号(1,2,3,4…), 这样不断的迭代,直到想要的层次。
如上图所示,通过不断的4等分,一层一层的进行分割。其编码也具备前缀属性,当然这些编码也可以是ABCD, 规则可以自定义。其实上图表示的是四叉树结构。
当然也有,其他的N等分方案,比如3等分,7等分等, 依据选择不同的网格形状使用不同的切分方案。比如正六边形的分割方案。
评价一个网格方案的好坏,至少要从以下角度来考虑:
参考文档:
https://zhuanlan.zhihu.com/p/21110862
https://zh.wikipedia.org/wiki/%E6%B2%83%E7%BD%97%E8%AF%BA%E4%BC%8A%E5%9B%BE
]]>类型 | 图形 | 例子 |
---|---|---|
Point | POINT (30 10) | |
LineString | LINESTRING (30 10, 10 30, 40 40) | |
Polygon | POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10)) | |
Polygon有孔 | POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10),(20 30, 35 35, 30 20, 20 30)) |
类型 | 图形 | 例子 |
---|---|---|
MultiPoint | MULTIPOINT ((10 40), (40 30), (20 20), (30 10)) 或者 MULTIPOINT (10 40, 40 30, 20 20, 30 10) | |
MultiLineString | MULTILINESTRING ((10 10, 20 20, 10 40),(40 40, 30 30, 40 20, 30 10)) | |
MultiPolygon | MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)),((15 5, 40 10, 10 20, 5 10, 15 5))) | |
MultiPolygon有孔 | MULTIPOLYGON (((40 40, 20 45, 45 30, 40 40)),((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))) | |
GeometryCollection | GEOMETRYCOLLECTION (POINT (40 10),LINESTRING (10 10, 20 20, 10 40),POLYGON ((40 40, 20 45, 45 30, 40 40))) |
GeometryCollection是一个符合类型,用于表示多个复杂基本类型的集合。
需要重点关注:Polygon多边形的表示方式,当顶点顺序逆时针标识的是内部的多边形,顺时针标识的是外部的多边形,这点容易出错。应仔细观察图中有孔洞的表达形式。
GeoJSON的类型与WKT一样,从名字上也可以看出是通过JSON来表达几何图形。除了GeometryCollection外,都必须包含一个coordinates成员,用来标识坐标。
1 | { |
1 | { |
1 | { |
1 | { |
1 | { |
有孔的(注意顺序):
1 | { |
1 | { |
1 | { |
此外,GeoJSON支持为集合类型添加特征属性,这就是feature
类型,而多个feature
类型可以组织成FeatureCollection
形式。
特征对象必须由一个名字为”geometry”的成员,这个几何成员的值是上面定义的几何对象或者JSON的null值。
特征必须有一个名字为“properties”的成员,这个属性成员的值是一个对象(任何JSON对象或者JSON的null值)。
如果特征是常用的标识符,那么这个标识符应当包含名字为“id”的特征对象成员。
1 | { |
类型为”FeatureCollection”的对象必须由一个名字为”features”的成员, 用于包含多个Feature对象
1 | { |
GeoJSON对象的坐标参考系统(CRS)是由它的”crs”成员(指的是下面的CRS对象)来确定的。如果对象没有crs成员,那么它的父对象或者祖父对象的crs成员可能被获取作为它的crs。如果这样还没有获得crs成员,那么默认的CRS将应用到GeoJSON对象。
GeoJSON对象可能有一个名字为”bbox的成员。bbox成员的值必须是2*n数组,这儿n是所包含几何对象的维数,并且所有坐标轴的最低值后面跟着最高者值。
参考文档:
https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
]]>四叉树 是一种非常简单的空间索引技术。在四叉树中,每个节点代表一个bbox,该bbox覆盖被索引空间的某些部分,而根节点则覆盖整个区域。每个节点要么是一个叶子节点-在这种情况下,它包含一个或多个索引点,并且没有子级;要么是一个内部节点,在这种情况下,它正好具有四个子级,每个象限一个子级,方法是将沿两个轴的一半-因此得名。
四叉树如何划分索引区域的表示。
将数据插入四叉树很简单:
从根开始,确定您的点占据哪个象限。递归到该节点并重复,直到找到叶节点。然后,将您的点添加到该节点的点列表中。如果列表超过了某些预定的最大元素数,请分割节点,然后将这些点移动到正确的子节点中。
四叉树内部结构的表示形式。
要查询四叉树,请从根开始,检查每个子节点,然后检查其是否与要查询的区域相交。如果是这样,则递归到该子节点。每当遇到叶节点时,请检查每个条目以查看其是否与查询区域相交,如果存在,则将其返回。
请注意,四叉树非常规则-实际上,它是Trie树,因为树节点的值不取决于要插入的数据。这样的结果是,我们可以以一种简单明了的方式对节点进行唯一编号:只需对每个象限以二进制编号(左上角为00,右上角为10,依此类推),而节点的数目就是串联从其根部开始,计算其每个祖先的象限数。使用此系统,示例图像中右下角的节点将被编号为1101。
如果我们为树定义了最大深度,那么我们可以在不参考树的情况下计算点的节点号-只需将节点的坐标标准化为适当的整数范围(例如,每个32位),然后将x和y坐标-每个位对在假设的四叉树中指定一个象限。
Cartesian Grid方案,本质上就是QuadTree的一种变种,用来处理空间地理搜索。
显然,地球是一个3D的,需要先投影成一个平面。
Cartesian Grid使用的是Sinusoidal正弦投影方案,平面地图如下所示:
Cartesian Tier不断的往这个投影地图上覆盖,每个Tier的网格数是2的Tier id指数幂。
Tier 0有1个网格
Tier 1有2*2个网格;
Tier 2有 4*4个网格;
Tier 3有8*8个网格;
这样,任何一个地理坐标都可以放到这些不断细分的网格内。
在Tier 15, 就有32768*32768个网格,这时网格的大小已经小于1英里了。
当不断往下细分,到第Tier 19(共20层)就能满足绝大部分的需求了。
那么,如果有一个需求,需要找E附近25英里内的数据,那么只要计算出距离E25英里的有哪些网格,然后去匹配搜索这些网格即可。如下图A,D,H,I,F,C,B可能就是一种答案。
为了便于描述,需要为每个网格赋予一个唯一标识,这里称为Box ID。当然这个Box ID如果从Quadtree角度,是可以按固定的规则来一层一层的定义的,这个Box ID肯定也满足前缀树的特征。
在lucene中,使用的Box ID计算方式有些不同,正常情况下一个2D数据就是一个(X,Y)
来表示。lucene中是使用一个double类型来表示的,格式X.Y
, 这主要是用来加快遍历速度,不再分2个坐标来分别遍历。
在一个256*256的网格内,一个box位于(57, 34)
, 那么它的Box ID=57.034
。这里可以看到Y其实是被除以1000之后,添加到X后的。
为什么是1000呢,因为Y在256*256的情况下,离Y最近的10整数幂是1000,100显然不可以。
所以,在一个3000*3000的网格,(57,34)
的Box ID应为57.0034
, 此时Y应该除以10000, 1000已经不能Cover。
参考文档:
https://dzone.com/articles/algorithm-week-spatial
https://medium.com/@waleoyediran/spatial-indexing-with-quadtrees-b998ae49336
http://bl.ocks.org/patricksurry/6478178
http://www.nsshutdown.com/projects/lucene/whitepaper/locallucene_v2.html
]]>大比例尺制图中实际用到的投影有27种+之多,其中最重要的有:墨卡托(Mercator)投影(85%),Lambert等角正割圆锥投影(5%),Albers等积正割圆锥投影,等距圆锥投影, 最为常用的是横轴墨卡托投影。
具体来说,不同的地理区域常用的地图投影方法也不同。
投影种类繁多,命名也极其繁杂,有很多其实是同一类投影,单是叫法很容易混淆。下面进行一个清晰的讲解,让大家了解一下各种投影的区别。
按投影后,在地图上的表示不同,分为等积投影、等角投影
所谓等积投影,就是不同地理位置上投影后的面积是一样的,面积上比例是一样的。
等角投影,主要是指投影后,角度(经度或者纬度)是不变的。
根据投影后的形状不同,主要分为:圆锥投影、圆柱投影、方位投影
在形状上投影后,展开为2D平面,如下图所示。
按照不同的轴线都可以进行投影, 主要有以下几种:正轴投影、横轴投影、斜轴投影等等。
综合来说,依据不同投影性质,投影形状,投影方案,可以唯一确定一种投影方式。
如下图所示:
通过不同的方式组合,投影出来的地图也是不一样的,这些都是标准的称呼,即学术上的称呼。由于不同的人或者机构率先投入使用,所以就有了很多别名。比如墨卡托投影就是一个别名,实际上就是等角正轴圆柱投影。
墨卡托投影就是将三维的地球表示在一个二维平面上的方法之一,也是应用得最广泛的方法。我们平时看到的谷歌地图,百度地图,腾讯地图。都是使用的墨卡托投影。
墨卡托投影的过程其实非常简单,就是将地球投影到一个圆柱再将圆柱展开成平面。
从球心出发射出一条直线,它与球的交点投影后的位置就是这条线与圆柱的交点。
当然,中间的计算过程中会做一些取舍。下面是一个动图,用于理解这个投影过程。
参考文档:
https://www.zhihu.com/question/21161865
https://www.zhihu.com/question/21161865/answer/21052254
https://www.zhihu.com/question/21161865/answer/531923615
https://zhuanlan.zhihu.com/p/24981976
]]>S2其实是来自几何数学中N维球面的一个数学符号S²,它表示的是3维空间内的2维球面。S2 这个库其实是被设计用来解决球面上各种几何问题的。
接下来就看看怎么用 S2 来解决多维空间点索引的问题的。
众所周知,地球是近似一个球体。球体是一个三维的,如何把三维降成一维呢?
球面上的一个点,在直角坐标系中,可以这样表示:
1 | x = r * sin θ * cos φ |
通常地球上的点我们会用经纬度来表示。
再进一步,我们可以和球面上的经纬度联系起来。不过这里需要注意的是,纬度的角度 α 和直角坐标系下的球面坐标 θ 加起来等于 90°。所以三角函数要注意转换。
于是地球上任意的一个经纬度的点,就可以转换成 f(x,y,z)
。
在 S2 中,地球半径被当成单位1了,所以半径不用考虑。x,y,z的值域都被限定在了[-1,1]*[-1,1]*[-1,1]
这个区间之内了。
接下来一步 S2 把球面碾成平面。怎么做的呢?
首先在地球外面套了一个外切的正方体,如下图。
从球心向外切正方体6个面分别投影。S2 是把球面上所有的点都投影到外切正方体的6个面上。
这里简单的画了一个投影图,上图左边的是投影到正方体一个面的示意图,实际上影响到的球面是右边那张图。
从侧面看,其中一个球面投影到正方体其中一个面上,边缘与圆心的连线相互之间的夹角为90°,但是和x,y,z轴的角度是45°。我们可以在球的6个方向上,把45°的辅助圆画出来,见下图左边。
上图左边的图画了6个辅助线,蓝线是前后一对,红线是左右一对,绿线是上下一对。分别都是45°的地方和圆心连线与球面相交的点的轨迹。这样我们就可以把投影到外切正方体6个面上的球面画出来,见上图右边。
投影到正方体以后,我们就可以把这个正方体展开了。
一个正方体的展开方式有很多种。不管怎么展开,最小单元都是一个正方形。
在 Google S2 中,它是把地球展开成如下的样子:
这样第一步的球面坐标进一步的被转换成 f(x,y,z) -> g(face,u,v)
,其中:
face是正方形的6个面(0~5),u,v对应的是六个面中的一个面上的坐标。
上一步我们把球面上的球面矩形投影到正方形的某个面上,形成的形状类似于矩形,但是由于球面上角度的不同,最终会导致即使是投影到同一个面上,每个矩形的面积也不大相同。
上图就表示出了球面上个一个球面矩形投影到正方形一个面上的情况。
经过实际计算发现,最大的面积和最小的面积相差5.2倍。见上图左边。相同的弧度区间,在不同的纬度上投影到正方体上的面积不同。
现在就需要修正各个投影出来形状的面积。如何选取合适的映射修正函数就成了关键。目标是能达到上图右边的样子,让各个矩形的面积尽量相同。
这块转换的的不同如下表所示:
面积比率 | 边比率 | 对角线比率 | ToPointRaw | ToPoint | FromPoint | |
---|---|---|---|---|---|---|
线性变换 | 5.200 | 2.117 | 2.959 | 0.020 | 0.087 | 0.085 |
tan()变换 | 1.414 | 1.414 | 1.704 | 0.237 | 0.299 | 0.258 |
二次变换 | 2.082 | 1.802 | 1.932 | 0.033 | 0.096 | 0.108 |
线性变换是最快的变换,但是变换比最小。
tan变换可以使每个投影以后的矩形的面积更加一致,最大和最小的矩形比例仅仅只差0.414。可以说非常接近了。但是 tan函数的调用耗时较长。如果把所有点都按照这种方式计算的话,性能将会降低3倍。
最后google默认选择的是二次变换,这是一个近似切线的投影曲线。它的计算速度远远快于 tan,大概是 tan计算的3倍速度。生成的投影以后的矩形大小也类似。不过最大的矩形和最小的矩形相比依旧有2.082的比率。
上表中,ToPoint
和FromPoint
分别是把单位向量转换到 Cell ID 所需要的毫秒数、把 Cell ID 转换回单位向量所需的毫秒数(Cell ID 就是投影到正方体6个面,某个面上矩形的 ID,矩形称为 Cell,它对应的 ID 称为 Cell ID)。ToPointRaw
是某种目的下,把 Cell ID 转换为非单位向量所需的毫秒数。
所以投影之后的修正函数三种变换应该如下:
1 | // 线性转换 |
经过修正变换以后,u,v都变换成了s,t。值域也发生了变化。u,v的值域是[-1,1],变换以后,是s,t的值域是[0,1]。
至此,小结一下,球面上的点S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t)。目前总共转换了4步,球面经纬度坐标转换成球面(x,y,z)坐标,再转换成外切正方体投影面上的坐标g(face, u,v),最后变换成修正后的坐标h(face, s, t)。
在 S2 算法中,默认划分 Cell 的等级是30,也就是说把一个正方形划分为 2^30 * 2^30个小的正方形。
那么上一步的h(face , s, t)映射到这个正方形上面来,对应该如何转换呢?
s,t的值域是[0,1],现在值域要扩大到[0, 2^30-1]。
1 | inline int S2CellId::STtoIJ(double s) { |
到这一步,是h(face,s,t) -> H(face,i,j)。
最后一步,如何把 i,j 和希尔伯特曲线上的点关联起来呢?
1 | const ( |
在变换之前,先来解释一下定义的一些变量。
posToIJ 代表的是一个矩阵,里面记录了一些单元希尔伯特曲线的位置信息。
把 posToIJ 数组里面的信息用图表示出来,如下图:
同理,把 ijToPos 数组里面的信息用图表示出来,如下图:
posToOrientation 数组里面装了4个数字,分别是1,0,0,3。
lookupIJ 和 lookupPos 分别是两个容量为1024的数组。这里面分别对应的就是希尔伯特曲线 ID 转换成坐标轴 IJ 的转换表,和坐标轴 IJ 转换成希尔伯特曲线 ID 的转换表。
1 |
|
这里是初始化的递归函数,在希尔伯特曲线的标准顺序中可以看到是有4个格子,并且格子都有顺序的,所以初始化要遍历满所有顺序。入参的第4个参数,就是从0 - 3 。
1 | // initLookupCell initializes the lookupIJ table at init time. |
上面这个函数是生成希尔伯特曲线的。我们可以看到有一处对pos << 2
的操作,这里是把位置变换到第一个4个小格子中,所以位置乘以了4。
由于初始设置的lookupBits = 4
,所以i,j的变化范围是从[0,15],总共有16*16=256个,然后i,j坐标是表示的4个格子,再细分,lookupBits = 4
这种情况下能表示的点的个数就是256*4=1024个。这也正好是 lookupIJ 和 lookupPos 的总容量。
画一个局部的图,i,j从 0-7 变化。
上图是一个4阶希尔伯特曲线。初始化的实际过程就是初始化4阶希尔伯特上的1024个点的坐标与坐标轴上的x,y轴的对应关系表。
举个例子,下表是i,j在递归过程中产生的中间过程。下表是lookupPos 表计算过程。
(i,j) | ij | ij 计算过程 | lookupPos[i j] | lookupPos[i j]计算过程 | 实际坐标 |
---|---|---|---|---|---|
(0,0) | 0 | 0 | 0 | 0 | (0,0) |
(1,0) | 64 | (1*16+0)*4=64 | 5 | 1*4+1=5 | (3,0) |
(1,1) | 68 | (1*16+1)*4=68 | 9 | 2*4+1=9 | (3,2) |
(0,1) | 4 | (0*16+1)*4=4 | 14 | 3*4+2=14 | (0,2) |
(0,2) | 8 | (0*16+2)*4=8 | 17 | 4*4+1=17 | (1,4) |
(0,3) | 12 | (0*16+3)*4=12 | 20 | 5*4+0=20 | (0,6) |
(1,3) | 76 | (1*16+3)*4=76 | 24 | 6*4+0=24 | (2,6) |
(1,2) | 72 | (1*16+2)*4=72 | 31 | 7*4+3=31 | (3,4) |
(2,2) | 136 | (2*16+2)*4=136 | 33 | 8*4+1=33 | (5,4) |
取出一行详细分析一下计算过程。
假设当前(i,j)=(0,2),ij 的计算过程是把 i 左移4位再加上 j,整体结果再左移2位。目的是为了留出2位的方向位置。ij的前4位是i,接着4位是j,最后2位是方向。这样计算出ij的值就是8 。
接着计算lookupPos[i j]的值。从上图中可以看到(0,2)代表的单元格的4个数字是16,17,18,19 。计算到这一步,pos的值为4(pos是专门记录生成格子到第几个了,总共pos的值会循环0-255)。pos代表的是当前是第几个格子(4个小格子组成),当前是第4个,每个格子里面有4个小格子。所以4*4就可以偏移到当前格子的第一个数字,也就是16 。posToIJ 数组里面会记录下当前格子的形状。从这里我们从中取出 orientation 。
看上图,16,17,18,19对应的是 posToIJ 数组轴旋转的情况,所以17是位于轴旋转图的数字1代表的格子中。这时 orientation = 1 。
这样 lookupPos[i j] 表示的数字就计算出来了,4*4+1=17 。这里就完成了i,j与希尔伯特曲线上数字的对应。
那如何由希尔伯特曲线上的数字对应到实际的坐标呢?
lookupIJ 数组里面记录了反向的信息。lookupIJ 数组 和 lookupPos 数组存储的信息正好是反向的。lookupIJ 数组 下表存的值是 lookupPos 数组 的下表。我们查 lookupIJ 数组 ,lookupIJ[17]的值就是8,对应算出来(i,j)=(0,2)。这个时候的i,j还是大格子。还是需要借助 posToIJ 数组 里面描述的形状信息。当前形状是轴旋转,之前也知道 orientation = 1,由于每个坐标里面有4个小格子,所以一个i,j代表的是2个小格子,所以需要乘以2,再加上形状信息里面的方向,可以计算出实际的坐标 (0 * 2 + 1 , 2 * 2 + 0) = ( 1,4) 。
至此,整个球面坐标的坐标映射就已经完成了。
球面上的点S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t) -> H(face,i,j) -> CellID。目前总共转换了6步,球面经纬度坐标转换成球面xyz坐标,再转换成外切正方体投影面上的坐标,最后变换成修正后的坐标,再坐标系变换,映射到 [0,2^30^-1]区间,最后一步就是把坐标系上的点都映射到希尔伯特曲线上。
最后需要来谈谈 S2 Cell ID 数据结构,这个数据结构直接关系到不同 Level 对应精度的问题。
上图左图中对应的是 Level 30 的情况,右图对应的是 Level 24 的情况。(2的多少次方,角标对应的也就是 Level 的值)
在 S2 中,每个 CellID 是由64位的组成的。可以用一个 uint64 存储。开头的3位表示正方体6个面中的一个,取值范围[0,5]。3位可以表示0-7,但是6,7是无效值。
64位的最后一位是1,这一位是特意留出来的。用来快速查找中间有多少位。从末尾最后一位向前查找,找到第一个不为0的位置,即找到第一个1。这一位的前一位到开头的第4位(因为前3位被占用)都是可用数字。
绿色格子有多少个就能表示划分多少格。上图左图,绿色的有60个格子,于是可以表示[0,2^30^ -1] * [0,2^30^ -1]这么多个格子。上图右图中,绿色格子只有48个,那么就只能表示[0,2^24^ -1]*[0,2^24^ -1]这么多个格子。
那么不同 level 可以代表的网格的面积究竟是多大呢?
由上一章我们知道,由于投影的原因,所以导致投影之后的面积依旧有大小差别。
层(level) | 最小面积 | 最大面积 | 平均面积 | 单位 | Random cell 1 (UK) min edge length | Random cell 1 (UK) max edge length | Random cell 2 (US) min edge length | Random cell 2 (US) max edge length | Cell个数 |
---|---|---|---|---|---|---|---|---|---|
00 | 85011012.19 | 85011012.19 | 85011012.19 | km2 | 7842 km | 7842 km | 7842 km | 7842 km | 6 |
01 | 21252753.05 | 21252753.05 | 21252753.05 | km2 | 3921 km | 5004 km | 3921 km | 5004 km | 24 |
02 | 4919708.23 | 6026521.16 | 5313188.26 | km2 | 1825 km | 2489 km | 1825 km | 2489 km | 96 |
03 | 1055377.48 | 1646455.50 | 1328297.07 | km2 | 840 km | 1167 km | 1130 km | 1310 km | 384 |
04 | 231564.06 | 413918.15 | 332074.27 | km2 | 432 km | 609 km | 579 km | 636 km | 1536 |
05 | 53798.67 | 104297.91 | 83018.57 | km2 | 210 km | 298 km | 287 km | 315 km | 6K |
06 | 12948.81 | 26113.30 | 20754.64 | km2 | 108 km | 151 km | 143 km | 156 km | 24K |
07 | 3175.44 | 6529.09 | 5188.66 | km2 | 54 km | 76 km | 72 km | 78 km | 98K |
08 | 786.20 | 1632.45 | 1297.17 | km2 | 27 km | 38 km | 36 km | 39 km | 393K |
09 | 195.59 | 408.12 | 324.29 | km2 | 14 km | 19 km | 18 km | 20 km | 1573K |
10 | 48.78 | 102.03 | 81.07 | km2 | 7 km | 9 km | 9 km | 10 km | 6M |
11 | 12.18 | 25.51 | 20.27 | km2 | 3 km | 5 km | 4 km | 5 km | 25M |
12 | 3.04 | 6.38 | 5.07 | km2 | 1699 m | 2 km | 2 km | 2 km | 100M |
13 | 0.76 | 1.59 | 1.27 | km2 | 850 m | 1185 m | 1123 m | 1225 m | 402M |
14 | 0.19 | 0.40 | 0.32 | km2 | 425 m | 593 m | 562 m | 613 m | 1610M |
15 | 47520.30 | 99638.93 | 79172.67 | m2 | 212 m | 296 m | 281 m | 306 m | 6B |
16 | 11880.08 | 24909.73 | 19793.17 | m2 | 106 m | 148 m | 140 m | 153 m | 25B |
17 | 2970.02 | 6227.43 | 4948.29 | m2 | 53 m | 74 m | 70 m | 77 m | 103B |
18 | 742.50 | 1556.86 | 1237.07 | m2 | 27 m | 37 m | 35 m | 38 m | 412B |
19 | 185.63 | 389.21 | 309.27 | m2 | 13 m | 19 m | 18 m | 19 m | 1649B |
20 | 46.41 | 97.30 | 77.32 | m2 | 7 m | 9 m | 9 m | 10 m | 7T |
21 | 11.60 | 24.33 | 19.33 | m2 | 3 m | 5 m | 4 m | 5 m | 26T |
22 | 2.90 | 6.08 | 4.83 | m2 | 166 cm | 2 m | 2 m | 2 m | 105T |
23 | 0.73 | 1.52 | 1.21 | m2 | 83 cm | 116 cm | 110 cm | 120 cm | 422T |
24 | 0.18 | 0.38 | 0.30 | m2 | 41 cm | 58 cm | 55 cm | 60 cm | 1689T |
25 | 453.19 | 950.23 | 755.05 | cm2 | 21 cm | 29 cm | 27 cm | 30 cm | 7e15 |
26 | 113.30 | 237.56 | 188.76 | cm2 | 10 cm | 14 cm | 14 cm | 15 cm | 27e15 |
27 | 28.32 | 59.39 | 47.19 | cm2 | 5 cm | 7 cm | 7 cm | 7 cm | 108e15 |
28 | 7.08 | 14.85 | 11.80 | cm2 | 2 cm | 4 cm | 3 cm | 4 cm | 432e15 |
29 | 1.77 | 3.71 | 2.95 | cm2 | 12 mm | 18 mm | 17 mm | 18 mm | 1729e15 |
30 | 0.44 | 0.93 | 0.74 | cm2 | 6 mm | 9 mm | 8 mm | 9 mm | 7e18 |
level 0 就是正方体的六个面之一。地球表面积约等于510,100,000 km^2。level 0 的面积就是地球表面积的六分之一。level 30 能表示的最小的面积0.48cm^2,最大也就0.93cm^2 。
Geohash 有12级,从5000km 到 3.7cm。中间每一级的变化比较大。有时候可能选择上一级会大很多,选择下一级又会小一些。比如选择字符串长度为4,它对应的 cell 宽度是39.1km,需求可能是50km,那么选择字符串长度为5,对应的 cell 宽度就变成了156km,瞬间又大了3倍了。这种情况选择多长的 Geohash 字符串就比较难选。选择不好,每次判断可能就还需要取出周围的8个格子再次进行判断。Geohash 需要 12 bytes 存储。
S2 有30级,从 0.7cm² 到 85,000,000km² 。中间每一级的变化都比较平缓,接近于4次方的曲线。所以选择精度不会出现 Geohash 选择困难的问题。S2 的存储只需要一个 uint64 即可存下。
S2 库里面不仅仅有地理编码,还有其他很多几何计算相关的库。地理编码只是其中的一小部分。本文没有介绍到的 S2 的实现还有很多很多,各种向量计算,面积计算,多边形覆盖,距离问题,球面球体上的问题,它都有实现。
S2 还能解决多边形覆盖的问题。比如给定一个城市,求一个多边形刚刚好覆盖住这个城市。
如上图,生成的多边形刚刚好覆盖住下面蓝色的区域。这里生成的多边形可以有大有小。不管怎么样,最终的结果也是刚刚覆盖住目标物。
用相同的 Cell 也可以达到相同的目的,上图就是用相同 Level 的 Cell 覆盖了整个圣保罗城市。
这些都是 Geohash 做不到的。
多边形覆盖利用的是近似的算法,虽然不是严格意义上的最优解,但是实践中效果特别好。
额外值得说明的一点是,Google 文档上强调了,这种多边形覆盖的算法虽然对搜索和预处理操作非常有用,但是“不可依赖”的。理由也是因为是近似算法,并不是唯一最优算法,所以得到的解会依据库的不同版本而产生变化。
先来看看经纬度和 CellID 的转换,以及矩形面积的计算。
1 | latlng := s2.LatLngFromDegrees(31.232135, 121.41321700000003) |
这里 Parent 方法参数可以直接指定返回改点的对应 level 的 CellID。
上面那些方法打印出来的结果如下:
1 | latlng = [31.2321350, 121.4132170] |
再举一个覆盖多边形的例子。我们先随便创建一个区域。
1 | rect = s2.RectFromLatLng(s2.LatLngFromDegrees(48.99, 1.852)) |
覆盖参数设置成 level 2 - 20,最多的 Cell 的个数是10个。
接着我们把 Cell 至多改成20个。
最后再改成30个。
可以看到相同的 level 的范围,cell 个数越多越精确目标范围。
这里是匹配矩形区域,匹配圆形区域也同理。
代码就不贴了,与矩形类似。这种功能 Geohash 就做不到,需要自己手动实现了。
最后举一个多边形匹配的例子。
1 |
|
这里用到了 Loop 类,这个类的初始化的最小单元是 Point,Point 是由经纬度产生的。最重要的一点需要注意的是,多边形是按照逆时针方向,左手边区域确定的。
如果一不小心点是按照顺时针排列的话,那么多边形确定的是外层更大的面,意味着球面除去画的这个多边形以外的都是你想要的多边形。
举个具体的例子,假如我们想要画的多边形是下图这个样子的:
如果我们用顺时针的方式依次存储 Point 的话,并用顺时针的这个数组去初始化 Loop,那么就会出现“奇怪”的现象。如下图:
这张图左上角的顶点和右下角的顶点在地球上是重合的。如果把这个地图重新还原成球面,那么就是整个球面中间挖空了一个多边形。
把上图放大,如下图:
这样就可以很清晰的看到了,中间被挖空了一个多边形。造成这种现象的原因就是按照顺时针的方向存储了每个点,那么初始化一个 Loop 的时候就会选择多边形外圈的更大的多边形。
使用 Loop 一定要切记,顺时针表示的是外圈多边形,逆时针表示的是内圈多边形。
多边形覆盖的问题同之前举的例子一样:
相同的 MaxLevel = 20,MinLevel = 1,MaxCells 不同,覆盖的精度就不同,下图是 MaxCells = 100 的情况:
下图是 MaxCells = 1000 的情况:
从这个例子也可以看出来 相同的 Level 范围,MaxCells 越精度,覆盖的精度越高。
S2 主要能用在以下 8 个地方:
参考文档:
]]>在Lucene-Solr中,提供了空间搜索能力,它主要提供了以下4种FieldType来支持:
LatLonPointSpatialField
LatLonType
(不再使用) , 以及非地理位置搜索版本 PointType
SpatialRecursivePrefixTreeFieldType
(简写RPT), 包含衍生出的RptWithGeometrySpatialField
BBoxField
LatLonPointSpatialField是用来取代LatLonType的一个FieldType。Lucene在7.0之后,推出了一种基于BKD树而实现的专用于处理多维数据的[索引格式,它原则上可以高效的处理任意维的数据。从一维的(int, long, double)数据类型、二维的点、面类型、三维的空间类型,甚至更多维度数据(暂未实现而已)。
1 | <fieldType name="location" class="solr.LatLonPointSpatialField" docValues="true"/> |
BBoxField是一种专门用来索引四方形数据的FieldType, 提供的功能包括:图形相交、图形内部、图形包含、图形相等。
1 | <field name="bbox" type="bbox" /> |
SpatialRecursivePrefixTreeFieldType则是最为通用的空间搜索解决方案。下面将对其重点来进行解释。
在Solr中提供了scheme.xml文件用来定义索引类型和字段,如下所示:
1 | <fieldType name="location_rpt" |
对于重要的属性说明如下:
属性 | 值 | 说明 |
---|---|---|
name | location_rpt | fieldType名称,任意合法值 |
class | solr.SpatialRecursivePrefixTreeFieldType | 用于深度遍历前缀树的FieldType,主要用于获得基于Lucene中的RecursivePrefixTreeStrategy。 |
spatialContextFactory | JTS /Geo3D | 用来处理多边形定义和解析的类. 如果不填,则不能处理多边形数据。 |
geo | true/false | true代表是地理数据。false则代表是正常的2D平面数据,采用Euclidean/Cartesian计算距离。 |
format | WKT /GeoJSON | 默认是WKT |
distanceUnits | degrees , kilometers , miles | 用来作为maxDistErr, distErr, d, geodist等的单位。当geo=true, 默认kilometers. 当geo=false, 默认是degrees. |
distErrPct | 0.025 | 取值范围[0.0, 0.5]。用于进行精度控制。越低的值,精度越高,但速度越慢。默认值是0.025,通常不建议改。 |
maxDistErr maxLevels | 0.000009 | maxDistErr定义为0.000009度,根据GeohashUtils.lookupHashLenForWidthHeight(0.000009, 0.000009)算出编码长度为11位,精度在1米左右 |
worldBounds | 定义边界。默认是ENVELOP(minX, maxX, maxY, minY)。如果geo=true, 默认就是经纬度。否则需要自行指定。 | |
distCalculator | 设置距离计算算法,geo=true默认是haversine,geo=false默认是cartesian(笛卡尔层),值可以为”lawOfCosines”(余弦定理), “vincentySphere”(文森特球面公式) 或 “cartesian^2” | |
prefixTree | geohash /quad | 使用什么方案来网格化整个世界。geo=true时,默认使用geohash,否则使用quad |
maxLevels | 索引网格的最大深度。优先级高于maxDistErr。通常11时,精度在1m左右,满足大部分需求。 |
其中prefixTree,可以用来指定使用geohash还是quad,分别对应于是GeohashPrefixTree类和QuadPrefixTree类,其中GeohashPrefixTree对应GeoHash算法,QuadPrefixTree对应Quad算法。
GeohashPrefixTree与QuadPrefixTree都继承SpatialPrefixTree这个前缀树基类,都使用了分层策略,主要索引和查询逻辑⼀样。不同点在于它们的maxLevels不一样,获取子cell的方式不一样,GeohashPrefixTree每层有32个子cell(编码base32),QuadPrefixTree 只有有4个子cell(笛卡尔分层)。
简单理解,geohash是一个32叉树,而quad是一个4叉树。
构建索引代码示例(Point类型的索引):
1 | doc.setField(”poi_location_p", "32.52162,120.31778") //point类型 |
构建流程:
空间索引创建过程
下面主要说明Point类型的term创建过程。
1、将空间索引域的shapeStr解析成相应的Shape(这里指Point,复杂Shape如Polygon要使用JTS中的WTKReader来解析)。
2、创建索引域,具体过程参考org.apache.lucene.spatial.prefix.RecursivePrefixTreeStrategy中的createIndexableFields
方法。
a、根据distErrPct字段,计算距离的误差值,对于Point来说默认为0(而对于非Point类型来说,是通过外包矩形中心点到矩形顶点的距离再乘以distErrPct来计算误差值的)
1 | double distErr = SpatialArgs.calcDistanceFromErrPct(shape, distErrPct, ctx); |
b、根据上述计算出的误差值,得到索引的geohash编码长度,对于Point类型来说值为maxLevels。
1 | public int getLevelForDistance(double dist) { |
c、根据编码长度得到满足所有条件的cells(每个cell表示一个前缀值),并将Cells都放在CellTokenStream中,同时构建索引域。Point类型每个Cell表示geohash的一个前缀值。
1 | public List<Cell> getCells(Point p, int detailLevel, boolean inclParents){ |
3、构建存取域存储索引
1 | if (field.stored()) { |
4、结果
如经纬度41.79452,123.41555,对应的geohash为wxrvb2kqexu
(maxLevels=11), 则其对应的term有11个(如w、wx、wxr、wxrv…
)。
查询语法示例:
1 | q={!geofilt pt=45.15,-93.85 sfield=poi_location_p d=5 score=distance} |
涉及到的字段说明:
字段 | 含义 |
---|---|
q | 查询条件,如 q=poi_id:134567 |
fq | 过滤条件,如 fq=store_name:农业 |
fl | 返回字段,如fl=poi_id,store_name |
pt | 坐标点,如pt=54.729696,-98.525391 |
d | 搜索半径,如 d=10表示10km范围内 |
sfield | 指定坐标索引字段,如sfield=geo |
defType | 指定查询类型 可以取 dismax和edismax,edismax支持boost函数相乘作用,dismax是通过累加方式计算最后的score. |
qf | 指定权重字段:qf=store_name^10+poi_location_p^5 |
score | 排序字段 根据qf定义的字段defType定义的方式计算得到score排序输出 |
其中有几种常见的Solr支持语法,其中有几种常见的Solr支持的几何操作:
日常常见的需求主要分为2类:
1. 范围搜索(周边商家搜索)
范围搜索支持2种范围确定方式:
geofilt方式:根据搜索半径过滤结果,返回以pt为圆心d为半径的圆内所有点,如左图所示返回圆形内所有点
bbox方式:根据具体的域过滤结果,不仅返回以pt为圆心d为半径的圆内所有点,还包括域内其他点,返回矩形框内所有点,如下图所示:
范围搜索的情况很多,下面列举一些常用场景的查询语法:
不需要排序的场景
fq={!geofilt pt=45.15,-93.85 sfield=geo d=5}
fq={!bbox pt=45.15,-93.85 sfield=geo d=5}
需要排序的场景,较复杂
1 | &fl=*,score&sort=score asc&q={!geofilt score=distance sfield=poi_location_p pt=54.729696,-98.525391 d=10}。 |
1 | &fl=*,score&sort=score asc&q={!geofilt score=reciDistance sfield=poi_location_p pt=54.729696,-98.525391 d=10} |
1 | &fl=*,score&sort=score asc&q={!geofilt score=distance filter=false sfield=poi_location_p pt=54.729696,-98.525391 d=10} |
1 | &fl=*,score& fq=store_name:农业&sort=score asc&q={!geofilt score=distance sfield=poi_location_p pt=54.729696,-98.525391 d=10} |
1 | &fl=*,score& fq=store_name:农业&sort=score asc&q={!geofilt score=distance sfield=poi_location_p pt=54.729696,-98.525391 d=10} &defType=dismax&qf=store_name^10+poi_location_p^5 |
2.图形搜索
1 | &fl=*,score&sort=score asc&q=poi_location_p:"Intersects(LINESTRING(116.38263702392578 39.86653357724533,116.4935302734375 39.8578370694061) d=1 |
1 | &fl=*,score&sort=score asc&q=poi_location_p:"Intersects(POLYGON ((116.37714385986328 39.88392328618825,116.46709442138672 39.86627006289872,116.40392303466797 39.83358644035512,116.33525848388672 39.85124807212413,116.37714385986328 39.88392328618825))) distErrPct=0 |
3)复杂(如自相交)多边形范围内的商家
b 对于这种自相交的多边形,Solr默认认为是非法的,会抛出类似这样的异常: com.spatial4j.core.exception.InvalidShapeException: Self-intersection at or near point (116.4272689819336 39.875755941712825, NaN),因此在将参数传入solr前,需要基于标准 Douglas-Peucker Algorithm 算法对多边形进行处理,处理后能去掉自相交的部分,效果如示意图所示:
处理后,自相交的部分变为3和6两个点。当然经过处理后的多边形数据会损失一些精确度。
简化前:
1 | POLYGON((116.4272689819336 39.875755941712825,116.50142669677734 39.84966661865515,116.4059829711914 39.83068633533497,116.48357391357422 39.8873480121113,116.47808074951172 39.827258780634594,116.47773742675781 39.8177661982179,116.41319274902344 39.87048617098581,116.4272689819336 39.875755941712825)) |
简化后:
1 | POLYGON ((116.4272689819336 39.875755941712825,116.45455487823865 39.866156528285366, 116.48357391357422 39.8873480121113, 116.48079281261445 39.85692579689432,116.50142669677734 39.84966661865515,116.47973485573046 39.84535290095104,116.47808074951172 39.827258780634594,116.47773742675781 39.8177661982179,116.45096720829837 39.8396320628827,116.4059829711914 39.83068633533497,116.43551562011505 39.85225289138226,116.41319274902344 39.87048617098581,116.4272689819336 39.875755941712825)) |
简化后查询语法变为:
1 | &fl=*,score&sort=score asc&q=poi_location_p:"Intersects(POLYGON ((116.4272689819336 39.875755941712825,116.45455487823865 39.866156528285366, 116.48357391357422 39.8873480121113, 116.48079281261445 39.85692579689432,116.50142669677734 39.84966661865515,116.47973485573046 39.84535290095104,116.47808074951172 39.827258780634594,116.47773742675781 39.8177661982179,116.45096720829837 39.8396320628827,116.4059829711914 39.83068633533497,116.43551562011505 39.85225289138226,116.41319274902344 39.87048617098581,116.4272689819336 39.875755941712825)) distErrPct=0 |
无论是范围查询还是图形搜索,他们的查询基本流程都类似,主要分为下面2步:
1、解析查询,生成Query树:获得相应的QParse(SpatialFilterQParser),对查询串进行语法解析,获得查询串的各个参数,并且获得相应的查询Query(包括相应的Filter),其中也计算了查询Shape的一些属性,如最大索引长度detailLevel。
1 | Query result = null; |
2、查询:SolrIndexSearch.search()进行创建Weight树和Score树,利用不同的filter和score策略得到符合条件的docIdSet。而对于几种不同的几何图形关系,Solr提供了几种不同的filter类来计算,这些filter都继承AbstractPrefixTreeFilter类,简单来说就是获取与查询Shape相交的所有子Cell,然后再与term进行匹配的过程。
参考文档:
https://tech.meituan.com/2014/09/02/solr-spatial-search.html
]]>Geohash 是一种地理编码,由 Gustavo Niemeyer 在2008年发明的,G.M.Morton在1966年做过类似的工作。。它是一种分级的数据结构,把空间划分为网格。Geohash 属于空间填充曲线中的 Z 阶曲线(Z-order curve)的实际应用。
何为 Z 阶曲线?
上图就是 Z 阶曲线。这个曲线比较简单,生成它也比较容易,只需要把每个 Z 首尾相连即可。
Z 阶曲线同样可以扩展到三维空间。只要 Z 形状足够小并且足够密,也能填满整个三维空间。
说到这里可能读者依旧一头雾水,不知道 Geohash 和 Z 曲线究竟有啥关系?其实 Geohash算法 的理论基础就是基于 Z 曲线的生成原理。继续说回 Geohash。
Geohash 能够提供任意精度的分段级别。一般分级从 1-12 级。
字符串长度 | cell 宽度 | cell 高度 | ||
---|---|---|---|---|
1 | ≤ | 5,000km | × | 5,000km |
2 | ≤ | 1,250km | × | 625km |
3 | ≤ | 156km | × | 156km |
4 | ≤ | 39.1km | × | 19.5km |
5 | ≤ | 4.89km | × | 4.89km |
6 | ≤ | 1.22km | × | 0.61km |
7 | ≤ | 153m | × | 153m |
8 | ≤ | 38.2m | × | 19.1m |
9 | ≤ | 4.77m | × | 4.77m |
10 | ≤ | 1.19m | × | 0.596m |
11 | ≤ | 149mm | × | 149mm |
12 | ≤ | 37.2mm | × | 18.6mm |
还记得引语里面提到的问题么?这里我们就可以用 Geohash 来解决这个问题。
我们可以利用 Geohash 的字符串长短来决定要划分区域的大小。这个对应关系可以参考上面表格里面 cell 的宽和高。一旦选定 cell 的宽和高,那么 Geohash 字符串的长度就确定下来了。这样我们就把地图分成了一个个的矩形区域了。
地图上虽然把区域划分好了,但是还有一个问题没有解决,那就是如何快速的查找一个点附近邻近的点和区域呢?
Geohash 有一个和 Z 阶曲线相关的性质,那就是一个点附近的地方(但不绝对) hash 字符串总是有公共前缀,并且公共前缀的长度越长,这两个点距离越近。
由于这个特性,Geohash 就常常被用来作为唯一标识符。用在数据库里面可用 Geohash 来表示一个点。Geohash 这个公共前缀的特性就可以用来快速的进行邻近点的搜索。越接近的点通常和目标点的 Geohash 字符串公共前缀越长(但是这不一定,也有特殊情况,下面举例会说明)
Geohash 也有几种编码形式,常见的有2种,base 32 和 base 36。
Decimal | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Base 32 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | b | c | d | e | f | g |
Decimal | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Base 32 | h | j | k | m | n | p | q | r | s | t | u | v | w | x | y | z |
base 36 的版本对大小写敏感,用了36个字符,“23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX”。
Decimal | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Base 36 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | b | B | C | d | D | F | g | G | h | H | j |
Decimal | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Base 36 | J | K | I | L | M | n | N | P | q | Q | r | R | t | T | V | W | X |
接下来的举例以 base-32 为例。举个例子。
上图是一个地图,地图中间有一个美罗城,假设需要查询距离美罗城最近的餐馆,该如何查询?
第一步我们需要把地图网格化,利用 geohash。通过查表,我们选取字符串长度为6的矩形来网格化这张地图。
经过查询,美罗城的经纬度是[31.1932993, 121.43960190000007]。
先处理纬度。地球的纬度区间是[-90,90]。把这个区间分为2部分,即[-90,0),[0,90]。31.1932993位于(0,90]区间,即右区间,标记为1。然后继续把(0,90]区间二分,分为[0,45),[45,90],31.1932993位于[0,45)区间,即左区间,标记为0。一直划分下去。
左区间 | 中值 | 右区间 | 二进制结果 |
---|---|---|---|
-90 | 0 | 90 | 1 |
0 | 45 | 90 | 0 |
0 | 22.5 | 45 | 1 |
22.5 | 33.75 | 45 | 0 |
22.5 | 28.125 | 33.75 | 1 |
28.125 | 30.9375 | 33.75 | 1 |
30.9375 | 32.34375 | 33.75 | 0 |
30.9375 | 31.640625 | 32.34375 | 0 |
30.9375 | 31.2890625 | 31.640625 | 0 |
30.9375 | 31.1132812 | 31.2890625 | 1 |
31.1132812 | 31.2011718 | 31.2890625 | 0 |
31.1132812 | 31.1572265 | 31.2011718 | 1 |
31.1572265 | 31.1791992 | 31.2011718 | 1 |
31.1791992 | 31.1901855 | 31.2011718 | 1 |
31.1901855 | 31.1956786 | 31.2011718 | 0 |
再处理经度,一样的处理方式。地球经度区间是[-180,180]
左区间 | 中值 | 右区间 | 二进制结果 |
---|---|---|---|
-180 | 0 | 180 | 1 |
0 | 90 | 180 | 1 |
90 | 135 | 180 | 0 |
90 | 112.5 | 135 | 1 |
112.5 | 123.75 | 135 | 0 |
112.5 | 118.125 | 123.75 | 1 |
118.125 | 120.9375 | 123.75 | 1 |
120.9375 | 122.34375 | 123.75 | 0 |
120.9375 | 121.640625 | 122.34375 | 0 |
120.9375 | 121.289062 | 121.640625 | 1 |
121.289062 | 121.464844 | 121.640625 | 0 |
121.289062 | 121.376953 | 121.464844 | 1 |
121.376953 | 121.420898 | 121.464844 | 1 |
121.420898 | 121.442871 | 121.464844 | 0 |
121.420898 | 121.431885 | 121.442871 | 1 |
纬度产生的二进制是101011000101110
,经度产生的二进制是110101100101101
,按照“偶数位放经度,奇数位放纬度”的规则,重新组合经度和纬度的二进制串,生成新的:111001100111100000110011110110
,最后一步就是把这个最终的字符串转换成字符,对应需要查找 base-32 的表。11100 11001 11100 00011 00111 10110
转换成10进制是 28 25 28 3 7 22
,查表编码得到最终结果: wtw37q
。
我们还可以把这个网格周围8个各自都计算出来。
从地图上可以看出,这邻近的9个格子,前缀都完全一致。都是wtw37
。
如果我们把字符串再增加一位,会有什么样的结果呢?Geohash 增加到7位。
当Geohash 增加到7位的时候,网格更小了,美罗城的 Geohash 变成了 wtw37qt。
看到这里,读者应该已经清楚了 Geohash 的算法原理了。咱们把6位和7位都组合到一张图上面来看。
可以看到中间大格子的 Geohash 的值是 wtw37q,那么它里面的所有小格子前缀都是 wtw37q。可以想象,当 Geohash 字符串长度为5的时候,Geohash 肯定就为 wtw37 了。
接下来解释之前说的 Geohash 和 Z 阶曲线的关系。回顾最后一步合并经纬度字符串的规则,“偶数位放经度,奇数位放纬度”。读者一定有点好奇,这个规则哪里来的?凭空瞎想的?其实并不是,这个规则就是 Z 阶曲线。看下图:
x 轴就是纬度,y轴就是经度。经度放偶数位,纬度放奇数位就是这样而来的。
最后有一个精度的问题。
Geohash 长度 | Lat位数 | Lng位数 | Lat误差 | 误差 | km误差 |
---|---|---|---|---|---|
1 | 2 | 3 | ±23 | ±23 | ±2500 |
2 | 5 | 5 | ±2.8 | ±5.6 | ±630 |
3 | 7 | 8 | ±0.70 | ±0.70 | ±78 |
4 | 10 | 10 | ±0.087 | ±0.18 | ±20 |
5 | 12 | 13 | ±0.022 | ±0.022 | ±2.4 |
6 | 15 | 15 | ±0.0027 | ±0.0055 | ±0.61 |
7 | 17 | 18 | ±0.00068 | ±0.00068 | ±0.076 |
8 | 20 | 20 | ±0.000086 | ±0.000172 | ±0.019 |
9 | 22 | 23 | ±0.000021 | ±0.000021 | ±0.00478 |
10 | 25 | 25 | ±0.00000268 | ±0.00000536 | ±0.0005971 |
11 | 27 | 28 | ±0.00000067 | ±0.00000067 | ±0.0001492 |
12 | 30 | 30 | ±0.00000008 | ±0.00000017 | ±0.0000186 |
Geohash 的优点很明显,它利用 Z 阶曲线进行编码。而 Z 阶曲线可以将二维或者多维空间里的所有点都转换成一维曲线。在数学上成为分形维。并且 Z 阶曲线还具有局部保序性。
Z 阶曲线通过交织点的坐标值的二进制表示来简单地计算多维度中的点的z值。一旦将数据被加到该排序中,任何一维数据结构,例如二叉搜索树,B树,跳跃表或(具有低有效位被截断)哈希表都可以用来处理数据。通过 Z 阶曲线所得到的顺序可以等同地被描述为从四叉树的深度优先遍历得到的顺序。
这也是 Geohash 的另外一个优点,搜索查找邻近点比较快。
Geohash 的缺点之一也来自 Z 阶曲线。
Z 阶曲线有一个比较严重的问题,虽然有局部保序性,但是它也有突变性。在每个 Z 字母的拐角,都有可能出现顺序的突变。
看上图中标注出来的蓝色的点点。每两个点虽然是相邻的,但是距离相隔很远。看右下角的图,两个数值邻近红色的点两者距离几乎达到了整个正方形的边长。两个数值邻近绿色的点也达到了正方形的一半的长度。
Geohash 的另外一个缺点是,如果选择不好合适的网格大小,判断邻近点可能会比较麻烦。
看上图,如果选择 Geohash 字符串为6的话,就是蓝色的大格子。红星是美罗城,紫色的圆点是搜索出来的目标点。如果用 Geohash 算法查询的话,距离比较近的可能是 wtw37p
,wtw37r
,wtw37w
,wtw37m
。但是其实距离最近的点就在 wtw37q
。如果选择这么大的网格,就需要再查找周围的8个格子。
如果选择 Geohash 字符串为7的话,那变成黄色的小格子。这样距离红星星最近的点就只有一个了。就是 wtw37qw
。
如果网格大小,精度选择的不好,那么查询最近点还需要再次查询周围8个点。
参考文档:
https://en.wikipedia.org/wiki/Geohash
]]>Uber H3是Uber实现的一个基于正六边形,分层空间索引系统。
H3:
h3选择以正二十面体做为投影,通过把地球坐标映射到正二十面体的20个面上,然后再针对每个面做进一步的切分。正二十面体如下图所示:
正二十面体每个面都是一个等边三角形,映射之后,在地球这一球体上的映射如上图右所示。为方便理解,动态git图如下所示:
以上,可以注意到正二十面体,一共有12个顶点,每个顶点都是一个凸起,为了更好的模拟球面,可以对这个12个顶点进行截角处理,这样每个角被截后,就变成了一个正五边形。因此,地球被映射后,其实变成了20个正六边形+12个正五边形构成的近似球体。如下图所示,其实我们日常生活中常见到的足球正是这种形状的。
在地球被正二十面体,投影到20个面之后,就考虑每个面怎么做网格切分的问题。H3使用的是正六边形做层次网格。下面来看正三角形如何做六边形切分。
h3以上图这种形式进行,正六边形切分,专业术语称为(aperture=4)。每个面有4个完整的正六边形,同时还有3个不完整的正六边形。通过这个单面的分析,我们可以计算出,第0层,地球可以被切分为5.5X20=110个正六边形,然后再加上12个(共12个顶点)正五边形,一共122个网格。
需要注意的是,有些网格是跨越多个面的,同时有正五边形的存在。正五边形的存在会对整个网格的切分有一定的影响,如何消除正五边形的影响呢。
首先,需要明确的是随着正六边形不断的切分,正五边形的个数始终都是12个,同时这个正五边形的面积也是不断的减小,直到变成一个足够小的面积。
另外,可以通偏移,把12个顶点五边形放置到大海中(R. Buckminster Fuller提出的方案,把其中一个顶点放在11.25°E, 58.2825°N),从而避免对实际的陆地业务产生影响。
上图展示了,h3网格切分,左侧是level 0,右侧是level 1。可以很清楚的看到五边形越来越小,并且12个顶点都被巧妙的分布在了大海之上, 避免对实际的业务产生影响(轮船、快艇实际上可能受到影响)。
关于如何标识一个网格,怎么定位呢?
H3使用了一种称为CoordIJK坐标系作为定位系统。
使用这种(i,j,k)三元组坐标,来为每个六边形网格编号。可以注意到两个坐标系之间的夹角是120°。比较类似于三维坐标系,不过这个是在二维平面上的。
H3使用FaceIJK坐标系,来表示二十面体不同面上的坐标。它是由面的编号(0~19)和对应的CoordIJK坐标构成。
随着网格的不断细分,ijk坐标系是有一定的偏转的,大概是19.1°。不过并不是一直往一个方向进行偏转,而是不断的左右偏转的。实际上不同的层,只有2种类型:
这里的Class II和Class III就是这2种类型,名字是R. Buckminster Fuller定义的专业术语。当然并不存在所谓的Class I,Class IV. 这里只是一种名字而已。针对base cell, 也就是0层的网格,他的类型就是Class II。
Hex2d坐标,是一个关联CoordIJK的笛卡尔坐标系。Hex2d的坐标原点与CoordIJK一样,x轴与与CoordIJK的I轴是对齐一致的。单位距离1.0的大小是CoordIJK两个相邻网格中心点之间的距离。
Hex2d在代码内是使用Vec2d
来表示的。
Local IJ坐标,是用来摆脱不同面或者不同base cell限制的一个概念。这个坐标只有2个夹角为120°的轴,坐标原点也是和H3 Index一样。
Local IJ坐标只有在同一原点坐标下才具有可比性;Local IJ坐标,只在原点附近是有效的;Local IJ坐标有可能由于五边形的存在而导致的失真。同一个索引有可能有多个Local IJ坐标,原点也可能不是(0,0)。
LocalIJ坐标在代码内使用CoordIJ
和一个关联的原始H3Index表示。
H3为每一个网格生成一个唯一的标识。每层(resolution)的网格,都是从第0层计算出的基础网格编码(cell number)开始的。 基础网格编码是固定下来的的(0121),其余层次的坐标都是从这个基础网格编码来定位的。子网格的06分布,如下图所示:
从上可以看出,其实octal就是通过3bit的ijk坐标来算出来的。
由于有12个五边形,五边形是细分成6个子网格,并不是7个。所以直接把编号为1的网格给去掉了。
H3Index是一个64位的整形来表示的。它有3种类型:
H3Index实际上只使用了64位的低63位。具体格式如下图所示:
需要注意的是,如果Resolution没有到15层的话,那么置对应bit为全1,也就是7(111),标志没有使用到。
比如83001dfffffffff, 对应的二进制为:
1 | 0 0001 000 0011 0000000 000 011 101 |
所以,可以解读如下:
index mode=1索引的是正六边形类型;
Resolution层数=3;
Base Cell=0;
Resolution1的网格编号是0;
Resolution2的网格编号是3;
Resolution3的网格编号是5。
其余为是1,代表没有那么resolution。
H3 Resolution | 平均六边形面积 (平方千米) | 平均六边形边 (千米) | Number of unique indexes |
---|---|---|---|
0 | 4,250,546.8477000 | 1,107.712591000 | 122 |
1 | 607,220.9782429 | 418.676005500 | 842 |
2 | 86,745.8540347 | 158.244655800 | 5,882 |
3 | 12,392.2648621 | 59.810857940 | 41,162 |
4 | 1,770.3235517 | 22.606379400 | 288,122 |
5 | 252.9033645 | 8.544408276 | 2,016,842 |
6 | 36.1290521 | 3.229482772 | 14,117,882 |
7 | 5.1612932 | 1.220629759 | 98,825,162 |
8 | 0.7373276 | 0.461354684 | 691,776,122 |
9 | 0.1053325 | 0.174375668 | 4,842,432,842 |
10 | 0.0150475 | 0.065907807 | 33,897,029,882 |
11 | 0.0021496 | 0.024910561 | 237,279,209,162 |
12 | 0.0003071 | 0.009415526 | 1,660,954,464,122 |
13 | 0.0000439 | 0.003559893 | 11,626,681,248,842 |
14 | 0.0000063 | 0.001348575 | 81,386,768,741,882 |
15 | 0.0000009 | 0.000509713 | 569,707,381,193,162 |
从上表可以知道,在Resolution=15的时候,平均面积已经在0.9平方米,边长也为0.5米了,完全满足常见的网格需求。
参考文档:
https://uber.github.io/h3/#/documentation/overview/introduction
]]>分形(英语:fractal,源自拉丁语:frāctus,有“零碎”、“破裂”之意),又称碎形、残形,通常被定义为“一个粗糙或零碎的几何形状,可以分成数个部分,且每一部分都(至少近似地)是整体缩小后的形状”,即具有自相似的性质。 分形在数学中是一种抽象的物体,用于描述自然界中存在的事物。1975 年本华·曼德博首次提出“分形(fractal)”这个术语。
关于分形的具体定义,权威专家也仍有一些争论,即使作为”分形几何之父“,本华·曼德博也一直更新其定义。
“美丽、(研究起来)极其困难但又非常的有用,这就是分形”。1982年更新为:”分形是一种其豪斯多夫维数严格大于拓扑维数的集合“,后来称为”分形是由与整体在某些方面相似的部分构成的图形“ 。又过了一段时间,决定这样描述分形:“**…在研究和使用分形时,不需要迂腐的定义。用分形维数作为描述各种不同分型的通用术语**”。
通常认为, 理论分型是无限迭代、自相似的、具有分形维数的详细数学结构。
是不是看完以上的描述,还是不太了解什么是分形,那么我们来看几个现实中的例子。
花椰菜 | 蕨类植物 |
---|---|
可以看出,每个花椰菜的凸起,单独拿出来其实都和花椰菜是一样的造型。蕨类植物的叶子,没个分支都和整体的形状一致。
本华·曼德博提出”关于英大不列颠岛的海岸线到底有多长?”这样的问题,他用分形几何给出的答案是无限长。
可以看出,在不同的比例尺下,去量海岸线的长度是不一样的。200KM比例尺海岸线约2300KM,而100KM比例尺则达到了2800KM,如果是50KM的比例尺,海岸长度达到了3500KM。试想一想,当比例尺越小,能够描绘的越精细,这个海岸线的长度也不断趋向于无限。
这个问题,回归到分形本身,其实就是针对直线这一图形,不断进行分形细分,直线分割越来越小。但是仍旧是一个直线(自相似)。
分形与其他几何图形相似但又有所不同。当你缩放一个图形时,你就能看出分形和其他几何图形的区别。将一个多边形的边长加倍,它的面积变为原来的四倍。新的边长与旧边长相比增加了 2 倍,而面积增加了 4 倍,即 2^2倍。平面内的多边形在二维空间中,指数 2 刚好是多边形所在的二维空间的维数。类似的,对于三维空间中的球,如果它的半径加倍,则它的体积变为原来的 8 倍,即2^3 倍,指数 3 依旧是球所在空间的维数。
如果将分形的一维长度加倍,如将康托三分集的初始线段长加倍,分型空间的内容2^n倍,此时n 不一定是个整数。幂指数n 称为分型的维数,它通常大于分型的拓扑维数。
要做出科赫雪花,将正三角形每边中央三分之一的线段以一对同长的线段取代,形成一个等腰的“凸角”。再对上一步骤所形成的每一边做同样的动作。每一次迭代,总长度增加三分之一。科赫雪花即是无限次迭代的结果,有无限长的周长,但其面积还是有限的。因此,科赫雪花和其他相似构造有时会被称为“怪兽曲线”。
下图是科赫雪花的动态展示:
分形的“自我相似”的特征很容易通过类比来理解,就像用镜头或其他设备放大数字图像,从而发现以前不可见的、更精细的新结构。如果你放大一个分形的图像,则不会出现新的细节;图像没什么变化,相似的图案一遍又一遍的重复出现。对于有些分形几乎完全一样的图像会不断地重复。 自我相似的特征并非反直觉的。人们在生活中也能看到自我相似的现象,例如:两面平行的镜子间的无限重复、山上庙里老和尚的故事里的山…分形的不同之处在于重复的图案一定有详细的细节。
细节性的概念和分形的另一个特征——分形维数有关。分形维数不需要数学背景,也很容易理解:分形的分形维数大于它的拓扑维数,通过将分形尺度与普通的几何形状相比较,我们便能感受到他们的差别。举个例子,通常认为直线是是一维的,如果直线被分为三部分,每部分都是原来的 1/3 长,你会得到相等的三部分。相比之下,科赫雪花的拓扑维数是 1,和普通的直线一样,但它的分形维数大于 1,因为它有很多的细节。雪花曲线被分为原长的 1/3,得到的是 4 条原始雪花曲线重组组合的结果。这种与众不同的关系是分形维数的基础。
这也引出了第三个特征:分形在数学上是处处不可微的。具体的说,这意味着分形不能用传统的方法测量。测量非分型曲线,如波浪曲线的长度,只要放大到足够大,总能用直线拟合一小段曲线,然后就能用卷尺测量这段直线的长度,再将各段直线长度相加,就可以得出波浪的长度。这样做实质上是把曲线看作数学上的函数,在一小段范围内取一阶泰勒展开,近似为直线,然后求和总长度。但分型曲线是处处不可微的,如果尝试使用直线去拟合分形曲线,如科赫雪花曲线,缩放的过程永远不会停止,因为曲线图案的重复模式总会不断地出现,每次缩放,都需要使用更小的卷尺来贴合曲线。
分形一般有以下特质:
因为分形在所有的大小尺度下都显得相似,所以通常被认为是无限复杂的。
一类分形的典型例子有: 康托尔集、龙形曲线,谢尔宾斯基三角形和地毯、门格海绵、、皮亚诺曲线等。
龙形曲线 | Gosper 曲线 | 谢尔宾斯基三角 | 谢尔宾斯基地毯 |
---|---|---|---|
分形可以是确定性的,如上述所有的分形;也可以是随机的(即非确定性的)。
比如说,平面上布朗运动的轨迹的豪斯多夫维数等于2。实际上,布朗粒子的轨迹由大量无规则可循的折线组成,是一种处处连续但处处不可微的曲线,是一种无规分形曲线,它也具有自相似性,但这种自相似性具有统计的性质。
分形也可以依据其自相似来分类,有如下三种:
现在有一些软件可以很好的画出精确自相似分形图形,甚至作为工艺品出售:
空间填充曲线目前来看其实就是分形的一种,它解决了这一难题:能否用一条无限长的线,穿过任意维度空间里面的所有点?
在1890年,Giuseppe Peano发现了一条连续曲线,现在称为 Peano 曲线,它可以穿过单位正方形上的每个点。他的目的是构建一个可以从单位区间到单位正方形的连续映射。 Peano 受到 Georg Cantor 早期违反直觉的研究结果的启发,即单位区间中无限数量的点与任何有限维度流型中无限数量的点,基数相同。 Peano 解决的问题实质就是,是否存在这样一个连续的映射,一条能填充满平面的曲线。上图就是他找到的一条曲线。
一般来说,一维的东西是不可能填满2维的方格的。但是Peano曲线恰恰给出了反例。
Peano曲线的构造方法如下:
取一个正方形并且把它分出九个相等的小正方形,然后从左下角的正方形开始至右上角的正方形结束,依次把小正方形的中心用线段连接起来;下一步把每个小正方形分成九个相等的正方形,然后上述方式把其中中心连接起来……将这种操作手续无限进行下去,最终得到的极限情况的曲线就被称作Peano曲线。
皮亚诺对区间[0,1]上的点和正方形上的点的映射作了详细的数学描述。实际上,正方形的这些点对于,可找到两个连续函数 x = f(t) 和 y = g(t),使得 x 和 y 取属于单位正方形的每一个值。
一年后,即1891年,希尔伯特发明了另外一个曲线,叫希尔伯特曲线(Hilbert curve)。具体步骤如下。
上图就是1-6阶的希尔伯特曲线。
同样的原理,希尔伯特曲线也可以扩展到3D空间,如下图所示。
在数学分析中,空间填充曲线是一个参数化的注入函数,它将单位区间映射到单位正方形,立方体,更广义的,n维超立方体等中的连续曲线,随着参数的增加,它可以任意接近单位立方体中的给定点。填充曲线也是多种多样的。如下所示:
除了数学重要性之外,空间填充曲线也可用于降维,数学规划,稀疏多维数据库索引,电子学和生物学。空间填充曲线的现在被用在互联网地图中。
希尔伯特曲线一种能填充满一个平面正方形的分形曲线(空间填充曲线),由大卫·希尔伯特在1891年提出。由于它能填满平面,它的豪斯多夫维是2。取它填充的正方形的边长为1,第n步的希尔伯特曲线的长度是2^n - 2^(-n)。
一阶的希尔伯特曲线,生成方法就是把正方形四等分(4个小正方形),从其中一个子正方形的中心开始(原则上从任何一个都可以),依次穿线,穿过其余3个正方形的中心。
二阶的希尔伯特曲线,生成方法就是把之前每个子正方形继续四等分,每4个小的正方形先生成一阶希尔伯特曲线。然后把4个一阶的希尔伯特曲线首尾相连。
三阶的希尔伯特曲线,生成方法就是与二阶类似,先生成二阶希尔伯特曲线。然后把4个二阶的希尔伯特曲线首尾相连。需要注意的是为了保证连续性,做了旋转操作。
n阶的希尔伯特曲线的生成方法也是递归的,先生成n-1阶的希尔伯特曲线,然后把4个n-1阶的希尔伯特曲线首尾相连。
作为空间填充曲线,希尔伯特曲线可以对多维空间有效的降维。希尔伯特曲线,本身就是对一条直线不断切分,生成的,从而可以填满2D区间。反过来说,这条曲线本身就是一维的。
上图就是希尔伯特曲线在填满一个平面以后,把平面上的点都展开成一维的线了。
当然,当n趋近于无穷大的时候,n阶希尔伯特曲线就可以近似填满整个平面了。这也是分形的特性,回到前面讲的“无限长的大不列颠岛的海岸线”。这里也是一样的,不但可以填满整个平面,从另外一个角度来说,这个线也是无限长的。当我们说这个长度是多少的时候,其实是有一个n来限制的。
当n阶希尔伯特曲线,n趋于无穷大的时候,曲线上的点的位置基本上趋于稳定。举个例子:
上图左边是希尔伯特曲线,右边是蛇形曲线。当n趋于无穷大的时候,两者理论上都可以填满平面。但是希尔伯特曲线更加优秀。
在蛇形曲线上给定一个点,当n趋于无穷大的过程中,这个点在蛇形曲线上的位置是时刻变化的。这就造成了点的相对位置始终不定。
再看看希尔伯特曲线,同样是一个点,在n趋于无穷大的情况下:
从上图可以看到,点的位置几乎没有怎么变化。所以希尔伯特曲线更加优秀。
希尔伯特曲线是连续的,所以能保证一定可以填满空间。连续性是需要数学证明的。具体证明方法这里就不细说了。
参考文档:
http://blog.christianperone.com/2015/08/googles-s2-geometry-on-the-sphere-cells-and-hilbert-curve/?utm_source=tuicool
https://zhuanlan.zhihu.com/p/35358486
https://halfrost.com/go_spatial_search/
Space-filling curve
List of fractals by Hausdorff dimension
介绍希尔伯特曲线的Youtube视频
希尔伯特曲线在线演示
希尔伯特曲线论文
Mapping the Hilbert curve
伯努瓦.曼德勃罗:分形和粗糙的艺术
https://www.bilibili.com/video/av3602681/
考虑到这一点,Uber开发了 H3 ,这是我们的网格系统,它可以用来有效优化乘车价格和调度、地图空间数据的可视化和挖掘。 H3使我们能够分析地理信息以设置动态价格并在整个城市范围内做出决策。 我们将H3作为网格系统用于整个市场的分析和优化。 H3就是为此目的而设计的,它使我们可以使用六边形层次索引。
今年早些时候,我们在Github上 开源了H3 ,大家都可以使用此强大的解决方案,上周,我们开源了 H3 JavaScript 版本。 在本文中,我们讨论了为什么我们使用网格系统,H3的某些独特属性以及如何开始使用H3。
图1. H3把地球分成很多六边形,使用户可以进行更精准的分析。
每天,Uber市场都会发生数以百万计数的事件。时时刻刻, 平台上的这些事件都在产生,包括但不限于:乘客请求乘车、驾驶员搭伴出行,饿了的用户寻找食物。 每个事件都发生在特定的位置,例如,乘客在自己家中发起乘车订单,而驾驶员则在几英里外的汽车中接受该订单。
这些事件使Uber能够更好地了解和优化我们的服务。 例如,这些事件可能告诉我们在城市的某个地方需求大于供应,然后我们就可以做出相应的价格调整,或者通知平台的某个Uber驾驶员附近有两个乘车订单。
要从Uber业务数据中获取信息和知识,需要分析整个城市的数据。 由于城市在地理位置上是多种多样的,因此需要以精细的粒度进行分析。 以精细的粒度(事件发生的确切位置)进行分析是非常困难且费力的。 对区域(例如城市中的社区)进行分析更加切实可行。
汽车分布在城市 | 汽车分割到六边形 | 六边形内汽车数量热度 |
图2.上面的地图描绘了使用H3进行分割的过程 |
我们使用网格系统将事件存储到六边形区域(即单元格)中。 数据点存储在六边形中,可以使用六边形存储的数据来标识。 例如,我们通过测量所服务的每个城市中六边形的供求来计算高峰价格。 这些六边形构成了我们分析Uber业务的基础。
六边形是一个重要的选择,因为城市中的人们经常在运动,而六边形可以最大程度地减少用户穿越城市时引入的 量化误差 。 六边形还使我们可以轻松地获取近似半径,例如在 此示例中使用Elasticsearch 。
图3.纽约市(曼哈顿)的邮政编码地图
当然,我们也可以使用其他的方案把事件存储到区域中,例如多边形区域。 这些区域可能是邮政编码标识的区域,但是这些区域具有不规则的形状和大小,对分析没有帮助,并且区域划分随时发生改变,而这些改变与我们使用它们的目的 完全无关的 。 Uber运营团队还可以根据他们对城市的了解来划定区域,但是随着城市的变化,这些区域需要经常更新,并且经常随意定义区域的边缘。
图4.随机生成的六边形簇覆盖旧金山市
网格系统在Uber运营所在的城市中应该具有可比的形状和大小,并且不受任意更改。 尽管网格系统无法与城市中的街道和社区保持一致,但可以通过对网格单元进行聚合来将其有效地表示社区。 可以使用目标函数完成聚合,从而生成对分析更有用的形状。 确定聚类的成员与设置查找操作也应该是很高效的。
我们决定创建H3,以充分利用到六边形全局网格系统这一层次索引系统。
全球网格系统通常至少需要两件事:地图投影和放置在地图上的网格。 从地球上的三维位置到地图上的二维点需要地图投影。 然后将网格覆盖在地图上,形成一个全球网格系统。
通过组合不同的地图投影和网格(例如,广为人知的墨卡托投影 和正方形网格),可以无数种方式完成此过程 。 尽管此简单方法行之有效,但它有许多缺点。 首先,墨卡托投影的大小会明显失真,因此某些网格的面积会大不相同。 正方形也有缺点,在用于分析时需要多组系数。 这个缺点是正方形具有两种不同类型的邻居的结果,一种邻居共享它们的边(在4个基本方向上),另一种邻居共享顶点(在4个对角线上)。
正二十面体 | 球形二十面体 |
图5.我们选择使用以正二十面体(左)为中心的球心投影进行H3的地图投影,将地球投影为球形二十面体(右)
对于地图投影,我们选择使用以 正二十面体 的面为中心的球心投影 。 它从地球作为一个球体投射到二十面体的二十个面。 基于二十面体的地图投影会产生二十个单独的二维平面,而不是单个平面。 每次,生成一个二维映射地图,正二十面体可以有 多种方式展开 。 但是,H3不会展开正二十面体以构建其网格系统,而是将其网格放置在二十面体的面自身上,从而形成了 测地线全球离散网格系统 。
三角形到其邻居的距离 | 正方形到其邻居的距离 | 六边形到其邻居的距离 |
图6.三角形,正方形,六边形到其邻居的距离
使用六边形作为单元格形状对于H3至关重要。 如图6所示,与正方形的2个距离或三角形的3个距离相比,六角形在中心点与其相邻点之间只有一个距离。 此属性极大地简化了梯度的分析和平滑处理。
图7.用于在单个正二十面体的面上创建网格的H3。
H3网格是通过在地球上布置122个基本单元构成的,每个面有10个单元。 有些单元格跨越多个面。 由于不可能仅用六边形平铺二十面体,因此我们选择引入12个五边形,每个二十面体顶点分别一个。 这些顶点是使用 R. Buckminster Fuller 的球形二十面体方向进行定位的 ,它将所有顶点都放置在大海中。 这有助于避免五边形出现在我们的陆地上,从而不会对实际工作产生影响。
图8. H3使用户可以将区域细分为越来越小的六边形。
H3支持16种分辨率(resolution)。 每个较精细的分辨率所具有的单元的面积均为较粗糙分辨率的1/7。 六边形不能完美地细分为7个六边形,因此,较细的单元仅近似包含在父单元中。
这些子单元的标识符可以很容易地通过截断来快速的找到较粗分辨率的父单元,从而实现高效索引。 由于子单元格仅仅是近似包含,因此在截断过程会产生一定的形状失真。 不过只有当单元标识符截断时才会出现这种失真。 当以特定分辨率索引位置时,单元格边界是精确的。
H3索引系统 是开源的,可在GitHub上获得。 H3库 本身是用C编写的,并且有多种语言的移植版本。 建议从各种移植版来开始使用H3。 Uber已经发布了 Java 和 JavaScript 版本 ,而社区已经为 更多语言 提供出来 。 Python和Go的版本即将推出。
点(蓝色)在六边形内 | 六边形中心点(浅蓝色) | 六边形点和中心点 |
图9. 实际点与六边形中心点之间可能存在的差异
代码: https//github.com/uber/h3/blob/master/examples/index.c
H3库的基本功能是用于位置索引,该位置将纬度和经度对转换为 64位H3索引 ,用来识别网格单元。 函数 geoToH3 接受纬度,经度和分辨率(介于0和15之间,其中0是最粗的,15是最细的),并返回一个索引。 h3ToGeo 和 h3ToGeoBoundary 是此函数的逆函数,分别提供了由H3索引指定的网格单元的中心坐标和轮廓。
使用H3为数据建立索引后,H3 API将具有处理索引的功能。
原始索引,距离0 | 原始索引的邻居,距离1 | 邻居的邻居,距离2 |
图10.六边形索引的邻居,距离为0,距离1和距离2(右;与邻居的邻居)
代码: https//github.com/uber/h3/blob/master/examples/neighbors.c
相邻的六边形具有使用网格系统近似圆行的特性。 kRing 函数提供原始索引的网格距离 k 内的网格单元 。
图11.加利福尼亚州:密集六边形与紧凑型六边形形成鲜明对比,只需要更少的六边形就可以表示同一个区域。
代码: https://github.com/uber/h3/blob/master/examples/compact.c
H3的层次结构性质使得可以高效地截断索引的精度(或分辨率)以及恢复原始索引。 上面显示了一组六边形的不紧凑和紧凑 表示。 非紧凑(uncompact)在分辨率6有10,633个六边形,而紧凑(compact)形式在最高为6的分辨率情况下只需要901个六边形。在两种情况下,六边形索引都是64位整数。
单个索引的精度可以按使用位运算被有效地截取缩小,或者扩展为更高精度的索引集。
图12. H3可以表示从一个单元格到另一个单元格的移动,此处通过绘制从起点到目标单元格的箭头表示
代码: https://github.com/uber/h3/blob/master/examples/edge.c
H3具有 网格单元的有向边功能 ,可以表示从一个单元格到另一个单元格的运动。 有向边也存储为64位整数,可以从两个相邻的单元格中获取,也可以通过找到一个单元格的所有边来获得。 如果需要,可以将边线转换回原始索引或目标索引。
H3在整个Uber中用于支持对我们市场的定量分析,并且由于它是开源的,您也可以免费试用! 我们期待您Fllow 在Github上的 存储库 并使用 #uberH3 主题标签发布推文,加入H3社区 。
翻译自:
]]>公司业务由于广告以及导流的影响,对搜索服务的稳定以及性能提出了更苛刻的要求,为了应对可能的流量突增,特进行了大规模,全方位的性能测试。摸清线上服务的能力,以及定位瓶颈所在。
搜索服务采用了Master-Slave的Redis服务来缓存用户的搜索结果,过期时间在5~10分钟,通过缓存来提升响应时间和并发。key是用户的请求实体json字符串,value自然是搜索结果json串,并没有使用set,hash等类型。
经过一轮的性能测试,最终的瓶颈点落在了Redis上,如下图Grafana监控所示:
在请求和并发上去之后,redis item迅速增长到300W左右,每秒command数达到近10W。由于redis存储做了限制20G,图中可以看到内存已经满了,item肯定会被频繁置换出去,总数提升不上去。从network上看,output已经达到了近200MB/s,远超运维的限制的100MB/s(千兆网卡极限约100MB,万兆约1000MB)。
核心瓶颈点:
所谓的BigKey,大概有以下的情况:
在我们的这个场景下,主要是json串太大。这涉及到key, value的如果减小的问题。
先来看key如何减小:
我们是使用fastjson来对Request对象做序列化的,key就是这个json串。
1 | Request request = (Request) obj; |
直接想到的就是对key做编码,减小长度。使用了commons-codec里的md5加密摘要,作为key。
1 | DigestUtils.md5Hex(json) |
这样就保证了key的长度缩小,并且长度一致。需要注意的是MD5有一定的冲突率,在本场景下可以基本忽略。
接下来,看value如何缩小:
value的序列化,我们使用的是jackson2来做的,反序列化的时候直接就是对象,方便配合Spring Cache使用。核心代码如下:
1 | private RedisSerializer getValueSerializer() { |
redis里存储的就是jackson2转化后的字符串,其中包含了class等相关信息。
当然,从业务上减小每次查询的搜索结果条数是一个办法,比如每次最多只能查询10条。但是,如果10条的数据仍旧比较大,我们想到的直接方法就是压缩。
直接使用JDK自带的GZIP压缩来实现如下:
1 | public class GzipSerializer implements RedisSerializer<Object> { |
通过这个GzipSerializer实现了压缩功能,并且最大可能的减少了对原始代码的改动。
1 |
|
更改之后,进行新一轮的性能压测。grafana监控效果如下:
可以看出,同样的压力情况下。内存占用不到5G,网络IO也在14M/S上下,效果显著。顺利的解决了Redis的瓶颈所在。
至此压力测试到一个阶段,下面来看一下,功能在生产环境的使用情况如何。
从上面看出,在1.16上线后,在整体流量基本不变的情况下,内存以及网络IO大幅度下降,经受住了生产环境的考验。
以上,为了解决Redis瓶颈,通过MD5摘要以及压缩快速而小改动的解决了问题,如果从长远来看,如何才能做到更好呢?
GZIP能取得不错的压缩比,但是对CPU要求较高,相当于把redis的存储瓶颈转移到了Java程序的解压缩上。是否有一个性能和压缩比兼得的压缩方案呢?
从参考1中,可以一探究竟。
文中以一个445M的文件对常见的压缩方式进行了比较。
压缩后的大小:
压缩比:
压缩耗时:
解压缩耗时:
从以上的benchmark对比来看,不同压缩方式优劣不一样,大家可以根据实际情况来选择。
个人推荐来看,也许LZ4是一个不错的选择,压缩比高一些,但是解压缩速度都比gzip要高。
1 | import java.io.ByteArrayInputStream; |
前面提到,我们的序列化方案是基于json的,那有没有更好的序列化方案,能够有更好的性能,更低的内存占用。
序列化方案无论是在redis,还是在各种RPC解决方案里都是大家热烈讨论的话题。那我们来看一下主要序列化方案的一些对比。
在参考2和3里都有提及。
各种序列化方案字节大小对比(越小越好):
耗时对比(越小越好):
综合以上文章所述,再加上易用性的使用角度,个人推荐倾向于:
当然关于Java对象的序列化,不同方案也有一些差异,比如是否实现Serializable接口,是否支持嵌套内部类在选择的时候,也都是需要注意的。
下面给一个kryo的实现:
1 | import org.springframework.data.redis.serializer.RedisSerializer; |
前面提到的,无论是压缩还是序列化都是基于Redis无法扩容,以及单机网络IO限制的。如果为了支持更大的并发和更快的响应,我们需要使用Redis Cluster,当然也可以从客户角度进行拆分,使用多个Redis实例,这里不予表述。
Redis Cluster在3.0之后推出,用于解决Redis的分布式需求。自动的在不同节点之间均衡数据,充分利用多机的优势。
参考:
1.Quick Benchmark: Gzip vs Bzip2 vs LZMA vs XZ vs LZ4 vs LZO
]]>Lucene Field本身并不支持嵌套类型,最多也就支持多值类型。ElasticSearch进行了扩展,使得Field可以是object对象类型或者nested嵌套类型。
object类型,本质上是把字段路径打平,最终在索引里还是一个正常的Field字段,object类型转化后,并不会保留对象内的属性对应关系,这在查询中可能需要特别注意。然后nested嵌套类型却实现的对应关系。
使用上的区别,可以参考官方Nested Data Type。
本文,我们主要关注的是elasticsearch内部是如何实现的?
Because nested documents are indexed as separate documents, they can only be accessed within the scope of the nested
query, the nested
/reverse_nested
aggregations, or nested inner hits.
For instance, if a string field within a nested document has index_options
set to offsets
to allow use of the postings during the highlighting, these offsets will not be available during the main highlighting phase. Instead, highlighting needs to be performed via nested inner hits.
Indexing a document with 100 nested fields actually indexes 101 documents as each nested document is indexed as a separate document. To safeguard against ill-defined mappings the number of nested fields that can be defined per index has been limited to 50. See Settings to prevent mappings explosionedit.
从这里的介绍,我们所知有限。能得到的结论如下:
org.elasticsearch.index.mapper.DocumentParser
类是专门用于解析要索引的Document的。
其中有这样一个方法来解析对象类型。
1 | static void parseObjectOrNested(ParseContext context, ObjectMapper mapper) throws IOException { |
下面来看是怎么处理的。
1 | private static ParseContext nestedContext(ParseContext context, ObjectMapper mapper) { |
总之,经过DocumentParser的处理,Nested Document多了这样的2个字段
从上面存储知道,nested document是和普通Document一起存在索引中的,但对外是隐藏的,甚至是MatchAllQuery。
在org.elasticsearch.common.lucene.search.Queries
类中:
1 | /** |
下面来看一下_primary_term
是如何放进去的。这里涉及到org.elasticsearch.index.mapper.SeqNoFieldMapper
这个类。他主要是用来为Document添加_seq_no
元字段的,附带着把_primary_term
给加了进去。
1 |
|
1 | public static SequenceIDFields emptySeqID() { |
下面,我们来看下,都什么地方使用到了Queries.newNonNestedFilter
.这里我在IDEA里做了一个截图。
从上可以看出,基本的正常查询都默认加了这个filter的,只有是nestedQuery才做特别的处理。
总结下如何隐藏的:
ElasticSearch版本 | 隐藏实现方式 |
---|---|
小于6.1.0 | 过滤掉_type以“__”为前缀的nested document |
大于等于6.1.0 | 只获取有__primary_term Field的父Document |
无论是ElasticSearch,还是Solr, 其底层都是通过Lucene来做索引的。Lucene本身并不支持这种嵌套类型。ElasticSearch通过一个比较hack的方式支持了这样的功能,用起来更加的顺手,功能更强大。
同时,我们通过上面的分析,Nested也是有缺点的。
Martin上来就问,我们要锁来干啥呢?2个原因:
对于第1种原因,我们对锁是有一定宽容度的,就算发生了两个节点同时工作,对系统的影响也仅仅是多付出了一些计算的成本,没什么额外的影响。这个时候 使用单点的 Redis 就能很好的解决问题,没有必要使用RedLock,维护那么多的Redis实例,提升系统的维护成本。
对于第2种原因,对正确性严格要求的场景(比如订单,或者消费),就算使用了 RedLock 算法仍然不能保证锁的正确性。
我们分析一下 RedLock 的有啥缺陷吧:
作者 Martin 给出这张图,首先我们上一讲说过,RedLock中,为了防止死锁,锁是具有过期时间的。这个过期时间被 Martin 抓住了小辫子。
这还了得,数据就发生了错误。RedLock 只是保证了锁的高可用性,并没有保证锁的正确性。
这个时候也许你会说,如果 Client 1 在提交任务之前去查询一下锁的持有者是不自己就能解决这个问题?
答案是否定的,FGC 会发生在任何时候,如果 FGC 发生在查询之后,一样会有如上讨论的问题。
那换一个没有 GC 的编程语言?
答案还是否定的, FGC 只是造成系统停顿的原因之一,IO或者网络的堵塞或波动都可能造成系统停顿。
Martin给出了一个解决的方案:
为锁增加一个 token-fencing。
我们其实可以理解这个 token-fencing 就是一个乐观锁,或者一个 CAS。
Martin 还指出了,RedLock 是一个严重依赖系统时钟的分布式系统。
还是这个过期时间的小辫子。如果某个 Redis Master的系统时间发生了错误,造成了它持有的锁提前过期被释放。
这个时候 Martin 又提出了一个相当重要的关于分布式系统的设计要点:
好的分布式系统应当是异步的,且不能时间作为安全保障的。因为在分布式系统中有会程序暂停,网络延迟,系统时间错误,这些因数都不能影响分布式系统的安全性,只能影响系统的活性(liveness property)。换句话说,就是在极端情况下,分布式系统顶多在有限的时间内不能给出结果,但是不能给出错误的结果。
所以总结一下 Martin 对 RedLock 的批评:
这个时候感觉醍醐灌顶,简直写的太好了。
RedLock 的作者,同时也Redis 的作者对 Martin的文章也做了回应,条理也是相当的清楚。
antirez 看到了 Martin 的文章以后,就写了一篇文章回应。剧情会不会反转呢?
antirez 总结了 Martin 对 RedLock的指控:
对于第一个问题:
antirez 洋洋洒洒的写了很多,仔细看半天,也没有解决我心中的疑问。回顾一下RedLock 获取锁的步骤:
如果,程序在1-3步之间发生了阻塞,RedLock可以感知到锁已经过期,没有问题。
如果,程序在第 4 步之后发生了阻塞?怎么办???
答案是,其他具有自动释放锁的分布式锁都没办解决这个问题。
对于第二个质疑:
antirez 认为,首先在实际的系统中,从两个方面来看:
对于第1个问题。上文已经提到了,RedLock做了一些微小的工作,但是没办法完全避免。其他带有自动释放的分布式锁也没有办法。
第2个问题,Martin认为系统时间的阶跃主要来自两个方面:
对于人为修改,能说啥呢?人要搞破坏没办法避免。
NTP受到一个阶跃时钟更新,对于这个问题,需要通过运维来保证。需要将阶跃的时间更新到服务器的时候,应当采取小步快跑的方式。多次修改,每次更新时间尽量小。
所以严格来说确实, RedLock建立在了 Time 是可信的模型上,理论上 Time 也是发生错误,但是在现实中,良好的运维和工程一些机制是可以最大限度的保证 Time 可信。
最后, antirez 还打出了一个暴击,既然 Martin 提出的系统使用 fecting token 保证数据的顺序处理。还需要 RedLock,或者别的分布式锁干啥??
每一个系统设计都有自己的侧重或者局限。工程也不是完美的。在现实中工程中不存在完美的解决方案。我们应当深入了解其中的原理,了解解决方案的优缺点。明白选用方案的局限性。是否可以接受方案的局限带来的后果。
架构本来就是一门平衡的艺术。
简单来说,单实例Redis能解决大部分的分布式锁需求。Redlock的引入意义不大,如果对可用性要求更高的话,使用其他方案也许是更好的选择。
Martin 推荐使用ZooKeeper 实现分布事务锁。Zookeeper 和 Redis的锁有什么区别? Zookeeper解决了Redis没有解决的问题了么?
这篇文章尝试提供更标准的算法来使用Redis实现分布式锁。我们提出一种算法,叫做Relock,它实现了我们认为比vanilla单一实例方式更安全的DLM(分布式锁管理)。我们希望社区分析它并提供反馈,以做为更加复杂或替代设计的一个实现。
在说具体算法之前,下面有一些具体的实现可供参考.
从有效分布式锁的最小保证粒度来说,我们的模型里面只用了3个属性,具体如下:
属性安全: 互斥性。在任何时候,只有1个客户端可以获得锁.
活跃属性A: 死锁自由. 即使一个客户端已经拥用了已损坏或已被分割资源的锁,但它也有可能请求其他的锁.
活跃属性B: 容错. 只要大部分Redis节点可用, 客户端就可以获得和释放锁。
要理解我们所做的改进,就要先分析下当前基于Redis的分布式锁的做法。
使用Redis锁住资源的最简单的方法是创建一对key-value值。利用Redis的expire机制,key被创建为有一定的生存期,因此它最终会被释放。而当客户端想要释放时,直接删除key就行了。
一般来说这工作得很好,但有个问题: 这是系统的一个单点。如果Redis主节点挂了呢?当然,我们可以加个从节点,主节点出问题时可以切换过来。不过很可惜,这种方案不可行,因为Redis的主-从复制是异步的,我们无法用其实现互斥的安全特性。
这明显是该模型的一种竞态条件:
1.客户端A在主节点获得了一个锁。
2.主节点挂了,而到从节点的写同步还没完成。
3.从节点被提升为主节点。
4.客户端B获得和A相同的锁。注意,锁安全性被破坏
有时候,在某些情况下这反而工作得很好,例如在出错时,多个客户端可以获得同一个锁。如果这正好是你想要的,那就可以使用主-从复制的方案。否则,我们建议使用这篇文章中描述的方法。
在尝试解决上文描述的单实例方案的缺陷之前,先让我们确保针对这种简单的情况,怎么做才是无误的,因为这种方案对某些程序而言也是可以接受的,而且这也是我们即将描述的分布式方案的基础。
为了获取锁,方法是这样的:
1 | SET resource_name my_random_value NX PX 30000 |
这条指令将设置key的值,仅当其不存在时生效(NX选项), 且设置其生存期为30000毫秒(PX选项)。和key关联的value值是”my_random_value”。这个值在所有客户端和所有加锁请求中是必须是唯一的。
使用随机值主要是为了能够安全地释放锁,这要同时结合这么个处理逻辑:删除key值当且仅当其已存在并且其value值是我们所期待的。看看以下lua代码:
1 | if redis.call("get",KEYS[1]) == ARGV[1] then |
这么做很重要,可以避免误删其他客户端创建的锁。例如某个客户端获得了一个锁,但它的处理时长超过了锁的有效时长,之后它删除了这个锁,而此时这个锁可能又被其他客户端给获得了。仅仅做删除是不够安全的,很可能会把其他客户端的锁给删了。结合上面的代码,每个锁都有个唯一的随机值,因此仅当这个值依旧是客户端所设置的值时,才会去删除它。
那么应该怎样生成这个随机值呢?我们使用的是从/dev/urandom读取的20个字节,但你也可以找个更简单的方法,只要能满足任务就行。例如,可以使用/dev/urandom初始化RC4算法,然后用其产生随机数流。更简单的方法是组合unix时间戳和客户端ID, 这并不安全,但对很多环境而言也够用了。
我们所说的key的时间,是指”锁的有效时长“。它代表两种情况,一种是指锁的自动释放时长,另一种是指在另一个客户端获取锁之前某个客户端占用这个锁的时长,这被限制在从锁获取后开始的一段时间窗口内。
现在我们已经有好的办法获取和释放锁了。在单实例非分布式系统中,只要保证节点没挂掉,这个方法就是安全的。那么让我们把这个概念扩展到分布式的系统中吧,那里可没有这种保证。
在此算法的分布式版本中,我们假设有N个Redis主节点。这些节点是相互独立的,因此我们不使用复制或其他隐式同步机制。我们已经描述过在单实例情况下如何安全地获取锁。我们也指出此算法将使用这种方法从单实例获取和释放锁。在以下示例中,我们设置N=5(这是个比较适中的值),这样我们需要在不同物理机或虚拟机上运行5个Redis主节点,以确保它们的出错是尽可能独立的。
为了获取锁,客户端执行以下操作:
获取当前时间,以毫秒为单位。
以串行的方式尝试从所有的N个实例中获取锁,使用的是相同的key值和相同的随机value值。在从每个实例获取锁时,客户端会设置一个连接超时,其时长相比锁的自动释放时间要短得多。例如,若锁的自动释放时间是10秒,那么连接超时大概设在5~50毫秒之间。这可以避免当Redis节点挂掉时,会长时间堵住客户端:如果某个节点没及时响应,就应该尽快转到下个节点。
客户端计算获取所有锁耗费的时长,方法是使用当前时间减去步骤1中的时间戳。当且仅当客户端能从多数节点(至少3个)中获得锁,并且耗费的时长小于锁的有效期时,可认为锁已经获得了。
如果锁获得了,它的最终有效时长将重新计算为其原时长减去步骤3中获取锁耗费的时长。
如果锁获取失败了(要么是没有锁住N/2+1个节点,要么是锁的最终有效时长为负数),客户端会对所有实例进行解锁操作(即使对那些没有加锁成功的实例也一样)。
算法依赖于这样一个假定,它在处理的时候不是(基于)同步时钟的,每个处理中仍然使用的是本地的时间,它只是大致地以同样地速率运行,这样它就会有一个小的错误,与之相比会有一个小的自动开合的时钟时间。这个假设很像真正世界的电脑:每一台电脑有一个本地时钟,通常我们使用不同的电脑会有一个很小的时钟差。
基于这个观点,我们需要更好地指明我们共同的互斥法则:这是保证客户端能长时间保持状态锁定,其将会终止它们在有效时间内的工作(在步骤3中获得),减去一些时间(在处理时时间差时减去了一些毫秒用来补偿)。
想要了解关于系统需要一个范围的时间差的内容可以获取更多的信息,这篇论文是很好的参考: Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
当客户端无法获取锁时,它应该在一个随机延迟后重试,从而避免多个客户端同时试图获取锁,相对应同一的同时请求(这可能会导致崩溃,没人会胜出)。同样的,客户端在大多数场合下尝试获取锁的速度越快,崩溃的窗口就越少(重试的需要也越少),所以实际情况下客户端应尝试采用复用方式发送SET命令到多个实例。
强调客户在获取主锁失败是值得的,释放(或部分)以尽快获得锁,这样没有必要为获取锁锁而去等待键到期(但是如果网络分区发生变化时客户端不能与Redis通信的情况下,需要显性提示和等待超时)。
释放锁是简单的,只需要释放所有实例的锁即可,尽管客户端认为有能力成功锁住一个给出的实例。
要问一个算法是安全的么?那么可以尝试着去理解在不同的情景下发生了什么。我们以假设客户端在大多数情况下都能获得锁来开始,所有的实例都包含相同生存周期的键。由于键是在不同的时间设定的,所以键也将在不同的时间超时。然而,如果第一个节点最迟在T1时刻建立(即样品接触的第一服务器之前),上一个键最迟在T2时刻建立(从上一个服务器获得回复的时间)。可以确定的是第一个键在超时之前将生存至少MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。所有其他的钥匙将到期后,钥匙将至少在这一次同时设置。
在过半的键被设置这段时间里,另一个客户端无法获得锁,如果N/2+1个键已经存在,N/2+1 SET NX操作将不能成功。所以一个锁被获取,同一时刻被重复获取是不可能的(违反互斥性)。
然而我们还想让多个客户端在获取锁的时候不能同时成功。
如果一个客户端锁定大部分实例的时间超过了锁的最大有效时间(TTL基本设定) ,它将考虑锁无效了,并解锁。所以我们仅考虑在有效时间内大部分实例获得锁的情况。这种情况已经在上文中讨论过, 对于MIN_VALIDITY没有客户端会重新获取锁。所以只有当锁大多数实例的时间超过TTL时间时,多客户端才能同时锁住N/2+1个实例(在步骤2的“时间”即将结束时),让锁失效。
你是否能提供一个形式化的证明,指出现存的足够相似的算法,或找出些bug? 那我们将感激不尽。
系统的存活性基于以下3个主要特性:
然而,在网络割裂的情况下,我们得付出等同于”TTL”时间的可用性代价,如果网络持续割裂,我们就得无限的付出这个代价。这发生于当客户端获取了一个锁,而在删除锁之前网络断开了。
基本上,如果网络无限期地持续割裂,那系统将无限期地不可用。
许多用户使用Redis作为一个需要高性能的加锁服务器,可以根据延迟动态的获取和释放锁,每秒可以成功执行大量的获取/释放锁操作。为了满足这些需求,一种多路复用策略是协同N台 Redis服务器减少延迟(或者叫做穷人的互助,也就是说,将端口置为non-blocking模式,发送所有的命令,延迟读出所有的命令,假定客户端和每个Redis实例的往返时间是相似的)。
然而,如果我们旨在实现一种故障系统的恢复模式,这里有另一种与持久性相关的思路。
考虑这个基本问题,假定我们完全没有配置Redis的持久性。一个客户端需要锁定5个实例中的3个。其中一个允许客户端获取的锁重新启动,虽然我们可以再次为一些资源锁定3个实例,但其它的客户端同样可以锁定它,违反了排他锁安全性。
如果我们启用AOF持久性,情况就会得到相当的改善。例如我们可以通过发送 SHUTDOWN升级一个服务器并且重启它。因为Redis的期限是通过语义设置的,所以服务器关闭的情况下虚拟时间仍然会流逝,我们所有的需求都得到了满足。不管怎样所有事务都会正常运转只要服务器完全关闭。如果电源中断会怎样?如果Redis进行了相关配置,默认情况下每秒文件都会同步写入磁盘,很有可能在重启后我们的数据会丢失。理论上,如果我们想在任何一种实例重启后保证锁的安全性,我们需要确保在持久性配置中设置fsync=always。这将会在同等级别的CP系统上损失性能,传统上这种方式用来更安全的分配锁。
不管怎样事情比我们初次瞥见他们看起来好些。基本上算法的安全性得到保留,就算是当一个实例在故障后重启,它也将不再参与任何当前活跃的锁的分配。因此当实例重启时,当前所有活动锁的设置将从锁定的实例中获取除它重新加入系统。
为了保证这一点,我们只需要做一个实例,在超过最大TTL后,崩溃,不可用,那么就需要时间去获取所有存在着的锁的钥匙,当实例崩溃时,其就会变得无效,会被自动释放。
使用延时重启可以基本上实现安全,甚至不需要利用任何Redis的持久化特性,但是这存在着另外的副作用。举例来说,如果大量的实例崩溃,系统变得全局不可用,那么TTL(这里的全局意味着根本就没有资源可用,在这个时间内所有的资源都会被锁定)。
如果客户工作的执行是由小步骤组成,那么它就可以在默认时间里默认使用更小的锁,并扩展了算法去实现的一个锁的扩展机制。当锁的有效性接近于一个低值,那么通常是客户端在运算中处于居中位置。当锁被取得时,可能扩展的锁通过发送一个Lua脚本到所有的实例,这个实例是扩展TTL的钥匙,如果钥匙存在,那么它的值就是客户端复制的随机值。
客户端应该仅考虑锁的重新取得,如果它可以被扩展,锁就会在有效时间内进入大量实例(基本的算法使用非常类似于获取锁的使用)。
虽然这不是从技术上去改变算法,但是无论如何尝试获取锁的最大次数是需要限制的,否则的话会违反活跃性中的一个属性。
如果你用的是分布式系统,对于你的意见或是分析都将是非常有用的。同时用其他语言实现也是很有意义的参考。
先谢谢了!
Martin Kleppmann 分析了Redlock算法. 我并不同意他的分析,并 进行了回复。
]]>Raft 是一种为了管理日志复制的一致性算法。它提供了和 Paxos 算法相同的功能和性能,但是它的算法结构和 Paxos 不同,使得 Raft 算法更加容易理解并且能更好的构建实际系统。为了提升可理解性,Raft 将一致性算法分解成了几个关键模块,例如Leader选举、日志复制和安全性。同时它通过实施一个更强的一致性来减少需要考虑的状态的数量。用户研究证明,对于学生而言,Raft 算法比 Paxos 算法更加容易学习。Raft 算法还包括一个新的机制来允许集群成员的动态改变,它利用重叠的大多数来保证安全性。
一致性算法允许一组机器像一个整体般工作,即使其中一些机器出现故障也能够继续工作下去。正因为如此,一致性算法在构建可信赖的大规模软件系统中扮演着重要的角色。在过去的 10 年里,Paxos 算法统治着一致性算法这一领域:绝大多数的实现都是基于 Paxos 或者受其影响。同时 Paxos 也成为了教学领域里讲解一致性问题时的示例。
但是不幸的是,尽管有很多工作都在尝试降低它的复杂性,但是 Paxos 算法依然十分难以理解。并且,Paxos 自身的算法结构需要进行大幅的修改才能够应用到实际的系统中。这些都导致了工业界和学术界都对 Paxos 算法感到十分头疼。
在和 Paxos 算法折腾一番之后,我们开始寻找一种新的一致性算法,可以为构建实际的系统和教学提供更好的基础。我们的思路和其他的是不一样的,我们的首要目标是可理解性:我们是否可以在实际系统中定义一个一致性算法,并且能够比 Paxos 算法更加容易学习。此外,我们希望该算法能否符合开发者的思路,一个算法能够工作很重要,但是能够直观的知道为什么能工作也很重要。
Raft 一致性算法就是这些工作的结果。在设计 Raft 算法的时候,我们使用一些特别的技巧来提升它的可理解性,包括算法分解(Raft 主要被分成了Leader选举,日志复制和安全3个模块)和减少状态机的状态(相对于 Paxos,Raft 减少了非确定性和服务器互相处于非一致性的方式)。一份针对两所大学 43 个学生的研究表明 Raft 明显比 Paxos 算法更加容易理解。在这些学生同时学习了这两种算法之后,和 Paxos 比起来,其中 33 个学生能够回答有关于 Raft 的问题。
Raft 算法在许多方面和现有的一致性算法都很相似(主要是 Oki 和 Liskov 的 Viewstamped Replication),但是它也有一些独特的特性:
我们相信,Raft 算法无论是出于教学目的,还是作为实践项目的基础都是要比 Paxos 或者其他一致性算法要优异的。它比其他算法更加简单,更加容易理解; 它的算法描述足以实现一个实际的系统;它有好多开源的实现并且在很多公司里使用;它的安全性也已经被证明;它的效率和其他算法比起来也不相上下。
接下来,这篇论文会介绍以下内容:复制状态机问题(第 2 节),讨论 Paxos 的优点和缺点(第 3 节),讨论我们为了理解能力而使用的方法(第 4 节),阐述 Raft 一致性算法(第 5-8 节),评价 Raft 算法(第 9 节),以及一些相关的工作(第 10 节)。
一致性算法是从复制状态机的背景下提出的(参考英文原文引用37)。在这种方法中,一组服务器上的状态机产生相同状态的副本,并且在一些机器宕掉的情况下也可以继续运行。复制状态机在分布式系统中被用于解决很多容错的问题。例如,大规模的系统中通常都有一个集群领导者,像 GFS、HDFS 和 RAMCloud,典型应用就是一个独立的的复制状态机去管理Leader选举和存储配置信息并且在Leader宕机的情况下也可以存活下来。比如 Chubby 和 ZooKeeper。
图 1 :复制状态机的结构。一致性算法管理着来自客户端指令的复制日志。状态机从日志中处理相同顺序的相同指令,所以产生的结果也是相同的。
复制状态机通常都是基于复制日志来实现的,如图 1。每一个服务器存储一个包含一系列指令的日志,并且按照日志的顺序进行执行。每一个日志都按照相同的顺序包含相同的指令,所以每一个服务器都执行相同的指令序列。因为每个状态机都是确定的,每一次执行操作都产生相同的状态和同样的序列。
保证复制日志相同就是一致性算法的工作了。在一台服务器上,一致性模块(Consensus Module)接收客户端发送来的指令然后增加到自己的日志中去。它和其他服务器上的一致性模块进行通信来保证每一个服务器上的日志最终都以相同的顺序包含相同的请求,尽管有些服务器可能会宕机。一旦指令被正确的复制,每一个服务器的状态机按照日志顺序处理他们,然后输出结果被返回给客户端。因此,服务器集群看起来形成一个高可靠的状态机。
实际系统中使用的一致性算法通常含有以下特性:
在过去的 10 年里,Leslie Lamport 的 Paxos 算法几乎已经成为一致性的代名词:Paxos 是在课程教学中最经常使用的算法,同时也是大多数一致性算法实现的起点。Paxos 首先定义了一个能够达成单一决策一致的协议,比如单条的复制日志项。我们把这一子集叫做单决策 Paxos。然后通过组合多个 Paxos 协议的实例来促进一系列决策的达成。Paxos 保证安全性和活性,同时也支持集群成员关系的变更。Paxos 的正确性已经被证明,在通常情况下也很高效。
不幸的是,Paxos 有两个明显的缺点。第1个缺点是 Paxos 算法特别的难以理解。完整的解释是出了名的不透明;通过极大的努力之后,也只有少数人成功理解了这个算法。因此,有了几次用更简单的术语来解释 Paxos 的尝试。尽管这些解释都只关注了单决策的子集问题,但依然很具有挑战性。在 2012 年 NSDI 的会议中的一次调查显示,很少有人对 Paxos 算法感到满意,甚至在经验老道的研究者中也是如此。我们自己也尝试去理解 Paxos;我们一直没能理解 Paxos 直到我们读了很多对 Paxos 的简化解释并且设计了我们自己的算法之后,这一过程花了近一年时间。
我们假设 Paxos 的不透明性来自它选择单决策问题作为它的基础。单决策 Paxos 是晦涩微妙的,它被划分成了2种没有简单直观解释和无法独立理解的情景。因此,这导致了很难建立起直观的感受为什么单决策 Paxos 算法能够工作。构成多决策 Paxos 增加了很多错综复杂的规则。我们相信,在多决策上达成一致性的问题(一份日志而不是单一的日志记录)能够被分解成其他的方式并且更加直接和明显。
Paxos算法的第2个问题就是它没有提供一个足够好的用来构建一个现实系统的基础。一个原因是还没有一种被广泛认同的多决策问题的算法。Lamport 的描述基本上都是关于单决策 Paxos 的;他简要描述了实施多决策 Paxos 的方法,但是缺乏很多细节。当然也有很多具体化 Paxos 的尝试,但是他们都互相不一样,和 Paxos 的概述也不同。例如 Chubby 这样的系统实现了一个类似于 Paxos 的算法,但是大多数的细节并没有被公开。
而且,Paxos 算法的结构也不是十分易于构建实际的系统;单决策分解也会产生其他的结果。例如,独立的选择一组日志条目然后合并成一个序列化的日志并没有带来太多的好处,仅仅增加了不少复杂性。围绕着日志来设计一个系统是更加简单高效的;新日志条目以严格限制的顺序增添到日志中去。另一个问题是,Paxos 使用了一种对等的点对点的方式作为它的核心(尽管它最终提议了一种弱领导人的方法来优化性能)。在只有一个决策会被制定的简化世界中是很有意义的,但是很少有现实的系统使用这种方式。如果有一系列的决策需要被制定,首先选择一个领导人,然后让他去协调所有的决议,会更加简单快速。
因此,实际的系统中很少有和 Paxos 相似的实践。每一种实现都是从 Paxos 开始研究,然后发现很多实现上的难题,再然后开发了一种和 Paxos 明显不一样的结构。这样是非常费时和容易出错的,并且理解 Paxos 的难度使得这个问题更加糟糕。Paxos 算法在理论上被证明是正确可行的,但是现实的系统和 Paxos 差别是如此的大,以至于这些证明没有什么太大的价值。下面来自 Chubby 实现非常典型:
在Paxos算法描述和实现现实系统中间有着巨大的鸿沟。最终的系统建立在一种没有经过证明的算法之上。
由于以上问题,我们认为 Paxos 算法既没有提供一个良好的基础给实践的系统,也没有给教学很好的帮助。基于一致性问题在大规模软件系统中的重要性,我们决定看看我们是否可以设计一个拥有更好特性的替代 Paxos 的一致性算法。Raft算法就是这次实验的结果。
设计 Raft 算法我们有几个初衷:它必须提供一个完整的供实际系统实现的基础,这样才能大大减少开发者的工作;它必须在任何情况下都是安全的并且在大多数的情况下都是可用的;并且它的大部分操作必须是高效的。但是我们最重要也是最大的挑战是可理解性。它必须保证对于普遍的人群都可以十分容易的去理解。另外,它必须能够让人形成直观的认识,这样系统的构建者才能够在现实中进行必然的扩展。
在设计 Raft 算法的时候,有很多的点需要我们在各种备选方案中进行选择。在这种情况下,我们评估备选方案基于可理解性原则:解释各个备选方案有多大的难度(例如,Raft 的状态空间有多复杂,是否有微妙的暗示)、对于一个读者而言,完全理解这个方案和暗示是否容易。
我们意识到对这种可理解性分析上具有高度的主观性;尽管如此,我们使用了两种通常适用的技术来解决这个问题。第1个技术就是众所周知的问题分解:只要有可能,我们就将问题分解成几个相对独立的,可被解决的、可解释的和可理解的子问题。例如,Raft 算法被我们分成Leader选举,日志复制,安全性和角色改变几个部分。
我们使用的第2个方法是通过减少状态的数量来简化需要考虑的状态空间,使得系统更加连贯并且在可能的时候消除不确定性。特别的,所有的日志是不允许有空洞的,并且 Raft 限制了日志之间变成不一致状态的可能。尽管在大多数情况下我们都试图去消除不确定性,但是也有一些情况下不确定性可以提升可理解性。尤其是,随机化方法增加了不确定性,但是他们有利于减少状态空间数量,通过处理所有可能选择时使用相似的方法。我们使用随机化去简化 Raft 中Leader选举算法。
Raft 是一种用来管理章节 2 中描述的日志复制的算法。图 2 为了参考之用,总结这个算法的简略版本,图 3 列举了这个算法的一些关键特性。图中的这些元素会在剩下的章节逐一介绍。
Raft 通过选举一个高贵的Leader,然后给予他全部的管理复制日志的责任来实现一致性。Leader从客户端接收日志条目,把日志条目复制到其他服务器上,并且当保证安全性的时候告诉其他的服务器应用日志条目到他们的状态机中。拥有一个Leader大大简化了对复制日志的管理。例如,Leader可以决定新的日志条目需要放在日志中的什么位置而不需要和其他服务器商议,并且数据都从Leader流向其他服务器。一个Leader可以宕机,可以和其他服务器失去连接,这时一个新的Leader会被选举出来。
通过Leader的方式,Raft 将一致性问题分解成了3个相对独立的子问题,这些问题会在接下来的子章节中进行讨论:
在展示一致性算法之后,这一章节会讨论可用性的一些问题和系统中的候选人角色的问题。
状态:
状态 | 所有服务器上持久存在的 |
---|---|
currentTerm | 服务器最后一次知道的任期号(初始化为 0,持续递增) |
votedFor | 在当前获得选票的候选人的 Id |
log[] | 日志条目集;每一个条目包含一个用户状态机执行的指令,和收到时的任期号 |
状态 | 所有服务器上经常变的 |
---|---|
commitIndex | 已知的最大的已经被提交的日志条目的索引值 |
lastApplied | 最后被应用到状态机的日志条目索引值(初始化为 0,持续递增) |
状态 | 在Leader里经常改变的 (选举后重新初始化) |
---|---|
nextIndex[] | 对于每一个服务器,需要发送给他的下一个日志条目的索引值(初始化为Leader最后索引值+1) |
matchIndex[] | 对于每一个服务器,已经复制给他的日志的最高索引值 |
附加日志 RPC:
由Leader负责调用来复制日志指令;也会用作heartbeat
参数 | 解释 |
---|---|
term | Leader的任期号 |
leaderId | Leader的 Id,以便于跟随者重定向请求 |
prevLogIndex | 新的日志条目紧随之前的索引值 |
prevLogTerm | prevLogIndex 条目的任期号 |
entries[] | 准备存储的日志条目(表示心跳时为空;一次性发送多个是为了提高效率) |
leaderCommit | Leader已经提交的日志的索引值 |
返回值 | 解释 |
---|---|
term | 当前的任期号,用于Leader去更新自己 |
success | 跟随者包含了匹配上 prevLogIndex 和 prevLogTerm 的日志时为真 |
接收者实现:
term < currentTerm
就返回 false (5.1 节)leaderCommit > commitIndex
,令 commitIndex 等于 leaderCommit 和 新日志条目索引值中较小的一个请求投票 RPC:
由候选人负责调用用来征集选票(5.2 节)
参数 | 解释 |
---|---|
term | 候选人的任期号 |
candidateId | 请求选票的候选人的 Id |
lastLogIndex | 候选人的最后日志条目的索引值 |
lastLogTerm | 候选人最后日志条目的任期号 |
返回值 | 解释 |
---|---|
term | 当前任期号,以便于候选人去更新自己的任期号 |
voteGranted | 候选人赢得了此张选票时为真 |
接收者实现:
term < currentTerm
返回 false (5.2 节)所有服务器需遵守的规则:
所有服务器:
commitIndex > lastApplied
,那么就 lastApplied +1,并把log[lastApplied]
应用到状态机中(5.3 节)T > currentTerm
,那么就令 currentTerm 等于 T,并切换状态为跟随者(5.1 节)跟随者(5.2 节):
候选人(5.2 节):
领导人(Leader):
N > commitIndex
的 N,并且大多数的matchIndex[i] ≥ N
成立,并且log[N].term == currentTerm
成立,那么令 commitIndex 等于这个 N (5.3 和 5.4 节)图 2:一个关于 Raft 一致性算法的浓缩总结(不包括成员变换和日志压缩)。
特性 | 解释 |
---|---|
选举安全特性 | 对于一个给定的任期号,最多只会有一个领导人被选举出来(5.2 节) |
领导人只附加原则 | 领导人绝对不会删除或者覆盖自己的日志,只会增加(5.3 节) |
日志匹配原则 | 如果两个日志在相同的索引位置的日志条目的任期号相同,那么我们就认为这个日志从头到这个索引位置之间全部完全相同(5.3 节) |
领导人完全特性 | 如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有领导人中(5.4 节) |
状态机安全特性 | 如果一个领导人已经在给定的索引值位置的日志条目应用到状态机中,那么其他任何的服务器在这个索引位置不会提交一个不同的日志(5.4.3 节) |
图 3:Raft 在任何时候都保证以上的各个特性。
一个 Raft 集群包含若干个服务器节点;通常是 5 个,这允许整个系统容忍 2 个节点的失效。在任何时刻,每一个服务器节点都处于这3个状态之一:Leader、Follower或者Candidate。在通常情况下,系统中只有一个Leader并且其他的节点全部都是Follower。Follower都是被动的:他们不会发送任何请求,只是简单的响应来自Leader或者Candidate的请求。Leader处理所有的客户端请求(如果一个客户端和Follower联系,那么Follower会把请求重定向给Leader)。第3种状态,Candidate,是用来在 5.2 节描述的选举新Leader时使用。图 4 展示了这些状态和他们之间的转换关系;这些转换关系会在接下来进行讨论。
图 4:服务器状态。Follower只响应来自其他服务器的请求。如果Follower接收不到消息,那么他就会变成Candidate并发起一次选举。获得集群中大多数选票的Candidate将成为Leader。在一个任期内,Leader一直都会是Leader直到自己宕机了。
图 5:时间被划分成一个个的任期,每个任期开始都是一次选举。在选举成功后,Leader会管理整个集群直到任期结束。有时候选举会失败,那么这个任期就会没有Leader而结束。任期之间的切换可以在不同的时间不同的服务器上观察到。
Raft 把时间分割成任意长度的任期,如图 5。任期用连续的整数标记。每一段任期从一次选举开始,就像章节 5.2 描述的一样,一个或者多个Candidate尝试成为Leader。如果一个Leader赢得选举,然后他就在接下来的任期内充当Leader的职责。在某些情况下,一次选举过程会造成选票的瓜分。在这种情况下,这一任期会以没有Leader结束;一个新的任期(和一次新的选举)会很快重新开始。Raft 保证了在一个给定的任期内,最多只有一个Leader。
不同的服务器节点可能多次观察到任期之间的转换,但在某些情况下,一个节点也可能观察不到任何一次选举或者整个任期全程。任期在 Raft 算法中充当逻辑时钟的作用,这会允许服务器节点查明一些过期的信息比如陈旧的Leader。每一个节点存储一个当前任期号,这一编号在整个时期内单调的增长。当服务器之间通信的时候会交换当前任期号;如果一个服务器的当前任期号比其他人小,那么他会更新自己的编号到较大的编号值。如果一个Candidate或者Leader发现自己的任期号过期了,那么他会立即恢复成Follower状态。如果一个节点接收到一个包含过期的任期号的请求,那么他会直接拒绝这个请求。
Raft 算法中服务器节点之间通信使用远程过程调用(RPCs),并且基本的一致性算法只需要两种类型的 RPCs。请求投票(RequestVote) RPCs 由Candidate在选举期间发起(章节 5.2),然后附加条目(AppendEntries)RPCs 由Leader发起,用来复制日志和提供一种心跳机制(章节 5.3)。第 7 节为了在服务器之间传输快照增加了第三种 RPC。当服务器没有及时的收到 RPC 的响应时,会进行重试, 并且他们能够并行的发起 RPCs 来获得最佳的性能。
Raft 使用一种心跳机制来触发Leader选举。当服务器程序启动时,他们都是Follower身份。一个服务器节点继续保持着Follower状态只要他从Leader或者Candidate处接收到有效的 RPCs。Leader周期性的向所有跟随者发送心跳包(即不包含日志项内容的附加日志项 RPCs)来维持自己的权威。如果一个Follower在一段时间里没有接收到任何消息,也就是选举超时,那么他就会认为系统中没有可用的Leader,并且发起选举以选出新的Leader。
要开始一次选举过程,Follower先要增加自己的当前任期号并且转换到Candidate状态。然后他会并行的向集群中的其他服务器节点发送请求投票的 RPCs 来给自己投票。Candidate会继续保持着当前状态直到以下3件事情之一发生:(a) 他自己赢得了这次的选举,(b) 其他的服务器成为Leader,(c) 一段时间之后没有任何一个获胜的人。这些结果会分别的在下面的段落里进行讨论。
当一个Candidate从整个集群的大多数服务器节点获得了针对同一个任期号的选票,那么他就赢得了这次选举并成为Leader。每一个服务器最多会对一个任期号投出一张选票,按照先来先服务的原则(注意:5.4 节在投票上增加了一点额外的限制)。要求大多数选票的规则确保了最多只会有一个Candidate赢得此次选举(图 3 中的选举安全性)。一旦Candidate赢得选举,他就立即成为Leader。然后他会向其他的服务器发送心跳消息来建立自己的权威并且阻止新的Leader的产生。
在等待投票的时候,Candidate可能会从其他的服务器接收到声明它是Leader的附加日志项 RPC。如果这个Leader的任期号(包含在此次的 RPC中)不小于Candidate当前的任期号,那么Candidate会承认Leader合法并回到Follower状态。 如果此次 RPC 中的任期号比自己小,那么Candidate就会拒绝这次的 RPC 并且继续保持Candidate状态。
第3种可能的结果是Candidate既没有赢得选举也没有输:如果有多个Follower同时成为候选人,那么选票可能会被瓜分以至于没有Candidate可以赢得大多数人的支持。当这种情况发生的时候,每一个Candidate都会超时,然后通过增加当前任期号来开始一轮新的选举。然而,没有其他机制的话,选票可能会被无限的重复瓜分。
Raft 算法使用随机选举超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快的解决。为了阻止选票起初就被瓜分,选举超时时间是从一个固定的区间(例如 150-300 毫秒)随机选择。这样可以把服务器都分散开以至于在大多数情况下只有一个服务器会选举超时;然后他赢得选举并在其他服务器超时之前发送心跳包。同样的机制被用在选票瓜分的情况下。每一个Candidate在开始一次选举的时候会重置一个随机的选举超时时间,然后在超时时间内等待投票的结果;这样减少了在新的选举中另外的选票瓜分的可能性。9.3 节展示了这种方案能够快速的选出一个Leader。
Leader选举这个例子,体现了可理解性原则是如何指导我们进行方案设计的。起初我们计划使用一种排名系统:每一个Candidate都被赋予一个唯一的排名,供Candidate之间竞争时进行选择。如果一个Candidate发现另一个Candidate拥有更高的排名,那么他就会回到Follower状态,这样高排名的Candidate能够更加容易的赢得下一次选举。但是我们发现这种方法在可用性方面会有一点问题(如果高排名的服务器宕机了,那么低排名的服务器可能会超时并再次进入Candidate状态。而且如果这个行为发生得足够快,则可能会导致整个选举过程都被重置掉)。我们针对算法进行了多次调整,但是每次调整之后都会有新的问题。最终我们认为随机重试的方法是更加明显和易于理解的。
一旦一个Leader被选举出来,他就开始为客户端提供服务。客户端的每一个请求都包含一条被复制状态机执行的指令。Leader把这条指令作为一条新的日志条目附加到日志中去,然后并行的发起附加条目 RPCs 给其他的服务器,让他们复制这条日志条目。当这条日志条目被安全的复制(下面会介绍),Leader会应用这条日志条目到它的状态机中然后把执行的结果返回给客户端。如果跟随者崩溃或者运行缓慢,再或者网络丢包,Leader会不断的重复尝试附加日志条目 RPCs (尽管已经回复了客户端)直到所有的Follower都最终存储了所有的日志条目。
图 6:日志由有序序号标记的条目组成。每个条目都包含创建时的任期号(图中框中的数字),和一个状态机需要执行的指令。一个条目当可以安全的被应用到状态机中去的时候,就认为是可以提交了。
日志以图 6 展示的方式组织。每一个日志条目存储一条状态机指令和从Leader收到这条指令时的任期号。日志中的任期号用来检查是否出现不一致的情况,同时也用来保证图 3 中的某些性质。每一条日志条目同时也都有一个整数索引值来表明它在日志中的位置。
Leader来决定什么时候把日志条目应用到状态机中是安全的;这种日志条目被称为已提交。Raft 算法保证所有已提交的日志条目都是持久化的并且最终会被所有可用的状态机执行。在Leader将创建的日志条目复制到大多数的服务器上的时候,日志条目就会被提交(例如在图 6 中的条目 7)。同时,Leader的日志中之前的所有日志条目也都会被提交,包括由其他Leader创建的条目。5.4 节会讨论某些当在Leader改变之后应用这条规则的隐晦内容,同时他也展示了这种提交的定义是安全的。Leader跟踪了最大的将会被提交的日志项的索引,并且索引值会被包含在未来的所有附加日志 RPCs (包括心跳包),这样其他的服务器才能最终知道Leader的提交位置。一旦跟随者知道一条日志条目已经被提交,那么他也会将这个日志条目应用到本地的状态机中(按照日志的顺序)。
我们设计了 Raft 的日志机制来维护一个不同服务器的日志之间的高层次的一致性。这么做不仅简化了系统的行为也使得更加可预计,同时他也是安全性保证的一个重要组件。Raft 维护着以下的特性,这些同时也组成了图 3 中的日志匹配特性:
第1个特性来自这样的一个事实,Leader最多在一个任期里在指定的一个日志索引位置创建一条日志条目,同时日志条目在日志中的位置也从来不会改变。第2个特性由附加日志 RPC 的一个简单的一致性检查所保证。在发送附加日志 RPC 的时候,Leader会把新的日志条目紧接着之前的条目的索引位置和任期号包含在里面。如果Follower在它的日志中找不到包含相同索引位置和任期号的条目,那么他就会拒绝接收新的日志条目。一致性检查就像一个归纳步骤:一开始空的日志状态肯定是满足日志匹配特性的,然后一致性检查保护了日志匹配特性当日志扩展的时候。因此,每当附加日志 RPC 返回成功时,Leader就知道跟随者的日志一定是和自己相同的了。
在正常的操作中,Leader和Follower的日志保持一致性,所以附加日志 RPC 的一致性检查从来不会失败。然而,Leader崩溃的情况会使得日志处于不一致的状态(老的Leader可能还没有完全复制所有的日志条目)。这种不一致问题会在Leader和Follower的一系列崩溃下加剧。图 7 展示了Follower的日志可能和新的Leader不同的方式。Follower可能会丢失一些在新的Leader中有的日志条目,他也可能拥有一些Leader没有的日志条目,或者两者都发生。丢失或者多出日志条目可能会持续多个任期。
图 7:当一个Leader成功当选时,Follower者可能是任何情况(a-f)。每一个盒子表示是一个日志条目;里面的数字表示任期号。跟随者可能会缺少一些日志条目(a-b),可能会有一些未被提交的日志条目(c-d),或者两种情况都存在(e-f)。例如,场景 f 可能会这样发生,某服务器在任期 2 的时候是领导人,已附加了一些日志条目到自己的日志中,但在提交之前就崩溃了;很快这个机器就被重启了,在任期 3 重新被选为Leader,并且又增加了一些日志条目到自己的日志中;在任期 2 和任期 3 的日志被提交之前,这个服务器又宕机了,并且在接下来的几个任期里一直处于宕机状态。
在 Raft 算法中,Leader处理不一致是通过强制Follower直接复制自己的日志来解决了。这意味着在Follower中的冲突的日志条目会被Follower的日志覆盖。5.4 节会阐述如何通过增加一些限制来使得这样的操作是安全的。
要使得Follower的日志进入和自己一致的状态,Leader必须找到最后两者达成一致的地方,然后删除从那个点之后的所有日志条目,发送自己的日志给跟随者。所有的这些操作都在进行附加日志 RPCs 的一致性检查时完成。Leader针对每一个Follower维护了一个 nextIndex,这表示下一个需要发送给Follower的日志条目的索引地址。当一个Leader刚获得权力的时候,他初始化所有的 nextIndex 值为自己的最后一条日志的index加1(图 7 中的 11)。如果一个Follower的日志和Leader不一致,那么在下一次的附加日志 RPC 时的一致性检查就会失败。在被Follower拒绝之后,Leader就会减小 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得Leader和Follower的日志达成一致。当这种情况发生,附加日志 RPC 就会成功,这时就会把Follower冲突的日志条目全部删除并且加上Leader的日志。一旦附加日志 RPC 成功,那么Follower的日志就会和Leader保持一致,并且在接下来的任期里一直继续保持。
如果需要的话,算法可以通过减少被拒绝的附加日志 RPCs 的次数来优化。例如,当附加日志 RPC 的请求被拒绝的时候,Follower可以包含冲突的条目的任期号和自己存储的那个任期的最早的索引地址。借助这些信息,Leader可以减小 nextIndex 越过所有那个任期冲突的所有日志条目;这样就变成每个任期需要一次附加条目 RPC 而不是每个条目一次。在实践中,我们十分怀疑这种优化是否是必要的,因为失败是很少发生的并且也不大可能会有这么多不一致的日志。
通过这种机制,Leader在获得权力的时候就不需要任何特殊的操作来恢复一致性。他只需要进行正常的操作,然后日志就能自动的在回复附加日志 RPC 的一致性检查失败的时候自动趋于一致。Leader从来不会覆盖或者删除自己的日志(图 3 的Leader只附加特性)。
日志复制机制展示出了第 2 节中形容的一致性特性:Raft 能够接受,复制并应用新的日志条目只要大部分的机器是工作的;在通常的情况下,新的日志条目可以在一次 RPC 中被复制给集群中的大多数机器;并且单个的缓慢的跟随者不会影响整体的性能。
前面的章节里描述了 Raft 算法是如何选举和复制日志的。然而,到目前为止描述的机制并不能充分的保证每一个状态机会按照相同的顺序执行相同的指令。例如,一个Follower可能会进入不可用状态同时Leader已经提交了若干的日志条目,然后这个Follower可能会被选举为Leader并且覆盖这些日志条目;因此,不同的状态机可能会执行不同的指令序列。
这一节通过在Leader选举的时候增加一些限制来完善 Raft 算法。这一限制保证了任何的Leader对于给定的任期号,都拥有了之前任期的所有被提交的日志条目(图 3 中的Leader完整特性)。增加这一选举时的限制,我们对于提交时的规则也更加清晰。最终,我们将展示对于Leader完整特性的简要证明,并且说明Leader是如何领导复制状态机的做出正确行为的。
在任何基于Leader的一致性算法中,Leader都必须存储所有已经提交的日志条目。在某些一致性算法中,例如 Viewstamped Replication,某个节点即使是一开始并没有包含所有已经提交的日志条目,它也能被选为Leader。这些算法都包含一些额外的机制来识别丢失的日志条目并把他们传送给新的Leader,要么是在选举阶段要么在之后很快进行。不幸的是,这种方法会导致相当大的额外的机制和复杂性。Raft 使用了一种更加简单的方法,它可以保证所有之前的任期号中已经提交的日志条目在选举的时候都会出现在新的Leader中,不需要传送这些日志条目给Leader。这意味着日志条目的传送是单向的,只从Leader传给跟随者,并且Leader从不会覆盖自身本地日志中已经存在的条目。
Raft 使用投票的方式来阻止一个Candidate赢得选举除非这个Candidate包含了所有已经提交的日志条目。Candidate为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志条目在这些服务器节点中肯定存在于至少一个节点上。如果候选人的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么他一定持有了所有已经提交的日志条目。请求投票 RPC 实现了这样的限制: RPC 中包含了Candidate的日志信息,然后投票人会拒绝掉那些日志没有自己新的投票请求。
Raft 通过比较两份日志中最后一条日志条目的索引值和任期号定义谁的日志比较新。如果两份日志最后的条目的任期号不同,那么任期号大的日志更加新。如果两份日志最后的条目任期号相同,那么日志比较长的那个就更加新。
如同 5.3 节介绍的那样,Leader知道一条当前任期内的日志记录是可以被提交的,只要它被存储到了大多数的服务器上。如果一个Leader在提交日志条目之前崩溃了,未来后续的Leader会继续尝试复制这条日志记录。然而,一个Leader不能断定一个之前任期里的日志条目被保存到大多数服务器上的时候就一定已经提交了。图 8 展示了一种情况,一条已经被存储到大多数节点上的老日志条目,也依然有可能会被未来的Leader覆盖掉。
图 8:如图的时间序列展示了为什么Leader无法决定对老任期号的日志条目进行提交。在 (a) 中,S1 是领导者,部分的复制了索引位置 2 的日志条目。在 (b) 中,S1 崩溃了,然后 S5 在任期 3 里通过 S3、S4 和自己的选票赢得选举,然后从客户端接收了一条不一样的日志条目放在了索引 2 处。然后到 (c),S5 又崩溃了;S1 重新启动,选举成功,开始复制日志。在这时,来自任期 2 的那条日志已经被复制到了集群中的大多数机器上,但是还没有被提交。如果 S1 在 (d) 中又崩溃了,S5 可以重新被选举成功(通过来自 S2,S3 和 S4 的选票),然后覆盖了他们在索引 2 处的日志。反之,如果在崩溃之前,S1 把自己主导的新任期里产生的日志条目复制到了大多数机器上,就如 (e) 中那样,那么在后面任期里面这些新的日志条目就会被提交(因为S5 就不可能选举成功)。 这样在同一时刻就同时保证了,之前的所有老的日志条目就会被提交。
为了消除图 8 里描述的情况,Raft 永远不会通过计算副本数目的方式去提交一个之前任期内的日志条目。只有Leader当前任期里的日志条目通过计算副本数目可以被提交;一旦当前任期的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。在某些情况下,Leader可以安全的知道一个老的日志条目是否已经被提交(例如,该条目是否存储到所有服务器上),但是 Raft 为了简化问题使用一种更加保守的方法。
当Leader复制之前任期里的日志时,Raft 会为所有日志保留原始的任期号, 这在提交规则上产生了额外的复杂性。在其他的一致性算法中,如果一个新的Leader要重新复制之前的任期里的日志时,它必须使用当前新的任期号。Raft 使用的方法更加容易辨别出日志,因为它可以随着时间和日志的变化对日志维护着同一个任期编号。另外,和其他的算法相比,Raft 中的新Leader只需要发送更少日志条目(其他算法中必须在他们被提交之前发送更多的冗余日志条目来为他们重新编号)。
在给定了完整的 Raft 算法之后,我们现在可以更加精确的讨论Leader完整性特性(这一讨论基于 9.2 节的安全性证明)。我们假设Leader完全性特性是不存在的,然后我们推出矛盾来。假设任期 T 的Leader(Leader T)在任期内提交了一条日志条目,但是这条日志条目没有被存储到未来某个任期的Leader的日志中。设大于 T 的最小任期 U 的领导人 U 没有这条日志条目。
图 9:如果 S1 (任期 T 的领Leader)提交了一条新的日志在它的任期里,然后 S5 在之后的任期 U 里被选举为Leader,然后至少会有一个机器,如 S3,既拥有来自 S1 的日志,也给 S5 投票了。
通过领导人完全特性,我们就能证明图 3 中的状态机安全特性,即如果已经服务器已经在某个给定的索引值应用了日志条目到自己的状态机里,那么其他的服务器不会应用一个不一样的日志到同一个索引值上。在一个服务器应用一条日志条目到他自己的状态机中时,他的日志必须和领导人的日志,在该条目和之前的条目上相同,并且已经被提交。现在我们来考虑在任何一个服务器应用一个指定索引位置的日志的最小任期;日志完全特性保证拥有更高任期号的领导人会存储相同的日志条目,所以之后的任期里应用某个索引位置的日志条目也会是相同的值。因此,状态机安全特性是成立的。
最后,Raft 要求服务器按照日志中索引位置顺序应用日志条目。和状态机安全特性结合起来看,这就意味着所有的服务器会应用相同的日志序列集到自己的状态机中,并且是按照相同的顺序。
到目前为止,我们都只关注了领导人崩溃的情况。跟随者和候选人崩溃后的处理方式比领导人要简单的多,并且他们的处理方式是相同的。如果跟随者或者候选人崩溃了,那么后续发送给他们的 RPCs 都会失败。Raft 中处理这种失败就是简单的通过无限的重试;如果崩溃的机器重启了,那么这些 RPC 就会完整的成功。如果一个服务器在完成了一个 RPC,但是还没有响应的时候崩溃了,那么在他重新启动之后就会再次收到同样的请求。Raft 的 RPCs 都是幂等的,所以这样重试不会造成任何问题。例如一个跟随者如果收到附加日志请求但是他已经包含了这一日志,那么他就会直接忽略这个新的请求。
Raft 的要求之一就是安全性不能依赖时间:整个系统不能因为某些事件运行的比预期快一点或者慢一点就产生了错误的结果。但是,可用性(系统可以及时的响应客户端)不可避免的要依赖于时间。例如,如果消息交换比服务器故障间隔时间长,候选人将没有足够长的时间来赢得选举;没有一个稳定的领导人,Raft 将无法工作。
领导人选举是 Raft 中对时间要求最为关键的方面。Raft 可以选举并维持一个稳定的领导人,只要系统满足下面的时间要求:
广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障间隔时间(MTBF)
在这个不等式中,广播时间指的是从一个服务器并行的发送 RPCs 给集群中的其他服务器并接收响应的平均时间;选举超时时间就是在 5.2 节中介绍的选举的超时时间限制;然后平均故障间隔时间就是对于一台服务器而言,两次故障之间的平均时间。广播时间必须比选举超时时间小一个量级,这样领导人才能够发送稳定的心跳消息来阻止跟随者开始进入选举状态;通过随机化选举超时时间的方法,这个不等式也使得选票瓜分的情况变得不可能。选举超时时间应该要比平均故障间隔时间小上几个数量级,这样整个系统才能稳定的运行。当领导人崩溃后,整个系统会大约相当于选举超时的时间里不可用;我们希望这种情况在整个系统的运行中很少出现。
广播时间和平均故障间隔时间是由系统决定的,但是选举超时时间是我们自己选择的。Raft 的 RPCs 需要接收方将信息持久化的保存到稳定存储中去,所以广播时间大约是 0.5 毫秒到 20 毫秒,取决于存储的技术。因此,选举超时时间可能需要在 10 毫秒到 500 毫秒之间。大多数的服务器的平均故障间隔时间都在几个月甚至更长,很容易满足时间的需求。
到目前为止,我们都假设集群的配置(加入到一致性算法的服务器集合)是固定不变的。但是在实践中,偶尔是会改变集群的配置的,例如替换那些宕机的机器或者改变复制级别。尽管可以通过暂停整个集群,更新所有配置,然后重启整个集群的方式来实现,但是在更改的时候集群会不可用。另外,如果存在手工操作步骤,那么就会有操作失误的风险。为了避免这样的问题,我们决定自动化配置改变并且将其纳入到 Raft 一致性算法中来。
为了让配置修改机制能够安全,那么在转换的过程中不能够存在任何时间点使得两个领导人同时被选举成功在同一个任期里。不幸的是,任何服务器直接从旧的配置直接转换到新的配置的方案都是不安全的。一次性自动的转换所有服务器是不可能的,所以在转换期间整个集群存在划分成两个独立的大多数群体的可能性(见图 10)。
图 10:直接从一种配置转到新的配置是十分不安全的,因为各个机器可能在任何的时候进行转换。在这个例子中,集群配额从 3 台机器变成了 5 台。不幸的是,存在这样的一个时间点,两个不同的领导人在同一个任期里都可以被选举成功。一个是通过旧的配置,一个通过新的配置。
为了保证安全性,配置更改必须使用两阶段方法。目前有很多种两阶段的实现。例如,有些系统在第一阶段停掉旧的配置所以集群就不能处理客户端请求;然后在第二阶段在启用新的配置。在 Raft 中,集群先切换到一个过渡的配置,我们称之为共同一致;一旦共同一致已经被提交了,那么系统就切换到新的配置上。共同一致是老配置和新配置的结合:
共同一致允许独立的服务器在不影响安全性的前提下,在不同的时间进行配置转换过程。此外,共同一致可以让集群在配置转换的过程人依然响应客户端的请求。
集群配置在复制日志中以特殊的日志条目来存储和通信;图 11 展示了配置转换的过程。当一个领导人接收到一个改变配置从 C-old 到 C-new 的请求,他会为了共同一致存储配置(图中的 C-old,new),以前面描述的日志条目和副本的形式。一旦一个服务器将新的配置日志条目增加到它的日志中,他就会用这个配置来做出未来所有的决定(服务器总是使用最新的配置,无论他是否已经被提交)。这意味着领导人要使用 C-old,new 的规则来决定日志条目 C-old,new 什么时候需要被提交。如果领导人崩溃了,被选出来的新领导人可能是使用 C-old 配置也可能是 C-old,new 配置,这取决于赢得选举的候选人是否已经接收到了 C-old,new 配置。在任何情况下, C-new 配置在这一时期都不会单方面的做出决定。
一旦 C-old,new 被提交,那么无论是 C-old 还是 C-new,在没有经过他人批准的情况下都不可能做出决定,并且领导人完全特性保证了只有拥有 C-old,new 日志条目的服务器才有可能被选举为领导人。这个时候,领导人创建一条关于 C-new 配置的日志条目并复制给集群就是安全的了。再者,每个服务器在见到新的配置的时候就会立即生效。当新的配置在 C-new 的规则下被提交,旧的配置就变得无关紧要,同时不使用新的配置的服务器就可以被关闭了。如图 11,C-old 和 C-new 没有任何机会同时做出单方面的决定;这保证了安全性。
图 11:一个配置切换的时间线。虚线表示已经被创建但是还没有被提交的条目,实线表示最后被提交的日志条目。领导人首先创建了 C-old,new 的配置条目在自己的日志中,并提交到 C-old,new 中(C-old 的大多数和 C-new 的大多数)。然后他创建 C-new 条目并提交到 C-new 中的大多数。这样就不存在 C-new 和 C-old 可以同时做出决定的时间点。
在关于重新配置还有三个问题需要提出。第一个问题是,新的服务器可能初始化没有存储任何的日志条目。当这些服务器以这种状态加入到集群中,那么他们需要一段时间来更新追赶,这时还不能提交新的日志条目。为了避免这种可用性的间隔时间,Raft 在配置更新的时候使用了一种额外的阶段,在这个阶段,新的服务器以没有投票权身份加入到集群中来(领导人复制日志给他们,但是不考虑他们是大多数)。一旦新的服务器追赶上了集群中的其他机器,重新配置可以像上面描述的一样处理。
第二个问题是,集群的领导人可能不是新配置的一员。在这种情况下,领导人就会在提交了 C-new 日志之后退位(回到跟随者状态)。这意味着有这样的一段时间,领导人管理着集群,但是不包括他自己;他复制日志但是不把他自己算作是大多数之一。当 C-new 被提交时,会发生领导人过渡,因为这时是最早新的配置可以独立工作的时间点(将总是能够在 C-new 配置下选出新的领导人)。在此之前,可能只能从 C-old 中选出领导人。
第三个问题是,移除不在 C-new 中的服务器可能会扰乱集群。这些服务器将不会再接收到心跳,所以当选举超时,他们就会进行新的选举过程。他们会发送拥有新的任期号的请求投票 RPCs,这样会导致当前的领导人回退成跟随者状态。新的领导人最终会被选出来,但是被移除的服务器将会再次超时,然后这个过程会再次重复,导致整体可用性大幅降低。
为了避免这个问题,当服务器确认当前领导人存在时,服务器会忽略请求投票 RPCs。特别的,当服务器在当前最小选举超时时间内收到一个请求投票 RPC,他不会更新当前的任期号或者投出选票。这不会影响正常的选举,每个服务器在开始一次选举之前,至少等待一个最小选举超时时间。然而,这有利于避免被移除的服务器扰乱:如果领导人能够发送心跳给集群,那么他就不会被更大的任期号废黜。
Raft 的日志在正常操作中不断的增长,但是在实际的系统中,日志不能无限制的增长。随着日志不断增长,他会占用越来越多的空间,花费越来越多的时间来重置。如果没有一定的机制去清除日志里积累的陈旧的信息,那么会带来可用性问题。
快照是最简单的压缩方法。在快照系统中,整个系统的状态都以快照的形式写入到稳定的持久化存储中,然后到那个时间点之前的日志全部丢弃。快照技术被使用在 Chubby 和 ZooKeeper 中,接下来的章节会介绍 Raft 中的快照技术。
增量压缩的方法,例如日志清理或者日志结构合并树,都是可行的。这些方法每次只对一小部分数据进行操作,这样就分散了压缩的负载压力。首先,他们先选择一个已经积累的大量已经被删除或者被覆盖对象的区域,然后重写那个区域还活跃的对象,之后释放那个区域。和简单操作整个数据集合的快照相比,需要增加复杂的机制来实现。状态机可以实现 LSM tree 使用和快照相同的接口,但是日志清除方法就需要修改 Raft 了。
图 12:一个服务器用新的快照替换了从 1 到 5 的条目,快照值存储了当前的状态。快照中包含了最后的索引位置和任期号。
图 12 展示了 Raft 中快照的基础思想。每个服务器独立的创建快照,只包括已经被提交的日志。主要的工作包括将状态机的状态写入到快照中。Raft 也包含一些少量的元数据到快照中:最后被包含索引指的是被快照取代的最后的条目在日志中的索引值(状态机最后应用的日志),最后被包含的任期指的是该条目的任期号。保留这些数据是为了支持快照后紧接着的第一个条目的附加日志请求时的一致性检查,因为这个条目需要最后的索引值和任期号。为了支持集群成员更新(第 6 节),快照中也将最后的一次配置作为最后一个条目存下来。一旦服务器完成一次快照,他就可以删除最后索引位置之前的所有日志和快照了。
尽管通常服务器都是独立的创建快照,但是领导人必须偶尔的发送快照给一些落后的跟随者。这通常发生在当领导人已经丢弃了下一条需要发送给跟随者的日志条目的时候。幸运的是这种情况不是常规操作:一个与领导人保持同步的跟随者通常都会有这个条目。然而一个运行非常缓慢的跟随者或者新加入集群的服务器(第 6 节)将不会有这个条目。这时让这个跟随者更新到最新的状态的方式就是通过网络把快照发送给他们。
安装快照 RPC:
在领导人发送快照给跟随者时使用到。领导人总是按顺序发送。
参数 | 解释 |
---|---|
term | 领导人的任期号 |
leaderId | 领导人的 Id,以便于跟随者重定向请求 |
lastIncludedIndex | 快照中包含的最后日志条目的索引值 |
lastIncludedTerm | 快照中包含的最后日志条目的任期号 |
offset | 分块在快照中的偏移量 |
data[] | 原始数据 |
done | 如果这是最后一个分块则为 true |
结果 | 解释 |
---|---|
term | 当前任期号,便于领导人更新自己 |
接收者实现:
term < currentTerm
就立即回复图 13:一个关于安装快照的简要概述。为了便于传输,快照都是被分成分块的;每个分块都给了跟随者生命的迹象,所以跟随者可以重置选举超时计时器。
在这种情况下领导人使用一种叫做安装快照的新的 RPC 来发送快照给太落后的跟随者;见图 13。当跟随者通过这种 RPC 接收到快照时,他必须自己决定对于已经存在的日志该如何处理。通常快照会包含没有在接收者日志中存在的信息。在这种情况下,跟随者直接丢弃他所有的日志;这些会被快照所取代,但是可能会和没有提交的日志产生冲突。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照之后的条目必须正确和保留。
这种快照的方式背离了 Raft 的强领导人原则,因为跟随者可以在不知道领导人情况下创建快照。但是我们认为这种背离是值得的。领导人的存在,是为了解决在达成一致性的时候的冲突,但是在创建快照的时候,一致性已经达成,这时不存在冲突了,所以没有领导人也是可以的。数据依然是从领导人传给跟随者,只是跟随者可以重新组织他们的数据了。
我们考虑过一种替代的基于领导人的快照方案,即只有领导人创建快照,然后发送给所有的跟随者。但是这样做有两个缺点。第一,发送快照会浪费网络带宽并且延缓了快照处理的时间。每个跟随者都已经拥有了所有产生快照需要的信息,而且很显然,自己从本地的状态中创建快照比通过网络接收别人发来的要经济。第二,领导人的实现会更加复杂。例如,领导人需要发送快照的同时并行的将新的日志条目发送给跟随者,这样才不会阻塞新的客户端请求。
还有两个问题影响了快照的性能。首先,服务器必须决定什么时候应该创建快照。如果快照创建的过于频繁,那么就会浪费大量的磁盘带宽和其他资源;如果创建快照频率太低,他就要承受耗尽存储容量的风险,同时也增加了从日志重建的时间。一个简单的策略就是当日志大小达到一个固定大小的时候就创建一次快照。如果这个阈值设置的显著大于期望的快照的大小,那么快照对磁盘压力的影响就会很小了。
第二个影响性能的问题就是写入快照需要花费显著的一段时间,并且我们还不希望影响到正常操作。解决方案是通过写时复制的技术,这样新的更新就可以被接收而不影响到快照。例如,具有函数式数据结构的状态机天然支持这样的功能。另外,操作系统的写时复制技术的支持(如 Linux 上的 fork)可以被用来创建完整的状态机的内存快照(我们的实现就是这样的)。
这一节将介绍客户端是如何和 Raft 进行交互的,包括客户端如何发现领导人和 Raft 是如何支持线性化语义的。这些问题对于所有基于一致性的系统都存在,并且 Raft 的解决方案和其他的也差不多。
Raft 中的客户端发送所有请求给领导人。当客户端启动的时候,他会随机挑选一个服务器进行通信。如果客户端第一次挑选的服务器不是领导人,那么那个服务器会拒绝客户端的请求并且提供他最近接收到的领导人的信息(附加条目请求包含了领导人的网络地址)。如果领导人已经崩溃了,那么客户端的请求就会超时;客户端之后会再次重试随机挑选服务器的过程。
我们 Raft 的目标是要实现线性化语义(每一次操作立即执行,只执行一次,在他调用和收到回复之间)。但是,如上述,Raft 是可以执行同一条命令多次的:例如,如果领导人在提交了这条日志之后,但是在响应客户端之前崩溃了,那么客户端会和新的领导人重试这条指令,导致这条命令就被再次执行了。解决方案就是客户端对于每一条指令都赋予一个唯一的序列号。然后,状态机跟踪每条指令最新的序列号和相应的响应。如果接收到一条指令,它的序列号已经被执行了,那么就立即返回结果,而不重新执行指令。
只读的操作可以直接处理而不需要记录日志。但是,在不增加任何限制的情况下,这么做可能会冒着返回脏数据的风险,因为领导人响应客户端请求时可能已经被新的领导人作废了,但是他还不知道。线性化的读操作必须不能返回脏数据,Raft 需要使用两个额外的措施在不使用日志的情况下保证这一点。首先,领导人必须有关于被提交日志的最新信息。领导人完全特性保证了领导人一定拥有所有已经被提交的日志条目,但是在他任期开始的时候,他可能不知道那些是已经被提交的。为了知道这些信息,他需要在他的任期里提交一条日志条目。Raft 中通过领导人在任期开始的时候提交一个空白的没有任何操作的日志条目到日志中去来实现。第二,领导人在处理只读的请求之前必须检查自己是否已经被废黜了(他自己的信息已经变脏了如果一个更新的领导人被选举出来)。Raft 中通过让领导人在响应只读请求之前,先和集群中的大多数节点交换一次心跳信息来处理这个问题。可选的,领导人可以依赖心跳机制来实现一种租约的机制,但是这种方法依赖时间来保证安全性(假设时间误差是有界的)。
我们已经为 RAMCloud 实现了 Raft 算法作为存储配置信息的复制状态机的一部分,并且帮助 RAMCloud 协调故障转移。这个 Raft 实现包含大约 2000 行 C++ 代码,其中不包括测试、注释和空行。这些代码是开源的。同时也有大约 25 个其他独立的第三方的基于这篇论文草稿的开源实现,针对不同的开发场景。同时,很多公司已经部署了基于 Raft 的系统。
这一节会从三个方面来评估 Raft 算法:可理解性、正确性和性能。
为了和 Paxos 比较 Raft 算法的可理解能力,我们针对高层次的本科生和研究生,在斯坦福大学的高级操作系统课程和加州大学伯克利分校的分布式计算课程上,进行了一次学习的实验。我们分别拍了针对 Raft 和 Paxos 的视频课程,并准备了相应的小测验。Raft 的视频讲课覆盖了这篇论文的所有内容除了日志压缩;Paxos 讲课包含了足够的资料来创建一个等价的复制状态机,包括单决策 Paxos,多决策 Paxos,重新配置和一些实际系统需要的性能优化(例如领导人选举)。小测验测试一些对算法的基本理解和解释一些边角的示例。每个学生都是看完第一个视频,回答相应的测试,再看第二个视频,回答相应的测试。大约有一半的学生先进行 Paxos 部分,然后另一半先进行 Raft 部分,这是为了说明两者独立的区别从第一个算法处学来的经验。我们计算参加人员的每一个小测验的得分来看参与者是否在 Raft 算法上更加容易理解。
我们尽可能的使得 Paxos 和 Raft 的比较更加公平。这个实验偏爱 Paxos 表现在两个方面:43 个参加者中有 15 个人在之前有一些 Paxos 的经验,并且 Paxos 的视频要长 14%。如表格 1 总结的那样,我们采取了一些措施来减轻这种潜在的偏见。我们所有的材料都可供审查。
关心 | 缓和偏见采取的手段 | 可供查看的材料 |
---|---|---|
相同的讲课质量 | 两者使用同一个讲师。Paxos 使用的是现在很多大学里经常使用的。Paxos 会长 14%。 | 视频 |
相同的测验难度 | 问题以难度分组,在两个测验里成对出现。 | 小测验 |
公平评分 | 使用红字标题。随机顺序打分,两个测验交替进行。 | 红字标题 |
表 1:考虑到可能会存在的偏见,对于每种情况的解决方法,和相应的材料。
参加者平均在 Raft 的测验中比 Paxos 高 4.9 分(总分 60,那么 Raft 的平均得分是 25.7,而 Paxos 是 20.8);图 14 展示了每个参与者的得分。一对 t -测试表明,拥有 95% 的可信度,真实的 Raft 分数分布至少比 Paxos 高 2.5 分。
图 14:一个散点图表示了 43 个学生在 Paxos 和 Raft 的小测验中的成绩。在对角线之上的点表示在 Raft 获得了更高分数的学生。
我们也建立了一个线性回归模型来预测一个新的学生的测验成绩,基于以下三个因素:他们使用的是哪个小测验,之前对 Paxos 的经验,和学习算法的顺序。模型显示,对小测验的选择会产生 12.5 分的差别在对 Raft 的好感度上。这显著的高于之前的 4.9 分,因为很多学生在之前都已经有了对于 Paxos 的经验,这相当明显的帮助 Paxos,对 Raft 就没什么太大影响了。但是奇怪的是,模型预测对于先进性 Paxos 小测验的人而言,Raft 的小测验得分会比 Paxos 低 6.3 分;我们不知道为什么,但这在统计学上是这样的。
我们同时也在测验之后调查了参与者,他们认为哪个算法更加容易实现和解释;这个的结果在图 15 上。压倒性的结果表明 Raft 算法更加容易实现和解释(41 人中的 33个)。但是,这种自己报告的结果不如参与者的成绩更加可信,并且参与者可能因为我们的 Raft 更加易于理解的假说而产生偏见。
图 15:通过一个 5 分制的问题,参与者(左边)被问哪个算法他们觉得在一个高效正确的系统里更容易实现,右边被问哪个更容易向学生解释。
关于 Raft 用户学习有一个更加详细的讨论。
在第 5 节,我们已经进行了一个正式的说明,和对一致性机制的安全性证明。这个正式说明让图 2 中的信息非常清晰通过 TLA+ 说明语言。大约 400 行说明充当了证明的主题。同时对于任何想实现的人也是十分有用的。我们非常机械的证明了日志完全特性通过 TLA 证明系统。然而,这个证明依赖的约束前提还没有被机械证明(例如,我们还没有证明这个说明中的类型安全)。而且,我们已经写了一个非正式的证明关于状态机安全性质是完备的,并且是相当清晰的(大约 3500 个词)。
Raft 和其他一致性算法例如 Paxos 有着差不多的性能。在性能方面,最重要的关注点是,当领导人被选举成功时,什么时候复制新的日志条目。Raft 通过很少数量的消息包(一轮从领导人到集群大多数机器的消息)就达成了这个目的。同时,进一步提升 Raft 的性能也是可行的。例如,很容易通过支持批量操作和管道操作来提高吞吐量和降低延迟。对于其他一致性算法已经提出过很多性能优化方案;其中有很多也可以应用到 Raft 中来,但是我们暂时把这个问题放到未来的工作中去。
我们使用我们自己的 Raft 实现来衡量 Raft 领导人选举的性能并且回答两个问题。首先,领导人选举的过程收敛是否快速?第二,在领导人宕机之后,最小的系统宕机时间是多久?
图 16:发现并替换一个已经崩溃的领导人的时间。上面的图考察了在选举超时时间上的随机化程度,下面的图考察了最小超时时间。每条线代表了 1000 次实验(除了 150-150 毫秒只试了 100 次),和相应的确定的选举超时时间。例如,150-155 毫秒意思是,选举超时时间从这个区间范围内随机选择并确定下来。这个实验在一个拥有 5 个节点的集群上进行,其广播时延大约是 15 毫秒。对于 9 个节点的集群,结果也差不多。
为了衡量领导人选举,我们反复的使一个拥有五个节点的服务器集群的领导人宕机,并计算需要多久才能发现领导人已经宕机并选出一个新的领导人(见图 16)。为了构建一个最坏的场景,在每一的尝试里,服务器都有不同长度的日志,意味着有些候选人是没有成为领导人的资格的。另外,为了促成选票瓜分的情况,我们的测试脚本在终止领导人之前同步的发送了一次心跳广播(这大约和领导人在崩溃前复制一个新的日志给其他机器很像)。领导人均匀的随机的在心跳间隔里宕机,也就是最小选举超时时间的一半。因此,最小宕机时间大约就是最小选举超时时间的一半。
图 16 上面的图表表明,只需要在选举超时时间上使用很少的随机化就可以大大避免选票被瓜分的情况。在没有随机化的情况下,在我们的测试里,选举过程往往都需要花费超过 10 秒钟由于太多的选票瓜分的情况。仅仅增加 5 毫秒的随机化时间,就大大的改善了选举过程,现在平均的宕机时间只有 287 毫秒。增加更多的随机化时间可以大大改善最坏情况:通过增加 50 毫秒的随机化时间,最坏的完成情况(1000 次尝试)只要 513 毫秒。
图 16 中下面的图显示,通过减少选举超时时间可以减少系统的宕机时间。在选举超时时间为 12-24 毫秒的情况下,只需要平均 35 毫秒就可以选举出新的领导人(最长的一次花费了 152 毫秒)。然而,进一步降低选举超时时间的话就会违反 Raft 的时间不等式需求:在选举新领导人之前,领导人就很难发送完心跳包。这会导致没有意义的领导人改变并降低了系统整体的可用性。我们建议使用更为保守的选举超时时间,比如 150-300 毫秒;这样的时间不大可能导致没有意义的领导人改变,而且依然提供不错的可用性。
已经有很多关于一致性算法的工作被发表出来,其中很多都可以归到下面的类别中:
Raft 和 Paxos 最大的不同之处就在于 Raft 的强领导特性:Raft 使用领导人选举作为一致性协议里必不可少的部分,并且将尽可能多的功能集中到了领导人身上。这样就可以使得算法更加容易理解。例如,在 Paxos 中,领导人选举和基本的一致性协议是正交的:领导人选举仅仅是性能优化的手段,而且不是一致性所必须要求的。但是,这样就增加了多余的机制:Paxos 同时包含了针对基本一致性要求的两阶段提交协议和针对领导人选举的独立的机制。相比较而言,Raft 就直接将领导人选举纳入到一致性算法中,并作为两阶段一致性的第一步。这样就减少了很多机制。
像 Raft 一样,VR 和 ZooKeeper 也是基于领导人的,因此他们也拥有一些 Raft 的优点。但是,Raft 比 VR 和 ZooKeeper 拥有更少的机制因为 Raft 尽可能的减少了非领导人的功能。例如,Raft 中日志条目都遵循着从领导人发送给其他人这一个方向:附加条目 RPC 是向外发送的。在 VR 中,日志条目的流动是双向的(领导人可以在选举过程中接收日志);这就导致了额外的机制和复杂性。根据 ZooKeeper 公开的资料看,它的日志条目也是双向传输的,但是它的实现更像 Raft。
和上述我们提及的其他基于一致性的日志复制算法中,Raft 的消息类型更少。例如,我们数了一下 VR 和 ZooKeeper 使用的用来基本一致性需要和成员改变的消息数(排除了日志压缩和客户端交互,因为这些都比较独立且和算法关系不大)。VR 和 ZooKeeper 都分别定义了 10 中不同的消息类型,相对的,Raft 只有 4 中消息类型(两种 RPC 请求和对应的响应)。Raft 的消息都稍微比其他算法的要信息量大,但是都很简单。另外,VR 和 ZooKeeper 都在领导人改变时传输了整个日志;所以为了能够实践中使用,额外的消息类型就很必要了。
Raft 的强领导人模型简化了整个算法,但是同时也排斥了一些性能优化的方法。例如,平等主义 Paxos (EPaxos)在某些没有领导人的情况下可以达到很高的性能。平等主义 Paxos 充分发挥了在状态机指令中的交换性。任何服务器都可以在一轮通信下就提交指令,除非其他指令同时被提出了。然而,如果指令都是并发的被提出,并且互相之间不通信沟通,那么 EPaxos 就需要额外的一轮通信。因为任何服务器都可以提交指令,所以 EPaxos 在服务器之间的负载均衡做的很好,并且很容易在 WAN 网络环境下获得很低的延迟。但是,他在 Paxos 上增加了非常明显的复杂性。
一些集群成员变换的方法已经被提出或者在其他的工作中被实现,包括 Lamport 的原始的讨论,VR 和 SMART。我们选择使用共同一致的方法因为他对一致性协议的其他部分影响很小,这样我们只需要很少的一些机制就可以实现成员变换。Lamport 的基于 α 的方法之所以没有被 Raft 选择是因为它假设在没有领导人的情况下也可以达到一致性。和 VR 和 SMART 相比较,Raft 的重新配置算法可以在不限制正常请求处理的情况下进行;相比较的,VR 需要停止所有的处理过程,SMART 引入了一个和 α 类似的方法,限制了请求处理的数量。Raft 的方法同时也需要更少的额外机制来实现,和 VR、SMART 比较而言。
算法的设计通常会把正确性,效率或者简洁作为主要的目标。尽管这些都是很有意义的目标,但是我们相信,可理解性也是一样的重要。在开发者把算法应用到实际的系统中之前,这些目标没有一个会被实现,这些都会必然的偏离发表时的形式。除非开发人员对这个算法有着很深的理解并且有着直观的感觉,否则将会对他们而言很难在实现的时候保持原有期望的特性。
在这篇论文中,我们尝试解决分布式一致性问题,但是一个广为接受但是十分令人费解的算法 Paxos 已经困扰了无数学生和开发者很多年了。我们创造了一种新的算法 Raft,显而易见的比 Paxos 要容易理解。我们同时也相信,Raft 也可以为实际的实现提供坚实的基础。把可理解性作为设计的目标改变了我们设计 Raft 的方式;这个过程是我们发现我们最终很少有技术上的重复,例如问题分解和简化状态空间。这些技术不仅提升了 Raft 的可理解性,同时也使我们坚信其正确性。
这项研究必须感谢以下人员的支持:Ali Ghodsi,David Mazie`res,和伯克利 CS 294-91 课程、斯坦福 CS 240 课程的学生。Scott Klemmer 帮我们设计了用户调查,Nelson Ray 建议我们进行统计学的分析。在用户调查时使用的关于 Paxos 的幻灯片很大一部分是从 Lorenzo Alvisi 的幻灯片上借鉴过来的。特别的,非常感谢 DavidMazieres 和 Ezra Hoch,他们找到了 Raft 中一些难以发现的漏洞。许多人提供了关于这篇论文十分有用的反馈和用户调查材料,包括 Ed Bugnion,Michael Chan,Hugues Evrard,Daniel Giffin,Arjun Gopalan,Jon Howell,Vimalkumar Jeyakumar,Ankita Kejriwal,Aleksandar Kracun,Amit Levy,Joel Martin,Satoshi Matsushita,Oleg Pesok,David Ramos,Robbert van Renesse,Mendel Rosenblum,Nicolas Schiper,Deian Stefan,Andrew Stone,Ryan Stutsman,David Terei,Stephen Yang,Matei Zaharia 以及 24 位匿名的会议审查人员(可能有重复),并且特别感谢我们的领导人 Eddie Kohler。Werner Vogels 发了一条早期草稿链接的推特,给 Raft 带来了极大的关注。我们的工作由 Gigascale 系统研究中心和 Multiscale 系统研究中心给予支持,这两个研究中心由关注中心研究程序资金支持,一个是半导体研究公司的程序,由 STARnet 支持,一个半导体研究公司的程序由 MARCO 和 DARPA 支持,在国家科学基金会的 0963859 号批准,并且获得了来自 Facebook,Google,Mellanox,NEC,NetApp,SAP 和 Samsung 的支持。Diego Ongaro 由 Junglee 公司,斯坦福的毕业团体支持。
略
原文:Raft
]]>Redis 服务器通过 socket 实现与客户端(或其他redis服务器)的交互,文件事件就是服务器对 socket 操作的抽象。 Redis 服务器,通过监听这些 socket 产生的文件事件并处理这些事件,实现对客户端调用的响应。
Redis 基于 Reactor 模式开发了自己的事件处理器。
这里就先展开讲一讲 Reactor 模式。看下图:
“I/O 多路复用模块”会监听多个 FD ,当这些FD产生,accept,read,write 或 close 的文件事件。会向“文件事件分发器(dispatcher)”传送事件。
文件事件分发器(dispatcher)在收到事件之后,会根据事件的类型将事件分发给对应的 handler。
我们顺着图,从上到下的逐一讲解 Redis 是怎么实现这个 Reactor 模型的。
Redis 的 I/O 多路复用模块,其实是封装了操作系统提供的 select,epoll,avport 和 kqueue 这些基础函数。向上层提供了一个统一的接口,屏蔽了底层实现的细节。
操作系统 | I/O多路复用 |
---|---|
Solaris | avport |
LINUX | epoll |
Mac | kqueue |
Other | select |
下面以Linux epoll为例,看看使用 Redis 是怎么利用 linux 提供的 epoll 实现I/O 多路复用。
LINUX epoll里介绍了epoll的3个方法。
Redis 对文件事件,封装epoll向上提供的接口:
1 | /* |
所以看看这个ae_peoll.c 如何对 epoll 进行封装的:
aeApiCreate()
是对 epoll.epoll_create()
的封装。aeApiAddEvent()
和aeApiDelEvent()
是对 epoll.epoll_ctl()
的封装。aeApiPoll()
是对 epoll_wait()
的封装。这样 Redis 的利用 epoll 实现的 I/O 复用器就比较清晰了。
再往上一层次我们需要看看 ae.c 是怎么封装的?
首先需要关注的是事件处理器的数据结构:
1 | typedef struct aeFileEvent { |
mask
就是可以理解为事件的类型。
除了使用 ae_epoll.c 提供的方法外, ae.c 还增加 “增删查” 的几个 API。
aeCreateFileEvent
aeDeleteFileEvent
aeGetFileEvents
获取某个 fd 的监听类型和aeWait
等待某个fd 直到超时或者达到某个状态。Redis 的事件分发器 ae.c/aeProcessEvents
不但处理文件事件还处理时间事件,所以这里只贴与文件分发相关的出部分代码,dispather 根据 mask 调用不同的事件处理器。
1 | //从 epoll 中获关注的事件 |
可以看到这个分发器,根据 mask 的不同将事件分别分发给了读事件和写事件。
Redis 有大量的事件处理器类型,我们就讲解处理一个简单命令涉及到的3个处理器:
我们按照开始给出的 Reactor 模型,从上到下讲解了文件事件处理器的实现,下面将会介绍时间时间的实现。
Reids 有很多操作需要在给定的时间点进行处理,时间事件就是对这类定时任务的抽象。
先看时间事件的数据结构:
1 | /* Time event structure |
看见 next
我们就知道这个 aeTimeEvent 是一个链表结构。看图:
注意这是一个按照id倒序排列的链表,并没有按照事件顺序排序。
Redis 使用这个函数处理所有的时间事件,我们整理一下执行思路:
综合调度器是 Redis 统一处理所有事件的地方。我们梳理一下这个函数的简单逻辑:
1 | // 1. 获取离当前时间最近的时间事件 |
以上的伪代码就是整个 Redis 事件处理器的逻辑。
我们可以再看看谁执行了这个 aeProcessEvents
:
1 | void aeMain(aeEventLoop *eventLoop) { |
然后我们再看看是谁调用了 aeMain
:
1 | int main(int argc, char **argv) { |
我们在 Redis 的 main 方法中找个了它。
这个时候我们整理出的思路就是:
aeMain()
方法。eaMain()
while(true) 的调用 aeProcessEvents()
。所以我们说 Redis 是一个事件驱动的程序,期间我们发现,Redis 没有 fork 过任何线程。所以也可以说 Redis 是一个基于事件驱动的单线程应用。
参考文档:
]]>Redis作为一个高性能的内存NoSQL数据库,其容量受到最大内存限制的限制。
在使用Redis时,除了对性能,稳定性有很高的要求外,对内存占用也比较敏感。在使用过程中,有些用户会觉得自己的线上实例内存占用比自己预想的要大。
事实上,实例中的内存除了保存原始的键值对所需的开销外,还有一些运行时产生的额外内存,包括:
为了防止一次性清理大量过期Key导致Redis服务受影响,Redis只在空闲时清理过期Key。
具体Redis逐出过期Key的时机为:
1 | robj *lookupKeyRead(redisDb *db, robj *key) { |
1 | aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) |
1 | void aeMain(aeEventLoop *eventLoop) { |
Redis过期Key清理的机制对清理的频率和最大时间都有限制,在尽量不影响正常服务的情况下,进行过期Key的清理,以达到长时间服务的性能最优.
Redis会周期性的随机测试一批设置了过期时间的key并进行处理。测试到的已过期的key将被删除。具体的算法如下:
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。这也意味着在长期来看任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4.
代码分析如下:
1 | void activeExpireCycle(int type) { |
1 | // 执行命令 |
在逐出算法中,根据用户设置的逐出策略,选出待逐出的key,直到当前内存小于最大内存值为主.
可选逐出策略如下:
具体代码如下
1 | int freeMemoryIfNeeded() { |
参考文档
]]>这里主要讨论Linux环境下的network IO。
在进行解释之前,首先要说明几个概念:
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间。
而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
总而言之就是很耗资源,具体的可以参考这篇文章:进程切换
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度io讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
Linux下的asynchronous IO其实用得很少。先看一下它的流程:
I用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。
有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。
而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
各个IO Model的比较如图所示:
通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)
1 | int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); |
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。
1 | int poll (struct pollfd *fds, unsigned int nfds, int timeout); |
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。
1 | struct pollfd { |
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll操作过程需要三个接口,分别如下:
1 | int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 |
1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
*2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event event);
函数是对指定描述符fd执行op操作。
1 | struct epoll_event { |
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件
。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件
。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
假如有这样一个例子:
LT模式:
如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。
ET模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。
当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:
1 | while(rs){ |
Linux中的EAGAIN含义
Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。
从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。
例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。
下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。
1 |
|
在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
epoll的优点主要是以下几个方面:
监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max
查看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
]]>在Redis的世界里,存储的所有值都是这个RedisObject对象。
源码定义如下:
1 | typedef struct redisObject { |
其中type可以取值枚举如下:
TYPE枚举 | VALUE值 | 代表 |
---|---|---|
OBJ_STRING | 0 | STRING |
OBJ_LIST | 1 | LIST |
OBJ_SET | 2 | SET |
OBJ_ZSET | 3 | ZSET |
OBJ_HASH | 4 | HASH |
OBJ_MODULE | 5 | — |
OBJ_STREAM | 6 | — |
不同类型Type,它的底层实现/编码方式也是不一样的。枚举类型如下:
encoding枚举 | VALUE值 | str描述 |
---|---|---|
OBJ_ENCODING_RAW | 0 | raw |
OBJ_ENCODING_INT | 1 | int |
OBJ_ENCODING_HT | 2 | hashtable |
OBJ_ENCODING_ZIPMAP | 3 | 不再使用, 转为ZIPLIST |
OBJ_ENCODING_LINKEDLIST | 4 | 不再使用,转为QUICKLIST |
OBJ_ENCODING_ZIPLIST | 5 | ziplist |
OBJ_ENCODING_INTSET | 6 | intset |
OBJ_ENCODING_SKIPLIST | 7 | skiplist |
OBJ_ENCODING_EMBSTR | 8 | embstr |
OBJ_ENCODING_QUICKLIST | 9 | quicklist |
define OBJ_ENCODING_STREAM | 10 | — |
Redis引入了一个SDS(Simple Dynamic String)类型,来表示String对象。
1 | struct sdshdr { |
自Redis3.2版本之后,为了更好的优化内存,把sdshdr分为sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdrhdr64。
1 | /* Note: sdshdr5 is never used, we just access the flags byte directly. |
dictEntry表示hash表的节点。
1 | /* |
dictht表示一个哈希表。
1 | /* |
dict代表了一个字典,需要注意的是dictht有2个,这个用来实现渐进式rehash。
1 | /* |
相关结构图示如下:
用于保存INT类型集合的结构,很简单:
1 | typedef struct intset { |
内部维护了一个contents数组来保存具体的类型。
1 | /* ZSETs use a specialized version of Skiplists */ |
图示表示为:
这个和常规意义上的跳表并没有区别。
这是一个双向链表结构(adlist.h)。
1 | typedef struct listNode { |
具体的双向链表定义如下:
1 | /* |
链表操作的具体实现在adlist.c里实现,同正常的双向链表没有区别。
什么时候使用ZIPLIST编码呢?如何实现的呢?
字段 | 含义 |
---|---|
zlbytes | 该字段是压缩链表的第一个字段,是无符号整型,占用4个字节。用于表示整个压缩链表占用的字节数(包括它自己)。 |
zltail | 无符号整型,占用4个字节。用于存储从压缩链表头部到最后一个entry(不是尾元素zlend)的偏移量,在快速跳转到链表尾部的场景使用。 |
zllen | 无符号整型,占用2个字节。用于存储压缩链表中包含的entry总数。 |
zlend | 特殊的entry,用来表示压缩链表的末尾。占用一个字节,值为255(0xFF)。 |
Entry部分:
一般来说,一个entry由prevlen,encoding,entry-data三个字段组成,但当entry是个很小的整数时,会根据编码省略掉entry-data字段。
prevlen表示前一个entry的长度。
encoding存储分为以下情况。
1.如果元素内容为字符串,encoding这样来表示。
encoding值 | 可表示长度 | 存储 |
---|---|---|
00xx xxxx | 6bit | — |
01xx xxxx xxxx xxxx | 14bit | 大端存储 |
1000 0000 xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx | 32bit | 大端存储 |
2.如果元素为整数,encoding这样表示:
encoding | 解释 |
---|---|
1100 0000 | 表示数字占用后面2个字节 |
1101 0000 | 表示数字占用后面4个字节 |
1110 0000 | 表示数字占用后面8个字节 |
1111 0000 | 表示数字占用后面3个字节 |
1111 1110 | 表示数字占用后面1个字节 |
1111 1111 | 表示压缩链表中最后一个元素(特殊编码0xFF)。即zlend |
1111 xxxx | 表示只用后4位表示0 |
源码文档里举了这样的一个包含”2”和“5”的ZIPLIST来说明:
1 | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
前4字节,表示为0x0f, 说明总共占用15字节。
接下来的4个字节,表示为0x0c,说明最后一个entry(这里是“5”)的offset是12.
接下来的2个字节表示zllen总共有0x02=2个entry。
接下来的2个字节00,前面一个长度为0(目前是第1个entry),接下来0xf3(11110011), 按上面的分析,这里3-1=2.
接下来的2个字节,02表示前一个entry长度为2, 0xf6(11110110)代表6-1=5。
最后一个字节0xff代表结束,最后一个。
接下来,如果在“5”后面插入“hello world”
1 | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
注意到zlbytes变成了28, zltail变成了14, entries变成了3。新加入的entry“hello word”,02表示前一个长度为2. 0x0b(0000 1011)表示后面有数据有11个字节。紧跟其后的就是11个字节的“hello world”ASCII码。
QUICKLIST是一个ziplist的双向链表,这个定义很准确。它综合了LINKEDLIST和ZIPLIST.
1 | typedef struct quicklistNode { |
其中:
prev,next:分别指向前一个和后一个node,典型的双链表。
zl:指向ziplist结构,如果启用了压缩,那么指向的就是quicklistLZF结构了。
sz:表示指向ziplist的总大小。即使被压缩,也仍旧是未压缩的大小。
count:表示ziplist里面数据项的个数。
encoding:表示是否压缩(2表示LZF,1为RAW)。
container: 表示使用什么类型存数据。目前(NONE=1, ZIPLIST=2)
recompress: 表明这个数据是否压缩过。
attempted_compress: 测试用
extra: 扩展字段。
1 | /* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'. |
sz: LZF压缩后的大小。
compressed: 存放压缩后ZIPLIST的char数组。
1 | /* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist. |
head,tail:分别指向头、尾
count: 所有ziplist数据项总和。
len: quicklistNode的总个数
fill: 16bit,ziplist大小设置。存放list-max-ziplist-size
参数的值
compress: 16bit, 压缩深度设置。存放list-compress-depth
参数的值。
简略图示如下:
上图中redis.conf中配置如下:
1 | # 每个ziplist最大存多少个 |
quicklist总分利用了ziplist的压缩比高,规避了量大效率低的问题。redis 3.2之后,默认使用QUICKLIST来实现.
格式如下:
1 | <zmlen><len>"foo"<len><free>"bar"<len>"hello"<len><free>"world"0XFF |
zmlen: 1byte, 表示当前zipmap的长度。当长度>254的时候,这个不需要。总长度需要遍历拿到
len: 后面跟着值的长度。如果第1字节在0-253,代表是一个单字节产股。如果第1字节是254,表示后面有4字节表示具体长度。遇到255(0xFF)表示结尾。
free: 表示未用的字符串。这种情况存在于修改内容的时候,比如foo->hi,则有1个的空白。
数据类型 | 一般情况编码 | 少量数据编码 | 数据为整形 |
---|---|---|---|
String | RAW | EMBSTR | INT |
List | LINKEDLIST(3.2前)/QUICKLIST(3.2后) | ZIPLIST(3.2前) | — |
Set | HT | — | INTSET |
Hash | HT | ZIPMAP | — |
ZSET | SKIPLIST | ZIPLIST | — |
EMBSTR(OBJ_ENCODING_EMBSTR)编码(长度小于<39/44使用这种类型):
1 | jemalloc chunk size = 64; |
所以:
Redis3.2前,64-16-8-1=39。
Redis3.2之后,64-16-3-1=44.
长度在39/44以内的话,可以直接存放在连续内存,省去了多次分配。
INT(OBJ_ENCODING_INT): 如果字符串都是整数的时候,使用INT编码。
ptr指针直接代表字符串的值。实际上Redis启动后,会默认创建10000个RedisObject, 用于代表地1-10000的整形,这个大小是可以配置的。
如果以上都不满足, 使用OBJ_ENCODING_RAW编码,即SDS类型。
在Redis3.2之后,统一使用OBJ_ENCODING_QUICKLIST来实现。
redis.conf配置文件里:
1 | set-max-intset-entries 512 |
意思是如果整数集合的元素个数超过512,则转为HT编码。当然,如果插入的是字符串,那也会直接转码,无视这个限制的。
redis.conf配置文件里:
1 | hash-max-ziplist-value 64 // ziplist中最大能存放的值长度 |
这些阈值,用于决定什么情况下使用何种类型编码。
以上可以看出。entry个数小于等于512并且value长度小于等于64的话才使用ZIPLIST,超出则选择HASHTABLE.
有序集合定义为zset
1 | /* |
可以看出,里面由一个dict和一个zskiplist来实现。dict用来存储key-score对, zskiplist用于快速定位查找。
1 |
在entry个数<=128并且value长度<=64的时候,使用的是ziplist,否则使用SKIPLIST格式。
Redis为了更大程度的提升性能,压缩数据大小, 在内存模型和数据结构上做了很多努力。
]]>ES集群,状态持续Yellow。出现部分replica一直在追primary的索引数据,追不上。线上提供服务出现慢查询,导致499量增大。
固定的几个ES节点(10.10.24.X网段),出现间歇性被踢出Cluster,随后又加入Cluster。这是导致出现yellow原因,并且一直不能恢复到Green状态。由于Master节点在10.10.19.X网段, 感觉起来就是2个网段之间不通了。
1 | [2018-12-08T19:25:29,993][WARN ][o.e.x.s.t.n.SecurityNetty4Transport] [es35-search.mars.ljnode.com] write and flush on the network layer failed (channel: [id: 0x0e6ac038, L:0.0.0.0/0.0.0.0:8309 ! R:/10.200.24.96:46802]) |
提示信息是底层的netty channel关闭,导致节点状态不可达,抛出ReceiveTimeoutTransportException,因此被踢出集群。
以上的情况,怀疑连接有问题,可能存在脑裂。找运维同学排查,查看网络状况。
通过监控图,首先可以排除网络断开。而整体的io吞吐,最高才只有150Mb(此处为bit),网络流量也不大。
由于线上的集群规模较大,复现集群搭建了3台ES,并分布在2个不同的网段。
那如何模拟高IO呢?
选择了iperf命令,来模拟网络流量,其实iperf主要是用来测试网络带宽以及丢包测试用的,也许有其他更好的工具。
具体使用方式参考iperf命令。
果然在网络带宽被占满的时候,复现了上面的问题。但是当时线上并不高,这是什么原因。
在测试的时候,调试调用dstat命令,来观察真实的带宽。
可以看到,net带宽达到了117MB/s,已经把网络打满,这时我们在看运维的统计监控图:
观察19:00左右的值,当时已经达到了117MB,但是统计出来值是很低的。和运维沟通后,确认采样是30s的平均值,所以在5~10秒短时间带宽打满,并不会体现出来。一切都可以解释了,happy。
网络是千兆的网卡,这在高并发,高吞吐的系统中,带宽100MB/s是不能满足需求的,瓶颈找到,那就更换为万兆网卡。
网络拓扑图如上,其中交换机和机器直连的网卡进行升级来解决。
假如,机器硬件资源有限,有没有一些办法来尽可能规避。
ElasticSearch针对recovery有速度限制,默认是40MB。如果一台机器部署多台(实际上,我们就是这样部署的),那这个值可能是倍增的,很容易打满。
1 | indices.recovery.max_bytes_per_sec: 10MB |
减小上面的这个值,能够一定程度上缓解,由于recovery导致的持续带宽打满,并引起连锁反应。带宽满->节点掉->节点加入->recovery->带宽满->…
网络带宽,应该在部署之前就进行充分的考虑和论证。根据不同的业务场景,选择不同的硬件配置。
]]>