Elasticsearch 不愿公开命名的 hash(),以及证明它是 Murmur3 的 12 个字节

0 阅读9分钟

作者:来自 Elastic Sachin Frayne

Elasticsearch 未命名的 hash() 以及证明它是 Murmur3 的 12 个字节。Elasticsearch 的 路由 公式使用 MurmurHash3 ,但文档从未说明这一点。本篇文章命名了该函数,完整拆解分片计算过程,并展示如何在外部复现它。

新接触 Elasticsearch 吗?参加我们的 Elasticsearch 入门 webinar。你也可以开始一个 免费云试用,或者现在就在你的 本地机器 上体验 Elastic。


当我第一次接触 Elasticsearch 时,我知道文档会被分布到多个 shards 上,也知道其中涉及某种 hash 机制。官方文档 甚至给出了这个公式:

`

1.  routing_factor = num_routing_shards / num_primary_shards
2.  shard_num      = (hash(_routing) % num_routing_shards) / routing_factor

`AI写代码

但那个 hash() 调用?完全是黑箱。点进 routing field 文档,你找不到这个函数的名字。去问别人,得到的回答通常是:这是实现细节,被刻意抽象掉了,这样团队以后可以替换实现而不需要更新文档。

从技术上讲,这个回答是站得住脚的。但它并没有帮助我或我的客户理解为什么会出现不均匀的 document 分布。

这篇文章就是我当时希望能看到的解释。

什么是 MurmurHash3,以及 Elasticsearch 为什么使用它?

hash() 背后的函数是 MurmurHash3。它是一个非加密哈希函数,由 Austin Appleby 在 2008 年设计。与加密哈希函数(SHA-256、MD5)不同,它不是为安全性设计的;它不需要不可逆,也不需要抵抗有意的碰撞攻击。它只为一件事服务:以尽可能快的速度,将输入值均匀地分布到输出空间中。

这正好适合 shard routing。Elasticsearch 需要把一个 document ID 转换成一个数字,然后用这个数字来决定该 document 存放在哪个 shard 上。它的需求与 Murmur3 提供的能力完全一致:

属性为什么它对分片路由很重要
均匀分布文档可以在各个 shard 之间均匀分布,无需人工干预。
确定性同一个 document ID 始终路由到同一个 shard。
速度在每一次 index 和 get 操作中都会执行,因此必须几乎没有延迟开销。

如果你想深入了解,可以看 MurmurHash。简短版本是:Murmur3 在字节块上运行,通过一系列 multiply-rotate-XOR(乘法-旋转-异或)步骤不断混合数据,从而产生所谓的 雪崩效应 avalanche effects(输入的微小变化会导致输出发生巨大且不可预测的变化)。最终结果是一个 32 位或 128 位整数。Elasticsearch 使用的是 32 位版本。

Elasticsearch 如何计算 shard number:一个完整示例

对于一个包含 6 个 shard 的 index,Elasticsearch 使用 768 个 routing shards,以及 128 的 routing factor。下面通过一个真实 document 来验证这一点。

创建一个包含 6 个 primary shards 的 index:

`

1.  PUT test
2.  {
3.    "settings": {"number_of_shards": 6}
4.  }

`AI写代码

检查默认设置,重点关注 index.number_of_routing_shards

`GET test?include_defaults&flat_settings`AI写代码

对于 6 个 primary shards,响应会显示 index.number_of_routing_shards = 768。文档中对这个设置的描述是:

默认值的设计目的是允许你按 2 的倍数进行拆分,最多扩展到 1024 个 shards。

它在实际中的含义是:Elasticsearch 以 primary shard 的数量为起点,不断进行翻倍,直到再翻倍会超过 1024 为止。对于 6 个 primary shards:

`

1.  6 × 1   = 6
2.  6 × 2   = 12
3.  6 × 4   = 24
4.  6 × 8   = 48
5.  6 × 16  = 96
6.  6 × 32  = 192
7.  6 × 64  = 384
8.  6 × 128 = 768
9.  6 × 256 = 1536  ← too high

`AI写代码

