Redis · 从库TTL问题深入剖析及版本迭代对比


背景

最近有项目找过来说自己的一台主从版实例(一主多从),设置了读写分离。 当在从库进行读请求时,发现本应该过期的数据仍然可以读到,影响到了业务的功能。于是针对于redis各个版本的从库TTL功能,进行了跟进和源码剖析

说明:

  • 用户实例是一台一主多从的实例,配置了读写分离

  • redis 版本为: 2.8.23

问题现象

image.png
  • key 设置 ttl 时间, ttl 时间到期后,从库上查看 ttl,发现 ttl=0,并且数据可以读取到
  • 访问 master 节点的 key,会发现 master 节点数据已过期,再去查询 slave 节点,发现 key 也过期了。

问题分析

初步猜测

redis key 的过期键删除策略有关

redis 的过期键删除策略为惰性删除和定期删除。
惰性删除意思是当 key 被访问到时,如果判断 key 已经过期了,会进行 key 的删除操作
定期删除是指 redis 每个一段时间会对不同 db 的 key 进行扫描,判断到扫描到的 key 过期后会进行 key 的删除。 目前 redis 会每秒钟执行 10 次扫描和删除,每次扫描 20 个 key。
如果过期 key 还没被清理掉的话,key 应该还会存在数据库里

ttl 值的含义

ttl > 0 表示 key 未过期
ttl = 0 表示 key 已经过期
ttl = -1 表示无过期时间
ttl = -2 表示 key 已经不存在

项目这里的从库 ttl 一直为 0,表示实际上 key 已经过期了,但是没有被删除,一直存在。
惰性删除和定期删除对从库不生效

master 节点会进行主动对 ttl 过期键的删除, slave 节点不会主动进行对 ttl 过期键的删除

当访问 master 上的过期 key 时,触发了惰性删除,此时删除命令同步到了 slave 节点,再去访问 slave 节点会发现,key 也过期了。

源码分析

所有源码只保留需要的部分,

redis 惰性删除策略

由 db.c/expireIfNeeded 函数实现, 所有读写数据库的 redis 命令在执行之前都会调用 expireIfNeeded 函数对输入键进行检查:

当查询 key 时,流程为
lookupKeyRead -> expireIfNeeded

  • 如果 输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除
  • 如果 输入键未过期,expireIfNeeded 不做动作

但是看到 expireIfNeeded 里有一段判断,if (server.masterhost != NULL) return now > when;, 如果是 slave 节点,直接返回了,不进行后续的删除操作。 这里解释了为什么主动访问 slave 节点时,也没有触发过期 key 删除策略。

  • lookupKeyRead:
robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;

    expireIfNeeded(db,key); //判断key是否过期
    val = lookupKey(db,key); //查询key的value
    if (val == NULL)
        server.stat_keyspace_misses++;
    else
        server.stat_keyspace_hits++;
    return val;
}
  • expireIfNeeded:
int expireIfNeeded(redisDb *db, robj *key) {
    mstime_t when = getExpire(db,key);
    mstime_t now;

    if (when < 0) return 0; /* No expire for this key */

    /* server在loading数据时,不对任何数据进行过期操作 */
    if (server.loading) return 0;

    ...

    /*
     * 如果是在从库上执行命令,返回bool(now > when), 即过期了返回1,没过期返回0,
     * 但是不对key做任何处理,等待master 的DEL命令同步过来 */
    if (server.masterhost != NULL) return now > when;

    /* 没过期返回0 */
    if (now <= when) return 0;

    /* 过期了,对过期的key进行删除操作,这里就是所谓的惰性删除 */
    server.stat_expiredkeys++;
    propagateExpire(db,key);
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);
    return dbDelete(db,key);
}

redis 定期删除策略

由 redis.c/activeExpireCycle 函数实现, 每当 redis 的服务器周期性操作 redis.c/serverCron 函数执行时, activeExpireCycle 函数就会被调用,在规定时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。

说明:

