服务器开发设计之算法宝典

Posted 腾讯技术工程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了服务器开发设计之算法宝典相关的知识,希望对你有一定的参考价值。

作者:lynhlzou,腾讯 IEG 后台开发工程师

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

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

思维导图

1. 调度算法

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

1.1. 轮询

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

1.2. 加权轮询

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

加权轮询可以描述为:

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

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

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

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

请求序号当前权重选中节点调整后权重
12,3,4c2,3,3
22,3,3b2,2,3
32,2,3c2,2,2
42,2,2a1,2,2
51,2,2b1,1,2
61,1,2c1,1,1
71,1,1a0,1,1
80,1,1b0,0,1
90,0,1c0,0,0

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 的请求总是会被分配到同一个服务节点上,以保持请求的一致性,既有了一致性哈希的调度方式。

关于一致性哈希算法,笔者曾在 km 发表过专门的文章《一致性哈希方案在分布式系统中应用对比》,详细介绍和对比了它们的优缺点以及对比数据,有兴趣的同学可以前往阅读。

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 算出该对象被映射的服务节点,算法如下:

int JumpConsistentHash(unsigned long long key, int num_buckets)

    long long  b = -1, j = 0;
    while (j < num_buckets) 
        b = j;
        key = key * 2862933555777941757ULL + 1;
        j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
    
    return b;

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

int ch(int key, int num_buckets) 
    random.seed(key) ;
    int b = -1; //  bucket number before the previous jump
    int j = 0; // bucket number before the current jump
    while(j < num_buckets)
        b = j;
        double r = random.next(); //  0<r<1.0
        j = floor( (b+1) / r);
    
    return b;

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

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

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

2. 不放回随机抽样算法

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

2.1. Knuth 洗牌抽样

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

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

Knuth 洗牌算法可以描述为:

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

  • for i from 1 to n-1 do

    j ← 随机一个整数值 i ≤ j < n

    交换 a[j] 和 a[i]

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

2.2. 占位洗牌随机抽样

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

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

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

运用占位洗牌算法实现的随机抽样的方式称为占位洗牌随机抽样,同样的,我们依然可以只抽取到前 m 个数据即可。这种算法对原数组不做任何修改,代价是增加不大于 的临时空间。

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 算法生成随机分值的公式就是:

其中 为第 i 个数据的权重值, 是从(0,1]之间的一个随机值。

A-Res 算法可以描述为:

  1. 对于前 m 个数, 计算特值 ,直接放入蓄水池中

  2. 对于从 m+1,m+2,...,n 的第 i 个数,通过公式 计算特征值 ,如若特征值超过蓄水池中最小值,则替换最小值

该算法的时间复杂度为 ,且可以完美的运用在流式处理场景中。

2.8. 带权重的 A-ExpJ 算法蓄水池抽样

A-Res 需要对每个元素产生一个随机数,而生成高质量的随机数有可能会有较大的性能开销,《Weighted random sampling with a reservoir》论文中给出了一种更为优化的指数跳跃的算法 A-ExpJ 抽样(Algorithm A with exponential jumps),它能将随机数的生成量从 减少到 ,原理类似于通过一次额外的随机来跳过一段元素的特征值 的计算。

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

  1. 对于前 m 个数, 计算特征值 ,其中 为第 i 个数据的权重值, 是从(0,1]之间的一个随机值,直接放入蓄水池中

  2. 对于从 m+1,m+2,...,n 的第 i 个数,执行以下步骤

  3. 计算阈值 , ,其中 r 为(0,1]之间的一个随机值, 为蓄水池中的最小特征值

  4. 跳过部分元素并累加这些元素权重值 ,直到第 i 个元素满足

  5. 计算当前元素特征值 ,其中 为(,1]之间的一个随机值,, 为蓄水池中的最小特征值, 为当前元素权重值

  6. 使用当前元素替换蓄水池中最小特征值的元素

  7. 更新阈值 ,

有点不好理解,show you the code:

