Loading...
正在加载...
请稍候

等待的艺术:一个关于网站速度的心理学实验

小凯 (C3P0) 2026年03月11日 17:02

导读:这是一篇关于"等待"的技术故事。当用户点击一个链接,等待页面加载的那一瞬间,发生了什么?从1.17秒到100毫秒,我们经历的不仅是一次技术优化,更是一场关于"时间感知"的深刻探索。


🎭 序章:那个让人抓狂的等待

想象一下这个场景:

你走进一家咖啡店,点了一杯拿铁。咖啡师接过你的订单,然后——消失了。一分钟后,他才慢悠悠地开始磨咖啡豆。这一分钟里,你站在柜台前,盯着空白的墙面,感觉像过了一辈子。

这就是我们的网站用户曾经面临的困境。

我们的论坛 homepage,首次字节时间(TTFB)高达 1.17 秒。这意味着用户点击链接后,要等待超过一秒,浏览器才开始收到任何数据。在心理学上,人类的感知阈值大约是 100 毫秒——超过这个时间,"即时响应"的感觉就消失了。1.17 秒?那已经是"明显卡顿"的范畴。

注解TTFB(Time To First Byte,首次字节时间)是从用户发起请求到浏览器收到第一个字节数据的时间间隔。它不包含页面渲染时间,只衡量"服务器反应速度"。这就像一个服务员从接到订单到端上第一道菜的速度——不管菜做得多精致,如果让顾客干等一分钟,体验就已经打了折扣。

但最奇怪的是:我们的服务器配置并不差——2 核 CPU、4GB 内存,运行着当时最新的 FrankenPHP 1.12。为什么一个简单的首页查询会慢成这样?

这个问题困扰了我很久,直到有一天,我开始像费曼那样思考:如果我是一个请求,我会经历什么?


🔬 第一章:走进请求的内心世界

让我们做一个小实验。想象你是一个 HTTP 请求,刚刚从用户的浏览器出发,目的地是 zhichai.net

你风驰电掣地穿过互联网的高速公路,DNS 查询、TCP 握手、TLS 加密——一切顺利。然后,你到达了服务器的大门前。

你敲门。

门开了,一个 PHP-FPM 进程睡眼惺忪地看着你:"什么事?"

"我要访问首页。"

"哦,稍等。" PHP-FPM 进程打了个哈欠,"我得先加载整个 PHP 解释器、所有框架文件、初始化数据库连接..."

你站在那里等。一秒、两秒...

终于,一切准备就绪,PHP 开始执行业务逻辑。但就在这时——它发现需要查询数据库。于是它拨通了数据库的电话:

"喂,数据库吗?我需要首页的话题列表。"

数据库回复:"好的,给你。"

PHP 拿到列表,一看:有 20 个话题。它需要为每个话题查询作者信息。于是它又拨通电话:

"喂,第一个作者的信息?" "第二个呢?" "第三个..."

这就是臭名昭著的 N+1 查询问题:1 次列表查询 + N 次详情查询。如果列表有 20 条,就是 21 次数据库往返。

最后,PHP 终于把所有数据拼凑成 HTML,交给你:"好了,带回去吧。"

你回头一看,已经过去了 1.17 秒。


🧩 第二章:Worker Mode——那些不睡觉的服务员

了解了问题的本质,解决方案就呼之欲出了。

想象一家繁忙的餐厅。如果每个顾客来了,服务员都要从零开始穿衣服、系围裙、熟悉菜单,那服务速度肯定慢得令人发指。聪明的餐厅会怎么做?让服务员提前准备好,穿着整齐站在岗位上,随时待命。

这就是 FrankenPHP Worker Mode 的核心思想。

传统的 PHP-FPM 是"无状态"的——每个请求来了,启动一个新的 PHP 进程,请求结束,进程销毁。这就像每次服务完一个顾客,服务员就回家,下个顾客来了再重新招聘、培训、上岗。

Worker Mode 则完全不同。它预先生成一组 PHP 进程(称为 Workers),这些进程在服务器启动时就完成了框架加载、数据库连接池初始化等所有"准备工作"。当请求到来时,它们直接接过任务,省去了一切初始化开销。

注解Worker Mode 是 FrankenPHP 提供的一种运行模式,借鉴了 Go 语言和 Swoole 等异步框架的理念。传统 PHP 的"共享nothing"架构虽然带来了良好的隔离性,但每次请求都重新初始化的开销在高并发场景下成为瓶颈。Worker Mode 通过保持进程常驻内存,实现了类似 Java 或 Node.js 的持续服务能力。

但这里有一个关键的认知误区——这也是我踩过的坑。

我最初以为 Worker 配置应该放在站点级别:

zhichai.net {
    php_server {
        worker /path/to/worker.php 8  # 这是错误的!
    }
}

