我点一下按钮,中间发生了什么:陪小迪走一遍从浏览器到数据库的全链路

0 阅读1分钟

起因

周二下午三点,刚换完水。

小迪把椅子转过来:「杰哥,我点一下这个『查看详情』,页面就出来了。中间它去哪了呀?」

「Nginx 收到,交给 PHP,PHP 查数据库,返回页面。」

「哦——所以就是……Nginx 把活儿给 PHP,PHP 问数据库,然后一路传回来?」她眨了眨眼,「那这中间……一共几步?」

「大概五步吧。」

老陈端着咖啡路过,停了一下:「五步?静态资源、FPM、框架启动、路由匹配、事务提交、浏览器渲染、页面上的 JS,全算进去是十三道关。你写 PHP 的时候只看得见中间那一道——一出问题就只调那一道,调不动就说『PHP 慢』。」

小迪把笔记本拖过来了:「陈哥,从头讲一遍呗,我想知道每一步到底在干嘛。」

老陈拉开白板:「今天不写代码,走一遍链子。你要记住的不是名词,是哪一步出事会在哪一步现形。」

全景:一次请求要过十三道关

关卡

谁在处理

出事时的现象

1

DNS 解析

运营商 / 云 DNS

页面干脆打不开,ping 也不通

2

安全组 / 防火墙

云服务器网络层

80/443 连不上,Nginx 日志里什么都没有

3

Nginx 接单

Nginx master + worker

502 / 504,access.log 有记录但没进 PHP

4

location 分流

Nginx 配置

静态资源 404、/api/ 返回 HTML

5

交给 PHP-FPM

Nginx ↔ FPM(Unix socket)

502 Bad Gateway,PHP 一行日志都没写

6

FPM 派发 worker

PHP-FPM 进程池

高并发下排队、504,但 CPU 不高

7

框架启动

bootstrap.php

白屏 500,时间耗在 include 上

8

路由匹配