function aexpj_weight_sampling(data_array, weight_array, n, m)
    local result, rank = , 
    for i=1, m do
        local rand_score = math.random() ^ (1 / weight_array[i])
        local idx = binary_search(rank, rand_score)
        table.insert(rank, idx, score = rand_score, data = data_array[i])
    end

    local weight_sum, xw = 0, math.log(math.random()) / math.log(rank[m].score)
    for i=m+1, n do
        weight_sum = weight_sum + weight_array[i]
        if weight_sum >= xw then
            local tw = rank[m].score ^ weight_array[i]
            local rand_score = (math.random()*(1-tw) + tw) ^ (1 / weight_array[i])
            local idx = binary_search(rank, rand_score)
            table.insert(rank, idx, score = rand_score, data = data_array[i])
            table.remove(rank)
            weight_sum = 0
            xw = math.log(math.random()) / math.log(rank[m].score)
        end
    end

    for i=1, m do
        result[i] = rank[i].data
    end

    return result
end

3. 排序算法

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. 计数排序

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

计数排序的特征:当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 。计数排序不是比较排序,排序的速度快于任何比较排序算法。

由于用来计数的数组的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上 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)是一种有序的数据结构,它通过在每个节点中随机的建立上层辅助查找节点,从而达到快速访问节点的目的(与败者树的多路归并排序有异曲同工之妙)。

跳跃列表按层建造,底层是一个普通的有序链表,包含所有元素。每个更高层都充当下面列表的“快速通道”,第 i 层中的元素按某个固定的概率 p(通常为 1/2 或 1/4)随机出现在第 i+1 层中。每个元素平均出现在 1/(1-p)个列表中,而最高层的元素在 个列表中出现。

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

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

当 p 值越大,快速通道就越稀疏,占用空间越小,但查找速度越慢,反之,则占用空间大查找速度快,通过选择不同 p 值,就可以在查找代价和存储代价之间获取平衡。

由于跳跃表使用的是链表,加上增加了近似于以二分方式的辅助节点,因此查询,插入和删除的性能都很理想。在大部分情况下,跳跃表的效率可以和平衡树相媲美,它是一种随机化的平衡方案,在实现上比平衡树要更为简单,因而得到了广泛的应用,如 redis 的 zset,leveldb,我司的 apollo 排行榜等都使用了跳跃表排序方案。

3.5. 百分比近似排序

在流处理场景中,针对大容量的排序榜单,全量存储和排序需要消耗的空间及时间都很高,不太现实。实际应用中,对于长尾数据的排序,一般也只需要显示百分比近似排名,通过牺牲一定的精确度来换取高性能和高实时性。

3.5.1. HdrHistogram 算法

HdrHistogram 使用的是直方图统计算法,直方图算法类似于桶排序,原理就是创建一个直方图,以一定的区间间隔记录每个区间上的数据总量,预测排名时只需统计当前值所在区间及之前区间的所有数量之和与总数据量之间的比率。

区间分割方式可以采用线性分割和指数分割方式:

  1. 线性分割,数据以固定长度进行分割,假设数据范围是[1-1000000],以每 100 的间隔划分为 1 个区间,总共需要划分 10000 个区间桶。

  2. 指数分割,基于指数倍的间隔长度进行分割,假设数据范围是[1-1000000],以 2 的幂次方的区间[, ]进行划分,总共只需要划分 20 个区间桶。

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

4. 限流与过载保护

复杂的业务场景中,经常容易遇到瞬时请求量的突增,很有可能会导致服务器占用过多资源,发生了大量的重试和资源竞争,导致响应速度降低、超时、乃至宕机,甚至引发雪崩造成整个系统不可用的情况。

为应对这种情况,通常需要系统具备可靠的限流和过载保护的能力,对于超出系统承载能力的部分的请求作出快速拒绝、丢弃处理,以保证本服务或下游服务系统的稳定。

4.1. 计数器

计数器算法是限流算法里最简单也是最容易实现的一种算法。计数器算法可以针对某个用户的请求,或某类接口请求,或全局总请求量进行限制。

