TCP 服务器如何自定义协议解析?
解读
国内高并发业务(电商秒杀、IM、物联网网关)普遍要求 PHP 服务常驻内存、不依赖 FPM。面试官想确认三件事:
- 是否理解 TCP 字节流与“拆包/粘包”本质;
- 能否用 PHP 写出“边收边判、边拆边解”的有限状态机;
- 是否具备协议设计能力(可扩展、可升级、可灰度)。
回答时先给出“协议格式规范”,再给出“接收缓冲区状态机”,最后落地到 Workerman/Swoole 代码骨架,并量化性能与异常处理,基本就能拿到高分。
知识点
- TCP 字节流特性:无消息边界,需应用层自断帧。
- 常见断帧策略:固定长度、固定头+变长体、分隔符、TLV(Tag-Length-Value)。
- PHP 常驻进程工具:Swoole(协程)、Workerman(多进程)、ReactPHP(Stream)。
- 接收缓冲区:使用静态变量或连接级对象缓存半包数据。
- 协议升级:版本号字段、向后兼容的 optional 字段、CRC/校验和、心跳机制。
- 异常与攻击防护:最大包长、最大并发、超时丢弃、恶意数据拉黑。
- 性能优化:
- 减少 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 升到版本 2(新增字段、加密标志、压缩标志)?
答:在头部预留 1 字节 flag,用于指示“是否压缩”“是否加密”;老版本收到不认识的 flag 直接走降级逻辑。 - 大文件上传:如果 JSON 里带二进制,如何防止内存暴涨?
答:采用两段式协议——先传元数据(大小、sha256),客户端再分片上传,服务端用临时文件拼接,完成后校验。 - 心跳与掉线:服务端多久没收到包视为掉线?
答:国内 4G/5G 弱网场景建议 45 秒无数据即触发心跳探测,连续两次无回包就 close,释放连接池。 - 安全:如何防止“重放攻击”?
答:在 JSON 里带 timestamp + nonce,服务端用 Redis 做 60 秒幂等窗口,超期或重复 nonce 直接拒绝。 - 性能极限:PHP 常驻进程到底能撑多少并发?
答:在 4 核 8G 的阿里云 ECS 上,Workerman 多进程模型可稳定 20 万连接、5 万 QPS(Echo 协议),CPU 约 60%;瓶颈主要在 Linux 内核参数(ulimit、somaxconn、tcp_mem)与业务复杂度,而非 PHP 本身。