先说个实际场景:你们团队的 PHP 项目准备上 Swoole,或者已经上了,但发版还是走“低峰期、全量重启、出问题赶紧回滚”的老流程。这个流程在流量小的时候还好,一旦日活上来,一次有问题的发版可能就会让一小部分用户直接看到 502,而你再想保留现场排查都没机会。我在这个阶段踩了不少坑之后,决定把灰度发布和 A/B 测试的路由统一放到 Swoole 网关层来做,也就是用一个常驻的 Swoole HTTP Server 作为请求入口,在路由转发之前先把流量分组,再根据规则分流到不同版本的服务。整个方案围绕 Swoole 的共享内存 Table 和协程客户端展开,规则支持热更新,不重启进程就能调整放量比例。这篇博文就是这套方案的完整复盘,适合正在做 PHP 架构升级、准备引入 Swoole,或者被灰度工具链折腾得够呛的团队参考。
1. 项目背景与整体设计思路
很多人一听“灰度发布”和“A/B 测试”,下意识会觉得这是大数据团队或者运维平台的事,跟普通 PHP 开发者关系不大。但实际做下来你会发现,如果团队没有专门的基础设施,灰度发布最终还是会落到应用层代码上。既然要落到应用层,那不如选一个最合适的落点。Swoole 常驻内存的特性,让它成为 PHP 技术栈里做流量路由的最佳载体之一。
1.1 为什么选择 Swoole 来做这套路由
传统 PHP-FPM 模式下,每个请求进来都要重新走一遍框架初始化、加载配置、编译代码的过程。想实现灰度发布,常规做法是在 Nginx 里按 IP 段或者 Cookie 转发到不同 upstream,或者干脆在业务代码里写 if/else 判断用户版本。这两种方案都有明显短板:Nginx 层的规则太死板,改一次配置要 reload,复杂条件很难表达,而且 reload 期间如果处理不当会有闪断;业务代码里的判断则会让灰度逻辑散落在各个项目里,A 项目写一套,B 项目又写一套,时间一长根本维护不动。
Swoole 出现之后,情况就不一样了。它的 HTTP Server 常驻内存,进程启动时加载的类、配置可以一直保留在内存里;再加上 Swoole\Table 这种共享内存表,所有 worker 进程都能实时读取同一份规则。这相当于把“路由决策”这件事从 Nginx 和业务代码中抽出来,放到一个独立的、可以热更新的网关层。我在实际项目中验证过,这套路线的维护成本比在 Nginx 里写 lua 低得多,也比在业务代码里埋点干净。
我可以打个比方:传统 PHP-FPM 是一次性餐具,每个请求来都得重新拆一套;Swoole 是反复使用的桌布和碗筷,桌子摆好了一次,后面只需要往桌上换菜。灰度规则就是那道“菜”,你不用重新摆桌子,直接换菜就行了。
1.2 整体架构:路由层和业务层怎么划分
在设计这套方案时,其实有三个候选位置可以放分流逻辑:Nginx 层、业务代码层、Swoole 网关层。我列了一张对比表,方便你看清楚各自的边界:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Nginx 层配置 | 性能好、部署简单、对业务完全透明 | 规则表达能力弱,复杂条件难写,改配置要 reload,灰度规则直观性差 |
| 业务代码层 | 灵活,能直接读业务数据,容易定制 | 侵入性强,多项目重复实现,灰度逻辑容易漏写,测试成本高 |
| Swoole 网关层 | 常驻内存、集中管理、规则可热更新、业务无感知 | 需要额外维护一个网关服务,转发增加一层网络开销 |
我最终选择 Swoole 网关层,不是因为它最先进,而是因为它的成本和收益最平衡。网关层只负责一件事:拿到请求,根据规则决定往哪个目标服务转发,同时把分组结果写进响应头和日志。业务服务完全不知道灰度逻辑的存在,它们还是按普通接口服务来开发。这样灰度规则要是写错了,影响的只是分流结果,不会污染业务代码。而且网关层可以顺带做请求日志、限流、监控上报,基础设施收拢到一个点。
需要注意,这里的“路由”是应用层的流量分发路由,和网络设备里常说的静态路由、策略路由不是一回事,和前端框架里的 vue-router 之类的路由也不一样。很多同事听到“路由”第一反应是路由表、网关 IP,其实这里指的是“这个 HTTP 请求由哪个后端服务来处理”的决策过程。后面我提到的所有路由,都是这个含义。
1.3 路由规则设计:灰度规则和 A/B 规则是两套东西
设计规则时,我一开始把灰度规则和 A/B 实验规则混在一个 JSON 里,结果发现字段互相干扰,灰度要一组“放量比例+白名单”,A/B 要一组“实验组+权重+对照组”,凑在一起非常别扭。后来我把它们拆成两套互不影响的规则结构,共用同一个“规则管理器”,但各自解析各自的字段。
灰度规则的典型结构长这样:
json复制{
"key": "gray_v2",
"type": "gray",
"rule_name": "用户中心v2灰度",
"gray_percent": 10,
"white_list": ["uid:10001", "uid:10002"],
"conditions": {
"ua_contains": ["WeChat"],
"path_prefix": ["/api/user/"]
},
"targets": {
"gray": "192.168.1.66:8080",
"stable": "192.168.1.65:8080"
}
}
灰度规则我用四个字段描述:放量比例、白名单、匹配条件、目标地址。放量比例单位是百分比,内部计算时会换算成 0-999 的千分位桶;白名单可以精确到 uid,也可以写成 IP 段;条件支持 User-Agent 包含匹配、路径前缀匹配、请求头存在性判断。这些都是从实际需求里总结出来的,比如“只让微信浏览器里的用户灰度”“只对用户中心接口灰度”这种需求,直接配置就能搞定。
A/B 实验规则则是另一套结构:
json复制{
"key": "ab_experiment_1",
"type": "ab",
"experiment_id": "exp_user_center_202405",
"groups": [
{ "name": "A", "weight": 50, "target": "192.168.1.65:8080" },
{ "name": "B", "weight": 50, "target": "192.168.1.67:8080" }
]
}
灰度规则的核心是“渐进放量 + 回滚能力”,A/B 规则的核心是“稳定分组 + 多版本对照”。把这两套结构分开,代码实现时逻辑边界会清晰很多,排查问题也不会串。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现原理与关键技术点
规则结构设计好之后,真正落地时还有几个关键的技术点需要想清楚。灰度放量是“见者有份”的随机抽样?还是按用户身份稳定分桶?A/B 测试怎么保证同一个用户不串组?这些问题的答案直接决定了方案的可靠性。
2.1 灰度发布的路由逻辑:渐进放量怎么实现
灰度放量的核心算法其实很简单:把用户标识映射成一个固定整数,再取模落到 0-999 的桶里。比如现在灰度比例是 10%,那桶号在 0-99 的请求就进新版,其余进旧版。用户标识一般用登录后的 user_id,没登录就用 Cookie 里的一份随机 ID。
注意这里的用户标识要选稳定值。我见过有个团队直接用 mt_rand() 来决断,结果同一个用户每次请求都随机进出灰度,用户一会儿看到新界面一会儿看到旧界面,体验极差,灰度期间的线上投诉直接爆了。稳定分桶的意义在于:一个用户在灰度期间始终处于同一个灰度桶,这样用户感知是连续的,后端监控曲线也是平滑的。
具体分桶代码我后面会给出。这里先解释几个设计细节:
- 为什么取模要用 1000 而不是 100?因为比例可以精细到 0.1%,灰度到 12.5% 这种数字时千分位更好表达。桶多,哈希冲突的概率也低一些,分配更均匀。
- 选哈希函数时要注意分布均匀。我直接用
crc32,对绝大多数场景够用;如果流量特别大,可以考虑更均匀的算法,但没必要为了灰度发布过度设计。 - 白名单判断必须放在最前面。内部员工、核心用户、测试账号要先走灰度,然后再按百分比分桶。这个顺序不能反,否则你把白名单加进灰度规则,结果它先被百分比分流挡在门外,白名单就失效了。
2.2 A/B 测试的路由逻辑:稳定分组和流量正交
A/B 测试和灰度的核心差异在于:灰度可以容忍用户偶尔从新版切回旧版,A/B 不行。A/B 实验要求在实验周期内,同一个用户必须固定访问同一个版本,否则最终统计出来的转化率、点击率就是一堆废数据。很多人一开始没意识到这个区别,直接把灰度的“按 uid 哈希取模”复用到 A/B 实验上,结果发现用户分组不稳定,实验数据根本没法看。
稳定性的实现,靠的是“实验 ID + 用户 ID”的组合哈希。组合哈希可以保证:
- 同一个实验里,同一个 uid 永远落在同一个组;
- 不同实验之间,同一个 uid 的分组结果互相独立,不会出现“实验 1 都是 A 组的用户,实验 2 也全是 A 组”这种正交性问题。
代码上就是这么一行:
php复制$bucket = hashToBucket($userId . '_' . $rule['experiment_id']);
注意一定要带上 experiment_id。如果两个实验都用 hashToBucket($userId),那同一个用户在两个实验里的分组会强相关,最后跑出来的数据会出现系统性偏差,你根本分不清是实验本身的效果还是用户群体偏差。
另外,A/B 实验的各组流量比例加起来必须等于 100%。它不像灰度发布那样有“默认版本”,对照组和实验组是同时存在的。灰度发布是“新版逐步取代旧版”,A/B 是“两个版本同时在线,科学比较优劣”,这两个概念对最终数据的解读方式完全不同。
2.3 动态配置与热更新:不重启进程怎么改规则
灰度发布的灵魂在于“动态”。如果每改一次放量比例都要 kill 进程再重启,那这个方案基本等于没有。我在项目里把规则放在 Swoole\Table 中,而不是 PHP 数组。原因是数组每个 worker 进程各存一份,改一个 worker 的变量其他 worker 不知道;Table 是共享内存区,所有进程都能读到同一份数据。
更新规则的入口我暴露成一个管理接口,只有内网能访问,调用时校验 token。接口收到新的规则后,先做一次 JSON 校验和必填字段检查,再写入 Table。写完之后灰度流量会在下一个请求立即生效,不需要 reload 进程。
这里有几个容易踩的坑,我逐一说明。第一,Table 的字符串字段必须预先指定长度,写超了会被直接截断,比如你规则 JSON 有 3000 个字符,但字段定义时只给了 2048,那规则就会被截断,后面的字段解析失败。第二,同一个 Table 不能重复 create(),否则会报 “table already exists”,这个问题在 reload 之后特别容易触发。第三,并发写同一个 key 时需要用 Lock 或者通过主进程统一写,否则高并发更新可能读到半截数据。第四,规则更新建议带版本号,方便排查“这条规则到底什么时候生效的”。
3. 实操:基于 Swoole 实现灰度与 A/B 路由
理论讲完,上代码。这一部分我会把网关服务从零开始搭建,包括规则管理器、分桶函数、转发逻辑,以及和 ThinkPHP 集成时的注意事项。代码是我实际跑过的简化版本,去掉了鉴权和复杂错误处理,但核心流程完整。
3.1 环境准备
首先确认环境版本。我推荐 PHP 7.4 以上,Swoole 4.8 以上,如果你用 ThinkPHP,需要同时检查 think-swoole 扩展的版本匹配。Swoole 5.x 我也用过,核心 API 变化不大,但建议先在小流量环境验证。
安装 Swoole 扩展的过程很简单:
bash复制pecl install swoole
安装完之后确认扩展加载成功:
bash复制php --ri swoole
只要能看到 swoole support => enabled,且版本号大于 4.8,就可以继续。Swoole 扩展安装时需要开启一些编译参数,比如协程、HTTP、Table 等,这些在默认安装中是开启的,不用额外操心。
如果你使用的是 ThinkPHP 的 think-swoole 扩展,建议先跑一下 composer show topthink/think-swoole 查看版本号。不同版本的 Manager 类方法差异很大,后面会详细讲一个典型报错。
3.2 核心代码实现:路由表、分桶算法、转发
先写规则管理器。它负责把规则 JSON 塞进 Swoole\Table,以及按 key 取出来。这里要特别强调 Table 的字段长度,我在实际开发中被截断坑过两次。
php复制<?php
use Swoole\Table;
class RuleManager
{
private Table $table;
public function __construct()
{
$this->table = new Table(1024);
// 注意:字符串字段必须指定长度,规则 JSON 可能很长,这里给 4096
$this->table->column('rule', Table::TYPE_STRING, 4096);
$this->table->column('version', Table::TYPE_INT, 8);
$this->table->create();
}
public function setRule(string $key, array $rule): bool
{
$json = json_encode($rule, JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
return $this->table->set($key, [
'rule' => $json,
'version' => time(),
]);
}
public function getRule(string $key): ?array
{
$row = $this->table->get($key);
if (!$row || empty($row['rule'])) {
return null;
}
return json_decode($row['rule'], true);
}
}
然后写分桶函数。这个函数是整个路由方案的核心,灰度分桶和 A/B 实验分桶都复用它。注意哈希因子的选择,我在这里踩过坑:一开始没带规则名,导致两个实验互相干扰。
php复制<?php
/**
* 将用户标识哈希到 0-999 的桶
*/
function hashToBucket(string $seed): int
{
// crc32 返回 32 位整数,取模 1000
return crc32($seed) % 1000;
}
/**
* 灰度判断:返回 true 表示命中灰度流量
*/
function shouldGrayRelease(array $rule, string $userId): bool
{
// 白名单优先
$whiteList = $rule['white_list'] ?? [];
if (in_array('uid:' . $userId, $whiteList, true)) {
return true;
}
$percent = intval($rule['gray_percent'] ?? 0);
if ($percent <= 0) {
return false;
}
if ($percent >= 100) {
return true;
}
// 注意这里带上了规则 key,保证不同灰度规则之间分桶独立
$bucket = hashToBucket($userId . '_' . ($rule['key'] ?? 'gray'));
return $bucket < $percent * 10;
}
/**
* A/B 分组:返回组名,如 A、B
*/
function getAbGroup(array $rule, string $userId): ?string
{
$groups = $rule['groups'] ?? [];
if (empty($groups)) {
return null;
}
$total = 0;
foreach ($groups as $group) {
$total += intval($group['weight'] ?? 0);
}
if ($total <= 0) {
return null;
}
// 稳定分桶:带上 experiment_id
$bucket = hashToBucket($userId . '_' . ($rule['experiment_id'] ?? 'ab'));
// 这里把桶号归一化到总权重范围内
$hit = ($bucket / 1000) * $total;
foreach ($groups as $group) {
if ($hit < intval($group['weight'])) {
return $group['name'];
}
$hit -= intval($group['weight']);
}
// 兜底返回最后一个组
return $groups[count($groups) - 1]['name'];
}
最后是网关转发的主要逻辑。我用一个 Swoole HTTP Server 监听 9501 端口,在 onRequest 回调里按规则路由。
php复制<?php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Coroutine\Http\Client;
$http = new Server('0.0.0.0', 9501);
$ruleManager = new RuleManager();
// 初始化规则,正常应该从数据库加载,这里直接写死演示
$ruleManager->setRule('gray_v2', [
'key' => 'gray_v2',
'type' => 'gray',
'gray_percent' => 10,
'white_list' => ['uid:10001'],
'targets' => [
'gray' => '192.168.1.66:8080',
'stable' => '192.168.1.65:8080',
],
]);
$ruleManager->setRule('ab_experiment_1', [
'key' => 'ab_experiment_1',
'type' => 'ab',
'experiment_id' => 'exp_user_center_202405',
'groups' => [
['name' => 'A', 'weight' => 50, 'target' => '192.168.1.65:8080'],
['name' => 'B', 'weight' => 50, 'target' => '192.168.1.67:8080'],
],
]);
$http->on('request', function (Request $request, Response $response) use ($ruleManager) {
// 1. 取用户标识,优先从 Header/Cookie/GET 里取
$userId = $request->header['x-user-id'] ?? $request->cookie['user_id'] ?? $request->get['user_id'] ?? '';
if (!$userId) {
// 拿不到稳定标识,退化为按 IP 分桶,但这种情况尽量少
$userId = 'ip_' . ($request->server['remote_addr'] ?? 'unknown');
}
// 2. 先判断灰度规则
$grayRule = $ruleManager->getRule('gray_v2');
if ($grayRule && shouldGrayRelease($grayRule, $userId)) {
proxy($request, $response, $grayRule['targets']['gray']);
return;
}
// 3. 再判断 A/B 实验规则
$abRule = $ruleManager->getRule('ab_experiment_1');
$group = $abRule ? getAbGroup($abRule, $userId) : null;
if ($group !== null) {
// 按组找目标地址
foreach ($abRule['groups'] as $g) {
if ($g['name'] === $group) {
proxy($request, $response, $g['target']);
return;
}
}
}
// 4. 默认走稳定版本
proxy($request, $response, '192.168.1.65:8080');
});
/**
* 把请求代理到目标服务,并把结果返回给客户端
*/
function proxy(Request $request, Response $response, string $target)
{
[$host, $port] = explode(':', $target);
$client = new Client($host, intval($port));
// 复制请求头
$headers = $request->header ?? [];
// 移除 hop-by-hop 头,避免转发时出问题
unset($headers['connection'], $headers['keep-alive']);
$client->setHeaders($headers);
// 复制 POST 数据
if (isset($request->rawContent()) && $request->rawContent() !== '') {
$client->setData($request->rawContent());
}
$uri = $request->server['request_uri'] ?? '/';
if (!empty($request->server['query_string'])) {
$uri .= '?' . $request->server['query_string'];
}
if (!$client->execute($uri)) {
$response->status(502);
$response->end('bad gateway');
$client->close();
return;
}
$response->status($client->statusCode);
foreach ($client->headers as $key => $value) {
$response->header($key, $value);
}
$response->end($client->body);
$client->close();
}
$http->start();
这段代码有几点值得展开说明:
第一,proxy() 函数里的 $request->rawContent() 是 Swoole 的 Request 对象方法,不是 PHP 原生方法。Swoole 的 Request 对象和 PHP 的 $_POST、$_GET 超全局变量不是一回事,在常驻内存环境下不要依赖超全局变量,它们不会按你预期的方式跨请求保留。
第二,hashToBucket 里用户标识和规则 key 做拼接,这个细节很少有人讲。如果不拼规则 key,只拼 user_id,那所有灰度规则、所有 A/B 实验都用同一个哈希盐,用户分桶之间有相关性。加上规则 key 之后,每个规则的桶分布互相独立,这才是“正交”的流量分配。
第三,目标服务不可达时,直接返回 502。在实际生产环境里,我建议改成“降级到默认服务”而不是
