bigywater.tech
MongoDB · 41

哈希索引:能做等值,不能做范围

哈希索引能做什么、不能做什么?这篇从索引键的实际哈希值看起,验证等值、范围、排序、限制、浮点截断和分布。

100 对 200,000
范围查询在普通索引与哈希索引上检查的键数
2.1 = 2.3 = 2.9
哈希索引对这三个浮点数算出同一个哈希(它们都先被截断成 2)
478~528
4000 个连续整数哈希后落在 8 等分空间里的各份个数(期望 500)

索引存什么

实测 · 本机执行输出
  test> db.t.getIndexes().filter(i => i.key.k == "hashed" || i.key.u == "hashed").map(i => ({name: i.name, key: i.key}))
  [
    { name: 'k_hashed', key: { k: 'hashed' } },
    { name: 'u_hashed', key: { u: 'hashed' } }
  ]
  test> [1, 2, 3, 4, 5].map(v => v + " → " + convertShardKeyToHashed(v))
  [
    '1 → 5902408780260971510',
    '2 → 848411777775835583',
    '3 → -1529429926180645992',
    '4 → 2433984061343616404',
    '5 → 4470791281878691347'
  ]
  test> ["user1", "user2"].map(v => v + " → " + convertShardKeyToHashed(v))
  [ 'user1 → 801843209332933044', 'user2 → -4095265838481657615' ]

哈希索引存的是字段值的 64 位哈希。相邻的整数哈希后毫无关系,所以只支持等值。

等值查询

实测 · 本机执行输出
  test> db.t.find({k: 12345}).hint("k_hashed")
  {
    plan: 'FETCH > IXSCAN(k_hashed)',
    nReturned: 1,
    keysExamined: 1,
    docsExamined: 1,
    ms: 6
  }
  # indexBounds
  test> var e = db.t.find({k: 12345}).hint("k_hashed").explain(); var s = e.queryPlanner.winningPlan; while (s.inputStage) s = s.inputStage; JSON.stringify(s.indexBounds)
  {"k":["[7147702579911805439, 7147702579911805439]"]}
  # $in: [1, 2, 3] 的 indexBounds
  test> var e = db.t.find({k: {$in: [1, 2, 3]}}).hint("k_hashed").explain(); var s = e.queryPlanner.winningPlan; while (s.inputStage) s = s.inputStage; JSON.stringify(s.indexBounds)
  {"k":["[-1529429926180645992, -1529429926180645992]","[848411777775835583, 848411777775835583]","[5902408780260971510, 5902408780260971510]"]}

等值是一个单点区间,$in 是多个单点区间,检查 1 个键。

范围与排序

实测 · 本机执行输出
  test> hint("k_hashed") 的范围查询
  {
    plan: 'FETCH > IXSCAN(k_hashed)',
    nReturned: 100,
    keysExamined: 200000,
    docsExamined: 200000,
    ms: 142
  }
  test> hint("k_range") 的同一个范围查询
  {
    plan: 'FETCH > IXSCAN(k_range)',
    nReturned: 100,
    keysExamined: 100,
    docsExamined: 100,
    ms: 2
  }
  test> 删掉普通索引后不 hint
  {
    plan: 'COLLSCAN',
    nReturned: 100,
    keysExamined: 0,
    docsExamined: 200000,
    ms: 30
  }
  test> sort({k: 1}) 用哈希索引
  {
    plan: 'SORT > FETCH > IXSCAN(k_hashed)',
    nReturned: 5,
    keysExamined: 200000,
    docsExamined: 200000,
    ms: 141
  }

范围查询要扫整个哈希索引(200,000 个键、142 ms),普通索引只要检查 100 个键;排序也不能利用,需要 SORT 阶段。

限制

实测 · 本机执行输出
  test> createIndex({k: "hashed"}, {unique: true})
  Currently hashed indexes cannot guarantee uniqueness. Use a regular index.
  test> 数组字段上建哈希索引
  Error: hashed indexes do not currently support array values. Found array at path: k
  test> createIndex({a: "hashed", b: "hashed"})
  A maximum of one index field is allowed to be hashed but found 2 for 'key' { a: "hashed", b: "hashed" }
  test> createIndex({a: 1, b: "hashed"})    // 只有一个哈希字段的复合索引
  ab

不支持 unique、不支持数组字段,复合索引里最多一个哈希字段。

浮点数

实测 · 本机执行输出
  test> [2.1, 2.3, 2.9, 3].map(v => v + " → " + convertShardKeyToHashed(v))
  [
    '2.1 → 848411777775835583',
    '2.3 → 848411777775835583',
    '2.9 → 848411777775835583',
    '3 → -1529429926180645992'
  ]
  test> db.f.find({x: 2.3}).hint("x_hashed")    // 键里同一个哈希有 3 条
  {
    plan: 'FETCH > IXSCAN(x_hashed)',
    nReturned: 1,
    keysExamined: 3,
    docsExamined: 3,
    ms: 0
  }
  test> 结果仍然只有 x = 2.3 的文档
  [ 2 ]

浮点数先截断成 64 位整数再哈希,所以 2.1、2.3、2.9 的哈希相同。查询结果仍然正确,但会多检查键和文档。

分布

实测 · 本机执行输出
  # 聚合里的 $toHashedIndexKey 算出的值与 convertShardKeyToHashed 一致
  test> db.t.aggregate([{$match: {_id: {$lt: 3}}}, {$project: {_id: 0, k: 1, h: {$toHashedIndexKey: "$k"}}}])
  [
    { k: 0, h: Long('4854801880128277513') },
    { k: 1, h: Long('5902408780260971510') },
    { k: 2, h: Long('848411777775835583') }
  ]
  前 8 个连续整数 0..7 的哈希的符号与量级: ['+4.9e18', '+5.9e18', '+0.8e18', '-1.5e18', '+2.4e18', '+4.5e18', '-6.5e18', '+9.2e18']
  4000 个连续整数的哈希落在 8 等分的 64 位空间里的个数: [482, 506, 505, 488, 506, 507, 528, 478](期望每份 500)

连续整数哈希后均匀分散到整个 64 位空间,这是它适合做分片键、避免写入热点的原因。

分步演示

哈希索引
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MongoDB 9.0.2(Homebrew 的 mongod、mongos)/ mongosh 2.12.0 / pymongo 4.18.3;每个实验在临时目录里起全新的 mongod(独立 dbpath,端口 9900 以上,只监听 127.0.0.1,WiredTiger 缓存 0.25 GB),需要副本集或分片集群时在本机起多个进程;命令输出来自真实的 mongosh,批量写入、计时与多线程用 pymongo;计时用 time.perf_counter,数字每次运行会有小幅抖动,ObjectId、时间戳等每次运行都不同。哈希值来自 convertShardKeyToHashed 与聚合的 $toHashedIndexKey;20 万个文档,k 是 0~199999 的整数
    • 没有验证:哈希索引作为分片键时的 chunk 分布(后面单独一篇);字符串、ObjectId、日期等类型的哈希是否也均匀;哈希碰撞的实际概率;复合哈希索引的前缀字段查询;哈希索引占用的磁盘空间对比普通索引
    京ICP备2026067502号-1京公网安备11010502063487号