[redis学习笔记]redis字典rehash机制导致数据淘汰分析

在redis中,如果在开启了maxmemory,且在内存使用量接近maxmemory时,刚好出现哈希表的rehash过程,会导致redis内存瞬间突增,内存使用量超过maxmemory,同时如果没有开启自动扩容且开启了淘汰策略的情况下,会导致redis中数据被淘汰。

案例

线上某个redis集群所有节点突然陆陆续续出现内存突增,瞬间增长1G左右,业务方开发却说业务没有这么大的写入数据,且监控显示key数量、流入流量均比较正常,没有猛增,部分节点出现大量key淘汰。使用内存和key淘汰监控图表如下所示

在无意中重新温习美团的一篇文章时,验证了该现象由redis rehash机制重新分配一个新的更大的哈希表用于重哈希数据,导致内存突增,内存因为rehash增长后使用量超过了节点的maxmemory,且该集群未开启自动扩容,但开启了驱逐策略,所以导致节点淘汰了大量数据。当时节点中key的数量刚好为33554432左右,处于rehash阈值范围,所以原因得到了验证。由于及时发现,所以运维及时调整了集群所有节点的maxmemory,所以只有最先触发rehash的几个节点有数据被淘汰,其余节点幸免了。

分析

《[redis学习笔记]redis渐进式rehash机制》一文中详细介绍了redis渐进式rehash机制,从该文章中我们可以看到,当Redis触发Resize后,就会动态分配一块内存,最终由ht[1].table指向,动态分配的内存大小为:realsize*sizeof(dictEntry*),table指向dictEntry*的一个指针,大小为8bytes(64位OS),即ht[1].table需分配的内存大小为:8*2*2^n (n大于等于2)。

梳理一下哈希表大小和内存申请大小的对应关系:

当key数量达到33554432时,会触发哈希表rehash,分配一个512M的新哈希表;为什么线上内存增长了1G左右,有点没有想明白,待继续研究。

现象复现

测试环境找一个节点(由于我们默认创建的都是集群模式,所以需要打开配置文件关闭集群模式,重启节点,否则会出现数据写入丢失的错觉,因为集群模式如果key不属于节点所负责的槽,会写入失败),先写入8300000个key,我们通过info stats和debug htstats 0命令看一下此时的节点统计数据。

从图中可以看到此时evicted_keys为0,没有驱逐数据,哈希表中key的数量为8300000,哈希表的大小为8388608,而且通过info memory命令查看当前的内存使用量为771M,对照上面的表格,下一次rehash需要分配的新哈希表大小为128M,所以此时通过config set maxmemeory命令设置maxmemory为900M(943718400)。

然后继续写入90000个数据,此时我们再次查看节点的状态。首先需要明确一下,这90000个数据是不足以导致内存超过900M的。

通过debug htstats 0命令可以看到节点正在扩容,分配了一个新的哈希表,新的哈希表大小为16777216,通过info stats命令可以看到有大量数据被淘汰了。看一下内存增长监控图表:

可以看到内存从771M瞬间增长到了900M,一段时间后,又降回了835M,因为rehash完成后,会释放原有的哈希表,所以该现象与线上完全一致。而且内存增长的大小128M符合上一节列出的表格对应关系。

我们可以得出结论:
当Redis 节点中的Key总量到达临界点后,Redis就会触发Dict的扩展,进行Rehash。申请扩展后相应的内存空间大小。如果刚好rehash时扩展后的内存空间超过maxmemory值,会导致数据驱逐淘汰。

Redis Rehash机制优化

那么针对在Redis满容驱逐状态下,如何避免因Rehash而导致Redis抖动的这种问题。

  • 在Redis Rehash源码实现的逻辑上,加上了一个判断条件,如果现有的剩余内存不够触发Rehash操作所需申请的内存大小,即不进行Resize操作;
  • 通过提前运营进行规避,比如容量预估时将Rehash占用的内存考虑在内,或者开启自动扩容,或者适当调低内存告警阈值,可以及时增加内存。

在dictExpand函数中,分配内存以前,增加如下判断:

if (server.maxmemory) {
    size_t mem_used = zmalloc_used_memory();
    size_t overhead = freeMemoryGetNotCountedMemory();
    mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
    mem_used += (realsize* sizeof(dictEntry*));
    if (mem_used >= server.maxmemory) return DICT_ERR;
    if (mem_used >= (server.maxmemory * 0.9)) return DICT_ERR;
}

增加优化代码后,再次测试,依然是先导入8300000个key,根据info memory返回的内存使用量+128M,设置一个合理的maxmemory,再次导入90000个key。测试结果如下图所示:

从图中可以看到,此时的key数量已经为8390000,而哈希表大小依然是8388608,key数量超过哈希表大小,但是没有发生rehash过程。因为内存没有超过maxmemory,所以通过info stats命令查看到evicted_keys为0,没有发生数据淘汰。

参考资料

附测试用例代码

public static void main(String[] args) {
    JedisPoolConfig config = new JedisPoolConfig();
    config.setMaxTotal(100);
    config.setMinIdle(100);
    config.setMinIdle(20);

    JedisPool pool = new JedisPool(config, "172.25.61.70", 8212);


    ExecutorService executorService = Executors.newFixedThreadPool(20);
    final AtomicInteger aa = new AtomicInteger();
    for (int j = 0; j < 80; j++) {
        executorService.submit(() -> {
            int counter = aa.addAndGet(1);
            int incr = 0;
            String threadName = Thread.currentThread().getName();

            Jedis jedis = pool.getResource();
            Pipeline pipeline = jedis.pipelined();
            for (int m = 0; m < 100; m++) {
                for (int i = 0; i < 1000; i++) {
                    incr++;
                    pipeline.set("key" + counter + threadName + RandomUtil.getRandomString(10) + "a" + incr, "value" + i);
                }
                pipeline.sync();
            }
            try {
                pipeline.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }

    try {
        TimeUnit.HOURS.sleep(24);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

RandomUtil代码:

import java.util.Random;

public class RandomUtil {
    private static int DEFAULT_STRING_LENGTH = 100;
    private static String BASE = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,./?<>:\"|';][{}+_)(*&^%$#@!~`0123456789";

    protected static Random random = new Random();

    public static String getRandomString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            int number = random.nextInt(BASE.length());
            sb.append(BASE.charAt(number));
        }
        return sb.toString();
    }

    public static String getRandomString() {
        return getRandomString(DEFAULT_STRING_LENGTH);
    }

    @Deprecated
    public static String getRamdomString() {
        return getRandomString(DEFAULT_STRING_LENGTH);
    }

    @Deprecated
    public static String getRamdomString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            int number = random.nextInt(BASE.length());
            sb.append(BASE.charAt(number));
        }
        return sb.toString();
    }
}