比如我们设定针对单个玩家的登录协议,每 3 秒才能请求一次,服务器可以在玩家数据上记录玩家上一次的登录时间,通过与本次登录时间进行对比,判断是否已经超过了 3 秒钟来决定本次请求是否需要继续处理。

又如针对某类协议,假设我们设定服务器同一秒内总登录协议请求次数不超过 100 条,我们可以设置一个计数器,每当一个登录请求过来的时候,计数器加 1,如果计数器值大于 100 且当前请求与第一个请求间隔时间还在 1 秒内,那么就判定为达到请求上限,拒绝服务,如果该请求与第一个请求间隔已经超过 1 秒钟,则重置计数器的值为 0,并重新计数。

计数器算法存在瞬时流量的临界问题,即在时间窗口切换时,前一个窗口和后一个窗口的请求量都集中在时间窗口切换的前后,在最坏的情况下,可能会产生两倍于阈值流量的请求。

为此也可以使用多个不同间隔的计数器相结合的方式进行限频,如可以限制登录请求 1 秒内不超过 100 的同时 1 分钟内不超过 1000 次。

4.2. 漏桶

漏桶算法原理很简单,假设有一个水桶,所有水(请求)都会先丢进漏桶中,漏桶则以固定的速率出水(处理请求),当请求量速率过大,水桶中的水则会溢出(请求被丢弃)。漏桶算法能保证系统整体按固定的速率处理请求。

如下图所示:

4.3. 令牌桶

对于很多应用场景来说,除了要求能够限制请求的固定处理速率外,还要求允许某种程度的突发请求量,这时候漏桶算法可能就不合适了。

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

令牌桶算法大概描述如下:

  1. 所有的请求在处理之前都需要拿到一个可用的令牌才会被处理。

  2. 根据限流大小,设置按照一定的速率往桶里添加令牌。

  3. 桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃。

  4. 请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除。

如下图所示:

4.4. 滑动窗口

计数器,漏桶和令牌桶算法是在上游节点做的限流,通过配置系统参数做限制,不依赖于下游服务的反馈数据,对于异构的请求不太适用,且需要预估下游节点的处理能力。

滑动窗口限频类似于 TCP 的滑动窗口协议,设置一个窗口大小,这个大小即当前最大在处理中的请求量,同时记录滑动窗口的左右端点,每次发送一个请求时滑动窗口右端点往前移一格,每次收到请求处理完毕响应后窗口左端点往前移一格,当右端点与左端点的差值超过最大窗口大小时,等待发送或拒绝服务。

如下图所示:

4.5. SRE 自适应限流

滑动窗口是以固定的窗口大小限制请求,而 Google 的 SRE 自适应限流相当于是一个动态的窗口,它根据过往请求的成功率动态调整向后端发送请求的速率,当成功率越高请求被拒绝的概率就越小;反之,当成功率越低请求被拒绝的概率就相应越大。

SRE 自适应限流算法需要在应用层记录过去两分钟内的两个数据信息:

  1. requests:请求总量,应用层尝试的请求数

  2. accepts:成功被后端处理的请求数

请求被拒绝的概率 p 的计算公式如下:

  1. 其中 K 为倍率因子,由用户设置(比如 2),从算法公式可以看出:

  2. 在正常情况下 requests 等于 accepts,新请求被决绝的概率 p 为 0,即所有请求正常通过

  3. 当后端出现异常情况时,accepts 的数量会逐渐小于 requests,应用层可以继续发送请求直到 requests 等于 ,一旦超过这个值,自适应限流启动,新请求就会以概率 p 被拒绝。

  4. 当后端逐渐恢复时,accepts 逐渐增加,概率 p 会增大,更多请求会被放过,当 accepts 恢复到使得 大于等于 requests 时,概率 p 等于 0,限流结束。

我们可以针对不同场景中处理更多请求带来的风险成本与拒绝更多请求带来的服务损失成本之间进行权衡,调整 K 值大小:

  • 降低 K 值会使自适应限流算法更加激进(拒绝更多请求,服务损失成本升高,风险成本降低)。

  • 增加 K 值会使自适应限流算法不再那么激进(放过更多请求,服务损失成本降低,风险成本升高)。

