如何优雅地删除Redis大键

关于Redis大键(Key),我们从[空间复杂性]和访问它的[时间复杂度]两个方面来定义大键。
前者主要表示Redis键的占用内存大小;后者表示Redis集合数据类型(set/hash/list/sorted set)键,所含有的元素个数。以下两个示例:

1个大小200MB的String键(String Object最大512MB);内存空间角度占用较大
1个包含100000000(1kw)个字段的Hash键,对应访问模式(如hgetall)时间复杂度高

因为内存空间复杂性处理耗时都非常小,测试 del 200MB String键耗时约1毫秒,
而删除一个含有1kw个字段的Hash键,却会阻塞Redis进程数十秒。所以本文只从时间复杂度分析大的集合类键。删除这种大键的风险,以及怎么优雅地删除。


在Redis集群中,应用程序尽量避免使用大键;直接影响容易导致集群的容量和请求出现”倾斜问题“。但在实际生产过程中,总会有业务使用不合理,出现这类大键;当DBA发现后推进业务优化改造,然后删除这个大键;如果直接删除它,DEL命令可能阻塞Redis进程数十秒,对应用程序和Redis集群可用性造成严重的影响。


直接删除大Key的风险

DEL命令在删除单个集合类型的Key时,命令的时间复杂度是O(M),其中M是集合类型Key包含的元素个数。

DEL key
Time complexity: O(N) where N is the number of keys that will be removed. When a key to remove holds a value other than a string, the individual complexity for this key is O(M) where M is the number of elements in the list, set, sorted set or hash. Removing a single key that holds a string value is O(1).

生产环境中遇到过多次因业务删除大Key,导致Redis阻塞,出现故障切换和应用程序雪崩的故障。
测试删除集合类型大Key耗时,一般每秒可清理100w~数百w个元素; 如果数千w个元素的大Key时,会导致Redis阻塞上10秒
可能导致集群判断Redis已经故障,出现故障切换;或应用程序出现雪崩的情况。

说明:Redis是单线程处理。
单个耗时过大命令,导致阻塞其他命令,容易引起应用程序雪崩或Redis集群发生故障切换。
所以避免在生产环境中使用耗时过大命令。

Redis删除大的集合键的耗时, 测试估算,可参考;和硬件环境、Redis版本和负载等因素有关

Key类型Item数量耗时
Hash~100万~1000ms
List~100万~1000ms
Set~100万~1000ms
Sorted Set~100万~1000ms

当我们发现集群中有大key时,要删除时,如何优雅地删除大Key?


如何优雅地删除各类大Key

从Redis2.8版本开始支持SCAN命令,通过m次时间复杂度为O(1)的方式,遍历包含n个元素的大key.
这样避免单个O(n)的大命令,导致Redis阻塞。 这里删除大key操作的思想也是如此。


Delete Large Hash Key

通过hscan命令,每次获取500个字段,再用hdel命令,每次删除1个字段。

Delete Large Set Key

删除大set键,使用sscan命令,每次扫描集合中500个元素,再用srem命令每次删除一个键

Delete Large List Key

删除大的List键,未使用scan命令; 通过ltrim命令每次删除少量元素。

Delete Large Sorted set key

删除大的有序集合键,和List类似,使用sortedset自带的zremrangebyrank命令,每次删除top 100个元素。

Redis Lazy Free

背景

redis重度使用患者应该都遇到过使用 DEL 命令删除体积较大的键, 又或者在使用 FLUSHDB 和 FLUSHALL 删除包含大量键的数据库时,造成redis阻塞的情况;另外redis在清理过期数据和淘汰内存超限的数据时,如果碰巧撞到了大体积的键也会造成服务器阻塞。

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

lazyfree机制

lazyfree的原理不难想象,就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的destruct,避免由于对象体积过大而造成阻塞。redis的lazyfree实现即是如此,下面我们由几个命令来介绍下lazyfree的实现。

1. UNLINK命令

首先我们来看下新增的unlink命令:

void unlinkCommand(client *c) {
    delGenericCommand(c, 1);
}

入口很简单,就是调用delGenericCommand,第二个参数为1表示需要异步删除。

/* This command implements DEL and LAZYDEL. */
void delGenericCommand(client *c, int lazy) {
    int numdel = 0, j;

    for (j = 1; j < c->argc; j++) {
        expireIfNeeded(c->db,c->argv[j]);
        int deleted  = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
                              dbSyncDelete(c->db,c->argv[j]);
        if (deleted) {
            signalModifiedKey(c->db,c->argv[j]);
            notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
                "del",c->argv[j],c->db->id);
            server.dirty++;
            numdel++;
        }
    }
    addReplyLongLong(c,numdel);
}

delGenericCommand函数根据lazy参数来决定是同步删除还是异步删除,同步删除的逻辑没有什么变化就不细讲了,我们重点看下新增的异步删除的实现。