这为你提供了足够的 routing 空间,使索引的 primary shard 数量可以在不重新索引的情况下最多翻倍 7 次。

对于我们的 index:

`

1.  num_primary_shards = 6
2.  num_routing_shards = 768
3.  routing_factor     = 768 / 6 = 128

`AI写代码

现在索引一个 document:

`

1.  PUT test/_doc/654321
2.  {
3.    "field": "value"
4.  }

`AI写代码

检查它落到了哪个 shard 上:

`GET _cat/shards/test?v`AI写代码

它最终落在 shard 4。我们来验证一下原因。

逐步计算 MurmurHash3 分片路由公式

使用上述值(num_routing_shards = 768routing_factor = 128,document ID 654321),分片计算如下:

`shard_num = Math.floorMod(hash("654321"), 768) / 128`AI写代码

在这一点上,我们终于需要回答文档没有明确说明的问题:hash() 到底是什么?

在 Elasticsearch 中,它就是 MurmurHash3。对于字符串 "654321",它的返回值是 1424940152

`

1.  Math.floorMod(1424940152, 768) = 632
2.  632 / 128                      = 4

`AI写代码

Shard 4,正是 Elasticsearch 放置它的位置。

如果你想用自己的 ID 验证这一点,可以使用下面这个小型 Java 脚本,它使用 Lucene 的 Murmur3 实现,在 Elasticsearch 之外计算相同的 shard number。先在 Elasticsearch 中索引一个 document,查看它落到哪个 shard,然后把同一个 ID 传入这个脚本运行,确认结果是否一致。

将其保存为 Murmur3Demo.java

`

1.  class run {

3.      // MurmurHash3 x86_32 — a fast non-cryptographic hash by Austin Appleby.
4.      // These two constants were chosen empirically for their avalanche (bit-mixing) properties.
5.      static final int C1 = 0xcc9e2d51;
6.      static final int C2 = 0x1b873593;

8.      static int murmurhash3(byte[] data, int seed) {
9.          int len = data.length;
10.          int h   = seed;

12.          // Mix in 4 bytes at a time (little-endian 32-bit words)
13.          int blocks = len / 4;
14.          for (int i = 0; i < blocks; i++) {
15.              int k = (data[i*4  ] & 0xFF)
16.                    | (data[i*4+1] & 0xFF) <<  8
17.                    | (data[i*4+2] & 0xFF) << 16
18.                    | (data[i*4+3] & 0xFF) << 24;

20.              k *= C1;
21.              k  = Integer.rotateLeft(k, 15);
22.              k *= C2;

24.              h ^= k;
25.              h  = Integer.rotateLeft(h, 13);
26.              h  = h * 5 + 0xe6546b64;
27.          }

29.          // Mix in any leftover 1–3 bytes
30.          int t = blocks * 4;
31.          int k = 0;
32.          if ((len & 3) == 3) k  = (data[t+2] & 0xFF) << 16;
33.          if ((len & 3) >= 2) k |= (data[t+1] & 0xFF) <<  8;
34.          if ((len & 3) >= 1) {
35.              k |= (data[t] & 0xFF);
36.              k *= C1;
37.              k  = Integer.rotateLeft(k, 15);
38.              k *= C2;
39.              h ^= k;
40.          }

42.          // fmix32: force all bits to fully avalanche before returning
43.          h ^= len;
44.          h ^= h >>> 16;  h *= 0x85ebca6b;
45.          h ^= h >>> 13;  h *= 0xc2b2ae35;
46.          h ^= h >>> 16;

48.          return h;
49.      }

51.      public static void main(String[] args) {
52.          String id = args[0];
53.          int n     = Integer.parseInt(args[1]);

55.          // Elasticsearch encodes strings as UTF-16LE (2 bytes per char, low byte first)
56.          byte[] bytes = new byte[id.length() * 2];
57.          for (int i = 0; i < id.length(); i++) {
58.              char c       = id.charAt(i);
59.              bytes[i*2  ] = (byte)  c;
60.              bytes[i*2+1] = (byte) (c >>> 8);
61.          }

63.          // To avoid modulo bias, Elasticsearch rounds n up to the next power-of-2 (r),
64.          // takes floorMod(hash, r), then scales back down with / (r/n).
65.          int r = n;
66.          while (r * 2 <= 1024) r *= 2;

68.          System.out.println(Math.floorMod(murmurhash3(bytes, 0), r) / (r / n));
69.      }
70.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)收起代码块![](https://csdnimg.cn/release/blogv2/dist/pc/img/arrowup-line-top-White.png)

运行方式如下:

`java Murmur3Demo.java 654321 6`AI写代码

它会输出:

`4`AI写代码

这与上面的分片分配结果一致。

如果你的 index 在创建时显式设置了 index.number_of_routing_shards,请直接使用该值,而不是从 number_of_shards 推导。

Elasticsearch 源码:hash() 如何编码 routing 值

Elasticsearch 源代码 会在将 routing 值传入 MurmurHash3 之前,将其编码为 UTF-16LE。下面是相关方法:

`

