分享好友 最新资讯首页 最新资讯分类 切换频道
服务器开发设计之算法宝典(上)
2024-12-28 04:11

​​孙子云:“上兵伐谋,其次伐交,其次伐兵,其下攻城”,最上乘行军打仗的方式是运用谋略,下乘的方式才是与敌人进行惨烈的厮杀。同样的,在程序设计中,解决问题的办法有很多种,陷入到与逻辑进行贴身肉搏的境况实属下下之策,而能运用优秀合理的算法才是”伐谋”的上上之策。


算法的思想精髓是值得深入研究和细细品味的,本宝典总结了服务器开发设计过程中涉及到的一些常用算法,试图尽量以简洁的文字和图表来解释和说明其中的思想原理,希望能给大家带来一些思考和启示。







在服务器逻辑开发设计中,调度算法随处可见,资源的调度,请求的分配,负载均衡的策略等等都与调度算法相关。调度算法没有好坏之分,最适合业务场景的才是最好的。



1.1. 轮询


轮询是非常简单且常用的一种调度算法,轮询即将请求依次分配到各个服务节点,从第一个节点开始,依次将请求分配到最后一个节点,而后重新开始下一轮循环。最终所有的请求会均摊分配在每个节点上,假设每个请求的消耗是一样的,那么轮询调度是最平衡的调度(负载均衡)算法。



1.2. 加权轮询


有些时候服务节点的性能配置各不相同,处理能力不一样,针对这种的情况,可以根据节点处理能力的强弱配置不同的的权重值,采用加权轮询的方式进行调度。


加权轮询可以描述为:


  1. 调度节点记录所有服务节点的当前权重值,初始化为配置对应值。


2. 当有请求需要调度时,每次分配选择当前权重最高的节点,同时被选择的节点权重值减一。


3. 若所有节点权重值都为零,则重置为初始化时配置的权重值。


最终所有请求会按照各节点的权重值成比例的分配到服务节点上。假设有三个服务节点{a,b,c},它们的权重配置分别为{2,3,4},那么请求的分配次序将是{c,b,c,a,b,c,a,b,c},如下所示:



1.3. 平滑权重轮询


加权轮询算法比较容易造成某个服务节点短时间内被集中调用,导致瞬时压力过大,权重高的节点会先被选中直至达到权重次数才会选择下一个节点,请求连续的分配在同一个节点上的情况,例如假设三个服务节点{a,b,c},权重配置分别是{5,1,1},那么加权轮询调度请求的分配次序将是{a,a,a,a,a,b,c},很明显节点 a 有连续的多个请求被分配。


为了应对这种问题,平滑权重轮询实现了基于权重的平滑轮询算法。所谓平滑,就是在一段时间内,不仅服务节点被选择次数的分布和它们的权重一致,而且调度算法还能比较均匀的选择节点,不会在一段时间之内集中只选择某一个权重较高的服务节点。


平滑权重轮询算法可以描述为:


  1. 调度节点记录所有服务节点的当前权重值,初始化为配置对应值。


2. 当有请求需要调度时,每次会先把各节点的当前权重值加上自己的配置权重值,然后选择分配当前权重值最高的节点,同时被选择的节点权重值减去所有节点的原始权重值总和。


3. 若所有节点权重值都为零,则重置为初始化时配置的权重值。


同样假设三个服务节点{a,b,c},权重分别是{5,1,1},那么平滑权重轮询每一轮的分配过程如下表所示:



最终请求分配的次序将是{ a, a, b, a, c, a, a},相对于普通权重轮询算法会更平滑一些。



1.4. 随机


随机即每次将请求随机地分配到服务节点上,随机的优点是完全无状态的调度,调度节点不需要记录过往请求分配情况的数据。理论上请求量足够大的情况下,随机算法会趋近于完全平衡的负载均衡调度算法。



1.5. 加权随机


类似于加权轮询,加权随机支持根据服务节点处理能力的大小配置不同的的权重值,当有请求需要调度时,每次根据节点的权重值做一次加权随机分配,服务节点权重越大,随机到的概率就越大。最终所有请求分配到各服务节点的数量与节点配置的权重值成正比关系。



1.6. 最小负载


