websocket中所遇到的tcp粘包问题

Websocket中所遇到的粘包问题

问题描述

在websocket项目中,平时正常用网站与服务器进行少量的websocket数据传输没有出现问题。

而对服务端进行压测的时候,发现服务端收到了大量未知数据。

并且压测用客户端也在一定时间后奔溃:

初步判断

应该是网络传输的数据出现了问题。

所以利用gdb以及wireshark对程序进行调试。

对于服务端,对一处重要的地方进行了断点操作:

c++ 复制代码
                    res = jsonReader.parse(frame.payload_data, root);
                    if (!res) {
                        LOG_WARN << "parse json failed, data: " << frame.payload_data.substr(0, 100);
                    } 

具体指令:

cmd 复制代码
b websocket_conn.cc:433
condition 1 !res

对于客户端,通过bt观察最后断在哪里。

同时通过wireshark对传输的数据进行记录,毕竟内核的传输内容通过gdb无法看到:

cmd 复制代码
sudo tcpdump -i lo -w chatroom_capture.pcap port 8080

并生成chatroom_capture.pcap。之后放在wireshark中查看。

现象观察

对于服务端,发现接受的数据打印如下:

对于websocket的一帧数据来说:

fin和opcode是一个字节:fin = 0, opcode = 1,说明是01110001,正好是'q'。

mask和payload是一个字节:mask = 0, payload_length = 114,说明是01110010,正好是'r'。

测试用的数据正好是26个字母!说明这里服务端把一个本不应该是websocket帧的数据作为了一个帧。

将chatroom_capture.pcap在wireshark gui中打开观察,发现tcp在发送的时候出现了粘包:

且最后明显将websocket帧进行了截断:

这是因为使用了本地回环,MTU达到了65535,65535 - 20(IP) - 20(TCP) - 12(TCP选项) = 65483,正好等于tcp目前的mss:

而使用以太网的话,应该就只有1460,所以目前的粘包问题更加严重了。

估算下来一次可以发送几百个websocket帧!

回到代码

c++ 复制代码
            // ❌ 问题1:一次性取出所有数据,假设是完整的一帧或多帧
            string request = buf->retrieveAllAsString();
            
            LOG_DEBUG << "收到数据,长度: " << request.size();
            
            // ❌ 问题2:直接解析,不检查数据是否完整
            WebSocketFrame frame = parseWebSocketFrame_Original(request);
            
            LOG_DEBUG << "解析帧: opcode=" << (int)frame.opcode << ", payload_len=" << frame.payload_length;
            // ❌ 问题3:如果有多个帧粘在一起,只处理了第一个
            // ❌ 问题4:如果帧不完整,可能解析出错误数据

            // 业务逻辑处理(两个版本共用)
#endif
            if (frame.opcode == 0x01) { // 文本帧

通过muduo一次性可以取出的字节非常多。

而parseWebSocketFrame_Original只截取了第一帧,后面的全部抛弃,导致可能后面的websocket的帧头也被抛弃掉了!出现的情况可能如下:

c++ 复制代码
═══════════════════════════════════════════════════════════

第 1 步:客户端快速发送 Frame1、Frame2、Frame3...
         TCP 粘包,服务器一次收到 400 字节

═══════════════════════════════════════════════════════════

第 2 步:服务器 OnRead 被调用
         buffer: [Frame1: 270字节][Frame2 前130字节]
                 ↑ 共 400 字节

         buf->retrieveAllAsString() 取走全部 400 字节!
         
         parseWebSocketFrame_Original 解析 Frame1:
         byte[0]=0x81, byte[1]=0x7E → 正常帧头
         payload_data = Frame1的JSON + Frame2前130字节的垃圾
                                       ↑ 多出来的数据
         
         JSON 解析器恰好可以解析到 }}\n 就停止 → 成功

═══════════════════════════════════════════════════════════

第 3 步:Frame2 剩余 140 字节到达
         buffer: [Frame2 后140字节](从消息内容中间开始!)

         OnRead 再次被调用
         buf->retrieveAllAsString() 取走 140 字节

         parseWebSocketFrame_Original 尝试解析:
         byte[0] = 'q' (0x71) → fin=false, opcode=1  不是帧头!
         byte[1] = 'r' (0x72) → mask=false, len=114  不是帧头!
         payload_data = "stuvwxyzABCDEF..."           不是有效JSON

         JSON 解析失败

═══════════════════════════════════════════════════════════

至于为啥客户端也会崩掉,我对opcode = 8的情况,也就是断联也打了断点,发现有些数据可能会被识别成断联:

对的,这里的XY被解读成断联了,所以触发了disconnect,而disconnect在写法上存在缺陷:

c++ 复制代码
void CWebSocketConn::sendCloseFrame(uint16_t code, const std::string reason) {
    if (!tcp_conn_) return;

    // 构造关闭帧
    char frame[2 + reason.size()];
    frame[0] = (code >> 8) & 0xFF;  // 状态码高位
    frame[1] = code & 0xFF;         // 状态码低位
    std::memcpy(frame + 2, reason.data(), reason.size());

    // 发送关闭帧
    tcp_conn_->send(frame, sizeof(frame));
}

光顾着包装fin和opcode,而没有注意mask和payload lens,故而使得客户端解析错误。

错误纠正

首先把disconnect的bug改过来:

c++ 复制代码
// 发送 WebSocket 关闭帧
void CWebSocketConn::sendCloseFrame(uint16_t code, const std::string reason) {
    if (!tcp_conn_) return;

    // 构造关闭帧的 payload:2字节状态码 + reason字符串
    std::string payload;
    payload.push_back((code >> 8) & 0xFF);  // 状态码高位
    payload.push_back(code & 0xFF);         // 状态码低位
    payload.append(reason);

    // 用 buildWebSocketFrame 包装成完整的 WebSocket 帧(opcode=0x08 表示关闭帧)
    std::string frame = buildWebSocketFrame(payload, 0x08);
    tcp_conn_->send(frame);
}

然后在读取websocket帧的时候,用循环读取一个个帧:

c++ 复制代码
        // websocket帧至少两个字节
		while (buf->readableBytes() >= 2) {
            const char* data = buf->peek();
            size_t data_len = buf->readableBytes();
            
            // 先检查帧是否完整
            ssize_t frame_total_len = getWebSocketFrameLength(data, data_len);
            if (frame_total_len <= 0) {
                // 数据不足以判断帧长度,等待更多数据
                LOG_DEBUG << "数据不足以判断帧长度,等待更多数据";
                break;
            }
            
            // 防止超大帧长度导致挂起
            // WebSocket 协议建议最大帧长度为 10MB(可根据需求调整)
            const size_t MAX_FRAME_SIZE = 10 * 1024 * 1024;  // 10MB
            if (static_cast<size_t>(frame_total_len) > MAX_FRAME_SIZE) {
                LOG_ERROR << "帧长度异常:" << frame_total_len << " 字节(超过 " 
                          << MAX_FRAME_SIZE << " 字节限制),可能是损坏数据,跳过";
                buf->retrieve(1);  // 跳过这个异常字节,尝试恢复
                continue;
            }
            
            // 处理拆包 - 等待数据完整
            if (static_cast<size_t>(frame_total_len) > data_len) {
                // 帧不完整,等待更多数据(处理拆包)
                LOG_DEBUG << "帧不完整,需要 " << frame_total_len << " 字节,当前 " << data_len << " 字节";
                break;
            }
            
            // 解析一个完整的 WebSocket 帧
            WebSocketFrame frame;
            size_t frame_len = 0;
            if (!parseWebSocketFrame(data, data_len, frame, frame_len)) {
                LOG_ERROR << "解析 WebSocket 帧失败";
                buf->retrieve(1);  // 跳过一个字节,尝试恢复
                continue;
            }
            
            // 从 buffer 中移除已解析的帧数据
            buf->retrieve(frame_len);
            
            LOG_DEBUG << "解析帧成功: opcode=" << (int)frame.opcode << ", payload_len=" << frame.payload_length;

getWebSocketFrameLength函数,其实就是看看包长度:

c++ 复制代码
ssize_t getWebSocketFrameLength(const char* data, size_t data_len) {
    if (data_len < 2) {
        return 0;  // 数据不足
    }
    
    const uint8_t* bytes = reinterpret_cast<const uint8_t*>(data);
    size_t header_len = 2;
    uint64_t payload_len = bytes[1] & 0x7F;
    bool has_mask = (bytes[1] & 0x80) != 0;
    
    if (payload_len == 126) {
        header_len += 2;
        if (data_len < header_len) return 0;
        payload_len = (static_cast<uint64_t>(bytes[2]) << 8) | bytes[3];
    } else if (payload_len == 127) {
        header_len += 8;
        if (data_len < header_len) return 0;
        payload_len = (static_cast<uint64_t>(bytes[2]) << 56) |
                      (static_cast<uint64_t>(bytes[3]) << 48) |
                      (static_cast<uint64_t>(bytes[4]) << 40) |
                      (static_cast<uint64_t>(bytes[5]) << 32) |
                      (static_cast<uint64_t>(bytes[6]) << 24) |
                      (static_cast<uint64_t>(bytes[7]) << 16) |
                      (static_cast<uint64_t>(bytes[8]) << 8) |
                      static_cast<uint64_t>(bytes[9]);
    }
    
    if (has_mask) {
        header_len += 4;
    }
    
    return static_cast<ssize_t>(header_len + payload_len);
}

正常压测

相关推荐
消失的旧时光-194310 小时前
从 0 开始理解 RPC —— 后端工程师扫盲版
网络·网络协议·rpc
“αβ”11 小时前
网络层协议 -- ICMP协议
linux·服务器·网络·网络协议·icmp·traceroute·ping
wearegogog12311 小时前
基于C#的TCP/IP通信客户端与服务器
服务器·tcp/ip·c#
袁小皮皮不皮12 小时前
数据通信18-网络管理与运维
运维·服务器·网络·网络协议·智能路由器
Vect__14 小时前
UDP原理和极简socket编程demo
网络·网络协议·udp
小锋学长生活大爆炸16 小时前
【教程】查看docker容器的TCP连接和带宽使用情况
tcp/ip·docker·容器
跨境小技17 小时前
如何验证代理IP纯净度?2026年IP检测与优化指南
网络·网络协议·tcp/ip
Trouvaille ~18 小时前
【Linux】UDP协议详解:无连接、不可靠但高效的传输协议
linux·运维·服务器·网络·c++·网络协议·udp
玄斎18 小时前
手把手教你做eNSP动态路由实验
网络·网络协议·学习·网络安全·智能路由器·hcia·ospf
IP搭子来一个18 小时前
Python爬虫代理,选短效IP还是长效IP?
爬虫·python·tcp/ip