hiredis-0.13.1 实战:C 客户端编译、连接池与异步接口避坑指南
简介hiredis-0.13.1 是一份面向 C 语言后端开发者与 Redis 应用工程师的轻量级客户端库源码包适合需要在高并发场景下与 Redis 高效通信、又希望深入理解协议解析与网络 I/O 实现的技术人员研读。压缩包共 32 个文件约 54KB以 13 个 .h 头文件与 13 个 .c 源文件为主体涵盖 hiredis 核心接口、sds 动态字符串、dict 字典、net 网络读写及异步适配层另含 Makefile 构建脚本、README 与 CHANGELOG 说明文档、COPYING 许可协议及 .travis.yml 持续集成配置并附带 libev、libevent、glib、libuv 等多套事件循环的示例代码。已有 228 人学习关注。通过阅读源码读者可掌握 Redis 协议的解析流程、连接与命令收发机制、错误处理与多线程安全设计思路并借助示例快速将 hiredis 集成到自有服务中为排查性能瓶颈与二次开发提供直接参考。1. hiredis-0.13.1一个老版本 C 客户端为什么还在被翻出来用如果你在维护一套跑了七八年的 C/C 服务某天老板让你接一个 Redis 做缓存你翻遍内网镜像只找到 hiredis-0.13.1 这个包别急着骂人。这个版本发布于 Redis 还在 3.x 的年代API 简单、依赖极少、编译一把过很多嵌入式网关和老交易系统至今锁死在这个版本上。它解决的核心问题就一个让 C 程序用同步或异步方式跟 Redis 说话把 RESP 协议、连接管理、超时重连这些脏活封装掉。适合谁适合手头有存量 C 工程、不想引入 C 或 Go 运行时、又必须跟 Redis 打交道的后端和嵌入式开发者。这篇笔记不讲虚的从编译参数到连接池到异步回调的坑按我实际踩过的顺序捋一遍。2. 先把 hiredis-0.13.1 编出来Makefile 参数与静态库取舍2.1 为什么这个版本值得单独说编译hiredis 的构建系统在 0.13 这个阶段还是纯手写 Makefile没有 CMake也没有 meson。好处是依赖干净坏处是默认行为跟新版本不一样。默认make出来的是静态库libhiredis.a动态库要显式加USE_SHARED1。很多人在自己工程里链接报一堆 undefined reference八成就是拿了静态库却没把-lhiredis放在源文件后面或者漏了-lpthread。先看标准编译流程# 解压后进入源码目录 tar -xzf hiredis-0.13.1.tar.gz cd hiredis-0.13.1 # 默认编译静态库产物在源码根目录 make # 如果要动态库显式指定 make USE_SHARED1 # 安装到自定义前缀避免污染系统目录 make PREFIX/opt/hiredis-0.13.1 install逻辑说明make不带参数时Makefile 里USE_SHARED默认为空只生成libhiredis.a。PREFIX控制install目标把hiredis.h、read.h、sds.h和库文件拷到哪。参数说明USE_SHARED1会额外生成libhiredis.so.0.13并建立libhiredis.so软链PREFIX默认是/usr/local生产环境我一般指定到独立目录方便回滚。2.2 静态库还是动态库一个链接顺序的血泪经验选静态库的理由部署机器不需要额外装 so二进制拷过去就能跑适合容器镜像做小。选动态库的理由多个进程共享一份代码段内存省升级库不用重编业务。我一般这么定如果是单体服务且镜像大小不敏感用静态如果是同一台机器跑十几个小进程用动态。链接时的顺序问题值得单独拎出来。GCC 的链接器从左到右解析符号静态库必须放在引用它的目标文件后面# 正确源文件在前静态库在后 gcc -o demo demo.c -I/opt/hiredis-0.13.1/include \ -L/opt/hiredis-0.13.1/lib -lhiredis -lpthread # 错误库在前源文件在后会报 undefined reference to redisConnect gcc -o demo -lhiredis demo.c逻辑说明-I指定头文件搜索路径-L指定库搜索路径-lhiredis告诉链接器找libhiredis.a或libhiredis.so。-lpthread不能省hiredis 内部用 pthread 做超时控制。参数说明如果你的工程用-Wl,--as-needed动态库链接时也要注意顺序否则可能被优化掉。提示交叉编译时记得在make里传CCarm-linux-gnueabihf-gcc和ARCH相关变量0.13.1 的 Makefile 对交叉编译支持比较原始必要时手动改CFLAGS。3. 同步接口跑通最小闭环redisConnect 到 redisFree 的完整链路3.1 一个能抄的同步读写示例同步接口是 hiredis 最常用的部分核心就四个函数redisConnect、redisCommand、redisReply解析、redisFree。下面这段代码我用了很多次直接改改就能进项目#include stdio.h #include stdlib.h #include string.h #include hiredis/hiredis.h int main(void) { // 1. 建立连接带超时参数秒 微秒 struct timeval timeout { 2, 500000 }; // 2.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(分配上下文失败\n); } return 1; } // 2. 执行 SET 命令 redisReply *reply (redisReply *)redisCommand(c, SET foo bar); if (reply NULL) { printf(命令执行失败连接可能已断\n); redisFree(c); return 1; } printf(SET 返回类型: %d\n, reply-type); // 期望 REDIS_REPLY_STATUS freeReplyObject(reply); // 3. 执行 GET 并读取字符串 reply (redisReply *)redisCommand(c, GET foo); if (reply-type REDIS_REPLY_STRING) { printf(GET foo %s\n, reply-str); } freeReplyObject(reply); // 4. 释放连接 redisFree(c); return 0; }逻辑说明redisConnectWithTimeout比redisConnect多一个超时控制避免网络抖动时卡死。redisCommand是可变参数函数内部会格式化命令并发送返回的redisReply必须用freeReplyObject释放否则内存泄漏。参数说明timeout结构体第一个字段是秒第二个是微秒{2, 500000}表示 2.5 秒reply-type常见值有REDIS_REPLY_STRING、REDIS_REPLY_STATUS、REDIS_REPLY_INTEGER、REDIS_REPLY_ARRAY、REDIS_REPLY_NIL、REDIS_REPLY_ERROR。3.2 返回值类型判断别拿 status 当 string 用新手最容易翻车的地方是SET返回的是REDIS_REPLY_STATUSreply-str里是OK但如果你直接当 string 处理逻辑上没错可一旦 Redis 返回错误type会变成REDIS_REPLY_ERROR这时候reply-str是错误信息不是数据。我见过有人在生产代码里只判断reply ! NULL就取reply-str结果 Redis 认证失败时把错误信息当缓存值写进了数据库。正确的做法是每个命令都判断typereply (redisReply *)redisCommand(c, GET %s, key); if (reply NULL) { // 连接层错误需要重连 reconnect(c); } else if (reply-type REDIS_REPLY_ERROR) { // 命令层错误比如 WRONGTYPE printf(Redis 错误: %s\n, reply-str); } else if (reply-type REDIS_REPLY_NIL) { // key 不存在 } else if (reply-type REDIS_REPLY_STRING) { // 正常取值 } freeReplyObject(reply);逻辑说明reply NULL表示连接已断redisCommand内部发送或接收失败此时c-err会被置位需要redisFree后重连。REDIS_REPLY_ERROR是 Redis 服务端返回的错误连接本身还在。参数说明redisCommand支持 printf 风格格式化但注意%s传入的字符串如果含空格或特殊字符hiredis 不会自动转义需要自己保证 key/value 安全或者改用二进制安全的redisCommandArgv。注意0.13.1 的redisCommand在格式化%b时行为跟新版本有差异二进制数据建议直接用redisCommandArgv避免踩到长度截断的坑。4. 异步接口与连接池把吞吐从几百拉到几万的关键4.1 异步回调模型redisAsyncConnect 的正确打开方式同步接口一个连接一次只能跑一个命令QPS 上到几千就顶天了。异步接口基于事件循环一个连接可以同时发多个命令回调按回复到达顺序触发。hiredis 的异步部分依赖ae事件库0.13.1 自带一个简化版ae.c也可以接 libev、libevent。最小异步示例#include stdio.h #include hiredis/hiredis.h #include hiredis/async.h #include hiredis/adapters/ae.h static void getCallback(redisAsyncContext *c, void *r, void *privdata) { redisReply *reply (redisReply *)r; if (reply NULL) return; if (reply-type REDIS_REPLY_STRING) { printf(异步 GET 结果: %s\n, reply-str); } // 异步回调里不要 freeReplyObjecthiredis 会自动释放 } static void connectCallback(const redisAsyncContext *c, int status) { if (status ! REDIS_OK) { printf(异步连接失败: %s\n, c-errstr); return; } printf(异步连接成功\n); // 连接成功后发命令 redisAsyncCommand(c, getCallback, NULL, GET foo); } int main(void) { redisAsyncContext *c redisAsyncConnect(127.0.0.1, 6379); if (c-err) { printf(异步连接错误: %s\n, c-errstr); return 1; } // 绑定事件循环 aeEventLoop *loop aeCreateEventLoop(1024); redisAeAttach(loop, c); // 设置连接回调 redisAsyncSetConnectCallback(c, connectCallback); // 跑事件循环 aeMain(loop); aeDeleteEventLoop(loop); return 0; }逻辑说明redisAsyncConnect立即返回不阻塞真正的连接结果在connectCallback里通知。redisAeAttach把 Redis 的读写事件注册到ae事件循环。redisAsyncCommand发送命令并注册回调回调里拿到的reply由 hiredis 管理生命周期不能手动freeReplyObject。参数说明aeCreateEventLoop(1024)的参数是最大文件描述符数按实际连接数调整redisAsyncSetConnectCallback必须在aeMain之前调用否则可能错过连接事件。4.2 连接池自己写还是用现成的hiredis 本身不提供连接池0.13.1 更没有。生产环境常见做法是自己封装一个简单的池子或者用libevent的 bufferevent 做多连接管理。我一般会写一个固定大小的连接数组每个连接配一个互斥锁取用时加锁、用完归还。池子大小按QPS / 单连接吞吐估算单连接同步模式大概 5000 QPS异步模式能到 2 万以上。一个极简连接池结构typedef struct { redisContext **conns; // 连接数组 int *in_use; // 使用标记 int size; // 池大小 pthread_mutex_t lock; // 全局锁 } redis_pool_t; redisContext *pool_get(redis_pool_t *p) { pthread_mutex_lock(p-lock); for (int i 0; i p-size; i) { if (!p-in_use[i]) { p-in_use[i] 1; pthread_mutex_unlock(p-lock); // 检查连接是否还活着断了就重连 if (p-conns[i]-err) { redisFree(p-conns[i]); p-conns[i] redisConnect(127.0.0.1, 6379); } return p-conns[i]; } } pthread_mutex_unlock(p-lock); return NULL; // 池满调用方决定等待还是报错 } void pool_put(redis_pool_t *p, redisContext *c) { pthread_mutex_lock(p-lock); for (int i 0; i p-size; i) { if (p-conns[i] c) { p-in_use[i] 0; break; } } pthread_mutex_unlock(p-lock); }逻辑说明pool_get遍历找空闲连接标记占用后返回返回前检查c-err如果连接已断就重连避免把坏连接给业务。pool_put只做标记归还不关闭连接。参数说明size建议设为 CPU 核数的 2 到 4 倍太大反而增加 Redis 的连接负担锁的粒度可以优化成每个连接一把锁减少竞争。提示连接池里的连接如果长时间不用Redis 服务端可能主动断开timeout配置所以取用时必须检查err不能假设连接一直有效。5. 避坑与排查hiredis-0.13.1 上最容易翻车的 4 个点5.1 现象程序跑几小时后内存持续上涨原因redisCommand返回的redisReply没有freeReplyObject或者异步回调里手动释放了导致双重释放。0.13.1 的redisReply内部有嵌套数组只 free 外层不够。解决同步代码里每个redisCommand后面必须跟freeReplyObject包括错误分支。异步回调里绝对不要手动释放。用 valgrind 跑一遍看redisReply相关的分配是否全部释放。5.2 现象偶发Connection reset by peer重连后正常原因Redis 服务端timeout配置到期断开空闲连接或者网络中间设备清理了长连接。hiredis 不会自动重连c-err置位后所有命令都失败。解决在pool_get里检查err并重连或者在业务层捕获reply NULL后重建连接。更稳妥的做法是给连接加心跳定期发PING。5.3 现象异步回调里调用redisAsyncCommand导致崩溃原因在回调里直接操作redisAsyncContext可能重入事件循环0.13.1 对重入保护不完善。解决不要在回调里直接发命令把要发的命令塞进队列回到主循环后再发。或者用redisAsyncCommand的privdata传递状态避免在回调里做复杂逻辑。5.4 现象编译时报undefined reference to redisConnectWithTimeout原因链接了旧版本的libhiredis或者头文件和库文件版本不匹配。0.13.1 的头文件里redisConnectWithTimeout是存在的但如果系统里先搜到了/usr/lib下的老库就会链接错。解决用-L显式指定库路径并用ldd或nm确认链接的是哪个库。静态链接时检查ar t libhiredis.a里有没有对应的.o文件。6. 进阶技巧用 redisCommandArgv 处理二进制安全与批量管道6.1 二进制安全为什么 %s 不够用redisCommand的%s遇到\0会截断存图片、序列化后的 protobuf 就会丢数据。0.13.1 提供了redisCommandArgv直接传参数数组和长度完全二进制安全。// 存二进制数据 const char *key bin_key; const char *val \x00\x01\x02\x03; // 含 \0 size_t keylen strlen(key); size_t vallen 4; const char *argv[3]; size_t argvlen[3]; argv[0] SET; argvlen[0] 3; argv[1] key; argvlen[1] keylen; argv[2] val; argvlen[2] vallen; redisReply *reply (redisReply *)redisCommandArgv(c, 3, argv, argvlen); freeReplyObject(reply);逻辑说明redisCommandArgv的第二个参数是参数个数第三个是参数指针数组第四个是每个参数的长度数组。hiredis 按长度拼接 RESP 协议不会因为\0截断。参数说明argvlen必须准确传错会导致协议解析错位Redis 返回Protocol error。6.2 管道一次发多条命令减少 RTT管道pipeline是提升吞吐最直接的手段。同步模式下把多条命令写进缓冲区最后一次读回复。0.13.1 支持redisAppendCommand加redisGetReply的组合。// 管道写 3 条命令 redisAppendCommand(c, SET k1 v1); redisAppendCommand(c, SET k2 v2); redisAppendCommand(c, GET k1); // 依次读回复 redisReply *reply; for (int i 0; i 3; i) { if (redisGetReply(c, (void **)reply) REDIS_OK) { // 处理 reply freeReplyObject(reply); } else { // 连接错误 break; } }逻辑说明redisAppendCommand只把命令写入输出缓冲区不等待回复redisGetReply阻塞读取一个回复。三条命令一次网络往返RTT 从 3 次降到 1 次。参数说明管道里的命令数量不宜过多几千条以上会占用大量内存建议按 100 到 500 条一批。6.3 验证方法用 redis-benchmark 对比同步与管道改完代码别拍脑袋用redis-benchmark压一下# 同步模式基准 redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get -q # 管道模式-P 指定管道深度 redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get -P 16 -q逻辑说明-c是并发连接数-n是总请求数-P是管道深度。对比两次的requests per second管道模式通常能提升 3 到 5 倍。参数说明-q只输出 QPS 摘要方便脚本采集。我自己的习惯是任何 hiredis 相关的改动先在测试环境用redis-benchmark跑一轮再用 valgrind 跑一轮两个都过了才上生产。这个版本老归老把连接池、二进制安全、管道这三件事做扎实撑住几万 QPS 没问题。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