controller/*.php

404,或者路由到了错误的闭包

9

业务 + 取数

MariaDB / Redis

慢,但 CPU 和带宽都很闲

10

写库提交

unit_of_work + MariaDB

报 data in unit of work is expired

11

响应回浏览器

FPM → Nginx → TCP

数据对了但页面没变(缓存)

12

浏览器渲染

浏览器

首屏空白几秒,然后一次性全亮

13

页面上的 JavaScript

浏览器 JS 引擎

页面卡死、点了没反应、接口报错

小迪看着这张表:「十三道……我平时只盯着第 9 关。」

「对。」老陈说,「大多数人的『性能优化』,就是在第 9 关原地打转。」

第 1 关:浏览器发出去的不是一个请求

「先纠正个错觉。」老陈在「1」后面画了个圈,「你以为浏览器发了一个请求?你打开一个首页,是几十个请求排队出门。」

在地址栏敲下回车,浏览器干这些事:

  1. 查 DNS:www.xxx.com 换成 IP。本地没缓存就一路问上去,几十到几百毫秒。

  2. 建连接:TCP 三次握手;HTTPS 再加 TLS 握手,一两个来回。

  3. 发请求:发出去的就这几行——

    GET /user/list HTTP/1.1 Host: www.xxx.com Cookie: token=abc123 Accept: text/html,application/xhtml+xml

小迪举手:「Cookie 为什么在这儿?」

「浏览器知道『同一个网站的请求要带上这一家的钥匙』。」老陈说,「登录态是跟着请求走的,不是跟着服务器走的。后面 PHP 里 cookie('token') 拿到的就是这一行。」

页面 HTML 回来,浏览器接着解析,碰到 <link>、<script>、<img>,再发一批同样的请求。这批请求走的是另一条路。

第 2 关:云服务器门口那道墙

请求出了公网,第一站是你的云服务器。

「这步没代码可看,但它是排查的第一现场。」老陈说,「本地 curl 通的东西,线上不通,八成卡在这儿。」

  • 安全组 / 防火墙:80、443 放行了没?云控制台那两条入站规则不加,网站就是「部署好了打不开」。
  • 公网 IP 与域名解析:域名解析到的是不是这台机器?换过服务器忘了改解析,能查一晚上。

判断方法:telnet 你的域名 80。连不上,就是这一关,跟 PHP 一个字没关系。

「那怎么知道是 Nginx 之前还是之后?」小迪问。

「看 access.log。有记录 = 请求进到 Nginx 了;没记录 = 还在门口。」老陈说,「排障第一步是定位断在哪一段,不是猜。」

第 3 关:Nginx 接单,但它先做减法

请求进到 Nginx。这一关最容易被低估。

我们的配置长这样:

server {
    listen 80;
    root /var/www/php-vibe-coding-frame/public;
    index index.php;

    gzip on;
    gzip_types application/json;

    location /assets/ {
        expires 30d;
        access_log off;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ^~ /api/ {
        include fastcgi_params;
        fastcgi_pass unix:/var/run/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root/api.php;
    }

    location ^~ /sse/ {
        include fastcgi_params;
        fastcgi_pass unix:/var/run/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root/sse.php;
        fastcgi_buffering off;
        fastcgi_read_timeout 3600s;
        gzip off;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

逐行读,有三件反直觉的事。

第一,/assets/ 下面的请求根本不进 PHP。

location /assets/ { expires 30d; access_log off; }

CSS、JS、图片走到这儿,Nginx 直接从磁盘读、直接返回,给 30 天缓存头,日志都不记。

「等等。」小迪把头抬起来,「我的图片不是 PHP 吐出来的?」

「不是。也不该是。」老陈说,「有人把图片交给 PHP 读,再用 readfile() 转发出去。一个 200KB 的图,本来 Nginx 一个 sendfile 就送走了,现在要起一个 PHP 进程、占一份内存、跑一遍解释器。这就是让 PHP 干不该它干的事。」

上次那个列表页,我在 access.log 里数了一下:一个页面出去 87 个请求,其中 84 个是静态文件,全是 Nginx 直接发的,PHP 只处理了 3 个。

第二,页面和接口是两个入口。

普通路径走 try_files $uri $uri/ /index.php?$query_string——找得到静态文件就给文件,找不到交给 index.php。/api/ 开头的请求被指到 api.php。

「为什么要拆?」小迪问。

「这两类请求的输出契约不一样。」老陈写了两行:

  • 页面入口 public/index.php:闭包必须返回字符串(HTML)。返回数组,框架直接抛异常:页面路由必须返回字符串(HTML)。
  • 接口入口 public/api.php:闭包返回什么,统一包成 {"code":0,"msg":"","data":...}。

「还有个好处。」阿杰插嘴,「以前我用别的框架,一个 JSON 接口报错,浏览器里冒出来一大坨 HTML 报错页,前端 JSON.parse 直接崩。现在两个入口分家,页面只可能在页面入口 500,接口只可能在接口入口返回 JSON 错误。」

「比你在每个控制器里 try-catch 强。」老陈点头。

第三,SSE 单独一条路,改了三个默认值。

fastcgi_buffering off;      # 不关:Nginx 攒满整个流才吐给浏览器,流式变成一次性
fastcgi_read_timeout 3600s; # 放宽:长连接没数据就超时,一小时必断
gzip off;                   # 不关:gzip 缓冲同样破坏流式

小迪盯着这三行:「不写会怎样?」

「不写,你的『实时推送』会变成『一分钟后一次性推送』。」老陈说,「然后你以为是 PHP 的锅,在 PHP 里加了一堆 flush(),没用。问题在 Nginx,代码里看不见。」

第 4 关:PHP-FPM 和那条 Unix socket

Nginx 处理不了 PHP,得把活转出去。

fastcgi_pass unix:/var/run/php-fpm.sock;

「FPM 是个啥?」小迪问。

「Nginx 是前台。」老陈说,「它能接单、能送快递(静态文件),但它不会写代码。所以它把单子从门缝塞给里面的办事员。」

「门缝?」

「就是这条 Unix socket。同一台机器上两个进程说话的管道,写起来跟写文件一样,但不落盘,比走 127.0.0.1:9000 少一层网络栈。同一台机器用 socket,跨机器才用 TCP 端口。」

门后面是 PHP-FPM:

  • master 进程:读配置、管进程池,自己不干活。
  • worker 进程:真正执行 PHP 的,一个请求占一个,干完放回去。
  • 进程池策略:dynamic 是闲时少开、忙时多开;static 是永远这么多。

「有个现象特别容易误判。」老陈说,「站突然 504,CPU 只有 10%。第一反应是加机器——真正的原因往往是 worker 全占满了,请求在 socket 里排队,pm.max_children 太小。或者某个接口卡在外部 HTTP 请求上,worker 被拖着不放。」

小迪:「那怎么看出来?」

「看 FPM 慢日志,看 pm.max_children 有没有被打满。CPU 不忙、请求全超时,先查 worker 数量。」

第 5 关:框架启动,十来个 include

FPM 把请求交给 PHP。入口第一行:

include __DIR__.'/../bootstrap.php';

bootstrap.php 全部内容就是十来个 include:

include FRAME_DIR.'/base_function.php';
include FRAME_DIR.'/orm_entity.php';
include FRAME_DIR.'/otherwise.php';
include FRAME_DIR.'/database_mysql.php';
include FRAME_DIR.'/cache_redis.php';
include FRAME_DIR.'/clickhouse.php';
include FRAME_DIR.'/queue_beanstalk.php';
include FRAME_DIR.'/orm_unitofwork.php';
include FRAME_DIR.'/log.php';

config_dir(ROOT_DIR.'/config');

include UTIL_DIR.'/load.php';
include DOMAIN_DIR.'/load.php';
include QUEUE_JOB_DIR.'/load.php';

「就这么点?」小迪有点不敢相信。

「就这么点。」老陈说,「这是它跟 Laravel 这类框架最大的区别。你跑一个 Laravel 项目,光启动就加载几百个类、注册几十个 ServiceProvider、解析一堆容器绑定和注解。这些开销每个请求都要付一遍,因为 PHP-FPM 是一个请求一个进程生命周期,没有常驻内存这回事。」

阿杰接上:「所以 Laravel 上线必须开 route:cache、config:cache,甚至上 Octane 把框架塞进常驻进程。这套框架不用预热——启动开销就是读十个文件。」

「框架越重,你越依赖缓存和常驻进程;框架越轻,你越接近 PHP 本身的样子。Vibe Coding 要的是后面这种。」老陈画了个箭头,「让 AI 改一行代码,不用管缓存失效、不用管容器重绑。」

说句我自己的偏见:我从来不信「框架得够重才显得专业」这套说法。改一行代码之前还得先清缓存、重绑容器,那不叫专业,那叫仪式。

config_dir() 那行也说一句:配置是 PHP 数组,支持按环境覆盖——

config/
├── mysql.php
├── redis.php
└── development/
    └── mysql.php     # ENV=development 时覆盖基础配置

环境变量还是 Nginx 给的:

fastcgi_param ENV 'development';

「线上跑 production 配置、本地跑 development 配置,靠的是同一份代码加一个环境变量。」老陈敲了敲白板,「不是靠改代码。这点很多人做错。」

接着三个 handler:

set_error_handler('http_err_action', E_ALL);
set_exception_handler('http_ex_action');
register_shutdown_function('http_fatal_err_action');

「这三个把一切『爆炸』转成一个体面的响应。」老陈说,「所以框架里所有报错能变成 500 页面或 JSON 错误对象,不会把堆栈直接怼在用户脸上。」

第 6 关:路由不是配置,是代码

这关最反直觉。

别家框架,路由是配置文件或者注解:

// 别家的写法(示意)
Route::get('/user/{id}', [UserController::class, 'show']);

这里是:

// controller/user.php
if_get('/user/*', function ($user_id) {
    return dao('user')->find($user_id);
});

小迪凑近了:「这不就是……在代码里写了个 if?」

「对,就是 if。」老陈说,「而且它真的是直接执行的一个 if。你看 index.php 最后那几行:

include CONTROLLER_DIR.'/base.php';
not_found();

include 把 controller/ 下的文件全读进来,文件里的 if_get(...) 在 include 那一刻就跑了。它内部拿当前请求路径去匹配,匹配上就执行闭包,然后 exit。」

「那它得把每个路由都试一遍?」

「就是一个个试。匹配函数朴素得不能再朴素:

function route(string $rule): array
{
    $reg = '/^'.str_replace('\*', '([^\/]+?)', preg_quote($rule, '/')).'$/';

    preg_match_all($reg, uri_info('path'), $matches);
    ...
}

* 变成捕获组,/user/123 被拆成 ['123'] 塞给闭包。」

小迪皱眉:「一个个试,不慢吗?」

「算账。」老陈说,「一次 preg_match 微秒级。200 条路由最坏试 200 次,零点几毫秒。Laravel 那边的路由缓存,其实也是把几百条路由编译成一个静态数组或者一段匹配代码,它干的也是这件事。区别是这里的路由表就是代码本身,你改一行路由,不用重建缓存。」

「哦——所以它就是……一份不用编译的路由表?」

「还有个副作用你可能想到了。」老陈说,「if_get 里先判方法:

function if_get(string $rule, closure $action)
{
    if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
        return;
    }
    if_any($rule, $action);
}

不是 GET 就直接跳过这个文件里所有 GET 路由,连正则都不用跑。一个 POST 请求,在 GET 路由堆里几乎没成本。」

第 7 关:MariaDB 和 Redis 各管什么

路由命中,闭包开始跑业务。

if_get('/user/*', function ($user_id) {
    return dao('user')->find($user_id);
});

dao('user') 拿到 DAO 实例,find() 背后是 MariaDB 一次查询。

「它俩分工是什么?」小迪问。

「MariaDB 存不能丢的东西,Redis 存能重算的东西。」老陈说。

在这套框架里,角色很具体:

  • MariaDB:真数据。MySQL 协议、InnoDB 引擎、事务、行锁。ORM 走 PDO 预处理语句(prepare + execute),参数和数据分开送到数据库——防 SQL 注入的正道,不是自己拼字符串再转义。
  • Redis:缓存 + 全局 ID 生成。Entity 的主键 id 靠 Redis 的 INCR 拿——INCR 是原子操作,多个进程同时要 ID 也不会撞。

小迪:「为什么不用数据库自增?」

「业务上常常需要先生成 ID、再拼结构。有时候一个请求要写多张表,还得先知道主键才能建关联。数据库自增要等 INSERT 完才知道 ID,INCR 是先拿到号再去办手续。」老陈说,「代价是 ID 会有空洞——号发了但业务失败了。绝大多数场景不是问题。」

阿杰插了一句:「提醒一句:Redis 是内存库,默认不是持久化存储。拿它当缓存、当计数器、当锁都行,别当唯一的账本。」

第 8 关:写操作,闭包跑完自动落库

写操作是这套框架最特别的地方。

if_post('/order', function () {
    $order = order::create(input('user_id'), input('amount'));
    return $order;
    // 没有 $order->save(),闭包一结束,自动 INSERT
});

为什么不用手写 save()?入口处那个拦截器把所有路由闭包包了一层:

if_verify(function ($action, $args) {
    return unit_of_work(function () use ($action, $args) {
        $data = call_user_func_array($action, $args);
        ...
    });
});

unit_of_work 干四件事:

  1. 执行你的闭包,收集过程中所有实体变更(新建的、改过的、删掉的);
  2. 把变更翻译成 INSERT / UPDATE / DELETE;
  3. 开事务、提交;
  4. 每条写语句校验影响行数必须等于 1,不等于 1 就当乐观锁冲突,回滚。

第 4 步就是那句报错的来源:

data in unit of work is expired

小迪:「这句我在日志里见过!当时完全没看懂。」

「翻译成人话:你读到这条数据的时候它是 A 版本,写回去的时候已经变成 B 版本了,说明你操作期间有别人动过它,这次写入作废。」

「那……算并发事故吗?」

「算,而且是好事。」老陈说,「它不上锁排队,它是先赌没人改,写的时候对一下版本号,对不上就认输。表里那个 version 字段就是干这个的,每次更新 +1,写的时候带上旧版本当条件:

UPDATE order SET amount = ?, version = version + 1
WHERE id = ? AND version = ?

影响行数是 0,就说明版本被推进了。」

小迪:「认输之后呢?」

「业务上要么重试,要么把冲突明确告诉用户。数据不会坏——脏写没进去。比两个请求各写一半、最后谁也不知道谁赢了强。」

第 9 关:回程,页面不是一次请求画出来的

数据拿到,render() 渲染模板返回 HTML 字符串,入口处加个头:

header('Content-type: text/html');
return $data;

反向走一遍:PHP-FPM → Unix socket → Nginx → TCP → 浏览器。

浏览器拿到 HTML,重头戏才开始:

  1. 解析 HTML 建 DOM 树;
  2. 碰到 <link> 拉 CSS,建 CSSOM;
  3. 两棵树合成渲染树;
  4. 布局(Layout):算每个盒子在哪、多大;
  5. 绘制(Paint):画像素。

「这解释了那个你肯定遇到过的现象。」老陈说,「首屏空白两秒,然后『唰』一下全出来。不是 PHP 慢——HTML 早到了,但它引用的 CSS/JS 还没回来,浏览器不敢画,只能白屏等着。」

小迪点头:「所以那些 CSS、JS 就该走 /assets/ 让 Nginx 直接发。」

「对。这跟第 3 关是同一件事的两端。」老陈说,「一个页面能不能秒开,取决于有多少个东西必须等。@import 串起来加载、<script> 不加 defer、图片该走 CDN 却让 PHP 发,这些都在增加等待。」

第 10 关:页面上的 JavaScript

「还没完。」老陈说,「HTML 和 CSS 画完,只是把静态画面摆好了。交互是页面上的 JavaScript 干的。」

小迪:「JS 不就是在浏览器里跑的小脚本吗?」

「是,但它在这个链路里的身份很特殊。」老陈把第 13 关圈出来,往回画了一条箭头,指回第 3 关,「JS 可以随时把刚才那条链子从头再走一遍。」

先说 JS 自己怎么被执行:

<script src="/assets/app.js"></script>
<div id="list"></div>

浏览器解析 HTML,走到这行会停下来:下载 app.js、交给 JS 引擎(Chrome 的 V8、Safari 的 JavaScriptCore)编译执行,执行完才继续往下解析 DOM。

「<script> 放 <head> 里又不加参数,页面就白屏等着你那个 JS 文件。」老陈说,「两种解法:

<script src="/assets/app.js" defer></script>  
<script src="/assets/app.js" async></script>  

defer 是别挡着解析,async 是下完就跑、谁先到谁先跑。依赖顺序的脚本用 defer,独立的统计脚本用 async。」

然后是这关最关键的机制——JS 主线程就是渲染那条线程。

「浏览器要解析 HTML、算布局、画像素,还要跑你的 JS,这几件事共用一条线程。」老陈说,「你在页面里写一个十万次的 for 循环,会怎样?」

小迪想了一下:「页面……卡住不动?」

「对。不是变慢,是完全不动,因为渲染根本没机会排队。所以『点了没反应』八成不是后端慢,是主线程被你的 JS 占着。」

阿杰接上:「那怎么知道是被占了?」

「Performance 面板录一段,看有没有一个长任务横在那儿,通常还带一行红字说它阻塞了渲染。别一看到卡就去查接口耗时。」

链子在这儿拐回来

JS 里最常见的动作是改 DOM 和发请求:

fetch('/api/user/123')
  .then(res => res.json())
  .then(data => {
    document.querySelector('#name').textContent = data.data.name;
  });

「注意这个 URL:/api/user/123。」老陈敲了敲白板上第 3 关的位置,「它又回到第 3 关了。」

小迪一下坐直了:「哦——那条 location ^~ /api/ 就是给它准备的?」

「对。三个分支:

  • /assets/app.js → location /assets/,Nginx 直接返回,不进 PHP;

  • /api/user/123 → location ^~ /api/,指向 api.php,只返回 JSON;

  • JS 拿到的 JSON 是这个结构:

    {"code":0,"msg":"","data":{"id":123,"name":"小迪"}}

前端判断成功失败只看 code === 0,不管后端具体怎么写的。」

「那接下来呢?」小迪追着问。

「JS 把数据写进 DOM,textContent 一改,浏览器发现节点变了,重新布局(重排)和绘制(重绘),回到第 12 关。」

老陈把这条路线描成红色:

JS fetch → /api/ → Nginx → PHP-FPM → api.php → MariaDB/Redis → JSON
   → JS 改 DOM → 重排 → 重绘 → 用户看到数字变了

「真实的页面就是这样。浏览器打开时链子走一遍,页面跑起来之后 JS 让它一圈一圈转。」他说,「所以你会遇到这些:

现象

位置

页面点了没反应

第 13 关:JS 报错 / 主线程被长任务占住

没报错但数据不更新

第 13 关:接口返回了,JS 没写进 DOM

接口 404、控制台刷红

第 13 关:请求路径不对,没命中 /api/ 分流

列表滚动特别卡

第 13 关:每改一次 DOM 触发一次重排,改了几百次

数字是旧的

第 11 关:浏览器缓存,或者 JS 拿的是本地旧数据

「最后一条单独说。」老陈说,「很多人查半天 PHP,结果是浏览器把 /api/user/123 缓存了。排查顺序永远是:先看控制台 Network 里那个请求有没有真发出去、返回了什么,再往服务端看。」

小迪把这几行抄下来,抬头问:「第 13 关和我们后面要讲的队列有关系吗?」

「有,方向相反。」老陈说,「JS 让页面更活跃,队列让请求更轻。一个加,一个减。」

第 11 关:不该待在请求链路里的活

请求链路里每多一件慢活,用户就多等一秒。所以框架把两类活挪出去了:

  • SSE(/sse/ 入口):要长连接的推送单独走一条路,Nginx 关缓冲、放宽超时,一路流式返回。

  • 队列(Beanstalkd + supervisor):发短信、发邮件、生成报表、调外部接口,统统投进队列立刻返回。supervisor 常驻几个 worker 慢慢消费:

    [program:php-vibe-coding-frame_queue_worker] command=/usr/bin/php /var/www/php-vibe-coding-frame/public/cli.php queue:worker --tube=default numprocs=5 restart=always

    if_unit_of_work_executed(function () use (user) { queue_push('send_notification', ['user_id' => user->id]); });

「注意那个钩子的名字。」老陈说,「if_unit_of_work_executed——事务成功提交之后才投递,不是流程走到这里就投。否则你会碰上那个经典事故:通知发了,事务最后回滚了,用户收到『你的订单已创建』,订单根本不存在。」

收尾:让小迪复述一遍

「来,你自己说一遍。」老陈把笔递给她。

小迪看着白板:

「浏览器先查 DNS、握手,发请求,带上 Cookie。请求到云服务器,先过安全组。」

「嗯。」

「Nginx 接单,先看路径——图片 CSS 这种它自己就发了,不进 PHP。页面的交给 index.php,/api/ 的交给 api.php,/sse/ 的单独一条路。」

「然后?」

「Nginx 从 Unix socket 把活塞给 PHP-FPM 的 worker。worker 里跑 PHP,先 include 十来个文件把框架支起来,注册好错误处理。」

「再然后?」

「include 路由文件,路由就是一个一个 if 试过去,试中了执行闭包、exit。闭包里取参数、查 MariaDB。要写的时候框架已经在外面套了事务,版本号对不上就整个回滚。」

「最后?」

「返回 JSON 或者 HTML,原路走回去。浏览器接到 HTML,还要建 DOM、建 CSSOM、布局、绘制。页面不是一次请求画出来的,它是一次请求起了个头,然后几十个小弟各自去搬砖。」

「还有。」她补了一句,「页面画完不算完。上面的 JS 接着跑,一发 fetch('/api/...'),整条链子又从头走一圈,回来改 DOM、再重排重绘。浏览器打开时这链子走一遍,页面跑起来之后它一直转。」

老陈愣了一下,笑了:「这句比我总结得好。」

阿杰在旁边摸鱼五分钟,也听明白了:「我以前页面慢就加 PHP 缓存,一大半时间其实花在 Nginx 前面和浏览器后面,根本没在软肋上使劲。」

「今天唯一的结论。」老陈把咖啡喝完,「一次请求快不快,不取决于你 PHP 写了多少行,取决于你让它做了几件不该它做的事。」

附:排查清单

现象

先去哪看

网站完全打不开,telnet 不通

第 2 关:DNS / 安全组

502 Bad Gateway,PHP 无日志

第 5 关:FPM 没起 / socket 路径不对

全站超时但 CPU 很低

第 6 关:worker 池被打满、或接口阻塞

静态资源 404、/api/ 返回 HTML

第 4 关:location 分流写错

实时推送变成一次性发放

第 4 关:fastcgi_buffering 没关

首屏空白两秒然后一次性出现

第 9 关:CSS/JS 阻塞渲染

页面点了没反应、整个卡住

第 13 关:JS 报错或主线程被长任务占住

控制台接口 404 / 请求路径不对

第 13 关:没命中 /api/ 分流

数据是旧的

第 11 关:缓存,或 JS 拿的是旧数据

data in unit of work is expired

第 8 关:乐观锁冲突,非致命

线上跑的是本地配置

第 5 关:fastcgi_param ENV 没传对

想自己走一遍?

git clone https://github.com/smarty-kiki/php-vibe-coding-frame my-project && cd my-project
sh project/tool/start_development_server.sh   # 需要 Docker

打开 http://localhost,你会看到 hello world。照第 3 关的配置翻 project/config/production/nginx/,看你自己那条 location 怎么分流的。