实际应用中,各个请求很有可能是异构的,不同的请求对服务器的消耗各不相同,无论是使用轮询还是随机的方式,都可能无法准确的做到完全的负载均衡。最小负载算法是根据各服务节点当前的真实负载能力进行请求分配的,当前负载最小的节点会被优先选择。


最小负载算法可以描述为:


  1. 服务节点定时向调度节点上报各自的负载情况,调度节点更新并记录所有服务节点的当前负载值。


2. 当有请求需要调度时,每次分配选择当前负载最小(负载盈余最大)的服务节点。


负载情况可以统计节点正在处理的请求量,服务器的 CPU 及内存使用率,过往请求的响应延迟情况等数据,综合这些数据以合理的计算公式进行负载打分。



1.7. 两次随机选择策略


最小负载算法可以在请求异构情况下做到更好的均衡性。然而一般情况下服务节点的负载数据都是定时同步到调度节点,存在一定的滞后性,而使用滞后的负载数据进行调度会导致产生“群居”行为,在这种行为中,请求将批量地发送到当前某个低负载的节点,而当下一次同步更新负载数据时,该节点又有可能处于较高位置,然后不会被分配任何请求。再下一次又变成低负载节点被分配了更多的请求,一直处于这种很忙和很闲的循环状态,不利于服务器的稳定。


为应对这种情况,两次随机选择策略算法做了一些改进,该算法可以描述为:


  1. 服务节点定时向调度节点上报各自的负载情况,调度节点更新并记录所有服务节点的当前负载值。


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。



割环法实现复杂度略高,时间复杂度为 O(log(vn)),(其中,n 是服务节点个数,v 是每个节点拥有的虚拟节点数),它具有很好的单调性,而平衡性和稳定性主要取决于虚拟节点的个数和虚拟节点生成规则,例如 ketama hash 割环法采用的是通过服务节点 ip 和端口组成的字符串的 MD5 值,来生成 160 组虚拟节点。



1.8.3. 二次取模


取模哈希映射是一种简单的一致性哈希方式,但是简单的一次性取模哈希单调性很差,对于故障容灾非常不好,一旦某台服务节点不可用,会导致大部分的请求被重新分配到新的节点,造成缓存的大面积迁移,因此有了二次取模的一致性哈希方式。


二次取模算法即调度节点维护两张服务节点表:松散表(所有节点表)和紧实表(可用节点表)。请求分配过程中,先对松散表取模运算,若结果节点可用,则直接选取;若结果节点已不可用,再对紧实表做第二次取模运算,得到最终节点。如下图示:



二次取模算法实现简单,时间复杂度为 O(1),具有较好的单调性,能很好的处理缩容和节点故障的情况。平衡性和稳定性也比较好,主要取决于对象 key 的分布是否足够散列(若不够散列,也可以加一层散列函数将 key 打散)。



1.8.4. 最高随机权重


最高随机权重算法是以请求 key 和节点标识为参数进行一轮散列运算(如 MurmurHash 算法),得出所有节点的权重值进行对比,最终取最大权重值对应的节点为目标映射节点。可以描述为如下公式:



散列运算也可以认为是一种保持一致性的伪随机的方式,类似于前面讲到的普通随机的调度方式,通过随机比较每个对象的随机值进行选择。


这种方式需要 O(n)的时间复杂度,但换来的是非常好的单调性和平衡性,在节点数量变化时,只有当对象的最大权重值落在变化的节点上时才受影响,也就是说只会影响变化的节点上的对象的重新映射,因此无论扩容,缩容和节点故障都能以最小的代价转移对象,在节点数较少而对于单调性要求非常高的场景可以采用这种方式。



1.8.5. Jump consistent hash


jump consistent hash 通过一种非常简单的跳跃算法对给定的对象 key 算出该对象被映射的服务节点,算法如下:



这个算法乍看难以理解,它其实是下面这个算法的一个变种,只是将随机函数通过线性同余的方式改造而来的。


它也是一种伪随机的方式,通过随机保证了平衡性,而这里随机函数用到的种子是各个请求的 key 值,因此保证了一致性。它与最高随机权重的差别是这里的随机不需要对所有节点都进行一次随机,而是通过随机值跳跃了部分节点的比较。


