ElIauk

Redis Cluster 与一致性哈希

Redis Cluster 分的是槽,不是环 面试里经常把 Redis Cluster 说成“一致性哈希”,其实两者机制完全不同。一致性哈希是把节点和 key 映射到一个环上,节点增减只影响相邻区段,靠虚拟节点均衡;Redis Cluster 用的是固定的 16384 个哈希槽,CRC16(key)

评论
Redis Cluster 与一致性哈希

Redis Cluster 分的是槽,不是环

面试里经常把 Redis Cluster 说成“一致性哈希”,其实两者机制完全不同。一致性哈希是把节点和 key 映射到一个环上,节点增减只影响相邻区段,靠虚拟节点均衡;Redis Cluster 用的是固定的 16384 个哈希槽,CRC16(key) mod 16384 决定槽号,槽再分配给节点。节点变化时迁移的是“槽”,不是“环上的区间”。

这个区别有实际影响:一致性哈希下新增节点会自动分担一部分 key;Redis Cluster 下新节点加入后如果没做 reshard,它一个槽都没有,流量不会自动过去。很多人扩容完发现新节点 CPU 为 0,就是这个原因。

16384 这个数字也不是随便定的:槽位图用位图表示,16384 位正好 2KB,节点间 gossip 交换槽信息的开销可控;再大就会让心跳包变重。

MOVED、ASK 与客户端拓扑

访问一个不在当前节点上的 key,会收到两种重定向:

  • MOVED:这个槽永久归另一个节点,客户端应该刷新槽表后重试;
  • ASK:这个槽正在迁移,这个 key 已经搬到目标节点,本次请求要临时改道,但不能刷新槽表。

客户端的差别就在这里。只支持 MOVED 的客户端遇到迁移会不断重试原节点,表现为迁移期间大量超时;支持 ASK 的客户端才能做到平滑迁移。Lettuce 和 Jedis 4.x 之后都支持,但拓扑刷新的触发策略要配:

spring:
  data:
    redis:
      cluster:
        nodes:
          - 10.0.3.21:7000
          - 10.0.3.22:7000
          - 10.0.3.23:7000
        max-redirects: 3
      lettuce:
        cluster:
          refresh:
            adaptive: true
            period: 30s

adaptive 让客户端在收到重定向后主动刷新拓扑,比固定周期轮询更及时。max-redirects 设太大没用,正常的重定向一次就够,超过 3 次说明拓扑已经乱了。

批量操作与 hash tag

MGET、MSET、SUNION 这类多 key 命令要求所有 key 在同一个槽,否则直接报 CROSSSLOT Keys in request don't hash to the same slot。这不是 Bug,是分片系统的必然。

解法是 hash tag:用 {} 包住决定分片的部分,只有 {} 里的内容参与计算。

redis-cli -c -h 10.0.3.21 -p 7000 mget "{order:10086}:items" "{order:10086}:total"
redis-cli -c -h 10.0.3.21 -p 7000 cluster keyslot "{order:10086}:items"

用 Java 计算槽号时,要注意 hash tag 的规则是“取第一个 { 到其后第一个 } 之间的内容,且中间不能为空”:

public final class RedisSlots {

    private static final int SLOT_COUNT = 16384;

    private RedisSlots() {
    }

    public static int slotOf(String key) {
        Objects.requireNonNull(key, "key 不能为空");
        return crc16(hashTag(key)) & (SLOT_COUNT - 1);
    }

    private static String hashTag(String key) {
        int start = key.indexOf('{');
        if (start < 0) {
            return key;
        }
        int end = key.indexOf('}', start + 1);
        if (end <= start + 1) {
            return key;
        }
        return key.substring(start + 1, end);
    }

    private static int crc16(String value) {
        int crc = 0;
        for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
            crc ^= (b & 0xFF) << 8;
            for (int i = 0; i < 8; i++) {
                crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1;
                crc &= 0xFFFF;
            }
        }
        return crc;
    }
}

hash tag 用过头会带来反效果:所有订单都写成 {order} 前缀,等于把整个集群退化成单分片,热点全压在一个节点上。tag 的粒度应该跟着“必须一起原子访问的数据”走,而不是图省事。

扩容与迁移

扩容的标准动作是加节点、reshard、再均衡:

redis-cli --cluster check 10.0.3.21:7000
redis-cli --cluster add-node 10.0.3.24:7000 10.0.3.21:7000
redis-cli --cluster reshard 10.0.3.21:7000 \
  --cluster-from 3f8a1e2b9c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f \
  --cluster-to 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b \
  --cluster-slots 2000 --cluster-yes

迁移是以槽为单位、逐个 key 搬的。大 key(几 MB 的 Hash、几十万元素的 ZSet)会把迁移线程卡住,期间该槽的请求要么阻塞要么重定向,表现为毛刺。上线前用 --bigkeys 扫一遍,把大 key 拆掉,比事后优化迁移参数有效。

常见坑

扩容后不做 reshard。 新节点空转,老节点继续扛全部流量,集群看起来变大了,容量没变。

把一致性哈希的客户端混进来。 有的客户端自己做分片(一致性哈希),又连到 Redis Cluster 前面,双重分片导致数据找不到,且报错信息非常隐晦。

忽略 cluster-require-full-coverage。 设为 yes 时任何一个槽不可用整个集群就拒绝写入,虽然“看起来安全”,但一个节点故障会引发全站写失败。生产上通常设为 no,靠客户端容忍部分错误,同时把 min-replicas-to-write 打开,避免主从脱节时继续写。

从节点读。 Redis Cluster 默认不从从节点读,READONLY 需要在连接上显式开启,且读到的是异步复制数据。对一致性敏感的场景不要开。

跨槽事务。 MULTI 里的命令必须同槽,跨槽直接报错。分布式事务应该在上层用补偿解决,而不是指望 Redis。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者