记一次集群版 Redis 热点问题

分类: 工作

问题

最近需要实现一个功能,统计单个直播间的 QPS 占全局 QPS 的比例,并在达到一定比例时进行报警。很自然的我选择了 Redis 来作为计数器。使用 incr 命令来进行自增计数,key 设计为 alive::,其中 alive_id 是直播间 ID,ts 是时间戳。

因为我需要对数据进行一定处理计算,所以选用了 Redis Lua 脚本来实现。因为线上使用的是集群版的 Redis ,在使用 lua 时有一定限制,执行过程中所涉及的 Key 需要在同一个 slot 中,否则会报错。开发环境的 Redis 是单机版,所以执行起来没任何问题,并没有注意到这一点。但是在上正式环境后,执行到这部分一直报错。

查阅 redis 文档。关于集群版 Redis 的 slot 是通过 对 key 进行 CRC16 计算后模 16384 的结果来决定的。

在需要指定 key 在同一 slot 的场景,可以使用 {} 来圈定进行 CRC16 的部分,被圈部分右叫 hash tag。例如:{alive:absc}:1234567890 , 那么在 CRC16 计算时,只会计算大括号中的部分:alive:absc。 这样在一致前缀的规则下可以保证有相关 key 可以落在同一 slot 。用已保证 lua 的顺利执行。

在刚接触到这个问题的时候,我选择了 alive 来作为 hash tag,这是一个固定值,不会变得。所以和这相关所有 key CRC16计算的结果都是相同的,都落在了相同的 slot,在相同分片上。

这导致,我们 16 分片的 redis 集群。有一个分片 CPU 使用率直接达到了 90%,而其他分片的 CPU 使用率都只有 10% 左右。一片有难,十五片围观。

解决方案

首先我先去查询资料了解 redis cluster slot 的资料.

redis中slot和节点的对应关系图 10-1.png

hash tag 规则

以下是从官网获取到的 hash tag 规则 ,可以参考官网的文档。 redis cluster doc

hash tag 匹配规则: 为了实现 hash tag, 在某些情况下 key 的 hash slot 计算规则略有不同。如果 key 包含 “{…}” 模式,则仅通过计算 { 和 } 之间的子字符串的散列来确定 hash slot. 然而 { 或 } 可能多次出现,因此具体的计算方式通过一下规则来确定:

  • key 包含了 { 字符.
  • 并且在 { 字符的右边有一个 } 字符.
  • 并且在第一次出现的 { 字符和第一次出现的 } 字符之间有一个或多个字符.

根据以上规则有以下例子可供参照:

  1. 有两个 key 分别是 {user1000}.following 和 {user1000}.followers 他们会落在相同的 slot 下,因为他都有相同的前缀:user1000。
  2. 对于 foo{}{bar} 这个 key, 将会以整个 key foo{}{bar} 进行 hash 计算, 因为首次出现的 { 和 } 字符之间并并没有字符串
  3. 对于 foozap 这个 key 将会对 {bar 进行 hahs 计算, 因为它是第一个 { 和 } 字符之间的字符串。
  4. 对于 foo{bar}{zap} 这个 key 将会对 bar 进行 hash 计算, 因为算法会在第一次有效或无效(比如中间没有任何字节)的匹配到 { 和 } 的时候停止。
  5. 按照这个算法,如果一个键是以 {} 开头的话,那么就当作整个键会被用来计算哈希值。当使用二进制数据做为键名称的时候,这是非常有用的。

hash tag 的匹配实现

unsigned int HASH_SLOT(char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    /* Search the first occurrence of '{'. */
    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    /* No '{' ? Hash the whole key. This is the base case. */
    if (s == keylen) return crc16(key,keylen) & 16383;

    /* '{' found? Check if we have the corresponding '}'. */
    for (e = s+1; e < keylen; e++)
        if (key[e] == '}') break;

    /* No '}' or nothing between {} ? Hash the whole key. */
    if (e == keylen || e == s+1) return crc16(key,keylen) & 16383;

    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 16383;
}

解决办法

我调整了 key 的 hash tag 的规则, 使用 {alive:}: 作为 hash tag, 那么不同的自直播用于计算的 tag 是不一致的,但是相同直播间的不同数据用统一的 {alive:} 前缀是可以落在同一 slot ,可以满足 lua 的使用。

但是如果一个直播间比较热门,依旧会出现一个分片负载比较高的情况。为了避免这种情况,应该避免使用 lua 的情况,进来将逻辑转移到自己的业务代码上,使用 lua 就无可避免会遇到这种情况。

从此延伸我想到的:

集群版的本质除了高可用就是,一个 hash 规则 + 多个单机版。(个人理解)

  1. 集群版可以对一个热 key 加上编号,成为多个缓存数据分散热点
  2. 单机无法承受的热点数据,可以在业务上进行 hash 分散到 redis 上,并结合多个缓存数据的方式,提高热点的分散性

上一篇: 构建 CICD 遇到的 Docker in Docker 问题
下一篇: 连接池设计导致的 Redis Proxy 负载不均衡