AIGC标识 redis-源码带读-05-集群事务与高级特性

Redis 源码带读 第5篇:集群、事务与高级特性

本篇目标

了解Redis的集群架构、事务机制和高级特性。

前置知识

  • 阅读过第1-4篇
  • 了解分布式系统基本概念

1. Redis Cluster(cluster.c)

1.1 哈希槽(Hash Slot)

Redis Cluster将所有数据划分为16384个哈希槽:

slot = CRC16(key) % 16384

每个节点负责一部分槽。客户端发送命令时,先计算key对应的槽,发给对应节点。

1.2 Cluster数据结构

// cluster.h
typedef struct clusterState {
    clusterNode *myself;           // 当前节点
    clusterNode **slots;           // 16384个槽的指针数组
    int slots_count;               // 槽数量
    dict *nodes;                   // 节点字典(nodeID -> node)
    clusterNode *nodes_buckets[CLUSTER_SLOTS/16]; // 槽的位图
    ...
} clusterState;

typedef struct clusterNode {
    char name[CLUSTER_NAMELEN];    // 节点ID(40字节十六进制)
    int flags;                     // 节点状态(master/slave/fail等)
    uint64_t configEpoch;          // 配置纪元(用于故障转移)
    clusterNode *slaveof;          // 主节点指针(从节点才有)
    unsigned char slots[CLUSTER_SLOTS/8]; // 槽位图
    int numslots;                  // 负责的槽数量
    int numslaves;                 // 从节点数量
    clusterNode **slaves;          // 从节点数组
    ...
} clusterNode;

1.3 Gossip协议

节点之间通过Gossip协议交换状态信息:每个节点定期向随机节点发送PING,接收PONG,更新集群状态。

int clusterCommand(client *c) {
    if (!strcasecmp(c->argv[1]->ptr, "meet")) {
        // CLUSTER MEET <ip> <port>
        // 添加新节点到集群
        clusterProcessGossipSection(c, hdr);
    } else if (!strcasecmp(c->argv[1]->ptr, "nodes")) {
        // CLUSTER NODES
        // 返回集群中所有节点信息
        clusterGenNodesDescription(0);
    } else if (!strcasecmp(c->argv[1]->ptr, "info")) {
        // CLUSTER INFO
        // 返回集群状态信息
        clusterInfoCommand(c);
    }
    ...
}

Gossip消息格式

+-------+-------+-------+-------+-------+
| type  | sender| state | slots | nodes |
+-------+-------+-------+-------+-------+
| 1字节 | 40字节| 1字节 | 2KB   | 动态  |
+-------+-------+-------+-------+-------+

1.4 MOVED/ASK重定向

  • MOVED:key所在的槽已迁移到其他节点,客户端应更新本地路由表
  • ASK:槽正在迁移中,临时重定向
// 当key不在当前节点时
void getKeysFromCommand(client *c, ...);
int getKeysFromCommandWithFlags(client *c, ...);

// 检查key是否在当前节点
if (keyslot != myself->slots) {
    // 发送MOVED重定向
    addReplyError(c, "MOVED <slot> <ip>:<port>");
    return;
}

// 槽正在迁移时发送ASK重定向
if (slot_state == CLUSTER_SLOT_IMPORTING) {
    addReplyError(c, "ASK <slot> <ip>:<port>");
    return;
}

1.5 故障转移

当主节点故障时,其从节点发起选举(Raft-like),获得多数投票后升级为主节点。

// 故障检测
int clusterNodeFailure(clusterNode *node) {
    // 检查节点是否超时未响应
    if (node->flags & CLUSTER_NODE_PFAIL) {
        // 收集足够的PFAIL报告
        if (clusterNodeFailureReportsCount(node) >= clusterGetN/AE_FAIL_HELD) {
            node->flags &= ~CLUSTER_NODE_PFAIL;
            node->flags |= CLUSTER_NODE_FAIL;
            // 广播FAIL消息
            clusterSendFail(node->name);
        }
    }
}

