Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 339

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 392

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 394
Joyber 的博客

要在线上无缝添加 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 生态内,无需额外服务
  • 支持多商家数据隔离
  • 纯语义搜索,超越关键词匹配
  • 毫秒级响应

前端开发中,引入美观的中文字体(如阿里巴巴普惠体)往往会遇到一个痛点:完整中文字体文件体积巨大(通常 5MB~10MB),直接引入会严重拖慢页面加载速度。本文将介绍如何通过 Font Splitter 工具结合 CSS unicode-range 特性,实现字体文件的按需加载,大幅优化页面性能。


一、问题背景:大字体文件的性能困境

当我们在项目中全局引入中文字体时:

body {
  font-family: 'alibaba-pu-hui-ti-3-regular', Arial, Helvetica, sans-serif;
}

如果直接引入完整字体文件(如 8.5MB 的阿里巴巴普惠体),会带来以下问题:

  • 首屏加载慢:用户需要等待完整字体下载完成才能看到渲染后的文字,影响体验。
  • 带宽浪费:页面实际只用到几百个常用字符,却要下载包含数万字符的完整字体。
  • 缓存冗余:即使只修改了几个字,也需要重新下载整个大文件。

二、核心原理:unicode-range 按需加载

CSS 的 @font-face 规则提供了 unicode-range 属性,它允许我们为同一个字体族定义多个子字体文件,并指定每个子文件负责渲染的 Unicode 字符范围。

浏览器的工作流程如下:

  1. 扫描页面文字:解析页面时,提取所有可见文字的 Unicode 编码。
  2. 匹配字符范围:将文字编码与 @font-face 中的 unicode-range 逐一比对。
  3. 按需下载:只下载包含页面所需字符的子字体文件,而非完整字体。
  4. 渲染文字:用下载的子字体文件渲染对应字符,未覆盖的字符自动使用兜底字体。

三、工具选择:Font Splitter 快速分割字体

Font Splitter 是一个轻量级命令行工具,能自动将大字体文件按 Unicode 范围分割成多个小文件,并生成对应的 CSS 代码,完美适配 unicode-range 按需加载方案。

1. 安装 Font Splitter

npm install -g @vdustr/font-splitter

若生成 woff2 格式时提示 Brotli 缺失,需先安装 Python 依赖:

pip install Brotli

2. 基础使用命令

# 基础用法:分割字体并生成 woff2 格式
font-splitter ./AlibabaPuHuiTi-3-55-Regular.ttf -f woff2

# 自定义字体族名称(与项目中 font-family 保持一致)
font-splitter ./AlibabaPuHuiTi-3-55-Regular.ttf -f woff2 -n alibaba-pu-hui-ti-3-regular

# 不生成 local() 声明(避免中文乱码)
font-splitter ./AlibabaPuHuiTi-3-55-Regular.ttf -f woff2 -n alibaba-pu-hui-ti-3-regular --no-local

3. 输出结果

执行命令后,会在当前目录生成 output 文件夹,包含:

  • 多个分割后的字体文件(如 AlibabaPuHuiTi-3-55-Regular.Basic-Latin.woff2
  • 对应的 CSS 文件(如 AlibabaPuHuiTi-3-55-Regular.css

四、项目集成:无缝接入全局字体

1. 资源放置

将分割后的字体文件和 CSS 文件放入项目静态资源目录,例如:

├── public/
│   └── fonts/
│       ├── AlibabaPuHuiTi-3-55-Regular.Basic-Latin.woff2
│       ├── AlibabaPuHuiTi-3-55-Regular.Latin-1-Supplement.woff2
│       └── ...
└── src/
    └── assets/
        └── css/
            └── AlibabaPuHuiTi-3-55-Regular.css

2. 修改 CSS 路径

打开生成的 CSS 文件,将 url() 中的字体路径修改为项目实际路径:

@font-face {
  font-family: alibaba-pu-hui-ti-3-regular;
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/AlibabaPuHuiTi-3-55-Regular.Basic-Latin.woff2') format('woff2');
  unicode-range: U+0, U+d, U+20-7e;
}
若生成的 CSS 中存在乱码的 local() 项,可直接删除,不影响功能。

3. 全局引入

在项目的全局样式入口(如 main.css)中引入分割后的 CSS:

/* 先引入分割字体的 CSS,定义字体族 */
@import './assets/css/AlibabaPuHuiTi-3-55-Regular.css';

/* 原有全局字体配置,无需修改 */
body {
  font-family: 'alibaba-pu-hui-ti-3-regular', Arial, Helvetica, sans-serif;
}

五、性能优化:结合 Nginx 缓存

为了进一步提升加载速度,我们可以对分割后的字体文件配置长期缓存:

location ~* \.(woff|woff2|ttf|otf|eot)$ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000, immutable";
    etag on;
    access_log off;
}
  • expires 30d:设置缓存有效期为 30 天。
  • Cache-Control: immutable:告诉浏览器该文件不会修改,避免重复验证请求。
  • etag on:生成文件唯一标识,文件更新时自动触发重新下载。

六、效果验证

1. 浏览器 Network 面板验证

打开浏览器开发者工具(F12)→ Network 面板,刷新页面后:

  • 观察字体文件请求:只会下载页面实际用到的字符范围对应的小文件。
  • 查看响应头:确认 Cache-Control 等缓存头已生效。

2. 加载速度对比

方案字体文件大小首屏加载时间带宽占用
完整字体引入~8.5MB
Font Splitter 分割后~2MB(常用中文+英文)

七、常见问题与解决方案

1. CSS 中出现乱码

原因:Font Splitter 生成 local() 时,将字体的中文名称直接写入 CSS,导致编码问题。
解决

  • 方案 1:删除乱码的 local() 项,只保留 url()
  • 方案 2:生成时添加 --no-local 参数,从根源避免生成 local() 声明。

2. 字体不生效

排查方向

  • 确认 font-family 名称在 CSS 和项目中完全一致。
  • 检查字体文件路径是否正确。
  • 确认浏览器支持 woff2 格式(现代浏览器均支持)。

3. 生僻字显示异常

原因:生僻字未被分割后的 unicode-range 覆盖。
解决:重新运行 Font Splitter,调整分割范围,包含生僻字对应的 Unicode 区间。


八、总结

通过 Font Splitter + unicode-range 的组合,我们实现了:

  • 按需加载:只下载页面实际用到的字符,大幅减少字体体积。
  • 全局兼容:完美适配 body 全局字体配置,无需修改业务代码。
  • 性能最优:结合 Nginx 长期缓存,实现「一次下载,长久使用」。
  • 体验友好font-display: swap 确保文字先渲染,避免页面闪烁。

这种方案特别适合需要引入中文字体的前端项目,在保证视觉美观的同时,最大化提升页面加载性能。


九、延伸阅读