[NeoKV] 基于Braft分布式KV,支持Redis协议

NeoKV 是一个基于 Braft + RocksDB 构建的强一致分布式 KV 存储, 对外暴露 Redis 兼容协议(RESP), 可以直接用 redis-cli 或任何 Redis SDK 连接使用. 项目源码在 GitHub, 主要用于学习和实践 Multi-Raft 架构, 代码参考了 BaikalDB、Kvrocks、Pika 等优秀开源项目.

为什么做这个项目

之前做 NanoRedis 的时候, 实现了一个纯内存的类 Redis 数据库, 用 Shared-Nothing 架构解决了多核利用率的问题. 但内存数据库有一个根本性的局限: 数据规模受限于单机内存, 而且没有副本机制, 节点挂了数据就丢了.

2019 年前后我维护过一套 Codis + Redis-Server 的缓存集群. 这套系统的核心思路很简单: Redis-Server 本质上是一个单机内存数据库, 它自己不知道”集群”是什么, 所以需要在外面包一层 — 用固定 Hash 分片把数据打散, 用 Sentinel 做主从复制和故障切换, 用 Codis 作为无状态代理层路由请求, 用 ZooKeeper 存储元数据. 这套架构虽然扛住了百万并发, 但问题也很明显:

如果存储节点自身就具备共识能力 — 它自己就能决定”这条数据是否已经被多数节点确认”, 不需要外部的 Sentinel 来仲裁主从切换, 不需要外部的 ZooKeeper 来存储元数据 — 整个系统的架构会简洁得多. 这就是 Raft 共识算法的价值所在.

所以 NeoKV 要解决的是下一步的问题: 数据持久化 + 多副本强一致 + 水平扩展. 技术选型上, 存储层用 RocksDB(成熟的 LSM-Tree 引擎), 一致性协议用 Braft(百度开源的 Raft C++ 实现, 基于 brpc), 上层协议用 Redis RESP(生态成熟, 客户端丰富). 这三者的组合在工业界已经被充分验证 — TiKV 用的是 Raft + RocksDB, Kvrocks 用的是 RocksDB + Redis 协议.

NeoKV 选择了 CP — 优先保证一致性. 每次写入都必须经过多数节点确认才返回成功. 代价是写入延迟更高, 但换来的是: 一旦告诉客户端”写入成功”, 这条数据就不会丢失, 即使 Leader 随后立刻宕机. 它不是一个缓存系统, 而是一个强一致的分布式存储, 只是恰好兼容 Redis 协议.

架构

NeoKV 由两类进程组成:

                    ┌─────────────┐
                    │   neoMeta   │
                    │ (Raft Group)│
                    └──────┬──────┘
                           │ heartbeat / region schedule
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ neoStore │ │ neoStore │ │ neoStore │
        │ Region 1 │ │ Region 1 │ │ Region 1 │  ← Raft Group (3副本)
        │ Region 2 │ │ Region 2 │ │ Region 3 │
        │   ...    │ │   ...    │ │   ...    │
        └────┬─────┘ └────┬─────┘ └────┬─────┘
             │             │             │
         Redis RESP    Redis RESP    Redis RESP
             │             │             │
          redis-cli     Redis SDK      ...

这个架构和 TiKV 非常相似: neoMeta 对应 PD(Placement Driver), neoStore 对应 TiKV 节点, Region 对应 TiKV 的 Region. 核心思想是 Multi-Raft: 数据按 Region 分片, 每个 Region 是一个独立的 Raft Group, Region 之间互不干扰, 可以独立选举、独立复制.

单个 Raft 组有三个明显的瓶颈: 所有写入都经过单一 Leader, 写入吞吐无法水平扩展; 单个 Raft 组的数据量受限于单机存储容量; 所有请求集中在一个 Leader 上, 容易形成热点. Multi-Raft 将数据分成多个 Region, 每个 Region 是一个独立的 Raft 组, 拥有自己的 Leader、自己的日志、自己的选举. 不同 Region 的 Leader 天然分布在不同节点上, 写入负载自动分散.