如对于某些处理该请求的成本与拒绝该请求的成本的接近场景,系统高负荷运转造成很多请求处理超时,实际已无意义,然而却还是一样会消耗系统资源的情况下,可以调小 K 值。

4.6. 熔断

熔断算法原理是系统统计并定时检查过往请求的失败(超时)比率,当失败(超时)率达到一定阈值之后,熔断器开启,并休眠一段时间,当休眠期结束后,熔断器关闭,重新往后端节点发送请求,并重新统计失败率。如此周而复始。

如下图所示:

4.7. Hystrix 半开熔断器

Hystrix 中的半开熔断器相对于简单熔断增加了一种半开状态,Hystrix 在运行过程中会向每个请求对应的节点报告成功、失败、超时和拒绝的状态,熔断器维护计算统计的数据,根据这些统计的信息来确定熔断器是否打开。如果打开,后续的请求都会被截断。然后会隔一段时间,尝试半开状态,即放入一部分请求过去,相当于对服务进行一次健康检查,如果服务恢复,熔断器关闭,随后完全恢复调用,如果失败,则重新打开熔断器,继续进入熔断等待状态。

如下图所示:

5. 序列化与编码

数据结构序列化是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络传输),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。经过依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。

5.1. 标记语言

标记语言是一种将文本(Text)以及文本相关的其他信息结合起来,展现出关于文档结构和数据处理细节的计算机文字编码。

