TCP 服务器如何自定义协议解析?

解读

国内高并发业务(电商秒杀、IM、物联网网关)普遍要求 PHP 服务常驻内存、不依赖 FPM。面试官想确认三件事:

  1. 是否理解 TCP 字节流与“拆包/粘包”本质;
  2. 能否用 PHP 写出“边收边判、边拆边解”的有限状态机;
  3. 是否具备协议设计能力(可扩展、可升级、可灰度)。
    回答时先给出“协议格式规范”,再给出“接收缓冲区状态机”,最后落地到 Workerman/Swoole 代码骨架,并量化性能与异常处理,基本就能拿到高分。

知识点

  1. TCP 字节流特性:无消息边界,需应用层自断帧。
  2. 常见断帧策略:固定长度、固定头+变长体、分隔符、TLV(Tag-Length-Value)。
  3. PHP 常驻进程工具:Swoole(协程)、Workerman(多进程)、ReactPHP(Stream)。
  4. 接收缓冲区:使用静态变量或连接级对象缓存半包数据。
  5. 协议升级:版本号字段、向后兼容的 optional 字段、CRC/校验和、心跳机制。
  6. 异常与攻击防护:最大包长、最大并发、超时丢弃、恶意数据拉黑。
  7. 性能优化:
    • 减少 memcpy:unpack 直接读内存;
    • 使用 pack 格式字符 “N”“n”“C” 而非 substr+hex;
    • 开启 TCP_NODELAY 降低小包延迟;
    • 在 Swoole 中启用 open_length_check / package_max_length 让底层 C 层拆包,PHP 只负责解析。

答案

下面以“2 字节魔数 + 1 字节版本 + 4 字节大端长度 + N 字节 JSON 负载”协议为例,给出 Workerman 下的最小可运行骨架,并演示拆包、解包、回包、异常处理全流程。生产环境可直接扩展为 Protobuf、MessagePack 等二进制格式。

<?php
require_once __DIR__ . '/vendor/autoload.php';
use Workerman\Worker;

$tcp_worker = new Worker('tcp://0.0.0.0:9501');
$tcp_worker->count = 4;                       // 4 进程
$tcp_worker->maxPackageSize = 2 * 1024 * 1024; // 2M 保护

// 连接级缓冲区
$tcp_worker->onConnect = function($connection) {
    $connection->buffer = '';                  // 半包数据
};

$tcp_worker->onMessage = function($connection, $data) {
    // 追加到缓冲区
    $connection->buffer .= $data;

    while (true) {
        $buf = $connection->buffer;
        $len = strlen($buf);

        // 1. 至少 7 字节头部
        if ($len < 7) break;

        // 2. 验证魔数 0x88 0x77
        if ($buf[0] !== "\x88" || $buf[1] !== "\x77") {
            // 非法连接直接断开,防止恶意探测
            $connection->close();
            return;
        }

        // 3. 版本号(后续可灰度升级)
        $ver = ord($buf[2]);
        if ($ver !== 1) {
            // 版本不支持,返回错误包后断开
            sendError($connection, 0x01, 'version not supported');
            $connection->close();
            return;
        }

        // 4. 读取 4 字节大端长度
        $bodyLen = unpack('N', substr($buf, 3, 4))[1];
        if ($bodyLen > 2 * 1024 * 1024) {
            // 超长包直接断开
            $connection->close();
            return;
        }

        // 5. 判断完整包
        if ($len < 7 + $bodyLen) break;

        // 6. 提取 JSON 负载
        $json = substr($buf, 7, $bodyLen);
        $payload = json_decode($json, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            sendError($connection, 0x02, 'invalid json');
            $connection->close();
            return;
        }

        // 7. 业务处理(伪代码)
        $resp = handleBusiness($payload);

        // 8. 回包:魔数+版本+长度+JSON
        $respJson = json_encode($resp, JSON_UNESCAPED_UNICODE);
        $header = pack('CCNn', 0x88, 0x77, 1, strlen($respJson));
        $connection->send($header . $respJson);

        // 9. 移除已处理数据
        $connection->buffer = substr($buf, 7 + $bodyLen);
    }
};

function sendError($connection, $code, $msg) {
    $body = json_encode(['code' => $code, 'msg' => $msg]);
    $header = pack('CCNn', 0x88, 0x77, 1, strlen($body));
    $connection->send($header . $body);
}

function handleBusiness($payload) {
    // 示例:echo 服务
    return ['echo' => $payload['data'] ?? ''];
}

Worker::runAll();

运行 php tcp.php start -d 即可在 9501 端口提供自定义协议服务。
若使用 Swoole,可直接在 onReceive 里用 open_length_check 让底层完成拆包,PHP 仅聚焦解析与业务,性能可再提升 30%+。

拓展思考

  1. 协议升级:如何在不停服的情况下把版本 1 升到版本 2(新增字段、加密标志、压缩标志)?
    答:在头部预留 1 字节 flag,用于指示“是否压缩”“是否加密”;老版本收到不认识的 flag 直接走降级逻辑。
  2. 大文件上传:如果 JSON 里带二进制,如何防止内存暴涨?
    答:采用两段式协议——先传元数据(大小、sha256),客户端再分片上传,服务端用临时文件拼接,完成后校验。
  3. 心跳与掉线:服务端多久没收到包视为掉线?
    答:国内 4G/5G 弱网场景建议 45 秒无数据即触发心跳探测,连续两次无回包就 close,释放连接池。
  4. 安全:如何防止“重放攻击”?
    答:在 JSON 里带 timestamp + nonce,服务端用 Redis 做 60 秒幂等窗口,超期或重复 nonce 直接拒绝。
  5. 性能极限:PHP 常驻进程到底能撑多少并发?
    答:在 4 核 8G 的阿里云 ECS 上,Workerman 多进程模型可稳定 20 万连接、5 万 QPS(Echo 协议),CPU 约 60%;瓶颈主要在 Linux 内核参数(ulimit、somaxconn、tcp_mem)与业务复杂度,而非 PHP 本身。