2.6KV存储项目
KV存储项目

关于网络编程
- 0.一请求一线程
- 1.select/poll/epoll
io并发 - 2.协程ntyco
- 3.dpdk,tcp/ip
- 4.io_uring, iocp
KV存储的应用
key-value,键值对,通过key来查找对应的value
区别于mysql的关系性数据库, 需要建表(需要符合某种规则存储数据),通过ID来查取[姓名、年龄、行不、手机号码],key-value存储的变种,而key-value存储作为一种NoSQL,不需要建表,key的形式不局限于ID,key可以是一个名字、double、某种数据结构都可以,是一种重要的网络基础设施,也称为中间件开发。
- Redis、MongoDB、Memcached
- 抖音:抖音ID、用户名
- IM即时通讯:微信、评论区
- leetcode:题号对应一个题目
- 拉勾/Boss直聘:一个ID对应一个公司的招聘信息
- 图床:一个链接,通过跳转,对应一个图片,形成短连接-长连接的映射关系
- 淘宝:分享商品生成的短连接 -> 长连接
那既然有了redis、memcached、mongodb, 为什么还会做一个基础kv存储呢,有什么意义?
- 不需要做到像redis那样大而全的功能, 业务简单,比如图床我只需要一种[短连接-长连接]这样一种映射关系就行,虽然redis可以做,但没有必要。公司自己开发可以申请技术专利、体现公司开发团队的技术实力
- 针对业务场景,把性能做到更优
架构是在迭代的过程中产生的
软件代码都是通过分层设计的
KVstore的网络框架设计:
- reactor
- ntyco
- io_uring
- iocp (windows)
main放在网络框架,还是kvstore.c? \(\Rightarrow\) 最好在kvstore.c, 这样才是一个kv项目,否则更像一个基于网络框架的业务开发。
main放在网络框架的编程方式:

# reactor
gcc -o kvstore kvstore.c reactor.c
# ntyco
gcc -o kvstore kvstore.c hook_tcpserver.c -I NtyCo/core -L ./NtyCo/ -lntyco
# io_uring
gcc -o kvstore kvstore.c Iouring/uring_tcpserver.c -luring -static
但是作为一个kv存储的项目,我们需要把网络框架封装,不关心网络底层实现,只需要传(port、kvs_protocol)就行

main放在kvstore.c的编程方式

对于网络框架而言,我们要把之前的main函数改造为一个网络框架接口,并使其适配kvs的规则。
以reactor.c为例,做如下变动

编译:
gcc -o kvstore kvstore.c reactor.c
gcc -o kvstore Ntyco.c kvstore.c -I NtyCo/core/ -L NtyCo/ -lntyco
gcc -o kvstore ./Iouring/uring_tcpserver.c kvstore.c -luring -static
或者采用整体编译的方式


gcc -o kvstore kvstore.c reactor.c proactor.c Ntyco.c -I ./NtyCo/core/ -L ./NtyCo/ -luring -lntyco
KVstore的协议设计:

tcp的分包与粘包
此处参考这位大神的讲解。
What is it?
先通过一个具体的例子帮助理解,假如要通过客户端给 KV 存储服务端,连续发送 3 条命令, 理想情况,是客户端分三次send,然后服务端也同样分三次recv,每次执行一个命令。
1. [15字节]SET Key Value\r\n
2. [17字节]SET Key1 Value1\r\n
3. [17字节]SET Key2 Value2\r\n
但 TCP 实际可能不是这样的, 可能会出现以下的情况。
a. 粘包:多个数据包被一次性接收
客户端分 3 次发送的 3 条独立命令,服务端一次recv就全收到了,数据变成了:
SET Key Value\r\nSET Key1 Value1\r\nSET Key2 Value2\r\n
服务端无法区分哪部分是第一条命令、哪部分是第二条,这就是粘包。
b. 分包:一个数据包被拆分多次接收
分包就是反过来, 假如客户端只发送了一条[15字节]SET Key Value\r\n,服务端却分两次才收到完整数据:
第一次recv只收到前 5 个字节:SET K
第二次recv才收到剩下的 10 个字节:ey Value\r\n一条完整的命令被拆成了多个部分,单次收到的数据不完整,无法解析,这就是分包。
c.又粘又分: 粘包 + 分包同时出现
第一次recv收到:SET Key Value\r\nSET Ke
第二次recv收到:y1 Value1\r\nSET Key2 Value2\r\n既有前两条命令的粘包,又有第二条命令的分包。
Why?
为什么会造成这种情况?
原因是 TCP 的流式传输特性。
这里用一个比喻:TCP 是一根 “水管”,而不是 “快递柜”。
你往水管里分 3 次倒水,水管不会给你把 3 次倒的水分成 3 份,只会把所有水汇成一股流;你在水管另一头接水,也无法保证接水的次数和倒水的次数一致 —— 这就是 TCP 的流式传输:它只负责保证字节流按序、可靠的到达,完全不关心业务层面的 “消息边界”,不知道你这 100 个字节里,前 50 是一条命令,后 50 是另一条。
具体到 底层,粘包 / 分包的出现,主要来自三个环节:
1. 发送端:TCP 的优化机制导致粘包
- Nagle 算法:TCP 为了减少网络小包的传输开销,会默认开启 Nagle 算法 —— 它会把多个小的send请求攒起来,等数据量达到 MSS(最大报文段长度),或者等之前的包收到 ACK 确认后,再一次性发出去。你连续 3 次send小数据包,TCP 可能一次就全发出去了,自然就粘包了。
- 发送缓冲区机制:调用send的时候,并不是直接把数据发到网络上,只是把数据拷贝到了内核的 TCP 发送缓冲区里。什么时候发、一次发多少,是 TCP 内核协议栈说了算,不是你调用几次send就发几次。
2. 接收端:缓冲区读取机制导致粘包 + 分包
- 粘包成因:TCP 收到的数据,会先放到内核的接收缓冲区里。如果你的业务代码读取不及时,缓冲区里就会攒下客户端多次发送的数据包,你调用一次recv,就会把缓冲区里所有能读的数据都读出来,自然就粘包了。
- 分包成因:你调用recv的时候,传入的 buffer 大小是有限的(比如你定义了buffer[1024]),如果此时缓冲区里有 2000 字节的数据,你一次只能读 1024 字节,剩下的 976 字节只能下次recv再读,一个完整的包就被拆分了,也就是分包。
3. 网络层:MTU 限制导致分包
以太网的 MTU(最大传输单元)默认是 1500 字节,如果你发送的数据包超过了 MTU 大小,IP 层会把这个数据包拆分成多个分片传输,TCP 会在接收端重组这些分片。但如果分片还没全部到达接收缓冲区,你就调用了recv,就只能读到部分数据,导致分包。
How to solve?
在应用层自己定义一个规则,即定义一个消息边界,让接收端能准确区分 “一个完整的消息从哪开始、到哪结束”。
方案一:固定包头 + 包体
给每个业务消息,都定义一个固定长度的包头,包头里只放一个字段:包体的长度。通用格式:[固定长度的包头][业务包体]
- 包头:通常用 2 字节 或 4 字节,根据你的业务最大消息长度选择;
- 包体:就是你的实际业务数据(比如 KV 的 SET/GET 命令)。
那对于SET Key Value\r\n,包体长度是 15 字节,用 2 字节包头的话,完整的消息就是:
[15][SET Key Value\r\n]
方案二:特殊分隔符方案
加一个业务中绝对不会出现的特殊分隔符,接收端通过找分隔符来区分消息边界。
最典型的就是 HTTP 协议,用\r\n\r\n作为请求头的结束符;还有很多文本型的命令行协议,用\r\n(换行符)作为每条命令的结束符。
对于我们KV存储的Redis采取如下方案:
对于Redis命令SET Key Value,我们按照总长度+[分词1长度 分词1 + 分词2长度 分词2 +...], 并以\r\n作为分隔符。例如:

那么服务器解析的代码伪代码如下(其中readline表示每次读一行):
readline(fd, buffer);
count = atoi(buffer);
for (int i = 0; i < count; i ++) {
readline(fd, tokenlen);
readline(fd, token);
}
KVstore的存储引擎设计
方式1:数组实现
首先用最简单数据结构来作为KVstore的存储引擎
// singleto 单例模式
// 确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
#include "kvstore.h"
kvs_array_t global_array = {0};
//创建,分配内存
int kvs_array_create(kvs_array_t *inst) {
if(!inst) return -1;
if(inst->table) {
printf("tanle has talloc\n");
return -1;
}
inst->table = kvs_malloc(KVS_ARRAY_SIZE * sizeof(kvs_array_item_t));
if(!inst->table) {
return -1;
}
inst->idx = 0;
inst->total = 0;
return 0;
}
//销毁, 释放内存
void kvs_array_destory(kvs_array_t *inst) {
if(!inst) return;
if(inst->table) {
kvs_free(inst->table);
}
//不需要释放传入的inst,符合开闭原则
}
char* kvs_array_get(kvs_array_t *inst, char *key) {
if(inst == NULL || key == NULL) return NULL;
int i = 0;
for (i = 0; i < inst->total; i ++) {
if(inst->table[i].key == NULL) {
continue;
}
if(strcmp(inst->table[i].key, key) == 0) {
return inst->table[i].value;
}
}
return NULL;
}
/*
* @return: <0, errro; =0, success; >0, exist
*/
int kvs_array_set(kvs_array_t *inst, char *key, char *value) {
if(inst == NULL || key == NULL || value == NULL) return -1;
if(inst->total == KVS_ARRAY_SIZE) return -1;
//如果当前key已经存在
char *str = kvs_array_get(inst, key);
if(str) {
return 1; //存在
}
char *kcopy = kvs_malloc(strlen(key)+1);
if(kcopy == NULL) return -2;
memset(kcopy, 0, strlen(key)+1);
strncpy(kcopy, key, strlen(key)+1);
char *kvalue = kvs_malloc(strlen(value)+1);
if(kvalue == NULL) return -2;
memset(kvalue, 0, strlen(value)+1);
strncpy(kvalue, value, strlen(value)+1);
int i = 0;
for (i = 0; i < inst->total; i ++ ) {
if(inst->table[i].key == NULL) {
inst->table[i].key = kcopy;
inst->table[i].value = kvalue;
inst->total ++;
return 0;
}
}
// 如果所有都是非空的
if(i == inst->total && i < KVS_ARRAY_SIZE) {
inst->table[i].key = kcopy;
inst->table[i].value = kvalue;
inst->total ++;
return 0;
}
else {
printf("Arrive KVS_ARRAY_SIZE, can't allocate\n");
return -2;
}
}
/*
*@ return < 0, error; = 0, success; > 0, not exist;
*/
int kvs_array_del(kvs_array_t *inst, char *key) {
if(inst == NULL || key == NULL) return -1;
int i = 0;
for (i = 0; i <inst->total; i ++) {
if(inst->table[i].key == NULL) {
continue;
}
if(strcmp(inst->table[i].key, key) == 0) {
kvs_free(inst->table[i].key);
inst->table[i].key = NULL;
kvs_free(inst->table[i].value);
inst->table[i].value = NULL;
return 0;
}
}
return i;
}
/*
*@ return < 0, error; = 0, success; > 0, not exist;
*/
int kvs_array_mod(kvs_array_t *inst, char *key, char *value) {
if(inst == NULL || key == NULL || value == NULL) return -1;
int i = 0;
for (i = 0; i < inst->total; i ++) {
if(inst->table[i].key == NULL) {
continue;
}
if(strcmp(inst->table[i].key, key) == 0) {
kvs_free(inst->table[i].value);
char *kvalue = kvs_malloc(strlen(value)+1);
if(kvalue == NULL) return -2;
memset(kvalue, 0, strlen(value)+1);
strncpy(kvalue, value, strlen(value)+1);
inst->table[i].value = kvalue;
return 0;
}
}
return i;
}
/*
*@ return = 1, exist; = 0, not exist;
*/
int kvs_array_exist(kvs_array_t *inst, char *key) {
char *str = kvs_array_get(inst, key);
if(!str) {
return 1; //存在
}
return 0;
}
方式2:rbtree
方式3:hash
自动化测试用例
- tcp客户端,建立连接
- 发送协议
- 接收服务端返回的数据
- 预期数据 与 服务端返回数据对比
检验kv代码逻辑是否有问题
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#define MAX_MSG_LENGTH 1024
#define TIME_SUB_MS(tv1, tv2) ((tv1.tv_sec - tv2.tv_sec) * 1000 + (tv1.tv_usec - tv2.tv_usec) / 1000)
int send_msg(int connfd, char *msg, int length) {
int res = send(connfd, msg, length, 0);
if(res < 0) {
perror("send");
exit(1);
}
return res;
}
int recv_msg(int connfd, char *msg, int length) {
int res = recv(connfd, msg, length, 0);
if(res < 0) {
perror("send");
exit(1);
}
return res;
}
void testcase(int connfd, char *msg, char *pattern, char *casename) {
if(!msg || !pattern || !casename) return;
send_msg(connfd, msg, strlen(msg));
char result[MAX_MSG_LENGTH] = {0};
recv_msg(connfd, result, MAX_MSG_LENGTH);
if(strcmp(result, pattern) == 0) {
//printf("==> PASS -> %s\n", casename);
} else {
printf("==> FAILED -> %s, '%s' != '%s'\n", casename, result, pattern);
exit(1);
}
}
int connect_tcpserver(const char *ip, unsigned short port) {
int connfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(ip);
server_addr.sin_port = htons(port);
if(0 != connect(connfd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr))) {
perror("connect");
return -1;
}
return connfd;
}
void array_testcase_10w(int connfd) {
int count = 1000;
struct timeval tv_begin;
gettimeofday(&tv_begin, NULL);
for (int i = 0; i < count; i ++ ) {
testcase(connfd, "SET Teacher King", "OK\r\n", "SET-Teacher");
testcase(connfd, "GET Teacher", "King\r\n", "GET_Teacher");
testcase(connfd, "MOD Teacher Xiaomo", "OK\r\n", "MOD_Teacher");
testcase(connfd, "GET Teacher", "Xiaomo\r\n", "GET_Teacher");
testcase(connfd, "EXIST Teacher", "EXIST\r\n", "EXIST_Teacher");
testcase(connfd, "DEL Teacher", "OK\r\n", "DEL_Teacher");
testcase(connfd, "EXIST Teacher", "NOT EXIST\r\n", "EXIST_Teacher");
testcase(connfd, "GET Teacher", "NOT EXIST\r\n", "GET_Teacher");
testcase(connfd, "MOD Teacher King", "NOT EXIST\r\n", "MOD_Teacher");
}
struct timeval tv_end;
gettimeofday(&tv_end, NULL);
int time_used = TIME_SUB_MS(tv_end, tv_begin);
printf("array testcase --> time_used: %d, qps: %d\n", time_used, 9*count*1000 / time_used);
}
// ./Test_case 192.168.66.130 8888
int main(int argc, char *argv[]) {
if(argc != 3) {
printf("Params error\n");
return -1;
}
char *ip = argv[1];
unsigned short port = atoi(argv[2]);
int connfd = connect_tcpserver(ip, port);
array_testcase_10w(connfd);
return 0;
}
遇到的问题
bug1:
数组kv_array, count=1000, 测试用例代码没有问题,但是改为10000后,程序崩了,但是由于程序每执行9个命令,kv存储引擎的数据为空,逻辑上是可以执行的,发现bug是因为数组的大小定义的是1024, 删除的时候,我们并没有更新total减少,然而SET的时候total一直在增大,最终爆掉
解决方法:当DEL时删到当前最后一个节点,更新total-1