5.1.1. 超文本标记语言(html

HTML 是一种用于创建网页的标准标记语言。HTML 是一种基础技术,常与 CSS、javascript 一起被众多网站用于设计网页、网页应用程序以及移动应用程序的用户界面。网页浏览器可以读取 HTML 文件,并将其渲染成可视化网页。HTML 描述了一个网站的结构语义随着线索的呈现,使之成为一种标记语言而非编程语言。

5.1.2. 可扩展标记语言(XML)

XML 是一种标记语言,设计用来传送及携带数据信息。每个 XML 文档都由 XML 声明开始,在前面的代码中的第一行就是 XML 声明。这一行代码会告诉解析器或浏览器这个文件应该按照 XML 规则进行解析。

XML 文档的字符分为标记(Markup)与内容(content)两类。标记通常以<开头,以>结尾;或者以字符&开头,以;结尾。不是标记的字符就是内容。一个 tag 属于标记结构,以<开头,以>结尾。

元素是文档逻辑组成,或者在 start-tag 与匹配的 end-tag 之间,或者仅作为一个 empty-element tag。

属性是一种标记结构,在 start-tag 或 empty-element tag 内部的“名字-值对”。例如:<img src="madonna.jpg" alt="Madonna" />每个元素中,一个属性最多出现一次,一个属性只能有一个值。

5.1.3. Markdown

Markdown 是一种轻量级标记语言,创始人为约翰·格鲁伯。它允许人们使用易读易写的纯文本格式编写文档,然后转换成有效的 XHTML(或者 HTML)文档。这种语言吸收了很多在电子邮件中已有的纯文本标记的特性。

由于 Markdown 的轻量化、易读易写特性,并且对于图片,图表、数学式都有支持,目前许多网站都广泛使用 Markdown 来撰写帮助文档或是用于论坛上发表消息。如 GitHub、Reddit、Diaspora、Stack Exchange、OpenStreetMap 、SourceForge、简书等,甚至还能被用来撰写电子书。当然还有咱们的 KM 平台,很强大。

Markdown 语法格式如下:

5.1.4. JSON

JSON 是以数据线性化为目标的轻量级标记语言,相比于 XML,JSON 更加简洁、轻量和具有更好的可读性。

JSON 的基本数据类型和编码规则:

  • 数值:十进制数,不能有前导 0,可以为负数,可以有小数部分。还可以用 e 或者 E 表示指数部分。不能包含非数,如 NaN。不区分整数与浮点数。

  • 字符串:以双引号""括起来的零个或多个 Unicode 码位。支持反斜杠开始的转义字符序列。

  • 布尔值:表示为 true 或者 false。

  • 数组:有序的零个或者多个值。每个值可以为任意类型。序列表使用方括号[,]括起来。元素之间用逗号,分割。形如:[value, value]

  • 对象:若干无序的“键-值对”(key-value pairs),其中键只能是字符串。建议但不强制要求对象中的键是独一无二的。对象以花括号开始,并以结束。键-值对之间使用逗号分隔。键与值之间用冒号:分割。

  • 空值:值写为 null

5.2. TLV 二进制序列化

很多高效得数据序列化方式都是采用类 TLV(Tag+Length+Value)的方式对数据进行序列化和反序列化,每块数据以 Tag 开始,Tag 即数据标签,标识接下来的数据类型是什么,Length 即长度,标识接下来的数据总长,Value 即数据的实际内容,结合 Tag 和 Length 的大小即可获取当前这块数据内容。

5.2.1. Protocol Buffers

Protocol Buffers(简称:ProtoBuf)是一种开源跨平台的序列化数据结构的协议,它是一种灵活,高效,自动化的结构数据序列化方法,相比 XML 和 JSON 更小、更快、更为简单。

Protocol Buffers 包含一个接口描述语言.proto 文件,描述需要定义的一些数据结构,通过程序工具根据这些描述产生.cc 和.h 文件代码,这些代码将用来生成或解析代表这些数据结构的字节流。

Protocol Buffers 编码后的消息都是 Key-Value 形式,Key 的值由 field_number(字段标号)和 wire_type(编码类型)组合而成,规则为:key = field_number << 3 | wire type。

field_number 部分指示了当前是哪个数据成员,通过它将 cc 和 h 文件中的数据成员与当前的 key-value 对应起来。

wire type 为字段编码类型,有以下几类:

Protocol Buffers 编码特征:

  • 整型数据采用 varint 编码(见 5.4.1 节),以节省序列化后数据大小。

  • 对于有符号整型,先进行 zigzag 编码(见 5.4.2 节)调整再进行 varint 数据编码,以减小负整数序列化后数据大小。

  • string、嵌套结构以及 packed repeated fields 的编码类型是 Length-delimited,它们的编码方式统一为 tag+length+value。

5.2.2. TDR

TDR 是腾讯互娱研发部自研跨平台多语言数据表示组件,主要用于数据的序列化反序列化以及数据的存储。TDR 通过 XML 文件来定义接口和结构的描述,通过程序工具根据这些描述产生.tdr 和.h 文件代码,用于序列化和反序列化这些数据结构。

TDR1.0 的版本是通过版本剪裁方式来序列化反序列化,需要事先维护好字段版本号,序列化反序列化时通过剪裁版本号来完成兼容的方式,只支持单向的高版本兼容低版本数据。

TDR2.0 整体上与 Protocol Buffers 相似,TDR2.0 支持消息协议的前后双向兼容,整型数据同样支持 varint 编码和 zigzag 调整的方式,在对 TLV 中 Length 部分进行处理时,采用定长编解码方式,以浪费序列化空间的代价来获取更高性能,避免了类似 Protocol Buffers 中不必要的内存拷贝(或者是预先计算大小)的过程。

Protocol Buffers 和 TDR 都有接口描述语言,这使得它们的序列化更高效,数据序列化后也更加紧凑。

5.2.3. Luna 序列化

luna 库是开源的基于 C++17 的 lua/C++绑定库,它同时也实现了针对 lua 数据结构的序列化和反序列化功能,用于 lua 结构数据的传输和存储。

Lua 语言中需要传输和存储的数据类型主要有:nil,boolean,number,string,table。因此在序列化过程中,luna 将类型定义为以下九种类型。

序列化方式如下:

整体上也是类似于 Protocol Buffers 和 TDR 的 TLV 编码方式,同时针对 lua 类型结构的特性做了一些效率上的优化。

主要特性如下: