qinghuan
← 数据系统 / 全部笔记

单元 77 / 数据系统

P1 · 重点提升L2 · 进阶

Redis Cluster:键存在哪里,多键怎样一起操作

先分清分片和副本,再看槽怎样决定请求去向,以及需要一起操作的多个键为何必须提前规划。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能规划同一业务原子操作的键,并解释分片扩容和故障切换的不同作用。

学习理由、准备要求与知识点

为什么学它

集群改变键的路由和多键操作边界,单机正确的 Lua 或事务不能直接假设集群可用。

先读这些内容

知识点清单

  • 16384 哈希槽
  • 节点与分片
  • MOVED 与 ASK
  • 集群客户端
  • 哈希标签
  • 同槽多键操作
  • 迁槽
  • Sentinel 与 Cluster

运行前先确认Redis Cluster 7.4;源码固定 Jedis 5.2.0。Java 17 完整程序仅计算字节哈希与批次槽分组,不建立集群。CLUSTER 命令需要独立 Cluster 环境,普通单机实例不适用。

01先弄懂它是什么

数据或请求太多时,分片把负担分到多个节点;副本则复制数据,并在部分故障时接管,二者用途不同。Redis Cluster 先把键分到 0 至 16383 这些哈希槽,再把槽分配给主节点。增加节点改变的是哪个节点负责哪些槽,不是键本身的槽号,所以客户端要根据槽找节点,不能直接按节点数量给键取模。

需要一起原子处理的多个键,设计时就应安排在同一个槽。例如 Lua 要同时改优惠券库存和购买者集合,这些键必须满足同槽要求。它们目前碰巧在同一台主节点上并不够,因为迁移槽后可能分开;不同槽也不会因为暂时同节点,就满足标准多键操作的同槽限制。

哈希标签用来让需要一起操作的键进入同一个槽,设计时先问:最少有哪些数据必须一起改?例如同一张优惠券的库存和购买者集合,可以按券编号安排。若所有业务都用一个标签,请求和内存就会挤在同一个槽;若只按用户分,又可能把必须一起处理的券库存拆开。要同时考虑哪些操作必须一起完成、流量能否分散,以及以后怎样迁移数据。

文中这几个词,先认一认

哈希槽
Redis Cluster 给键计算的逻辑编号,范围为 0 到 16383,再由这个编号找到负责节点。
哈希标签
键名中符合规则的一段花括号内容,用这部分计算槽,便于让相关键落在一起。
MOVED 与 ASK
MOVED 告诉客户端槽应找哪个节点;ASK 用于迁移期间,让本次请求临时转向另一个节点。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先按实际发送字节理解哈希。使用 CRC-16/XMODEM 后取低十四位得到槽号;不是 Java String.hashCode,也不是对 Unicode 字符逐个直接运算。键包含花括号时,从第一个左括号寻找其后的第一个右括号,只有中间非空才使用标签。foo{}{bar} 使用整个键,不能跳过空标签继续选择 bar。算法中的这些边界会决定应用与服务端是否算出相同路由。

在隔离 Cluster 中执行 CLUSTER KEYSLOT stock:{voucher-7} 与 CLUSTER KEYSLOT buyers:{voucher-7},比较输出,再换成 voucher-8。redis-cli -c 能跟随重定向,应用则应采用 Cluster 客户端并配置可从应用网络到达的节点地址。连接种子节点成功只是启动入口可达,不能证明拓扑返回的所有地址都可达。

迁槽时客户端可能收到 ASK,需要在目标连接发送 ASKING 后重试这次请求,而不是立即永久改写该槽映射;MOVED 则提示更新归属或刷新拓扑。同槽多键在迁移过程中也可能暂时不可执行,因此同槽是必要设计条件,不是任何时刻成功的保证。结果未知的网络超时与明确重定向应分类处理,不能统统盲目重放非幂等命令。

正在加载流程图…

键到槽的计算稳定,槽到节点的映射会随迁移与故障切换变化。
查看流程图源码(Mermaid)
flowchart TD
A["键的实际编码字节"] --> B["选择有效哈希标签或完整键"]
B --> C["CRC16 取低十四位"]
C --> D["查槽到节点映射"]
D --> E["执行请求"]
E --> F["MOVED 刷新映射"]
E --> G["ASK 临时 ASKING 后重试"]

按顺序看,每一步做了什么

  1. 列出需要原子处理的键集合,选择最小业务标签。
  2. 用字节算法和 CLUSTER KEYSLOT 比较同槽关系。
  3. 验证客户端拓扑发现、节点可达与重定向处理。
  4. 在迁槽和故障演练中观察超时、热点及多键失败策略。

03对照实现代码

JedisClusterCRC16 的标签边界与槽掩码 (新标签页)↗

