likes
comments
collection
share

用 Redis 做分布式锁?配合源码食用更稳妥 - SET NX 命令剖析

作者站长头像
站长
· 阅读数 9

在分布式系统中,我们经常会借助 redis 的 SET NX 机制来实现多个服务之间的锁机制,即所谓的分布式锁。这是一条简单的获取锁的命令,当然,实际应用中需要处理更多的场景:

$ SET foo bar NX EX 60

该命令使用了 NX 选项(if Not eXists)表示当 key foo 不存在时,设置 foo 的值为 bar,并设置过期时间为 60 秒,当过期时间内再有其它客户端试图设置 foo 的值则会失败,这就达到了互斥的效果。当需要手动释放锁时,可直接使用 DEL 命令将 key 删除。

SET NX

接下来我们从源码看看这条命令是如何被执行的。redis SET 命令的处理函数是源码 src/t_string.c 文件中的 setCommand 方法,对应的数据类型是 string

该方法接受一个 redis client 作为参数,它表示一个已连接的客户端,记录了客户端的连接状态、命令请求、数据库索引等许多必要的属性。setGenericCommand 是一个通用的 SET 命令实现函数,它支持多个 SET 相关的命令,如 SETNXSETEX 等。

/* SET key value [NX] [XX] [KEEPTTL] [GET] [EX <seconds>] [PX <milliseconds>]
 *     [EXAT <seconds-timestamp>][PXAT <milliseconds-timestamp>] */
void setCommand(client *c) {
    robj *expire = NULL;
    int unit = UNIT_SECONDS;
    int flags = OBJ_NO_FLAGS;

    // 解析命令参数字符串以及进行一些必要的参数校验
    if (parseExtendedStringArgumentsOrReply(c,&flags,&unit,&expire,COMMAND_SET) != C_OK) {
        return;
    }

    // c->argv[1] = key, c->argv[2] = value
    // 这里会尝试对 value 进行编码,以节省内存占用
    c->argv[2] = tryObjectEncoding(c->argv[2]);
    // 使用通用 SET 实现
    setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}

相信你也猜到了,对于作为锁来使用的 SET NX 命令来说,setGenericCommand 会先从数据库中查找对应的键值,如果 key 存在,会直接向客户端返回 Nil,表示“锁已被占用”;key 不存在时则会将键值存储到数据库里并向客户端返回 OK,表示“获取到锁”。

如果命令中存在额外的 EX 或其它过期时间相关的选项,该 key 也会被设置一个过期时间。

void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
    ...
    // 从对应的数据库中查找 key
    found = (lookupKeyWrite(c->db,key) != NULL);

    // 存在 NX 选项时,如果 key 存在则直接返回客户端 Nil 表示设置失败
    // XX 选项与 NX 正好相反,表示仅在 key 存在时设置,否则直接返回 Nil
    if ((flags & OBJ_SET_NX && found) ||
        (flags & OBJ_SET_XX && !found))
    {
        if (!(flags & OBJ_SET_GET)) {
            addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
        }
        return;
    }
    
    /* When expire is not NULL, we avoid deleting the TTL so it can be updated later instead of being deleted and then created again. */
    setkey_flags |= ((flags & OBJ_KEEPTTL) || expire) ? SETKEY_KEEPTTL : 0;
    setkey_flags |= found ? SETKEY_ALREADY_EXIST : SETKEY_DOESNT_EXIST;

    // 存储键值对
    setKey(c,c->db,key,val,setkey_flags);
    server.dirty++;
    ...
    
    // 过期时间设置
    if (expire) {
        setExpire(c,c->db,key,milliseconds);
        ...
    }
}

数据竞态?

redis 服务是通过事件循环机制(如 epoll)来保证命令是串行的,这种设计使得 redis 在处理多个客户端请求时不需要考虑并发访问的问题,从而避免了典型的多线程/多进程编程所面临的竞态条件和死锁等问题。因此从这个角度来看,set 命令是不存在数据竞态问题的。

SET NX 命令的主要执行流程已经介绍完了,接下来我们看看从数据库检索 key 和设置 key 的一些细节流程,可以帮助我们之后扩展到 redis 的其它部分。

扩展 - lookupKey

SET 命令中 redis 使用了 lookupKeyWrite 方法来查找 key,源码位于 src/db.c,它表示一个针对写操作的查找,与之对应的是 lookupKeyRead,二者有一些不同,如 lookupKeyWrite 会将已过期的 key 删除并清理空间。

robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 最终执行到 lookupKey,并将 flags 设置成 Write
    return lookupKey(db, key, flags | LOOKUP_WRITE);
}

robj *lookupKeyWrite(redisDb *db, robj *key) {
    return lookupKeyWriteWithFlags(db, key, LOOKUP_NONE);
}

lookupKey 最直接的流程就是根据 key 查找出对应的键值对,并返回最终的值,除此之外还会有一些“副作用”,即会根据不同的 flags 值来定义不同的查找行为。