ThinkPHP6插件化实战:think-addons实现模块解耦与钩子扩展

ThinkPHP6插件化实战:think-addons实现模块解耦与钩子扩展

简介:think-addons 是一套专为 ThinkPHP6 打造的插件扩展包,面向需要将业务模块按插件化方式拆分、降低模块耦合、统一扩展入口的 PHP 开发者。其核心价值在于:安装后能在配置目录自动生成插件配置文件,也支持通过命令行指令手动创…

2026/10/17 0:40:04 阅读更多 →
SpringBoot+Freemarker模板生成Word:告别POI手拼报表合同

SpringBoot+Freemarker模板生成Word:告别POI手拼报表合同

简介:面向后端Java开发者的Spring Boot整合FreeMarker生成Word文档示例工程,解决在项目中按模板动态输出报告、合同等文档并将图片嵌入Word的需求。资源包共30个文件,涵盖Java源码与编译后的class文件、FreeMarker的ftl模板、XML配置、proper…

2026/10/17 0:40:04 阅读更多 →
JDK 1.8 下载安装全攻略:从选型到环境变量配置避坑指南

JDK 1.8 下载安装全攻略:从选型到环境变量配置避坑指南

简介:Java JDK 1.8完整开发工具包,面向需要搭建Java 8开发环境或学习该经典版本的开发者与初学者。资源内置标准JDK组件,包括编译器、运行虚拟机、核心类库以及常用命令行工具,能够满足企业级应用开发、课程实验、项目维护等多种场…

