精解计算机系统课程

  1. MIT6.824 分布式系统系列

MIT 分布式系统(六)Multi-Raft 设计与实现 #

设计思考

在上一章中,我们应用 Raft 实现了一个单分组的 KV 集群。客户端写请求到 Leader,Leader 把操作复制到 Follower 节点。当 Leader 挂了,会按第四章描述的 Raft 算法库进行一轮新的选举,选出新的 Leader 后继续提供服务。这样我们就有了一个高可用的集群。

我们通过单分组的 KV 集群实现了高可用以及分区容忍性,但是分布式系统还有一个可扩展的特性——我们可以通过增加机器来让系统实现更高的吞吐量。第五章实现的是单分组集群,只有单个 Leader 节点承担客户端的写入,吞吐量上限取决于单机性能。那么我们该如何实现分布式可扩展性呢?

接下来我们将介绍 Multi-Raft 实现,它可以解决单分组集群的可扩展性问题。思路是这样的:既然单组有上限,那么我们可不可以用多组 Raft KV 集群来实现扩展呢?我们需要有一个配置中心来管理多个分组服务器的地址信息。有了多个分组之后,我们就要考虑怎样把用户的请求均衡地分发到相应的分组服务器上。我们可以使用哈希算法来解决这个问题,对用户的 key 做哈希计算,然后映射到不同的服务分片(Shard)上,这样可以保证流量的均衡。

整合一下上面的思路,我们可以得到如下的系统架构图:

首先,客户端启动之后,会从配置服务器(ShardCtrler)拉取集群的分组信息以及分组负责的数据分片(Shard)信息到本地。客户端发送请求的时候会计算 key 的哈希值,找到相应的分片以及负责这个分片的服务器分组(ShardKV)地址信息,然后将操作发送到对应的服务器分组进行处理。这里 ShardCtrler 服务器分组和 ShardKV 服务器分组都是高可用的,多个 ShardKV 实现了系统的可扩展性。

在 raft_cpp 中,这一章对应 MIT 6.824 的 Lab 4A(ShardCtrler)和 Lab 4B(ShardKV)。下面我们分别来看这两个核心组件的 C++ 实现。

ShardCtrler 配置服务器实现

配置服务的实现在 shardctrler.h / shardctrler.cpp 中。根据上面的架构图,配置服务器需要存储以下信息:(1)每个服务分组的服务器地址信息;(2)服务分组负责的分片信息。这个结构定义在 sc_common.h 中:

// sc_common.h

constexpr int NShards = 10;  // 系统中分片的最大数量

struct SCConfig {
    int Num = 0;                                       // 配置版本号
    std::array<int, NShards> Shards = {};               // shard -> gid
    std::map<int, std::vector<std::string>> Groups;      // gid -> servers[]

    SCConfig() { Shards.fill(0); }
};

inline SCConfig DefaultSCConfig() {
    SCConfig c;
    c.Num = 0;
    return c;
}
      

Num 表示当前配置的版本号。Shards 数组存储了每个分片由哪个分组(gid)负责。NShards 是一个常量,表示系统中分片的数量,默认为 10。对于大规模的分布式系统,NShards 可以被设置得更大。Groups 是一个映射表,记录了每个 gid 对应的服务器地址列表。

配置服务器支持四个操作:Join(加入新分组)、Leave(删除分组)、Move(重新分配分片)、Query(查询配置)。操作类型和请求/响应结构定义如下:

// sc_common.h

enum SCOperationOp : uint8_t {
    SCOpJoin  = 0,
    SCOpLeave = 1,
    SCOpMove  = 2,
    SCOpQuery = 3
};

struct SCCommandRequest {
    std::map<int, std::vector<std::string>> Servers;  // for Join
    std::vector<int> GIDs;                             // for Leave
    int Shard = 0;                                     // for Move
    int GID   = 0;                                     // for Move
    int Num   = 0;                                     // for Query
    SCOperationOp Op = SCOpQuery;
    int64_t ClientId  = 0;
    int64_t CommandId = 0;
};

struct SCCommandResponse {
    SCErr    Err = SC_OK;
    SCConfig Config;
};
      

配置服务器定义了一个抽象的状态机接口 ConfigStateMachine,以及基于内存的实现 MemoryConfigStateMachine:

// sc_statemachine.h

class ConfigStateMachine {
public:
    virtual ~ConfigStateMachine() = default;
    virtual SCErr Join(const std::map<int, std::vector<std::string>>& groups) = 0;
    virtual SCErr Leave(const std::vector<int>& gids) = 0;
    virtual SCErr Move(int shard, int gid) = 0;
    virtual std::pair<SCConfig, SCErr> Query(int num) = 0;
    virtual void Close() = 0;
    virtual int64_t Size() = 0;
};

class MemoryConfigStateMachine : public ConfigStateMachine {
public:
    MemoryConfigStateMachine();
    SCErr Join(const std::map<int, std::vector<std::string>>& groups) override;
    SCErr Leave(const std::vector<int>& gids) override;
    SCErr Move(int shard, int gid) override;
    std::pair<SCConfig, SCErr> Query(int num) override;
    void Close() override {}
    int64_t Size() override { return static_cast<int64_t>(configs_.size()); }

protected:
    std::vector<SCConfig> configs_;
};
      