jump consistent hash 实现简单,零内存消耗,时间复杂度为 O(log(n))。具有很高的平衡性,在单调性方面,扩容和缩容表现较好,但对于中间节点故障,理想情况下需要将故障节点与最后一个节点调换,需要将故障节点和最后的节点共两个节点的对象进行转移。



1.8.6. 小结


一致性哈希方式还有很多种类,通常结合不同的散列函实现。也有些或为了更简单的使用,或为了更好的单调性,或为了更好的平衡性等而对以上这些方式进行的改造等,如二次 Jump consistent hash 等方式。另外也有结合最小负载方式等的变种,如有限负载一致性哈希会根据当前负载情况对所有节点限制一个最大负载,在一致性哈希中对 hash 进行映射时跳过已达到最大负载限制的节点,实际应用过程中可根据业务情况自行做更好的调整和结合。





不放回随机抽样即从 n 个数据中抽取 m 个不重复的数据。关于不放回随机抽样算法,笔者曾在 km 发表过专门的文章详细演绎和实现了各种随机抽样算法的原理和过程,以及它们的优缺点和适用范围,有兴趣的同学可以前往阅读。



2.1. Knuth 洗牌抽样


不放回随机抽样可以当成是一次洗牌算法的过程,利用洗牌算法来对序列进行随机排列,然后选取前 m 个序列作为抽样结果。


Knuth 洗牌算法是在 Fisher-Yates 洗牌算法中改进而来的,通过位置交换的方式代替了删除操作,将每个被删除的数字交换为最后一个未删除的数字(或最前一个未删除的数字)。


Knuth 洗牌算法可以描述为:


  • 生成数字 1 到 n 的随机排列(数组索引从 1 开始)


  • for i from 1 to n-1 doj ← 随机一个整数值 i ≤ j < n交换 a[j] 和 a[i]


运用 Knuth 洗牌算法进行的随机抽样的方式称为 Knuth 洗牌随机抽样算法,由于随机抽样只需要抽取 m 个序列,因此洗牌流程只需洗到前 m 个数据即可。



2.2. 占位洗牌随机抽样


Knuth 洗牌算法是一种 in-place 的洗牌,即在原有的数组直接洗牌,尽管保留了原数组的所有元素,但它还是破坏了元素之间的前后顺序,有些时候我们希望原数组仅是可读的(如全局配置表),不会因为一次抽样遭到破坏,以满足可以对同一原始数组多次抽样的需求,如若使用 Knuth 抽样算法,必须对原数组先做一次拷贝操作,但这显然不是最好的做法,更好的办法在 Knuth 洗牌算法的基础上,不对原数组进行交换操作,而是通过一个额外的 map 来记录元素间的交换关系,我们称为占位洗牌算法。


占位洗牌算法过程演示如下:



最终,洗牌的结果为 3,5,2,4,1。



2.3. 选择抽样技术抽样


洗牌算法是对一个已经预初始化好的数据列表进行洗牌,需要在内存中全量缓存数据列表,如果数据总量 n 很大,并且单条记录的数据也很大,那么在内存中缓存所有数据记录的做法会显得非常的笨拙。而选择选择抽样技术算法,它不需要预先全量缓存数据列表,从而可以支持流式处理。


选择抽样技术算法可以描述为:


  1. 生成 1 到 n 之间的随机数 U


2. 如果 U≥m,则跳转到步骤 4


3. 把这个记录选为样本,m 减 1,n 减 1。如果 m>0,则跳转到步骤 1,否则取样完成,算法终止


4. 跳过这个记录,不选为样本,n 减 1,跳转到步骤 1


选择抽样技术算法过程演示如下:



最终,抽样的结果为 2,5。



选择抽样技术算法虽然不需要将数据流全量缓存到内存中,但他仍然需要预先准确的知道数据量的总大小即 n 值。它的优点是能保持输出顺序与输入顺序不变,且单个元素是否被抽中可以提前知道。



2.4. 蓄水池抽样


很多时候我们仍然不知道数据总量 n,上述的选择抽样技术算法就需要扫描数据两次,第一次先统计 n 值,第二次再进行抽样,这在流处理场景中仍然有很大的局限性。