/* 
 * 这里贴一下 flags 对应的行为的官方定义:
 *  LOOKUP_NONE (or zero): No special flags are passed.
 *  LOOKUP_NOTOUCH: Don't alter the last access time of the key.
 *  LOOKUP_NONOTIFY: Don't trigger keyspace event on key miss.
 *  LOOKUP_NOSTATS: Don't increment key hits/misses counters.
 *  LOOKUP_WRITE: Prepare the key for writing (delete expired keys even on
 *                replicas, use separate keyspace stats and events (TODO)).
 *  LOOKUP_NOEXPIRE: Perform expiration check, but avoid deleting the key,
 *                   so that we don't have to propagate the deletion.
 */
robj *lookupKey(redisDb *db, robj *key, int flags) {
    // 根据 key 查找数据库中的键值对
    dictEntry *de = dictFind(db->dict,key->ptr);
    robj *val = NULL;
    if (de) {
        // 获取键值对中的值
        val = dictGetVal(de);
        
        // flags 对应过期键相关的行为
        int is_ro_replica = server.masterhost && server.repl_slave_ro;
        int expire_flags = 0;
        if (flags & LOOKUP_WRITE && !is_ro_replica)
            expire_flags |= EXPIRE_FORCE_DELETE_EXPIRED;
        if (flags & LOOKUP_NOEXPIRE)
            expire_flags |= EXPIRE_AVOID_DELETE_EXPIRED;
        if (expireIfNeeded(db, key, expire_flags)) {
            /* The key is no longer valid. */
            val = NULL;
        }
    }
    
    // 其它的 flags 行为
    if (val) {
        ...
    } else {
        ...
    }

    return val;
}

介绍 dictFind 方法之前,我们先来看一下相关的 redis 数据结构关系。一个 redisDb 类型用来代表一个 redis 数据库,它的 dict 成员用来存储键值对数据,dict 中存储哈希表(实际会存储两个哈希表),dictEntry 是哈希表中的一个条目,存储单个键值对,dictEntry 使用拉链法(链表)来解决哈希冲突,数据结构关系图如下:

用 Redis 做分布式锁?配合源码食用更稳妥 - SET NX 命令剖析

根据这些相关的数据结构关系可以得知, dictFind 其实就是获取 key 的 hash 值然后从哈希表中查找出对应条目。

dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    uint64_t h, idx, table;

    if (dictSize(d) == 0) return NULL; /* dict is empty */
    if (dictIsRehashing(d)) _dictRehashStep(d);
    // 调用 hash 函数获取 key 的 hash 值
    h = dictHashKey(d, key);
    for (table = 0; table <= 1; table++) {
        idx = h & DICTHT_SIZE_MASK(d->ht_size_exp[table]);
        // 从哈希表中查找对应的 entry 条目
        he = d->ht_table[table][idx];
        while(he) {
            // 遍历 entry 链表,对比 key 值并返回最终的 entry 键值对
            void *he_key = dictGetKey(he);
            if (key == he_key || dictCompareKeys(d, key, he_key))
                return he;
            he = dictGetNext(he);
        }
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}

关于 rehashing,之前提到过在 dict 结构下实际存储了两个哈希表,这是为了支持渐进式 rehashing,即渐进式地对字典进行扩容和数据迁移。如果只有一个哈希表,当数据量过大需要扩容或迁移时,会占用很长时间从而影响系统性能,因此采用这种渐进式的方式将数据迁移操作分摊到单次命令中进行,保证了系统的高可用和性能。

扩展 - setKey

setKey 方法源码位于 src/db.c 文件,由于方法里处理了许多不同的场景,这里我们只关注 SET NX 命令的情况。

void dbAdd(redisDb *db, robj *key, robj *val) {
    // 最终会执行到这个方法里
    dbAddInternal(db, key, val, 0);
}

void setKey(client *c, redisDb *db, robj *key, robj *val, int flags) {
    int keyfound = 0;

    ...
    if (!(flags & SETKEY_DOESNT_EXIST))
        keyfound = (lookupKeyWrite(db,key) != NULL);

    if (!keyfound) {
        // 向 db 中添加键值对
        dbAdd(db,key,val);
    }
    ...
}

内部的 dbAddInternal 方法会在 dict 中为键值对(entry)分配一个内存空间,然后设置它的 key 和 value。键值对的存储位置计算与查找时是相似的,都是根据 key 计算出 hash 并插入到一个合适的哈希表中。

static void dbAddInternal(redisDb *db, robj *key, robj *val, int update_if_existing) {
    ...
    // 为键值对在 dict 中分配一个内存空间
    dictEntry *de = dictAddRaw(db->dict, key->ptr, &existing);
    // 设置 key
    dictSetKey(db->dict, de, sdsdup(key->ptr));
    // 设置 value
    dictSetVal(db->dict, de, val);
    ...
}

总结

redis 的 SET NX 命令实现了一个基本的原子性操作,因此它可以用来实现分布式锁机制。这里有一些 redis 官方的分布式锁实现,它们的实现更为健壮,有需要的可以参考一下。

PS:从文章中应该可以看出,实际上 redis 的源码逻辑是非常清晰易读的,后续我也会进行一些其它重要模块的解读。希望这篇文章的内容能够起到一些帮助。