MemoryConfigStateMachine 内部维护了一个 configs_ 向量,保存了所有历史版本的配置。Join 操作会创建新的配置版本,将新分组加入 Groups 映射,并将新分片分配给分组中最少分片的组。Leave 操作会移除指定分组,并将这些分组负责的分片重新分配。Move 操作会将指定分片从一个分组迁移到另一个分组。Query 操作根据版本号查询配置,num=-1 表示查询最新配置。

ShardCtrler 类的设计与第五章的 KVServer 非常类似,只是将 KV 操作替换为配置操作:

// shardctrler.h

class ShardCtrler : public std::enable_shared_from_this<ShardCtrler> {
public:
    static std::shared_ptr<ShardCtrler> Make(
        std::vector<std::shared_ptr<RaftPeer>> peers,
        int me,
        std::shared_ptr<Persister> persister);

    void HandleCommand(const SCCommandRequest& req, SCCommandResponse& resp);
    void Kill();

private:
    ShardCtrler() = default;

    mutable std::mutex mu_;
    std::atomic<bool> dead_{false};

    std::shared_ptr<Raft> rf_;
    std::shared_ptr<BlockingQueue<ApplyMsg>> applyCh_;
    int lastApplied_ = 0;

    std::unique_ptr<ConfigStateMachine> stateMachine_;
    std::unordered_map<int64_t, SCOperationContext> lastOperations_;
    std::unordered_map<int, std::shared_ptr<BlockingQueue<SCCommandResponse>>> notifyChans_;

    std::thread applierThread_;
};
      

HandleCommand 的处理流程与 KVServer::HandleCommand 几乎一样:先做去重检查(非 Query 操作),然后序列化提交到 Raft,等待 applier 通过 notifyChan 通知结果:

// shardctrler.cpp

void ShardCtrler::HandleCommand(const SCCommandRequest& req, SCCommandResponse& resp) {
    // 1. 去重检查(非 Query 操作)
    {
        std::lock_guard<std::mutex> lk(mu_);
        if (req.Op != SCOpQuery && isDuplicateRequest(req.ClientId, req.CommandId)) {
            auto it = lastOperations_.find(req.ClientId);
            if (it != lastOperations_.end()) {
                resp = it->second.LastResponse;
                return;
            }
        }
    }

    // 2. 序列化并提交到 Raft
    SCCommand cmd;
    cmd.Request = req;
    auto cmdBytes = scser::serializeCommand(cmd);

    auto result = rf_->Start(cmdBytes);
    if (!result.isLeader) {
        resp.Err = SC_ErrWrongLeader;
        return;
    }

    // 3. 等待通知
    std::shared_ptr<BlockingQueue<SCCommandResponse>> ch;
    {
        std::lock_guard<std::mutex> lk(mu_);
        ch = getNotifyChan(result.index);
    }

    SCCommandResponse reply;
    bool got = ch->pop_for(reply, kSCExecuteTimeout);

    if (got) {
        resp = reply;
    } else {
        resp.Err = SC_ErrTimeout;
    }

    // 4. 异步清理
    std::thread([this, index = result.index]() {
        std::lock_guard<std::mutex> lk(mu_);
        removeOutdatedNotifyChan(index);
    }).detach();
}
      

applier 线程从 applyCh 中读取已提交的消息,根据操作类型应用到配置状态机:

// shardctrler.cpp

SCCommandResponse ShardCtrler::applyLogToStateMachine(const SCCommand& cmd) {
    SCCommandResponse resp;
    switch (cmd.Request.Op) {
        case SCOpJoin:
            resp.Err = stateMachine_->Join(cmd.Request.Servers);
            break;
        case SCOpLeave:
            resp.Err = stateMachine_->Leave(cmd.Request.GIDs);
            break;
        case SCOpMove:
            resp.Err = stateMachine_->Move(cmd.Request.Shard, cmd.Request.GID);
            break;
        case SCOpQuery: {
            auto [config, err] = stateMachine_->Query(cmd.Request.Num);
            resp.Config = config;
            resp.Err = err;
            break;
        }
    }
    return resp;
}
      

SCClerk 是配置服务器的客户端,提供 Query/Join/Leave/Move 四个接口,内部实现了与 Clerk 类似的自动重试和 Leader 发现逻辑。ShardKV 节点通过 SCClerk 来查询最新的配置信息,客户端通过 SCClerk 来加入/离开分组。

// sc_clerk.h

class SCClerk {
public:
    explicit SCClerk(std::vector<std::shared_ptr<SCClerkPeer>> servers);

    SCConfig Query(int num);                                    // 查询配置
    void Join(const std::map<int, std::vector<std::string>>& servers);  // 加入分组
    void Leave(const std::vector<int>& gids);                      // 离开分组
    void Move(int shard, int gid);                              // 迁移分片

private:
    SCConfig doCommand(const SCCommandRequest& req);

    std::vector<std::shared_ptr<SCClerkPeer>> servers_;
    int     leaderId_  = 0;
    int64_t clientId_  = 0;
    int64_t commandId_ = 0;
};
      

ShardKV 分片服务器实现

ShardKV 是实际处理客户端 KV 请求的分片服务器。与第五章的 KVServer 不同,ShardKV 需要处理分片迁移、配置更新、跨组通信等复杂逻辑。我们先来看分片状态和命令类型的定义:

// skv_common.h

// 分片状态
enum ShardStatus : uint8_t {
    Serving   = 0,   // 正常服务
    Pulling   = 1,   // 正在从其他分组拉取数据
    BePulling = 2,   // 正在被其他分组拉取数据
    GCing     = 3    // 等待垃圾回收(数据已拉走但尚未清理)
};

// 命令类型
enum SKVCommandType : uint8_t {
    SKV_Operation     = 0,  // 普通 KV 操作
    SKV_Configuration = 1,  // 配置更新
    SKV_InsertShards  = 2,  // 插入拉取到的分片数据
    SKV_DeleteShards  = 3,  // 删除已迁走的分片数据
    SKV_EmptyEntry    = 4   // 空条目(保证 Leader 有当前任期日志)
};
      

分片有四种状态,构成了一个状态机:Serving 表示分片正常服务客户端请求;Pulling 表示分片刚分配给本组,正在从旧所有者拉取数据;BePulling 表示分片不再属于本组,正在被新所有者拉取数据;GCing 表示数据已被新所有者拉走,等待本地清理。

分片状态机演示

新所有者路径 Serving 正常服务 Pulling 拉取数据 GCing 等待清理 Serving 获得所有权 数据接收完成 GC完成 旧所有者路径 Serving 正常服务 BePulling 被拉取中 Serving 失去所有权 清除数据 点击「播放」查看分片状态转换过程

SKVCommand 是存储在 Raft 日志中的命令包装,支持五种类型:普通 KV 操作、配置更新、插入分片数据、删除分片数据和空条目。空条目的作用是保证 Leader 在当前任期有日志条目,这对于 Raft 的选举承诺机制很重要。

// skv_common.h

struct SKVCommand {
    SKVCommandType type = SKV_EmptyEntry;
    std::vector<uint8_t> data;  // 序列化的 payload
};

// 分片操作请求(跨组通信)
struct ShardOperationRequest {
    int              configNum = 0;
    std::vector<int> shardIDs;
};

// 分片操作响应(跨组通信)
struct ShardOperationResponse {
    SKVErr  err       = SKV_OK;
    int     configNum = 0;
    std::map<int, std::map<std::string, std::string>> shards;        // shardID -> KV data
    std::map<int64_t, SKVOperationContext>             lastOperations; // 去重信息
};

// key 到分片的映射
inline int key2shard(const std::string& key) {
    int shard = 0;
    if (!key.empty()) {
        shard = static_cast<unsigned char>(key[0]);
    }
    return shard % NShards;
}
      

key2shard() 使用 key 的第一个字符对 NShards 取模来计算分片 ID。ShardOperationRequest/Response 用于跨组通信,当分片迁移时,新所有者通过 GetShardsData RPC 从旧所有者拉取数据,旧所有者通过 DeleteShardsData RPC 通知新所有者可以清理数据。

ShardKV 类是整个分片服务器的核心,它继承了 enable_shared_from_this 并运行 5 个后台线程:

// shardkv.h

class ShardKV : public std::enable_shared_from_this<ShardKV> {
public:
    static std::shared_ptr<ShardKV> Make(
        std::vector<std::shared_ptr<RaftPeer>> peers,
        int me,
        std::shared_ptr<Persister> persister,
        int maxRaftState,
        int gid,
        std::shared_ptr<SCClerk> sc,
        ShardKVPeerProvider peerProvider);

    void Command(const SKVCommandRequest& req, SKVCommandResponse& resp);
    void GetShardsData(const ShardOperationRequest& req, ShardOperationResponse& resp);
    void DeleteShardsData(const ShardOperationRequest& req, ShardOperationResponse& resp);

private:
    // ── 成员变量 ──
    std::shared_ptr<Raft> rf_;
    std::shared_ptr<BlockingQueue<ApplyMsg>> applyCh_;
    int maxRaftState_ = -1;
    int lastApplied_  = 0;

    int gid_;                                    // 本组的分组 ID
    std::shared_ptr<SCClerk> sc_;               // 配置服务器客户端

    std::array<std::map<std::string, std::string>, NShards> shards_;  // 分片数据
    std::array<ShardStatus, NShards> shardStatus_ = {};                 // 分片状态

    SCConfig currentConfig_;
    SCConfig lastConfig_;

    std::unordered_map<int64_t, SKVOperationContext> lastOperations_;
    std::unordered_map<int, std::shared_ptr<BlockingQueue<SKVCommandResponse>>> notifyChans_;

    ShardKVPeerProvider peerProvider_;            // 跨组通信的 peer 提供器

    // 5 个后台线程
    std::thread applierThread_;
    std::thread configureThread_;
    std::thread migrationThread_;
    std::thread gcThread_;
    std::thread emptyEntryThread_;
};
      

ShardKV 的构造流程在 Make() 工厂方法中,启动了 5 个后台线程:

// shardkv.cpp