Alan G. Waterman 给出了一种叫蓄水池抽样(Reservoir Sampling)的算法,可以在无需提前知道数据总量 n 的情况下仍然支持流处理场景。


蓄水池抽样算法可以描述为:


  1. 数据游标 i←0,将 i≤m 的数据一次放入蓄水池,并置 pool[i] ←i


2. 生成 1 到 i 之间的随机数 j


3. 如果 j>m,则跳转到步骤 5


4. 把这个记录选为样本,删除原先蓄水池中 pool[j]数据,并置 pool[j] ←i


5. 游标 i 自增 1,若 i<n,跳转到步骤 2,否则取样完成,算法终止,最后蓄水池中的数据即为总样本


蓄水池抽样算法过程演示如下:



最终,抽样的结果为 1,5。



2.5. 随机分值排序抽样


洗牌算法也可以认为就是将数据按随机的方式做一个排序,从 n 个元素集合中随机抽取 m 个元素的问题就相当于是随机排序之后取前 m 排名的元素,基于这个原理,我们可以设计一种通过随机分值排序的方式来解决随机抽样问题。


随机分值排序算法可以描述为:


  1. 系统维护一张容量为 m 的排行榜单


2. 对于每个元素都给他们随机一个(0,1] 区间的分值,并根据随机分值插入排行榜


3. 所有数据处理完成,最终排名前 m 的元素即为抽样结果


尽管随机分值排序抽样算法相比于蓄水池抽样算法并没有什么好处,反而需要增加额外的排序消耗,但接下来的带权重随机抽样将利用到它的算法思想。



2.6. 朴素的带权重抽样


很多需求场景数据元素都需要带有权重,每个元素被抽取的概率是由元素本身的权重决定的,诸如全服消费抽奖类活动,需要以玩家在一定时间段内的总消费额度为权重进行抽奖,消费越高,最后中奖的机会就越大,这就涉及到了带权重的抽样算法。


朴素的带权重随机算法也称为轮盘赌选择法,将数据放置在一个假想的轮盘上,元素个体的权重越高,在轮盘上占据的空间就越多,因此就更有可能被选中。



假设上面轮盘一到四等奖和幸运奖的权重值分别为 5,10,15,30,40,所有元素权重之和为 100,我们可以从[1, 100] 中随机得到一个值,假设为 45,而后从第一个元素开始,不断累加它们的权重,直到有一个元素的累加权重包含 45,则选取该元素。如下所示:



由于权重 45 处于四等奖的累加权重值当中,因此最后抽样结果为四等奖。


若要不放回的选取 m 个元素,则需要先选取一个,并将该元素从集合中踢除,再反复按同样的方法抽取其余元素。



2.7. 带权重的 A-Res 算法蓄水池抽样


朴素的带权重抽样算法需要内存足够容纳所有数据,破坏了原数据的可读属性,时间复杂度高等缺点,而经典的蓄水池算法高效的实现了流处理场景的大数据不放回随机抽样,但对于带权重的情况,就不能适用了。


A-Res(Algorithm A With a Reservoir) 是蓄水池抽样算法的带权重版本,算法主体思想与经典蓄水池算法一样都是维护含有 m 个元素的结果集,对每个新元素尝试去替换结果集中的元素。同时它巧妙的利用了随机分值排序算法抽样的思想,在对数据做随机分值的时候结合数据的权重大小生成排名分数,以满足分值与权重之间的正相关性,而这个 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. 计数排序


计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。



由于用来计数的数组的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上 1),这使得计数排序对于数据范围很大的数组,需要大量时间和空间。


过程演示如下:



3.2.2. 桶排序


桶排序是计数排序的升级版,它利用了函数的映射关系,桶排序高效与否的关键就在于这个映射函数的确定。比如我们可以将排序数据进行除 10 运算,运算结果中具有相同的商值放入相同的桶中,即每十个数会放入相同的桶中。


过程演示如下:



为了使桶排序更加高效,我们需要做到这两点:


  1. 在额外空间充足的情况下,尽量增大桶的数量


2. 使用的映射函数能够将输入的所有数据均匀的分配到所有桶中


计数排序本质上是一种特殊的桶排序,当桶的个数取最大值(max-min+1)的时候,桶排序就变成了计数排序。



3.2.3. 基数排序


