2. 哈希分桶原理:一致性哈希、普通哈希在分桶中的应用与区别

分桶这件事,说白了就是把流量或用户均匀地扔进几个桶里。我刚开始做推荐系统时,觉得这不就是个取模吗?后来线上出了几次事故,才发现没那么简单。

今天咱们就聊聊两种主流哈希分桶方式——普通哈希和一致性哈希。它们在实时推荐实验里怎么用?区别在哪?坑在哪?我用自己的踩坑经历给你讲透。

2.1 普通哈希分桶:简单直接,但有硬伤

普通哈希分桶,最常见的做法就是 hash(user_id) % N。N 是桶数。比如你有 10 个桶,用户 1001 的 hash 值对 10 取模,结果就是桶编号。

代码实现很简单:

def normal_hash_bucket(user_id: str, bucket_num: int) -> int:
    hash_val = hash(user_id)
    return hash_val % bucket_num

我在项目中第一次用这个方案时,觉得稳得很。直到有一天,实验组从 5 个桶扩容到 6 个桶——结果所有用户的桶号都变了。原本在实验组的用户跑到了对照组,对照组跑到了实验组。实验数据全废了。

⚠️ 核心问题: 普通哈希在桶数变化时,映射关系会全部打乱。这叫做「雪崩式重映射」。对于实时推荐实验来说,这意味着历史实验数据无法跨版本对比。

为什么会这样?因为取模运算依赖分母 N。N 一变,余数全变。你想想看,一个用户原本在桶 3,N 从 5 变成 6 后,他可能去了桶 0。实验组和对照组的用户群体就混了。

普通哈希适合什么场景?桶数固定不变的场景。比如你确定永远只用 10 个桶做 A/B 测试,那没问题。但现实是,实验规模经常调整,桶数说变就变。

特性 普通哈希 一致性哈希
桶数变化影响 全部重映射 仅影响相邻桶
实现复杂度 低 中
数据迁移量 100% 约 1/N
适合场景 桶数固定 桶数动态变化

2.2 一致性哈希:优雅解决扩容问题

一致性哈希的核心思路,是把哈希值空间组织成一个环。嗯,你可以想象成一个钟表盘,从 0 到 2^32-1 首尾相连。

具体做法是这样的:

  1. 把每个桶(比如实验组 A、B、C)通过哈希函数映射到环上的某个位置
  2. 把每个用户也通过哈希函数映射到环上
  3. 用户沿着环顺时针走,遇到的第一个桶就是他的归属桶

我画了一张图,你看一眼就明白了:

一致性哈希环结构图 桶A 桶B 桶C 桶D 用户1 用户2 用户3 用户顺时针找到第一个桶节点即为归属桶

你看,用户 1 顺时针走,先碰到桶 B,所以归桶 B。用户 2 顺时针走,先碰到桶 D,归桶 D。用户 3 顺时针走,先碰到桶 C,归桶 C。

现在问题来了:如果我要加一个桶 E,会发生什么?

只有桶 E 在环上的位置到前一个桶之间的那部分用户会受影响。其他用户完全不动。这就是一致性哈希最牛的地方——最小化数据迁移。

💡 关键优势: 桶数从 N 变成 N+1 时,只有约 1/N 的用户需要重新分配。对于实时推荐实验来说,这意味着你可以平滑扩缩容,历史实验数据依然有效。

2.3 虚拟节点:解决数据倾斜

一致性哈希有个问题——数据倾斜。如果桶在环上分布不均匀,某些桶会分到特别多的用户,某些桶分得特别少。

我记得有一次线上实验,三个桶,结果一个桶占了 60% 的流量,另外两个各占 20%。实验结论根本没法信。

解决方案是引入虚拟节点。每个物理桶在环上映射多个虚拟位置。比如桶 A 有 100 个虚拟节点,分散在环上各处。这样分布就均匀了。

class ConsistentHash:
    def __init__(self, physical_nodes: list, virtual_nodes_per_node: int = 100):
        self.ring = {}
        self.virtual_nodes_per_node = virtual_nodes_per_node
        
        for node in physical_nodes:
            for i in range(virtual_nodes_per_node):
                virtual_key = f"{node}_{i}"
                hash_val = hash(virtual_key)
                self.ring[hash_val] = node
    
    def get_bucket(self, user_id: str) -> str:
        if not self.ring:
            return None
        hash_val = hash(user_id)
        # 找到环上第一个大于等于 hash_val 的节点
        sorted_keys = sorted(self.ring.keys())
        for key in sorted_keys:
            if hash_val <= key:
                return self.ring[key]
        # 如果没找到,回到环首
        return self.ring[sorted_keys[0]]
🎯 实践经验: 虚拟节点数量建议设为物理节点数的 100~200 倍。太少不均匀,太多浪费内存。我一般用 150 倍,效果不错。

2.4 实时推荐场景下的选择建议

说了这么多,到底怎么选?我给你几个判断标准:

  • 实验桶数固定不变 → 普通哈希就够了,简单高效
  • 实验桶数可能动态调整 → 必须用一致性哈希
  • 流量规模巨大(千万级用户) → 一致性哈希 + 虚拟节点
  • 需要跨版本对比实验数据 → 一致性哈希,保证用户归属稳定

我曾经在一个日活千万的推荐系统里,一开始用了普通哈希。后来产品经理说「加两个实验组看看效果」,我一算,所有用户都得重新分桶,历史数据全废了。那次之后,我所有新项目都默认用一致性哈希。

⚠️ 避坑指南: 我曾经在一致性哈希的实现里忘了处理「环上无节点」的边界情况,结果线上返回了 None,导致推荐服务大面积报错。记得加空环判断。

最后说一句:哈希分桶没有银弹。普通哈希简单但脆弱,一致性哈希灵活但复杂。根据你的实验规模和变更频率来选,别盲目跟风。