2026/10/17 0:40:04 阅读更多 →

最新新闻

Ray多机启动实战:大模型推理GPU集群调度与排障

Ray多机启动实战:大模型推理GPU集群调度与排障

最近在做一套多机大模型推理服务,模型权重分散在几台GPU机器上,头一回用Ray往多个节点拉起进程的时候,我犯了个很低级的错误:只在head节点执行了ray start --head,然后在另一台机器上敲了半天ray start --address...&a…

2026/10/17 3:24:49 阅读更多 →
Bootstrap 3 侧边导航栏生产级实现方案

Bootstrap 3 侧边导航栏生产级实现方案

简介:本资源是一份基于Bootstrap 3框架实现的响应式侧边导航栏完整示例,面向前端初学者与Web开发人员,解决在传统网页中快速构建结构清晰、适配多端(尤其移动端折叠交互)的垂直导航结构问题。压缩包共9个文件&#xff…

2026/10/17 3:24:49 阅读更多 →
OpenCV文档图像增强实战:从光照不均到高精度OCR预处理

OpenCV文档图像增强实战:从光照不均到高精度OCR预处理

1. 文档图像增强到底在解决什么问题做过文档扫描、拍照存档或者OCR识别的人,大概率都遇到过这样的场景:手机拍了一张纸质合同,光线不均匀,左边亮右边暗,中间还有一道阴影;或者用扫描仪扫出来的老文档&#…