这里的源码分析是重点, 整个流程为
serverCron -> databasesCron -> activeExpireCycle -> activeExpireCycleTryExpire
可以整个定期的任务由 serverCron 发起, 具体做什么操作由 databasesCron 来定义, 最后实际清除过期 key 由 activeExpireCycleTryExpire 来完成。
我们看 databaseCron 的源码里有这样的逻辑,server.active_expire_enabled && server.masterhost == NULL, 只有当 server 开启了 expire 定期清理并且 server.masterhost==NULL 表示是 master 的时候才会发起 activeExpireCycle 的定期任务, 这里就解释了为什么从库的过期 key 不会被定期任务清理掉。
顺便可以看到 serverCron 的执行频次,1000/server.hz, 我们当前 hz 为 10 表示每秒执行 10 次, 100ms 执行一次

  • serverCron:
/* serverCron定期任务,每秒钟会执行server.hz次,这其中包含了多个任务,如
 * - Active expired keys collection (it is also performed in a lazy way on
 *   lookup). 清理任务
 * - Software watchdog.
 * - Update some statistic.
 * - Incremental rehashing of the DBs hash tables.
 * - Triggering BGSAVE / AOF rewrite, and handling of terminated children.
 * - Clients timeout of different kinds.
 * - Replication reconnection.
 * - Many more...
 *
 * 这里的任务都会每秒钟被调用server.hz次
 */

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    int j;
    REDIS_NOTUSED(eventLoop);
    REDIS_NOTUSED(id);
    REDIS_NOTUSED(clientData);

    ...

    /* We need to do a few operations on clients asynchronously. */
    clientsCron();

    /* redis 执行backgroud的任务. */
    databasesCron();

    ...

    server.cronloops++;
    return 1000/server.hz;
}
  • databasesCron:
void databasesCron(void) {
    /*  随机进行key过期, 如果是slave的话,这里不执行操作,只是等待master的dels命令同步过来 */
    if (server.active_expire_enabled && server.masterhost == NULL)
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
        ...
}
  • activeExpireCycle:
void activeExpireCycle(int type) {

            ...

            expired = 0;
            ttl_sum = 0;
            ttl_samples = 0;

            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            while (num--) {
                dictEntry *de;
                long long ttl;

                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                ttl = dictGetSignedIntegerVal(de)-now;
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                ttl_sum += ttl;
                ttl_samples++;
            }
}
  • activeExpireCycleTryExpire:
/* 定期删除key的实际逻辑 */
int activeExpireCycleTryExpire(redisDb *db, struct dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key,sdslen(key));

        propagateExpire(db,keyobj);
        dbDelete(db,keyobj);
        notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
            "expired",keyobj,db->id);
        decrRefCount(keyobj);
        server.stat_expiredkeys++;
        return 1;
    } else {
        return 0;
    }
}

设置过期时间

expire 命令由 db.c/expireGenericCommand 实现。 expireGenericCommandset 调用 setExpire 函数,将 key 加入 redisDb 对象的 expires 字典,同时设置过期时间 value。

/*-----------------------------------------------------------------------------
 * Expires Commands
 *----------------------------------------------------------------------------*/

/* 这个命令用来设置key的过期时间,可以被 EXPIRE, PEXPIRE, EXPIREAT PEXPIREAT等命令使用.  */
void expireGenericCommand(redisClient *c, long long basetime, int unit) {
    robj *key = c->argv[1], *param = c->argv[2];
    long long when; /* unix time in milliseconds when the key will expire. */

    if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK)
        return;

    if (unit == UNIT_SECONDS) when *= 1000;
    when += basetime;

    /* No key, return zero. */
    if (lookupKeyRead(c->db,key) == NULL) {
        addReply(c,shared.czero);
        return;
    }

    /* EXPIRE with negative TTL, or EXPIREAT with a timestamp into the past
     * should never be executed as a DEL when load the AOF or in the context
     * of a slave instance.
     *
     * Instead we take the other branch of the IF statement setting an expire
     * (possibly in the past) and wait for an explicit DEL from the master. */
    if (when <= mstime() && !server.loading && !server.masterhost) {
        robj *aux;

        redisAssertWithInfo(c,key,dbDelete(c->db,key));
        server.dirty++;

        /* Replicate/AOF this as an explicit DEL. */
        aux = createStringObject("DEL",3);
        rewriteClientCommandVector(c,2,aux,key);
        decrRefCount(aux);
        signalModifiedKey(c->db,key);
        notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id);
        addReply(c, shared.cone);
        return;
    } else {
        setExpire(c->db,key,when);
        addReply(c,shared.cone);
        signalModifiedKey(c->db,key);
        notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id);
        server.dirty++;
        return;
    }
}