1.  public static int hash(String routing) {
2.      assert assertHashWithoutInformationLoss(routing);
3.      final int strLen = routing.length();
4.      final byte[] bytesToHash = strLen * 2 <= MAX_SCRATCH_SIZE
5.          ? scratch.get()
6.          : new byte[strLen * 2];
7.      for (int i = 0; i < strLen; ++i) {
8.          ByteUtils.LITTLE_ENDIAN_CHAR.set(bytesToHash, 2 * i, routing.charAt(i));
9.      }
10.      return hash(bytesToHash, 0, strLen * 2);
11.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

这里有两个值得注意的点:

第一,routing value 是一个字符串,在进行 hash 之前,每个字符都会被写成一个 little-endian 的两字节序列。这就是为什么不能把 ID 当作 ASCII 来复现 hash:"654321" 会被编码成 12 个字节,而不是 6 个。

第二,这个函数返回的是一个有符号 32 位整数,但 Elasticsearch 会对结果使用 Math.floorMod,以避免出现负的 shard number。如果你要在其他语言中复现 routing 计算,一定要注意 signed / unsigned integer 的差异。

当 MurmurHash3 路由导致 hot shards 以及如何检查

对于大多数 workload,你不需要关心这些细节。使用自动生成的 document ID 写入时,Murmur3 的均匀分布会把数据很好地分散到各个 shard 上。真正容易出问题的是使用自定义 ID 或 routing value,并且这些值分布不均匀的情况。比如顺序整数、基于时间戳的值,或者具有明显模式的标识符,并不一定会导致 skew,但如果你观察到 hot shards,就值得检查。

hash 函数本身并不是问题来源:Murmur3 的设计目标就是均匀分布输入。但如果 shard 分布看起来异常,首先应该检查的是你的 ID 或 routing value 的选择。

如果你发现 hot shards,并且使用了自定义 ID,可以对一部分真实 ID 重复刚才对 "654321" 的计算过程:用 Murmur3 对每个 ID 计算 hash,然后应用 Math.floorMod(..., num_routing_shards),再除以 routing factor,看看它们最终分别落在哪些 shard 上。

为什么 Elasticsearch 不公开 hash 函数的名称

将实现细节隐藏在稳定接口之后,是一种良好的软件设计方式。routing 的计算公式是有文档说明且保证稳定的,但底层具体使用哪种 hash 函数则没有必要公开,因为它不依赖于这个实现细节;对几乎所有使用场景来说,关键在于文档能否均匀且一致地分布,而 Murmur3 正好可以做到这一点 —— 无论你是否知道它的名字。

唯一的例外是在出现问题的时候。如果文档集中在某些 shard 上,而你又不知道 routing 的工作方式,你就很难开始排查。一旦你了解了这个公式以及背后的函数,从 “为什么这些 shard 变热(hot)?” 到得到具体答案之间,其实只差几行计算。

对大多数用户来说,这正是了解它是 Murmur3 的意义所在。现在你已经知道了。

原文:Why Elasticsearch shards go hot: MurmurHash3 explained - Elasticsearch Labs