#define LAZYFREE_THRESHOLD 64
// 首先定义了启用后台删除的阈值,对象中的元素大于该阈值时才真正丢给后台线程去删除,如果对象中包含的元素太少就没有必要丢给后台线程,因为线程同步也要一定的消耗。
int dbAsyncDelete(redisDb *db, robj *key) {
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
    //清除待删除key的过期时间

    dictEntry *de = dictUnlink(db->dict,key->ptr);
    //dictUnlink返回数据库字典中包含key的条目指针,并从数据库字典中摘除该条目(并不会释放资源)
    if (de) {
        robj *val = dictGetVal(de);
        size_t free_effort = lazyfreeGetFreeEffort(val);
        //lazyfreeGetFreeEffort来获取val对象所包含的元素个数

        if (free_effort > LAZYFREE_THRESHOLD) {
            atomicIncr(lazyfree_objects,1);
            //原子操作给lazyfree_objects加1,以备info命令查看有多少对象待后台线程删除
            bioCreateBackgroundJob(BIO_LAZY_FREE ,val,NULL,NULL);
            //此时真正把对象val丢到后台线程的任务队列中
            dictSetVal(db->dict,de,NULL);
            //把条目里的val指针设置为NULL,防止删除数据库字典条目时重复删除val对象
        }
    }

    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        //删除数据库字典条目,释放资源
        return 1;
    } else {
        return 0;
    }
}

以上便是异步删除的逻辑,首先会清除过期时间,然后调用dictUnlink把要删除的对象从数据库字典摘除,再判断下对象的大小(太小就没必要后台删除),如果足够大就丢给后台线程,最后清理下数据库字典的条目信息。

由以上的逻辑可以看出,当unlink一个体积较大的键时,实际的删除是交给后台线程完成的,所以并不会阻塞redis。

2. FLUSHALL、FLUSHDB命令

4.0给flush类命令新加了option——async,当flush类命令后面跟上async选项时,就会进入后台删除逻辑,代码如下:

/* FLUSHDB [ASYNC]
 *
 * Flushes the currently SELECTed Redis DB. */
void flushdbCommand(client *c) {
    int flags;

    if (getFlushCommandFlags(c,&flags) == C_ERR) return;
    signalFlushedDb(c->db->id);
    server.dirty += emptyDb(c->db->id,flags,NULL);
    addReply(c,shared.ok);

    sds client = catClientInfoString(sdsempty(),c);
    serverLog(LL_NOTICE, "flushdb called by client %s", client);
    sdsfree(client);
}

/* FLUSHALL [ASYNC]
 *
 * Flushes the whole server data set. */
void flushallCommand(client *c) {
    int flags;

    if (getFlushCommandFlags(c,&flags) == C_ERR) return;
    signalFlushedDb(-1);
    server.dirty += emptyDb(-1,flags,NULL);
    addReply(c,shared.ok);
    ...
}

flushdb和flushall逻辑基本一致,都是先调用getFlushCommandFlags来获取flags(其用来标识是否采用异步删除),然后调用emptyDb来清空数据库,第一个参数为-1时说明要清空所有数据库。

long long emptyDb(int dbnum, int flags, void(callback)(void*)) {
    int j, async = (flags & EMPTYDB_ASYNC);
    long long removed = 0;

    if (dbnum < -1 || dbnum >= server.dbnum) {
        errno = EINVAL;
        return -1;
    }

    for (j = 0; j < server.dbnum; j++) {
        if (dbnum != -1 && dbnum != j) continue;
        removed += dictSize(server.db[j].dict);
        if (async) {
            emptyDbAsync(&server.db[j]);
        } else {
            dictEmpty(server.db[j].dict,callback);
            dictEmpty(server.db[j].expires,callback);
        }
    }
    return removed;
}

进入emptyDb后首先是一些校验步骤,校验通过后开始执行清空数据库,同步删除就是调用dictEmpty循环遍历数据库的所有对象并删除(这时就容易阻塞redis),今天的核心在异步删除emptyDbAsync函数。

/* Empty a Redis DB asynchronously. What the function does actually is to
 * create a new empty set of hash tables and scheduling the old ones for
 * lazy freeing. */
void emptyDbAsync(redisDb *db) {
    dict *oldht1 = db->dict, *oldht2 = db->expires;
    db->dict = dictCreate(&dbDictType,NULL);
    db->expires = dictCreate(&keyptrDictType,NULL);
    atomicIncr(lazyfree_objects,dictSize(oldht1));
    bioCreateBackgroundJob(BIO_LAZY_FREE,NULL,oldht1,oldht2);
}

这里直接把db->dict和db->expires指向了新创建的两个空字典,然后把原来两个字典丢到后台线程的任务队列就好了,简单高效,再也不怕阻塞redis了。

lazyfree线程

接下来介绍下真正干活的lazyfree线程。