Jedis v5.2.0 · JedisClusterCRC16#getSlot(byte[]);JedisClusterCRC16#getCRC16(byte[],int,int)

下面是原创标签选择教学实现。真实 Jedis 使用 CRC 查表法,完整算法部分改用更易推导的逐位计算;两者不属于 Redis 服务端实现。

核心机制代码 · java
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

class SourceModel {
  static byte[] hashInput(String key) {
    byte[] bytes = key.getBytes(StandardCharsets.UTF_8); // 显式统一网络编码
    int open = -1;
    for (int i = 0; i < bytes.length; i++) {
      if (bytes[i] == '{') { open = i; break; } // 只选择第一个左括号
    }
    if (open >= 0) {
      for (int end = open + 1; end < bytes.length; end++) {
        if (bytes[end] == '}') {
          if (end == open + 1) return bytes; // 空标签直接退回完整键
          return Arrays.copyOfRange(bytes, open + 1, end);
        }
      }
    }
    return bytes; // 缺少配对右括号也使用完整键
  }
}

真实 byte[] 重载记录第一个左括号和随后第一个右括号,仅在两者之间存在字节时选择子区间。该规则不支持任意挑选最后一组括号。

CRC 方法每次结合前一状态和当前无符号字节查表,最终限制为十六位;槽计算再通过 16383 掩码取得十四位。

String 重载先经过标签处理与编码,二进制键则直接处理字节。教学实现固定 UTF-8 字符串入口,不覆盖任意二进制键的输入解析。

这段源码只计算键属于哪个槽,不决定槽由谁负责、故障时选谁为主,也不保证数据保存。槽号算对,只完成了本地路由计算,还不能证明集群请求已成功。

代码先调用谁,再调用谁

  1. 业务键编码
  2. 有效标签选择
  3. getCRC16
  4. 低十四位得到 slot
  5. 客户端查询拓扑并发出命令

04动手写一个例子

逐位 CRC16 与同槽批次分组

对 UTF-8 键按 Redis 标签规则计算槽,将请求按槽分组,并检查一个要求原子的键集合是否同槽。分组只生成执行计划,不会把多个槽变成一个事务。

输入 1
a{123456789}、b{123456789}
输出 1
两个键均落在槽12739
输入 2
foo{}{bar}
输出 2
以完整键计算,不能取bar

先选择参与计算的字节,避免把键名中不生效的花括号误认为标签。

CRC 初值为零,将每个无符号字节放入高八位,逐位左移;最高位为一时异或多项式 0x1021,始终保留十六位。

计算完成后与 0x3fff 做按位与,分组使用槽号作为键。不同标签可能碰撞到同槽,因此判断应比较槽号而不是比较标签字符串。

为什么这样写是对的处理每一位后 crc 始终位于十六位范围,并表示截至该位的 XMODEM 余数;输出槽始终位于零到16383。

时间 O(B)额外空间 O(B+n)

B 为所有键的编码字节总量,每字节固定八步;存储编码或标签副本及 n 个分组引用。若逐键流式处理,临时字节空间可降到最长键长度。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 55 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.nio.charset.StandardCharsets;
import java.util.*;

public class Main {
  static byte[] input(String key) {
    byte[] b = key.getBytes(StandardCharsets.UTF_8);
    int left = -1;
    for (int i=0; i<b.length; i++) if (b[i]=='{') { left=i; break; }
    if (left >= 0) {
      for (int i=left+1; i<b.length; i++) if (b[i]=='}') {
        return i == left+1 ? b : Arrays.copyOfRange(b,left+1,i); // 空标签不继续寻找
      }
    }
    return b;
  }
  static int crc(byte[] bytes) {
    int value = 0;
    for (byte b : bytes) {
      value ^= (b & 0xff) << 8; // byte 有符号,先还原为无符号八位数
      for (int bit=0; bit<8; bit++)
        value = ((value & 0x8000) != 0 ? (value << 1) ^ 0x1021 : value << 1) & 0xffff;
    }
    return value; // 每步掩码保持十六位余数
  }
  static int slot(String key) { return crc(input(key)) & 0x3fff; }
  static Map<Integer,List<String>> group(List<String> keys) {
    Map<Integer,List<String>> result = new TreeMap<>();
    for (String key : keys) result.computeIfAbsent(slot(key), s -> new ArrayList<>()).add(key);
    return result; // 只是独立槽批次,不能据此执行跨槽原子操作
  }
  static int requireSameSlot(List<String> keys) {
    if (keys.isEmpty()) throw new IllegalArgumentException("empty keys");
    int expected = slot(keys.get(0));
    for (String key : keys) if (slot(key) != expected) throw new IllegalArgumentException("CROSSSLOT");
    return expected;
  }
  static void check(boolean ok) { if (!ok) throw new AssertionError(); }
  static String selected(String key) { return new String(input(key), StandardCharsets.UTF_8); }
  public static void main(String[] args) {
    check(crc("123456789".getBytes(StandardCharsets.US_ASCII)) == 0x31c3);
    check(slot("") == 0);
    check(requireSameSlot(List.of("a{123456789}","b{123456789}")) == 12739);
    check(selected("foo{}{bar}").equals("foo{}{bar}"));
    check(selected("foo{{bar}}zap").equals("{bar"));
    check(selected("foo{bar}{zap}").equals("bar"));
    check(selected("foo{bar").equals("foo{bar"));
    check(selected("stock:{优惠券7}").equals("优惠券7"));
    check(group(List.of("a{123456789}","b{123456789}","")).size()==2);
    boolean rejected=false;
    try { requireSameSlot(List.of("a{123456789}","")); }
    catch (IllegalArgumentException expected) { rejected=true; }
    check(rejected);
    System.out.println("10 cluster slot checks passed");
  }
}

