图 1:本文封面,点明主题「槽位分片」。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「用过 Redis 集群吗,原理是什么」是缓存方向的高频题,答题的分水岭在于:你是背了「16384 个槽」这个数字,还是讲得出槽位、路由、通信、故障转移这条完整链路。
场景很熟悉:缓存数据涨到单机内存装不下,QPS 也高到单节点扛不住,主从复制解决了可用性,却解决不了容量——所有数据在每个节点都有一份。要水平扩展,必须分片:把数据拆开,不同节点管不同的部分。Redis Cluster 就是官方的分片方案,而理解它的钥匙就是那 16384 个槽。
第一层,槽位分片:key 怎么找到家。
Redis Cluster 把整个 key 空间切成 16384 个哈希槽。每个 key 先算 CRC16 再对 16384 取模,得到槽号;每个主节点负责一段连续的槽,比如三主节点集群:A 管 0 到 5460,B 管 5461 到 10922,C 管 10923 到 16383。客户端拿着槽到节点的映射表,直接连对应节点读写。
为什么取模对象是槽而不是节点?这是整个设计最妙的一笔。**节点会变,槽位表不变。**扩容加节点,不用重哈希全部数据,只需把一部分槽连数据一起搬到新节点——槽(而不是 key)是迁移与扩容的最小单位。搬完更新映射,客户端下次自然走到新节点。
第二层,请求路由:客户端找错门怎么办。
槽在迁移过程中,客户端的映射表可能是旧的,请求打到错误的节点。此时节点回一个 MOVED 重定向,告诉客户端「这个槽现在归谁管」,客户端更新映射表再去新节点。这是无中心设计:没有代理层,重定向由节点之间直接完成。
第三层,节点通信:Gossip 协议维持集群视图。
所有节点通过集群总线两两相连,用 Gossip 协议互发心跳,交换「谁活着、各管哪些槽」这类状态。没有中心节点,意味着没有单点;心跳包里要携带槽位信息,这也是为什么槽位数定为 16384 的考量之一:16384 个槽用 2KB 位图就能完整表示,心跳包保持小巧;同时对常见集群规模来说分片粒度足够细。太大浪费带宽,太碎又增加管理负担,16384 是工程权衡的结果。
第四层,故障转移:主节点挂了谁顶上。
每个主节点带从节点。主节点失联后,先被标记为主观下线;当多数主节点都确认它联系不上,升级为客观下线,它的从节点发起选举,获得多数主节点支持后升主,接管全部槽位,集群继续服务。
实测:槽位到底怎么算。
下面实现官方规范给出的 CRC16-XMODEM 算法,算几个 key 的槽位与归属节点,并演示哈希标签,Node.js 22.15.0 直接运行(node redis-slot.js):
'use strict';
// CRC16-XMODEM:与 Redis Cluster 规范附录给出的算法一致
function crc16(str) {
let crc = 0;
for (const byte of Buffer.from(str, 'utf8')) {
crc ^= byte << 8;
for (let i = 0; i < 8; i++) {
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
}
}
return crc;
}
const SLOT_COUNT = 16384;
const slotOf = (key) => {
// 哈希标签:key 含 {} 时只对花括号内的部分算哈希
const match = key.match(/\{([^}]+)\}/);
return crc16(match ? match[1] : key) % SLOT_COUNT;
};
// 自检:XMODEM 对 "123456789" 的校验值应为 0x31C3
console.log('CRC16 自检(应为 0x31c3):', '0x' + crc16('123456789').toString(16));
// 三主节点集群的槽位划分
const nodes = [
{ name: '节点 A', start: 0, end: 5460 },
{ name: '节点 B', start: 5461, end: 10922 },
{ name: '节点 C', start: 10923, end: 16383 },
];
const route = (key) => {
const slot = slotOf(key);
const node = nodes.find((n) => slot >= n.start && slot <= n.end);
return `${key} -> 槽 ${slot} -> ${node.name}`;
};
for (const key of ['user:1001', 'user:1002', 'order:88', 'session:abc', 'cart:9527']) {
console.log(route(key));
}
// 哈希标签:让相关 key 落到同一个槽(多 key 操作的前提)
console.log(route('{user:1001}.profile'));
console.log(route('{user:1001}.orders'));
console.log('普通 key user:1001 的槽位:', slotOf('user:1001'));
实测输出:
CRC16 自检(应为 0x31c3): 0x31c3
user:1001 -> 槽 5712 -> 节点 B
user:1002 -> 槽 9779 -> 节点 B
order:88 -> 槽 1492 -> 节点 A
session:abc -> 槽 14788 -> 节点 C
cart:9527 -> 槽 9853 -> 节点 B
{user:1001}.profile -> 槽 5712 -> 节点 B
{user:1001}.orders -> 槽 5712 -> 节点 B
普通 key user:1001 的槽位: 5712
注意最后三行:{user:1001}.profile 和 {user:1001}.orders 只对花括号内算哈希,落在同一个槽——哈希标签是把相关 key 钉在同一节点的手段,因为集群里跨槽的多 key 操作(MGET、事务)是不支持的,只有同槽才能一起操作。
图 2:槽位分片、请求路由、Gossip 通信与故障转移。
可能的追问与易错点。
「和一致性哈希有什么区别?」一致性哈希把节点和 key 都映射到环上,扩容时只迁移相邻节点的一段;槽位方案把映射显式存成表,迁移精确到槽,但表本身要靠 Gossip 维护。「为什么不用 CRC16 的全部 65536 个值?」65536 个槽需要 8KB 位图,心跳包体积翻四倍,而常见集群节点数远用不满这么细的粒度,16384 是带宽与粒度的平衡点。「集群模式有什么限制?」只支持 0 号数据库,跨槽多 key 操作受限,扩容期间部分多 key 操作会短暂不可用——这些代价要答得出来。
回到面试现场,建议的答题主线:单机瓶颈引出分片需求,槽位是解法的核心概念,然后沿「key 怎么定位(CRC16 取模)→ 客户端怎么路由(映射表加 MOVED)→ 节点怎么通信(Gossip)→ 故障怎么转移(选举升主)」走完整链路,最后用「为什么是 16384」收尾加分。
你的缓存架构走到分片这一步了吗?扩缩容时搬槽的坑,欢迎分享你的经历。