首先要澄清一个误区,很多人提到redis时都会讲这是一个单线程的内存数据库,其实不然。虽然redis把处理网络收发和执行命令这些操作都放在了主工作线程,但是除此之外还有许多bio后台线程也在兢兢业业的工作着,比如用来处理关闭文件和刷盘这些比较重的IO操作,这次bio家族又加入了新的小伙伴——lazyfree线程。

void *bioProcessBackgroundJobs(void *arg) {
    ...
        if (type == BIO_LAZY_FREE) {
            /* What we free changes depending on what arguments are set:
             * arg1 -> free the object at pointer.
             * arg2 & arg3 -> free two dictionaries (a Redis DB).
             * only arg3 -> free the skiplist. */
            if (job->arg1)
                lazyfreeFreeObjectFromBioThread(job->arg1);
            else if (job->arg2 && job->arg3)
                lazyfreeFreeDatabaseFromBioThread(job->arg2, job->arg3);
            else if (job->arg3)
                lazyfreeFreeSlotsMapFromBioThread(job->arg3);
        }
    ...
}

redis给新加入的lazyfree线程起了个名字叫BIO_LAZY_FREE,后台线程根据type判断出自己是lazyfree线程,然后再根据bio_job里的参数情况去执行相对应的函数。

  1. 后台删除对象,调用decrRefCount来减少对象的引用计数,引用计数为0时会真正的释放资源。
    void lazyfreeFreeObjectFromBioThread(robj *o) { decrRefCount(o); atomicDecr(lazyfree_objects,1); }
    这里也要额外补充一下,自redis 4.0开始,redis存储的key-value对象的引用计数只有1或者shared两种状态,换句话说交给lazyfree线程处理的对象必然是1,这样也就避免了多线程竞争问题。
  2. 后台清空数据库字典,调用dictRelease循环遍历数据库字典删除所有对象。
    void lazyfreeFreeDatabaseFromBioThread(dict *ht1, dict *ht2) { size_t numkeys = dictSize(ht1); dictRelease(ht1); dictRelease(ht2); atomicDecr(lazyfree_objects,numkeys); }
  3. 后台删除key-slots映射表,原生redis如果运行在集群模式下会用,云redis使用的自研集群模式这一函数目前并不会调用。
    void lazyfreeFreeSlotsMapFromBioThread(rax *rt) { size_t len = rt->numele; raxFree(rt); atomicDecr(lazyfree_objects,len); }

过期与逐出

redis支持设置过期时间以及逐出,而由此引发的删除动作也可能会阻塞redis。

所以redis 4.0这次除了显示增加unlink、flushdb async、flushall async命令之外,还增加了4个后台删除配置项,分别为:

  • slave-lazy-flush:slave接收完RDB文件后清空数据选项
  • lazyfree-lazy-eviction:内存满逐出选项
  • lazyfree-lazy-expire:过期key删除选项
  • lazyfree-lazy-server-del:内部删除选项,比如rename oldkey newkey时,如果newkey存在需要删除newkey

以上4个选项默认为同步删除,可以通过config set [parameter] yes打开后台删除功能。

后台删除的功能无甚修改,只是在原先同步删除的地方根据以上4个配置项来选择是否调用dbAsyncDelete或者emptyDbAsync进行异步删除,具体代码可见:

  1. slave-lazy-flush
    void readSyncBulkPayload(aeEventLoop *el, int fd, void *privdata, int mask) { ... if (eof_reached) { ... emptyDb( -1, server.repl_slave_lazy_flush ? EMPTYDB_ASYNC : EMPTYDB_NO_FLAGS, replicationEmptyDbCallback); ... } ... }
  2. lazyfree-lazy-eviction
    int freeMemoryIfNeeded(long long timelimit) { ... /* Finally remove the selected key. */ if (bestkey) { ... propagateExpire(db,keyobj,server.lazyfree_lazy_eviction); if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); ... } ... }
  3. lazyfree-lazy-expire
    int activeExpireCycleTryExpire(redisDb *db, struct dictEntry *de, long long now) { ... if (now > t) { ... propagateExpire(db,keyobj,server.lazyfree_lazy_expire); if (server.lazyfree_lazy_expire) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); ... } ... }
  4. lazyfree-lazy-server-del
    int dbDelete(redisDb *db, robj *key) { return server.lazyfree_lazy_server_del ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); }

此外云redis对过期和逐出做了一点微小的改进。

expire及evict优化

redis在空闲时会进入activeExpireCycle循环删除过期key,每次循环都会率先计算一个执行时间,在循环中并不会遍历整个数据库,而是随机挑选一部分key查看是否到期,所以有时时间不会被耗尽(采取异步删除时更会加快清理过期key),剩余的时间就可以交给freeMemoryIfNeeded来执行。

void activeExpireCycle(int type) {
    ...
afterexpire:
    if (!g_redis_c_timelimit_exit &&
        server.maxmemory > 0 &&
        zmalloc_used_memory() > server.maxmemory)
    {
        long long time_canbe_used = timelimit - (ustime() - start);
        if (time_canbe_used > 0) freeMemoryIfNeeded(time_canbe_used);
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值