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

cd5252c46a121e2b60c2b2bc0bd34a57.gif

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

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

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

思维导图

fb26df44d39cc4e52fa04e40cf44137a.png

1. 调度算法

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

1.1. 轮询

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

1.2. 加权轮询

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

加权轮询可以描述为:

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

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

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

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

请求序号 当前权重 选中节点 调整后权重
1 {2,3,4} c {2,3,3}
2 {2,3,3} b {2,2,3}
3 {2,2,3} c {2,2,2}
4 {2,2,2} a {1,2,2}
5 {1,2,2} b {1,1,2}
6 {1,1,2} c {1,1,1}
7 {1,1,1} a {0,1,1}
8 {0,1,1} b {0,0,1}
9 {0,0,1} c {0,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},那么平滑权重轮询每一轮的分配过程如下表所示:

5a18d8aa0f33194f7f885ce0bb43052a.png 最终请求分配的次序将是{ 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。

462f0afff8e85efb0fea103978107abc.png

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

1.8.3. 二次取模

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

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

8796d7a86d18d86781def1b4951a51e7.png

二次取模算法实现简单,时间复杂度为 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 来记录元素间的交换关系,我们称为占位洗牌算法。

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

ed406cc61b53ee1194610cb74ef3c607.png

最终,洗牌的结果为 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

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

c35ee3f1e0b003e549b79a60dfde51a2.png

最终,抽样的结果为 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,否则取样完成,算法终止,最后蓄水池中的数据即为总样本

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

7a8005eb2207125ec52f57f69944a5f9.png

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

可以证明,每个数据被选中且留在蓄水池中的概率为 。

2.5. 随机分值排序抽样

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

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

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

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

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

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

2.6. 朴素的带权重抽样

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

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

b1d6575f07b198ff30e48a573a9f287d.png

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

05eb7568e3143a3d29ccea59a61b8d7c.png

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

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

这种抽样算法的复杂度是 ,并且将元素从集合中删除破坏了原数据的可读属性,更重要的是这个算法需要多次遍历数据,不适合在流处理的场景中应用。

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

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值