bug2
红黑树rb_tree,执行这九条指令,最后一个RMOD操作失败,但是我自己调试,是没有Teacher的,但是程序却返回了ok。

解决方法:就是虽然之前某个节点被删除了, 且现在rbtree的节点数为0,此时search返回红黑树的nil节点,即该rbtree树非空,但是没有一个key。所以只需要特判一下是否是nil节点。
问题:
- kvstore为什么使用单线程?多线程?
- 如果要实现一个范围查询?实现什么数据结构的kv引擎?
- skiptable融入进去
- kv存储的内存管理如何实现?内存池
全量持久化与增量持久化
当前数据只在内存里,进程一退出,数据就没了。接下来详细解析全量持久化(snapshot)与增量持久化(journal)。
原来数据只在这些内存结构里:
- global_array
- global_rbtree
- global_hash
一旦进程退出或者服务器宕机,所有数据将丢失,为了保证数据的持久性,我们是需要将内存中的数据写入磁盘。
实现数据持久化策略有两种方法:
AOF (Append Only File):增量持久化,记录每一条写指令。
RDB (Redis Database):全量持久化,保存内存数据的快照。
如果只做一种增量或全量方式(RDB/AOF),会有两个问题:
问题 1:每次都全量写磁盘,成本高
问题 2:两次快照之间的新操作会丢

浙公网安备 33010602011771号