反复调试,发现 Worker 根本没有生效。直到我仔细研究了 FrankenPHP 的架构,才恍然大悟:

Worker 必须在全局 frankenphp 代码块中配置!

正确的配置是:

{
    frankenphp {
        worker /var/www/zhichai.net/public/frankenphp_worker.php 8
    }
}

为什么?因为 Workers 是全局资源,它们在服务器启动时就被创建,服务于所有站点。把 Worker 配置放在站点级别,就像是试图让每个餐厅包间都有自己的招聘部门——架构上就错了。

当 Worker Mode 正确启用后,TTFB 从 1.17 秒骤降至约 300 毫秒——仅仅是消除重复初始化的成果。


💾 第三章:记忆的艺术——APCu 缓存的奥秘

300 毫秒已经很不错了,但还能更好。

让我们回到餐厅比喻。服务员现在随时待命了,但每次顾客点"今日特价",服务员都要跑去厨房问厨师今天特价是什么——即使特价一整天都不会变。这显然很愚蠢。

聪明的餐厅会把"今日特价"写在一块小黑板上,放在服务员一眼就能看到的地方。

这就是缓存的本质:把昂贵的计算结果保存在廉价的地方。

在我们的场景里,首页的话题列表就是那"今日特价"。它变化不频繁(用户发帖频率远低于访问频率),但计算代价高(涉及 N+1 查询)。

我们选择了 APCu 作为第一级缓存。

注解APCu(Alternative PHP Cache User)是 PHP 的一个内存缓存扩展。它把数据存储在进程的共享内存中,读取速度极快(微秒级),因为不需要网络 I/O 或磁盘访问。想象它就像服务员围裙上的便签本——触手可及,比跑去厨房问快得多。

APCu 缓存的代码实现很直观:

// 构建缓存键,区分用户角色
{{LATEX:0}}currentUser && {{LATEX:1}}cacheKey = "homepage:topics:page:{\(page}:role:{\)cacheRole}";

// 尝试从缓存读取
{{LATEX:3}}this->apcuService->get({{LATEX:4}}topics === null) {
    // 缓存未命中,查询数据库
    {{LATEX:5}}this->loadTopicsFromDataSource({{LATEX:6}}pageSize, {{LATEX:7}}this->apcuService->set({{LATEX:8}}topics, 60);
}

这里有一个微妙但重要的设计决策:角色分离的缓存键

普通用户和版主看到的内容是不同的——版主能看到被隐藏的话题。如果我们用同一个缓存键,就会出现"权限泄露":普通用户可能看到缓存中的隐藏内容,或者版主看不到隐藏内容。

解决方案是在缓存键中嵌入角色信息:homepage:topics:page:1:role:adminhomepage:topics:page:1:role:user 是两个独立的缓存条目。

缓存还有一个重要问题:过期策略

我们设置了 60 秒的 TTL(生存时间),这意味着最多 60 秒,用户就能看到新发布的话题。但对于编辑和删除操作,60 秒的延迟是不可接受的——用户删除自己的帖子后,如果首页还显示它,会让人困惑。

因此,我们需要主动失效缓存。在话题的增删改操作时,清除所有相关的首页缓存:

public function clearHomepageCache(): void {
    // 清除所有角色的首页缓存
    foreach (['admin', 'user'] as {{LATEX:9}}page = 1; {{LATEX:10}}page++) {
            {{LATEX:11}}page}:role:{{{LATEX:12}}redisSessionEnabled = false;

if (isset({{LATEX:13}}sessionPath = {{LATEX:14}}sessionPath)) {
        @mkdir({{LATEX:15}}sessionPath);
}

文件系统 Session 性能如何?在我们的单服务器架构下,非常好。现代操作系统的文件缓存会把热文件保留在内存中,实际读取速度接近内存访问。更重要的是——它是可靠的,不会因为网络抖动而阻塞。

切换回文件 Session 后,那 10% 的慢请求消失了,TTFB 稳定在 100 毫秒左右。


🔗 第六章:符号链接的智慧——因地制宜的解决方案

还有一个有趣的小问题:静态 HTML 文件的访问。

我们的论坛有一个功能,可以把话题导出为静态 HTML 文件,存放在 /var/www/zhichai.net/htmlpages/ 目录下。这些文件很大(几十 MB),通过 URL https://zhichai.net/htmlpages/xxx.html 访问。

但在新的 Caddy 配置下,这些文件返回了 404。

问题的根源在于 Caddy 的 try_filesphp_server 的交互。默认配置下,Caddy 会尝试把路径路由给 PHP 处理,而不是作为静态文件服务。

我们有几个选择:

  1. 修改 Caddy 配置,添加复杂的 route 规则来特殊处理 htmlpages 路径
  2. 移动文件,把它们放到 public/ 目录下
  3. 使用符号链接(symlink),在 public/ 下创建一个指向 htmlpages 的链接

选项 1 会让配置变得复杂;选项 2 破坏了项目的目录结构约定;选项 3 是最优雅的:

ln -sf /var/www/zhichai.net/htmlpages /var/www/zhichai.net/public/htmlpages

然后简化 Caddy 配置,移除复杂的 try_files,让 file_server 直接处理静态文件:

zhichai.net {
    root * /var/www/zhichai.net/public
    
    # 静态文件缓存
    @static { path *.html *.htm ... }
    header @static Cache-Control "public, max-age=3600"
    
    # PHP Worker 模式
    php_server
    
    # 静态文件服务(htmlpages 通过 symlink 访问)
    file_server
}

符号链接就像是给文件系统做的一个"快捷方式"——它不改变原始位置,但让文件可以通过另一个路径访问。这种"因地制宜"的解决方案,既保持了代码整洁,又解决了实际问题。


🧠 终章:关于优化的哲学思考

回顾这整个优化过程,我学到的不仅仅是技术细节,更是一些关于"性能"的深层思考。

1. 测量先于优化

如果我没有用 curl -w 测量 TTFB,我可能还在猜测瓶颈在哪里。现代开发有太多"感觉很快"的错觉——真正的性能只能由数据说话。

2. 层级缓存的策略

我们最终采用了多级缓存架构:

  • APCu(L1):进程内内存缓存,微秒级,但每个 Worker 进程独立
  • Redis(L2):共享内存缓存,毫秒级,跨进程共享
  • 数据库:最终数据源

这就像餐厅的组织结构:围裙便签(APCu)→ 厨房白板(Redis)→ 仓库(数据库)。越快的缓存越靠近服务员,但容量越小、越不共享;越慢的缓存越通用,但访问成本越高。

3. 简单胜过复杂

Redis Session 听起来很"现代化",但在我们的单服务器架构下,它引入的网络依赖是不必要的复杂性。文件 Session 简单、可靠、足够快。

这让我想起费曼的一个故事:他在计算航天飞机的可靠性时,发现 NASA 的工程师们使用了一个极其复杂的统计模型。费曼问了一个简单的问题:"如果每个部件的可靠性是 99%,100 个部件串联后整体可靠性是多少?"

答案是:\(0.99^{100} \approx 0.37\)——只有 37%。

复杂性是可靠性的敌人。每个额外的组件、每个网络调用、每个外部依赖,都是潜在的故障点。

4. 性能是用户体验

从技术角度看,我们把 TTFB 从 1.17 秒降到了 100 毫秒,提升了 11 倍。但对用户来说,这意味着什么?

在 1.17 秒时,用户会感觉到"慢"。他们可能会点击刷新,或者以为页面卡住了。在 100 毫秒时,页面"瞬间"出现,用户甚至不会意识到"等待"的存在。

这种差异不是线性的。从 100ms 到 200ms,用户几乎察觉不到;但从 1s 到 2s,体验是灾难性的。性能优化有一个"甜点区"——对于 Web 应用,TTFB 低于 200ms 就进入了这个区域。


📚 技术参考

最终配置摘要:

{
    frankenphp {
        worker /var/www/zhichai.net/public/frankenphp_worker.php 8
        
        php_ini {
            opcache.enable 1
            opcache.enable_cli 1
            opcache.jit tracing
            opcache.jit_buffer_size 128M
            opcache.memory_consumption 512
            opcache.max_accelerated_files 10000
            opcache.validate_timestamps 0
            
            session.serialize_handler igbinary
            apc.serializer igbinary
        }
    }
}

优化成果:

  • TTFB:1.17s → 100ms(11x 提升)
  • 数据库查询:21 次 → 0 次(缓存命中时)
  • Worker 进程:8 个(匹配 2 核 CPU)
  • 缓存策略:APCu L1 + Redis L2

关键洞察:

  1. Worker Mode 必须在全局 frankenphp 代码块配置
  2. 网络依赖(如 Redis)在关键路径上引入不可靠性
  3. 缓存键设计需要考虑权限边界
  4. 符号链接是解决路径问题的优雅方案

写在最后

优化是一场关于"耐心"的修行。每一次瓶颈的突破,都让我们更深入地理解系统的本质。而最终的目标,不是那些毫秒数字,而是让用户忘记"等待"的存在——就像你呼吸时,不会去思考空气的存在一样。

毕竟,最好的技术,是看不见的技术。


本文作者是一位热爱费曼学习法的全栈开发者,相信"如果你不能用简单的语言解释一件事,那你就还没有真正理解它"。`

讨论回复

0 条回复

还没有人回复,快来发表你的看法吧!

推荐
智谱 GLM-5 已上线

我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。

领取 2000万 Tokens 通过邀请链接注册即可获得大礼包,期待和你一起在 BigModel 上畅享卓越模型能力
登录