沃梦达 / IT编程 / 数据库 / 正文

【redis源码】删除大key导致redis主从切换

1. 问题简述前几天接收到报警,同时Redis团队监控到redis集群发生了主从切换;最终分析原因是,删除大key,导致redis主服务器阻塞,sentinel哨兵认为主服务器宕机,进行了故障转移;如下图所示:在Redis集群中,应...

1. 问题简述

前几天接收到报警,同时Redis团队监控到redis集群发生了主从切换;

最终分析原因是,删除大key,导致redis主服务器阻塞,sentinel哨兵认为主服务器宕机,进行了故障转移;如下图所示:

在Redis集群中,应用程序尽量避免使用大键;直接影响容易导致集群的容量和请求出现”倾斜问题“,同时在删除大键或者打键过期时,容易出现故障切换和应用程序雪崩的故障;

查询线上有一个集合键,集合oea_set_star_ol_2017元素个数达到4300万;当删除这个键,或者键过期时,会阻塞redis主进程,从而发生了主从切换;(集合中的每个元素对象都要释放内存空间,时间复杂度比较高)

2. 解决方案

众所周知,Redis是单进程执行命令请求的;集合已经有4000多万元素了,想要删除这个集合,肯定不能直接删除,否则必会阻塞主进程;

我们可以一点一点删除集合中的元素;

Redis 2.8以上版本提供了这么一个命令:SCAN 命令,其相关的 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令;

它们每次执行都只会返回少量元素;(而不会出现像 KEYS命令、 SMEMBERS 命令带来问题 —— 当 KEYS 命令被用于处理一个大的数据库时, 又或者 SMEMBERS 命令被用于处理一个大的集合键时, 它们可能会阻塞服务器达数秒之久。)

我们可以这样做:通过HSCAN,每次获取500个字段,再用HDEL命令,每次删除1个字段;

这样虽然删除过程时间复杂度也很高(提高客户端复杂度,需要多次获取key,批量执行删除命令),但是至少不会阻塞redis服务器。

3. 更好的解决方案

redis也发现了这个问题:直接使用del命令删除大key会导致Redis主进程阻塞;分批次删除,客户端复杂度又比较高;

因此在Redis 4.0 的时候,提出了惰性删除lazyfree:当用户删除集key时,或者集合key过期需要删除时,检测如果集合元素大于64个,则使用惰性删除,只解除集合对象与数据库字典的关系,将集合对象放入待删除队列中,后台现成依次获取队列中的对象,并真正的删除;

redis 4.0 引入了lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞。

lazyfree的原理不难想象,就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的destruct,避免由于对象体积过大而造成阻塞

下面我们深入redis源码,分析redis惰性删除策略;我们分析两个方面:客户端使用命令删除大key,大key过期删除;

3.1 客户端使用命令删除大key

redis 4.0删除元素有两个命令,del和unlink;del和之前版本一样,直接删除对象,可能会阻塞主进程,unlink就是惰性删除;

下面看看del和unlink命令的代码逻辑:

{"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0}
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
 
void delCommand(client *c) {
    delGenericCommand(c,0);
}
 
void unlinkCommand(client *c) {
    delGenericCommand(c,1);
}
delGenericCommand函数第二个参数是lazy标志;0同步删除,1惰性/异步删除,先解除对象数据库字典关联关系,再调用后台线程释放对象空间;
//lazy表示是否懒删除
void delGenericCommand(client *c, int lazy) {
    int numdel = 0, j;
 
    for (j = 1; j < c->argc; j++) {
        expireIfNeeded(c->db,c->argv[j]); //校验对象是否过期(顺便说一下,redis数据库有两个字典:对象字典 存储键值对,过期时间字典 存储键和过期时间)
        int deleted  = lazy ? dbAsyncDelete(c->db,c->argv[j]) : //根据lazy表示执行同步/异步删除操作
                              dbSyncDelete(c->db,c->argv[j]);
        if (deleted) {
            signalModifiedKey(c->db,c->argv[j]);
            notifyKeyspaceEvent(NOTIFY_GENERIC,
                "del",c->argv[j],c->db->id);
            server.dirty++;
            numdel++;
        }
    }
    addReplyLongLong(c,numdel);
}

删除命令之前如果检测到这个key已过期,则执行过期删除操作;

int expireIfNeeded(redisDb *db, robj *key) {
    mstime_t when = getExpire(db,key);
    mstime_t now;
 
    if (when < 0) return 0; //key没有配置过期时间
 
    //正在加载db,直接返回
    if (server.loading) return 0;
    //slave机器,不处理
    if (server.masterhost != NULL) return now > when;
 
    //没有到期
    if (now <= when) return 0;
 
    //删除
    server.stat_expiredkeys++;
    propagateExpire(db,key,server.lazyfree_lazy_expire); //传播到期删除命令给aof和slaves
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : //根据过期删除策略决定同步/异步删除(用户可配置)
                                         dbSyncDelete(db,key);
}

惰性删除时,会执行异步删除函数

//异步删除函数:
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
    //删除过期字典
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
 
    //从字典删除键值对,并返回
    dictEntry *de = dictUnlink(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        size_t free_effort = lazyfreeGetFreeEffort(val); //获得当前对象长度(列表元素数目,hash对象键值对数目。。。)
        //当对象元素超过64个,且对象引用计数为1,才会懒删除;
        //开启bio后台线程删除
        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects,1);
            bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);  //子线程删除
            dictSetVal(db->dict,de,NULL);
        }
    }
 
    //释放键值对(假如懒释放,这里只释放键对象)
    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        if (server.cluster_enabled) slotToKeyDel(key);
        return 1;
    } else {
        return 0;
    }
}
//同步删除函数,直接删除
int dbSyncDelete(redisDb *db, robj *key) {
    /* Deleting an entry from the expires dict will not free the sds of
     * the key, because it is shared with the main dictionary. */
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
    if (dictDelete(db->dict,key->ptr) == DICT_OK) {
        if (server.cluster_enabled) slotToKeyDel(key);
        return 1;
    } else {
        return 0;
    }
}

3.2 过期删除

对于过期键有三种检测策略:

1.添加定时器:设置key过期时间时,添加定时器,定时执行过期删除(没有这么做)

2.周期性检测:周期性检测若干key过期时间,过期则删除;

3.访问这个key时,如果已经过期,则删除

redis结合2和3两种策略,实现过期键的检测;

过期键删除函数如下所示:

//过期键删除函数
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key,sdslen(key)); //数据库字典key存储的是字符串对象;过期字典key存储的是sds
 
        //代码基本与删除key代码相同;
        propagateExpire(db,keyobj,server.lazyfree_lazy_expire);
        if (server.lazyfree_lazy_expire)    //过期删除时,是否执行异步删除操作,由用户配置,server.lazyfree_lazy_expire
            dbAsyncDelete(db,keyobj);
        else
            dbSyncDelete(db,keyobj);
        notifyKeyspaceEvent(NOTIFY_EXPIRED,
            "expired",keyobj,db->id);
        decrRefCount(keyobj);
        server.stat_expiredkeys++;
        return 1;
    } else {
        return 0;
    }
}

4.总结

对于大key删除,上面提出了两种方案

  1. 对于低版本redis 2.8以上 4.0以下:使用scan命令分批次获得大key中的元素,分批次删除,直到删除大key中的所有元素;
     
  2. 客户端删除大key时,使用unlink命令,其会执行惰性删除策略,只是逻辑删除大key,真正的删除是在后台线程进行的;而对于过期删除,则需要用户配置server.lazyfree_lazy_expir,这样redis在删除过期键时,才会执行惰性删除策略。

本文标题为:【redis源码】删除大key导致redis主从切换