十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

hiredis 0.13.1实战:C语言Redis客户端核心API与异步性能优化

hiredis 0.13.1实战:C语言Redis客户端核心API与异步性能优化 简介hiredis 0.13.1 是一份用 C 语言编写的轻量级 Redis 客户端库源码面向 C/C 服务端开发者、嵌入式应用以及需要高性能 Redis 交互的场景旨在简化命令发送、响应解析与连接管理并降低网络通信延迟。资源包以 .tar.gz 形式发布共 32 个文件其中以 .h 头文件和 .c 源文件为主体覆盖 hiredis 核心实现与多平台适配另附 Makefile、README/CHANGELOG 文档、CI 配置及许可证说明整体体积仅 54KB非常适合快速阅读、二次开发和集成部署。阅读源码不仅能看清 hiredis 对 RESP 协议解析、异步连接、缓冲区管理等核心机制的实现细节还能通过适配器目录下的 libevent、libev、libuv 等事件库接入示例与多个 example 程序快速掌握如何将客户端集成到不同事件循环或基于现有代码改造出适合自身业务的定制版本。目前该资源已有 228 人学习适合希望理解 Redis 客户端底层原理或准备在 C/C 项目中引入高性能 Redis 访问能力的开发者收藏参考。1. hiredis 是什么为什么 C 项目绕不开它1.1 一个没有依赖的 C 语言客户端内核我第一次在项目里正式引入 hiredis是在用 C 写一个网关服务的时候。当时业务上需要把热数据放到 Redis 里而手里的服务是纯 C 写的性能敏感不能随便套一层 HTTP更不可能为了一个缓存功能引入 Java 或 Go 的重型客户端。对比过几个方案之后我还是回到了 hiredis 上。原因很直接它没有任何外部依赖编译出来就是一套静态库或动态库直接链进去就能跑而且它是 Redis 官方配套维护的 C 语言客户端源码在 GitHub 上公开可读出了问题你可以自己顺着协议层往下翻。hiredis 这个项目的定位非常克制它只解决一件事让 C 程序能够快速、稳定地和 Redis 服务端通信。它不像后来的很多客户端那样提供完整的上层抽象没有对象映射、没有连接池、没有自动重连策略。它提供给你的是一套非常朴素的 API——建立连接、发送命令、读取回复以及一个基于事件循环的异步模式。这套设计的价值在于它把协议解析和业务逻辑彻底解耦你在上层想怎么包装完全由你自己决定。就 0.13.1 这个版本而言它是 hiredis 早期比较稳定的版本。API 和后来的 0.14、1.x 系列保持了大体兼容核心的数据结构和函数签名几乎没变。在这个版本里你能获得的核心能力包括同步命令接口、异步命令接口、命令格式化函数、以及基于 Redis 序列化协议RESP的解析器。没有的东西包括SSL/TLS 加密连接、Unix Socket 的高级配置、集群支持、哨兵支持。这些功能是后续版本逐步补上的所以如果你的老项目锁定在 0.13.1意味着你要自己处理加密和故障转移的问题。不过在多数内网业务场景里没有 SSL 其实不影响什么这也是老项目愿意长期停留在老版本的原因之一。1.2 版本号 0.13.1 背后的现实意义很多人会问都什么年代了为什么还要盯着一本 0.13.1 不放这里说一个实际情况有一类项目尤其是长期运行的嵌入式系统、网关设备、车机服务它们的代码仓库一旦稳定依赖版本就会锁得很死。我在一个智能网关项目里就见过 hiredis 长期停留在 0.13.1 的情况不是团队不想升级而是升级所影响的周边模块太多了从事件循环库的适配到编译参数全部要回归测试。所以这篇文章本质上是在记录把 0.13.1 这套 API 吃透之后如何在生产环境里稳定地使用它。另外一点hiredis 的 API 兼容性做得相当好。你在 0.13.1 里学会的东西换到新版 hiredis 基本可以平滑迁移。比如redisConnect、redisCommand、redisReply这些核心概念从 0.13.1 到最新版本都没有变过。所以就算你不是在维护老项目而是从一个新项目开始这篇文章里的经验依然适用。2. 编译与第一个连接把代码跑起来的全过程2.1 从 make 到链接最容易卡住的三个位置在 Linux 上拿到 hiredis-0.13.1 的源码后第一步就是编译。它用的是传统的 Makefile不依赖 CMake。你只需要在源码根目录执行make make install默认情况下这个操作会同时产出静态库libhiredis.a和动态库libhiredis.so并把头文件、库文件安装到系统路径。这里立刻会踩到第一个坑0.13.1 的 Makefile 里默认安装路径是/usr/local如果系统库搜索路径里没有/usr/local/lib链接时报错就是找不到libhiredis.so。解决方式很简单手动指定编译参数gcc -o my_redis_test my_redis_test.c -I/usr/local/include -L/usr/local/lib -lhiredis或者更简单直接在当前目录引用make之后生成的文件连make install都可以跳过make gcc -o my_redis_test my_redis_test.c -I./ -L./ -lhiredis第二个容易卡人的位置是交叉编译。如果你是要把程序编译到 ARM 开发板或者 MIPS 设备上直接执行make产出的库是用本机编译参数生成的。你需要修改 Makefile 里的编译器变量通常需要手动指定CC和AR。我实际遇到过一个问题在 x86 上编译一切正常换到 ARM 板上程序一运行就段错误后来发现是编译器优化参数里带了不兼容的特性。解决方式是调整CFLAGS例如make CFLAGS-O2 -g -Wall -fPIC CCarm-linux-gnueabihf-gcc ARarm-linux-gnueabihf-ar第三个高频问题是make install的权限。如果你当前用户对/usr/local没有写权限需要sudo make install或者干脆放弃全局安装直接在项目里引用源码目录相对路径。后面的做法我自己更推荐因为这样依赖关系非常明确换环境部署时不需要额外安装系统库。2.2 最小可用连接代码里的错误处理逻辑编译走通了下一步就是写第一段代码。hiredis 的同步客户端模型非常直观先建立一个redisContext然后通过这个上下文发送命令最后根据返回的redisReply处理结果。一个最小的 PING 连接代码如下#include stdio.h #include hiredis/hiredis.h int main() { redisContext *c redisConnect(127.0.0.1, 6379); if (c NULL || c-err) { if (c) { printf(连接失败: %s\n, c-errstr); redisFree(c); } else { printf(无法分配 redisContext\n); } return -1; } redisReply *reply redisCommand(c, PING); if (reply NULL) { printf(命令执行失败\n); redisFree(c); return -1; } printf(PING 返回类型%d, 内容%s\n, reply-type, reply-str); freeReplyObject(reply); redisFree(c); return 0; }这段代码里最容易被忽略的是两点。第一redisConnect返回的redisContext指针可能是 NULL也可能是非空但err字段被置为异常状态的对象两种情况必须分开判断。如果只检查指针是否为空网络不通时就会出现空指针解引用。第二redisCommand返回的reply无论看起来多简单底层都是一块堆内存必须用freeReplyObject释放否则就是内存泄漏。这个习惯要从第一段代码开始养成。如果你需要在建立连接时设置超时建议用redisConnectWithTimeout。它的第二个参数接收一个struct timeval可以控制 TCP 连接阶段的等待时间。默认情况下redisConnect是阻塞等待操作系统完成连接如果 Redis 服务端所在主机网络不通程序可能会卡在连接调用上很久。设置超时的代码struct timeval tv; tv.tv_sec 3; tv.tv_usec 0; redisContext *c redisConnectWithTimeout(192.168.1.100, 6379, tv);这里要明确一点这个超时只作用于连接建立阶段不作用于命令执行阶段。也就是说连接建立成功后如果你发送一条BLPOP或SUBSCRIBE这类长期阻塞的命令redisCommand依然会一直等待直到 Redis 服务端返回数据。想控制命令超时需要走非阻塞连接加轮询或者直接用异步接口。3. 同步接口的底层逻辑请求、响应与内存池设计3.1 redisContext 与 redisReply 到底在内存里做了什么hiredis 的同步接口虽然简单但它的结构体设计很值得琢磨。先看redisContext它不仅仅是一个 socket 的包装内部还包含了一个输出缓冲区obuf和一个解析器redisReader。你在调用redisCommand时命令会先被格式化成 RESP 协议格式写入obuf再通过 socket 发送到 Redis 服务端。响应数据到达后由redisReader逐步解析最终生成redisReply。这个设计意味着hiredis 不是每次命令都新建一切而是复用了同一个上下文内的缓冲区和解析器状态。如果你连续执行多条命令使用同一个redisContext在底层能省掉很多反复分配、释放资源的时间。这一点和很多用惯了 HTTP 客户端的人直觉不同但在 Redis 这种基于文本协议的高吞吐场景里正是这种朴素的复用带来了非常稳定的性能表现。redisReply的结构则是一块多态的回复容器。它的type字段决定了你应该读取哪个字段type 值宏名称说明1REDIS_REPLY_STRING字符串数据在str和len2REDIS_REPLY_ARRAY数组数据在element和elements3REDIS_REPLY_INTEGER整数数据在integer4REDIS_REPLY_NIL空值无数据5REDIS_REPLY_STATUS状态回复数据在str常见于 PING/SET6REDIS_REPLY_ERROR错误回复数据在str我把类型值和宏名称对照列出来是因为实际调试时直接看reply-type的数字比猜宏名称更实用。你经常会遇到的情况是命令执行成功了但返回类型和你想的不一样。比如SET返回的是REDIS_REPLY_STATUS不是字符串EXISTS返回的是REDIS_REPLY_INTEGER。如果你只笼统地处理字符串和数组后续的取值逻辑就会出错。3.2 命令构造的两种方式与场景选择hiredis 同步接口中命令拼接有两种方式。一种是直接格式化拼接redisReply *reply redisCommand(c, SET %s %s, key, value);另一种是参数数组方式const char *argv[3]; size_t argvlen[3]; argv[0] SET; argv[1] key; argv[2] value; argvlen[0] 3; argvlen[1] strlen(key); argvlen[2] strlen(value); redisReply *reply redisCommandArgv(c, 3, argv, argvlen);两种方式都行但有一个重要区别redisCommandArgv在构造命令之前会对每个参数做长度标记避免传入的字符串里包含空格、换行等特殊字符破坏协议格式。在处理用户输入相关的业务中这条是安全底线。即使是redisCommand的%s格式化如果传入字符串包含二进制内容也可能会破坏 RESP 协议结构。所以凡是涉及不可控字符串的键值对优先用redisCommandArgv。除了单条命令0.13.1 还提供了redisAppendCommand和redisGetReply组合用于管道pipeline操作。基本用法是先追加多条命令再统一读取所有回复redisAppendCommand(c, SET key1 val1); redisAppendCommand(c, GET key1); redisReply *reply1 NULL; redisReply *reply2 NULL; redisGetReply(c, (void**)reply1); redisGetReply(c, (void**)reply2); freeReplyObject(reply1); freeReplyObject(reply2);这种模式最大的价值是减少网络往返。在同一个连接上一次系统调用发送多条命令再一次性读取所有响应在延迟敏感的业务中能带来几倍甚至一个数量级的性能提升。我的经验是如果你的业务中存在连续发送多条相互独立命令的场景不要一条条调用redisCommand改成redisAppendCommand批量发送收益非常明显。3.3 回复类型与嵌套数组的坑回复类型的处理是 hiredis 使用中最高频的 bug 来源。最典型的例子是解析MGET结果时看到type REDIS_REPLY_ARRAY就以为万事大吉但实际上数组里的每个元素都可能是REDIS_REPLY_NIL。Redis 的集合类操作中个别键不存在是很常见的情况如果你在遍历element时直接访问element[i]-str马上就是段错误。正确做法是每次遍历element[i]时都要再次检查它内部的type是否为REDIS_REPLY_STRING或其他你期望的类型再决定是否读取str。类似的对于HGETALL这类哈希操作返回的数组是键值对交替出现的也需要先检查元素类型再读取。这个习惯一旦养成生产环境崩溃日志里的段错误会显著减少。另外要注意freeReplyObject的递归释放行为当你释放一个数组类型的reply时它会自动把element[0]、element[1]等所有子元素一并释放。这带来了一个隐患——如果某段代码把element[i]单独保存下来父回复释放后这个指针就成了野指针。在多人协作的项目里尤其要约定清楚回复对象的所有权边界避免有人把子元素拷贝出去长期持有。4. 异步接口与事件循环集成从回调地狱到顺手流程4.1 异步上下文与回调机制的本质只要遇到需要维持大量长连接或者命令发送不能阻塞主线程的场景同步接口就不够用了。hiredis 的异步接口核心是redisAsyncContext。它的内部虽然包含了一个redisContext c作为基础但异步上下文自带一套事件回调机制完全脱离同步接口的发命令-等回复模型。建立异步连接的代码redisAsyncContext *ac redisAsyncConnect(127.0.0.1, 6379); if (ac-err) { printf(异步连接失败: %s\n, ac-errstr); return -1; }注意redisAsyncConnect返回之后TCP 握手不一定已经完成。真正可以发命令的时机是后续事件循环回调注册之后。hiredis 采用观察者模式你需要通过redisAsyncSetConnectCallback设置连接回调int connectCallback(const redisAsyncContext *c, int status) { if (status ! REDIS_OK) { printf(连接回调失败\n); return REDIS_ERR; } redisAsyncCommand(c, commandCallback, NULL, SET %s %s, hello, world); return REDIS_OK; }在连接回调里发起第一条命令是保证连接就绪后才发请求的标准姿势。很多人图省事在redisAsyncConnect返回后立刻调用redisAsyncCommand在极少数网络异常的情况下命令会以一个未知的状态发出甚至直接被丢弃。在异步编程里时序的正确性比代码的简洁性重要得多。4.2 和 libevent 集成的完整模式异步连接建立之后hiredis 还需要一个外部事件循环来驱动读写。hiredis 本身不内嵌事件循环它只负责提供文件描述符和读写状态真正监听这些状态变化的动作要交给事件循环库。常见的组合是 libevent、libev或者 Redis 自带的 ae 事件库。用 libevent 集成的模式大致如下用redisAsyncConnect建立异步上下文。取ac-c.fd也就是底层 socket 的文件描述符。把这个 fd 注册到 libevent 的 event_base 中。在 fd 可读时调用redisAsyncHandleRead可写时调用redisAsyncHandleWrite。根据后续调用的返回状态动态调整事件注册。实际代码里最常见的错误是事件注册遗漏。比如只注册了读事件没注册写事件结果命令发不出去程序看起来像是在等待结果其实是写入事件根本没被处理。我的经验是在连接建立后先注册写事件完成首次写操作再根据 hiredis 内部状态维护读写事件的启停。另外很多人会在事件回调中调用redisAsyncHandleRead或redisAsyncHandleWrite但不检查返回值。如果一个调用返回REDIS_ERR说明连接已经出错应该立即走清理流程释放资源而不是继续等待。在 0.13.1 这个版本里用户还容易忽略一点redisAsyncHandleRead和redisAsyncHandleWrite不是线程安全的。如果你的事件循环跑在 A 线程业务线程又试图通过redisAsyncCommand往同一个异步上下文里塞命令就会发生数据竞争。解决方案是让所有对 hirdis 异步上下文的访问都集中在事件循环线程内业务线程通过队列把命令递交给事件循环线程执行。4.3 资源释放与生命周期管理异步接口的资源释放是个容易踩坑的地方。redisAsyncFree和同步的redisFree不一样它不会立即释放内存而是在事件循环的下一轮中执行实际清理。这个特性带来的后果是在一个回调函数里调用redisAsyncFree随后事件循环可能再次触发访问到已经释放的内存。0.13.1 里一个常见问题场景在连接断开回调中用redisAsyncFree(ac)直接释放上下文后续事件循环再次触发读写回调时发生在已释放对象上的函数调用行为完全不可预测。更稳妥的做法是在断开回调里设置一个标志位把释放动作推迟到事件循环主循环之外。比如维护一个待释放上下文队列在每次事件循环循环的末尾统一处理待释放列表。我自己的实现习惯是给异步上下文套一层业务包装包含上下文指针、释放标志、业务回调函数指针。连接断开时只标记不再使用不立即释放等到事件循环确认没有更多事件要处理时统一清理这层包装和底层上下文。虽然多写了些代码但彻底规避了 use-after-free 这类异步编程中最难缠的问题。5. 真实业务中的性能调优与避坑记录5.1 多线程环境下的使用边界很多 C 项目里用 hiredis都会遇到同一个问题多个线程能不能共用一个redisContext答案很明确——不能。hiredis 的上下文对象没有内置锁内部的obuf缓冲区和redisReader状态都不是线程安全的。如果在多个线程里同时对同一个上下文调用redisCommand轻则命令顺序错乱重则内存损坏、进程崩溃。多线程场景下合理的做法是每个线程维护独立的redisContext或redisAsyncContext或者用一个连接池管理。自己实现连接池也不复杂用互斥锁保护空闲连接列表线程需要连接时取出一个空闲上下文用完后再归还。关键约束是同一时刻一个上下文只能被一个线程使用。如果你用异步上下文则更严格一些——连归还给池子这个动作都应该发生在事件循环线程里。5.2 内存抖动、批处理与本地缓存优化redisReply对象每次都会在堆上分配。在高并发场景下频繁分配和释放会造成不小的内存抖动也会拖慢整体性能。有几个明确的优化方向减少单条命令的数量。能合并的请求尽量合并用MGET、MSET替代循环GET、SET或者用管道批量发送。对于大量只读的键值在客户端增加本地缓存。尤其是在网关、代理类服务中本地缓存命中率一上来Redis 的压力和网络开销都会明显下降。注意维护缓存与 Redis 的一致性通常配合过期时间或者发布订阅失效通知。警惕大 key 返回值。如果某个 key 存了很大的字符串或者很大的哈希表redisReply会一次性把整块数据加载到内存里。业务侧要提前做截断或者分页读取。对于 0.13.1 的freeReplyObject它支持递归释放数组内嵌元素释放一个数组回复时只需要调用一次。这是个不错的默认行为但也意味着每个element[i]的生命周期完全隶属于父回复。如果你把element[i]拷贝出来单独保存父回复释放后它就不可用了这一点在协作开发的代码评审里需要重点关注。5.3 0.13.1 时代的典型故障与排查思路最后整理几个我在 0.13.1 使用过程中遇到的、有代表性的故障场景供参考连接超时redisConnectWithTimeout只影响连接建立阶段不影响命令响应阶段。如果服务端响应很慢同步接口会一直阻塞。最简单的策略是用非阻塞连接加select/poll轮询或者直接用异步接口。大批量写入内存飙升往 Redis 里写大量 key 时如果每条都循环调用redisCommand每个 reply 都生成并堆积内存容易涨。用管道或批量命令能显著降低内存峰值。命令能跑但解析异常如果你发现命令在 redis-cli 里能跑在 hiredis 里却解析异常优先检查reply-type是否和你假设的一致。老版本里对某些命令的 RESP 协议处理可能不完整比如包含CLIENT、INFO、MODULE等特殊命令时要先确认返回结构。代码升级后链接的版本不对动态库方式链接时系统里可能同时存在多个 hiredis 版本运行时用的是哪个要检查清楚。可以用ldd查看程序实际加载的库路径。如果出现编译用了新版头文件运行加载老版本 so的情况后果非常隐蔽。话说回来hiredis 0.13.1 虽然是个老版本但它的基础设计和使用模式直到今天仍然是 C 语言访问 Redis 的默认模板。后来出现的各种 C 语言 Redis 客户端在易用性和性能上很少能把这套东西完全推翻重来。如果你正在维护老项目或者在评估是否要使用 hiredis不要被版本号吓到把上下文、回复对象、异步回调这三件事理清楚大部分实际问题都能迎刃而解。最后说一个我个人的使用习惯我在新项目里如果不想绑定太多复杂依赖依然会直接拉一份 hiredis 源码放进项目然后只编译静态库把编译参数固定好这样整个服务就只有一个二进制文件部署成本极低。这个做法从 0.13.1 时代一直用到现在hireids 的 API 始终没有让我失望过。本文还有配套的精品资源点击获取
返回列表