
做 C 后端这几年Redislist 是我用得非常多的数据结构没有之一。任务队列要排空、滚动动态流要展示、批量分发要拆列表随便一个业务场景都能扯上它。但说实话很多同学用 C 操作 Redis List还停留在 hiredis 裸调用的阶段——代码能跑可一旦并发上来、连接变多、数据变复杂各种问题就全冒出来了。这篇文章我就从现代 C 的视角把 Redis List 从基础命令到生产级任务队列完整过一遍既聊库选型也聊封装思路适合那些想把 Redis List 用得更踏实的 C 服务端开发。1. 为什么 Redis List 在现代 C 服务里这么常见1.1 Redis List 的本质与特性Redis List 就是 Redis 内置的列表数据类型底层在传统实现上是一张双向链表新版 Redis 使用 quicklist 把多个压缩链表串起来。很多 C 开发者第一次看到 Redis List 时会下意识把它当成 C 里的std::list这个直觉方向是对的但千万别忘了两点Redis List 存储的元素是字符串而且每个字符串最长可以到 512MBRedis List 本身没有“值对象”概念所有值都是以字节序列形式落地的。因为底层是链表结构所以头尾两端的操作性能是 O(1)比如LPUSH、RPUSH、LPOP、RPOP。但中间位置的操作是 O(N)比如LINDEX取中间某个元素、LRANGE批量取区间随着列表变长耗时线性上升。这一点对 C 后端非常关键尤其是分页场景。很多人把 Redis List 当数据库里的“分页列表”用结果列表长了之后LRANGE越来越慢最后才发现问题出在数据结构选型上。Redis List 还有一个其他数据结构不具备的王牌能力阻塞式弹窗。BLPOP、BRPOP可以带超时时间等待新元素进入这让它天生适合做轻量消息队列。因为它不需要客户端轮询而是让消费线程睡在内核等待队列里有数据时立刻返回没数据时到超时再返回比空转轮询省太多资源。这也是 Redis List 在现代 C 服务里如此常见的核心原因。1.2 典型业务场景我先盘一下自己实际用过的几个场景你会更直观感受到 Redis List 的位置轻量消息队列生产者LPUSH消息消费者BRPOP阻塞消费天然支持多消费者竞争。滚动时间线/最新动态比如用户动态流每产生一条动态就LPUSH到用户的 List展示时LRANGE 0 19取最近 20 条。任务池把待处理任务 ID 直接LPUSH进 List多个 worker 并发LPOP完成后再删除。延迟重试队列消费失败的消息重新RPUSH到另一个 List由定时任务重新尝试。日志缓冲收集端先落地到 List再由消费端批量拉取刷到磁盘或数据库。其中消息队列是最典型的也是这篇文章后半部分会展开的场景。其他场景也类似核心操作无非是“推入、弹出、区间查询”这三板斧但真正写得优雅、能抗住生产压力的 C 代码并不多。1.3 与专业消息队列的取舍有人会问有 RabbitMQ、Kafka 这种专业消息队列为什么还要用 Redis List我的回答是看业务体量和对可靠性的要求。Redis List 的定位是“足够好、足够快、足够轻”。如果你只需要几千条每秒的吞吐消息偶尔丢失可以接受那维护一套 Kafka 反而成本过高。Redis List 没有复杂的消费组概念没有分区重平衡但胜在部署简单、延迟极低C 进程直接通过网络就能读写。不过你也得清醒一点Redis List 不是“至少一次语义”的保证者LPOP弹出后如果进程崩溃消息就丢了。BRPOPLPUSH也只能做到“把正在处理的消息放到备份队列”做不到 Kafka 那种持久化在海量消费者之间重平衡的能力。所以我的经验是小规模内部任务分发用 Redis List需要强事务、严格排序、跨团队契约时老老实实上专业 MQ。2. C 操作 Redis List 的客户端库选型2.1 hiredisC 语言底层库能做但费手hiredis 是 Redis 官方推荐的 C 语言客户端库几乎所有 C 库底层都是套它。它提供的redisCommand函数确实简单但返回值是redisReply指针你要手动检查type、手动freeReplyObject写着写着就很容易内存泄漏。举个典型场景你用LRANGE拉出一百条结果返回的是一个字符串数组你得逐个reply-element[i]-str拷贝出来再手动释放redisReply。如果中间加一个分支判断忘记释放连接池很快就炸了。hiredis 还缺连接池和自动重连只提供redisConnect。在高并发 C 服务里你要自己封装连接池、自己处理断线重连、自己控制超时。不是不行而是很容易把代码写成“可用但不可维护”的状态。我早期项目里就写过这种封装后来每次加需求都要动底层特别痛苦。所以现在除非是极端依赖性能的原生模块否则我不建议直接在业务代码里裸用 hiredis。2.2 redis-plus-plus更贴合现代 C 的封装后来我转用 redis-plus-plus项目地址在 GitHub 上底层依然基于 hiredis但全部用 C11/17 重新封装。它支持连接池、自动重连、Redis 集群、Redis Sentinel、发布订阅、管道、事务还提供Optional语义API 风格很 C。对 Redis List 操作来说它把lpush、rpush、lrange、lpop、brpop都变成普通方法返回值是long long或std::vectorOptionalString代码读起来舒服很多。它的关键优势是 RAII 友好。你可以创建一个redis::Redis对象内部持有连接池析构时自动释放所有连接。不像 hiredis 那样每次都要小心redisFree。连接池参数也能在构造时设置最小连接数、最大连接数、连接超时、Socket 超时全都能用配置声明式搞定。我后续所有示例都会以 redis-plus-plus 为主它已经是我生产项目里的标准选择。2.3 其他库的考量除了 redis-plus-plus还有 cpp_redis、acl、甚至直接用 Boost.Asio 自己写协议。cpp_redis 早年很火API 也不错但很长时间没有活跃维护C20 时代的编译适配比较吃力。acl 是一个更综合的网络库内置 Redis 客户端功能全但文档偏少团队上手成本高。自己写协议更是下策除非你的系统里有极端特殊的裁剪需求否则不要重复造轮子。选择客户端库我的参考维度就三条一是底层是否基于 hiredis这决定性能和兼容性二是 API 是否贴合现代 C能不能配合 RAII、异常、std::optional使用三是社区维护活跃度别选一个三年不更新的库。redis-plus-plus 在这三条上都过关所以下面所有示例都围绕它展开。2.4 我为什么最终选了 redis-plus-plus说一个实际对比场景同样实现“从 List 弹出一条消息解析为 JSON再删除备份”hiredis 写法至少要二十行还得自己管理redisReply而 redis-plus-plus 可以写成不到十行且不需要手动释放内存。更重要的是它天然支持std::vector批量插入和std::optional结果判断。比如lpush批量加入一组任务 IDhiredis 需要构造命令字符串redis-plus-plus 直接传两个迭代器就搞定。redis-plus-plus 的异步接口也值得一提。它提供RedisAsync配合回调可以支撑高性能场景。但我的生产经验是大多数 Redis List 场景用同步接口 连接池已经足够因为 List 操作本身都在毫秒级瓶颈往往在序列化和网络往返而不是客户端库的同步阻塞。异步接口适合那些需要同时维护很多长连接并减少线程数的架构但会显著提高排查复杂度我不建议新手一上来就上异步。3. 现代 C 封装 Redis List 核心操作3.1 用 RAII 管理 Redis 连接现代 C 的核心理念之一就是 RAII资源获取时初始化析构时自动释放。用 redis-plus-plus 实现这个再自然不过。我把 Redis List 操作封装成一个类比如叫RedisListClient构造函数里创建redis::Redis对象析构函数什么都不用做因为连接池是成员对象会跟着生命周期走。#include sw/redis/redis.h class RedisListClient { public: explicit RedisListClient(const std::string host, int port, int pool_size 10, int timeout_ms 500) : host_(host), port_(port) { redis::ConnectionOptions opts; opts.host host_; opts.port port_; opts.connect_timeout std::chrono::milliseconds(timeout_ms); opts.socket_timeout std::chrono::milliseconds(timeout_ms); redis::ConnectionPoolOptions pool_opts; pool_opts.pool_size pool_size; pool_opts.wait_timeout std::chrono::milliseconds(timeout_ms); redis_ptr_ std::make_sharedredis::Redis(opts, pool_opts); } // 确保对象非拷贝防止连接池被错误复制 RedisListClient(const RedisListClient) delete; RedisListClient operator(const RedisListClient) delete; private: std::string host_; int port_; std::shared_ptrredis::Redis redis_ptr_; };这里用shared_ptr管理redis::Redis是为了后续如果有多个模块需要共享同一个客户端实例时可以直接传递shared_ptr避免重复建连。析构时redis_ptr_引用计数归零Redis 客户端连接池自动关闭。在整个生命周期里你不需要手动close或release这就是 RAII 的价值。3.2 基础命令封装从 LPUSH 到 BRPOP我把常用的 List 操作封装成类方法让调用方不用关心命令细节。比如PushHead和PushTail对应LPUSH和RPUSH的批量版本PopHead和PopTail对应非阻塞弹出。// 压入队列头部支持多元素批量压入 long long PushHead(const std::string key, const std::vectorstd::string items) { try { if (items.empty()) return 0; return redis_ptr_-lpush(key, items.begin(), items.end()); } catch (const redis::Error e) { // 记录日志 return -1; } } // 压入队列尾部 long long PushTail(const std::string key, const std::vectorstd::string items) { try { if (items.empty()) return 0; return redis_ptr_-rpush(key, items.begin(), items.end()); } catch (const redis::Error e) { return -1; } } // 从头部弹出一条返回 std::optional表示可能没有数据 std::optionalstd::string PopHead(const std::string key) { try { auto val redis_ptr_-lpop(key); if (val) return val-value(); return std::nullopt; } catch (const redis::Error e) { return std::nullopt; } } // 从尾部弹出一条 std::optionalstd::string PopTail(const std::string key) { try { auto val redis_ptr_-rpop(key); if (val) return val-value(); return std::nullopt; } catch (const redis::Error e) { return std::nullopt; } }阻塞版本是重点。BRPOP会一直阻塞到超时redis-plus-plus 里的brpop返回std::optionalstd::pairstd::string, OptionalString这个 pair 是key和value。因为 BRPOP 可以同时监听多个 key所以你要习惯把 key 传进向量里。std::optionalstd::string BlockPopTail(const std::string key, long timeout_sec 5) { try { std::vectorstd::string keys{key}; auto result redis_ptr_-brpop(keys.begin(), keys.end(), timeout_sec); if (result) { return result-second.value(); } return std::nullopt; } catch (const redis::Error e) { return std::nullopt; } }这个封装看起来简单但有几个细节值得强调。一是超时时间单位是秒生产里我会单独配置不要让消费者死等。二是brpop返回结构体中first是那个最终命中的 keysecond才是元素值。三是如果列表里同时有多个阻塞消费者当元素到来时Redis 会均匀分配给其中一个消费者这是实现工作队列的基础。3.3 区间查询与分页LRANGE是另一个高频操作封装成返回std::vectorstd::string的接口最稳妥。如果你的列表元素是 JSON 字符串也可以直接把这个模板扩展。std::vectorstd::string Range(const std::string key, long start, long stop) { std::vectorstd::string result; try { auto items redis_ptr_-lrange(key, start, stop); result.reserve(items.size()); for (const auto item : items) { if (item) result.push_back(item-value()); } } catch (const redis::Error e) { // 记日志 } return result; }这里有个容易踩的坑lrange返回的每个元素是OptionalString如果某个元素是空字符串它依然是个合法的OptionalString你不要因为空字符串就不处理。另外lrange的stop参数是闭区间和 C 的右开区间不一样。比如取前 20 条你要写lrange(key, 0, 19)不是0, 20。这个差异第一次用很容易出错。3.4 序列化策略不只存字符串Redis List 只认字符串所以我们必须在 C 侧做好序列化。最简单的方案是直接存std::string适合消息体本身就是文本的场景。但如果要存一个结构体对象我建议统一用 JSON。现代 C 里用nlohmann/json库非常顺手先把对象序列化成字符串再LPUSH读取时再反序列化。struct Task { int id; std::string type; uint64_t create_ts; std::string payload; }; nlohmann::json TaskToJson(const Task task) { return nlohmann::json{ {id, task.id}, {type, task.type}, {create_ts, task.create_ts}, {payload, task.payload} }; } Task JsonToTask(const std::string json_str) { auto j nlohmann::json::parse(json_str); Task t; t.id j[id].getint(); t.type j[type].getstd::string(); t.create_ts j[create_ts].getuint64_t(); t.payload j[payload].getstd::string(); return t; }序列化这一层必须和 Redis List 操作解耦。我一般会在封装类里加模板方法调用方传任意可序列化对象内部自动转成字符串读取时再自动反序列化。这样业务代码就只需要关心自己的Task不需要碰 JSON 解析细节。template typename T long long PushHead(const std::string key, const T item) { return PushHead(key, std::vectorstd::string{SaveToJson(item)}); }序列化格式的选择也要结合场景。如果追求极限性能而且数据只是简单的数字 ID直接用std::to_string存字符串即可不需要 JSON 的开销。JSON 的优势是可读性强、字段扩展方便劣势是序列化反序列化会吃掉一些 CPU。我的经验是消息体一旦超过两三个字段就用 JSON只有单一 ID 时用纯字符串。3.5 异常与重试策略现代 C 里处理 Redis 错误我建议把“异常捕获”和“业务重试”分开。redis-plus-plus 在连接失败、命令超时时会抛redis::Error子类异常。你在底层封装里捕获异常并记录日志然后向上抛一个统一的自定义异常或者返回一个表示失败的结果。不要在每个业务方法里都 try catch那样代码会很乱。重试策略要谨慎。Redis List 的LPUSH、RPUSH天然是幂等的你可以重试但LPOP不是因为第一次LPOP已经弹出元素第二次再去LPOP就会弹出下一个元素导致消息错乱。所以在封装非阻塞Pop时我不会做自动重试最多在连接层面自动重连。阻塞BRPOP也一样超时返回空不算错误更不应该重试不然消费者会无限循环抢占消息。4. 生产级实践用 Redis List 打造可靠任务队列4.1 需求与设计假设我们现在要做一个图片处理任务队列。上游服务把任务 ID 和参数 JSON 序列化后LPUSH到task:queue下游多个 C worker 进程并发地从队列BRPOP消费任务处理完成后把结果写库。这个需求很简单但生产级还要求几个点至少不能因为 worker 崩溃导致任务永久丢失。单个 worker 处理失败时任务不能无限重试要有次数上限。多 worker 之间要公平抢任务不能某个 worker 空转、另一个堆积。队列积压时要能平滑扩容 worker不需要停机。我之前在项目里用BRPOPLPUSH解决了“崩溃丢失”问题。BRPOPLPUSH会把弹出的元素同时推入另一个备份队列。我用task:queue作为主队列task:processing作为处理中队列。worker 正常处理完任务后主动从task:processing移除该元素如果 worker 崩溃那么元素还留在task:processing由定时任务重新放回主队列。4.2 生产者实现生产者代码很简单关键是埋点。我会在压入队列前给任务生成唯一 ID时间戳、来源、负载都放进去。这样后面排查问题、做消息追踪都能用到。void ProduceTask(const std::string task_type, const std::string payload) { TaskTask task; task.id next_task_id(); task.type task_type; task.create_ts get_current_ms_epoch(); task.payload payload; auto json_str SaveToJson(task); auto len client_.PushTail(task:queue, {json_str}); if (len 0) { // 这里要告警任务没进队列是严重问题 } }生产者的核心注意事项是不要远程调用一次LPUSH还要再做大量计算。如果任务体很大先在本地压缩或裁剪。Redis List 的单条元素上限很大但过大的 JSON 会拖慢网络和序列化。我一般限制任务 JSON 不超过 10KB超过就走对象存储队列里只存引用。4.3 消费者实现与消费确认消费者主循环要用BRPOPLPUSH替代简单的BRPOP这是可靠队列的关键。BRPOPLPUSH原子地把元素从主队列右边弹出同时压入备份队列左边。这样即使 worker 在处理时崩溃元素还留在备份队列里。std::optionalstd::string consume_once(const std::string from_queue, const std::string to_queue, long timeout_sec) { try { auto res redis_ptr_-brpoplpush(from_queue, to_queue, timeout_sec); if (res) return res-value(); return std::nullopt; } catch (const redis::Error e) { return std::nullopt; } }拿到任务后worker 先反序列化再执行处理逻辑。处理成功从task:processing里删除该元素。这个删除操作可以用LREM指定删除值。如果任务体中有重复值为避免误删应该使用唯一 ID 字符串作为列表元素或者存储时带上唯一标记。处理失败时要区分“可重试失败”和“丢弃失败”。可重试的任务重新RPUSH回主队列但必须加一个重试次数计数。如果重试次数超过阈值把消息转移到task:dead-letter死信队列由人工或离线任务处理。4.4 死信队列与延迟重试死信队列我一般直接用另一个 Redis List名叫task:dead-letter。主消费程序识别到失败重试次数大于阈值后就把 JSON 字符串原样LPUSH到死信队列。死信队列可以有专门的消费者负责统计、告警、甚至把失败消息 dump 到磁盘归档。延迟重试的一种实现是双队列先把重试消息RPUSH到task:retry然后启动一个定时器每隔 30 秒从task:retry中所有元素再搬回主队列。没有 Redis 过期特性的环境这种轮询搬运是简单可靠的。你也可以给消息附上next_retry_ts定时器只搬那些到了时间的消息。这里要注意不要把重试逻辑写进主消费者的热路径否则主队列会一直被重试消息塞满。我在项目里就是单独扛一个清扫协程每 5 秒扫描task:retry把到期任务搬回主队列。这样主消费者只负责从主队列拿任务一旦失败就立刻抛给重试队列不会被阻塞。4.5 线程安全与并发控制redis-plus-plus 的Redis对象本身是线程安全的内部自带连接池。但在业务代码里多个线程共享同一个RedisListClient时要保证封装方法没有数据竞争。我的做法是不要共享可变状态所有请求参数都是局部变量返回值也是局部变量。封装类内部只有redis_ptr_是共享的而它内部自己做了连接池调度所以没问题。如果要启动多个消费者最简单的方式是每个线程独立持有自己的RedisListClient或者共享一个客户端但各自调用。注意BRPOPLPUSH是阻塞操作一个 worker 线程可能长时间占用一个连接。如果线程数超过连接池最大连接数其他请求就会等待连接释放。因此连接池的pool_size要至少大于消费者线程数否则会出现消费者把自己堵死的情况。我习惯把pool_size设成消费者线程数的两倍保证还留有连接处理其他管理命令。5. 常见问题与排查技巧5.1 阻塞命令为什么会卡死很多人第一次用BRPOP会写一个while(true)循环然后发现服务下线时进程无法退出。原因就是BRPOP一直阻塞在 Redis 连接上进程收到 SIGTERM 后由于主线程还卡在同步调用里优雅退出迟迟不能完成。解决办法有两个一是给BRPOP设置合理的超时时间比如 2 秒超时后循环体检查退出标记二是用异步接口加回调但会增加代码复杂度。我一般选择超时轮询虽然会偶尔多一次空转但架构最清晰。5.2 LRANGE 大列表的性能陷阱LRANGE是分页利器但列表长度超过一万之后区间查询会开始出现延迟。Redis 是单线程处理命令的一个大LRANGE会拖慢同一实例上的其他命令。我的经验是如果列表预期会增长到十万级以上就别用 List 做分页存储改用 Redis Sorted Set 按 score 分页或者把数据放到关系型数据库。如果必须用 List就要控制列表长度定期裁剪或迁移旧数据。一个很实用的技巧是用LTRIM控制列表上限。比如只保留最近 5000 条动态在每次LPUSH后执行LTRIM key 0 4999。这样列表永远不膨胀LRANGE的性能也稳定。但你要想清楚超出部分的数据是不可恢复的所以业务上要容忍丢失旧数据。5.3 连接池耗尽与慢命令连接池耗尽是最容易在生产里出现的隐性故障。表面现象就是服务偶发超时日志里全是TimeoutException。排查时除了看 Redis 端是否慢还要看你自己的业务代码是否阻塞得太久。在 Redis List 的消费者里如果你在拿到消息后直接在主线程里做了耗时计算比如调用外部 HTTP 接口那么这个线程会一直占用着 Redis 连接。其他操作 Redis 的请求就排不上队。我的建议是消费线程只负责从 Redis 弹出消息然后把消息塞进本地线程池真正耗时处理放到工作线程池里处理完成后再由专用线程回写 Redis。这样 Redis 连接永远不会被业务阻塞连接池的压力也小很多。5.4 持久化配置对任务队列的影响Redis List 做任务队列最怕 Redis 重启丢数据。Redis 默认配置下如果没开启 AOF 或者 RDB 策略过于宽松宕机时队列数据会丢。对于不是特别严格的场景这可能能接受但对于任务队列来说任务丢了就是事故。我在生产环境至少开启 AOF并设置appendfsync everysec。每次写入都fsync太慢每秒一次可以兼顾可靠性和性能。还需要特别注意BRPOPLPUSH配合持久化的语义即使 Redis 持久化做得很好整个操作在 Redis 内部是原子的但如果我们把备份队列里的元素重新搬回主队列这个搬运过程是业务代码控制不是 Redis 原子操作。如果搬运脚本和消费进程同时操作可能造成重复消费所以搬运逻辑要加锁或用 SCRIPT 保证原子性。简单场景下我直接把清洁逻辑放到 Redis 的 Lua 脚本里让“读取待重试的元素 搬回主队列”变成原子操作。5.5 一个容易被忽略的坑类型混淆Redis 的 key 是字符串但 List 命令只对 List 类型生效。如果你误把task:queue这个 key 用SET写成了 String 类型之后再用LPUSH task:queue会直接报WRONGTYPE Operation against a key holding the wrong kind of value。这个问题我踩过不止一次。排查方法是用TYPE task:queue查看类型然后用LRANGE task:queue 0 -1确认数据是否符合预期。更隐蔽的坑是多个业务共用一个客户端对象然后有人把 List key 写成了普通 String key 的命名前缀。比如动态流服务里user:123:timeline是 List但有人建了一个user:123的 String导致命名冲突。我的建议是给不同业务的数据类型增加明确后缀List 类型统一用:lst结尾String 类型用:str结尾从源头避免类型错乱。这个方法很简单但真的能省很多凌晨三点排查问题的时间。写了这么多最后分享一个我自己的习惯不管用LPUSH还是BRPOP都在 Redis 客户端层打上一条“命令名 key 耗时”的结构化日志。Redis List 本身操作很快但一旦出现网络抖动或慢命令这条日志能帮你立刻定位到是哪个 key、哪条命令卡住了。很多人只关注业务日志忽略了 Redis 客户端日志真出了跨进程问题就会抓瞎。如果你也想少踩几个 Redislist 的坑不妨从这句日志开始。