fastcgi_finish_request 救 ACK 不救业务 Token:PHP-FPM 双时钟模型踩坑
接入某第三方开放平台的回调时,我们遇到了一个让人抓狂的现象:回调处理器明明返回了 200 OK,上游不再重试,但关键业务调用(使用一次性 token 发送消息)却稳定失败,错误码固定是 token 已失效。
团队第一反应是加 fastcgi_finish_request(),"异步化"处理,避免超时。加完之后,上游重试消失了——但业务还是失败。这才意识到:我们误解了这个函数能做什么。
现象
第三方平台的回调带有两个独立的时效约束:
- 外层 ACK 时钟(5s):5 秒内必须响应
success,否则上游判定失败并重试。 - 内层业务 token 时钟(20s):回调携带的一次性 token,必须在 20 秒内消费,否则失效(errcode 类似
41050)。
我们的回调处理链路原本的执行顺序是:写日志 → 同步用户数据 → 打标签 → 调自动标签服务 → 最后才消费 token 调下游 API。整条链路耗时超过 80 秒。
加了 fastcgi_finish_request() 之后:上游 5s ACK 问题解决,但 token 消费仍排在链路尾部,80 秒后调用,token 早已在第 20 秒失效。现象变成:上游不重试了,业务永远失败。
根因
PHP-FPM 存在两个完全独立运转的时钟,fastcgi_finish_request() 只能影响其中一个:
第三方服务端
│ T=0 POST /callback(携带一次性 token)
▼
PHP-FPM worker
│
│ ⏱ 客户端 ACK 时钟(5s)
│ ↓ PHP-FPM 持有 fd
│ ↓ fastcgi_finish_request() 主动关 fd
│ ↓ 上游收到 200 → 不再重试 ✓
│
│ T=3 fastcgi_finish_request()
│ T=4 ~ T=80 继续执行副作用 + token 消费
│
▼
第三方服务端(另一个内部计时器)
│ ⏱ 业务 token 时钟(20s)
│ ↓ 从 T=0 开始,由远端服务独立维护
│ ↓ 与 PHP-FPM fd 状态完全无关
│ T=20 token 失效 → errcode=41050
关键事实:fastcgi_finish_request() 操作的是 PHP-FPM 进程的文件描述符,让客户端(上游)提前收到 HTTP 响应。但第三方服务端的业务 token 计时器从不感知我方系统的 fd 状态——它从 token 生成那一刻起独立倒计时,不受任何 PHP 侧操作影响。
很多人把"异步化"和"不再受时效约束"画等号。实际上 fastcgi_finish_request() 做的只是关闭 fd、让上游提前收到响应。PHP 进程继续跑,但远端服务的业务 token 依然在倒计时。加了这个函数,上游 ACK 问题解决,业务 token 失效问题丝毫没变。
正解
根据同步阶段耗时 X 选择对应方案:
- X < 5s:直接同步,不需要
fastcgi_finish_request。 - 5s < X < 20s:
fastcgi_finish_request合法有效——上游 ACK 得到保证,token 业务时钟仍在窗口内,业务能成功。 - X > 20s:必须重构链路,把 token 消费前置。
我们的修复方案是将 token 消费调用(send)提到链路最前段,副作用(打标签、自动标签、完整同步等)后置到 fastcgi_finish_request() 之后:
public function handleCallback()
{
// 1. 写审计日志(快速 insert)
Db::name('callback_log')->insert(['token' => $token, 'raw' => $rawData]);
// 2. 最小预取:只拿 send 必需的配置
$config = Db::name('welcome_config')->where('id', 1)->find();
if (!$config || !$config['is_active']) {
echo 'success';
fastcgi_finish_request();
return;
}
// 3. 最小 upsert:只同步 send 依赖的字段(如昵称)
$nickname = $this->fetchNickname($externalUserId);
Db::name('external_contact')->insertOrUpdate([
'external_userid' => $externalUserId,
'name' => $nickname,
]);
// 4. ★ 业务时钟内必须完成:消费 token
$this->sendWelcomeMessage($externalUserId, $token, $userId);
// 5. ACK 给上游,关闭 fd
echo 'success';
session_write_close(); // session 锁必须在 fastcgi_finish_request 前释放
fastcgi_finish_request();
// ===== 以下不依赖 token 有效性,fd 已关,上游不再等待 =====
// 6. 副作用(失败只记日志,不影响主流程)
try {
$this->syncTags($externalUserId);
$this->autoTag($externalUserId);
} catch (\Throwable $e) {
Log::error('side-effect failed: ' . $e->getMessage());
}
}把需要消费业务 token 的调用挪到 fastcgi_finish_request() 之前,把所有不依赖 token 的副作用挪到之后。fastcgi_finish_request 只负责防上游 ACK 重试,业务 token 的窗口靠链路前置来保障。
fastcgi_finish_request() 后代码继续跑但 fd 已关,抛出的异常用户看不到——必须 Log::error 留痕,否则静默崩。另外 session_write_close() 必须在关 fd 之前调用,否则 session 锁在后续并发请求中会造成阻塞。
一句话外卖
fastcgi_finish_request()管的是"我方 fd",管不了"远端业务计时器"——有多少个外部时钟约束,就要分别找到对应的解法。