← 学习路线

本地和线上是两个世界

为什么本地好好的,一上线就不对?又为什么明明改了,页面却纹丝不动?

课型:概念·约 20 分钟·5 个术语

先看出事的那一刻

2026 年 9 月,自动驾驶知识站决定下线所有配图。正文里的配图标记删干净了,校验通过,前端也重新构建了。可在本地打开页面,[figure: 这样的标记原样躺在正文里,像是什么都没改。

校验和构建都通过了,问题不像出在代码里。于是换了一个问法:回答页面请求的,到底是谁? 前端开发服务器把 /api 请求转给 8787 端口。一查,占着 8787 的是一个 7 月 28 日启动的进程,它的脚本文件已经不存在了。

这节课要回答的是:为什么本地好好的,一上线就不对?又为什么明明改了,页面却纹丝不动?

装备几个词

为什么要懂

AI 替你做了

一句“帮我跑起来看看”,AI 就会启动开发服务器、模拟接口,需要时再起一个数据库,还会把它们留在后台,方便你随时打开。

留给你的

分清眼前的页面来自哪个进程、哪个端口、哪一份数据。AI 在后台启动过什么,它自己下一次未必记得。

不懂的代价

对着一份旧的东西反复改、反复验证,越改越乱;或者本地验收全部通过,上线才发现两边根本不是一回事。

同一份代码,两场演出

本地是彩排,线上是正式演出。剧本是同一份,舞台、灯光、演员却不一样。以 E2E Review 为例:

本地(这台电脑)线上
谁在运行开发服务器,改了代码自动刷新构建好的产物和容器,放在托管平台上
前端怎么找到后端开发服务器把 /api 转给 localhost:8787Cloudflare 上的转发脚本把 /api/ 转给 Railway
内容从哪来后端进程启动时读进内存的那一份部署时打进镜像的那一份
数据库得自己在电脑上启动(这台电脑上没有)平台提供的 Postgres
配置.env 文件平台后台的环境变量,数据库地址和端口由平台注入
Node 版本2522,写死在镜像里
谁能访问只有你所有人

表里每一行,都可能造成一次"本地没问题、线上出问题",或者反过来。上线前最稳妥的做法,是按线上的方式构建一次、在本地预览构建结果,而不是只看开发服务器的效果。

端口上的那个人是谁

开发服务器的代理只认端口,不认人:谁占着 8787,/api 请求就交给谁。它不会告诉你对面是什么时候启动的、跑的是哪个版本。

查端口上是谁,两条命令就够:

lsof -nP -iTCP:8787 -sTCP:LISTEN          # 谁占着 8787
ps -o pid=,lstart=,command= -p 进程号     # 它什么时候、用什么命令启动的

9 月那天查到的是:

82587 Tue Jul 28 15:54:00 2026     bun /private/tmp/…/scratchpad/mock-api.mjs

再去找这个脚本,文件已经不存在了。

撕掉菜谱,菜照样在炒

进程启动时,会把代码和要用的数据读进内存,之后就按内存里的那一份运行。E2E Review 的后端是这样读内容的:

const raw = JSON.parse(readFileSync(PATH, "utf8"));

只在启动时读一次。之后内容文件怎么改,它都不知道,除非重启;脚本文件被删,也不影响已经在内存里运行的它。

那个模拟接口,是 7 月 28 日 AI 在一次工作会话里临时起的:真后端需要数据库,这台电脑上没有,只好先起一个只读内容的替身。它用 nohup … & 放进了后台,这种写法的意思就是"关掉终端也别停"。AI 当天在发给站长的优先级清单里写过一句:这个替身是会话临时目录里的脚本,目录被清或者机器重启,它就没了;要么换回正式的后端,要么把脚本正式放进仓库,发布前必须二选一。结果只说对了一半:目录确实被清了,进程却一直活着。

深潜为什么两个月都没被发现+

7 月的旧前端会把正文里的 [figure:…] 画成配图。旧前端配旧内容,看起来一切正常;两个月里,内容本身也只改了三行。

9 月删配图时,前端先换成了新的(开发服务器会自动刷新),不再认识这些标记,后端却还是 7 月那一份。新前端配上旧数据,标记才原样露了出来。

换句话说,要不是这次恰好让两边对不上,这个进程还会一直安静地运行下去。

深潜环境变量:同一份菜谱,墙上贴着不同的当班说明+

E2E Review 需要七个环境变量:数据库地址、登录签名密钥、端口、运行模式和三项大模型配置。本地写在 .env 文件里;线上在 Railway 后台配置,其中数据库地址和端口由平台自动注入。

两边只要有一项不同,行为就可能不同。更隐蔽的是带默认值的变量:缺了不报错,程序照常启动,只是悄悄换了一种行为。第 11 课会讲一次真实事故:.env 里的配置被这台电脑上已有的同名变量悄悄盖掉,结果是一连串 401。

找茬

下面是 E2E Review 本地开发相关的真实代码和命令(节选)。它们合在一起,让一个两个月前的旧进程冒充了后端,而且没有人察觉。站长的需求很简单:本地看到的,应该就是刚改完的内容。

这段 JS / Shell 里埋了 6 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。

查看代码与答案(6 处问题)
 1  // web/vite.config.js(前端开发服务器)
 2    server: { port: 5199, proxy: { "/api": "http://localhost:8787" } },
 3  
 4  // server/config.mjs(后端配置,节选)
 5    port: Number(process.env.PORT ?? 8787),
 6    jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
 7    // Railway 注入 DATABASE_URL;本地开发用 docker compose 起的 pg
 8    databaseUrl: need("DATABASE_URL"),
 9  
10  // server/content.mjs(读取内容快照,节选)
11  const raw = JSON.parse(readFileSync(PATH, "utf8"));
12  
13  // pipeline/validate.mjs(生成内容快照,节选)
14      builtAt: null,
15  
16  # 7 月 28 日,AI 在后台启动本地模拟接口的命令
17  nohup bun /private/tmp/…/scratchpad/mock-api.mjs > /dev/null 2>&1 &
  • 第 2 行 · 中危 · 代理不管对面是谁:开发服务器把 /api 交给 8787 端口上的任何进程,不检查它是谁、什么时候启动、跑的哪个版本。端口上换了人,页面照样显示,只是内容不对。 该问的话:8787 端口上现在是哪个进程?它是什么时候启动的?
  • 第 6 行 · 低危 · 没配密钥,就用人人都看得到的默认值:生产环境有检查,缺了签名密钥会直接退出;但判断是不是生产环境只看 NODE_ENV。哪天换个方式部署、忘了设它,登录签名就会悄悄用上这个写在代码里的默认值。 该问的话:如果线上忘了设置 NODE_ENV,登录签名用的是哪把密钥?
  • 第 7 行 · 中危 · 注释里的本地环境并不存在:仓库里从来没有过 docker compose 文件,照着这句注释没法在本地把数据库跑起来。本地起不来真后端,才有了那个临时模拟接口。 该问的话:照着这句注释,我怎么在本地把数据库跑起来?文件在哪?
  • 第 11 行 · 高危 · 内容只在启动时读一次:进程启动时读一次内容,之后文件怎么改它都不知道。模拟接口复用了这段代码,于是两个月后还在返回 7 月的内容。 该问的话:内容更新以后,这个服务要重启才能看到吗?
  • 第 14 行 · 中危 · 快照不带时间:内容快照的生成时间永远是空的。一个正在运行的进程拿着哪一版内容,从返回结果里看不出来,只能靠猜。 该问的话:页面上现在的内容,是什么时候生成的?
  • 第 17 行 · 高危 · 不受会话管理、没有日志的后台进程:nohup … & 让进程脱离当前会话,输出全部丢进 /dev/null,脚本放在会被清空的临时目录里。会话结束、目录被清,它照样在跑,而且不留任何痕迹。 该问的话:你在后台启动过哪些进程?任务结束时,它们停了吗?

快测

1. 你改了后端的内容,刷新页面没有变化。开发服务器显示前端已经重新加载过。最先该确认什么?

查看选项与答案
  • A. 确认是哪个进程在回答 /api 请求(正确)——前端会自动刷新,普通后端进程不会。改了没重启,或者端口上根本是另一个旧进程,页面都不会变。所以先看回答的是谁、它什么时候启动的。
  • B. 重新构建一遍前端,确保页面用的是最新的代码——重新构建一遍,总能保证用的是最新代码。可前端早就是新的,旧的是后端;重新构建做完,你会觉得“该做的都做了”,转而去怀疑代码。
  • C. 清空浏览器缓存,再强制刷新页面,排除旧缓存的干扰——页面没变,多半是浏览器缓存了旧内容。缓存可能是原因之一,但开发服务器已经确认前端重新加载过,数据是后端给的。先确认回答请求的后端是不是新的。

改了没生效,先问“回答我的是谁”,再问“它是哪一版”。

2. localhost:5199 在你电脑上能正常打开。你把这个链接发给同事,同事打开会看到什么?

查看选项与答案
  • A. 多半是线上的版本,因为开发服务器会同步到线上——开发服务器会自动把改动发到线上。可它只在你这台电脑上运行,线上是另一套:构建好的产物放在托管平台上。localhost 和线上域名没有任何关系。
  • B. 多半打不开,localhost 指向同事自己的电脑(正确)——同事的电脑上没有这个服务。要给别人看,得部署到公开地址,或者用托管平台的预览部署。
  • C. 和你看到的一样,只要你们在同一个 Wi-Fi 或局域网里——同一个 Wi-Fi 里,localhost 应该大家都能用。可 localhost 永远指向打开它的那台电脑,同事打开的是自己的电脑,和是不是同一个网络无关。

本地地址只对本机有效。

3. 后端启动时发现数据库地址没配。下面哪种处理最好?

查看选项与答案
  • A. 启动时就明确报错,说清楚缺了哪一项,然后退出(正确)——E2E Review 就是这样做的:缺数据库地址就退出,数据库连不上也不“假装健康”。越早失败,越容易查。
  • B. 自动换成一个默认地址,先跑起来再说——有个默认地址,服务就能先跑起来。可带默认值的配置缺了不报错,程序照常启动,只是悄悄换了一种行为;带着错误的配置运行,问题要很久以后才暴露。
  • C. 不报错,跳过数据库相关的功能,其余照常运行——别的功能还能用,总比整体报错强。可一部分功能悄悄失效,比整体报错更难发现,出了问题也很难联想到是缺了一项配置。

配置缺失时,明确地失败,比悄悄地凑合好。

判断时刻

你在本地删掉了正文里的配图标记,页面上却还显示着标记原文。AI 查了一圈,给了三个建议。

你先做哪一个?

三个选项各自的代价
  • A. 清空浏览器缓存,重新构建前端——考察排查顺序:代价小,值得一试,但这次没用:前端早就是新的,旧的是后端。更麻烦的是,做完它你会觉得“该做的都做了”,转而去怀疑代码。
  • B. 让后端换一个端口重新启动,再把前端代理改过去——考察绕开还是查清:页面马上就正常了,问题看似解决。可旧进程还在后台占着 8787,别的脚本、别的人照样会连上它;你也永远不知道,刚才到底是谁在回答。
  • C. 先用 lsof 查 8787 端口上是谁,看它的启动时间和命令——考察根因:多花一分钟,直接找到根因:一个 7 月 28 日启动、脚本已被删除的旧进程。停掉它,换成读取最新内容的服务,问题就不会换个样子再出现。

三个做法都可能让页面“看起来好了”,区别在于你知不知道刚才是谁在回答。遇到“改了没生效”,先确认回答你的进程,再动代码。

带走

下次让 AI 做这件事时,问它

  1. 这个项目本地要启动哪些服务?各占哪个端口,谁把请求转给谁?
  2. 你在后台帮我启动过哪些进程?现在还在跑吗?任务结束时,请停掉不再需要的。
  3. 本地和线上有哪些不同:环境变量、数据来源、数据库、Node 版本、构建方式?
  4. 内容或配置更新以后,哪些服务需要重启才能生效?

自己验证

  • lsof -nP -iTCP:端口号 -sTCP:LISTEN:查端口上是哪个进程。
  • ps -o pid=,lstart=,command= -p 进程号:看它什么时候、用什么命令启动。
  • 上线前按线上的方式构建一次,再在本地预览构建结果(比如 npm run build 之后 npm run preview),别只看开发服务器的效果。

改了没生效时,先问一句:回答我的,是哪个进程?