学完这篇,你能从零搭起一条「PHP 后端产生消息 → 浏览器无刷新收到并展示」的实时推送链路,包括服务端进程、前端连接、Nginx 反代和上线后的兜底方案。
第一步:先定架构,别指望 PHP-FPM 自己推
PHP 在传统 FPM 模式下是一次性执行:脚本跑完进程就回收,请求和连接一起结束。所以它没法自己保持一条长连接给浏览器推消息。
正确做法是拆成两部分:
- 一个常驻的 WebSocket 服务进程,独立监听端口(比如
127.0.0.1:8282),负责和浏览器保持长连接;
- 你的 PHP 站点照旧跑业务,消息产生时(新回复、新私信、被 @、悬赏被采纳)通过一个内部端口把消息「塞」给 WebSocket 服务,由它转发出去。
第二步:用 Workerman 起 WebSocket 服务
Workerman 是纯 PHP 实现,不需要装扩展、不需要编译,PHP 7.4-8.5 都能跑,对新手最友好。
composer require workerman/workerman
新建 ws/start.php:
<?php
require __DIR__ . '/vendor/autoload.php';
use Workerman\Worker;
$ws = new Worker('websocket://127.0.0.1:8282');
$ws->count = 2; // 2 个进程,够小站用
$clients = []; // uid => connection
$ws->onMessage = function ($conn, $data) use ($ws, &$clients) {
$msg = json_decode($data, true);
if (!$msg) return;
if ($msg['type'] === 'auth') {
// 关键:这里做 token 校验,通过才绑定 uid
$uid = verify_token($msg['token']); // 自己实现
if ($uid) { $conn->uid = $uid; $clients[$uid] = $conn; }
}
if ($msg['type'] === 'ping') $conn->send('{"type":"pong"}');
};
$ws->onClose = function ($conn) use (&$clients) {
if (isset($conn->uid)) unset($clients[$conn->uid]);
};
// 内部投递端口,只允许本机访问
$inner = new Worker('text://127.0.0.1:8283');
$inner->onMessage = function ($conn, $data) use (&$clients) {
$job = json_decode($data, true);
foreach ($job['uids'] as $uid) {
if (isset($clients[$uid])) {
$clients[$uid]->send(json_encode($job['payload']));
}
}
};
Worker::runAll();
启动:
php ws/start.php start -d # -d 后台运行,调试时去掉
注意:两个 Worker 都监听 127.0.0.1,不要写 0.0.0.0。外部访问统一走 Nginx 反代,8282/8283 直接暴露在公网等于把推送口子敞开。
第三步:前端连接并展示
let ws, timer;
function connect() {
ws = new WebSocket('wss://你的域名/wss');
ws.onopen = () => {
// 登录态下发的一次性 token
ws.send(JSON.stringify({ type: 'auth', token: window.wsToken }));
timer = setInterval(() => ws.send('{"type":"ping"}'), 25000);
};
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'pong') return;
showToast(m.title); // 右下角弹一条
bumpBadge(m.unread); // 未读数 +1
prependToList(m); // 插到消息列表顶部
};
ws.onclose = () => { clearInterval(timer); setTimeout(connect, 3000); };
}
connect();
展示层就三件事:提示条、红点未读数、列表顶部插入。别做复杂了,先跑通再说。
第四步:PHP 侧把消息投进去
写一个全局函数,业务代码里随手调用:
function ws_push(array $uids, array $payload): void
{
if (!$uids) return;
$fp = @stream_socket_client('tcp://127.0.0.1:8283', $errno, $errstr, 1);
if (!$fp) return; // 推不动就算了,绝不能拖垮主流程
fwrite($fp, json_encode(['uids' => $uids, 'payload' => $payload]) . "\n");
fclose($fp);
}
调用位置就是原来「生成站内通知」的地方:写入通知表之后,紧接着 ws_push([$toUid], [...])。如果你用的是 Clara BBS 这类带运行时插件钩子的系统,可以在插件里挂到通知产生的位置,保存即生效、不用清缓存;定时清理任务也能用它的 Cron::register 顺手注册。
注意:stream_socket_client 一定要加超时(1 秒)和 @ 抑制,并做失败静默。WebSocket 服务挂了不该导致发帖失败——这是实时推送和核心业务的分界线。
第五步:Nginx 反代出 wss
在站点配置里加:
location /wss {
proxy_pass http://127.0.0.1:8282;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
宝塔用户在「网站 → 设置 → 配置文件」里粘贴即可,重载后生效。
注意:https 页面里必须连 wss://,连 ws:// 会被浏览器以混合内容为由直接拦截。另外三个 proxy_set_header 少任何一个都会握手失败,报 400 或反复重连。
第六步:上线前的兜底
- 进程守护:宝塔「进程守护」或 supervisor 托管
php start.php start,否则重启服务器就断。
- 降级轮询:
onclose 连续重连失败 3 次后,切回 AJAX 每 30 秒拉一次未读数,页面不至于变哑巴。
- 鉴权别偷懒:token 用登录态签发、限时一次性,服务端反查 uid。绝不能信前端传来的
uid——改个数字就能收别人私信。
小结
- PHP-FPM 不能自己保持长连接,必须拆出独立的常驻 WebSocket 进程。
- Workerman 纯 PHP、免扩展,是新手最快的起步方案;两个端口分工:8282 对外、8283 对内。
- 前端只需做三件事:弹提示、加未读、插列表,外加心跳和断线重连。
- PHP 侧一个
ws_push() 函数搞定投递,超时 + 静默失败是硬要求。
- Nginx 反代必须带
Upgrade / Connection 头,https 站点只能连 wss://。
- 身份校验放在服务端 token 上,前端传的 uid 一律不信。