2.6KV存储项目

KV存储项目
image

关于网络编程

  • 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放在网络框架的编程方式:

image

# 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)就行
image

main放在kvstore.c的编程方式

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

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

或者采用整体编译的方式
image
image

gcc -o kvstore kvstore.c reactor.c proactor.c Ntyco.c -I ./NtyCo/core/ -L ./NtyCo/ -luring -lntyco

KVstore的协议设计:

image

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作为分隔符。例如:
image

那么服务器解析的代码伪代码如下(其中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

自动化测试用例

  1. tcp客户端,建立连接
  2. 发送协议
  3. 接收服务端返回的数据
  4. 预期数据 与 服务端返回数据对比
    检验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
image

bug2

红黑树rb_tree,执行这九条指令,最后一个RMOD操作失败,但是我自己调试,是没有Teacher的,但是程序却返回了ok
image
解决方法:就是虽然之前某个节点被删除了, 且现在rbtree的节点数为0,此时search返回红黑树的nil节点,即该rbtree树非空,但是没有一个key。所以只需要特判一下是否是nil节点。

问题:

  1. kvstore为什么使用单线程?多线程?
  2. 如果要实现一个范围查询?实现什么数据结构的kv引擎?
  3. skiptable融入进去
  4. kv存储的内存管理如何实现?内存池

全量持久化与增量持久化

当前数据只在内存里,进程一退出,数据就没了。接下来详细解析全量持久化(snapshot)与增量持久化(journal)。

原来数据只在这些内存结构里:

  • global_array
  • global_rbtree
  • global_hash

一旦进程退出或者服务器宕机,所有数据将丢失,为了保证数据的持久性,我们是需要将内存中的数据写入磁盘。

实现数据持久化策略有两种方法:

AOF (Append Only File):增量持久化,记录每一条写指令。
RDB (Redis Database):全量持久化,保存内存数据的快照。

如果只做一种增量或全量方式(RDB/AOF),会有两个问题:
问题 1:每次都全量写磁盘,成本高
问题 2:两次快照之间的新操作会丢

全量持久化

posted @ 2026-03-17 18:09  Xiaomostream  阅读(22)  评论(0)    收藏  举报