Joyber 发布的文章

Swoole WebSocket 并发架构与实战:从原理到部署

引言

使用 Swoole 开发 WebSocket 服务时,很多从传统 PHP-FPM 转过来的开发者会有一个疑问:每个 WebSocket 连接是否对应一个独立的进程或线程? 答案是否定的。Swoole 采用“多进程 + 协程”的架构,能在一个极小的资源开销下支撑数十万乃至百万级并发连接。本文将深入解析 Swoole 的并发模型,并通过一个完整的多人聊天室示例,带你从零开始构建高性能 WebSocket 服务。最后,我们将探讨在生产环境中如何调整系统限制,以确保服务器能承载预期的连接数。


一、Swoole 的并发模型:进程、线程与协程

1.1 传统 PHP 的局限

在 PHP-FPM 模式下,每个请求都会启动一个进程,处理完即销毁。这种方式无法维持长连接,更不可能同时处理数万连接,因为进程创建和内存开销极大。

1.2 Swoole 的多进程 + 协程架构

Swoole 的 WebSocket 服务器在启动时会创建固定数量的 Worker 进程(例如 8 个),这些进程常驻内存,负责处理所有连接的业务逻辑。每个连接不是独占一个进程,而是通过 协程(Coroutine)来实现并发。

核心组件:

  • Master 进程:负责监听端口、管理 Reactor 线程和 Worker 进程。
  • Reactor 线程池:负责网络 I/O 的读写,将数据分发给 Worker 进程。
  • Worker 进程池:固定的进程数,处理业务逻辑(解析消息、数据库操作、广播等)。
  • 协程:在每个 Worker 进程内,每个连接对应一个协程。协程的切换开销远小于线程或进程,几 KB 的内存即可承载一个协程,从而让单个 Worker 进程可同时处理数千连接。

下面这张图清晰地展示了客户端连接如何在 Swoole 内部流转:

graph TD
    subgraph Client [客户端]
        C1[客户端 1]
        C2[客户端 2]
        C3[客户端 3]
        C4[客户端 ...]
    end

    subgraph SwooleServer [Swoole WebSocket 服务器]
        direction LR
        Master[主进程<br>Master Process<br>负责创建和管理子进程]
        
        subgraph Reactor [Reactor 线程池]
            R1[Reactor 线程]
            R2[Reactor 线程]
            R3[Reactor 线程]
        end
        
        subgraph Workers [Worker 进程池]
            direction TB
            W1[Worker 进程 1<br>协程1 协程2 ...]
            W2[Worker 进程 2<br>协程1 协程2 ...]
            W3[Worker 进程 N<br>协程1 协程2 ...]
        end
    end

    C1 ---> R1
    C2 ---> R2
    C3 ---> R3
    C4 ---> R1
    
    R1 --> W1
    R2 --> W2
    R3 --> W3

当某个连接需要等待 I/O(如数据库查询)时,Swoole 会自动挂起该协程,Worker 进程转而去处理其他协程的任务,待 I/O 完成后再恢复执行。这种机制使得 CPU 利用率极高,且无需开发者手动处理异步回调。

1.3 对比总结

特性误解:每个连接一个线程/进程实际:Swoole 架构
连接与进程关系1 个连接 = 1 个进程/线程多个连接共享固定数量 Worker 进程
并发实现方式通过大量进程/线程通过少量进程 + 大量协程
内存开销极高(每个线程几 MB)极低(每个协程几 KB)
最大连接数受进程/线程数限制(通常几千)可达数十万甚至百万
典型代表PHP-FPM, ApacheSwoole, Nginx, Node.js

二、实战:用 Swoole 开发一个多人聊天室

下面我们构建一个简单的聊天室,所有连接的用户都能看到彼此发送的消息。

2.1 服务端代码 chat_server.php

<?php
use Swoole\WebSocket\Server;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;

// 创建 WebSocket 服务器,监听 0.0.0.0:9501
$server = new Server("0.0.0.0", 9501);

// 使用 Swoole\Table 存储所有客户端连接的文件描述符(fd),以便多进程共享
$connections = new Swoole\Table(1024);
$connections->column('fd', Swoole\Table::TYPE_INT);
$connections->create();

// 监听 WebSocket 连接打开事件
$server->on('open', function (Server $server, Request $request) use ($connections) {
    $fd = $request->fd;
    $connections->set((string)$fd, ['fd' => $fd]);
    echo "新连接: fd={$fd}\n";

    // 欢迎消息
    $server->push($fd, "欢迎加入聊天室!");
    // 通知其他人
    foreach ($connections as $conn) {
        if ($conn['fd'] != $fd) {
            $server->push($conn['fd'], "用户 {$fd} 进入房间");
        }
    }
});

// 监听 WebSocket 消息事件
$server->on('message', function (Server $server, Frame $frame) use ($connections) {
    $fd = $frame->fd;
    $data = $frame->data;
    echo "收到消息: fd={$fd}, data={$data}\n";

    // 广播给所有客户端
    foreach ($connections as $conn) {
        $server->push($conn['fd'], "用户 {$fd} 说: {$data}");
    }
});

// 监听 WebSocket 连接关闭事件
$server->on('close', function (Server $server, int $fd) use ($connections) {
    $connections->del((string)$fd);
    echo "连接关闭: fd={$fd}\n";

    // 广播离开消息
    foreach ($connections as $conn) {
        $server->push($conn['fd'], "用户 {$fd} 离开了聊天室");
    }
});

// 启动服务器
$server->start();

关键点

  • 使用 Swoole\Table 存储连接的 fd,这是共享内存结构,可在多个 Worker 进程间共享。
  • 广播时遍历所有连接,向每个客户端推送消息。如果连接已断开,push 可能会失败,可根据实际情况忽略错误。

2.2 客户端代码 index.html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Swoole 多人聊天室</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        #messages { list-style: none; padding: 0; height: 300px; overflow-y: auto; border: 1px solid #ccc; margin-bottom: 10px; }
        #messages li { padding: 5px; border-bottom: 1px solid #eee; }
        #form { display: flex; }
        #input { flex: 1; padding: 8px; }
        button { padding: 8px 15px; }
    </style>
</head>
<body>
    <h2>Swoole 多人聊天室</h2>
    <ul id="messages"></ul>
    <form id="form" action="">
        <input id="input" autocomplete="off" />
        <button>发送</button>
    </form>

    <script>
        const ws = new WebSocket("ws://你的服务器IP:9501");
        const messages = document.getElementById('messages');
        const input = document.getElementById('input');
        const form = document.getElementById('form');

        ws.onmessage = function(event) {
            const li = document.createElement('li');
            li.textContent = event.data;
            messages.appendChild(li);
            messages.scrollTop = messages.scrollHeight;
        };

        form.addEventListener('submit', function(e) {
            e.preventDefault();
            if (input.value) {
                ws.send(input.value);
                input.value = '';
            }
        });
    </script>
</body>
</html>
注意:将 ws://你的服务器IP:9501 替换为实际服务器的 IP 和端口。

2.3 运行测试

  1. 启动服务端:php chat_server.php
  2. 启动一个简易 HTTP 服务器(用于托管 index.html):php -S 0.0.0.0:8080
  3. 用多个浏览器标签页或不同设备访问 http://服务器IP:8080,即可模拟多人聊天。

三、系统限制:文件描述符与 ulimit

当连接数增长时,我们需要确保操作系统允许打开足够多的文件描述符(每个网络连接占用一个)。默认的 ulimit -n 通常为 1024,对于高并发服务远远不够。

