作者:来自 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 = 768,routing_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写代码收起代码块
运行方式如下:
`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写代码
这里有两个值得注意的点:
第一,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