Region 与 Slot 映射

NeoKV 借鉴了 Redis Cluster 的 16384 Slot 设计. 每个 key 通过 CRC16 哈希(兼容 Redis Cluster 的 CRC16-CCITT 算法, 支持 {hash_tag} 语义)映射到 0-16383 的某个 slot, 每个 Region 负责一段连续的 slot 范围.

路由层 RedisRouter 维护了一个 SlotTable: 一个 16384 大小的数组, 每个 slot 直接映射到对应的 Region 指针, 实现 O(1) 的路由查找:

struct SlotTable {
    struct Entry {
        SmartRegion region;
        int64_t region_id = 0;
    };
    std::array<Entry, 16384> slots{};
};

SlotTable 本身是不可变的(immutable) — 一旦创建就不会被修改. 当 Region 发生变更(Split、Merge、新增、删除)时, rebuild_slot_table() 创建一个全新的 SlotTable, 然后通过 shared_ptr 原子替换旧的. 读路径通过在锁保护下拷贝 shared_ptr(纳秒级), 让锁的持有时间降到最低, 后续的数组访问在锁外完成.

Hash Tag 让多 key 命令成为可能. 规则很简单: 如果 key 中包含 {tag}, 就只用 tag 部分计算 slot. 于是 {user}:name{user}:age 都落在同一个 slot, MSET {user}:name Alice {user}:age 30 就可以正常工作了.

NeoKV 还实现了 CLUSTER SLOTSCLUSTER NODESCLUSTER INFOCLUSTER KEYSLOTCLUSTER MYID 等命令, 让 Jedis、redis-py、go-redis 等标准 Redis Cluster 客户端可以直接连接, 不需要任何适配.

Braft 集成: 写路径

NeoKV 中所有写操作都必须经过 Raft 状态机, 这是保证强一致性的核心. 我们选择了百度开源的 braft 作为 Raft 实现. braft 是一个成熟的 C++ Raft 库, 基于 brpc 框架, 在百度内部经过了大规模生产验证. 它的存储层是可插拔的 — 这一点对 NeoKV 至关重要.

braft 的设计围绕一个简单的思路: 它帮你处理所有 Raft 协议层面的事情(选举、日志复制、心跳、成员变更), 你只需要告诉它两件事 — “数据怎么存”和”日志 committed 之后做什么”. 对于单 Raft 组的场景, braft 默认的 local:// 存储实现完全够用. 但当你在一台机器上运行数百个 Raft 组时 — 这正是 NeoKV 的 Multi-Raft 架构 — 默认实现会遇到严重的 I/O 问题. 数百个目录、上千个文件描述符、每个 Raft 组独立 fsync. 所以我们实现了自定义的存储后端, 将所有 Raft 组的日志和元数据汇聚到同一个 RocksDB 实例中.

以一个 SET key value 命令为例, 完整的写路径如下:

1. Redis 协议解析: brpc 内置了完整的 Redis 协议支持. brpc::RedisService 提供了 RESP 协议解析、命令分发、响应编码和连接管理 — 我们没有写一行 RESP 协议解析代码. 只需要实现 brpc::RedisCommandHandler 接口, 注册到 brpc::RedisService 即可:

class SetCommandHandler final : public RedisDataCommandHandler {
    brpc::RedisCommandHandlerResult Run(
        const std::vector<butil::StringPiece>& args,
        brpc::RedisReply* output, bool) override {
        // 1. 解析 SET key value [EX seconds] [PX ms] [NX|XX]
        // 2. 路由到目标 Region
        // 3. 通过 Raft 写入
    }
};

2. 路由: RedisRouter::route() 从命令参数中提取所有 key(根据 RedisKeyPattern 定义每个命令的 key 位置), 检查是否属于同一个 slot(否则返回 CROSSSLOT 错误), 然后通过 SlotTable 找到目标 Region.

3. 构造 Raft 日志并提交: 写命令被序列化为 RedisWriteRequest protobuf, 通过 Region::exec_redis_write() 提交给 Braft. 内部将请求包装成 Braft 的 Task, 提交到 Raft Group, 然后同步等待 Raft 日志被 commit 并 apply:

// store.interface.proto
message RedisWriteRequest {
    optional RedisCmd   cmd     = 1;  // SET/DEL/EXPIRE/HSET/SADD/ZADD/...
    repeated RedisKv    kvs     = 2;
    optional uint32     slot    = 3;
    optional RedisSetCondition set_condition = 4;  // NX/XX
    repeated RedisHashField fields = 5;
    repeated bytes members = 6;
    repeated RedisZSetEntry zset_entries = 8;
};

4. 状态机 Apply: 当 Raft 日志被多数派确认后, Region::on_apply() 被回调, 最终调用 apply_redis_write() 将数据写入 RocksDB. 所有修改都在同一个 WriteBatch 中 — 子键的写入和 Metadata 的更新是原子的.

这里有一个非常重要的设计决策: NX/XX 条件检查在 Apply 层执行, 而不是 Handler 层. 因为 Apply 是 Raft 保证的串行执行 — 同一个 Region 的 on_apply() 不会并发调用. 如果在 Handler 层检查条件, 两个并发的 SET key value NX 可能都发现 key 不存在, 都提交 Raft 日志, 最终两个都成功 — 这就违反了 NX 的语义. 同样的道理, INCR 的读-改-写三步操作也全部在 Apply 层执行, 利用 Raft 的串行 apply 天然避免了 lost update 问题.

读路径与 Follower Read

读操作有两种模式:

Leader Read(默认): 读请求只在 Leader 上执行, 直接读 RocksDB, 保证线性一致性. 如果当前节点不是 Leader, 返回 MOVED 错误, 客户端重定向到 Leader — 格式为 -MOVED <slot> <leader_ip>:<leader_port>, 与 Redis Cluster 协议完全兼容.

Follower Read(ReadIndex): 通过 FLAGS_use_read_index 开启. Follower 收到读请求后, 向 Leader 请求当前的 commit index, 等待本地 apply 追上这个 index 后再执行读操作. 这样既保证了强一致性, 又分散了读压力:

bool RedisDataCommandHandler::check_read_consistency(
    const RedisRouteResult& route, brpc::RedisReply* output) {
    if (route.region->is_leader()) return true;  // Leader 直接读

    if (FLAGS_use_read_index) {
        // Follower ReadIndex: 向 Leader 确认 read_index, 等待本地 apply 追上
        pb::ErrCode err = route.region->follower_read_wait(0, timeout_us);
        if (err == pb::SUCCESS) return true;
    }

    // 非 Leader 且未开启 ReadIndex: 返回 MOVED
    set_moved_error(route.slot, leader_addr, output);
    return false;
}

Learner 还有额外的特殊处理 — 在 Snapshot 恢复完成之前, Learner 的数据是不完整的, 如果这时候允许读, 客户端会看到不一致的结果. 所以 check_read_consistency() 先检查 Learner 是否就绪, 不就绪就返回 CLUSTERDOWN Learner not ready for read.

RocksDB 存储设计

每个 Store 节点只有一个 RocksDB 实例, 通过 RocksWrapper 单例管理. 所有 Region 共享一个 RocksDB, 利用其 group commit 和统一的 compaction 来消化 Multi-Raft 带来的 I/O 压力. 使用的是 rocksdb::TransactionDB::Open() 而非普通的 DB::Open(), 提供行级锁和事务支持.

这个 RocksDB 实例内部划分为 8 个 Column Family, 每个 CF 针对自己承载的数据特点做了专门调优:

Key 编码

RocksDB 内部按字节序排列 key. NeoKV 设计了一套 mem-comparable 编码, 让编码后的字节序与业务排序一致. 核心工具是 KeyEncoder — 对于无符号整数转为大端序就够了, 但有符号整数需要翻转符号位:

static uint64_t encode_i64(int64_t val) {
    uint64_t uval = static_cast<uint64_t>(val);
    return uval ^ (1ULL << 63);  // 翻转符号位, 让字节比较等价于数值比较
}

所有 Redis 数据的 key 都遵循统一的前缀格式:

┌──────────┬──────────┬──────┬────────────┬─────────┬───────────┐
│region_id │ index_id │ slot │user_key_len│user_key │  suffix   │
│  8 字节   │  8 字节  │2 字节 │   4 字节    │ 变长    │  变长      │
└──────────┴──────────┴──────┴────────────┴─────────┴───────────┘

前 16 个字节(region_id + index_id)正好对应 DATA_CF 和 REDIS_METADATA_CF 的 Prefix Bloom Filter 长度 — 这不是巧合. region_id 保证同一 Region 的所有 key 在 RocksDB 中物理相邻.

Redis 存储编码

NeoKV 的 Redis 存储编码经历了两个阶段. Phase 1 是最初的简单编码, 所有数据存在 DATA_CF 中. 当要支持 Hash、Set、List、ZSet 这些复合类型时, 问题就来了 — 无法支持 Lazy Deletion. Phase 2 引入了 Metadata 编码, 核心思路是将元数据和子键数据分离到不同的 CF 中, 参考了 Kvrocks 的编码设计.

Metadata CF 中每个 Redis key 对应一条记录. value 的第一个字节是 flags(低 4 位存储类型枚举: String=1, Hash=2, List=3, Set=4, ZSet=5), 之后的内容因类型而异:

String:   [flags:1][expire_ms:8][inline_value...]
Hash/Set: [flags:1][expire_ms:8][version:8][size:8]
List:     [flags:1][expire_ms:8][version:8][size:8][head:8][tail:8]
ZSet:     [flags:1][expire_ms:8][version:8][size:8]

String 是单 KV 类型 — 值直接内联在 Metadata 中, GET/SET 只需一次 RocksDB 读写, 这是 String 类型性能最优的关键.

Subkey CF(DATA_CF) 存储复合类型的子元素. key 格式是在 Metadata key 的基础上追加 [version:8][sub_key]. 对于 Hash, sub_key 是 field name; 对于 Set, sub_key 是 member, value 为空 — 成员信息完全编码在 key 中, RocksDB 的 key 存在性检查就等价于成员存在性检查; 对于 List, sub_key 是大端编码的 uint64 索引; 对于 ZSet, sub_key 是 member, value 是 8 字节编码后的 score.

Score CF(REDIS_ZSET_SCORE_CF) 是 ZSet 的双 CF 索引之一. key 格式为 [metadata_prefix][version:8][score:8][member], value 为空. 但 IEEE 754 double 的字节表示不能直接用于字节序比较, NeoKV 使用了一种编码让 double 的字节序与数值序一致:

void encode_score(double score, std::string* dst) {
    uint64_t bits;
    memcpy(&bits, &score, sizeof(bits));
    if (bits & (1ULL << 63)) {
        bits = ~bits;           // 负数: 全部取反
    } else {
        bits ^= (1ULL << 63);  // 正数: 翻转符号位
    }
    PutFixed64BigEndian(dst, bits);
}

正数之间指数和尾数的字节比较顺序与数值顺序一致, 只需翻转符号位让正数排在负数后面. 但负数在 IEEE 754 中有一个特殊性质: -1.0 的位表示 > -0.5 的位表示, 而数值上 -1.0 < -0.5 — 负数的位表示与数值是反序的. 全部取反正好把这个反序翻转过来. 编码后 -inf < -1.0 < -0.0 < +0.0 < 1.0 < +inf, 字节序与数值序完全一致.

Version-Based Lazy Deletion

这是从 Kvrocks 移植的核心机制, 解决了一个在生产环境中非常实际的问题.

想象一个 Hash key 有 100 万个 field. 当执行 DEL mykey 时, 朴素的做法是遍历并删除 100 万个子键 — 这会阻塞 Raft apply 线程数秒, 在此期间所有写入都被卡住. Redis 自身也遇到过同样的问题, 所以引入了 UNLINK 命令在后台异步删除. NeoKV 用 Lazy Deletion 更优雅地解决了它.

核心思路: 删除操作只更新 Metadata, 不触碰子键.

删除前:
  Metadata CF: mykey → {type=Hash, version=100, size=1000000}
  Data CF:     mykey/v100/field1 → value1
               mykey/v100/field2 → value2
               ... (100 万条)

执行 DEL mykey:
  只删除 Metadata CF 中的 mykey 记录
  Data CF 中的 100 万条子键原封不动

后续写入 HSET mykey field1 newvalue:
  创建新 Metadata: {version=200, size=1}
  写入子键: mykey/v200/field1 → newvalue
  旧子键 mykey/v100/* 仍然存在, 但 version 不匹配, 永远不会被读到

DEL 操作变成了 O(1). 旧 version 的子键变成了”孤儿” — 它们虽然仍然存在于 RocksDB 中, 但不会被读到, 最终在 compaction 中被清理. 这个过程是渐进式的, 不会产生突发的 I/O 峰值.

Version 的生成使用了一个 53-bit 微秒时间戳加 11-bit 原子计数器的组合. 计数器在启动时随机初始化 — 这是为了避免 Follower 被提升为新 Leader 时, 在同一微秒内生成与旧 Leader 相同的 version.

五种 Redis 数据类型

String: 最短的路径

String 是单 KV 类型, 值直接内联在 Metadata CF 的 value 中, 不需要子键, 不需要 version. SET 只需要一次 batch.Put(), GET 只需要一次 rocks->get(). 在五种数据类型中, String 是唯一不需要 version 字段的.

INCR/DECR 等数值操作遵循读-改-写模式, 全部在 Raft Apply 中串行执行, 天然避免了 lost update. 溢出检测用的是标准的整数溢出判断技巧 — 不是先加再检查(那样已经溢出了), 而是用减法反向判断.

两层编码通过”读时回退 + 写时迁移”的策略共存: 写入时始终使用新编码(Metadata CF), 读取时先查 Metadata CF, 未找到则回退查 DATA CF(旧编码). 旧数据随着正常的读写操作逐渐迁移到新编码.

Hash: 复合类型的标准模式

Hash 使用两层结构: Metadata CF 存元信息, DATA CF 存子键. Metadata 中维护 size 字段, 让 HLEN 变成 O(1). 代价是每次 HSET/HDEL 都需要额外更新一次 Metadata.

HSET 的 Apply 层实现展示了复合类型写入的标准模式 — 读 Metadata、处理子键、更新 Metadata、原子提交. 对于每个 field, 在写入之前先检查它是否已存在, 这不是为了防止覆盖, 而是为了正确维护 size 计数 — 只有新增的 field 才让 size 加一.

HGETALL 展示了 RocksDB 前缀迭代的能力 — 构建 [prefix][version] 前缀, 使用 prefix_same_as_start = true 的 Iterator 扫描, 只遍历属于这个 Hash 的、当前 version 的子键. field name 通过从完整 key 中去掉前缀部分来提取 — 前缀之后的所有字节就是 field name 本身.

当 HDEL 使 size 降为 0 时, Metadata 也被删除 — 空 Hash 不应该存在, 这与 Redis 的行为一致.

Set: member 就是 key, value 为空

Set 的存储设计是五种类型中最简洁的. member 直接作为子键的 key, value 为空(rocksdb::Slice()). 这个设计节省空间, 而且存在性检查即成员检查 — SISMEMBER 只需要一次 rocks->get() 判断 key 是否存在.

SADD 比 HSET 多了一步输入去重: SADD myset a b a c a 如果不去重, 代码会对 “a” 检查三次, 第一次发现不存在 size 加一, 第二次又发现不存在(因为 WriteBatch 还没提交到 RocksDB) size 又加一, 最终 size 比实际多了 2.

SPOP 的随机选择在 LSM-Tree 上比较有趣 — 先收集所有 member, 再用 Fisher-Yates 部分洗牌随机选择, 只做 k 步交换而不是全洗牌. 不过在大 Set 上性能不理想, 这是 RocksDB 上实现随机访问的固有限制.

集合运算(SINTER/SUNION/SDIFF)在 Handler 层直接执行(只读操作不需要经过 Raft), 在内存中完成计算. STORE 变体则把计算和写入分开 — Handler 层做计算, 结果通过 Raft 提交, Apply 层用新 version 写入, 旧数据通过 Lazy Deletion 处理.

List: 从中间开始的双指针

List 的 Metadata 比其他复合类型多了 head 和 tail 两个字段, 初始值都是 UINT64_MAX / 2(约 9.2 × 10^18). 为什么从中间开始? 因为 LPUSH 往左边加元素需要递减 head, 如果从 0 开始就需要负数索引, 而 RocksDB 的 key 是按无符号字节序排列的, 负数索引会排在正数后面, 破坏元素的逻辑顺序.

LPUSH/RPUSH/LPOP/RPOP 都是 O(1) — 只需要一次 RocksDB Put/Delete 加一次 Metadata 更新. 但更有趣的是 LINDEX 也是 O(1) — 这是 NeoKV 相比 Redis 原生实现的一个优势. Redis 原生用双向链表, LINDEX 需要从头或尾遍历, O(n). NeoKV 用 uint64 索引作为 key, head + logical_index 就是实际的存储位置, 一次 Get 就完成.

代价是中间操作更昂贵. LINSERT 需要搬移后续元素, O(n). LREM 更激进 — 删除匹配元素后, 为了保持索引连续性(不留空洞), 直接重建整个 List, 把 head 和 tail 重置回 UINT64_MAX / 2. 为什么不留空洞? 因为空洞会破坏 LINDEX 的 O(1) 语义.

ZSet: 双 CF 索引

ZSet 是五种数据类型中最复杂的. 它同时需要按 member 查询和按 score 排序, 单个 CF 的排序维度无法同时满足两种需求, 于是引入了双 CF 索引:

两个索引必须始终保持一致. ZADD 更新 member 的 score 时, 必须先删除旧的 Score CF 条目, 再写入新的. 如果忘记删除旧条目, Score CF 中就会残留旧的 (old_score, member) 映射 — 下次 ZRANGEBYSCORE 时, 同一个 member 可能出现在两个不同的 score 位置上.

ZRANK 返回 member 的排名, 在 RocksDB 上只能从头开始计数, O(n) — 这是 LSM-Tree 的固有限制. Redis 原生用跳表实现 O(log n) 的 ZRANK, 但 LSM-Tree 不维护 key 的位置信息.

DEL 一个 ZSet 时, Lazy Deletion 同时作用于两个 CF — DATA_CF 和 SCORE_CF 中的孤儿数据量是其他复合类型的两倍. 但 DEL 仍然是 O(1), 200 万条孤儿记录在 compaction 时被逐步清理.

TTL 与过期清理

NeoKV 的过期机制由两层保证.

被动过期是第一层防线. 每次读取 key 时, 检查 Metadata 中的 expire_ms — 如果当前时间已经超过过期时间, 直接返回 nil. 被动过期不删除数据, 这在分布式系统中是必要的: 读操作不经过 Raft, 如果在读时删除数据, Leader 上的删除不会同步到 Follower, 导致数据不一致.

主动清理是第二层. RedisTTLCleaner 后台线程定期扫描 REDIS_METADATA_CF, 发现已过期的就删除其 Metadata 记录(子键通过 Lazy Deletion 处理). 清理只在 Leader Region 上执行, 避免不一致. 每个 Region 每轮最多清理 1000 个过期 key, 使用 fill_cache = false 避免污染 Block Cache.

TTL Cleaner 的删除操作不经过 Raft — 直接写 RocksDB. 理由是: 被动过期已经保证了语义正确性(客户端永远不会读到过期数据), TTL Cleaner 只是做空间回收. 空间回收不一致是可以容忍的 — Follower 上多占一些磁盘空间, 远比 Raft 日志膨胀的代价小.

支持的 Redis 命令

NeoKV 实现了 98 个 Redis 命令, 覆盖 5 种数据类型和通用操作:

String(19个): GET, SET(EX/PX/NX/XX), MGET, MSET, SETNX, SETEX, PSETEX, INCR, DECR, INCRBY, DECRBY, INCRBYFLOAT, APPEND, STRLEN, GETSET, GETDEL, GETEX, GETRANGE, SETRANGE

Hash(15个): HSET, HGET, HDEL, HMSET, HMGET, HGETALL, HKEYS, HVALS, HLEN, HEXISTS, HSETNX, HINCRBY, HINCRBYFLOAT, HRANDFIELD, HSCAN

Set(17个): SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SRANDMEMBER, SPOP, SMISMEMBER, SINTER, SUNION, SDIFF, SINTERCARD, SINTERSTORE, SUNIONSTORE, SDIFFSTORE, SMOVE, SSCAN

List(14个): LPUSH, RPUSH, LPOP, RPOP, LLEN, LINDEX, LRANGE, LPOS, LSET, LINSERT, LREM, LTRIM, LMOVE, LMPOP

Sorted Set(17个): ZADD(NX/XX/GT/LT/CH/INCR), ZREM, ZSCORE, ZRANK, ZREVRANK, ZCARD, ZCOUNT, ZRANGE, ZRANGEBYSCORE, ZREVRANGE, ZREVRANGEBYSCORE, ZINCRBY, ZPOPMIN, ZPOPMAX, ZSCAN, ZLEXCOUNT, ZRANGEBYLEX

Key(13个): DEL, UNLINK, EXISTS, TYPE, EXPIRE, EXPIREAT, PEXPIRE, PEXPIREAT, TTL, PTTL, PERSIST, FLUSHDB, FLUSHALL

Cluster(3个): CLUSTER INFO/NODES/SLOTS/MYID/KEYSLOT, PING, ECHO

所有写命令都经过 Raft 保证强一致, 读命令支持 Leader Read 和 Follower ReadIndex 两种模式.

测试体系

NeoKV 的测试分两层: C++ 单元测试Go 集成测试.

C++ 单元测试验证内部机制的正确性 — CRC16 计算、Key 编解码往返、Protobuf 序列化、slot 分布均匀性, 以及一个特别有意思的并发测试: 16 个线程同时执行 SET NX, 验证只有一个能成功(验证 NX 检查确实在 Apply 层串行执行).

Go 集成测试是主要的质量保障手段, 覆盖了全部 98 个 Redis 命令. 为什么用 Go? go-redis 是 Redis 生态里最成熟的客户端库之一, 而且用 Go 客户端测试 NeoKV, 和真实用户用各种语言的 Redis SDK 连接是同一个路径, 测试可信度更高.

集成测试的基石是 neo_redis_standalone — 一个单进程的 NeoKV, 内嵌 RocksDB + 单副本 Raft Region, 不需要 MetaServer. 单副本 Raft 会自动选举自己为 Leader, 写操作仍然走完整的 Raft 代码路径(日志追加、on_apply 回调、WriteBatch 提交), 所以测试覆盖的代码路径和生产环境是一致的.

# 启动 Standalone 模式
./neo_redis_standalone --redis_port=16379 --data_dir=/tmp/neokv_standalone

# 验证
redis-cli -p 16379 SET hello world
redis-cli -p 16379 GET hello
redis-cli -p 16379 HSET user:1 name "Alice" age 30
redis-cli -p 16379 HGETALL user:1
redis-cli -p 16379 CLUSTER INFO

总结

NeoKV 是一个学习项目, 但它覆盖了构建分布式 KV 存储的核心技术栈:

更详细的设计文档在项目的 book/ 目录下, 从 Raft 共识到 RocksDB 存储、从 Redis 数据结构编码到协议层路由, 共 17 章. 如果你对分布式存储感兴趣, 这个项目的代码结构比较清晰, 适合作为学习 Multi-Raft 架构的起点.

[NanoRedis]: 一个极简的类Redis内存数据库
LevelDB, 一份 C++ 学习指南