// 故障转移
int clusterFailoverReplaceYourMaster(void) {
    // 1. 选择新的主节点
    clusterNode *newmaster = clusterSelectSlaveOfMaster(myself->slaveof);

    // 2. 将从节点升级为主节点
    clusterBumpConfigEpoch(newmaster);
    newmaster->flags &= ~CLUSTER_NODE_SLAVE;
    newmaster->flags |= CLUSTER_NODE_MASTER;
    newmaster->slaveof = NULL;

    // 3. 更新槽的归属
    for (int i = 0; i < CLUSTER_SLOTS; i++) {
        if (clusterNodeGetSlot(newmaster, i)) {
            server.cluster->slots[i] = newmaster;
        }
    }

    // 4. 广播更新后的配置
    clusterBroadcastPong(CLUSTER_BROADCAST_ALL);
    return C_OK;
}

2. 事务(multi.c)

2.1 MULTI/EXEC

void multiCommand(client *c) {
    // 检查是否已经在事务中
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c, "MULTI calls can not be nested");
        return;
    }

    // 开始事务
    c->flags |= CLIENT_MULTI;
    c->mstate.count = 0;
    c->mstate.cmd_flags = 0;
    addReply(c, shared.ok);
}

void execCommand(client *c) {
    // 1. 检查是否在事务中
    if (!(c->flags & CLIENT_MULTI)) {
        addReplyError(c, "EXEC without MULTI");
        return;
    }

    // 2. 执行所有入队命令
    for (int j = 0; j < c->mstate.count; j++) {
        client *cmd_client = c->mstate.commands[j];
        // 执行命令
        call(cmd_client, CMD_CALL_FULL);
    }

    // 3. 清理事务状态
    c->flags &= ~CLIENT_MULTI;
    c->mstate.count = 0;

    addReply(c, shared.ok);
}

2.2 WATCH乐观锁

void watchCommand(client *c) {
    for (int j = 1; j < c->argc; j++) {
        robj *key = c->argv[j];

        // 为每个被监控的key创建一个watchedKey
        watchedKey *wk = zmalloc(sizeof(watchedKey));
        wk->key = key;
        wk->db = c->db;

        // 将watchedKey添加到key的watched_keys字典
        dictAdd(key->watched_keys, wk, NULL);

        // 将key添加到客户端的watched_keys列表
        listAddNodeTail(c->client->watched_keys, wk);
    }
    addReply(c, shared.ok);
}

void touchWatchedKey(redisDb *db, robj *key) {
    // 当key被修改时,标记所有监控该key的客户端为CLIENT_DIRTY_CAS
    list *clients = dictFetchValue(key->watched_keys, NULL);
    if (clients) {
        listIter li;
        listNode *ln;
        listRewind(clients, &li);
        while ((ln = listNext(&li))) {
            client *client = listNodeValue(ln);
            client->flags |= CLIENT_DIRTY_CAS;
        }
    }
}

void execCommand(client *c) {
    // 在执行前检查是否有watched key被修改
    if (c->flags & CLIENT_DIRTY_CAS) {
        // 有key被修改,事务失败
        addReply(c, shared.null);
        c->flags &= ~(CLIENT_MULTI | CLIENT_DIRTY_CAS);
        return;
    }
    // 执行事务...
}

2.3 重要特性

Redis事务不支持回滚! 如果EXEC执行中某条命令失败,其他命令仍然执行。这是因为Redis追求简单和性能。

事务的限制

  • 不能在事务中使用SUBSCRIBE、WATCH等命令
  • 不能根据某个key的值决定后续操作
  • 没有真正的回滚机制

Lua脚本的优势

  • 可以在脚本中使用条件逻辑
  • 可以读取中间结果,根据结果决定后续操作
  • 更灵活,但脚本执行时间有限制(默认5秒)

3. EVAL Lua脚本(eval.c)

3.1 基本用法

EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 key value

3.2 执行流程

void evalCommand(client *c) {
    // 1. 计算脚本的SHA1哈希
    char funcname[41];
    sha1hex(funcname, c->argv[1]->ptr, sdslen(c->argv[1]->ptr));

    // 2. 检查脚本缓存中是否存在
    if (scriptExists(funcname)) {
        // 脚本已缓存,直接执行
        evalGenericCommand(c, 0);
    } else {
        // 脚本不存在,加载并缓存
        evalLuaScript(c);
    }
}