2026/10/17 3:24:49 阅读更多 →
桌面端微信接入实战:用Claw中间件打通个人微信消息自动化

桌面端微信接入实战:用Claw中间件打通个人微信消息自动化

身边不少折腾过微信自动化的朋友,最后都卡在同一个地方:手机端的机器人方案要么要求系统环境特殊,要么长期挂着不稳定;云端方案又拿不到个人微信的完整会话数据,用起来总觉得隔了一层。我自己的需求其实很朴素——想把…

2026/10/17 3:24:49 阅读更多 →
TCP可靠传输与流量控制:滑动窗口、重传机制与RTO算法全解析

TCP可靠传输与流量控制:滑动窗口、重传机制与RTO算法全解析

很多同学学到“TCP 可靠传输与流量控制”这一篇时,第一反应往往是“又要背一堆机制和参数”。确实,这一篇是计网里知识点最密集的章节之一,但如果你只知道“超时重传”“滑动窗口”这六个字,考场上照样容易翻车。我带过不少学生&a…

2026/10/17 3:24:49 阅读更多 →
AVPlayer打造资讯App级列表视频流:UITableView自动播放与Cell复用实践

AVPlayer打造资讯App级列表视频流:UITableView自动播放与Cell复用实践

简介:面向苹果设备开发者的列表视频播放功能示例,使用Objective-C编写,模仿网易视频与今日头条的交互体验,能够解决列表滚动预览、点击全屏与自动暂停等常见需求。工程覆盖UITableView/UICollectionView上的视频滚动播放、AVFound…

2026/10/17 3:23:48 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →