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 无限增长”的直接解释,却出现了新的问题:为什么同一模板的一千个逻辑分片没有散到整个集群?
这时至少有四个候选原因:
- 业务分片本身不均匀,某些 shard 承担了更多用户;
- 每个 Key 数量相近,Value 大小却差异很大;
- 代理路由让大量逻辑 shard 落到了少数节点;
- 节点权重、加入退出或配置不一致改变了映射。
可以先统计每个逻辑 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 模板,可以维护一组离线检查:
- 生成代表性的逻辑 Key 样本;
- 使用当前代理版本的真实哈希实现;
- 在计划中的节点数和权重下计算分布;
- 检查最大桶、CV、Top N 占比和空桶数;
- 节点变更、代理升级或 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 在真实代理中是否均匀;
- 扩缩容后的节点集合是否重新做过分布模拟;
- 监控能否看到节点间差异,而不只是集群平均值。
这些检查无法替代线上监控,但能把一部分容量倾斜从事故排查变成上线前的普通测试。
公开资料
如果这篇文章对你有帮助