在嵌入式环境、网关设备或者自研中间件里,用 C 语言直接操作 Redis 是件很常见的事。很多开发者一开始习惯用 hiredis 这类现成客户端库,但在资源受限或者需要深度定制协议的场景下,自己动手实现一套简洁的客户端反而更能理解 Redis 的通信本质。这篇内容就围绕“C 编码实现连接 Redis 服务器”展开,从 TCP 建连、RESP 协议拼装、命令收发,到响应解析和踩坑经验,完整梳理一套可落地的思路。
1. 项目概述与整体设计思路
1.1 为什么值得自己写 C 代码连接 Redis
先说结论:如果你只是做应用开发,用 hiredis 或者 Redis 官方推荐的其他语言客户端就够了。但当你需要精细控制连接池、定制 pipeline 批处理、解析特殊返回结构,或者运行环境根本不方便引入动态库时,手写一段基于 TCP 的 Redis 客户端代码就非常有必要了。
Redis 的通信协议叫 RESP(REdis Serialization Protocol),本质上就是一种基于文本行的简单协议。搞清楚这个协议之后,C 代码连接 Redis 就变成了三件事:建立 TCP 连接、把命令格式化成协议数据、把服务端返回的数据流解析成可用的值。这三件事拆开看都不难,难的是细节处理得够不够稳。
这个项目适合的人群很明确:一是嵌入式方向的开发者,需要在交叉编译环境里集成 Redis 访问能力;二是中间件开发者,希望理解客户端 SDK 背后的协议逻辑;三是刚接触网络编程的 C 语言学习者,想通过一个真实项目把 socket、缓冲区、状态机这些知识点串起来。这篇文章不依赖任何第三方 Redis 客户端库,直接基于 POSIX socket 实现。
1.2 方案选型:手写协议而不是直接套库
选用纯手写方案,核心原因是想彻底掌握 RESP 协议的细节,同时摆脱对第三方库的依赖。hiredis 内部做的事情其实也是这几步:连接、格式化命令、解析响应。只不过它把这些步骤封装得足够完善,使用者感知不到中间发生了什么。
自己写就需要额外处理几个以前没注意过的问题:命令参数里包含空格和换行时怎么转义?服务端返回的二进制安全字符串怎么存储?多条回复和错误回复如何区分?这些问题恰恰是网络协议实战中最有价值的部分。把一个简单的 GET 命令跑通不难,但要把 SET 一个含换行符的二进制值、批量写入、订阅消息这些场景全部覆盖,就需要对协议有相当深入的理解。
考虑到项目目标是练手和自定义扩展,我选择纯 C 实现,不依赖 pthread,也不引入任何第三方库,只使用 glibc 的标准 socket 接口和字符串处理函数。整个工程可以在 Linux 和 macOS 下直接编译,Windows 下需要做少量适配。这套方案的好处是可控性强,出问题可以直接从协议层定位,代价是需要自己处理很多边界情况。
2. 准备工作与环境搭建
2.1 编译环境与必要的头文件
开始写代码之前,先把环境准备好。我这里用的是 Linux 环境,内核版本没有特殊要求,只要是常见的发行版就可以。需要的基础头文件包括:
<sys/socket.h>:socket 相关的函数与结构体定义<netinet/in.h>:sockaddr_in结构体,用于描述 IPv4 地址<arpa/inet.h>:inet_pton函数,把字符串 IP 转为二进制<unistd.h>:close、read、write等 POSIX 函数<string.h>、<stdio.h>、<stdlib.h>:基础 C 库
编译的时候我用的是 gcc,命令很简单:
gcc -o redis_client redis_client.c不需要任何额外链接选项,因为所有用到的函数都在 glibc 里。如果你在 macOS 上编译,同样可以直接编,需要注意的点是 macOS 的 socket 默认不设置 SO_NOSIGPIPE,写数据时可能会触发 SIGPIPE 信号导致进程退出,后面我会重点说这个问题。
验证环境最简单的方式是先写一个 10 行的 TCP 客户端,连一个已知端口,能通就说明网络层没问题。这里建议先在本机启动一个 Redis 服务实例,端口用默认的 6379,用于后续联调。
2.2 Redis 服务端准备与连通性预检
连接 Redis 之前,先把服务端跑起来。这里用一个干净的 Redis 实例,不加载持久化配置,纯内存运行即可。启动命令加--daemonize yes让它在后台运行,也可以不加直接前台跑,方便看日志。
redis-server --port 6379 --save ""--save ""的意思是禁用 RDB 持久化,避免测试过程中产生磁盘文件。启动完成后用redis-cli ping验证,如果返回 PONG 就说明服务端正常。
还有一点要确认的是 Redis 是否开启了 protected mode。默认配置下,Redis 只允许回环地址访问,也就是说只有本机程序能连。这正好符合我们本地测试的需求,不需要额外修改配置。如果你要连的 Redis 在另外一台机器上,需要把 redis.conf 里的bind改一下,并且设置密码或者把 protected-mode 关掉,否则会被拒绝连接。
网络层面确认之后,再确认协议层面的一个细节:Redis 默认使用的数据库是 db0,不同 db 之间数据隔离,连接建立后不需要额外初始化,直接发命令即可。如果服务端设置了密码,则需要在发业务命令之前先发AUTH命令完成鉴权,否则会收到NOAUTH Authentication required的错误。这部分逻辑我会在代码里预留接口。
3. 核心实现:从 TCP 建连到 RESP 协议交互
3.1 建立 TCP 连接与超时控制
C 语言连 Redis 第一步是建 socket。这里我用的是socket(AF_INET, SOCK_STREAM, 0)创建 TCP 连接,然后通过inet_pton把点分十进制的 IP 地址转换成sockaddr_in结构体。如果 Redis 跑在本地,直接填127.0.0.1,端口 6379。
连接时有两个细节需要注意。
第一,connect函数在没有设置非阻塞的前提下会一直阻塞,直到连接成功或超时。操作系统默认的超时时间很长(大概 75 秒左右),这在生产环境是不可接受的。处理方式是把 socket 设为非阻塞,然后配合select或poll等待可写事件,再通过getsockopt检查是否连接成功。这样可以把连接超时控制在自己想要的范围内,比如 2 秒。
第二,每个 socket 都有一个发送缓冲区和接收缓冲区。如果服务端迟迟不响应,读取操作会一直占着线程。所以连接建立之后,收发两端都应该设置超时。发送超时和接收超时可以用setsockopt的SO_SNDTIMEO和SO_RCVTIMEO设置,单位是 struct timeval。这样做的好处是即使服务端卡住,客户端最多等一个超时周期就能返回错误,不会无限期挂起。
建立连接的核心代码思路大致是这样:先创建 socket,设置非阻塞标志,然后调用 connect。如果返回值是 -1 且 errno 是 EINPROGRESS,说明连接正在后台进行,此时用 select 等待它可写,超时时间设为 2000 毫秒。之后再用 getsockopt 取 SO_ERROR,如果值是 0 说明连接成功。整个过程不需要引入复杂的异步框架,线性逻辑即可。
3.2 RESP 协议基础与命令格式化
Redis 客户端和服务端之间的通信完全基于 RESP 协议。这个协议定义了五种数据类型,但在客户端向服务器发送请求这个方向上,只会用到一种,那就是数组。数组里每个元素都是批量字符串。
举个例子,SET foo bar这条命令在协议层面的表示是:
*3\r\n $3\r\n SET\r\n $3\r\n foo\r\n $3\r\n bar\r\n逐行解释一下:
*3表示接下来是一个包含 3 个元素的数组$3表示后面是一个长度为 3 的字符串- 字符串内容跟随其后,以
\r\n结尾
也就是说,命令本身、key、value,以及各个参数,全部被当作长度前缀的字符串来传输。命令名在协议里也被当作一个普通字符串处理,比如SET就是$3\r\nSET\r\n。
我在代码里封装了一个函数,参数是命令名和参数字符串数组,输出是完整的协议缓冲区。这个函数内部先计算所有参数的总长度,算出整个请求的长度,然后一次性分配缓冲区,填上数组头和各参数的长度前缀。之所以要一次性拼装而不是多次 write,是为了减少系统调用的次数。如果每条命令拆成十几次 write,在高并发场景下开销会非常明显。
拼接时需要注意的一点是,参数内容本身可能包含\r\n或者空格,这不影响协议解析,因为服务端是根据长度前缀来读取数据的,不会因为内容里有特殊字符就错位。这也是 RESP 协议设计得比较精巧的地方,不需要转义,任何二进制数据都可以直接用长度前缀封装。
3.3 命令发送与响应读取的状态机设计
连接建立好、请求也格式化好了,接下来是发送和接收。
发送阶段相对简单,write函数把缓冲区里的数据写进 socket。但 write 并不保证一次把全部数据写完,它只保证尝试写这么多字节,实际返回值可能小于请求长度。我封装了一个write_full函数,循环调用 write,直到所有字节都发出去。这里有个坑:如果在循环里因为 EINTR(信号中断)导致 write 返回 -1,不能把它当作错误,应该继续重试。这个处理在本地测试时很难触发,但在生产环境中,如果程序注册了信号处理器,很容易碰到。
接收阶段复杂一些。Redis 返回的内容可能是单行字符串、错误信息、整数、普通字符串、数组,以及空值。不同类型的首字节不一样:
+表示单行字符串-表示错误信息:表示整数$表示批量字符串*表示数组
解析过程需要逐字节读取,遇到\r\n就把当前行解析出来,根据首字节进入不同的处理分支。一个稳妥的做法是实现一个简单的状态机,每次从 socket 读一批数据到应用层缓冲区,然后从这个缓冲区里逐步解析。缓冲区里可能只有半条消息,比如$5\r\n读到了但后面的内容还没到;也可能同时包含多条消息,比如一次 recv 就收到两个命令的响应。无论哪种情况,解析器都应该能正确处理。
我在实现中用了动态增长的缓冲区,每读完一条完整响应就返回一个结构体,剩下的数据继续保留在缓冲区里,等待下一次解析。这个设计是很多通用 Redis 客户端内部状态机的简化版,理解了它,再看 hiredis 的 reader 模块就会觉得似曾相识。
4. 完整代码与实操要点
4.1 一个可以直接编译运行的最小示例
下面是完整代码。我把功能做了精简,实现了 Ping、Set、Get 三个操作,覆盖了单行响应、简单字符串响应和错误响应三种类型。这个版本适合作为起点,后续想要增加新的命令,只需要扩展命令分发部分即可。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #include <fcntl.h> #include <sys/select.h> #include <sys/time.h> typedef struct { int fd; char buffer[4096]; size_t buf_len; size_t buf_pos; } RedisClient; static int connect_with_timeout(const char *ip, int port, int timeout_ms) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { return -1; } int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip, &addr.sin_addr); int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); if (ret < 0 && errno != EINPROGRESS) { close(fd); return -1; } fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; ret = select(fd + 1, NULL, &wfds, NULL, &tv); if (ret <= 0) { close(fd); return -1; } int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err != 0) { close(fd); return -1; } fcntl(fd, F_SETFL, flags); return fd; } static int write_full(int fd, const char *data, size_t len) { size_t sent = 0; while (sent < len) { ssize_t n = write(fd, data + sent, len - sent); if (n < 0) { if (errno == EINTR) { continue; } return -1; } sent += n; } return 0; } static char *redis_build_command(int argc, char **argv) { size_t total = 0; for (int i = 0; i < argc; i++) { size_t arg_len = strlen(argv[i]); total += 1 + 8 + arg_len + 2; } total += 1 + 8 + 2; char *cmd = malloc(total + 1); if (!cmd) return NULL; int off = 0; off += snprintf(cmd + off, 32, "*%d\r\n", argc); for (int i = 0; i < argc; i++) { size_t arg_len = strlen(argv[i]); off += snprintf(cmd + off, 32, "$%zu\r\n", arg_len); memcpy(cmd + off, argv[i], arg_len); off += arg_len; cmd[off++] = '\r'; cmd[off++] = '\n'; } cmd[off] = '\0'; return cmd; } static ssize_t redis_read_line(RedisClient *c, char *line, size_t max_len) { size_t used = 0; while (1) { if (c->buf_pos < c->buf_len) { char ch = c->buffer[c->buf_pos++]; if (ch == '\n') { line[used] = '\0'; return used; } if (used < max_len - 1) { line[used++] = ch; } } else { ssize_t n = recv(c->fd, c->buffer, sizeof(c->buffer), 0); if (n > 0) { c->buf_len = n; c->buf_pos = 0; } else { return -1; } } } } static int redis_read_reply(RedisClient *c, char *out, size_t out_len) { char line[128]; ssize_t n = redis_read_line(c, line, sizeof(line)); if (n < 0) { return -1; } if (line[0] == '+') { snprintf(out, out_len, "%s", line + 1); return 0; } else if (line[0] == '-') { snprintf(out, out_len, "ERROR: %s", line + 1); return -2; } else if (line[0] == ':') { snprintf(out, out_len, "%s", line + 1); return 0; } else if (line[0] == '$') { int len = atoi(line + 1); if (len == -1) { snprintf(out, out_len, "(nil)"); return 0; } char *body = malloc(len + 1); if (!body) return -1; size_t got = 0; while (got < (size_t)len + 2) { if (c->buf_pos < c->buf_len) { body[got++] = c->buffer[c->buf_pos++]; } else { ssize_t r = recv(c->fd, c->buffer, sizeof(c->buffer), 0); if (r <= 0) { free(body); return -1; } c->buf_len = r; c->buf_pos = 0; } } body[len] = '\0'; snprintf(out, out_len, "%s", body); free(body); return 0; } else if (line[0] == '*') { int count = atoi(line + 1); if (count <= 0) { snprintf(out, out_len, "(empty array)"); return 0; } size_t total_out = 0; for (int i = 0; i < count; i++) { char item[512]; if (redis_read_reply(c, item, sizeof(item)) != 0) { return -1; } total_out += snprintf(out + total_out, out_len - total_out, "%s%s", i ? ", " : "", item); } return 0; } snprintf(out, out_len, "UNKNOWN"); return -1; } static int redis_command(RedisClient *c, char *out, size_t out_len, int argc, char **argv) { char *cmd = redis_build_command(argc, argv); if (!cmd) return -1; int ret = write_full(c->fd, cmd, strlen(cmd)); free(cmd); if (ret != 0) return -1; return redis_read_reply(c, out, out_len); } int main(void) { RedisClient c; memset(&c, 0, sizeof(c)); memset(c.buffer, 0, sizeof(c.buffer)); c.fd = connect_with_timeout("127.0.0.1", 6379, 2000); if (c.fd < 0) { printf("connect failed\n"); return 1; } char reply[1024]; char *ping_args[] = {"PING"}; if (redis_command(&c, reply, sizeof(reply), 1, ping_args) == 0) { printf("PING -> %s\n", reply); } char *set_args[] = {"SET", "name", "zhangsan"}; redis_command(&c, reply, sizeof(reply), 3, set_args); printf("SET -> %s\n", reply); char *get_args[] = {"GET", "name"}; redis_command(&c, reply, sizeof(reply), 2, get_args); printf("GET -> %s\n", reply); close(c.fd); return 0; }这段代码在一个文件里即可编译通过。注意redis_read_reply的数组响应处理逻辑采用了递归调用,好处是天然支持嵌套数组,代价是深度特别大的响应可能占用较多栈空间。在 Redis 的实际返回中,嵌套层级一般不超过两层,所以这样处理是没问题的。
4.2 关键细节:为什么缓冲区要自己管理
这个示例里有一个我自己维护的输入缓冲区buffer、buf_len和buf_pos三个字段。很多第一次接触网络编程的人会问,为什么不能每次 recv 之后直接处理?因为 TCP 是流式协议,它没有消息边界。一次 recv 返回的数据可能只是半条消息,比如服务端分两次发送了$5\r\nhello\r\n,你的第一次 recv 可能只收到$5\r\nhel,剩余的部分还留在内核缓冲区里。
如果每次收到数据就尝试解析,代码必须处理“数据不完整”的情况,逻辑会变得非常繁琐。而维护一个应用层缓冲区,把 recv 到的数据先存起来,解析器只管从这个缓冲区里取字节,取不到就继续 recv,这样就把流式数据转换成了可以按需消费的字节流。这个思路在几乎所有网络协议解析里都适用,包括 HTTP 解析、WebSocket 帧解析。
另外要注意的是,redis_read_reply里读取批量字符串时,$len后面的\r\n也需要消费掉,否则会把 CRLF 当成下一个命令的起始字节,导致解析错位。我在代码里循环读取len + 2个字节,目的就是把末尾的 CRLF 一并处理掉。
4.3 常见问题排查与踩坑记录
这个项目看着简单,实际操作中踩的坑比想象中多。我把常见问题整理成了排查表,方便对照处理。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| connect 报 Connection refused | Redis 未启动或端口不对 | 检查服务端进程,用 redis-cli ping 验证 |
| 连接成功但 PING 无响应 | 服务端开启了 protected mode,且连接来自非回环地址 | 修改 Redis 配置,允许对应来源访问 |
| 发送命令后收到 NOAUTH | 服务端设置了密码 | 在业务命令前发送 AUTH 命令,携带正确密码 |
| 解析结果出现多余字符 | 批量字符串末尾的 CRLF 未消费 | 确认读取了len + 2字节 |
| 程序运行一段时间后崩溃 | 未处理 SIGPIPE,写数据时连接已断开 | 调用 signal(SIGPIPE, SIG_IGN),或发送时设置 MSG_NOSIGNAL |
| 高并发下命令错乱 | 多个线程共用一个连接发送命令 | 为每个连接加锁,或每个线程使用独立连接 |
这里重点说一下 SIGPIPE 问题。write一个已经对端关闭的 socket,内核会向进程发送 SIGPIPE 信号,默认行为是终止进程。本地测试时很难复现,因为连接刚断开就发现异常了。但在生产环境,如果对端因为超时把你这个连接断掉,你的程序还在尝试写入,进程就会无声无息地退出,连错误日志都来不及打。处理方式有两种:一是signal(SIGPIPE, SIG_IGN)忽略这个信号,让 write 正常返回 EPIPE 错误;二是在 send 时传入MSG_NOSIGNAL标志。
我在实际项目里两种都用,先忽略 SIGPIPE,再在 send 调用时也加上 MSG_NOSIGNAL,双保险。类似的坑在 Redis 客户端里很常见,标志位这种小细节往往决定程序的稳定性,面试和项目评审的时候也经常拿来考察候选人的网络编程功底。
5. 进阶用法与性能优化方向
5.1 为代码增加 AUTH 鉴权支持
生产环境的 Redis 不可能裸奔,密码是标配。往现有代码里加 AUTH 鉴权很简单。在 main 函数建立连接之后、发送业务命令之前,调用一次 redis_command,参数是 AUTH 和密码。
char *auth_args[] = {"AUTH", "my_secure_password"}; if (redis_command(&c, reply, sizeof(reply), 2, auth_args) != 0) { printf("auth failed: %s\n", reply); return 1; }需要注意的是,AUTH 命令的响应是单行字符串+OK。如果密码错误,返回的是一个错误响应,redis_read_reply会把-ERR invalid password解析成以 ERROR 开头的字符串。判断返回值是否为 0 就能知道鉴权是否成功。
如果 Redis 配置了 ACL 而不是简单的 requirepass,情况会复杂一些。ACL 方式下可能要先发送AUTH username password两个参数。这类需求改动不多,只需要把参数数组多传一个用户名即可。我把命令函数设计成变参的样子,就是为了方便支持这种灵活的参数组合。
5.2 超时、重试与连接池的取舍
示例代码只做了一次连接、几条命令、关闭退出。真实项目中,连接 Redis 的操作不会是一次性的,而是反复进行的。这里就有几个设计决策需要做。
第一个是超时时间。上面 connect 的超时设为 2 秒,收发超时我建议设置在 1 到 3 秒之间。太短了,Redis 在慢命令(比如 KEYS 全表扫描、BGSVAE 期间执行某些命令)时容易误报超时;太长了,调用方等待时间会变得不可接受。取舍的关键是对业务命令耗时的心中有数。
第二个是断线重连。我在代码里没有封装重连逻辑,但在实际使用中,如果 Redis 主从切换或网络抖动导致连接断开,客户端必须能够自动重新连接。最简单的方式是把 connect 封装成一个函数,出错的时候调用方决定是否重试。做一些简单的指数退避也是可以的,比如第一次等待 100ms、第二次 200ms、第三次 400ms,最多重试 5 次。
第三个是连接池。C 语言没有像 Java、Go 那样的语言级并发原语,要实现连接池需要自己管理互斥锁和空闲列表。如果项目只有少数几个线程并发访问 Redis,为每个线程单独分配一个连接可能是更简单的方案。线程数少、连接数可控,就不需要处理锁竞争和连接复用逻辑。只有在线程数较多或连接数频繁变化时,引入连接池才值得。
5.3 Pipeline 与批量命令的实现思路
Redis 的 pipeline 机制比很多人想象的简单:它并不是服务端提供的功能,而是客户端可以在一个 TCP 连接上连续发送多条命令,不必等待上一条命令的响应。服务端会依次处理并依次返回结果,客户端一次性读取所有响应。
在我这个代码框架里实现 pipeline 只需要两步。第一步,组装多个命令的协议数据,依次调用 redis_build_command,把得到的协议字符串拼接进一个大缓冲区,然后一次性 write_full。第二步,用 redis_read_reply 循环读取响应,次数等于命令条数。
批量命令处理的核心收益是减少了网络往返时延。假设每条命令的网络 RTT 是 0.5ms,按条发送 100 条命令需要 50ms,而 pipeline 只需要一次 RTT 加服务端处理时间。这个优化在局域网内效果可能不明显,但在公网环境或者跨机房场景下,收益是数量级的差距。
实现 pipeline 时有一件事必须注意:服务端会按照接收顺序返回响应,所以客户端必须按发送顺序读取。也就是说,如果你发了 SET、GET、SET,那么解析响应的顺序也必须对应这三条命令。不要试图并发解析,否则必然错位。另一个注意点是,如果某条命令在协议层就非法(比如参数数量不对),服务端会返回一个错误响应占一个位置,后续的响应不会因为这个错误而错位。这个设计让 pipeline 的错误处理变得很干净。
6. 经验总结与后续扩展建议
代码写完、测试通过之后,我复盘了整个项目的实现过程。最大的收获是彻底搞懂了 RESP 协议在实际网络交互中的应用价值。以前用 hiredis 的时候,意识不到协议细节,总觉得连 Redis 是“理所当然”的。真正手写一遍之后,才明白客户端 SDK 在背后做了多少数据整理工作。
有一点要特别提醒:这段代码是同步阻塞模型,适合脚本、工具、后台任务这类场景。如果要用在事件驱动的高并发服务里,需要把 recv 和解析逻辑搬进 epoll 事件循环中,并且处理半包和粘包问题。字段buf_pos和buf_len就是为这个改造预留的,引入 epoll 后,只需要把 recv 改成非阻塞循环,把解析状态保存到每个连接对应的结构体里。
如果你想把协议支持扩展得更完整,下一步可以加的功能有:SUBSCRIBE 订阅模式的无阻塞读取、事务 MULTI/EXEC 的返回值处理、SCAN 游标遍历、以及集群模式下的 MOVED 重定向处理。每一个功能都会触及协议解析的新边界,也都是很好的实战练习。
我是把这段代码作为公司一个嵌入式采集模块的 Redis 上报客户端来用的。当时遇到一个诡异的问题:设备连续运行几天后,Redis 连接偶发失效,重启程序就能恢复。后来定位到是服务端设置了空闲超时,把长时间没有数据传输的连接回收了。解决方式很简单,客户端每隔 30 秒发一个只包含 PING 命令的小包,保持连接活跃。类似这种“连接一直挂在那边,但实际已经死了”的场景,在长连接应用里特别常见,测试时也可以专门模拟这种空闲断连,提前把心跳逻辑做进去。
如果你是初学者,建议在现有代码基础上做两个练习:第一个是把redis_read_reply改成支持嵌套数组类型,并打印完整的 JSON 格式输出;第二个是模拟一个网络极慢的场景,比如用 tc 命令在 loopback 接口上加延迟,观察超时机制是否按预期工作。这两个练习做完,你对 TCP 客户端编程的理解会上一个台阶。