这些特殊情况也要试一试

  • 空标签和缺少右括号时使用整个键。
  • 嵌套括号按字节位置处理,不做语法嵌套解析。
  • 槽碰撞正常存在,同槽不表示属于同一业务或具有授权关系。
进阶选读 · 生产拓展:在热点约束下选择标签粒度

场景一张热门券的全部请求集中到一个槽,增加其他主节点也不能分散该原子热点。

做法先度量按标签的请求率和数据体积,再考虑业务分桶与预分配库存;每桶保持局部原子性,跨桶汇总、调拨和防超卖另建协议,不能只在键名追加随机数。

复杂度观测统计随请求量增长;本地分桶路由可为 O(1),跨桶一致性成本由业务协议决定。

好处与代价分桶有助于并行,但会引入库存分配不均和跨桶协调,可能不值得为普通负载采用。

05项目里怎么用

迁到 Cluster 前,列出所有多键命令、Lua、事务和程序拼出的键,逐项计算它们是否同槽。脚本使用的键要按客户端和服务端要求明确传入,别在脚本里临时拼跨槽键。某些客户端能把 MGET 拆成多个请求,但这时各部分不再保证来自同一个瞬间的整体快照。

扩容的工作包含分配槽、迁移数据、验证地址可达及观察流量倾斜。槽数量均匀不代表内存或负载均匀,一个超大键和一个热门标签就可能主导节点成本。迁移大键会影响延迟,应该用实际键体积与负载评估窗口,而不是只按槽数平均分配。

副本可能读到旧数据,异步复制和网络问题也可能让切换后的节点丢掉部分写入。关键业务仍要保留数据库记录、重复请求处理和对账能力,才有办法确认不确定结果。还要设结果查询入口及重试上限,避免请求反复重定向或大量客户端一起刷新节点信息造成新高峰。

容易踩的坑

  • 用 Java hashCode 或节点数量取模代替 Redis 槽算法。
  • 认为同节点就满足同槽原子操作条件。
  • 遇到 ASK 就永久更新槽映射,忽略一次性 ASKING 语义。
  • 把所有业务都放进同一个标签,使集群退化成热点单槽。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
按业务聚合根使用标签容易形成清晰的局部原子范围热门聚合可能集中负载单券、单账户等有明确边界的状态
按槽拆分批量请求可利用多个节点并行没有跨槽原子快照与统一提交允许独立读取或部分失败的批处理
读取副本分担部分读负载需要接受复制延迟和陈旧读取不要求立即读到新值的页面

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01扩容后键的槽号会变化吗?

展开参考答案与推导

相同字节与标签规则下槽号不变,改变的是槽到主节点的映射。

02同槽是否等于同业务?

展开参考答案与推导

不是。哈希有碰撞,不同业务键也可能落同槽;业务边界由应用定义。

03Sentinel 与 Cluster 的主要区别是什么?

展开参考答案与推导

Sentinel 主要处理监控与故障切换,Cluster 还提供基于槽的数据分片;不能将 Sentinel 当成自动分片。

换个情况,还会处理吗

01种子节点能连,但读取某些键失败,先检查什么?

展开参考答案与推导

检查拓扑公布的目标节点地址是否可达、认证是否一致,以及客户端是否支持重定向。

02同槽脚本迁移期间偶发失败,能证明设计错误吗?

展开参考答案与推导

不能直接证明。同槽迁移期间仍有暂时不可用情况,要结合错误类型、迁移状态和有界重试观察。

03十个节点中一个持续高负载,平均槽数正常,怎么办?

展开参考答案与推导

按键和标签检查热点与体积;节点或槽数量均匀不等于请求与内存均匀。

写代码试一试

01foo{}{bar} 使用哪个哈希输入?

输入 / 条件
foo{}{bar}
输出 / 预期
完整键

提示 第一组为空即停止。