std::shared_ptr<ShardKV> ShardKV::Make(
    std::vector<std::shared_ptr<RaftPeer>> peers,
    int me,
    std::shared_ptr<Persister> persister,
    int maxRaftState,
    int gid,
    std::shared_ptr<SCClerk> sc,
    ShardKVPeerProvider peerProvider)
{
    auto applyCh = std::make_shared<BlockingQueue<ApplyMsg>>();

    std::shared_ptr<ShardKV> kv(new ShardKV());
    kv->maxRaftState_  = maxRaftState;
    kv->applyCh_       = applyCh;
    kv->rf_            = raft::Raft::Make(peers, me, persister, applyCh);
    kv->gid_           = gid;
    kv->sc_            = sc;
    kv->peerProvider_  = std::move(peerProvider);
    kv->currentConfig_ = DefaultSCConfig();
    kv->lastConfig_    = DefaultSCConfig();

    // 初始化分片状态
    kv->initStateMachines();

    // 从快照恢复
    auto snap = persister->ReadSnapshot();
    if (!snap.empty()) {
        kv->restoreSnapshot(snap);
    }

    // 使用 weak_ptr 避免循环引用
    std::weak_ptr<ShardKV> weakKv = kv;

    // 启动 5 个后台线程
    kv->applierThread_     = std::thread(&ShardKV::applier, kv.get());
    kv->configureThread_   = std::thread(&ShardKV::monitorLoop, kv.get(),
        [weakKv]() { auto kv = weakKv.lock(); if (kv) kv->configureAction(); },
        kConfigureMonitorTimeout);
    kv->migrationThread_   = std::thread(&ShardKV::monitorLoop, kv.get(),
        [weakKv]() { auto kv = weakKv.lock(); if (kv) kv->migrationAction(); },
        kMigrationMonitorTimeout);
    kv->gcThread_          = std::thread(&ShardKV::monitorLoop, kv.get(),
        [weakKv]() { auto kv = weakKv.lock(); if (kv) kv->gcAction(); },
        kGCMonitorTimeout);
    kv->emptyEntryThread_  = std::thread(&ShardKV::monitorLoop, kv.get(),
        [weakKv]() { auto kv = weakKv.lock(); if (kv) kv->checkEntryInCurrentTermAction(); },
        kEmptyEntryDetectorTimeout);

    return kv;
}
      

注意这里使用了 weak_ptr 来避免循环引用:线程的 lambda 捕获了 weak_ptr 而非 shared_ptr,每次执行时先尝试 lock() 获取 shared_ptr,如果对象已销毁则跳过。这是 C++ 生命周期管理的常见模式。

5 个线程的职责分别是:

applier:从 applyCh 读取 Raft 提交的消息,应用到状态机。
configureThread:定期查询配置服务器是否有新配置,有则提交到 Raft。
migrationThread:定期检查是否有分片需要从其他组拉取数据。
gcThread:定期检查是否有分片需要通知旧所有者清理数据。
emptyEntryThread:定期检查 Leader 是否在当前任期有日志,没有则提交空条目。

请求处理:Command() 与 Execute()

// shardkv.cpp

void ShardKV::Command(const SKVCommandRequest& req, SKVCommandResponse& resp) {
    int shardID = key2shard(req.key);
    {
        std::lock_guard<std::mutex> lk(mu_);
        // 去重检查(非 Get 操作)
        if (req.op != OpGet && isDuplicateRequest(req.clientId, req.commandId)) {
            auto it = lastOperations_.find(req.clientId);
            if (it != lastOperations_.end()) {
                resp = it->second.lastResponse;
                return;
            }
        }
        // 检查是否可以服务这个分片
        if (!canServe(shardID)) {
            resp.err = SKV_ErrWrongGroup;
            return;
        }
    }

    // 构造 Operation 命令并执行
    auto cmd = skvser::makeOperationCmd(req);
    Execute(cmd, resp);
}

void ShardKV::Execute(const SKVCommand& cmd, SKVCommandResponse& resp) {
    auto cmdBytes = skvser::serializeCommand(cmd);

    auto result = rf_->Start(cmdBytes);
    if (!result.isLeader) {
        resp.err = SKV_ErrWrongLeader;
        return;
    }

    // 等待结果
    std::shared_ptr<BlockingQueue<SKVCommandResponse>> ch;
    {
        std::lock_guard<std::mutex> lk(mu_);
        ch = getNotifyChan(result.index);
    }

    SKVCommandResponse reply;
    bool got = ch->pop_for(reply, kSKVExecuteTimeout);

    if (got) {
        resp = reply;
    } else {
        resp.err = SKV_ErrTimeout;
    }

    // 异步清理
    std::weak_ptr<ShardKV> weakSelf = shared_from_this();
    std::thread([weakSelf, index = result.index]() {
        auto self = weakSelf.lock();
        if (!self) return;
        std::lock_guard<std::mutex> lk(self->mu_);
        self->removeOutdatedNotifyChan(index);
    }).detach();
}
      

Command() 首先计算 key 对应的分片 ID,然后检查去重和分片服务状态。canServe() 判断当前分组是否可以服务该分片——需要满足两个条件:配置表中该分片属于本组(gid_),且分片状态为 Serving 或 GCing。如果不能服务,返回 ErrWrongGroup,客户端会刷新配置重试。

// shardkv.cpp

bool ShardKV::canServe(int shardID) {
    return currentConfig_.Shards[shardID] == gid_ &&
           (shardStatus_[shardID] == Serving || shardStatus_[shardID] == GCing);
}
      

