技术 · 思考 · 实践 · 成长

首页/Swoole/正文

ThinkPHP 5 + Swoole 实现博客实时聊天:WebSocket、离线留言与后台回复

Swoole12 次阅读

本文记录一套不依赖 AI Token 的博客实时聊天系统:访客在博客右侧发起咨询,站长在 ThinkPHP 后台实时接收和回复;站长离线或 WebSocket 暂时不可用时,消息仍通过 HTTP 保存到 MySQL。

示例采用“现有 ThinkPHP 网站 + 独立 Swoole WebSocket 服务”的方式。两者互不绑定:聊天服务停止不会影响博客访问,传统 PHP-FPM 也不需要改成长驻进程模式。

一、需求与成功标准

  • 前台右侧显示轻量聊天窗口,手机端自动适配。
  • 访客不登录也能留言,但身份 Token 不能被随意伪造。
  • 站长登录原有后台后查看会话、未读消息并实时回复。
  • WebSocket 断开时自动使用 HTTP 保存消息。
  • 刷新页面后可以加载历史记录。
  • Swoole 只监听本机端口,由 Nginx 提供 HTTPS/WSS。
  • 限制消息长度和发送频率,所有输出按纯文本处理。
  • 聊天服务故障或重启不能影响博客主站。

二、整体架构

访客浏览器
  ├─ HTTPS:创建会话、历史记录、离线留言
  └─ WSS:实时发送与接收消息
           ↓
       Nginx 443
  ├─ PHP 请求 → PHP-FPM → ThinkPHP
  └─ /chat-ws → 127.0.0.1:9502 → Swoole
                                      ↓
                                    MySQL
                                      ↑
ThinkPHP 管理后台 ← HTTPS/WSS ← 管理员浏览器

各组件职责如下:

  • ThinkPHP:创建访客会话、后台登录鉴权、历史记录、HTTP 兜底接口。
  • Swoole:维护 WebSocket 长连接、在线状态和实时消息推送。
  • MySQL:保存会话、消息和短期管理员 WebSocket Token。
  • Nginx:复用站点证书,把 wss://blog.example.com/chat-ws 代理到本机 Swoole。
  • systemd:守护 Swoole,异常退出后自动重启,并设置开机启动。

三、版本选择与兼容性

本次环境是 PHP 7.3,因此使用与旧 PHP 兼容的 Swoole 4.8.13,并且只在 PHP CLI 配置中加载扩展。这样不会改动正在运行的 PHP-FPM。

新项目不要直接照搬这个版本。应优先升级到受支持的 PHP 版本,再根据官方兼容矩阵选择当前 Swoole 或 OpenSwoole。旧版本方案只适用于暂时不能升级的存量项目,同时要限制端口、控制权限并制定后续升级计划。

安装前检查环境:

php -v
php --ini
php -m | grep -Ei 'swoole|openswoole'
free -h
/www/server/nginx/sbin/nginx -v

四、安装 Swoole 扩展

PHP 7.3 示例:

/path/to/php/bin/pecl install swoole-4.8.13

只在 CLI 使用时,将下面内容写入 CLI 专用的 php-cli.ini:

extension = swoole.so

验证扩展:

/path/to/php/bin/php -m | grep -i '^swoole$'
/path/to/php/bin/php --ri swoole

如果网站的 CLI 与 PHP-FPM 使用不同配置文件,不要误改。可以通过 php --ini 确认实际加载路径。

五、设计数据库表

第一版只需要三张表:

  • chat_sessions:访客会话和状态。
  • chat_messages:访客及管理员消息。
  • chat_admin_tokens:后台生成的短期 WebSocket 鉴权 Token。