展开参考答案与推导

不能继续寻找 bar;返回整个编码字节数组计算 CRC。

02foo{{bar}}zap 使用哪个片段?

输入 / 条件
foo{{bar}}zap
输出 / 预期
{bar

提示 第一个左括号到其后第一个右括号。

展开参考答案与推导

不处理嵌套语义,所以片段包含第二个左括号。

03标准向量123456789的槽号是多少?

输入 / 条件
CRC为0x31C3
输出 / 预期
12739

提示 取低十四位。

展开参考答案与推导

0x31C3 小于0x4000,与0x3fff后不变,十进制为12739。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能规划同一业务原子操作的键,并解释分片扩容和故障切换的不同作用。

本篇重点加练

重定向和同槽约束在迁移期间同时生效,需明确重试何时会涉及业务结果未知。

  • MOVED 与 ASK
  • 同槽多键操作

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    三节点拥有不同槽区间,而一个活动键比其他键热百倍。画出键、槽、节点映射,解释均匀槽数能否代表均匀负载。

    提交内容
    映射图与负载假设
    怎样算完成
    • 保持槽和节点概念独立
    • 包含热点与数据量两种不均衡

    练到的知识点16384 哈希槽 · 节点与分片

  2. 作业 02代码推演

    客户端缓存了旧路由,连续收到 MOVED 与 ASK 两种响应。分别记录后续连接动作和路由表该保存的信息。

    提交内容
    客户端状态图
    怎样算完成
    • 区分临时和持续路由信息
    • 重试有次数与时间上限

    练到的知识点MOVED 与 ASK · 集群客户端

  3. 作业 03设计评审

    购物车结算要同时操作用户购物车和活动库存。设计两套哈希标签方案,评审多键操作适用范围及热点风险。

    提交内容
    键样本与操作矩阵
    怎样算完成
    • 逐键核查槽一致性
    • 不只追求所有键落同槽

    练到的知识点哈希标签 · 同槽多键操作

  4. 作业 04代码推演

    客户端连续得到重定向和连接中断,上一条写命令可能已执行。分别记录路由纠正与业务重试的判断依据。

    提交内容
    请求身份与路由时间线
    怎样算完成
    • 区分尚未发送和已发送未确认
    • 有明确总重试预算

    练到的知识点MOVED 与 ASK

综合应用

  1. 作业 05面试场景

    现有单主多副本部署需要自动切换,团队又希望扩展总数据量。比较 Sentinel 与 Cluster 的目标,并讨论迁槽期客户端需要观察的异常。

    提交内容
    需求映射与迁移风险表
    怎样算完成
    • 区分高可用与分片
    • 注明操作期间的可用性假设

    练到的知识点迁槽 · Sentinel 与 Cluster

  2. 作业 06编程实践

    实现本地 Java 键路由演示器,输入键与节点槽表,输出路由及多键兼容报告;另用本地客户端桩测试重定向次数上限。

    提交内容
    代码、样本与校验记录
    怎样算完成
    • 包含 Unicode 和多组花括号
    • 声明不验证真实迁槽与故障切换

    练到的知识点16384 哈希槽 · 节点与分片 · MOVED 与 ASK · 集群客户端 · 哈希标签 · 同槽多键操作

  3. 作业 07设计评审

    同一事务需要修改一个租户多个键,其中一部分键尚未迁移为统一标签。评审过渡期命名规则及多键操作资格。

    提交内容
    键版本与操作矩阵
    怎样算完成
    • 不能只检查当前所在节点
    • 说明旧键识别和迁移条件

    练到的知识点同槽多键操作

  4. 作业 08编程实践

    写本地客户端桩模拟 MOVED、ASK、断连和跨槽拒绝,对候选多键请求记录路由修正与执行尝试;限制事件序列长度。

    提交内容
    代码和状态报告
    怎样算完成
    • 不把桩响应当真实集群保证
    • 每次重试保留业务身份

    练到的知识点MOVED 与 ASK · 同槽多键操作

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    跨节点数据增长不均且部分客户端仍使用单机连接方式。设计扩容前检查清单和迁移后验收,给出不能通过加节点解决的问题。

    提交内容
    运维评审单
    怎样算完成
    • 包含各节点地址可达性
    • 按槽、键热度及客户端能力分别检查

    练到的知识点节点与分片 · 集群客户端 · 哈希标签 · 同槽多键操作 · 迁槽 · Sentinel 与 Cluster

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    构造所有键此刻都在同一个节点,却仍不足以长期保证多键操作资格的部署变化,说明需要保存哪类稳定映射证据。

    提交内容
    反例映射与变化轨迹
    怎样算完成
    • 区分节点位置和槽关系
    • 避免假设迁槽改变键的哈希规则

    练到的知识点MOVED 与 ASK · 同槽多键操作

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