3.1 查看当前使用情况

  • 系统整体已分配的文件描述符数

    cat /proc/sys/fs/file-nr

    输出示例:3120 0 9223372036854775807

    • 第一个数字 = 当前已分配数量
    • 第二个 = 未使用但已分配的数量(通常为 0)
    • 第三个 = 系统最大限制(fs.file-max
  • 查看某个进程(如 Swoole 服务)的文件描述符使用数

    ps aux | grep swoole          # 获取进程 PID
    ls /proc/<PID>/fd | wc -l     # 统计 fd 目录下的文件数
  • 统计当前 TCP 连接数(ESTABLISHED)

    ss -tan | grep ESTAB | wc -l

3.2 临时修改(重启后失效)

ulimit -n 1000000

该命令只影响当前 shell 会话,关闭终端即恢复。

3.3 永久修改

3.3.1 修改 /etc/security/limits.conf

* soft nofile 1000000
* hard nofile 1000000
root soft nofile 1000000
root hard nofile 1000000

修改后需重新登录才能生效。

3.3.2 针对 systemd 管理的服务(如 PHP-FPM、Swoole 守护进程)

在服务单元文件(如 /etc/systemd/system/swoole.service)中添加:

[Service]
LimitNOFILE=1000000

然后执行 systemctl daemon-reload 并重启服务。

3.3.3 WSL(Windows Subsystem for Linux)特殊配置

WSL 中 /etc/security/limits.conf 可能不生效,推荐使用以下方法:

  • 创建或编辑 /etc/wsl.conf

    [boot]
    command="ulimit -n 1000000"
  • 或者修改 Windows 宿主目录下的 .wslconfig 文件(位于用户目录),增加:

    [wsl2]
    kernelCommandLine = "sysctl fs.file-max=1000000"

    修改后需完全重启 WSL:wsl --shutdown,再重新启动。

3.4 系统上限 fs.file-max

fs.file-max 是系统级的总文件描述符上限。可通过以下命令查看:

cat /proc/sys/fs/file-max

临时修改:

sysctl -w fs.file-max=1000000

永久修改:在 /etc/sysctl.conf 中添加 fs.file-max=1000000,然后执行 sysctl -p

注意:即使将 ulimit -nfs.file-max 设置得非常大,实际能支撑的连接数仍受服务器内存限制(每个连接约几 KB 内存开销)。

四、总结

Swoole 通过 多进程 + 协程 的架构,打破了传统 PHP 的并发瓶颈。它利用少量常驻进程和轻量级协程,高效处理海量网络连接,是构建 WebSocket 服务、即时通讯、游戏服务器的理想选择。开发时,我们只需按事件驱动的模式编写业务逻辑,无需关心底层并发细节。部署时,合理调整系统文件描述符限制,就能让服务平滑支撑数万甚至数十万并发连接。

希望本文能帮助你从原理到实践全面掌握 Swoole WebSocket 的开发与运维。如果你想进一步探索房间管理、用户认证、消息持久化等高级功能,可以在现有代码基础上轻松扩展。

下面是一个基于 Swoole 的简单多人聊天室示例。它使用 WebSocket 协议,所有连接的用户都可以看到彼此发送的消息。


1. 核心原理

  • Swoole 启动一个 WebSocket 服务器,常驻内存。
  • 每当有新的客户端连接,服务器保存该连接的文件描述符($fd)到一个全局数组(或使用 Swoole\Table 等共享内存结构)。
  • 当某个客户端发送消息时,服务器遍历所有保存的连接,将消息广播出去(包括发送者自己,或者可以排除发送者,此处示例是发给所有人)。

2. 服务端代码

创建文件 chat_server.php

<?php
// chat_server.php

use Swoole\WebSocket\Server;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;

// 创建 WebSocket 服务器,监听 0.0.0.0:9501
$server = new Server("0.0.0.0", 9501);

// 存储所有客户端连接的文件描述符(fd)
$connections = new Swoole\Table(1024);
$connections->column('fd', Swoole\Table::TYPE_INT);
$connections->create();

// 监听 WebSocket 连接打开事件
$server->on('open', function (Server $server, Request $request) use ($connections) {
    $fd = $request->fd;
    $connections->set((string)$fd, ['fd' => $fd]);
    echo "新连接: fd={$fd}\n";

    // 可选:广播用户进入消息
    $server->push($fd, "欢迎加入聊天室!");
    // 通知其他人
    foreach ($connections as $conn) {
        if ($conn['fd'] != $fd) {
            $server->push($conn['fd'], "用户 {$fd} 进入房间");
        }
    }
});

// 监听 WebSocket 消息事件
$server->on('message', function (Server $server, Frame $frame) use ($connections) {
    $fd = $frame->fd;
    $data = $frame->data;
    echo "收到消息: fd={$fd}, data={$data}\n";

    // 广播给所有客户端
    foreach ($connections as $conn) {
        $server->push($conn['fd'], "用户 {$fd} 说: {$data}");
    }
});

// 监听 WebSocket 连接关闭事件
$server->on('close', function (Server $server, int $fd) use ($connections) {
    $connections->del((string)$fd);
    echo "连接关闭: fd={$fd}\n";

    // 广播离开消息
    foreach ($connections as $conn) {
        $server->push($conn['fd'], "用户 {$fd} 离开了聊天室");
    }
});

// 启动服务器
$server->start();

说明

  • 这里使用 Swoole\Table 来存储连接的 $fd,因为 Swoole\Table 是共享内存的,可以在多个 Worker 进程间共享。如果只用一个 Worker 进程(默认是1个),也可以直接用普通数组 $connections = [],但多进程时需要共享内存结构。
  • 广播时遍历所有连接并推送消息,注意如果某连接已断开,push 可能会失败,可以忽略或加入异常处理。

3. 客户端代码

创建 index.html,放在 Web 服务器可访问的目录(或直接用 php -S 0.0.0.0:8080 启动一个简单 HTTP 服务)。

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Swoole 多人聊天室</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        #messages { list-style: none; padding: 0; height: 300px; overflow-y: auto; border: 1px solid #ccc; margin-bottom: 10px; }
        #messages li { padding: 5px; border-bottom: 1px solid #eee; }
        #form { display: flex; }
        #input { flex: 1; padding: 8px; }
        button { padding: 8px 15px; }
    </style>
</head>
<body>
    <h2>Swoole 多人聊天室</h2>
    <ul id="messages"></ul>
    <form id="form" action="">
        <input id="input" autocomplete="off" />
        <button>发送</button>
    </form>

    <script>
        // 连接 WebSocket 服务器,替换成你的实际地址
        const ws = new WebSocket("ws://你的服务器IP:9501");

        const messages = document.getElementById('messages');
        const input = document.getElementById('input');
        const form = document.getElementById('form');

        // 收到消息时追加到列表
        ws.onmessage = function(event) {
            const li = document.createElement('li');
            li.textContent = event.data;
            messages.appendChild(li);
            messages.scrollTop = messages.scrollHeight;
        };

        // 发送消息
        form.addEventListener('submit', function(e) {
            e.preventDefault();
            if (input.value) {
                ws.send(input.value);
                input.value = '';
            }
        });
    </script>
</body>
</html>
注意:如果客户端和服务器不在同一台机器上,需要将 ws://你的服务器IP:9501 替换为服务器的实际 IP 或域名。同时确保服务器防火墙开放 9501 端口。

4. 运行与测试

  1. 安装 Swoole

    pecl install swoole

    或在 Docker 等环境中使用带有 Swoole 扩展的 PHP 镜像。

  2. 启动服务端

    php chat_server.php

    看到 Server started 之类的输出即成功。

  3. 启动客户端
    用浏览器打开 index.html(可以通过 php -S 0.0.0.0:8080 启动一个简单 HTTP 服务器,或者直接双击打开但某些浏览器可能限制 WebSocket 连接)。
    多个浏览器标签页或不同设备打开该页面,即可模拟多人聊天。

5. 扩展想法

  • 区分用户昵称:在 open 事件中让用户发送昵称,服务端存储昵称与 fd 的映射,广播时使用昵称。
  • 房间支持:引入房间 ID,只向同一房间的用户广播。
  • 心跳保活:定期发送 ping/pong 帧保持连接。
  • 持久化消息:将聊天记录存入 Redis 或数据库。
  • 使用协程:在消息处理中如需 I/O(如数据库查询),可以使用 Co\rungo 创建协程,避免阻塞。

这个例子展示了 Swoole WebSocket 服务器的基本用法,以及如何实现一个多人聊天室。通过常驻内存、多进程(默认单进程)和事件驱动,它能支撑大量并发连接。

以上写法只是入门演示,存在的问题点及解决思路:
一、这种写法存在的主要问题
单进程处理所有事件,无法利用多核 CPU
代码中没有设置 worker_num,Swoole 默认只启动 1 个 Worker 进程。这意味着:

服务器只能使用一个 CPU 核心,并发处理能力受限。

如果某个连接的 push 操作因网络延迟而阻塞(虽然 Swoole 的 push 是非阻塞的,但遍历大量连接仍会占用 CPU),会拖慢所有其他客户端消息的处理。

无法水平扩展进程内的事件处理能力。

广播操作同步遍历,会阻塞事件循环
在 onMessage 回调中,使用 foreach ($connections as $conn) { $server->push(...); } 直接遍历所有连接。当连接数达到数千甚至上万时:

这条 foreach 循环会长时间占用当前 Worker 进程,导致其他客户端的连接事件、消息事件被延迟处理。

如果某个 push 因对方连接已关闭而抛异常(代码中未处理),可能中断广播。

连接存储使用 Swoole\Table,但未设置进程间共享的合理结构
Swoole\Table 本身是共享内存的,可以在多 Worker 进程间共享。但当前示例中:

由于只有 1 个 Worker,实际上未发挥多进程优势。

广播时直接遍历整个 Table,如果未来增加 Worker 数量,每个 Worker 都会持有完整的连接表副本(共享内存是同一份),但每个 Worker 仍在自己的进程内遍历全表,造成冗余。

没有利用 Swoole 内置的 $server->connections 迭代器(它本身就提供了遍历所有连接的能力,且更高效)。

缺少必要的错误处理和资源限制

没有对 push 失败做处理(如连接已断开)。

没有设置 heartbeat_idle_time 和 heartbeat_check_interval,空闲连接不会自动清理,可能导致资源浪费。

没有限制最大连接数,可能被恶意连接耗尽内存。

客户端代码存在单点隐患

前端直接连接 WebSocket 服务器,没有通过 HTTP 升级或负载均衡,不利于扩展和安全性。

没有重连机制,网络波动后页面需要手动刷新。

二、针对问题的改进方案

  1. 配置多进程,利用多核 CPU

    $server = new Swoole\WebSocket\Server("0.0.0.0", 9501);
    $server->set([
     'worker_num' => swoole_cpu_num(), // 通常设置为 CPU 核心数
     'heartbeat_idle_time' => 600,      // 10分钟无心跳则断开
     'heartbeat_check_interval' => 60,  // 每分钟检查一次
     'max_connection' => 10000,         // 根据服务器内存设置
    ]);
  2. 使用 Swoole 内置的 $server->connections 遍历,并采用异步广播
    Swoole 提供了 $server->connections 迭代器,它是全局的,且在多进程下由主进程维护。推荐在 onMessage 中使用 foreach ($server->connections as $fd) 来广播,但注意避免在遍历过程中执行阻塞操作。如果广播耗时较长,可以将广播任务投递到 Task 进程 中异步处理:
$server->on('message', function ($server, $frame) {
    // 将广播任务投递到 task 进程
    $server->task(['type' => 'broadcast', 'data' => $frame->data, 'sender_fd' => $frame->fd]);
});

$server->on('task', function ($server, $task_id, $src_worker_id, $data) {
    if ($data['type'] === 'broadcast') {
        foreach ($server->connections as $fd) {
            if ($fd !== $data['sender_fd']) { // 可选:不发给发送者
                $server->push($fd, $data['data']);
            }
        }
    }
    $server->finish($data);
});

这样广播逻辑不会阻塞 Worker 进程接收新消息。

  1. 使用更高效的连接管理
    如果确实需要跨进程共享更复杂的连接元数据(如用户ID、房间ID),可以使用 Swoole\Table 存储扩展信息,但遍历仍建议通过 $server->connections 进行。
  2. 增加错误处理与连接限制

    $server->on('message', function ($server, $frame) {
     try {
         // 业务逻辑
         foreach ($server->connections as $fd) {
             if (!$server->isEstablished($fd)) {
                 continue;
             }
             $server->push($fd, $data);
         }
     } catch (\Throwable $e) {
         // 记录错误日志,避免崩溃
         error_log($e->getMessage());
     }
    });
  3. 前端增强与架构优化
    前端增加 WebSocket 重连逻辑(如 ws.onclose 中延迟重连)。

生产环境建议在 WebSocket 服务器前加一层 Nginx 做负载均衡和 SSL 卸载,使用 proxy_pass 到 Swoole 服务器。

三、单机可支撑的规模估算
在改进后(多进程、异步广播、合理配置),单机(8核16GB)能支撑的规模大致如下:

场景 连接数 消息吞吐量 说明
简单广播聊天室 3万~5万 每秒处理 1万~2万条消息 假设消息频率不高,广播遍历成本随连接数线性增长,CPU是主要瓶颈。
低频推送(如股票行情) 8万~10万 每秒广播 1000次 主要受内存(每个连接约占用 2-4KB)和网络带宽限制。
高交互游戏 1万~2万 每秒广播 5万条 消息频率高时,广播遍历开销大,需要更精细的优化(如分组广播、使用 sendMessage 等)。
实际影响因素:

CPU 核心数:Worker 进程数通常等于核心数,更多核心可并行处理不同连接的消息。

广播模式:全量广播最耗 CPU;如果改用按房间广播(连接分片),可大幅提升容量。

网络带宽:若消息体较大(如 1KB 以上),千兆网卡可能在 1万连接时就达到瓶颈。

内存:每个连接占用内存(Swoole 约 2-4KB,加上业务数据),16GB 内存可支撑约 5-8 万连接。

四、关于“手动创建进程和协程”的补充
在 Swoole 的 WebSocket 应用中,通常不需要手动创建进程,因为 Swoole 的 Server 已经通过 worker_num 配置帮你管理了多进程模型。只有在以下场景才考虑手动控制:

需要独立于请求生命周期的后台任务(如定时清理、数据聚合)→ 使用 Swoole\Process 或 Swoole\Timer。

需要在 Worker 内部并行执行多个 I/O 任务(如同时查询多个 API)→ 使用 go() 创建协程,避免阻塞。

需要实现自定义的进程间通信(IPC)或守护进程 → 使用 Swoole\Process。

控制使用 CPU 的核心方式:

通过 worker_num 设置 Worker 进程数量,一般设为 CPU 核心数(swoole_cpu_num())。

通过 task_worker_num 设置 Task 进程数量,用于处理异步任务。

协程是自动调度的,你只需在可能阻塞的地方(如 Co\run 或 go)中使用协程客户端(如 Co\Http\Client),避免使用同步阻塞的 I/O 函数。

要在线上无缝添加 Composer 组件,核心思想是不在生产服务器上直接运行 composer install,而是通过“构建 + 原子切换”的方式,将代码和依赖打包成一个不可变的整体进行发布。

这样做可以避免直接修改正在运行的 vendor 目录导致的类文件加载冲突、OPcache 错乱等风险。下面是一个完整的、低风险的部署流程。

核心部署流程

整个流程可以用下图概括,它清晰地展示了从代码提交到服务切换的完整链路,确保新依赖的引入是平滑且可回滚的。

flowchart TD
    A[开发者推送代码<br>(包含新的 composer 组件)] --> B[CI/CD 服务器<br>(如 Jenkins, GitLab CI)]
    
    B --> C[构建阶段<br>(在隔离环境执行)]
    
    subgraph C [构建阶段]
        C1[composer install<br>--no-dev --optimize-autoloader]
        C2[生成包含代码和<br>vendor 的发布包]
    end
    
    C --> D[将发布包部署到<br>生产服务器的全新目录]
    D --> E[执行平滑切换<br>(如更新符号链接)]
    
    E --> F{健康检查}
    F -- 成功 --> G[流量切换到新版本]
    F -- 失败 --> H[保留旧版本<br>(快速回滚)]

deepseek_服务部署.png

关键步骤详解

1. 构建阶段:生成完整的发布包

禁止直接在线上执行 Composer 命令。应该在持续集成(CI)流程中,拉取代码后执行安装命令,生成包含所有依赖的 vendor 目录。

  • 区分环境依赖:利用 composer.json 中的 require(生产必需)和 require-dev(开发/测试工具)字段做好隔离。在 CI 中构建生产包时,务必使用 --no-dev 参数跳过开发依赖的安装。
  • 优化生产加载:使用 --optimize-autoloader(或 -o)参数,生成类映射,提升生产环境的自动加载效率。

2. 部署阶段:使用“原子切换”策略

这是实现零停机的关键。将新版本部署到一个全新的目录,然后通过更新符号链接(symlink)的方式,让 Web 服务器(如 Nginx)的根目录瞬间指向新版本。

一个典型的部署脚本示例如下:

# 变量定义
RELEASE_DIR="/var/www/app/releases/$(date +%Y%m%d_%H%M%S)"
CURRENT_DIR="/var/www/app/current"
# 1. 创建新版本目录,并将代码包解压至此
mkdir -p $RELEASE_DIR
tar -xzf /path/to/your/build-artifact.tar.gz -C $RELEASE_DIR
# 2. 原子切换:将 "current" 符号链接指向新版本目录
ln -sfn $RELEASE_DIR $CURRENT_DIR
# 3. (可选)重新加载 PHP-FPM 以清理 OPcache
sudo systemctl reload php8.1-fpm  # 请根据实际 PHP 版本调整

使用 ln -sfn 命令更新符号链接是一个原子操作,能确保新旧版本切换在瞬间完成,从而避免服务中断。

3. 善后与兜底:确保运行环境一致

即使代码文件已更新,PHP 的运行环境也可能导致问题。

  • 清理 OPcache:PHP 的 OPcache 会缓存编译后的字节码。如果配置不当,它可能仍从旧文件路径执行代码。在切换版本后,建议重新加载 PHP-FPM 服务(如 systemctl reload php-fpm),这是最彻底的方法。如果无法重载服务,也需要通过脚本触发 opcache_reset() 函数。
  • 准备回滚:原子切换策略的最大优势之一就是回滚极快。如果新版本出现异常,只需再次执行 ln -sfn,将 current 链接指回上一个版本的目录即可。

数据库迁移的兼容性问题

如果新添加的 Composer 组件伴随着数据库表结构的变更,需要特别注意向前兼容。因为在切换流量前,旧版本的代码仍在运行,它必须能兼容新的数据库结构。

  • 安全模式:例如,添加新字段时,不要将其设置为 NOT NULL 且无默认值,这会导致旧代码写入失败。最佳实践是先发布一个仅增加可空字段的版本,等所有版本都更新后,再发一个版本将其改为非空约束。

总结来说,实现无缝添加组件的核心在于将部署过程从“修改”转变为“替换”。通过在 CI 阶段构建完整的、自包含的应用包,再利用原子操作进行环境切换,可以最大程度地规避因依赖更新引发的线上风险。

这段代码是 Mermaid 语言,用于绘制流程图。它以 flowchart TD 开头,定义了节点和它们之间的连接关系,通常嵌入在 Markdown 文档中实现图表的可视化。

flowchart TD
    A[开发者推送代码<br>(包含新的 composer 组件)] --> B[CI/CD 服务器<br>(如 Jenkins, GitLab CI)]
    
    B --> C[构建阶段<br>(在隔离环境执行)]
    
    subgraph C [构建阶段]
        C1[composer install<br>--no-dev --optimize-autoloader]
        C2[生成包含代码和<br>vendor 的发布包]
    end
    
    C --> D[将发布包部署到<br>生产服务器的全新目录]
    D --> E[执行平滑切换<br>(如更新符号链接)]
    
    E --> F{健康检查}
    F -- 成功 --> G[流量切换到新版本]
    F -- 失败 --> H[保留旧版本<br>(快速回滚)]

deepseek_服务部署.png

“Yii2 + MySQL向量检索”实战指南。从环境准备到代码实现的完整流程。


第一步:环境准备与库安装

1.1 服务器前置要求

根据 mysql-vector 库的官方说明,你的服务器需要满足 :

  • PHP 8.0+(需要 ext-mysqliext-json
  • MySQL 5.7+(8.0 完全兼容)
  • Composer 已安装

1.3 安装 PHP 库

在你的 Yii2 项目根目录执行:

#验证php有没有编译ffi扩展
php -m|grep FFI
#或者
php -r "var_dump( class_exists('ffi'));"

#已安装的跳过安装步骤,直接到composer

# CentOS/RHEL系列(你用的是CentOS吧?)
yum install libffi-devel -y

# 如果是Ubuntu/Debian,用下面这句
# apt-get install libffi-dev -y
# 下载与你当前PHP版本匹配的源码(8.2)
cd /usr/local/src
wget https://www.php.net/distributions/php-8.2.0.tar.gz
tar -xzf php-8.2.0.tar.gz
cd php-8.2.0/ext/ffi

#BT环境
/www/server/php/82/bin/phpize
./configure --with-php-config=/www/server/php/82/bin/php-config
make && make install
# Web 模式
vi /www/server/php/82/etc/php.ini
# CLI 模式
vi /www/server/php/82/etc/php-cli.ini
extension=ffi.so
ffi.enable = true
/etc/init.d/php-fpm-82 restart

#composer安装php开源库代码
composer require allanpichardo/mysql-vector

安装完成后,Composer 会自动加载类。

第二步:数据库设计

我们将采用 “基础表 + 向量辅助表” 的双表结构,完美实现多租户隔离。

2.1 创建商家知识库主表(用于管理)

CREATE TABLE `merchant_knowledge_base` (
    `id` INT UNSIGNED AUTO_INCREMENT COMMENT '知识条目ID',
    `merchant_id` INT UNSIGNED NOT NULL COMMENT '商家ID',
    `question` VARCHAR(500) NOT NULL COMMENT '用户问题(原始文本)',
    `answer` TEXT NOT NULL COMMENT '答案',
    `category` VARCHAR(100) DEFAULT NULL COMMENT '分类',
    `status` TINYINT DEFAULT 1 COMMENT '状态:1启用 0禁用',
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_merchant` (`merchant_id`),
    KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家知识库主表';

2.2 创建向量辅助表(由 VectorTable 管理)

向量表我们将通过 PHP 代码自动创建,但它的预期结构如下 :

字段名类型说明
idINT AUTO_INCREMENT PRIMARY KEY向量ID
embeddingJSON NOT NULL384维向量(存储为JSON数组)
metadataJSON存储关联的 merchant_idkb_id

关键点:我们在 metadata 中存储商家ID和知识库条目ID,查询时先过滤商家,再计算相似度。

第三步:Yii2 服务层封装

实际运行代码的时候,第一次会自动下载安装onnxruntime运行时,网络原因可能失败,需要手动安装

PHP Warning 'yii\base\ErrorException' with message 'file_get_contents(https://github.com/microsoft/onnxruntime/releases/download/v1.23.0/onnxruntime-linux-x64-1.23.0.tgz): Failed to open stream: HTTP request failed!'

手动安装方法:
第一步:手动下载 ONNX Runtime

将解压后的库文件复制到项目扩展目录中(ankane/onnxruntime)

wget https://github.com/microsoft/onnxruntime/releases/download/v1.23.0/onnxruntime-linux-x64-1.23.0.tgz
tar -zxvf ~/onnxruntime-linux-x64-1.23.0.tgz -C /www/wwwroot/supo-admin/vendor/ankane/onnxruntime/src/../lib/

3.1 创建向量搜索组件

创建 components/VectorSearchService.php

<?php
namespace app\components;

use yii\base\Component;
use MHz\MysqlVector\VectorTable;
use MHz\MysqlVector\Nlp\Embedder;
use Yii;

class VectorSearchService extends Component
{
    private $vectorTable;
    private $embedder;
    private $tableName = 'merchant_vectors';
    private $dimension = 384;
    
    public function init()
    {
        parent::init();
        
        // 创建 MySQLi 连接(从 Yii2 DB 组件复用配置)
        $db = Yii::$app->db;
        $mysqli = new \mysqli(
            $db->dsn,  // 注意:dsn是字符串如 "mysql:host=127.0.0.1;dbname=test"
            $db->username,
            $db->password,
            $db->dsn  // 这里需要提取数据库名,实际使用中建议单独配置
        );
        
        // 初始化 VectorTable
        $this->vectorTable = new VectorTable($mysqli, $this->tableName, $this->dimension);
        
        // 初始化 Embedder(第一次运行会自动下载模型,约30MB)
        $this->embedder = new Embedder();
    }
    
    /**
     * 为商家知识库建立索引(添加或更新)
     */
    public function indexKnowledge($merchantId, $kbId, $question)
    {
        // 1. 将问题文本转为向量
        $embeddings = $this->embedder->embed([$question]);
        $vector = $embeddings[0];  // 第一个问题的向量
        
        // 2. 准备 metadata
        $metadata = json_encode([
            'merchant_id' => $merchantId,
            'kb_id' => $kbId,
            'question' => $question
        ]);
        
        // 3. 存储向量(upsert 会自动处理重复)
        // 注意:库的 upsert 方法签名是 upsert(vector, id),metadata 需要单独处理
        // 这里我们简化处理:先查是否存在,再决定 insert/update
        $vectorId = $this->findVectorIdByKbId($kbId);
        if ($vectorId) {
            // 更新:需要自己实现更新逻辑
            $this->updateVector($vectorId, $vector, $metadata);
        } else {
            // 插入
            $this->insertVector($vector, $metadata);
        }
    }
    
    /**
     * 搜索最相似的答案(按商家过滤)
     */
    public function search($merchantId, $query, $topN = 5)
    {
        // 1. 将用户问题转为向量
        $embeddings = $this->embedder->embed([$query]);
        $queryVector = $embeddings[0];
        
        // 2. 手动编写 SQL,先过滤商家再计算相似度
        $vectorJson = json_encode($queryVector);
        
        $sql = "
            SELECT 
                id,
                metadata,
                COSIM(embedding, ?) as distance
            FROM {$this->tableName}
            WHERE JSON_EXTRACT(metadata, '$.merchant_id') = ?
            ORDER BY distance DESC
            LIMIT ?
        ";
        
        $mysqli = $this->getMysqli();
        $stmt = $mysqli->prepare($sql);
        $stmt->bind_param("sii", $vectorJson, $merchantId, $topN);
        $stmt->execute();
        $result = $stmt->get_result();
        
        $items = [];
        while ($row = $result->fetch_assoc()) {
            $metadata = json_decode($row['metadata'], true);
            $items[] = [
                'id' => $row['id'],
                'distance' => $row['distance'],
                'merchant_id' => $metadata['merchant_id'] ?? null,
                'kb_id' => $metadata['kb_id'] ?? null,
                'question' => $metadata['question'] ?? null,
            ];
        }
        
        // 3. 根据 kb_id 从主表获取完整答案
        return $this->enrichWithAnswers($items);
    }
    
    // 以下为辅助方法,需要你根据实际情况实现
    private function getMysqli() { /* 复用 init 中的连接 */ }
    private function findVectorIdByKbId($kbId) { /* 查询 metadata 中 kb_id 对应的向量ID */ }
    private function insertVector($vector, $metadata) { /* 调用 vectorTable->upsert */ }
    private function updateVector($id, $vector, $metadata) { /* 更新操作 */ }
    private function enrichWithAnswers($items) { /* 根据 kb_id 查询主表获取 answer */ }
}

3.2 在配置文件中注册组件

config/web.php

'components' => [
    // ... 其他组件
    'vectorSearch' => [
        'class' => 'app\components\VectorSearchService',
    ],
],

第四步:控制器中使用

4.1 索引知识库(管理员后台调用)

public function actionIndexKnowledge()
{
    $merchantId = Yii::$app->request->post('merchant_id');
    $kbId = Yii::$app->request->post('kb_id');
    $question = Yii::$app->request->post('question');
    
    Yii::$app->vectorSearch->indexKnowledge($merchantId, $kbId, $question);
    
    return $this->asJson(['success' => true]);
}

4.2 用户问答接口

public function actionAsk()
{
    $merchantId = Yii::$app->request->post('merchant_id'); // 从子域名或参数获取
    $question = Yii::$app->request->post('question');
    
    // 搜索最相似的3条知识
    $results = Yii::$app->vectorSearch->search($merchantId, $question, 3);
    
    if (empty($results)) {
        return $this->asJson(['answer' => '抱歉,暂时无法回答该问题']);
    }
    
    // 取最相似的一条作为答案
    $best = $results[0];
    return $this->asJson([
        'answer' => $best['answer'],
        'confidence' => $best['distance'],
    ]);
}

第五步:部署与验证

5.1 首次运行注意事项

  • 第一次实例化 Embedder:会自动下载 BGE 嵌入模型(约30MB)到服务器缓存目录,确保目录可写 。
  • MySQL 函数创建$vectorTable->initialize() 会创建 COSIM 函数,需要 MySQL 有创建函数的权限。

5.2 性能测试

根据官方基准,10万条384维向量搜索仅需 0.06秒 ,完全满足你的场景。

5.3 验证流程

  1. 为商家A导入10条问答,调用索引接口
  2. 为商家B导入10条不同的问答
  3. 用商家A的用户提问,确认只返回商家A的答案
  4. 测试语义理解效果(如“退货”是否能匹配到“退款政策”)

总结与下一步

你已经拥有了一个 生产可用的多租户语义问答系统,核心优势是:

  • 完全在 PHP/MySQL 生态内,无需额外服务
  • 支持多商家数据隔离
  • 纯语义搜索,超越关键词匹配
  • 毫秒级响应