Applier 线程与 Apply 处理

applier 线程从 applyCh 中读取消息,根据命令类型分发到不同的处理函数:

// shardkv.cpp

void ShardKV::applier() {
    ApplyMsg message;
    while (applyCh_->pop(message)) {
        if (killed()) break;

        if (message.command_valid) {
            std::lock_guard<std::mutex> lk(mu_);

            if (message.command_index <= lastApplied_) {
                continue;
            }
            lastApplied_ = message.command_index;

            auto command = skvser::deserializeCommand(message.command);
            SKVCommandResponse response;

            switch (command.type) {
                case SKV_Operation: {
                    auto req = skvser::deserializeRequest(command.data.data());
                    response = applyOperation(req);
                    break;
                }
                case SKV_Configuration: {
                    auto config = skvser::deserializeConfig(command.data.data());
                    response = applyConfiguration(config);
                    break;
                }
                case SKV_InsertShards: {
                    auto info = skvser::deserializeShardOpResp(command.data.data());
                    response = applyInsertShards(info);
                    break;
                }
                case SKV_DeleteShards: {
                    auto req = skvser::deserializeShardOpReq(command.data.data());
                    response = applyDeleteShards(req);
                    break;
                }
                case SKV_EmptyEntry:
                    response.err = SKV_OK;
                    break;
            }

            // 通知等待的 Execute 调用(任期匹配时)
            auto [currentTerm, isLeader] = rf_->GetState();
            if (message.command_term == currentTerm) {
                auto ch = getNotifyChan(message.command_index);
                ch->push(response);
            }

            // 检查快照
            if (needSnapshot()) {
                takeSnapshot(message.command_index);
            }
        } else if (message.snapshot_valid) {
            std::lock_guard<std::mutex> lk(mu_);
            if (rf_->CondInstallSnapshot(
                    message.snapshot_term, message.snapshot_index, message.snapshot)) {
                restoreSnapshot(message.snapshot);
                lastApplied_ = message.snapshot_index;
            }
        }
    }
}
      

applyOperation() 处理普通 KV 操作,与 KVServer 类似但增加了分片状态检查:

// shardkv.cpp

SKVCommandResponse ShardKV::applyOperation(const SKVCommandRequest& req) {
    int shardID = key2shard(req.key);

    if (!canServe(shardID)) {
        return {SKV_ErrWrongGroup, ""};
    }

    // 去重检查
    if (req.op != OpGet && isDuplicateRequest(req.clientId, req.commandId)) {
        auto it = lastOperations_.find(req.clientId);
        if (it != lastOperations_.end()) {
            return it->second.lastResponse;
        }
    }

    SKVCommandResponse resp;
    switch (req.op) {
        case OpPut:
            shards_[shardID][req.key] = req.value;
            resp.err = SKV_OK;
            break;
        case OpAppend: {
            auto& val = shards_[shardID][req.key];
            val += req.value;
            resp.value = val;
            resp.err = SKV_OK;
            break;
        }
        case OpGet: {
            auto it = shards_[shardID].find(req.key);
            if (it != shards_[shardID].end()) {
                resp.value = it->second;
                resp.err = SKV_OK;
            } else {
                resp.err = SKV_ErrNoKey;
            }
            break;
        }
    }

    // 更新去重表(非 Get 操作)
    if (req.op != OpGet) {
        SKVOperationContext ctx;
        ctx.maxAppliedCommandId = req.commandId;
        ctx.lastResponse = resp;
        lastOperations_[req.clientId] = ctx;
    }

    return resp;
}
      

注意 ShardKV 的分片数据存储在内存中(std::array<std::map<std::string, std::string>, NShards>),而非使用 RocksDB。这是因为每个分片的数据量较小,内存存储更方便迁移和快照。

配置更新:applyConfiguration() 与 updateShardStatus()

// shardkv.cpp

SKVCommandResponse ShardKV::applyConfiguration(const SCConfig& nextConfig) {
    if (nextConfig.Num == currentConfig_.Num + 1) {
        updateShardStatus(nextConfig);
        lastConfig_ = currentConfig_;
        currentConfig_ = nextConfig;
        return {SKV_OK, ""};
    }
    return {SKV_ErrOutDated, ""};
}
      

当新配置的版本号正好比当前配置大 1 时,调用 updateShardStatus() 更新分片状态,然后更新 lastConfig_ 和 currentConfig_。updateShardStatus() 是分片状态机的核心:

// shardkv.cpp

void ShardKV::updateShardStatus(const SCConfig& nextConfig) {
    for (int i = 0; i < NShards; ++i) {
        int prevOwner = currentConfig_.Shards[i];
        int nextOwner = nextConfig.Shards[i];

        if (prevOwner != gid_ && nextOwner == gid_) {
            // 获得分片的所有权
            if (prevOwner == 0) {
                shardStatus_[i] = Serving;    // 之前没有所有者,直接服务
            } else {
                shardStatus_[i] = Pulling;    // 之前有所有者,需要拉取数据
            }
        } else if (prevOwner == gid_ && nextOwner != gid_) {
            // 失去分片的所有权
            if (nextOwner == 0) {
                // 分片分配给 gid 0(无所有者),不做处理
            } else {
                shardStatus_[i] = BePulling;  // 等待新所有者拉取数据
            }
        } else if (prevOwner == gid_ && nextOwner == gid_) {
            // 保留分片所有权
            if (shardStatus_[i] != Pulling) {
                shardStatus_[i] = Serving;
            }
            // 如果正在 Pulling,保持 Pulling 状态不变
        }
    }
}
      