查看键过期时间 TTL 命令

由 db.c/ttlGenericCommand 函数实现。

执行 TTL 命令时,默认先进行一下 key 读取, lookupKeyRead, 如果返回为 NULL,认为已经过期,TTL 返回-2
否则 key 存在的话,若 key 没有 ttl 返回-1
若 key 有 ttl,还未过期,返回 key 的剩余时间

void ttlGenericCommand(redisClient *c, int output_ms) {
    long long expire, ttl = -1;

    /* 如果key不存在,返回 -2 */
    if (lookupKeyRead(c->db,c->argv[1]) == NULL) {
        addReplyLongLong(c,-2);
        return;
    }
    /* key如果存在. 没过期时间时返回-1,有过期时间时返回具体的TTL value */
    expire = getExpire(c->db,c->argv[1]);
    if (expire != -1) {
        ttl = expire-mstime();
        if (ttl < 0) ttl = 0;
    }
    if (ttl == -1) {
        addReplyLongLong(c,-1);
    } else {
        addReplyLongLong(c,output_ms ? ttl : ((ttl+500)/1000));
    }
}

总结

通过阅读 redis2.8.23 的相关源码,验证了问题的猜想,

  • 针对于 2.8.23 版本,惰性删除和定期删除对从库不生效, 同时访问从库的过期 key 也不会触发过期 key 的删除。

  • 如果 master 的定期清理任务不重的话,过期的 key 可以很快在 master 节点上被清除,然后会将一条 del 命令同步到 slave 上, 将 slave 上的过期 key 删除。 如果 master 定期清理任务比较繁重,或者刚好没有清理到某个过期 key,就会发生了业务出现的这种问题。 (之前拿某个 redis 做实验,发现 slave 会自动删除过期 key,后来验证是因为 master 清理的比较快,及时同步到了 slave,可以通过在 slave 上执行 monitor 进行观察)

其他版本的对比

expireIfNeeded. databaseCron
关于过期 key 清理的问题,我对比了 2.8.23 版本、3.0 版本、3.2 版本、4.0 版本、5.0 版本的代码变更,由于篇幅的原因我这里直接给出各个版本做出的变化和调整:

3.0 版本:

和 2.8.23 与 2.8 版本存在同样的问题

  • 惰性删除策略 无变化
  • 定期删除策略 无变化

3.2 版本:

3.2 版本相对于 3.0 版本, redis.c 改成了 server.c,

  • 定期删除策略, server.c/databasesCron 相比 3.0 版本 redis.c/databasesCron 没变化,只在 master 开启
  • 惰性删除策略, lookupKeyRead 进行了优化, 当为从库时且过期时,expireIfNeeded 返回 1, 此时进入从库的清理逻辑中,当同时满足 server.current_client && server.current_client != server.master && server.current_client->cmd && server.current_client->cmd->flags & CMD_READONLY 时,查询命令会直接返回 NULL,表示数据查询为空,表现为在从库过期了,实际上可以看到并没有执行删除命令。此时 ttl 返回为-2

