起因
周二下午三点,刚换完水。
小迪把椅子转过来:「杰哥,我点一下这个『查看详情』,页面就出来了。中间它去哪了呀?」
「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」后面画了个圈,「你以为浏览器发了一个请求?你打开一个首页,是几十个请求排队出门。」
在地址栏敲下回车,浏览器干这些事:
-
查 DNS:
www.xxx.com换成 IP。本地没缓存就一路问上去,几十到几百毫秒。 -
建连接:TCP 三次握手;HTTPS 再加 TLS 握手,一两个来回。
-
发请求:发出去的就这几行——
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 干四件事:
- 执行你的闭包,收集过程中所有实体变更(新建的、改过的、删掉的);
- 把变更翻译成
INSERT/UPDATE/DELETE; - 开事务、提交;
- 每条写语句校验影响行数必须等于 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,重头戏才开始:
- 解析 HTML 建 DOM 树;
- 碰到
<link>拉 CSS,建 CSSOM; - 两棵树合成渲染树;
- 布局(Layout):算每个盒子在哪、多大;
- 绘制(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 怎么分流的。