基数排序的原理是将整数按位数切割成不同的数字,然后对每个位数分别比较。基数排序首先按最低有效位数字进行排序,将相同值放入同一个桶中,并按最低位值顺序叠放,然后再按次低有效位排序,重复这个过程直到所有位都进行了排序,最终即是一个有序序列。


过程演示如下:



基数排序也是一种桶排序。桶排序是按值区间划分桶,基数排序是按数位来划分,基数排序可以看做是多轮桶排序,每个数位上都进行一轮桶排序。



3.3. 多路归并排序


多路归并排序算法是将多个已经有序的列表进行归并排序,合成为一组有序的列表的排序过程。


k 路归并排序可以描述为:


  1. 初始时取出 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. 跳跃表排序


跳跃表(Skip Lists)是一种有序的数据结构,它通过在每个节点中随机的建立上层辅助查找节点,从而达到快速访问节点的目的(与败者树的多路归并排序有异曲同工之妙)。



如下是四层跳跃表结构的示意:



在查找目标元素时,从顶层列表、头元素起步,沿着每层链表搜索,直至找到一个大于或等于目标的元素,或者到达当前层列表末尾。如果该元素等于目标元素,则表明该元素已被找到;如果该元素大于目标元素或已到达链表末尾,则退回到当前层的上一个元素,然后转入下一层进行搜索。依次类推,最终找到该元素或在最底层底仍未找到(不存在)。


当 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 可以描述为:


  1. 将新加入的数据点加入临时数组中,当临时数组满了或者需要计算分位数时,将临时数组中的数据点和已经存在的质心一起排序。(其中数据点和质心的表达方式是完全一样的:平均值和权重,每个数据点的平均值就是其本身,权重默认是 1)。


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 二叉平衡树的方式来搜索数据点最靠近的质心数,找到最靠近的质心数后,将二者进行合并。


最新文章
抖音线上小店怎么开店?要收费吗?(抖音小店操作教程)
随着电子商务的迅猛发展,线上店铺成为越来越多人创业的选择。抖音作为中国最受欢迎的短视频平台之一,也为创业者提供了开设抖音
怎么申请淘宝天猫店铺?开淘宝天猫店铺的成功案例分享
如何申请淘宝天猫店铺?淘宝天猫是中国的电子商务平台之一,拥有庞大的用户群体和丰富的商品资源。开设淘宝天猫店铺可以帮助商家
文件链接生成器:在线文件转链接生成器
文件链接生成器,也称为在线文件转链接生成器,是一种在线工具,它允许用户将文件转换成可以直接在网页上分享或嵌入的链接。这种
百度商业系统升级为“百度伴飞”
2024年12月5日,百度热AI营销大会在成都举行。会上,百度宣布百度商业系统重磅升级为“百度伴飞”。百度称,要让营销场域更广阔
网上怎么转让店铺,网上转让店铺要注意什么
在当前激烈的商业环境中,店铺转让已经成为很多经营者的选择,尤其是在需要转型、搬迁或改变经营方向时。相比传统的线下中介或者
公众号后台能一键排版?还能直接选择样式模板?
现在做公众号运营,相信大家都已经是排版熟手了:写好文案找好图片—选择合适的模板—填充内容,然后再粘贴复制到公众号后台,有
oracle部门总工资的函数,Oracle分析函数
分析函数,通过对SQL语言进行扩展。例子:将计算scott方案中emp表的员工薪资的运行总计,分部门运行总计。sel
汉得 × 奥马冰箱|推进APS数字化应用落地,让产供销实现“同频共振”!
奥马冰箱总裁 姚友军“我们必须用数字化转型来叠加我们的经验,借助APS智能排程做牵引,达到我们的智能化物流的实现,让我们的产
计算机毕业设计python基于django的少儿编程线上教育系统
安卓毕业设计a源码网站威创股份产业投资部投资总监郭晓乐编程作为2017年的当红方向,纯线上或者线上线下结合的模式都存在,我认
淘宝云客服抢名额有什么技巧(分析淘宝云客服靠谱吗)
许多人都喜欢通过坚持来赚取外快,又或者是通过兼职来提高自己的生活水平。阿里巴巴就推出了淘宝云客服这样一个平台。为大家介绍
推荐文章