孙子云:“上兵伐谋,其次伐交,其次伐兵,其下攻城”,最上乘行军打仗的方式是运用谋略,下乘的方式才是与敌人进行惨烈的厮杀。同样的,在程序设计中,解决问题的办法有很多种,陷入到与逻辑进行贴身肉搏的境况实属下下之策,而能运用优秀合理的算法才是”伐谋”的上上之策。
算法的思想精髓是值得深入研究和细细品味的,本宝典总结了服务器开发设计过程中涉及到的一些常用算法,试图尽量以简洁的文字和图表来解释和说明其中的思想原理,希望能给大家带来一些思考和启示。
1.1.轮询
轮询是非常简单且常用的一种调度算法,轮询即将请求依次分配到各个服务节点,从第一个节点开始,依次将请求分配到最后一个节点,而后重新开始下一轮循环。最终所有的请求会均摊分配在每个节点上,假设每个请求的消耗是一样的,那么轮询调度是最平衡的调度(负载均衡)算法。
1.2.加权轮询
有些时候服务节点的性能配置各不相同,处理能力不一样,针对这种的情况,可以根据节点处理能力的强弱配置不同的的权重值,采用加权轮询的方式进行调度。
加权轮询可以描述为:
2.当有请求需要调度时,每次分配选择当前权重最高的节点,同时被选择的节点权重值减一。
3.若所有节点权重值都为零,则重置为初始化时配置的权重值。
最终所有请求会按照各节点的权重值成比例的分配到服务节点上。假设有三个服务节点{a,b,c},它们的权重配置分别为{2,3,4},那么请求的分配次序将是{c,b,c,a,b,c,a,b,c},如下所示:
1.3.平滑权重轮询
平滑权重轮询算法可以描述为:
2.当有请求需要调度时,每次会先把各节点的当前权重值加上自己的配置权重值,然后选择分配当前权重值最高的节点,同时被选择的节点权重值减去所有节点的原始权重值总和。
同样假设三个服务节点{a,b,c},权重分别是{5,1,1},那么平滑权重轮询每一轮的分配过程如下表所示:
最终请求分配的次序将是{a,a,b,a,c,a,a},相对于普通权重轮询算法会更平滑一些。
1.4.随机
随机即每次将请求随机地分配到服务节点上,随机的优点是完全无状态的调度,调度节点不需要记录过往请求分配情况的数据。理论上请求量足够大的情况下,随机算法会趋近于完全平衡的负载均衡调度算法。
1.5.加权随机
类似于加权轮询,加权随机支持根据服务节点处理能力的大小配置不同的的权重值,当有请求需要调度时,每次根据节点的权重值做一次加权随机分配,服务节点权重越大,随机到的概率就越大。最终所有请求分配到各服务节点的数量与节点配置的权重值成正比关系。
1.6.最小负载
实际应用中,各个请求很有可能是异构的,不同的请求对服务器的消耗各不相同,无论是使用轮询还是随机的方式,都可能无法准确的做到完全的负载均衡。最小负载算法是根据各服务节点当前的真实负载能力进行请求分配的,当前负载最小的节点会被优先选择。
最小负载算法可以描述为:
2.当有请求需要调度时,每次分配选择当前负载最小(负载盈余最大)的服务节点。
负载情况可以统计节点正在处理的请求量,服务器的CPU及内存使用率,过往请求的响应延迟情况等数据,综合这些数据以合理的计算公式进行负载打分。
1.7.两次随机选择策略
最小负载算法可以在请求异构情况下做到更好的均衡性。然而一般情况下服务节点的负载数据都是定时同步到调度节点,存在一定的滞后性,而使用滞后的负载数据进行调度会导致产生“群居”行为,在这种行为中,请求将批量地发送到当前某个低负载的节点,而当下一次同步更新负载数据时,该节点又有可能处于较高位置,然后不会被分配任何请求。再下一次又变成低负载节点被分配了更多的请求,一直处于这种很忙和很闲的循环状态,不利于服务器的稳定。
为应对这种情况,两次随机选择策略算法做了一些改进,该算法可以描述为:
2.从所有可用节点列表中做两次随机选择操作,得到两个节点。
3.比较这两个节点负载情况,选择负载更低的节点作为被调度的节点。
两次随机选择策略结合了随机和最小负载这两种算法的优点,使用负载信息来选择节点的同时,避免了可能的“群居”行为。
1.8.一致性哈希
为了保序和充分利用缓存,我们通常希望相同请求key的请求总是会被分配到同一个服务节点上,以保持请求的一致性,既有了一致性哈希的调度方式。
1.8.1.划段
最简单的一致性哈希方案就是划段,即事先规划好资源段,根据请求的key值映射找到所属段,比如通过配置的方式,配置id为[1-10000]的请求映射到服务节点1,配置id为[10001-20000]的请求映射到节点2等等,但这种方式存在很大的应用局限性,对于平衡性和稳定性也都不太理想,实际业务应用中基本不会采用。
1.8.2.割环法
割环法的实现有很多种,原理都类似。割环法将N台服务节点地址哈希成N组整型值,该组整型即为该服务节点的所有虚拟节点,将所有虚拟节点打散在一个环上。
请求分配过程中,对于给定的对象key也哈希映射成整型值,在环上搜索大于该值的第一个虚拟节点,虚拟节点对应的实际节点即为该对象需要映射到的服务节点。
如下图所示,对象K1映射到了节点2,对象K2映射到节点3。
1.8.3.二次取模
取模哈希映射是一种简单的一致性哈希方式,但是简单的一次性取模哈希单调性很差,对于故障容灾非常不好,一旦某台服务节点不可用,会导致大部分的请求被重新分配到新的节点,造成缓存的大面积迁移,因此有了二次取模的一致性哈希方式。
二次取模算法即调度节点维护两张服务节点表:松散表(所有节点表)和紧实表(可用节点表)。请求分配过程中,先对松散表取模运算,若结果节点可用,则直接选取;若结果节点已不可用,再对紧实表做第二次取模运算,得到最终节点。如下图示:
1.8.4.最高随机权重
最高随机权重算法是以请求key和节点标识为参数进行一轮散列运算(如MurmurHash算法),得出所有节点的权重值进行对比,最终取最大权重值对应的节点为目标映射节点。可以描述为如下公式:
散列运算也可以认为是一种保持一致性的伪随机的方式,类似于前面讲到的普通随机的调度方式,通过随机比较每个对象的随机值进行选择。
1.8.5.Jumpconsistenthash
jumpconsistenthash通过一种非常简单的跳跃算法对给定的对象key算出该对象被映射的服务节点,算法如下:
这个算法乍看难以理解,它其实是下面这个算法的一个变种,只是将随机函数通过线性同余的方式改造而来的。
它也是一种伪随机的方式,通过随机保证了平衡性,而这里随机函数用到的种子是各个请求的key值,因此保证了一致性。它与最高随机权重的差别是这里的随机不需要对所有节点都进行一次随机,而是通过随机值跳跃了部分节点的比较。
1.8.6.小结
一致性哈希方式还有很多种类,通常结合不同的散列函实现。也有些或为了更简单的使用,或为了更好的单调性,或为了更好的平衡性等而对以上这些方式进行的改造等,如二次Jumpconsistenthash等方式。另外也有结合最小负载方式等的变种,如有限负载一致性哈希会根据当前负载情况对所有节点限制一个最大负载,在一致性哈希中对hash进行映射时跳过已达到最大负载限制的节点,实际应用过程中可根据业务情况自行做更好的调整和结合。
2.1.Knuth洗牌抽样
不放回随机抽样可以当成是一次洗牌算法的过程,利用洗牌算法来对序列进行随机排列,然后选取前m个序列作为抽样结果。
Knuth洗牌算法是在Fisher-Yates洗牌算法中改进而来的,通过位置交换的方式代替了删除操作,将每个被删除的数字交换为最后一个未删除的数字(或最前一个未删除的数字)。
Knuth洗牌算法可以描述为:
运用Knuth洗牌算法进行的随机抽样的方式称为Knuth洗牌随机抽样算法,由于随机抽样只需要抽取m个序列,因此洗牌流程只需洗到前m个数据即可。
2.2.占位洗牌随机抽样
Knuth洗牌算法是一种in-place的洗牌,即在原有的数组直接洗牌,尽管保留了原数组的所有元素,但它还是破坏了元素之间的前后顺序,有些时候我们希望原数组仅是可读的(如全局配置表),不会因为一次抽样遭到破坏,以满足可以对同一原始数组多次抽样的需求,如若使用Knuth抽样算法,必须对原数组先做一次拷贝操作,但这显然不是最好的做法,更好的办法在Knuth洗牌算法的基础上,不对原数组进行交换操作,而是通过一个额外的map来记录元素间的交换关系,我们称为占位洗牌算法。
占位洗牌算法过程演示如下:
最终,洗牌的结果为3,5,2,4,1。
2.3.选择抽样技术抽样
洗牌算法是对一个已经预初始化好的数据列表进行洗牌,需要在内存中全量缓存数据列表,如果数据总量n很大,并且单条记录的数据也很大,那么在内存中缓存所有数据记录的做法会显得非常的笨拙。而选择选择抽样技术算法,它不需要预先全量缓存数据列表,从而可以支持流式处理。
选择抽样技术算法可以描述为:
2.如果U≥m,则跳转到步骤4
3.把这个记录选为样本,m减1,n减1。如果m>0,则跳转到步骤1,否则取样完成,算法终止
4.跳过这个记录,不选为样本,n减1,跳转到步骤1
选择抽样技术算法过程演示如下:
最终,抽样的结果为2,5。
选择抽样技术算法虽然不需要将数据流全量缓存到内存中,但他仍然需要预先准确的知道数据量的总大小即n值。它的优点是能保持输出顺序与输入顺序不变,且单个元素是否被抽中可以提前知道。
2.4.蓄水池抽样
很多时候我们仍然不知道数据总量n,上述的选择抽样技术算法就需要扫描数据两次,第一次先统计n值,第二次再进行抽样,这在流处理场景中仍然有很大的局限性。
AlanG.Waterman给出了一种叫蓄水池抽样(ReservoirSampling)的算法,可以在无需提前知道数据总量n的情况下仍然支持流处理场景。
蓄水池抽样算法可以描述为:
2.生成1到i之间的随机数j
3.如果j>m,则跳转到步骤5
4.把这个记录选为样本,删除原先蓄水池中pool[j]数据,并置pool[j]←i
5.游标i自增1,若i 蓄水池抽样算法过程演示如下: 最终,抽样的结果为1,5。 2.5.随机分值排序抽样 洗牌算法也可以认为就是将数据按随机的方式做一个排序,从n个元素集合中随机抽取m个元素的问题就相当于是随机排序之后取前m排名的元素,基于这个原理,我们可以设计一种通过随机分值排序的方式来解决随机抽样问题。 随机分值排序算法可以描述为: 2.对于每个元素都给他们随机一个(0,1]区间的分值,并根据随机分值插入排行榜 3.所有数据处理完成,最终排名前m的元素即为抽样结果 尽管随机分值排序抽样算法相比于蓄水池抽样算法并没有什么好处,反而需要增加额外的排序消耗,但接下来的带权重随机抽样将利用到它的算法思想。 2.6.朴素的带权重抽样 朴素的带权重随机算法也称为轮盘赌选择法,将数据放置在一个假想的轮盘上,元素个体的权重越高,在轮盘上占据的空间就越多,因此就更有可能被选中。 假设上面轮盘一到四等奖和幸运奖的权重值分别为5,10,15,30,40,所有元素权重之和为100,我们可以从[1,100]中随机得到一个值,假设为45,而后从第一个元素开始,不断累加它们的权重,直到有一个元素的累加权重包含45,则选取该元素。如下所示: 由于权重45处于四等奖的累加权重值当中,因此最后抽样结果为四等奖。 若要不放回的选取m个元素,则需要先选取一个,并将该元素从集合中踢除,再反复按同样的方法抽取其余元素。 2.7.带权重的A-Res算法蓄水池抽样 2.8.带权重的A-ExpJ算法蓄水池抽样 3.1.基础排序 基础排序是建立在对元素排序码进行比较的基础上进行的排序算法。 3.1.1.冒泡排序 冒泡排序是一种简单直观的排序算法。它每轮对每一对相邻元素进行比较,如果相邻元素顺序不符合规则,则交换他们的顺序,每轮将有一个最小(大)的元素浮上来。当所有轮结束之后,就是一个有序的序列。 过程演示如下: 3.1.2.插入排序 插入排序通过构建有序序列,初始将第一个元素看做是一个有序序列,后面所有元素看作未排序序列,从头到尾依次扫描未排序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。 3.1.3.选择排序 选择排序首先在未排序序列中找到最小(大)元素,存放到已排序序列的起始位置。再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。直到所有元素处理完毕。 插入排序是每轮会处理好第一个未排序序列的位置,而选择排序是每轮固定好一个已排序序列的位置。冒泡排序也是每轮固定好一个已排序序列位置,它与选择排序之间的不同是选择排序直接选一个最小(大)的元素出来,而冒泡排序通过依次相邻交换的方式选择出最小(大)元素。 3.1.4.快速排序 快速排序使用分治法策略来把一串序列分为两个子串序列。快速排序是一种分而治之的思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。 快速排序从数列中挑出一个元素,称为"基准",所有元素比基准值小的摆放在基准前面,比基准值大的摆在基准的后面。一轮之后该基准就处于数列的中间位置。并递归地把小于基准值元素的子数列和大于基准值元素的子数列进行排序。 3.1.5.归并排序 归并排序是建立在归并操作上的一种有效的排序算法,也是采用分治法的一个非常典型的应用。归并排序首先将序列二分成最小单元,而后通过归并的方式将两两已经有序的序列合并成一个有序序列,直到最后合并为一个最终有序序列。 3.1.6.堆排序 堆排序(Heapsort)是利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:子结点的键值或索引总是小于(或者大于)它的父节点。 堆排序首先创建一个堆,每轮将堆顶元素弹出,而后进行堆调整,保持堆的特性。所有被弹出的元素序列即是最终排序序列。 3.1.7.希尔排序 希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本,但希尔排序是非稳定排序算法。 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。 希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。 3.2.分配排序 基础排序是建立在对元素排序码进行比较的基础上,而分配排序是采用“分配”与“收集”的办法。 3.2.1.计数排序 3.2.2.桶排序 桶排序是计数排序的升级版,它利用了函数的映射关系,桶排序高效与否的关键就在于这个映射函数的确定。比如我们可以将排序数据进行除10运算,运算结果中具有相同的商值放入相同的桶中,即每十个数会放入相同的桶中。 为了使桶排序更加高效,我们需要做到这两点: 2.使用的映射函数能够将输入的所有数据均匀的分配到所有桶中 计数排序本质上是一种特殊的桶排序,当桶的个数取最大值(max-min+1)的时候,桶排序就变成了计数排序。 3.2.3.基数排序 基数排序的原理是将整数按位数切割成不同的数字,然后对每个位数分别比较。基数排序首先按最低有效位数字进行排序,将相同值放入同一个桶中,并按最低位值顺序叠放,然后再按次低有效位排序,重复这个过程直到所有位都进行了排序,最终即是一个有序序列。 基数排序也是一种桶排序。桶排序是按值区间划分桶,基数排序是按数位来划分,基数排序可以看做是多轮桶排序,每个数位上都进行一轮桶排序。 3.3.多路归并排序 多路归并排序算法是将多个已经有序的列表进行归并排序,合成为一组有序的列表的排序过程。 k路归并排序可以描述为: 2.从比较池中取最小(大)的元素加入到结果列表,同时将该元素所在有序列表的下一个元素放入比较池(若有)。 3.重新复进行步骤2,直到所有队列的所有元素都已取出。 每次在比较池中取最小(大)的元素时,需要进行一次k个数据的比较操作,当k值较大时,会严重影响多路归并的效率,为提高效率,可以使用“败者树”来实现这样的比较过程。 败者树是完全二叉树,败者树相对的是胜者树,胜者树每个非终端结点(除叶子结点之外的其它结点)中的值都表示的是左右孩子相比较后的胜者。 如下图所示是一棵胜者树: 而败者树双亲结点表示的是左右孩子比较之后的失败者,但在上一层的比较过程中,仍然是拿前一次的胜者去比较。 如下图所示是一颗败者树: 叶子节点的值是:{7,4,8,2,3,5,6,1},7与4比较,7是败者,4是胜者,因此他们的双亲节点是7,同样8与2比较,8是败者,表示在他们双亲节点上,而7与8的双亲节点需要用他们的胜者去比较,即用4与2比较,4是败者,因此7与8的双亲节点记录的是4,依此类推。 假设k=8,败者树归并排序的过程演示如下所示: 首先构建起败者数,最后的胜者是1,第二次将1弹出,取1所在的第8列的第二个数15放入1所在的叶子节点位置,并进行败者树调整,此时只需调整原1所在分支的祖先节点,最后胜者为2,后续过程依此类推。最后每轮的最终胜者序列即是最后的归并有序序列。 笔者曾经基于lua语言利用败者树实现多路归并排序算法,有兴趣可以前往阅读。 3.4.跳跃表排序 跳跃表(SkipLists)是一种有序的数据结构,它通过在每个节点中随机的建立上层辅助查找节点,从而达到快速访问节点的目的(与败者树的多路归并排序有异曲同工之妙)。 如下是四层跳跃表结构的示意: 在查找目标元素时,从顶层列表、头元素起步,沿着每层链表搜索,直至找到一个大于或等于目标的元素,或者到达当前层列表末尾。如果该元素等于目标元素,则表明该元素已被找到;如果该元素大于目标元素或已到达链表末尾,则退回到当前层的上一个元素,然后转入下一层进行搜索。依次类推,最终找到该元素或在最底层底仍未找到(不存在)。 当p值越大,快速通道就越稀疏,占用空间越小,但查找速度越慢,反之,则占用空间大查找速度快,通过选择不同p值,就可以在查找代价和存储代价之间获取平衡。 由于跳跃表使用的是链表,加上增加了近似于以二分方式的辅助节点,因此查询,插入和删除的性能都很理想。在大部分情况下,跳跃表的效率可以和平衡树相媲美,它是一种随机化的平衡方案,在实现上比平衡树要更为简单,因而得到了广泛的应用,如redis的zset,leveldb,我司的apollo排行榜等都使用了跳跃表排序方案。 3.5.百分比近似排序 3.5.1.HdrHistogram算法 HdrHistogram使用的是直方图统计算法,直方图算法类似于桶排序,原理就是创建一个直方图,以一定的区间间隔记录每个区间上的数据总量,预测排名时只需统计当前值所在区间及之前区间的所有数量之和与总数据量之间的比率。 区间分割方式可以采用线性分割和指数分割方式: HdrHistogram为了兼顾内存和估算的准确度,同时采用了线性分割和指数分割的方式,相当于两层的直方图算法,第一层使用指数分割方式,可以粗略的估算数据的排名范围位置,第二层使用线性分割方式,更加精确的估算出数据的排名位置。线性区间划分越小结果越精确,但需要的内存越多,可以根据业务精确度需求控制线性区间的大小。 直方图算法需要预先知道数据的最大值,超过最大值的数据将存不进来。HdrHistogram提供了一个自动扩容的功能,以解决数据超过预估值的问题,但是这个自动扩容方式存在一个很高的拷贝成本。 3.5.2.CKMS算法 HdrHistogram是一种静态分桶的算法,当数据序列是均匀分布的情况下,有比较好的预测效果,然而实际应用中数据有可能并不均匀,很有可能集中在某几个区间上,CKMS采用的是动态分桶的方式,在数据处理过程中不断调整桶的区间间隔和数量。 CKMS同时引入一个可配置的错误率的概念,在抉择是否开辟新桶时,根据用户设置的错误率进行计算判定。判定公式为:区间间隔=错误率*数据总量。 下图是一个桶合并的例子: 如上所示,假设错误率设置为0.1,当数据总量大于10个时,通过判定公式计算出区间间隔为1,因此将会对区间间隔小于等于1的相邻桶进行合并。 CKMS算法不需要预知数据的范围,用户可以根据数据的性质设置合适的错误率,以控制桶的空间占用和精确度之间的平衡关系。 3.5.3.TDigest算法 Tdigest算法的思想是近似算法常用的素描法(Sketch),用一部分数据来刻画整体数据集的特征,就像我们日常的素描画一样,虽然和实物有差距,但是却看着和实物很像,能够展现实物的特征。它本质上也是一种动态分桶的方式。 TDigest算法估计具体的百分位数时,都是根据百分位数对应的两个质心去线性插值计算的,和精准百分位数的计算方式一样。首先我们根据百分位q和所有质心的总权重计算出索引值;其次找出和对应索引相邻的两个质心;最终可以根据两个质心的均值和权重用插值的方法计算出对应的百分位数。(实际的计算方法就是加权平均)。 由此我们可以知道,百分位数q的计算误差要越小,其对应的两个质心的均值应该越接近。TDigest算法的关键就是如何控制质心的数量,质心的数量越多,显然估计的精度就会越高,但是需要的内存就会越多,计算效率也越低;但是质心数量越少,估计的精度就很低,所以就需要一个权衡。 一种TDigest构建算法buffer-and-merge可以描述为: 2.遍历所有的数据点和质心,满足合并条件的数据点和质心就进行合并,如果超出权重上限,则创建新的质心数,否则修改当前质心数的平均值和权重。 假设我们有200个质心,那么我们就可以将0到1拆分200等份,则每个质心就对应0.5个百分位。假如现在有10000个数据点,即总权重是10000,我们按照大小对10000个点排序后,就可以确定每个质心的权重(相当于质心代表的数据点的个数)应该在10000/200=500左右,所以说当每个质心的权重小于500时,我们就可以将当前数据点加入当前的质心,否则就新建一个质心。 实际应用中,我们可能更加关心90%,95%,99%等极端的百分位数,所以TDigest算法特意优化了q=0和q=1附近的百分位精度,通过专门的映射函数K保证了q=0和q=1附近的质心权重较小,数量较多。 另外一种TDigest构建算法是AVL树的聚类算法,与buffer-and-merge算法相比,它通过使用AVL二叉平衡树的方式来搜索数据点最靠近的质心数,找到最靠近的质心数后,将二者进行合并。