void evalGenericCommand(client *c, int evalsha) {
    // 1. 创建Lua脚本执行上下文
    luaScript *script = createScript(c->argv[1]->ptr, ...);

    // 2. 设置KEYS和ARGV参数
    for (int i = 0; i < numkeys; i++) {
        lua_pushlstring(lua, keys[i]->ptr, sdslen(keys[i]->ptr));
        lua_setglobal(lua, "KEYS");
    }

    // 3. 执行脚本
    int err = lua_pcall(lua, 0, 0, 0);

    // 4. 获取返回结果
    getReplyFromInterpreter(lua, c);
}

3.3 原子性

脚本执行期间不处理其他命令(类似MULTI/EXEC),但比事务更灵活。

原子性的保证

  1. 脚本执行期间,Redis单线程不处理其他命令
  2. 脚本中的所有命令在一个原子操作中执行
  3. 脚本可以读取中间结果,根据结果决定后续操作

3.4 SCRIPT FLUSH/CHECKSHA

  • SCRIPT FLUSH:清空脚本缓存
  • SCRIPT EXISTS sha1:检查脚本是否存在
  • SCRIPT KILL:终止正在执行的脚本

4. 发布订阅(pubsub.c)

4.1 基本命令

命令 说明
SUBSCRIBE channel 订阅频道
UNSUBSCRIBE channel 取消订阅
PUBLISH channel message 发布消息
PSUBSCRIBE pattern 模式订阅(通配符)

4.2 实现原理

void subscribeCommand(client *c) {
    for (int j = 1; j < c->argc; j++) {
        robj *channel = c->argv[j];

        // 1. 将频道添加到客户端的订阅列表
        listAddNodeTail(c->pubsub_channels, channel);

        // 2. 将客户端添加到频道的订阅者列表
        dict *channels = server.pubsub_channels;
        if (dictFind(channels, channel) == NULL) {
            // 创建新的频道字典
            list *clients = listCreate();
            dictAdd(channels, channel, clients);
        }

        // 3. 将客户端添加到频道的订阅者列表
        list *clients = dictFetchValue(channels, channel);
        listAddNodeTail(clients, c);
    }
    addReply(c, shared.ok);
}

void publishCommand(client *c) {
    robj *channel = c->argv[1];
    robj *message = c->argv[2];

    // 1. 查找频道的订阅者列表
    dict *channels = server.pubsub_channels;
    list *clients = dictFetchValue(channels, channel);

    // 2. 向所有订阅者发送消息
    if (clients) {
        listIter li;
        listNode *ln;
        listRewind(clients, &li);
        while ((ln = listNext(&li))) {
            client *client = listNodeValue(ln);
            // 发送消息
            addReply(client, shared.message);
            addReplyBulk(client, channel);
            addReplyBulk(client, message);
        }
    }

    // 3. 返回收到消息的订阅者数量
    addReplyLongLong(c, listLength(clients));
}

4.3 Pub/Sub的限制

  • 消息不持久化
  • 不支持消息回放
  • 消息可能丢失(如果订阅者断线)
  • 需要此功能请使用Stream

5. Stream(t_stream.c)

5.1 概念

Redis Stream类似Kafka,提供:

  • 有序消息流
  • 消费者组(Consumer Group)
  • 消息持久化
  • ACK确认机制

5.2 Stream数据结构

typedef struct stream {
    rax *rax;                       // 消息索引(rax基数树)
    uint64_t length;                // 消息总数
    streamID last_id;               // 最后一条消息的ID
    streamID first_id;              // 第一条消息的ID
    streamID max_deleted_entry_id;  // 最大已删除消息ID
    rax *cgroups;                   // 消费者组字典
} stream;

typedef struct streamCG {
    sds name;                       // 消费者组名称
    streamID last_id;               // 最后投递的消息ID
    streamID pel;                   // 待确认消息列表(rax)
    rax *consumers;                 // 消费者字典
} streamCG;

typedef struct streamConsumer {
    sds name;                       // 消费者名称
    uint64_t seen_time;             // 最后活跃时间
    streamCG *group;                // 所属消费者组
    rax *pel;                       // 该消费者待确认的消息
} streamConsumer;