updateShardStatus 的逻辑:对于每个分片,比较前后配置中的所有者:

- 如果本组获得分片所有权:之前无所有者(gid=0)则直接 Serving,之前有所有者则设为 Pulling(需要从旧所有者拉数据)。
- 如果本组失去分片所有权:设为 BePulling(等待新所有者拉取数据后再清理)。
- 如果本组保留分片所有权:保持原状态(如果正在 Pulling 则继续,否则设为 Serving)。

分片迁移:migrationAction() 与 applyInsertShards()

migrationThread 定期(50ms)检查是否有处于 Pulling 状态的分片,如果有,则从旧所有者拉取数据:

// shardkv.cpp

void ShardKV::migrationAction() {
    std::map<int, std::vector<int>> gid2shardIDs;
    int configNum;
    {
        std::lock_guard<std::mutex> lk(mu_);
        gid2shardIDs = getShardIDsByStatus(Pulling);
        configNum = currentConfig_.Num;
    }

    for (auto& [gid, shardIDs] : gid2shardIDs) {
        ShardOperationRequest pullReq;
        pullReq.configNum = configNum;
        pullReq.shardIDs  = shardIDs;

        // 尝试源分组中的每个服务器
        const auto& servers = lastConfig_.Groups.count(gid) ?
            lastConfig_.Groups.at(gid) : currentConfig_.Groups.at(gid);

        bool success = false;
        for (size_t i = 0; i < servers.size() && !success; ++i) {
            if (!peerProvider_) continue;
            auto peer = peerProvider_(gid, static_cast<int>(i), servers[i]);
            if (!peer) continue;

            ShardOperationResponse resp;
            if (peer->GetShardsData(pullReq, resp) && resp.err == SKV_OK) {
                // 通过 Raft 提交 InsertShards 命令
                auto cmd = skvser::makeInsertShardsCmd(resp);
                SKVCommandResponse cmdResp;
                Execute(cmd, cmdResp);
                success = true;
            }
        }
    }
}
      

migrationAction 首先找到所有处于 Pulling 状态的分片,按旧所有者 gid 分组。然后对每个旧所有者,尝试与其组中的服务器通信,调用 GetShardsData RPC 拉取分片数据和去重信息。成功后通过 Raft 提交 InsertShards 命令,确保所有副本一致地接收分片数据。

GetShardsData RPC 的处理(只在 Leader 上执行):

// shardkv.cpp

void ShardKV::GetShardsData(const ShardOperationRequest& req, ShardOperationResponse& resp) {
    auto [term, isLeader] = rf_->GetState();
    if (!isLeader) {
        resp.err = SKV_ErrWrongLeader;
        return;
    }

    std::lock_guard<std::mutex> lk(mu_);

    if (currentConfig_.Num < req.configNum) {
        resp.err = SKV_ErrNotReady;  // 还没更新到对应配置
        return;
    }

    // 返回请求的分片数据和去重信息
    resp.shards.clear();
    for (int shardID : req.shardIDs) {
        resp.shards[shardID] = shards_[shardID];
    }

    resp.lastOperations.clear();
    for (auto& [cid, ctx] : lastOperations_) {
        resp.lastOperations[cid] = ctx;
    }

    resp.configNum = req.configNum;
    resp.err = SKV_OK;
}
      

applyInsertShards() 在 applier 线程中被调用,将拉取到的分片数据应用到本地:

// shardkv.cpp

SKVCommandResponse ShardKV::applyInsertShards(const ShardOperationResponse& info) {
    if (info.configNum == currentConfig_.Num) {
        for (auto& [shardId, shardData] : info.shards) {
            if (shardStatus_[shardId] == Pulling) {
                for (auto& [k, v] : shardData) {
                    shards_[shardId][k] = v;
                }
                shardStatus_[shardId] = GCing;  // 数据已接收,等待清理旧数据
            }
        }
        // 合并去重信息
        for (auto& [cid, ctx] : info.lastOperations) {
            auto it = lastOperations_.find(cid);
            if (it == lastOperations_.end() ||
                it->second.maxAppliedCommandId < ctx.maxAppliedCommandId) {
                lastOperations_[cid] = ctx;
            }
        }
        return {SKV_OK, ""};
    }
    return {SKV_ErrOutDated, ""};
}
      

分片数据接收后,分片状态从 Pulling 变为 GCing,表示数据已到位但还需要通知旧所有者清理。同时合并去重信息,确保客户端在分片迁移后仍能正确去重。

垃圾回收:gcAction() 与 applyDeleteShards()

gcThread 定期(50ms)检查是否有处于 GCing 状态的分片,通知旧所有者可以清理数据:

// shardkv.cpp

