简介hiredis-0.13.1 是一份面向 C 语言开发者的轻量级 Redis 客户端库源码包适合需要在服务端程序中嵌入高性能 Redis 通信能力的中高级工程师尤其适用于高并发、低延迟的命令收发与协议解析场景。压缩包共 32 个文件约 54KB以 13 个 .h 头文件与 13 个 .c 源文件为主体覆盖连接管理、协议读写、字典与动态字符串等核心模块另含 Makefile、README.md、CHANGELOG.md、COPYING 及 .travis.yml 等构建与说明文件并附带 libev、libevent、glib、libuv 等适配器示例便于快速对照接入。目前已有 228 人学习下载。通过阅读源码与示例读者可掌握 Redis 协议的解析流程、异步接口的调用方式以及多事件循环适配思路为排查连接异常、优化内存与 CPU 占用提供可参考的实现依据。1. hiredis-0.13.1一个 C 客户端库的实战拆解如果你正在用 C 或 C 写服务端程序需要跟 Redis 打交道那 hiredis 大概率是你绕不开的一个库。它足够轻轻到编译完只有几百 KB它也足够老0.13.1 这个版本号在不少存量项目里还能翻到。很多人第一次接触它是因为某个中间件源码里引了hiredis/hiredis.h然后发现这东西既没有连接池也没有自动重连甚至连超时都要自己处理——但恰恰是这种“什么都不替你做”的设计让它在高并发场景下反而比很多封装库更可控。这篇笔记拆的是 hiredis-0.13.1 这个具体版本从编译参数、同步接口、异步接口到协议解析的边界把能复现的步骤和踩过的坑都摆出来。适合手里有 C 项目、需要直连 Redis 或者要读中间件源码的从业者。2. 编译与链接从源码到可执行文件2.1 为什么选 0.13.1 而不是更新版本hiredis 的版本迭代不算激进0.13.1 属于 0.13 分支的维护版本。很多存量项目锁死在这个版本原因通常有两个一是构建系统里已经写死了hiredis-0.13.1的路径升级会触发连锁改动二是这个版本的 API 已经覆盖了绝大多数同步和异步场景没有引入后续版本里那些需要重新适配的接口调整。常见做法是如果项目没有用到 Redis 6 之后的 ACL 或 RESP3 特性0.13.1 完全够用。我一般会先确认项目里有没有依赖redisAsyncSetConnectCallback之外的异步回调如果没有就优先保持版本一致避免链接期出现符号冲突。从源码构建是最稳的方式因为发行版仓库里的 hiredis 版本往往跟你的目标版本对不上。源码包解压后根目录下有一个 Makefile直接 make 就能出静态库和动态库。但这里有个细节默认编译出来的libhiredis.so带版本号如果你要嵌到自己的构建系统里最好显式指定安装路径否则make install会往/usr/local/lib里塞跟系统里已有的版本打架。# 解压后进入源码目录 tar -xzf hiredis-0.13.1.tar.gz cd hiredis-0.13.1 # 指定安装前缀避免污染系统默认路径 make PREFIX/opt/hiredis-0.13.1 make PREFIX/opt/hiredis-0.13.1 install # 确认产物 ls /opt/hiredis-0.13.1/lib # 应看到 libhiredis.a、libhiredis.so、libhiredis.so.0.13 等上面这段命令里PREFIX是 Makefile 支持的变量用来控制安装根目录。make阶段会先编译出hiredis.o、net.o、sds.o等目标文件再链接成静态库和动态库。make install会把头文件拷到include/hiredis/库文件拷到lib/。注意libhiredis.so是一个软链接指向libhiredis.so.0.13而libhiredis.so.0.13又指向libhiredis.so.0.13.1。如果你在容器里构建记得把lib目录加到LD_LIBRARY_PATH否则运行时会报error while loading shared libraries。2.2 链接时容易翻车的两个参数编译自己的程序时链接命令里-lhiredis的顺序很关键。GCC 的链接器是从左到右解析符号的如果-lhiredis放在源文件前面链接器在处理源文件时还没看到库就会报一堆undefined reference to redisConnect。正确顺序是源文件在前库在后。另外如果用的是静态库libhiredis.a还需要额外链接-lpthread因为 hiredis 内部用了 pthread 做异步事件循环的锁保护。动态库则不需要显式加-lpthread因为.so的依赖关系已经记录在 ELF 的DT_NEEDED里了。# 动态链接源文件在前库在后 gcc -o demo demo.c -I/opt/hiredis-0.13.1/include \ -L/opt/hiredis-0.13.1/lib -lhiredis # 静态链接需要补上 pthread gcc -o demo_static demo.c -I/opt/hiredis-0.13.1/include \ /opt/hiredis-0.13.1/lib/libhiredis.a -lpthread参数说明-I指定头文件搜索路径-L指定库文件搜索路径-l指定库名去掉lib前缀和.so/.a后缀。静态链接时直接写.a文件的完整路径就不需要-L和-l了。如果编译时报undefined reference to pthread_create说明漏了-lpthread如果运行时提示找不到libhiredis.so.0.13检查LD_LIBRARY_PATH是否包含/opt/hiredis-0.13.1/lib。提示在 CMake 项目里用find_library(HIREDIS_LIB hiredis PATHS /opt/hiredis-0.13.1/lib NO_DEFAULT_PATH)可以精确锁定版本避免找到系统里其他版本的 hiredis。3. 同步接口实战连接、命令与回复解析3.1 建立连接与超时控制hiredis 的同步 API 核心就三个函数redisConnect、redisCommand、redisFree。redisConnect返回一个redisContext指针里面封装了 socket 文件描述符、读写缓冲区和一个err字段。如果连接失败redisConnect不会返回 NULL而是返回一个err被置位的 context所以判断连接是否成功不能只看指针是否为空必须检查c-err。#include stdio.h #include hiredis/hiredis.h int main(void) { // 连接本地 Redis端口 6379超时 1.5 秒 struct timeval timeout {1, 500000}; // 1.5 秒 redisContext *c redisConnectWithTimeout(127.0.0.1, 6379, timeout); if (c NULL || c-err) { if (c) { printf(连接错误: %s\n, c-errstr); redisFree(c); } else { printf(分配 context 失败\n); } return 1; } printf(连接成功Redis 版本: %d.%d.%d\n, c-version / 10000, (c-version % 10000) / 100, c-version % 100); redisFree(c); return 0; }这段代码里redisConnectWithTimeout比redisConnect多了一个struct timeval参数用来控制connect()系统调用的超时。如果不设超时在网络不通的情况下connect()会阻塞默认的 TCP 超时时间可能长达几十秒。c-err是错误码c-errstr是可读的错误描述。c-version是 Redis 服务端返回的版本号编码方式是major*10000 minor*100 patch所以0x000703对应 7.3。注意redisFree会关闭 socket 并释放 context不要重复调用。3.2 命令发送与回复类型判断redisCommand是一个可变参数函数用法类似printf。它会把命令按 Redis 协议编码后发给服务端然后阻塞等待回复返回一个redisReply指针。redisReply的type字段决定了怎么读数据REDIS_REPLY_STATUS是简单状态REDIS_REPLY_STRING是字符串REDIS_REPLY_INTEGER是整数REDIS_REPLY_ARRAY是数组REDIS_REPLY_NIL表示空。每次用完redisReply必须调用freeReplyObject否则内存会持续增长。// 发送 SET 命令 redisReply *reply redisCommand(c, SET %s %s, user:1001, alice); if (reply NULL) { printf(命令发送失败: %s\n, c-errstr); // 这里通常意味着连接已断开需要重建连接 } else { printf(SET 回复类型: %d, 内容: %s\n, reply-type, reply-str); freeReplyObject(reply); } // 发送 GET 命令 reply redisCommand(c, GET user:1001); if (reply ! NULL reply-type REDIS_REPLY_STRING) { printf(GET 结果: %s\n, reply-str); } freeReplyObject(reply); // 发送 INCR 命令返回整数 reply redisCommand(c, INCR counter:pageview); if (reply ! NULL reply-type REDIS_REPLY_INTEGER) { printf(当前计数: %lld\n, reply-integer); } freeReplyObject(reply);参数说明redisCommand的格式化字符串里%s会被替换成 C 字符串%b用于二进制安全字符串需要额外传长度参数%d用于整数。reply-str只在type为REDIS_REPLY_STRING或REDIS_REPLY_STATUS时有效reply-integer只在REDIS_REPLY_INTEGER时有效。如果type是REDIS_REPLY_ARRAYreply-elements是元素个数reply-element[i]是第 i 个元素的redisReply指针。一个常见的翻车点是对REDIS_REPLY_NIL的回复直接读reply-str结果拿到 NULL 指针程序崩溃。所以读之前一定先判type。3.3 管道模式减少往返延迟如果你要连续发多条命令而且不关心中间结果可以用管道模式。redisAppendCommand把命令写入输出缓冲区但不发送redisGetReply才真正发送并读取回复。这样多条命令可以一次性发出去减少网络往返次数。在批量写入场景下管道模式能把吞吐量提升好几倍。// 管道模式先追加三条命令 redisAppendCommand(c, SET key1 val1); redisAppendCommand(c, SET key2 val2); redisAppendCommand(c, GET key1); // 再依次读取回复 redisReply *r1, *r2, *r3; redisGetReply(c, (void**)r1); redisGetReply(c, (void**)r2); redisGetReply(c, (void**)r3); printf(r1: %s\n, r1-str); printf(r2: %s\n, r2-str); printf(r3: %s\n, r3-str); freeReplyObject(r1); freeReplyObject(r2); freeReplyObject(r3);注意redisGetReply的第二个参数是void**需要强制转换。如果某条命令的回复是数组或嵌套结构同样要按type逐层解析。管道模式不会自动处理错误如果中间某条命令失败后续的redisGetReply可能返回 NULL此时要检查c-err并重建连接。4. 异步接口与事件循环ae 与 libevent 的取舍4.1 异步连接的回调注册顺序hiredis 的异步 API 以redisAsyncConnect为入口返回redisAsyncContext。跟同步版本不同异步连接不会阻塞连接结果通过回调通知。这里有一个血泪经验redisAsyncSetConnectCallback和redisAsyncSetDisconnectCallback必须在事件循环启动之前注册否则连接建立或断开时回调不会被触发。另外异步 context 的生命周期由事件循环管理不能手动redisAsyncFree否则会跟事件循环里的引用计数冲突。#include hiredis/hiredis.h #include hiredis/async.h #include hiredis/adapters/libevent.h #include event2/event.h void onConnect(const redisAsyncContext *c, int status) { if (status ! REDIS_OK) { printf(异步连接失败: %s\n, c-errstr); return; } printf(异步连接成功\n); } void onDisconnect(const redisAsyncContext *c, int status) { if (status ! REDIS_OK) { printf(异常断开: %s\n, c-errstr); } else { printf(正常断开\n); } } void onSetReply(redisAsyncContext *c, void *reply, void *privdata) { redisReply *r reply; if (r NULL) { printf(SET 命令无回复\n); return; } printf(SET 回复: %s\n, r-str); } int main(void) { struct event_base *base event_base_new(); redisAsyncContext *ac redisAsyncConnect(127.0.0.1, 6379); if (ac NULL || ac-err) { printf(异步连接创建失败\n); return 1; } // 绑定事件循环必须在连接回调注册之前或同时完成 redisLibeventAttach(ac, base); // 注册连接与断开回调 redisAsyncSetConnectCallback(ac, onConnect); redisAsyncSetDisconnectCallback(ac, onDisconnect); // 发起异步命令 redisAsyncCommand(ac, onSetReply, NULL, SET async_key async_val); // 启动事件循环 event_base_dispatch(base); event_base_free(base); return 0; }这段代码里redisLibeventAttach把 hiredis 的异步 context 挂到 libevent 的event_base上之后所有 socket 读写都由 libevent 驱动。redisAsyncCommand的第二个参数是回调函数第三个参数是私有数据会原样传给回调。回调函数的签名固定为void (*)(redisAsyncContext*, void*, void*)其中第二个参数实际是redisReply*需要自己转换。注意异步回调里的redisReply不需要手动freeReplyObjecthiredis 会在回调返回后自动释放。4.2 ae 适配器与 libevent 适配器的差异hiredis 自带一个极简的事件循环ae也提供了 libevent、libev、glib 的适配器。选哪个取决于你项目里已经用了什么。如果项目本身就在用 libevent直接redisLibeventAttach最省事如果项目没有任何事件循环用自带的ae可以少引一个依赖。但ae的功能比较基础不支持定时器嵌套和信号处理复杂场景下还是 libevent 更稳。适配器头文件适用场景注意事项aeadapters/ae.h无现有事件循环简单异步不支持多线程事件循环libeventadapters/libevent.h已有 libevent 的项目需链接 -leventlibevadapters/libev.h已有 libev 的项目需链接 -levglibadapters/glib.h已有 GLib 主循环需链接 -lglib-2.0选择适配器时还要注意线程安全。hiredis 的异步 context 不是线程安全的如果多个线程要共用一个连接必须加锁或者每个线程独立连接。常见做法是每个工作线程维护自己的redisAsyncContext通过线程本地存储隔离。5. 避坑与排查五个真实踩过的坑5.1 连接池缺失导致的 fd 耗尽现象服务运行一段时间后redisConnect开始返回Connection refused或Cannot assign requested addressnetstat看到大量TIME_WAIT状态的连接。原因hiredis 本身不提供连接池每次redisConnect都新建一个 TCP 连接用完redisFree关闭。在高频短连接场景下端口会被快速耗尽内核的TIME_WAIT回收速度跟不上。解决在应用层自己维护一个连接池或者复用同一个redisContext做多次命令。如果必须短连接调整内核参数net.ipv4.tcp_tw_reuse和net.ipv4.ip_local_port_range但这只是缓解根本方案还是连接复用。5.2 异步回调里阻塞导致事件循环卡死现象异步命令的回调函数里执行了一个耗时操作比如写日志文件或查数据库之后所有 Redis 命令的回复都延迟很高甚至超时。原因hiredis 的异步回调是在事件循环线程里同步执行的回调不返回事件循环就无法处理下一个 socket 事件。整个连接上的所有命令都会被阻塞。解决回调里只做数据拷贝和状态标记把耗时逻辑丢到工作线程或任务队列里。如果必须同步处理改用同步 API 并放在独立线程里。5.3 二进制数据被截断现象用redisCommand(c, SET %s %b, key, bin_data, bin_len)写入二进制数据读出来发现长度不对或者内容被截断。原因%s格式符遇到\0就停止而二进制数据里经常包含\0。必须用%b并显式传入长度。解决写入时用%b读取时不要用strlen(reply-str)判断长度而要用reply-len。redisReply的len字段记录了字符串的实际字节数对二进制安全。5.4 超时设置对同步命令无效现象设置了redisConnectWithTimeout的超时但某条redisCommand还是卡了很久。原因redisConnectWithTimeout只控制connect()阶段的超时不控制命令读写阶段的超时。如果 Redis 服务端处理慢或者网络中断redisCommand里的read()会一直阻塞。解决同步 API 没有内置的命令级超时需要用setsockopt设置SO_RCVTIMEO和SO_SNDTIMEO或者改用异步 API 配合定时器。常见做法是封装一层带超时的redisCommand用poll或select监听 fd 可读后再读。5.5 版本不匹配导致的符号冲突现象编译通过运行时崩溃gdb显示redisConnect内部调用了错误的地址或者redisFree释放了非法内存。原因系统里存在多个版本的 hiredis链接时找到了旧版本的.so但头文件用的是新版本的。结构体布局不一致导致内存访问越界。解决用ldd检查可执行文件实际链接的libhiredis.so路径确保跟编译时用的头文件版本一致。在构建系统里显式指定-L和-Wl,-rpath把运行时库搜索路径锁死。6. 进阶技巧用 redisCommandArgv 做二进制安全封装redisCommand的格式化字符串虽然方便但在处理二进制 key 或 value 时容易踩坑。hiredis 提供了redisCommandArgv直接传参数数组和长度数组完全绕过格式化解析是二进制安全场景下的首选。我一般会封装一个redis_command_bin函数把 key 和 value 的指针与长度分开传内部拼成argv数组后调用redisCommandArgv。redisReply *redis_command_bin(redisContext *c, const char *cmd, const char *key, size_t key_len, const char *val, size_t val_len) { const char *argv[3]; size_t argvlen[3]; argv[0] cmd; argvlen[0] strlen(cmd); argv[1] key; argvlen[1] key_len; argv[2] val; argvlen[2] val_len; return (redisReply*)redisCommandArgv(c, 3, argv, argvlen); }这个封装里argv和argvlen是一一对应的redisCommandArgv的第二个参数是参数个数。注意argv里的指针在函数返回前必须保持有效因为 hiredis 会立即把它们编码进输出缓冲区不会持有引用。如果 key 或 val 是栈上的临时缓冲区调用完就可以释放。返回值同样是redisReply*用完记得freeReplyObject。验证这个封装是否二进制安全可以写一个测试构造一个包含\0的 value写入后用GET读出来比较reply-len和原始长度是否一致。如果一致说明整条链路没有截断。我习惯在每次升级 hiredis 版本后都跑一遍这个测试因为不同版本对%b和redisCommandArgv的处理细节可能有微调。还有一个容易忽略的点redisCommandArgv在管道模式下也有对应的redisAppendCommandArgv用法类似只是不立即读取回复。如果你在做批量二进制写入用redisAppendCommandArgv配合redisGetReply能兼顾吞吐和二进制安全。从那以后我每次在项目里引入 hiredis都会先跑一遍二进制读写测试和超时测试确认版本和链接路径没问题再往下写业务逻辑。希望帮到你。本文还有配套的精品资源点击获取