muduo聊天服务器演进:从单机到nginx+redis分布式集群 📅 发布时间:2026/9/11 10:32:28 👁 浏览次数: 简介基于muduo、nginx、redis的分布式C集群聊天服务器完整源码面向具备C基础、希望深入高并发网络编程与分布式系统设计的开发者。该资源共64个文件核心为hpp头文件与cpp实现配合CMake/Makefile构建脚本、txt说明、json配置以及可直接运行的bin/out程序压缩包仅4.76MB已有79人学习浏览。项目包含server与client双端利用muduo处理高并发IO通过nginx实现负载均衡借助redis完成跨服务器消息转发整体结构包括build目录、thirdparty三方库和autobuild.sh自动构建脚本。读者可从中学习集群聊天系统的模块划分、构建流程、配置方式并基于testmuduo.cpp体会功能调试与验证方法适合作为课程设计或企业级项目落地的参考模板。此外代码体现了C面向对象、泛型与过程化编程的混合使用覆盖从网络通信、业务处理到集群协作的完整链路对理解真实服务器项目有较大的参考价值。1. 单机muduo撑住后才理解nginx和redis为什么要进集群把聊天服务挂在单台muduo进程上在线用户到两千就可以看到EAGAIN和Acceptor::accept fd: too many opened files交替出现。这不是ulimit调大就完事的问题连接数、广播消息的扇出、内存里每连接缓冲全部挤在一个进程里加核数也只是让锁冲突更频繁。这套分布式集群聊天服务器把进程拆成三个角色——muduo只负责网络收发nginx做四层的TCP负载均衡redis负责跨节点的在线状态与消息扇出。适合已经用muduo写过单机聊天、想清楚集群路由怎么不丢消息的人。下面的目录结构来自实际工程包include/public.hpp定协议server/CMakeLists.txt接构建autobuild.sh一键编译thirdparty/json.hpp做序列化。读完后你不仅知道怎么编译它还能自己解释粘包、路由哈希和Pub/Sub 阻塞回调三者之间怎么配合。2. muduo网络层与C工程构建先拆出可编译的单机骨架2.1 从autobuild.sh看库的依赖链拿到的包里有一个autobuild.sh内容不算长核心逻辑是先检查muduo、hiredis是否已经安装缺失则停下来提示接着调用cmake配置再make -j$(nproc)。我一般会把脚本拆成两段看前面是环境检查后面是构建参数。环境检查的典型写法是这样#!/bin/bash set -e if [ ! -d /usr/include/muduo ] [ ! -d /usr/local/include/muduo ]; then echo muduo not found, install it first exit 1 fi if [ ! -d /usr/include/hiredis ] [ ! -d /usr/local/include/hiredis ]; then echo hiredis not found, install it first exit 1 fi BUILD_DIRbuild if [ ! -d $BUILD_DIR ]; then mkdir -p $BUILD_DIR fi cd $BUILD_DIR cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)set -e表示任何一条命令失败后立刻退出避免在缺库时继续编译生成一堆无意义日志。-j$(nproc)让make自动用满所有逻辑核在四核以上机器上能明显缩短编译时间。如果用 vscode 配过 C/C 环境这段代码可以直接放进tasks.json的command字段让IDE一键编译。依赖没有装时不要急着改源码先去安装libmuduo-dev和libhiredis-dev装完再跑脚本。2.2 CMakeLists.txt里静态库和系统库的链接顺序工程server/CMakeLists.txt的链接顺序是这个项目最常见的崩溃点。muduo 的头文件用了MUDUO_ROOT这种变量来定位CMake核心片段如下find_path(MUDUO_INCLUDE_DIR muduo/net/TcpServer.h) find_path(HIREDIS_INCLUDE_DIR hiredis/hiredis.h) include_directories(${MUDUO_INCLUDE_DIR} ${HIREDIS_INCLUDE_DIR}) add_executable(chatServer chatServer.cpp Chatservice.cpp ../include/public.hpp ) target_link_libraries(chatServer muduo_net muduo_base pthread hiredis )逻辑说明find_path找出头文件所在目录确保编译器能找到muduo/net/TcpServer.h和hiredis/hiredis.h。链接顺序上muduo_net依赖muduo_base所以muduo_base必须写在右边pthread放在最后是为了解析 muduo 内部的线程符号。这个顺序反了会弹出大量undefined reference to pthread_*。hiredis也不建议用pkg-config的动态链接因为集群版里需要用回调方式处理订阅消息静态编译时hiredis的符号依赖必须靠顺序保证-Wl,--no-as-needed可以再加一层保险。2.3 TcpServer回调消息缓冲、半包粘包与协议分离muduo 的单机骨架核心就是三个回调onConnection、onMessage、onWriteComplete。聊天协议里我用的是 4 字节大端长度头 JSON 体public.hpp里定义了消息结构// public.hpp #pragma once #include cstdint struct MsgHeader { uint32_t msgid; uint32_t len; }; enum ENetMsg { EN_MSG_LOGIN 1, EN_MSG_CHAT 2, EN_MSG_LOGOUT 3 };接收端在onMessage里必须先把数据追加到muduo::net::Buffer再判断长度是否足够一个MsgHeader。取完头之后继续比较buffer-readableBytes()是否达到len。这一步不写网络一抖就会出现半包 JSON 解析失败。拆包逻辑如下// ChatServer.cpp 片段 void ChatServer::onMessage(const TcpConnectionPtr conn, Buffer* buf, Timestamp time) { while (buf-readableBytes() sizeof(MsgHeader)) { MsgHeader hdr; memcpy(hdr, buf-peek(), sizeof(hdr)); hdr.len ntohl(hdr.len); hdr.msgid ntohl(hdr.msgid); if (buf-readableBytes() sizeof(MsgHeader) hdr.len) { break; // 半包等下一次回调 } buf-retrieve(sizeof(MsgHeader)); std::string jsonBody(buf-peek(), hdr.len); buf-retrieve(hdr.len); handleMsg(conn, hdr.msgid, jsonBody, time); } }逻辑说明while循环是必须的因为一次onMessage可能塞进多个完整包只处理一个会把第二个包留在 buffer 里导致后续粘包错位。ntohl把网络字节序转成主机字节序客户端发送时用htonl两端一致即可。这里的Buffer::retrieve只是移动读指针不会立刻回收内存所以长连接持续高频聊天时最好定期调用shrinkmuduo 内部有水位机制但自己掌握节奏更稳。单机能跑了就该解决“多台机器上一条消息发给谁”的问题。这需要 nginx 在连接层就把用户路由到固定节点。3. nginx四层转发与长连接路由sticky session决定消息落到哪台节点3.1 stream模块为什么http_upstream不适用于muduomuduo 的聊天客户端与服务器之间是长连接 TCP 流http模块的upstream是为短连接设计的后端关闭连接后 nginx 不会帮你透明重连。聊天场景里客户端一晚上开着 Appnginx 和后端的 keepalive 状态必须由四层的stream模块管理。nginx.conf里要加一个独立配置块stream { upstream chat_backend { server 192.168.1.11:6000 max_fails2 fail_timeout30s; server 192.168.1.12:6000 max_fails2 fail_timeout30s; } server { listen 9000; proxy_pass chat_backend; proxy_timeout 300s; proxy_connect_timeout 5s; } }逻辑说明stream块监听 9000客户端只连 9000nginx 把 TCP 字节流原样转发到后端 6000。max_fails2配合fail_timeout30s让 nginx 在连续两次失败后摘掉节点30 秒后再放回proxy_timeout 300s表示如果 300 秒内没有数据传输nginx 主动断掉连接muduo 端的TcpServer::setTcpNoDelay不能影响这层。这个配置只能解决分发不能解决“同一个用户总是被送到同一台”。如果不做会话粘连用户从客厅切换到卧室时 IP 变了nginx 的默认轮询会把用户送到另一台机器那 redis 里在线状态就得重新迁移消息时序乱掉。下面的一致性哈希就是干这个的。3.2 一致性哈希让同一个用户的路由保持稳定打开stream里的hash指令用客户端 IP 做哈希可以把同一 IP 的请求稳定到固定 upstream stream { upstream chat_backend { hash $remote_addr consistent; server 192.168.1.11:6000; server 192.168.1.12:6000; } server { listen 9000; proxy_pass chat_backend; } }consistent关键字启用 Ketama 一致性哈希。普通哈希在节点增加或减少时绝大多数 IP 的路由会重新打乱一致性哈希只影响少量 IP后端扩容时在线用户不会产生雪崩式迁移。移动网络下$remote_addr经常变更稳的是从客户端传到 nginx 的变量例如$arg_uid不过 stream 模块不解析 query string需要用js脚本或者 TCP 消息里的自定义字段做 hash key。我一般会把登录包里的userid提前到 TCP 前 16 字节nginx 用preread阶段读出来再哈希这样路由与IP无关跟 socket 生命周期绑定。3.3 proxy_timeout与TCP keepalive冲突排查这里有个高并发下容易翻车的参数proxy_timeout不能设太小。聊天是典型的低流量长连接用户可能几分钟不说一句话。如果设成 60smuduo 和客户端之间没数据nginx 就把连接切开客户端毫不知情下次发消息时连接已死表现就是消息悄无声息丢失。方案是 nginx 的proxy_socket_keepalive on;让 nginx 转发 TCP keepalive 探测报文给后端同时把proxy_timeout放到 300s 以上。server { listen 9000; proxy_pass chat_backend; proxy_socket_keepalive on; proxy_keepalive_timeout 120s; }proxy_socket_keepalive是基于 socket 的 SO_KEEPALIVE默认探测间隔 2 小时要优化到秒级需要在系统层设置net.ipv4.tcp_keepalive_time60。如果用户换了网络导致 IP 变化muduo 端看到的是原连接老化而 nginx 哈希又把他派到另一台节点这时就需要 redis 来告诉新节点“这个用户已经在那里登录过”。这就是 redis 在架构里不可替代的原因。4. redis在集群里的双角色在线状态缓存与跨节点Pub/Sub4.1 用string还是hash存在线状态聊天场景的在线状态每个用户只需要一条userid - nodeid的映射。这种高频更新、低字段数的模型用 string 比 hash 合适。hash 适合把一个用户多个属性昵称、头像、状态存一起但如果只存一个“用户属于哪个节点”hash 的 HGET 和 HSET 要带 field 名网络消耗更大。命令如下# 登录成功后在 node-1 上写入 redis-cli SET online:10001 node-1 EX 300 # 查询 redis-cli GET online:10001 # 心跳续期只在用户活跃时执行 redis-cli EXPIRE online:10001 300EX 300是 5 分钟自然过期解决了logout没发出来时状态假死的问题。注意EXPIRE每次心跳都要发如果不发用户安静挂机超过 5 分钟就会被判定离线客户端收到消息时 redis 里查不到。更稳妥的写法是在SET时给旧 key 续期而不是单独发 EXPIRE减少一次 RTT。muduo 端接入 Redis 时我用 hiredis 的redisCommand封装了一层返回结果统一转成std::string避免到处处理redisReply的类型。读多写少的场景下node 节点定期GET online:*扫描全部在线用户也不会压垮 redis但注意 keys 命令不要用SCAN才是正确姿势。这个差异在面试里经常被追问属于 C 服务端开发的基本功。4.2 订阅-发布的阻塞模型如何不被muduo事件循环卡死redis 做跨节点消息扇出的核心操作当用户 B 登录在 node-2而用户 A 在 node-1 上给 B 发消息时node-1 拿到 B 的online状态后应该执行PUBLISH chat:B这条命令把完整的 JSON 消息作为 payload 发出去。node-2 要提前订阅 B 的频道。问题就出在SUBSCRIBE是阻塞的hiredis 的同步接口会一直等 redis 推送这一步会死死卡住 muduo 的EventLoop线程。正确的接法是把订阅放到独立线程收到消息后向 muduo 主线程wakeup投递任务// RedisSubscriberh.cpp 片段 void RedisSubscriber::subscribeLoop() { redisContext* ctx redisConnect(127.0.0.1, 6379); redisReply* reply redisCommand(ctx, SUBSCRIBE chat:%d, userId_); freeReplyObject(reply); while (redisGetReply(ctx, (void**)reply) REDIS_OK) { if (reply-type REDIS_REPLY_ARRAY reply-elements 3) { std::string channel std::string(reply-element[1]-str); std::string payload std::string(reply-element[2]-str); loop_-queueInLoop(std::bind(ChatService::dispatch, this, channel, payload)); } freeReplyObject(reply); } }逻辑说明redisGetReply进入阻塞等待所以这个函数必须跑在独立std::thread里。loop_-queueInLoop是 muduo 线程安全的任务投递接口会把dispatch切回主事件循环执行。这样 redis 订阅线程和网络线程不互相卡。注意queueInLoop内部会wakeup阻塞的poll如果投递太快主循环排队任务积压需要用有界队列或者流量控制。其次每个用户一个频道会让 redis 的 pub-sub 通道数膨胀在线 10 万用户就是 10 万个 channel内存开销不小更省的做法是每个 node 只订阅自己的节点号消息体里带上目标节点由 redis 的脚本做分发但复杂度上升工程包里默认是方案是“按用户ID订阅”这也是可运行的。4.3 setnxexpire只有需要全局唯一时才碰分布式锁这个工程里 redis 还有一个隐藏用途生成跨节点单调递增的消息序号。消息要落库时两个节点分别给同一条会话生成 ID冲突几乎是必然的。用 redisINCR chat:seq可以解决它天然原子不需要锁。只有类似“强制把某个用户从所有节点踢下线”这种多步操作才需要分布式锁# node-1 抢锁 redis-cli SET lock:kick:10001 node-1 NX EX 10 # 抢到后执行踢人逻辑 redis-cli PUBLISH chat:kick:10001 force-offline # 完成后主动释放 redis-cli DEL lock:kick:10001NX表示只在 key 不存在时写入配合EX 10防止持有锁的节点崩溃后死锁。释放锁时要注意先 GET 再 DEL 不是原子的正确做法是 Lua 脚本比较值后删除。不过在这个聊天集群里只要消息序号由INCR生成业务层面很少需要锁这个参数是给扩展点留的。5. 从login到deliver一次完整消息跨机转发的数据流5.1 登录注册、路由表和channel绑定用下面的时序表说明一次登录是如何绑定到具体节点的步骤动作数据变化1客户端连接 nginx:9000nginx 按哈希选择 node-12客户端发送{msgid:1,userid:10001}node-1 建立 userId-TcpConnection 映射3node-1 写入 redisSET online:10001 node-1 EX 3004node-1 订阅chat:10001node-1 的订阅线程开始收消息5客户端发心跳EXPIRE online:10001 300登录时有一个顺序问题先写 redis 还是先建订阅如果先写 redis消息立刻来了但订阅还没建好消息会丢在 redis 的通道里。Pub/Sub 不持久化没有消息积压能力。稳妥顺序是先建订阅、再写 redis。订阅建立要从 redis 返回确认后才算完成不能直接sleep(1)。5.2 消息序列化4字节头 json体的拆包逻辑客户端给msgid2的聊天消息定义如下{ from: 10001, to: 10002, type: 1, content: 你好 }发送端先拼接MsgHeader再拼接 json 字节流// 发送端封包 Json::Value root; root[from] 10001; root[to] 10002; root[content] hello; std::string body root.toStyledString(); MsgHeader hdr; hdr.msgid htonl(EN_MSG_CHAT); hdr.len htonl(body.size()); std::string packet; packet.append(reinterpret_castchar*(hdr), sizeof(hdr)); packet.append(body); conn-send(packet);接收端拆包时目标节点先在 redis 里查online:10002得到 node-2如果查到的值不是当前节点就把这条 json 原封不动作为 payload 发布到chat:10002。node-2 的订阅线程收到后经过queueInLoop回到主线程再查自己的本地 userId-TcpConnection 表找到连接并send出去。这一段是整个集群最核心的条带许多同学只看源码以为自己懂了真到了多节点部署会发现消息已经从 redis 推过来了但接收端send时报Bad file descriptor原因就是连接映射表里存放的连接已失效需要校验TcpConnection::connected()。5.3 断线重连与旧连接抢占新连接的经典坑muduo 的连接回调里用户断开后 moduo 不会立刻销毁连接而是先把连接放到close队列。此时如果用户立刻重连nginx 又把他路由到同一节点onConnection里新建的TcpConnectionPtr和旧的条目同时存在映射表 d 里会指向旧连接。解决方法在onConnection里先删除旧连接。常见做法是维护一份userid - weak_ptrTcpConnection映射每次新连接建立时先对weak_ptr加锁若旧连接expired()则直接覆盖若是合法连接则主动shutdown()旧连接再覆盖void ChatService::onLogin(const TcpConnectionPtr conn, int userid) { auto old users_[userid].lock(); if (old old ! conn) { old-shutdown(); } users_[userid] conn; }逻辑说明weak_ptr不会延长旧连接的寿命但能安全判断它是否还活着。为什么不用shared_ptr直接存映射因为一旦连接断开muduo 的回调还在队列里shared_ptr会让连接延迟到回调队列清空才销毁期间新连接和新连接名下同一userid的老连接都在发送数据时会莫名跑到旧连接上。这个坑在压测断线重连时 10 次里能撞出 6 次值得在面试里当作亮点讲。6. 压测与排错用tcpdump和redis-cli校准集群行为6.1 压测前先确认路由是否均匀多节点启动后先用redis-cli monitor观察在线状态变化redis-cli monitor | grep SET online如果只看到一台 node 的 IP 在寫SET online说明 nginx 哈希把流量都打到同一边了。一致性哈希的均匀性在节点数少时本来就差2 台节点可以用hash key加consistent但更简单的是在 stream 配置里加replicate off并保持每台机器配置一致然后跑一批模拟用户统计online:*中每个 node 的 key 分布for i in $(seq 10000); do redis-cli SET online:$i node-$((i % 2)) EX 600 /dev/null done redis-cli --scan --pattern online:* | awk -F: {print $3} | sort | uniq -c这只是模拟验证真实环境下应该从 nginxstream的日志统计 upstream 地址。注意 awk 的字段分割需要根据 key 格式调整online:userid:nodeid这种三层结构用-F: {print $3}取节点名即可。6.2 抓包验证拆包边界用tcpdump抓 9000 端口观察一次登录的数据包大小和到达时序tcpdump -i any -X -s 0 port 9000 -w chat.pcap拿到 pcap 后打开看到客户端发出的第一个包应该是 4 字节0x00000015十进制 21紧跟 json 体。如果发现第一个包长度是 7说明客户端把消息头和 json 分两次发送了而服务端while拆包逻辑没问题但客户端发送端最好把len和body放进同一个Buffer再send。重点验证的是proxy_timeout断开的连接在 pcap 里是RST还是FIN如果出现大量RST通常是 nginx 或 muduo 设置了SO_LINGER的禁止后延需要检查两端的setTcpNoDelay和SO_KEEPALIVE是否一致。6.3 常见故障速查表现象可能原因排查入口用户 A 发消息B 延迟 30 秒以上redis 订阅线程被阻塞队列积压redis-cli info看订阅连接数检查 subscriber 线程里的redisGetReply换网络后消息丢失nginx 哈希 key 用 IPIP 变了路由到新节点改用 login 消息里的 userid 做 hash key某台节点内存暴涨muduo 的outputBuffer堆积对端消费慢ss -t看发送队列调highWaterMark回调redis 大量PUBLISH但后端无人收到订阅命令和写 redis 的顺序反了消息先到保证订阅确认后再SET online全部连接卡住conn-send返回成功但不发出去nginxproxy_timeout已断开连接客户端未知启动tcpdump看 FIN调整proxy_timeout排错时最忌讳直接重启。先看 redis 的PUBSUB CHANNELS确认订阅频道数量是否等于在线用户数再看redis-cli --stat里pubsub_channels是不是在涨。如果频道数对但消息收发不对称问题大概率出在 hash key 不一致登录用户在 node-1 建的订阅nginx 却把新连接路由到 node-2。回到stream块的 hash 配置用用户 ID 做 key而不是 IP。最后补一句把nohup启动日志里每台节点的connfd做个统计能最快定位到哈希空洞。本文还有配套的精品资源点击获取