Redis 分片为什么会倾斜:从 Key 设计追到哈希函数

用一个可复现的实验拆解 Redis 代理分片中的数据倾斜,分析 Key 结构、FNV-1a 实现与取模路由如何共同影响分布。

把一个大 Hash 拆成一千个小 Key,看起来已经解决了大 Key 问题。如果这一千个 Key 经过代理后只落到少数几个 Redis 节点上,单 Key 的确不大,集群仍会出现明显的内存倾斜。

问题通常不在某一个组件里。业务代码决定 Key 长什么样,哈希函数把字符串变成整数,路由算法再把整数映射到节点。三个环节单独看都合理,组合起来却可能产生很有规律的碰撞。

本文用公开源码和合成数据复现这个现象。文中的 Key、节点数和监控结果都是演示值,不对应任何真实业务或生产环境。

一、先把问题拆成两种倾斜

Redis 集群里常见的“倾斜”至少有两类。

第一类是访问倾斜。少数 Key 承担了大部分请求,形成热 Key。即使数据量不大,节点也可能因为 CPU、网络或命令耗时先到瓶颈。

第二类是容量倾斜。某些节点保存了更多 Key 或更大的 Value,内存使用率明显高于其他节点。本文讨论的是第二类,但排查时需要同时看请求量和容量,否则容易把热度问题误判成分片问题。

假设我们要记录一个主题下大量用户的状态,最直接的写法是把所有字段放进一个 Redis Hash:

metric:demo
  user_1 -> value
  user_2 -> value
  ...

当字段持续增长时,这个 Hash 会变得很大。一个常见改法是根据用户 ID 先算业务分片,再生成多个 Redis Key:

int shard = Math.floorMod(userId.hashCode(), 1000);
String key = "metric:demo:" + shard;

于是一个大 Hash 变成了 metric:demo:0 到 metric:demo:999。每个 Key 内的字段量下降了,单 Key 操作和迁移都更容易控制。

这里很容易产生一个未经验证的假设:业务层已经拆成一千份,代理层自然会把它们均匀分配到所有节点。业务分片数量只是哈希函数的输入数量,最终分布还取决于完整 Key、哈希实现、路由方式和节点数。

一个合成的排障现场

假设某天晚上,监控显示一个 Redis 代理集群的总内存仍有余量,其中两个节点却快速接近容量阈值。其他节点的曲线比较平缓,请求成功率暂时正常。

第一步是止损,而不是立刻证明根因。可以临时扩容异常节点、降低非必要写入,或缩短可丢弃数据的 TTL。止损动作要记录开始时间和影响范围,因为后面判断曲线变化时需要把它与业务流量分开。

容量稳定后,再按节点导出 Key 的聚合统计。异常节点上,大部分增量来自 metric:demo:*,每个 Hash 的字段数量处于正常范围。这个结果排除了“某一个 Hash 无限增长”的直接解释,却出现了新的问题:为什么同一模板的一千个逻辑分片没有散到整个集群?

这时至少有四个候选原因:

  1. 业务分片本身不均匀,某些 shard 承担了更多用户;
  2. 每个 Key 数量相近,Value 大小却差异很大;
  3. 代理路由让大量逻辑 shard 落到了少数节点;
  4. 节点权重、加入退出或配置不一致改变了映射。

可以先统计每个逻辑 shard 的字段数和内存。如果一千个 shard 大致接近,而它们在物理节点上明显聚集,调查重点就从业务数据转到代理分片。反过来,如果路由分布正常、少数 shard 的 Value 特别大,继续研究哈希函数只会浪费时间。

这一步的价值在于把“大 Key”和“Key 分布倾斜”分开。拆分大 Key 解决的是单个数据结构过大,代理分片解决的是多个 Key 如何分配到物理节点。两层问题可以同时存在,也可以各自独立发生。

二、代理分片到底做了什么

以 twemproxy 的 modula 分布方式为例,路由可以简化成两步:

hashValue = hash(key)
nodeIndex = hashValue % nodeCount

写成函数就是:

route(key, nodeCount) = hash(key) mod nodeCount

同一个 Key 在节点数不变时会稳定落到同一节点。节点数发生变化后,取模结果可能整体变化,这也是直接取模扩缩容时数据迁移比例很高的原因。

twemproxy 支持多种哈希函数和分布方式。公开文档列出了 fnv1a_64、fnv1a_32、Murmur、Jenkins 等哈希函数,以及 modula、ketama、random 三种分布方式。讨论分片问题时,只说“用了 Redis 集群”还不够,至少要确认四个参数:

参数 要确认的内容
Key 输入 完整字符串是什么,是否配置了 hash tag
哈希函数 名称、具体实现和返回位数
分布方式 直接取模、哈希环还是其他映射
节点集合 节点数、权重与节点变更方式

这些信息决定了最小复现应该怎样写。直接拿另一种客户端或标准库算 Hash,结果可能与线上代理完全不同。

三、一个名字容易造成误解的实现

twemproxy 源码中有一个名为 hash_fnv1a_64 的函数。看到名字时,很容易认为它会完整计算 64 位 FNV-1a,再返回低 32 位。实际源码里的计算状态是 uint32_t:

static const uint64_t FNV_64_INIT = UINT64_C(0xcbf29ce484222325);
static const uint64_t FNV_64_PRIME = UINT64_C(0x100000001b3);

uint32_t
hash_fnv1a_64(const char *key, size_t key_length)
{
    uint32_t hash = (uint32_t) FNV_64_INIT;

    for (size_t x = 0; x < key_length; x++) {
        uint32_t val = (uint32_t) key[x];
        hash ^= val;
        hash *= (uint32_t) FNV_64_PRIME;
    }

    return hash;
}

64 位 offset basis 截成 32 位后是 0x84222325。64 位 FNV prime 0x100000001b3 截成 32 位后只剩 0x1b3,十进制是 435。函数每轮实际执行的是:

hash = ((hash XOR byte) * 435) mod 2^32

这和 RFC 中定义的标准 64 位 FNV-1a 不同。标准 64 位实现应使用 64 位状态和完整的 0x100000001b3,每轮运算模 2^64。排查时应复刻正在使用的实现,包括整数溢出和类型转换,不能只根据函数名重写一个“看起来正确”的版本。

这个差异也解释了为什么简单的连续后缀可能留下明显的数值结构。固定前缀经过计算后得到同一个中间状态,只有末尾几位数字参与后续少量轮次。乘数又是较小的 435,连续输入产生的哈希值可能出现大量相同或相关的间隔。再做一次取模后,这些规律可能被放大。

这里需要控制结论范围。FNV-1a 按字节迭代,XOR、十进制位数变化和 2^32 溢出都会打断简单的线性关系。不能把全部输出写成严格等差数列。工程上更可靠的办法是复刻源码并测量分布,而不是只凭公式猜结果。

四、写一个与源码兼容的最小实验

下面的 Java 代码按 UTF-8 字节处理 Key,并模拟源码里的 32 位溢出。int 的自然溢出正好对应模 2^32,路由前再把结果转成无符号整数。

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.function.IntFunction;

public final class RedisShardExperiment {
    private static final int INIT = (int) 0xcbf29ce484222325L;
    private static final int PRIME = (int) 0x100000001b3L;

    static long proxyCompatibleHash(String key) {
        int hash = INIT;
        for (byte value : key.getBytes(StandardCharsets.UTF_8)) {
            hash ^= Byte.toUnsignedInt(value);
            hash *= PRIME;
        }
        return Integer.toUnsignedLong(hash);
    }

    static Distribution distribute(
            IntFunction<String> keyFactory,
            int keyCount,
            int nodeCount) {
        int[] buckets = new int[nodeCount];
        for (int shard = 0; shard < keyCount; shard++) {
            String key = keyFactory.apply(shard);
            int node = (int) (proxyCompatibleHash(key) % nodeCount);
            buckets[node]++;
        }
        return Distribution.from(buckets);
    }

    record Distribution(
            int usedNodes,
            int min,
            int max,
            double coefficientOfVariation) {

        static Distribution from(int[] buckets) {
            int used = 0;
            int min = Integer.MAX_VALUE;
            int max = 0;
            double mean = Arrays.stream(buckets).average().orElse(0);
            double squareSum = 0;

            for (int value : buckets) {
                if (value > 0) {
                    used++;
                    min = Math.min(min, value);
                    max = Math.max(max, value);
                }
                double delta = value - mean;
                squareSum += delta * delta;
            }

            double stddev = Math.sqrt(squareSum / buckets.length);
            double cv = mean == 0 ? 0 : stddev / mean;
            return new Distribution(used, min, max, cv);
        }
    }

    public static void main(String[] args) {
        int keyCount = 1000;
        int nodeCount = 60;

        System.out.println("prefix = " + distribute(
                i -> i + ":metric:demo", keyCount, nodeCount));
        System.out.println("middle = " + distribute(
                i -> "metric:" + i + ":demo", keyCount, nodeCount));
        System.out.println("suffix = " + distribute(
                i -> "metric:demo:" + i, keyCount, nodeCount));
    }
}

评估分布时,不要只看“用了多少节点”。至少同时记录:

  • usedNodes:真正收到 Key 的节点数;
  • min 与 max:单节点最少、最多分到多少 Key;
  • 变异系数 CV = 标准差 / 平均值,用于比较不同节点数下的离散程度;
  • Top N 节点占比,用于判断容量是否集中;
  • 如果 Value 大小不一致,还要按字节数重新统计,Key 数均匀不等于内存均匀。

这段代码只需要 Key 模板和节点数,不需要 Redis 进程。它很适合做设计阶段的离线检查,也适合在扩容前估算新的节点集合会怎样改变路由。

先验证复现代码,再相信实验结果

跨语言复现最常见的错误是“算法名字相同,输出却不同”。这里有几个容易踩到的细节:

  • C 的 char 在不同编译器中可能有符号,Java 的 byte 固定是有符号,需要用 Byte.toUnsignedInt 还原字节值;
  • 标准 FNV-1a 64 使用 64 位状态,本文复现的兼容函数使用 32 位状态;
  • Java % 对负数会返回负余数,因此应先转成无符号 long,或使用明确的无符号取模;
  • 非 ASCII Key 必须约定编码。代理处理的是字节流,Java 不能按 UTF-16 的 char 逐个计算;
  • 字符串末尾是否包含分隔符、换行或不可见空格,也会改变结果。

最稳妥的做法是建立 golden vectors。选十几个固定 Key,直接调用代理源码里的 C 函数打印哈希值,再要求 Java 版本逐项相等。测试样本应覆盖空字符串、ASCII、中文、不同长度数字和边界字节。

Map<String, Long> expected = Map.of(
        "", 0x84222325L,
        "metric:demo:0", 0x40c49708L,
        "metric:demo:999", 0xbd062e15L,
        "中文:key:7", 0x8b9452d7L
);

expected.forEach((key, value) ->
        assertEquals(value, proxyCompatibleHash(key)));

上面是本文兼容实现的测试向量。实际项目应由目标代理的构建产物重新生成一份,不能直接抄文章或另一个同名库的结果。golden vectors 通过后,再讨论 Key 位置和节点数才有意义。

五、改变变量位置会发生什么

固定前缀、连续数字后缀是一种结构很强的输入:

metric:demo:0
metric:demo:1
metric:demo:2
...

可以保留相同信息,只移动业务分片的位置:

前缀:0:metric:demo
中缀:metric:0:demo
后缀:metric:demo:0

在 FNV-1a 这类逐字节计算的函数中,越早进入的字节会继续参与后面的多轮混合。变量位于前缀或中缀时,差异会经过更多次 XOR、乘法和溢出;变量放在最后时,差异只经历最后几轮。

使用前面的兼容实现,对一千个合成 Key 和六十个合成节点做实验,可以看到后缀方案只使用了少量节点,少数桶拿走了大量 Key。把分片变量移到前面或中间后,分布明显改善。

为了避免只看一个节点数,我又测试了四组合成配置。表中 used 是收到 Key 的节点数,max 是单节点拿到的最多 Key 数,CV 越大说明分布越离散:

变量位置 节点数 used max CV
前缀 60 60 26 0.214
中缀 60 60 26 0.251
后缀 60 16 200 2.979
前缀 61 61 24 0.279
中缀 61 61 26 0.218
后缀 61 61 22 0.208
前缀 64 64 20 0.154
中缀 64 64 20 0.139
后缀 64 64 24 0.210
前缀 87 87 20 0.236
中缀 87 87 19 0.251
后缀 87 3 890 8.294

同一个后缀模板,在 61 和 64 个节点上看起来正常,换成 60 或 87 就严重倾斜。如果验收只跑一组“表现良好”的节点数,Key 设计会被误判为安全。这个现象也说明,生产扩容不能只验证容量和连接数,新的节点数本身就会改变取模分布。

前缀与中缀方案在这四组实验中都覆盖了全部节点,但它们也不是零风险。CV 仍然随节点数变化,而且实验只统计了每个 Key 的数量。如果各 shard 的 Value 差异很大,容量结果可能与表格不同。

这个结果只适用于给定的 Key 集合、哈希实现和节点数。换一种前缀、改变数字编码方式,或升级代理实现,具体数值都会变化。Key 位置不是一个脱离环境仍然成立的定理,它是成本较低、值得优先验证的设计变量。

还有一个容易忽略的细节:字符串里的分片编号并不是定长编码。9 到 10、99 到 100 会改变字节数。如果希望实验更容易分析,可以同时比较普通十进制与补零后的固定宽度形式:

metric:demo:9
metric:demo:10

metric:demo:0009
metric:demo:0010

补零不保证分布更均匀,但它能减少“位数变化”和“数值变化”混在一起造成的干扰。最终仍应使用真实的序列化规则跑完整样本。

六、为什么某些节点数特别危险

如果一段输入产生的哈希值间隔频繁包含 435 的倍数,而节点数 m 与 435 有较大的公因子,取模后的可达余数可能大幅减少。

可以用理想化的等差数列说明这个机制。设一组哈希值近似为:

h(k) = h(0) + k * g

映射到 m 个节点:

bucket(k) = (h(0) + k * g) mod m

这个序列最多访问:

m / gcd(g, m)

个不同余数。当 g 与 m 互质时,理想序列可以遍历全部余数;公因子越大,可访问的余数越少。例如 g = 435,而 m 含有 3、5 或 29 等因子时,碰撞风险会增加。

真实哈希序列不是严格等差数列,但这个模型能解释一个关键现象:哈希结果看起来很大,并不代表低位和差分已经足够随机。取模只关心余数结构,输入中的规律可能在最后一步重新出现。

素数和 2 的幂为什么有时表现较好

在这组特定实验里,某些素数节点数与 435 互质,因此分布会明显改善。2 的幂也与奇数 435 互质,并且常能得到较完整的余数覆盖。

这不构成通用选型规则。素数节点数不能修复糟糕的哈希输入,2 的幂会直接依赖哈希值的低位质量。换一个哈希函数、Key 模板或数列间隔,原来的优势可能消失。节点数还受到副本、故障域、扩缩容和机器配额影响,不能只为一次实验结果服务。

节点数适合作为诊断变量和短期规避手段。长期方案应减少哈希序列中的结构性相关,并在节点集合变化前自动跑分布测试。

七、排查时怎样避免走弯路

内存倾斜出现后,可以按下面的顺序缩小范围。

1. 先确认是 Key 数倾斜还是 Value 大小倾斜

对每个节点统计 Key 数、内存字节、请求量和大 Key 列表。某节点内存高,可能是 Key 更多,也可能只是几个 Value 特别大。两种原因需要不同修复。

2. 找出占用最高的 Key 模式

不要只截取单个 Key。按前缀、数据结构、TTL 和 Value 大小聚合,观察异常节点上的 Key 是否来自同一套模板。涉及生产数据时,应在受控环境里完成采样与脱敏。

3. 把业务分片与代理分片分开看

业务层的 shard = userId % 1000 只决定生成哪个逻辑 Key。代理还会对完整 Key 再做哈希与路由。画出两层映射后,很多“明明已经打散”的疑问会自然消失:

userId
  ↓ 业务分片
logical shard 0..999
  ↓ 拼接完整 Key
metric:demo:{shard}
  ↓ 代理哈希与分布
Redis node

4. 复刻实际实现

记录代理版本、哈希函数、分布方式、hash tag、节点数和权重。用公开源码或构建产物确认整数类型与溢出行为。Java、Go、Python 和 C 对有符号数、字节转换的处理不同,跨语言复现时要显式对齐。

5. 做单变量实验

每次只改变一项:Key 中变量的位置、编码方式、节点数、哈希函数或分布方式。一次改五个参数,即使分布改善,也无法知道哪项起作用。

6. 用线上样本做最后校验

合成的 0..999 适合解释原理,真实业务可能只使用其中一部分分片,各分片的 Value 大小也不同。最终评估应读取脱敏后的 Key 模板和大小分布,用同一套路由函数重放。

八、修复方案怎样选择

方案一:调整 Key 结构

把变化最大的部分放到 Key 的前部或中部,让差异更早参与逐字节哈希。这个方案通常改动小,也不要求代理集群整体升级。

它会改变 Key 名称,因此需要迁移策略。常见做法是双写新旧 Key、回填存量数据、灰度切读,再停止旧写入。若数据可以自然过期,也可以让旧 Key 依靠 TTL 退出,但要计算过渡期的双份容量。

调整前还要检查同槽需求。有些批量操作或 Lua 脚本要求相关 Key 路由到同一节点,随意移动 hash tag 里的内容可能破坏原子性。

方案二:引入一次额外混合

如果业务 Key 格式受兼容约束,可以先对逻辑分片值做一次质量更好的哈希,再将结果编码进 Key。例如,不直接使用连续的 0..999,而是使用稳定的混合值。

这种方式会降低连续数字带来的规律,但也会牺牲可读性。必须固定算法与编码版本,否则多语言客户端可能生成不同 Key。额外混合仍需要通过代理哈希,不能只看第一层输出均匀就结束测试。

方案三:更换哈希函数或修正实现

从根源上改用经过验证的完整实现,能减少对 Key 排列方式的敏感性。但代理侧变更会影响全部 Key 的路由,升级期间可能触发大规模缓存失效或数据迁移,成本通常高于修改一类业务 Key。

若决定修改,需要准备兼容窗口、双集群或双路由方案,并验证所有客户端对 hash tag 和路由规则的理解一致。不要在没有迁移设计时直接替换哈希函数。

方案四:改变分布层

直接取模实现简单,却对节点数变化敏感。哈希环或固定虚拟槽可以把“Key 到逻辑槽”与“逻辑槽到物理节点”拆开,扩缩容时只调整部分映射。

这能改善可运维性,但不会自动消除坏 Key 模式。如果第一层哈希只命中少量虚拟槽,物理节点仍可能倾斜。虚拟槽数量、槽迁移和节点权重也需要监控。

方案五:把分布测试放进工程流程

最便宜的事故是上线前就能复现的事故。对高基数 Key 模板,可以维护一组离线检查:

  1. 生成代表性的逻辑 Key 样本;
  2. 使用当前代理版本的真实哈希实现;
  3. 在计划中的节点数和权重下计算分布;
  4. 检查最大桶、CV、Top N 占比和空桶数;
  5. 节点变更、代理升级或 Key 模板变化时自动重跑。

阈值需要结合 Value 大小与容量余量制定。每个节点十六七个 Key 看起来很接近,如果其中一个 Key 比其他 Key 大一百倍,内存仍然会失衡。

九、几个看起来有效的错误修复

只给异常节点加内存

扩容能快速解除容量风险,适合作为止损。路由规律没有改变时,新增数据仍会优先落到同一批节点,过一段时间还会再次碰到阈值。止损完成后需要保留事故现场的 Key 分布和配置快照,否则扩容会把最有价值的证据冲淡。

只增加业务分片数

把一千个逻辑 shard 增加到两千个,不一定改善物理分布。如果新 Key 仍采用相同的连续后缀,哈希和取模中的结构可能继续存在。分片数变化还会引入数据迁移、双写和读取兼容成本,实施前应先用目标数量跑离线模拟。

看到素数表现好,就固定使用素数节点数

某个素数与当前主要间隔互质,只能说明它绕开了这一组碰撞。下一种 Key 模板可能有不同规律。节点数又与副本和故障域规划相关,为了哈希实验强行选择不方便部署的拓扑,可能把容量问题换成运维问题。

直接切换一致性哈希

一致性哈希主要减少节点变化时需要重新映射的 Key 数量。它通常通过虚拟节点改善负载分布,却不保证任何输入都均匀。如果 Key 哈希只落入很窄的值域,哈希环上同样会形成局部聚集。切换分布方式前,仍要用真实 Key 样本测试。

只看平均值

集群平均内存为 50%,无法排除某节点已经达到 90%。同样,平均每个节点十六个 Key,也可能隐藏一个节点拿到两百个 Key。分布问题需要看直方图、分位数、最大值和节点间比值。

在线上临时扫描全部 Key

为了定位倾斜直接执行高开销全量扫描,可能给已经紧张的节点增加压力。应优先使用已有统计、渐进式 SCAN、采样或离线 RDB 分析,并限制速率。排障工具本身也要有资源预算。

十、监控应该看什么

只监控集群总内存,会把局部倾斜藏在平均值里。至少应该有以下视图:

维度 建议指标
节点容量 used memory、内存增长率、eviction、碎片率
Key 分布 Key 数、按数据结构统计、Top Key 大小
请求负载 QPS、网络流量、CPU、慢命令、超时
不均衡程度 最大值/平均值、P95/平均值、CV
路由变化 节点加入退出、权重变化、映射版本

报警也应关注节点间差异。例如某节点内存达到固定阈值需要报警,最大节点与中位数的比值持续扩大同样值得报警。后者通常更早暴露倾斜趋势。

修复后的观察窗口不能只覆盖几分钟。容量倾斜可能随业务 Key 的 TTL、回填速度和访问峰值逐渐显现。应同时观察新旧 Key 占比、节点增长斜率和过渡期额外容量。

十一、从这次实验能带走什么

业务分片、哈希函数和节点路由是一条连续的数据路径。业务代码生成的一千个 Key,不会自动变成一千份均匀的数据。只要哈希输出保留了输入规律,最后一次取模就可能把规律放大。

排查这类问题最有效的方法很朴素:拿到真实配置,复刻真实实现,构造最小样本,然后一次只改一个变量。源码里的类型转换、Key 中一个字段的位置、节点数的因子,都可能比“哈希算法名称”更有解释力。

设计 Redis Key 时,我会额外检查四件事:

  • 高基数变量是否充分参与了哈希计算;
  • 业务分片后的 Key 在真实代理中是否均匀;
  • 扩缩容后的节点集合是否重新做过分布模拟;
  • 监控能否看到节点间差异,而不只是集群平均值。

这些检查无法替代线上监控,但能把一部分容量倾斜从事故排查变成上线前的普通测试。

公开资料