MongoDB 之使用故障转移的分布式任务分配

533 阅读5分钟
原文链接: coyee.com

当构建一个分布式系统的时候,你该如何分配任务是件很有趣的事情。换言之,假定你有多个节点,你会怎样决定选择哪个节点来做这件事?在某些情况下,这是个很简单的问题;你也许会说,“所有的节点都会处理读取的请求。”但是在其他情况下,就要复杂些了。我们来设想一种情况,你有多个节点,你需要对数据库进行常规备份,即在多个节点间进行复制。你也许不想在所有节点上都进行备份; 毕竟,每个节点上的操作都是一样的,你不会想将同一件东西备份多次。另一方面,你可能也不想静态的分配这个工作。如果你这样做的话,而用来备份的节点挂了的话,你就会丢掉备份了。

这个问题的另一个可见示例是如果您有其他进程,如果可能的话您更愿意会将其固定下来,只有在出现故障时才会跳转。 在一个集群中引入新的节点是一种常见的事情,在这种情况下,理想的场景是单个节点将为其提供所需的所有数据。 如果我们有多个节点这样做,它们可能会使数据重叠,并且它们很可能过载这台可怜的新服务器。 所以我们只需要一个节点来更新它的状态,但是如果该节点在中途宕机,我们需要其它节点来收拾残局。 对于RavenDB,这些任务包括ETL进程,订阅,备份,引导新服务器等等。 我们不断发现可以使用这种行为的新事物。

但是我们如何才能真正做到这一点呢?

这样做的方法之一是利用RavenDB使用Raft并让领导者执行任务分配。 它工作良好,但是不能伸缩。 那是什么意思? 我的意思是随着我们需要管理的任务数量的增长,代码的任务分配部分的复杂性也在增长。 理想情况下,我不想考虑二十个不同的变量和考虑因素,然后才决定在哪个服务器上执行哪些操作,并尝试在一个地方平衡这种已被证明是具有挑战性的工作。

相反的,我们决定基于简单规则来在集群中分配任务。每个集群中的任务都有一个键(一般是根据其参数生成哈希值),并且任务可以分配给一组服务器。鉴于这两个细节,我们可以使用 Jump Consistent Hashing 来分发负载。然而,这样就没法处理故障转移。我们有一个 heartbeat 进程可以在检测无效节点并进行通知,结合这两点,我们编写以下代码:

public static string WhoseTaskIsIt(List<Entry> topology, ulong key)
{
   topology = new List<Entry>(topology); // local copy so we can safely change it
   while (true)
   {
       var index = (int)JumpConsistentHash(key, topology.Count);
       var entry = topology.Values[index];
       if (entry.Disabled == false)
           return entry.Id;

       topology.RemoveAt(index);

       key = CombineHash(key, (ulong)topology.Count);
   }
}

//A Fast, Minimal Memory, Consistent Hash Algorithm
//by John Lamping, Eric Veach
//relevant article: https://arxiv.org/abs/1406.2294
public static long JumpConsistentHash(ulong key, int numBuckets)
{
  long b = 1L;
  long j = 0;
  while (j < numBuckets)
  {
      b = j;
      key = key * 2862933555777941757UL + 1;
      j = (long)((b + 1) * ((1L << 31) / ((double)(key >> 33) + 1)));
  }
  return b;
}

public static ulong CombineHash(ulong x, ulong y)
{
    // This is the Hash128to64 function from Google's CityHash (available
    // under the MIT License).  We use it to reduce multiple 64 bit hashes
    // into a single hash.

    // Murmur-inspired hashing.
    ulong a = (y ^ x) * kMul;
    a ^= (a >> 47);
    ulong b = (x ^ a) * kMul;
    b ^= (b >> 47);
    b *= kMul;

    return b;
}

我们在这里做的是依赖于两个不同的属性:跳跃一致性哈希(Jump Consistent Hashing )让我们知道哪个节点负责什么,以及Raft集群领导者跟踪所有的活动节点,让我们知道一个节点何时关闭。 当我们需要分配一个任务时,我们使用它的哈希键来找到它的首选所有者,如果它是活着的,那就是它了。 但是如果它当前处于关闭状态,我们会做两件事情:我们从拓扑中删除宕机的节点,并使用集群中新的节点数重新计算哈希键。 这给了我们一个新的首选节点,以此类推,直到我们找到一个活跃的节点为止。

我们重新处理故障转移的原因是,跳跃一致性哈希通常在拓扑中指向相同的位置(这就是为什么我们首先选择它),所以我们重新处理以获得一个不同的位置,于是它不会全部落在列表中的下一个节点上。 所有失败的节点任务在剩余的存活集群成员之间均匀分布。

关于这一点的好处是,除了保持存活/宕机列表最新,Raft集群并不需要做什么。 这是一个一致的算法,因此在相同数据上运行的不同节点可以取得相同的结果,并且一个节点宕机将导致另一个节点在更新新服务器时满足规范,另一个节点将启动备份过程。 所有这些逻辑都是我们想要的逻辑:就在紧挨着写任务逻辑的地方。

这个可以让我们更有效的推理每个独立任务的行为,同时让你了解运行在每个节点的每个任务的详情。