5.3 Stream的底层实现

  • rax(Radix Tree):压缩前缀树,按消息ID索引
  • 每个rax节点存储一个listpack(紧凑列表)
  • 消息ID格式:<毫秒时间戳>-<序列号>

5.4 消费者组

XGROUP CREATE mystream mygroup $    -> 创建消费者组
XREADGROUP GROUP mygroup consumer1 COUNT 1 STREAMS mystream >
  -> 读取新消息
XACK mystream mygroup 1526569495631-0
  -> 确认消息已处理

消费者组的实现

void xreadgroupCommand(client *c) {
    // 1. 查找或创建消费者组
    streamCG *cg = streamLookupCG(stream, groupname);

    // 2. 查找或创建消费者
    streamConsumer *consumer = streamLookupConsumer(cg, consumername, 1);

    // 3. 读取新消息
    if (strcmp(c->argv[4]->ptr, ">") == 0) {
        // 读取未投递的新消息
        streamIteratorInit(stream, &si, &cg->last_id, 1);
    } else {
        // 读取待确认的消息
        streamIteratorInit(stream, &si, &startid, 0);
    }

    // 4. 将消息添加到PEL(待确认列表)
    streamRaxInsert(&cg->pel, entry_id, ...);
    streamRaxInsert(&consumer->pel, entry_id, ...);

    // 5. 更新消费者组的last_id
    cg->last_id = entry_id;
}

void xackCommand(client *c) {
    // 1. 从PEL中移除已确认的消息
    streamRaxRemove(&cg->pel, entry_id, NULL);
    streamRaxRemove(&consumer->pel, entry_id, NULL);

    // 2. 返回确认的消息数量
    addReplyLongLong(c, acknowledged);
}

6. 阻塞命令(blocked.c)

6.1 BLPOP/BRPOP

BLPOP key1 key2 timeout
  -> 如果所有key都为空:阻塞客户端
  -> 当某个key有新元素入队时:唤醒客户端并返回
  -> 超时后返回nil

6.2 实现机制

void blpopCommand(client *c) {
    // 1. 尝试从所有key中弹出元素
    for (int j = 1; j < c->argc - 1; j++) {
        robj *val = listTypePop(c->db->dict, c->argv[j]->ptr);
        if (val) {
            // 找到非空key,直接返回
            addReply(c, shared.ok);
            return;
        }
    }

    // 2. 所有key都为空,阻塞客户端
    c->bpop.timeout = timeout;
    c->bpop.type = BLOCKED_LIST;
    c->bpop.keys = keys;

    // 将客户端从正常客户端列表中移除
    listDelNode(server.clients, listSearchKey(server.clients, c));

    // 添加到key的blocking_keys字表中
    for (int j = 1; j < c->argc - 1; j++) {
        dict *blocking_keys = c->db->blocking_keys;
        list *clients = dictFetchValue(blocking_keys, c->argv[j]);
        listAddNodeTail(clients, c);
    }

    // 设置超时事件
    aeCreateTimeEvent(server.el, timeout, unblockClientOnTimeout, c);
}

void unblockClient(client *c) {
    // 1. 从blocking_keys中移除
    for (int j = 1; j < c->argc - 1; j++) {
        dict *blocking_keys = c->db->blocking_keys;
        list *clients = dictFetchValue(blocking_keys, c->argv[j]);
        listDelNode(clients, listSearchKey(clients, c));
    }

    // 2. 添加回正常客户端列表
    listAddNodeTail(server.clients, c);

    // 3. 清除阻塞状态
    c->flags &= ~CLIENT_BLOCKED;
    c->bpop.type = BLOCKED_NONE;
}

6.3 阻塞命令列表

命令 说明
BLPOP/BRPOP 列表阻塞弹出
BZPOP/BZPOPMIN 有序集合阻塞弹出
XREAD BLOCK Stream阻塞读取
WAIT 等待复制完成

6.4 阻塞命令的唤醒机制

