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 天然分布在不同节点上, 写入负载自动分散.
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 SLOTS、CLUSTER NODES、CLUSTER INFO、CLUSTER KEYSLOT、CLUSTER MYID 等命令, 让 Jedis、redis-py、go-redis 等标准 Redis Cluster 客户端可以直接连接, 不需要任何适配.
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 问题.
读操作有两种模式:
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.
每个 Store 节点只有一个 RocksDB 实例, 通过 RocksWrapper 单例管理. 所有 Region 共享一个 RocksDB, 利用其 group commit 和统一的 compaction 来消化 Multi-Raft 带来的 I/O 压力. 使用的是 rocksdb::TransactionDB::Open() 而非普通的 DB::Open(), 提供行级锁和事务支持.
这个 RocksDB 实例内部划分为 8 个 Column Family, 每个 CF 针对自己承载的数据特点做了专门调优:
kOldestLargestSeqFirst — 优先合并最旧的数据, 匹配 Raft 日志”追加写入、旧数据先被截断”的访问模式region_id 分区, 注册了 SplitCompactionFilter 用于 Region Split 后清理超出新范围的数据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 中物理相邻.
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, 字节序与数值序完全一致.
这是从 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.
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 使用两层结构: 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 为空(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 的 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 是五种数据类型中最复杂的. 它同时需要按 member 查询和按 score 排序, 单个 CF 的排序维度无法同时满足两种需求, 于是引入了双 CF 索引:
ZSCORE、member 存在性检查ZRANGEBYSCORE、ZRANGE、ZCOUNT两个索引必须始终保持一致. 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 时被逐步清理.
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 日志膨胀的代价小.
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 架构的起点.