所以,总结 3.2 版本此时要注意几点

  • 从库实际没有调用删除的命令,此时过期 key 还在从库数据库里,等待 master 过期后的 del 命令同步过来。(However if we are in the context of a slave, expireIfNeeded() will not really try to expire the key, it only returns information about the "logical" status of the key: key expiring is up to themaster in order to have a consistent view of master's data set)

  • 只有从库开启了 slave-read-only=yes 参数, 才会生效,否则从库还是可以读到数据

     robj *lookupKeyRead(redisDb *db, robj *key)
    {
        robj *val;
    
        if (expireIfNeeded(db, key) == 1)
        {
            /* key过期的逻辑,如果是slave,则返回NULL  */
            if (server.masterhost == NULL)
                return NULL;
    
            /* 为了保证数据一致性,如果是slave时,expireIfNeeded() 函数并不会进行过期操作,而是返回key的逻辑状态, key实际的过期,要等待master的同步命令。
             * Notably this covers GETs when slaves are used to scale reads. */
            if (server.current_client &&
                server.current_client != server.master &&
                server.current_client->cmd &&
                server.current_client->cmd->flags & CMD_READONLY)
            {
                return NULL;
            }
        }
        val = lookupKey(db, key);
        if (val == NULL)
            server.stat_keyspace_misses++;
        else
            server.stat_keyspace_hits++;
        return val;
    }
    

4.0 版本

4.0 版本的定期删除策略相比于 3.2 版本有了较大改进,体现在

  • 惰性删除策略, lookupKeyRead 改成了 lookupKeyReadWithFlags 函数, 但是相比 3.2 版本逻辑依然没有变化,访问从库的过期 key 时,返回 key 的逻辑状态为 NULL,但是实际 key 还在数据库里等待 master 过期后同步过来的 del 命令进行删除。此时 ttl 返回为-2

  • 定期清理策略,增加了对可写从库的过期清理逻辑, 即要求 slave-read-only=no。官方此做法的目的是为了解决可写从库写入带 ttl 的数据后,永远无法删除的问题,因为此时 master 无法感知 slave 里的新增数据,无法通过 master 的过期操作触发 slave 的删除操作。
    可写 slave 的定期清理具体流程为:当 a writable slave 写入数据时,会通过 rememberSlaveKeyWithExpire 函数,将有 ttl 的 key 记录到 slaveKeysWithExpire 字典里,
    然后 redis 的定期任务 databaseCron 在判断当前为 slave 时,调用 expireSlaveKeys 函数,进行清理。

    ```
    void databasesCron(void) {
        /* 随机进行key过期, 如果是slave的话,这里不执行操作,只是等待master的dels命令同步过来. */
        if (server.active_expire_enabled && server.masterhost == NULL) {
            activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
        } else if (server.masterhost != NULL) {
            expireSlaveKeys();
        }
        ...
    }
    
    
    dict *slaveKeysWithExpire = NULL;
    
    /* 过期从库上的key. */
    void expireSlaveKeys(void)
    {
        if (slaveKeysWithExpire == NULL ||
            dictSize(slaveKeysWithExpire) == 0)
            return;
    
        int cycles = 0, noexpire = 0;
        mstime_t start = mstime();
        while (1)
        {
            dictEntry *de = dictGetRandomKey(slaveKeysWithExpire);
            sds keyname = dictGetKey(de);
            uint64_t dbids = dictGetUnsignedIntegerVal(de);
            uint64_t new_dbids = 0;
    
            /* Check the key against every database corresponding to the
             * bits set in the value bitmap. */
            int dbid = 0;
            while (dbids && dbid < server.dbnum)
            {
                if ((dbids & 1) != 0)
                {
                    redisDb *db = server.db + dbid;
                    dictEntry *expire = dictFind(db->expires, keyname);
                    int expired = 0;
    
                    if (expire &&
                        activeExpireCycleTryExpire(server.db + dbid, expire, start))
                    {
                        expired = 1;
                    }
    
                  ...
            }
    
              ...
        }
    }
    
    /* 记录从库上的有ttl的key */
    void rememberSlaveKeyWithExpire(redisDb *db, robj *key)
    {
        if (slaveKeysWithExpire == NULL)
        {
            static dictType dt = {
                dictSdsHash,       /* hash function */
                NULL,              /* key dup */
                NULL,              /* val dup */
                dictSdsKeyCompare, /* key compare */
                dictSdsDestructor, /* key destructor */
                NULL               /* val destructor */
            };
            slaveKeysWithExpire = dictCreate(&dt, NULL);
        }
        if (db->id > 63)
            return;
    
        dictEntry *de = dictAddOrFind(slaveKeysWithExpire, key->ptr);
        /* If the entry was just created, set it to a copy of the SDS string
         * representing the key: we don't want to need to take those keys
         * in sync with the main DB. The keys will be removed by expireSlaveKeys()
         * as it scans to find keys to remove. */
        if (de->key == key->ptr)
        {
            de->key = sdsdup(key->ptr);
            dictSetUnsignedIntegerVal(de, 0);
        }
    
        uint64_t dbids = dictGetUnsignedIntegerVal(de);
        dbids |= (uint64_t)1 << db->id;
        dictSetUnsignedIntegerVal(de, dbids);
    }
    
    ```
    

5.0 版本

相比于 4.0 版本没有太多改进

  • 惰性删除策略,lookupKeyReadWithFlags 函数增加了统计信息,即访问过期 key 的命令认为失败命令,会记录到失败命令里

    long long stat_keyspace_misses; /* Number of failed lookups of keys */
    robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
        robj *val;
        if (expireIfNeeded(db,key) == 1) {
            if (server.masterhost == NULL) {
                server.stat_keyspace_misses++;
                return NULL;
            }
    
          ...
    
            if (server.current_client &&
                server.current_client != server.master &&
                server.current_client->cmd &&
                server.current_client->cmd->flags & CMD_READONLY)
            {
                server.stat_keyspace_misses++;
                return NULL;
            }
        }
        val = lookupKey(db,key,flags);
        if (val == NULL)
            server.stat_keyspace_misses++;
        else
            server.stat_keyspace_hits++;
        return val;
    }
    
    
  • 定期清理策略,相比于 4.0 版本没有变化

6.0 版本

  • 惰性删除策略,lookupKeyReadWithFlags 相比 5.0 版本没有太大改变,函数统计信息的基础上,增加了通知 keyspaceEvent 的操作,

    robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
        robj *val;
    
        if (expireIfNeeded(db,key) == 1) {
            if (server.masterhost == NULL) {
                server.stat_keyspace_misses++;
                notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
                return NULL;
            }
    
  • 定期清理策略,相比于 5.0 版本没有变化

6.2 版本

  • 惰性删除策略,相对于 6.0 版本逻辑基本没有变化

  • 定期清理策略,相比于 5.0 版本逻辑没有变化

总结

总结下各个版本针对于从库的定期删除策略和惰性删除策略

功能 2.8.23 3.0 3.2 4.0 5.0 6.0 6.2
访问从节点触发惰性删除 不会触发删除,返回value 不会触发删除,返回value 不会触发删除,返回NULL 不会触发删除,返回NULL 不会触发删除,返回NUL,增加统计信息 不会触发删除,返回NUL,增加统计信息和通知keysspaceEvent 不会触发删除,返回NUL,增加统计信息和通知keysspaceEvent
定期删除 从库不开启 从库不开启 从库不开启 对可写从库开启定期删除任务 对可写从库开启定期删除任务 对可写从库开启定期删除任务 对可写从库开启定期删除任务
ttl返回值 0 0 -2 -2 -2 -2 -2
主节点过期key删除触发从库删除

可以看到,redis 各个版本在访问从库的过期 key 时,都不会从库上进行删除, 3.2 以后的版本会返回 NULL,表示 key 不存在了。

测试验证

2.8 的版本线上的问题已经相当于做了版本验证,我这里直接拿 3.2 、4.0 、 5.0、6.0、6.2 的非 cluster类型做测试。 对于 cluster 类型,直接在测试结果的最后做下说明。

相关准备

搭建测试环境
版本为: 3.2.11
环境:
7.32.248.76 master
7.32.245.87 slave

其他版本的 redis IP 信息省略

相关测试步骤和命令为

int active_expire_enabled;      /* Can be disabled for testing purposes. */
  • 1、在 master 执行 debug set-active-expire 0,关闭 master 节点的定期删除策略
  • 在 master 执行 setex xiaotengkun 10 handsomeboy. 设置 key 过期时间为 10s
  • 2、在 slave 上执行 monitor 命令,redis-cli monitor |grep xiaotengkun ,查看 key 过期后,从库有没有收到 master 同步过来的 del 命令
  • 3、在 slave 再开一个客户端,执行 ttl 命令和 get 命令, 查看返回值
  • 4、在 master 执行 ttl xiaotengkun 或者 get xiaotengkun ,或者开启 master 的定期删除 debug set-active-expire 1, 查看 slave 有没有收到 master 同步过来的 del 命令

具体测试结果如下:

具体操作步骤和从库的 monitor 日志对应关系都在图中进行了标识

  • 3.2 版本


    image.png
  • 4.0 版本


    image.png

    测试结果与 3.2 完全一致

  • 5.0 版本
    再拿 5.0 的版本测一下,结果一样,只是 master 同步过来的命令会由 DEL 变为 UNLINK


    image.png
  • 6.0 版本
    测试结果与 5.0 版本一致


    image.png
  • 6.2 版本
    测试结果与 6.0 版本一致, (setex 命令变成了 set PX ,这里是 6.2 的一个改变)


    image.png
  • cluster 类型特殊说明:
    对于 cluster 类型, 如果从库开启了 readonly,表示从库支持读请求,那么此类情况和以上测试结果保持一致。
    如果 没有开启 readonly, 当传入-c 参数,在从库发起读请求时,会将请求转发到对应的 master 节点上, 此时会触发 master 的惰性删除操作,所以也会触发从库的删除操作。


    image.png

可以看到测试结果完全符合预期,,其他版本若小伙伴们有兴趣可以自行进行测试。

结论

各个版本针对于从库的定期删除策略和惰性删除策略如下,

功能 2.8.23 3.0 3.2 4.0 5.0 6.0 6.2
访问从节点触发惰性删除 不会触发删除,返回value 不会触发删除,返回value 不会触发删除,返回NULL 不会触发删除,返回NULL 不会触发删除,返回NUL,增加统计信息 不会触发删除,返回NUL,增加统计信息和通知keysspaceEvent 不会触发删除,返回NUL,增加统计信息和通知keysspaceEvent
定期删除 从库不开启 从库不开启 从库不开启 对可写从库开启定期删除任务 对可写从库开启定期删除任务 对可写从库开启定期删除任务 对可写从库开启定期删除任务
ttl返回值 0 0 -2 -2 -2 -2 -2
主节点过期key删除触发从库删除
  1. redis 目前的所有版本, redis 各个版本在访问从库的过期 key 时,都不会从库上进行删除, 3.2 以后的版本会返回 NULL,表示 key 不存在了。
  2. 当使用 3.0 及以前的版本时,如果 master 节点没有及时清理掉过期的 key,在 slave 上访问时,就有可能出现 key 已经过期但是实际还能访问到的情况,这对于配置了读写分离的业务,影响是巨大的。
  3. 对于 cluster 类型,要注意从库 readonly 的使用,如果从库开启了 readonly,表示从库支持读请求,那么此类情况和以上测试结果保持一致。
    如果 没有开启 readonly, 在从库发起读请求时,会将请求转发到对应的 master 节点上, 此时会触发 master 的惰性删除操作,所以也会触发从库的删除操作。
  4. 总体来说,结合以上测试结果和源码分析,我们更推荐使用 5.0 以后的版本,无论是性能还是 bug 修复方面,都有不错的提升。

你可能感兴趣的:(Redis · 从库TTL问题深入剖析及版本迭代对比)