void signalBlockedClientsAwoken(redisDb *db, robj *key) {
    // 查找在该key上阻塞的客户端
    dict *blocking_keys = db->blocking_keys;
    list *clients = dictFetchValue(blocking_keys, key);

    if (clients) {
        listIter li;
        listNode *ln;
        listRewind(clients, &li);
        while ((ln = listNext(&li))) {
            client *c = listNodeValue(ln);

            // 检查是否有数据可弹出
            if (c->bpop.type == BLOCKED_LIST) {
                robj *val = listTypePop(db->dict, key);
                if (val) {
                    // 有数据,唤醒客户端
                    addReply(c, shared.ok);
                    addReplyBulk(c, key);
                    addReplyBulk(c, val);
                    unblockClient(c);
                }
            }
        }
    }
}

7. 本篇小结

概念 要点
Cluster 16384哈希槽 + Gossip + MOVED重定向
事务 MULTI/EXEC + WATCH,不支持回滚
Lua脚本 EVAL原子执行,比事务更灵活
Pub/Sub 发布订阅,不持久化
Stream Kafka-like消息流,消费者组
阻塞命令 客户端挂起直到有数据或超时

思考题

  1. 为什么Redis Cluster只用16384个槽而不是65536?
  2. Redis事务和数据库事务的本质区别是什么?
  3. EVAL脚本和MULTI/EXEC在原子性上有什么异同?
  4. Stream和Pub/Sub各自适合什么场景?

思考题解答

1. 为什么Redis Cluster只支持16384个slot而不是65536?

原因

  1. 心跳包大小:每个节点需要向其他节点发送心跳包,包含自己的slot信息。16384个slot只需要2KB(16384/8)的位图,而65536个slot需要8KB。心跳包更小,网络开销更低。

  2. 性能考虑:slot数量越多,查找和路由的开销越大。16384个slot对于大多数场景足够,且性能更好。

  3. 历史原因:16384是2^14,是一个合理的选择,既能支持足够多的节点,又能保持较小的位图。

  4. 节点数量限制:理论上最多支持16384个主节点,但实际中很少有超过1000个节点的集群。

2. Redis事务对数据库的修改顺序有什么特点?

特点

  1. 串行执行:MULTI/EXEC之间的所有命令在一个事务中原子执行,不会被其他客户端打断。

  2. 命令排队:所有命令先入队,然后一次性执行。执行过程中不会被其他命令插入。

  3. 无回滚:如果某个命令失败,其他命令仍然会执行。Redis不支持事务回滚。

  4. 命令有限制:不能在事务中使用SUBSCRIBE、WATCH等命令。

  5. Lua脚本:EVAL脚本中的命令也是串行执行,但可以在脚本中使用事务命令。

3. EVAL脚本和MULTI/EXEC的原子性有什么区别?

MULTI/EXEC

  • 命令排队后一次性执行
  • 不会读取中间结果
  • 不能使用条件逻辑(如根据某个key的值决定后续操作)

EVAL脚本

  • 命令在Lua脚本中执行,可以使用条件逻辑
  • 可以读取中间结果,根据结果决定后续操作
  • 更灵活,但脚本执行时间有限制(默认5秒)

关键区别

  • MULTI/EXEC:命令串行执行,但不能根据中间结果做决策
  • EVAL:可以读取中间结果,支持复杂逻辑
  • 两者都是原子性的,但EVAL更灵活

4. Stream和Pub/Sub分别适合什么场景?

Pub/Sub适合

  1. 实时通知:如在线状态变更、新消息通知
  2. 广播消息:一条消息需要发送给多个订阅者
  3. 低延迟要求:消息立即发送,不保存历史

Stream适合

  1. 消息队列:需要持久化和回放的消息
  2. 消费者组:多个消费者共同消费一组消息
  3. 消息确认:需要确认消息被正确处理
  4. 消息历史:需要保留消息历史,支持回溯

对比

特性 Pub/Sub Stream
持久化 不持久化 持久化
消费者组 不支持 支持
消息确认 不支持 支持
消息回放 不支持 支持
延迟 极低 较低
内存占用 较高

建议:如果需要消息持久化和消费者组,使用Stream;如果只需要实时广播,使用Pub/Sub。

posted @ 2026-09-04 17:26  IcarusLee  阅读(3)  评论(0)    收藏  举报