void ShardKV::gcAction() {
    std::map<int, std::vector<int>> gid2shardIDs;
    int configNum;
    {
        std::lock_guard<std::mutex> lk(mu_);
        gid2shardIDs = getShardIDsByStatus(GCing);
        configNum = currentConfig_.Num;
    }

    for (auto& [gid, shardIDs] : gid2shardIDs) {
        ShardOperationRequest gcReq;
        gcReq.configNum = configNum;
        gcReq.shardIDs  = shardIDs;

        const auto& servers = lastConfig_.Groups.count(gid) ?
            lastConfig_.Groups.at(gid) : currentConfig_.Groups.at(gid);

        bool success = false;
        for (size_t i = 0; i < servers.size() && !success; ++i) {
            if (!peerProvider_) continue;
            auto peer = peerProvider_(gid, static_cast<int>(i), servers[i]);
            if (!peer) continue;

            ShardOperationResponse resp;
            if (peer->DeleteShardsData(gcReq, resp) && resp.err == SKV_OK) {
                // 通过 Raft 提交本地 DeleteShards 命令
                auto cmd = skvser::makeDeleteShardsCmd(gcReq);
                SKVCommandResponse cmdResp;
                Execute(cmd, cmdResp);
                success = true;
            }
        }
    }
}
      

DeleteShardsData RPC 发送给旧所有者,通知它这些分片的数据已经被新所有者接收。旧所有者收到后通过 Raft 提交 DeleteShards 命令,在 applyDeleteShards 中将分片状态从 BePulling 变为 Serving(或清理数据):

// shardkv.cpp

SKVCommandResponse ShardKV::applyDeleteShards(const ShardOperationRequest& req) {
    if (req.configNum == currentConfig_.Num) {
        for (int shardId : req.shardIDs) {
            if (shardStatus_[shardId] == GCing) {
                shardStatus_[shardId] = Serving;     // 新所有者:GC 完成,正常服务
            } else if (shardStatus_[shardId] == BePulling) {
                shardStatus_[shardId] = Serving;     // 旧所有者:清理数据
                clearShardData(shardId);
            }
        }
        return {SKV_OK, ""};
    }
    return {SKV_OK, ""};  // 已经过了这个配置版本,当作成功处理
}
      

这里有一个巧妙的设计:DeleteShards 命令在新所有者和旧所有者上都被应用,但产生不同的效果。在新所有者上(GCing 状态),分片变为 Serving;在旧所有者上(BePulling 状态),分片变为 Serving 并清理数据。

配置更新:configureAction()

// shardkv.cpp

void ShardKV::configureAction() {
    bool canPerformNextConfig = true;
    int currentConfigNum;
    {
        std::lock_guard<std::mutex> lk(mu_);
        for (int i = 0; i < NShards; ++i) {
            if (shardStatus_[i] != Serving) {
                canPerformNextConfig = false;
                break;
            }
        }
        currentConfigNum = currentConfig_.Num;
    }

    if (canPerformNextConfig) {
        auto nextConfig = sc_->Query(currentConfigNum + 1);
        if (nextConfig.Num == currentConfigNum + 1) {
            auto cmd = skvser::makeConfigurationCmd(nextConfig);
            SKVCommandResponse resp;
            Execute(cmd, resp);
        }
    }
}
      

configureAction 定期(100ms)检查是否可以更新到下一个配置版本。关键点:只有当所有分片都处于 Serving 状态时才更新配置,这确保了迁移过程完成后再开始新一轮迁移,避免并发迁移导致数据不一致。

配置更新流程演示

检查所有分片状态 0 1 2 3 4 5 6 7 8 9 ✗ 有分片未就绪 ✓ 全部 Serving SCClerk::Query(N+1) 查询配置服务器 Raft::Start(Config) 提交配置命令到 Raft applyConfiguration() → updateShardStatus() 更新分片状态 获得分片→Pulling | 失去分片→BePulling | 保留→Serving

点击「播放」查看配置更新流程

空条目检测:checkEntryInCurrentTermAction()

// shardkv.cpp

void ShardKV::checkEntryInCurrentTermAction() {
    if (!rf_->HasLogInCurrentTerm()) {
        auto cmd = skvser::makeEmptyEntryCmd();
        SKVCommandResponse resp;
        Execute(cmd, resp);
    }
}
      

这个线程定期(200ms)检查 Leader 是否在当前任期有日志条目。如果没有,提交一个空条目到 Raft。这是 Raft 协议中的一个重要优化:新当选的 Leader 需要在当前任期提交至少一条日志,才能安全地提交之前任期的日志(参考 Raft 论文 Figure 8 的问题)。

快照与恢复

ShardKV 的快照包含:所有分片数据、分片状态、去重信息、当前配置和上一配置:

// shardkv.cpp

void ShardKV::takeSnapshot(int index) {
    std::map<int64_t, SKVOperationContext> orderedOps(
        lastOperations_.begin(), lastOperations_.end());
    auto snapshot = skvser::serializeSnapshot(
        shards_, shardStatus_, orderedOps, currentConfig_, lastConfig_);
    rf_->Snapshot(index, snapshot);
}

void ShardKV::restoreSnapshot(const std::vector<uint8_t>& snap) {
    if (snap.empty()) {
        initStateMachines();
        return;
    }
    std::map<int64_t, SKVOperationContext> orderedOps;
    skvser::deserializeSnapshot(
        snap.data(), snap.size(),
        shards_, shardStatus_, orderedOps, currentConfig_, lastConfig_);
    lastOperations_.clear();
    for (auto& [cid, ctx] : orderedOps) {
        lastOperations_[cid] = ctx;
    }
}
      

客户端实现

ShardKVClerk 是分片 KV 系统的客户端。与第五章的 Clerk 不同,它需要先从 ShardCtrler 获取配置信息,再根据 key 计算分片,找到负责该分片的分组服务器:

// skv_clerk.h

class ShardKVClerk {
public:
    explicit ShardKVClerk(std::shared_ptr<SCClerk> sm);

    std::string Get(const std::string& key);
    void Put(const std::string& key, const std::string& value);
    void Append(const std::string& key, const std::string& value);

    void SetPeers(int gid, std::vector<std::shared_ptr<ShardKVClientPeer>> peers);

private:
    std::string doCommand(const SKVCommandRequest& req);

    std::shared_ptr<SCClerk> sm_;               // 配置服务器客户端
    SCConfig config_;                              // 缓存的配置
    std::map<int, std::vector<std::shared_ptr<ShardKVClientPeer>>> clients_;  // gid -> peers
    std::map<int, int> leaderIds_;                 // gid -> leader index
    int64_t clientId_  = 0;
    int64_t commandId_ = 0;
};
      

ShardKVClerk 的工作流程:

1. 构造时通过 SCClerk 查询最新的配置信息,缓存到 config_ 中。
2. 收到 Get/Put/Append 请求时,先计算 key2shard(key),得到分片 ID。
3. 查 config_.Shards[shardID] 得到负责该分片的 gid。
4. 查 clients_[gid] 得到该分组的所有服务器 peer。
5. 向缓存的 leaderId 节点发送请求,如果返回 ErrWrongGroup 则刷新配置重试。

与 Clerk 类似,commandId_ 只在成功后才递增,配合服务端的去重机制保证幂等性。

分片迁移全流程总结

让我们总结一下分片迁移的完整流程。假设配置从版本 N 更新到版本 N+1,分片 S 从分组 A 迁移到分组 B:

1. 配置更新:B 组的 configureAction 查询到配置 N+1,通过 Raft 提交 Configuration 命令。B 组的 applier 应用后,updateShardStatus 将分片 S 设为 Pulling。A 组也会收到同样的配置更新,将分片 S 设为 BePulling
2. 数据拉取:B 组的 migrationAction 检测到分片 S 处于 Pulling,向 A 组发送 GetShardsData RPC。A 组的 Leader 返回分片 S 的 KV 数据和去重信息。
3. 数据插入:B 组通过 Raft 提交 InsertShards 命令,applier 应用后将数据写入本地,分片 S 状态变为 GCing
4. 垃圾回收:B 组的 gcAction 检测到分片 S 处于 GCing,向 A 组发送 DeleteShardsData RPC。A 组通过 Raft 提交 DeleteShards 命令,applier 应用后清除分片 S 的数据,状态变为 Serving(空数据)。同时 B 组的 applier 也会应用这个 DeleteShards 命令,将分片 S 从 GCing 变为 Serving
5. 恢复服务:分片 S 在 B 组上变为 Serving 状态,可以正常服务客户端请求。所有分片都 Serving 后,configureAction 才会查询下一个配置版本。

这个流程保证了分片迁移过程中的数据一致性:通过 Raft 共识确保所有副本一致地更新配置、接收数据和清理数据;通过分片状态机确保迁移步骤的有序性;通过去重信息传递确保迁移后客户端仍能正确去重。

分片迁移全流程演示

Group A(旧所有者) Group B(新所有者) Serving 分片 S Serving 分片 S applyConfiguration configureAction GetShardsData migrationAction applyInsertShards gcAction applyDeleteShards applyDeleteShards GetShardsData RPC 分片数据 + 去重信息 DeleteShardsData RPC DeleteShards 确认 步骤1:配置更新 步骤2:数据拉取 步骤3:数据插入 步骤4:垃圾回收 步骤5:恢复服务 点击「播放」查看分片迁移全流程

对比 Go 版本的 eraft 实现,raft_cpp 的 Multi-Raft 实现有以下改进:

- Go 版本只有 ConfigAction 一个后台协程,迁移和 GC 逻辑混在 apply 中。C++ 版本拆分为 5 个独立线程,职责更清晰。
- Go 版本使用 Bucket 概念,每个 Bucket 关联一个 DB 引擎。C++ 版本使用分片(Shard)概念,分片数据存储在内存 map 中,更简洁。
- Go 版本的跨组通信直接使用 gRPC 客户端。C++ 版本通过 ShardKVPeerProvider 抽象接口实现,测试中使用内存模拟,生产中使用 gRPC。
- C++ 版本增加了空条目检测线程(emptyEntryThread),主动解决 Raft Figure 8 问题。
- C++ 版本的分片状态机设计更完善,明确了 Serving/Pulling/BePulling/GCing 四个状态及其转换。

捐赠

整理这本书耗费了我们大量的时间和精力。如果你觉得有帮助,一瓶矿泉水的价格支持我们继续输出优质的分布式存储知识体系,2.99¥,感谢大家的支持。

遵循MIT协议开源。

感谢 「赫蹏」 提供如此优秀的中文排版系统

本站总访问量