CREATE TABLE `chat_sessions` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `public_id` CHAR(32) NOT NULL,
  `visitor_token_hash` CHAR(64) NOT NULL,
  `visitor_name` VARCHAR(50) NOT NULL,
  `ip` VARCHAR(45) NOT NULL DEFAULT '',
  `user_agent` VARCHAR(255) NOT NULL DEFAULT '',
  `status` TINYINT UNSIGNED NOT NULL DEFAULT 1,
  `last_message_at` DATETIME NOT NULL,
  `created_at` DATETIME NOT NULL,
  `updated_at` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_public_id` (`public_id`),
  UNIQUE KEY `uk_visitor_token_hash` (`visitor_token_hash`),
  KEY `idx_status_last_message` (`status`, `last_message_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `chat_messages` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `session_id` BIGINT UNSIGNED NOT NULL,
  `sender_type` VARCHAR(16) NOT NULL,
  `content` TEXT NOT NULL,
  `is_read` TINYINT UNSIGNED NOT NULL DEFAULT 0,
  `created_at` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_session_id_id` (`session_id`, `id`),
  KEY `idx_session_sender_read`
    (`session_id`, `sender_type`, `is_read`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `chat_admin_tokens` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `token_hash` CHAR(64) NOT NULL,
  `admin_id` INT UNSIGNED NOT NULL,
  `expires_at` DATETIME NOT NULL,
  `created_at` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_token_hash` (`token_hash`),
  KEY `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

数据库表存在前缀时,应按项目配置统一添加。例如 ThinkPHP 表前缀为 admin_,实际表名可能是 admin_chat_sessions。

六、为什么只保存 Token 哈希

浏览器保存 64 位随机 Token,数据库只保存:

$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);

客户端连接时提交原始 Token,服务端重新计算哈希后查询。即使数据库被只读泄露,也不能直接拿表中的哈希建立聊天连接。

七、ThinkPHP 前台接口

新建 application/index/controller/Chat.php。它不继承加载大量侧栏数据的公共控制器,避免每次 AJAX 请求执行无关查询。

1. 创建或恢复访客会话

<?php
namespace app\index\controller;

use think\Controller;
use think\Db;

class Chat extends Controller
{
    public function bootstrap()
    {
        if (!request()->isPost()) {
            return json(['status' => 0, 'msg' => '请求方式错误']);
        }

        $token = trim((string)input('post.token'));
        $session = $this->findSessionByToken($token);

        if (!$session) {
            $token = bin2hex(random_bytes(32));
            $now = date('Y-m-d H:i:s');
            $publicId = bin2hex(random_bytes(16));

            $sessionId = Db::name('chat_sessions')->insertGetId([
                'public_id' => $publicId,
                'visitor_token_hash' => hash('sha256', $token),
                'visitor_name' => '访客' . strtoupper(substr($publicId, 0, 6)),
                'ip' => request()->ip(),
                'user_agent' => mb_substr(
                    (string)request()->header('user-agent'), 0, 255
                ),
                'status' => 1,
                'last_message_at' => $now,
                'created_at' => $now,
                'updated_at' => $now,
            ]);

            $session = Db::name('chat_sessions')
                ->where('id', $sessionId)->find();
        }

        $messages = Db::name('chat_messages')
            ->where('session_id', $session['id'])
            ->field('id,sender_type,content,is_read,created_at')
            ->order('id desc')
            ->limit(80)
            ->select();

        return json([
            'status' => 1,
            'token' => $token,
            'session' => [
                'id' => $session['public_id'],
                'name' => $session['visitor_name'],
            ],
            'messages' => array_reverse($messages ?: []),
            'websocket' => 'wss://' . request()->host() . '/chat-ws',
        ]);
    }

    private function findSessionByToken($token)
    {
        if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
            return null;
        }

        return Db::name('chat_sessions')
            ->where('visitor_token_hash', hash('sha256', $token))
            ->find();
    }
}

2. HTTP 离线兜底

WebSocket 不可用时,前端调用普通 HTTP 接口。服务端必须再次验证 Token、消息长度和频率,不能只依赖 JavaScript 校验。

public function send()
{
    if (!request()->isPost()) {
        return json(['status' => 0, 'msg' => '请求方式错误']);
    }

    $token = trim((string)input('post.token'));
    $content = trim((string)input('post.content'));
    $session = $this->findSessionByToken($token);

    if (!$session || (int)$session['status'] !== 1) {
        return json(['status' => 0, 'msg' => '会话已失效']);
    }

    if ($content === '' || mb_strlen($content) > 1000) {
        return json(['status' => 0, 'msg' => '消息应为 1~1000 字']);
    }

    $now = date('Y-m-d H:i:s');
    $id = Db::name('chat_messages')->insertGetId([
        'session_id' => $session['id'],
        'sender_type' => 'visitor',
        'content' => $content,
        'is_read' => 0,
        'created_at' => $now,
    ]);

    Db::name('chat_sessions')->where('id', $session['id'])->update([
        'last_message_at' => $now,
        'updated_at' => $now,
    ]);

    return json([
        'status' => 1,
        'message' => [
            'id' => $id,
            'sender_type' => 'visitor',
            'content' => $content,
            'created_at' => $now,
        ],
    ]);
}

八、后台生成短期 WebSocket Token

不能把 ThinkPHP 管理员 Session 直接交给独立的 Swoole 服务。后台先通过已登录的 HTTP 请求生成短期 Token,Swoole 再读取数据库验证。

public function socketToken()
{
    if (!request()->isPost()) {
        return json(['status' => 0, 'msg' => '请求方式错误']);
    }

    $token = bin2hex(random_bytes(32));
    $now = date('Y-m-d H:i:s');

    Db::name('chat_admin_tokens')
        ->where('expires_at', '<', $now)
        ->delete();

    Db::name('chat_admin_tokens')->insert([
        'token_hash' => hash('sha256', $token),
        'admin_id' => (int)session('id'),
        'expires_at' => date('Y-m-d H:i:s', time() + 43200),
        'created_at' => $now,
    ]);

    return json([
        'status' => 1,
        'token' => $token,
        'websocket' => 'wss://' . request()->host() . '/chat-ws',
    ]);
}

该方法所在控制器必须继承现有后台公共控制器,由公共控制器先检查管理员登录 Session。

九、Swoole WebSocket 服务

服务只监听 127.0.0.1:9502,不允许公网直接访问。下面展示核心实现,实际项目还应增加日志、异常处理与监控。

<?php
$config = require dirname(__DIR__) . '/config/database.php';
$pdo = null;
$clients = [];
$visitorFds = [];
$adminFds = [];

function db(&$pdo, array $config)
{
    if ($pdo instanceof PDO) {
        try {
            $pdo->query('SELECT 1');
            return $pdo;
        } catch (Throwable $e) {
            $pdo = null;
        }
    }

    $port = $config['hostport'] ?: 3306;
    $dsn = "mysql:host={$config['hostname']};port={$port};" .
           "dbname={$config['database']};charset=utf8mb4";

    $pdo = new PDO($dsn, $config['username'], $config['password'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);

    return $pdo;
}

function pushJson($server, $fd, array $data)
{
    if ($server->isEstablished($fd)) {
        $server->push(
            $fd,
            json_encode($data, JSON_UNESCAPED_UNICODE)
        );
    }
}

$server = new Swoole\WebSocket\Server('127.0.0.1', 9502);
$server->set([
    'worker_num' => 1,
    'max_conn' => 512,
    'heartbeat_check_interval' => 30,
    'heartbeat_idle_time' => 90,
    'log_file' => dirname(__DIR__) . '/runtime/log/chat-swoole.log',
]);

$server->on('Open', function ($server, $request) use (&$clients) {
    $origin = strtolower(rtrim($request->header['origin'] ?? '', '/'));
    $allowed = [
        'https://blog.example.com',
        'https://example.com',
    ];

    if ($origin !== '' && !in_array($origin, $allowed, true)) {
        $server->disconnect($request->fd, 1008, 'Origin denied');
        return;
    }

    $clients[$request->fd] = ['role' => 'guest'];
});

1. 首帧完成身份认证

Token 不放在 WebSocket URL 查询参数中,避免进入代理访问日志。连接建立后,客户端发送第一条认证消息:

socket.send(JSON.stringify({
  type: 'auth',
  role: 'visitor',
  token: visitorToken
}));

Swoole 端验证访客:

if ($data['type'] === 'auth' && $data['role'] === 'visitor') {
    $stmt = $db->prepare(
        'SELECT id,status FROM chat_sessions ' .
        'WHERE visitor_token_hash=? LIMIT 1'
    );
    $stmt->execute([hash('sha256', $data['token'])]);
    $session = $stmt->fetch();

    if (!$session || (int)$session['status'] !== 1) {
        $server->disconnect($frame->fd, 1008, 'Session unavailable');
        return;
    }

    $clients[$frame->fd] = [
        'role' => 'visitor',
        'session_id' => (int)$session['id'],
    ];
    $visitorFds[(int)$session['id']][$frame->fd] = true;

    pushJson($server, $frame->fd, [
        'type' => 'ready',
        'admin_online' => !empty($adminFds),
    ]);
}

2. 保存并推送访客消息

if ($client['role'] === 'visitor') {
    $sessionId = (int)$client['session_id'];
    $content = trim((string)$data['content']);

    if ($content === '' || mb_strlen($content) > 1000) {
        pushJson($server, $frame->fd, [
            'type' => 'error',
            'message' => '消息应为 1~1000 字',
        ]);
        return;
    }

    $now = date('Y-m-d H:i:s');
    $stmt = $db->prepare(
        'INSERT INTO chat_messages ' .
        '(session_id,sender_type,content,is_read,created_at) ' .
        'VALUES (?,?,?,?,?)'
    );
    $stmt->execute([$sessionId, 'visitor', $content, 0, $now]);

    $message = [
        'id' => (int)$db->lastInsertId(),
        'sender_type' => 'visitor',
        'content' => $content,
        'created_at' => $now,
    ];

    pushJson($server, $frame->fd, [
        'type' => 'message',
        'session_id' => $sessionId,
        'message' => $message,
    ]);

    foreach ($adminFds as $adminFd => $_) {
        pushJson($server, $adminFd, [
            'type' => 'message',
            'session_id' => $sessionId,
            'message' => $message,
        ]);
    }
}

3. 关闭连接时清理映射

$server->on('Close', function ($server, $fd)
    use (&$clients, &$visitorFds, &$adminFds) {

    $client = $clients[$fd] ?? null;

    if ($client && $client['role'] === 'visitor') {
        $sessionId = (int)$client['session_id'];
        unset($visitorFds[$sessionId][$fd]);
        if (empty($visitorFds[$sessionId])) {
            unset($visitorFds[$sessionId]);
        }
    }

    if ($client && $client['role'] === 'admin') {
        unset($adminFds[$fd]);
    }

    unset($clients[$fd]);
});

$server->start();

十、前台聊天组件

组件在公共页脚引入,因此文章页、首页和分类页可以共用:

{include file="public/chat" /}

聊天组件 HTML:

<div class="blog-chat" id="blogChat"
     data-bootstrap-url="{:url('chat/bootstrap')}"
     data-send-url="{:url('chat/send')}"
     data-history-url="{:url('chat/history')}">
  <button id="blogChatToggle" type="button">
    在线咨询
  </button>

  <section id="blogChatPanel" hidden>
    <header>
      <strong>在线咨询</strong>
      <span id="blogChatStatus">点击开始咨询</span>
    </header>

    <div id="blogChatMessages" aria-live="polite"></div>

    <form id="blogChatForm">
      <textarea id="blogChatInput"
                maxlength="1000"
                placeholder="输入消息…"></textarea>
      <button type="submit">发送</button>
    </form>
  </section>
</div>

只在用户点击后创建会话

不要在页面加载时立即调用 bootstrap,否则每次访问都会产生没有消息的空会话。

let initialized = false;

function openPanel() {
  panel.hidden = false;

  if (!initialized) {
    bootstrapChat();
  }
}

async function bootstrapChat() {
  const token = localStorage.getItem('blog_chat_token') || '';
  const response = await fetch(root.dataset.bootstrapUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
    },
    body: new URLSearchParams({ token }),
    credentials: 'same-origin'
  });

  const result = await response.json();
  localStorage.setItem('blog_chat_token', result.token);
  initialized = true;
  connectWebSocket(result.websocket, result.token);
}

WebSocket 失败时使用 HTTP

async function sendMessage(content) {
  if (socketReady && socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({
      type: 'message',
      content
    }));
    return;
  }

  await fetch(root.dataset.sendUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
    },
    body: new URLSearchParams({
      token: localStorage.getItem('blog_chat_token'),
      content
    }),
    credentials: 'same-origin'
  });
}

渲染消息时不要使用 innerHTML 拼接访客内容,应使用 textContent,防止存储型 XSS:

const text = document.createElement('div');
text.textContent = message.content;
bubble.appendChild(text);

十一、后台聊天页面

后台页面可以采用左右两栏布局:

左侧:会话列表
  ├─ 访客名称
  ├─ 最后一条消息
  ├─ 未读数量
  └─ 最后咨询时间

右侧:当前会话
  ├─ 访客信息
  ├─ 历史消息
  ├─ 回复输入框
  └─ 关闭会话

后台同时连接 WebSocket 和定时轮询:

  • WebSocket 正常时实时显示消息。
  • 每隔数秒刷新会话列表,防止错过 HTTP 离线留言。
  • WebSocket 失败时,管理员回复走 HTTP 接口。
  • 选择会话后,把访客消息更新为已读。

十二、配置 Nginx WSS 反向代理

将以下配置放入 HTTPS 站点的 server 中:

location = /chat-ws {
    proxy_pass http://127.0.0.1:9502;
    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header Origin $http_origin;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 100s;
    proxy_send_timeout 100s;
    proxy_buffering off;
}

检查配置后再重新加载:

nginx -t
nginx -s reload

Swoole 只绑定 127.0.0.1,不需要开放 9502 公网防火墙端口。

十三、使用 systemd 守护服务

创建 /etc/systemd/system/blog-chat.service:

[Unit]
Description=Blog WebSocket Chat
After=network.target mysqld.service

[Service]
Type=simple
User=www
Group=www
WorkingDirectory=/www/wwwroot/blog.example.com
ExecStart=/path/to/php/bin/php \
  -c /path/to/php-cli.ini \
  /www/wwwroot/blog.example.com/chat-server/server.php
Restart=on-failure
RestartSec=3
LimitNOFILE=4096
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/www/wwwroot/blog.example.com/runtime

[Install]
WantedBy=multi-user.target

启动并检查:

systemctl daemon-reload
systemctl enable --now blog-chat.service
systemctl status blog-chat.service
ss -lntp | grep 9502
journalctl -u blog-chat.service -n 100 --no-pager

十四、心跳、重连与在线状态

浏览器每隔 25 秒发送心跳:

setInterval(() => {
  if (socketReady && socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({ type: 'ping' }));
  }
}, 25000);

Swoole 配置空闲连接检测:

$server->set([
    'heartbeat_check_interval' => 30,
    'heartbeat_idle_time' => 90,
]);

连接关闭后使用递增或固定延时重新连接,避免网络波动时高频重试。管理员连接数量从 0 变为 1 时,可以向访客推送在线状态;最后一个管理员断开时,再推送离线状态。

十五、限流与安全设计

  • Swoole 只监听 127.0.0.1。
  • WebSocket 握手后必须先认证,未认证连接不能发消息。
  • 检查 Origin,只允许自己的 HTTPS 域名。
  • 访客 Token 至少使用 32 字节安全随机数,数据库只存哈希。
  • 管理员 Token 短期有效并定时清理。
  • 单条消息限制为 1000 字,服务端再次校验。
  • 按连接和 IP 限制发送频率。
  • 消息使用参数化 SQL,不拼接用户输入。
  • 前端使用 textContent,不直接渲染用户 HTML。
  • 后台接口必须经过原有管理员 Session 鉴权。
  • 数据库、日志和聊天记录按最小权限管理。

十六、部署顺序

  1. 备份即将修改的模板、Nginx 和 PHP CLI 配置。
  2. 安装并验证 Swoole CLI 扩展。
  3. 创建聊天数据表。
  4. 部署 ThinkPHP 前后台控制器和视图。
  5. 部署前端 CSS、JavaScript 和 Swoole 服务文件。
  6. 执行 php -l 检查 PHP 语法。
  7. 执行 nginx -t 检查代理配置。
  8. 启动 systemd 服务,再重新加载 Nginx。
  9. 完成 HTTPS、WSS、离线留言和后台鉴权测试。
  10. 删除自动化测试产生的会话和消息。

十七、端到端测试

1. 检查 HTTP 页面

curl -I https://blog.example.com/
curl -I https://blog.example.com/static/index/js/chat.js

2. 检查服务

systemctl is-active blog-chat.service
ss -lntp | grep 9502
nginx -t

3. 浏览器测试

  • 打开文章页,确认没有点击聊天时不会创建会话。
  • 点击聊天按钮后发送消息,后台应立即出现。
  • 后台回复,访客端应实时显示。
  • 停止 Swoole 后发送消息,应通过 HTTP 保存。
  • 重新启动 Swoole,刷新页面后历史消息仍在。
  • 退出管理员登录后,后台聊天接口必须拒绝访问。
  • 手机端检查弹层宽度、输入框和键盘遮挡。

十八、常见问题

1. 浏览器连接返回 400 或 502

检查 Nginx 是否传递 Upgrade 和 Connection 头,Swoole 是否监听 9502,以及 proxy_pass 地址是否正确。

2. PHP 显示没有 Swoole 扩展

确认运行服务时使用的是哪个 PHP 二进制和哪个 php.ini。CLI 与 PHP-FPM 很可能不是同一个配置文件。

3. 服务启动后立即退出

systemctl status blog-chat.service
journalctl -u blog-chat.service -n 100 --no-pager
php -l chat-server/server.php

4. 每次访问文章都产生空会话

原因通常是页面加载后立即调用创建会话接口。应改成用户第一次展开聊天窗口时再初始化。

5. 多 worker 后消息推送失败

示例使用单 worker,因此内存中的 FD 映射在同一进程。扩展到多 worker 或多台服务器时,应使用 Redis、Swoole Table、进程间消息或专门的消息总线管理在线连接,不能继续依赖单进程数组。

十九、后续扩展

  • Redis 管理在线状态和多 worker 消息分发。
  • 多客服分配、转接和客服在线状态。
  • 浏览器通知、声音提醒和邮件提醒。
  • 图片上传,但必须限制类型、大小和访问权限。
  • 敏感词、黑名单和验证码。
  • 会话搜索、备注、标签和统计。
  • 消息保留周期、匿名化和隐私说明。

二十、回滚方法

上线前保存所有被修改文件。需要回滚时:

systemctl disable --now blog-chat.service
rm /etc/systemd/system/blog-chat.service
systemctl daemon-reload

# 恢复备份的 Nginx、模板和 CLI 配置
nginx -t && nginx -s reload

聊天表中可能已经有真实访客数据,不应直接执行 DROP TABLE。先导出聊天表备份,再根据数据保留要求决定归档或删除。

二十一、官方文档

二十二、总结

这套方案把传统 ThinkPHP 请求、Swoole 长连接和 MySQL 持久化分开:ThinkPHP 负责业务与权限,Swoole 负责实时传输,HTTP 负责断线兜底,Nginx 统一提供 HTTPS/WSS,systemd 负责守护进程。

第一版保持单 worker 和纯文本消息,代码少、资源占用低,也容易回滚。访问量增加后,再引入 Redis 和多进程分发,而不是一开始就把系统设计得过于复杂。

再长的路,一步步也能走完,再短的路,不迈开双脚也无法到达

评论交流

0 条评论 · 支持多级回复
验证码
暂无评论,来说说你的想法吧

相关文章

继续阅读