在 KV 存储这类网络项目里,服务端功能写出来只是第一步,真正能帮助定位问题、验证逻辑、观察性能的,往往是测试代码。一个完整的测试程序,通常不只是"连上服务端发几条消息",而是要把功能测试、结果校验和简单压测串成一条完整链路。本文就围绕一个简易 TCP 测试程序,梳理 KV 存储项目中测试与测压的实现思路,包括如何建立连接、如何封装单次测试、如何组织一整组功能测试,以及如何做一个最基础的 QPS 统计。
一、KV 存储里的测试,到底在测什么
很多时候一提"测试",容易想到的只是发一条 SET 命令,看看服务端回不回 OK。
但在 KV 存储项目里,真正有意义的测试不是单条命令,而是一整条数据生命周期。
一个典型的测试流程通常是这样的:
- 先写入一条键值对
- 再把它读出来
- 再修改它的值
- 再次读取确认修改成功
- 判断这个 key 是否存在
- 删除这条数据
- 删除后再次读取,确认已经不存在
- 删除后再修改,确认服务端能返回"对象不存在"
- 再次判断是否存在
也就是说,测试程序不是在"乱发命令",而是在模拟一个 key 从创建到删除的完整过程。
这种测试方式的价值很大,因为它不仅能验证某个接口是否可用,还能验证多个接口串起来之后,整个状态流转是不是正确的。KV 存储这种项目,最怕的恰恰不是命令不能发,而是"前面能写,后面不能改""删完以后状态还不对"这类链路问题。当前测试程序里,就是按固定顺序执行 SET、GET、MOD、GET、EXIST、DEL、GET、MOD、EXIST 这一组操作来完成验证。
二、测试程序的核心,不是发消息,而是"带着答案去校验"
在网络项目里,测试程序本质上就是一个 TCP 客户端。
它做的事情可以概括成 4 步:
- 和服务端建立连接
- 发送一条请求
- 接收服务端响应
- 把"实际响应"和"期望响应"做比较
真正关键的地方,不是 send() 和 recv() 本身,而是把一次测试封装成统一格式 。
这样每一条测试项都能写得很清楚,也很容易定位错误。
先看建立连接这一部分:
cpp
#include <arpa/inet.h>
/* 连接服务端 */
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; // IPv4
server_addr.sin_addr.s_addr = inet_addr(ip); // IP地址转换
server_addr.sin_port = htons(port); // 端口号转网络字节序
if(0 != connect(connfd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr_in))){
perror("connect");
return -1;
}
return connfd;
}
这一段代码的作用很直接:
给测试程序一个 IP 和端口,它就主动去连接服务端。连接成功后,后面的所有请求和响应都走这条 TCP 连接。
再看发送和接收的简单封装:
cpp
/* 发送请求 */
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("recv");
exit(1);
}
return res;
}
单独把 send 和 recv 包一下,最大的好处就是后面写测试逻辑时更清楚,不用每次都重复写错误处理。
接下来才是测试代码里最关键的部分,也就是"单条测试用例"的封装:
cpp
#define MAX_MSG_LENGTH 1024
/* 单条测试:发请求、收响应、校验结果 */
void testcase(int connfd, char *msg, char *pattern, char *casename){
if(!msg || !pattern || !casename) return;
/* 1. 发送请求 */
send_msg(connfd, msg, strlen(msg));
/* 2. 接收服务端返回值 */
char result[MAX_MSG_LENGTH] = {0};
recv_msg(connfd, result, MAX_MSG_LENGTH);
/* 3. 比较实际结果和期望结果 */
if(strcmp(result, pattern) == 0){
// printf("==> PASS -> %s\n", casename);
}else{
printf("==> FAILED -> %s, '%s' != '%s'\n", casename, result, pattern);
exit(1);
}
}
这一段特别值得学习,因为它把一次测试统一成了三个输入:
msg:要发给服务端的命令pattern:希望服务端返回什么casename:这条测试项的名字
比如下面这句:
cpp
testcase(connfd, "SET Teacher Charon", "OK\r\n", "SET-Teacher");
它的意思就是:
- 给服务端发:
SET Teacher Charon - 期望服务端回:
OK\r\n - 如果返回值不是这个,就当成测试失败
也就是说,测试程序不是"试试看能不能发出去",而是先把正确答案写出来,再让程序自动对照检查 。
这就是功能测试真正有用的地方。
三、KV 存储功能测试怎么组织,才算完整
有了 testcase() 这种单条测试封装以后,剩下的事情就简单很多了。
只需要把一组业务相关的命令按顺序串起来,就能形成一整套功能测试。
例如下面这组测试:
cpp
/* 一组完整的 KV 功能测试 */
void array_testcase(int connfd){
testcase(connfd, "SET Teacher Charon", "OK\r\n", "SET-Teacher");
testcase(connfd, "GET Teacher", "Charon\r\n", "GET-Teacher");
testcase(connfd, "MOD Teacher King", "OK\r\n", "MOD-Teacher");
testcase(connfd, "GET Teacher", "King\r\n", "GET-Teacher");
testcase(connfd, "EXIST Teacher King", "EXIST\r\n", "MOD-Teacher");
testcase(connfd, "DEL Teacher", "OK\r\n", "DEL-Teacher");
testcase(connfd, "GET Teacher", "NO EXIST\r\n", "GET-Teacher");
testcase(connfd, "MOD Teacher Charon", "NO EXIST\r\n", "MOD-Teacher");
testcase(connfd, "EXIST Teacher", "NO EXIST\r\n", "GET-Teacher");
}
这一组命令本质上是在做下面几件事:
1)验证最基础的写入和读取
先 SET,再 GET,确认写进去的值确实能读出来。
2)验证修改逻辑
先 MOD,再 GET,确认旧值已经被替换成新值。
3)验证存在性判断
调用 EXIST,确认 key 仍然在存储里。
4)验证删除后的状态
先 DEL,再 GET、再 MOD、再 EXIST,确认删除以后服务端不会还把它当成存在对象。
这几步看起来简单,但其实已经把一个 KV 存储里最基本的状态流转都覆盖到了。
测试代码写到这一步,才算真正开始具备"帮忙发现 bug"的能力,而不是只做表面验证。当前服务端协议层也正是根据命令类型,把 SET/GET/DEL/MOD/EXIST 分发到对应的处理逻辑,并返回 "OK\r\n"、"NO EXIST\r\n"、"EXIST\r\n" 这类响应字符串,所以客户端才能拿这些字符串做自动校验。
四、功能测通以后,为什么还要测压
功能测试解决的是"对不对",
压测解决的是"快不快"。
一个 KV 存储项目,如果只是偶尔发一两条命令,很多问题根本暴露不出来。
真正循环跑起来以后,才会发现一些隐藏问题,比如:
- 状态计数是否维护正确
- 删除后是否能继续插入
- 某个接口在大量调用下是否会返回异常结果
- 性能大概在什么数量级
所以,最简单的做法就是把前面那组功能测试放进循环里,连续跑很多轮,再统计总耗时。
当前测试程序里,压测逻辑大概是这样写的:
cpp
#include <sys/time.h>
/* 计算两次时间的毫秒差 */
#define TIME_SUB_MS(tv1, tv2) \
((tv1.tv_sec - tv2.tv_sec) * 1000 + (tv1.tv_usec - tv2.tv_usec) / 1000)
/* 简单压测:循环执行固定测试集 */
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 Charon", "OK\r\n", "SET-Teacher");
testcase(connfd, "GET Teacher", "Charon\r\n", "GET-Teacher");
testcase(connfd, "MOD Teacher King", "OK\r\n", "MOD-Teacher");
testcase(connfd, "GET Teacher", "King\r\n", "GET-Teacher");
testcase(connfd, "EXIST Teacher King", "EXIST\r\n", "MOD-Teacher");
testcase(connfd, "DEL Teacher", "OK\r\n", "DEL-Teacher");
testcase(connfd, "GET Teacher", "NO EXIST\r\n", "GET-Teacher");
testcase(connfd, "MOD Teacher Charon", "NO EXIST\r\n", "MOD-Teacher");
testcase(connfd, "EXIST Teacher", "NO EXIST\r\n", "GET-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, 9000 * 1000 / time_used);
}
这里的思路其实很清楚:
- 先记录开始时间
- 连续执行很多轮测试
- 再记录结束时间
- 用总请求数除以总耗时,得到一个最基础的吞吐量指标
为什么这里写的是 9000 * 1000 / time_used?
因为当前一轮里固定执行了 9 条请求 ,而 count = 1000,所以总请求数就是:
cpp
9 * 1000 = 9000
如果 time_used 是毫秒,那么乘以 1000 以后,算出来的就是一个简化版的 QPS。
虽然这个统计方式还比较粗糙,但对于项目初期已经很有参考价值了:至少能知道服务端大概处在什么数量级。
五、KV 存储里的测试代码,真正有价值的地方是什么
很多人一开始写测试代码,会觉得这只是"辅助代码",不重要。
但实际上,在 KV 存储这种项目里,测试代码的价值非常高,主要体现在三个方面。
1)它能快速定位功能 bug
比如返回值不对、删除后状态异常、修改逻辑有问题,这些都能通过"期望值和实际值不一致"第一时间暴露出来。
之前这套逻辑在循环测试时出现过 SET 返回 ERROR、MOD 返回值和预期不一致,本质上就是因为反复测试把存储逻辑里的状态维护问题逼出来了。当前数组存储实现里,set/get/del/mod/exist 的行为都直接影响客户端看到的 "OK"、"ERROR" 或 "NO EXIST"。
2)它能把"手工点点点"变成自动验证
原来要手动敲很多条命令,现在只要运行一次测试程序,就能自动把一整套流程跑完。
对于后面继续改服务端代码来说,这一点非常重要,因为每改一次逻辑都能很快回归测试。
3)它能顺手完成最基础的性能观察
虽然这里只是一个很简单的 QPS 统计,但已经足够用来观察优化前后性能有没有变化。
比如改了协议解析、改了数据结构、改了删除逻辑之后,跑一遍测试就能马上看到耗时和吞吐量有没有明显波动。
总结
KV 存储项目里的测试板块,真正重要的不是"能不能发一条请求",而是能不能把功能验证、状态校验和简单性能观察串成一个完整闭环。
这类测试程序通常有三个最核心的作用:
- 验证功能链路是否正确
- 发现服务端逻辑 bug
- 统计一个最基础的吞吐量指标
从实现上看,它其实并不复杂:
连接服务端、发送请求、接收响应、比较结果、循环执行、统计耗时。
但恰恰是这样一个看起来不大的测试模块,往往最能帮助把 KV 存储项目真正跑通。