PHP 是「嵌在 HTML 里的脚本」:一个请求进来,从头跑到尾,跑完进程就没了。 这条模型决定了后面所有写法——变量不跨请求、连接不跨请求、状态必须外存。 它不优雅,但活得久:部署便宜、模板即页面、和 MySQL 是天生的搭档。
第 21 章是全书唯一一章讲「宿主语言怎么用 MySQL」。 前 20 章都在 SQL 里打转,这一章换个视角:从一门编程语言看数据库。 教材 21.1 是 PHP 语言概述,讲概念、特点、工作原理、结合数据库的优势。 这一篇按这四块讲,重点放在「工作原理」——因为它解释了后面所有的坑。 沙箱里没有 PHP,也没有 MySQL 服务端。 所以 PHP 的原码照教材贴出来、标「不执行」, 而「一个请求一个进程」这件事用 Python 复刻——连接成本、状态外存,都能真量。
一、PHP 是什么:嵌在 HTML 里的脚本
PHP 的原名是 Personal Home Page,后来改成递归缩写 PHP: Hypertext Preprocessor。 名字本身就说明它的定位:预处理超文本——在 HTML 送到浏览器之前,先把里面的一段算出来。
| 时间 | 版本 | 关键变化 |
|---|---|---|
| 1994 | PHP/FI | Rasmus Lerdorf 写的一小组 Perl 脚本 |
| 1997 | PHP 3 | 重写成 C 实现,语法接近今天的模样 |
| 2000 | PHP 4 | 引入 Zend 引擎,性能上一个台阶 |
| 2004 | PHP 5 | 真正的面向对象:类、接口、异常 |
| 2015 | PHP 7 | 重写内部数据结构,比 5.6 快一倍多 |
| 2020 | PHP 8 | JIT、命名参数、联合类型 |
看这张表能看出一个特点:PHP 每次大版本升级,主线都是「性能」和「工程化」,
不是「语法更漂亮」。
这也解释了它在技术圈的口碑:一边被吐槽「函数名不统一、参数顺序反人类」,
一边撑着大量在跑的网站。
PHP 代码写在 <?php ?> 里,文件后缀 .php,交给 Web 服务器执行。
服务器把 <?php ?> 里的部分算成字符串,替换进 HTML,再把整页发出去。
一段最小的 PHP 长这样:
<?php
$link = mysqli_connect('localhost', 'root', '123456');
mysqli_select_db($link, 'shop');
$rs = mysqli_query($link, 'SELECT name FROM tb_g');
while ($row = mysqli_fetch_assoc($rs)) {
echo '<li>' . $row['name'] . '</li>';
}
mysqli_close($link);
?>
这段代码把「连库、查库、输出、关连接」四件事全塞在一个文件里,
上面一行 HTML 都没有——真实的 PHP 页面是反过来的:一大段 HTML,中间插几行 <?php ?>。
这种「HTML 为主、代码为辅」的写法,就是 PHP 最早的形态,也是它当年能迅速铺开的原因。
二、四个特点,和一个代价
| 特点 | 具体表现 | 代价 |
|---|---|---|
| 模板即页面 | HTML 和代码写在同一个文件里 | 逻辑和显示混在一起,容易乱 |
| 弱类型 | "1" == 1 为真,不用声明类型 |
隐式转换埋坑,PHP 8 收紧了一些 |
| 超全局变量 | $_GET、$_POST、$_SESSION 随处可用 |
谁都能改,调试时难追 |
| 共享主机友好 | 上传几个文件就能跑,不用编译 | 默认配置偏宽松,安全靠自觉 |
第一条是它最大的优点,也是它最大的缺点。
「模板即页面」让一个不懂编程的人也能改页面上的一个字;
但它也让「一个文件里既有 SQL 又有 HTML 又有业务逻辑」变成常态。
后面 22 章那个物流项目就是这种风格的典型:每个功能一个 .php 文件,上面一段连库,
中间一段 SQL,下面一段表格。
这不是好架构,但它是那个年代 PHP 项目的真实样子,也是理解它的起点。
三、工作原理:一个请求一个进程
这一条是整章的地基。PHP 的默认运行方式是「每来一个请求,启动一个进程(或线程), 把脚本从头执行一遍,输出结果,然后进程结束」。
| 环节 | 发生了什么 |
|---|---|
| ① 请求到达 | 浏览器发 GET /index.php,Web 服务器接住 |
| ② 启动解释器 | 服务器拉起一个 PHP 进程,加载 php.ini |
| ③ 编译执行 | 脚本编译成 opcode,从第一行跑到最后一行 |
| ④ 输出 | 结果交给服务器,服务器加上 HTTP 头发给浏览器 |
| ⑤ 进程结束 | 变量、连接、文件句柄,全部销毁 |
第⑤步是关键: 所有「存在内存里的东西」在请求结束时都消失了。 所以 PHP 里没有「全局的计数器」,没有「常驻的连接」,也没有「上一个请求留下的变量」。 这一条带来三个必须记住的后果:
| 后果 | 具体表现 |
|---|---|
| 状态必须外存 | session 文件、数据库、Redis,三选一 |
| 连接每次新建 | 除非用持久连接,否则每个请求都重新握手 |
| 没有「预热」 | 每次都要重新编译脚本(opcache 缓的是 opcode) |
第三行值得多说一句:opcache 缓的是「编译结果」,不是「运行时状态」。
它让第③步省掉编译,但省不掉第②步和第⑤步——进程照样起、照样灭。
四、为什么 PHP 配 MySQL 这么常见
教材 21.1 最后讲「PHP 结合数据库应用的优势」,讲得比较笼统。具体说是四条:
| 优势 | 说明 |
|---|---|
| 内置驱动 | mysqli 和 PDO_MySQL 随 PHP 一起发,不用装扩展 |
| 同一套部署 | LAMP 四件套都是免费的,一台机器全装下 |
| 文档同源 | 两边的官方手册互相引用,示例可以直接抄 |
| 生态惯性 | 大量现成项目、教程、主机商都按这个组合来 |
前两条是技术原因,后两条是历史原因。
而「历史原因」往往比「技术原因」更能解释一个技术栈为什么流行。
这一章要讲的两套接口分别是 mysqli 和 PDO:
| 接口 | 特点 | 什么时候用 |
|---|---|---|
mysqli |
MySQL 专用,函数式 + 面向对象两套写法 | 只连 MySQL,代码老 |
PDO |
支持多种数据库,只有面向对象写法 | 新项目,或可能换库 |
mysql_* |
PHP 5.5 起废弃,7.0 起移除 | 见到就该改 |
第三行要点名:教材里如果出现 mysql_connect()、mysql_query() 这种不带 i 的函数,
那是 PHP 5 时代的写法,现在跑不起来。
看到的时候要能认出来,别照着抄。
五、动手 Demo:一次请求一次连接,钱花在哪
这一段量三件事:新建连接到底多贵、「连接加查询」的耗时怎么分、 以及状态外存(写 session 表)的真实成本。 顺带量一个 PHP 项目里最常见的性能坑:循环里逐条提交和包一个事务的差别。
import sqlite3, time
DB = u"web.db"
# ① 新建连接 vs 复用连接
def once():
cc = sqlite3.connect(DB)
cc.execute(u"SELECT 1").fetchone()
cc.close()
N = 1000
t = time.perf_counter()
for _ in range(N):
once()
t_conn = time.perf_counter() - t
cc = sqlite3.connect(DB)
t = time.perf_counter()
for _ in range(N):
cc.execute(u"SELECT 1").fetchone()
t_reuse = time.perf_counter() - t
cc.close()
print(u"新建 %.3f 秒 / 复用 %.4f 秒 / %.0f 倍"
% (t_conn, t_reuse, t_conn / t_reuse))
# ② 一次「连接-查询-关闭」拆开看
def request():
c2 = sqlite3.connect(DB)
c2.execute(u"SELECT * FROM tb_g WHERE id = ?", (1,)).fetchone()
c2.close()
t = time.perf_counter()
for _ in range(500):
request()
whole = (time.perf_counter() - t) / 500 * 1000
t = time.perf_counter()
for _ in range(500):
sqlite3.connect(DB).close()
only = (time.perf_counter() - t) / 500 * 1000
print(u"整次 %.3f ms,连接占 %.3f ms(%.0f%%)"
% (whole, only, only / whole * 100))
# ③ 状态外存:包事务 vs 逐条提交
c = sqlite3.connect(DB, isolation_level=None)
t = time.perf_counter()
c.execute(u"BEGIN")
for i in range(1000):
c.execute(u"INSERT OR REPLACE INTO tb_sess VALUES (?,?,?)",
(u"s%04d" % i, u"u%04d" % i, time.time()))
c.execute(u"COMMIT")
t_bulk = time.perf_counter() - t
t = time.perf_counter()
for i in range(200):
c.execute(u"INSERT OR REPLACE INTO tb_sess VALUES (?,?,?)",
(u"t%04d" % i, u"u%04d" % i, time.time()))
t_one = time.perf_counter() - t
print(u"包事务 %.4f ms/条,逐条 %.3f ms/条,差 %.0f 倍"
% (t_bulk / 1000 * 1000, t_one / 200 * 1000,
(t_one / 200) / (t_bulk / 1000)))
c.close()
实跑输出:
--------------------------------------------------------------------
PHP 的请求-响应模型:为什么每次请求都要重新连库
--------------------------------------------------------------------
**PHP 的核心事实是「一个请求一个进程」:请求进来,脚本从头跑一遍,跑完进程就没了。**
**所以全局变量、连接、文件句柄,全都不跨请求。**
**这一条决定了后面所有的写法——包括「为什么要有连接池」。**
① 新建连接到底多贵
--------------------------------------------------------------------
做法 1000 次总耗时 每次
----------------------------------------
每次新建连接 1.705 秒 1.705 ms
复用同一个连接 0.0021 秒 0.0021 ms
倍数 800.4 倍 —
**差 800 倍。**
**这个倍数说明一件事:「连库」这件事本身就是成本,而且比「执行一条简单查询」贵得多。**
**PHP 默认就是「每次请求新建连接、请求结束关闭」,所以一个每秒一千次请求的站点,**
**每秒要付一千次连接的账。**
② 一次请求的耗时拆开看
--------------------------------------------------------------------
环节 耗时 占比
------------------------------------------
连接(含打开文件、建句柄) 1.569 ms 89%
一条主键查询 0.195 ms 11%
合计 1.764 ms 100%
**连接占了 89%,查询只占 11%。**
**这不是 SQLite 的特例,MySQL 上更极端——它还要多走一次 TCP 握手和账号密码校验。**
**所以优化 PHP 应用的数据库耗时,第一个要动的往往不是 SQL,是「减少连接次数」。**
**`mysqli` 的持久连接(`p:` 前缀)、PDO 的 `ATTR_PERSISTENT`,都是在做这件事。**
③ 变量不跨请求:状态必须外存
--------------------------------------------------------------------
**PHP 里写 `$count++` 想把访问量累加,是新手最常踩的坑。**
**因为下一个请求跑的是一个全新进程,`$count` 又从 0 开始。**
[教材原码 · 本沙箱不执行]
| <?php
| $count = 0;
| $count++; // 每个请求都从 0 加 1,永远是 1
| echo $count;
| ?>
教材原码:这个计数器永远是 1,不是因为它写错了,是因为进程不跨请求
**要跨请求保留,只有三条路:session 文件、数据库、缓存服务。**
**这里用一张 `tb_sess` 表演示第三条路的成本,顺便量一件更容易被忽略的事:**
**「循环里逐条提交」和「包一个事务」能差多少。**
做法 条数 每条
--------------------------------
包在一个事务里 1000 0.2211 ms
逐条自动提交 200 128.661 ms
倍数 — 582 倍
**差 582 倍。这 582 倍不是数据库慢,是「每写一行都要等一次落盘」的代价。**
**PHP 里 `foreach` 循环里跑 `mysqli_query`,默认就是逐条提交——导入一万条数据要几分钟,**
**包一层事务就变成几秒。**
**这是「性能优化」里性价比最高的一条:不改 SQL,只改提交边界。**
**不过这个 582 倍要打个折看:逐条那档量出来是每次 129 ms,比正常硬盘慢得多。**
**普通固态上单次落盘在零点几毫秒量级,倍数会缩到几十倍。**
**但「包事务比逐条快一到两个数量级」这个结论,换什么盘都成立。**
**还有一个反直觉的数:逐条写一次 session(129 ms)比新建一次连接(1.705 ms)还贵。**
**因为前者要等盘,后者只是打开一个文件。**
**这就解释了 PHP 为什么默认把会话存文件——**
**文件写由操作系统在后台刷,不强制每次落盘,而数据库为了不丢数据必须等。**
第一段两组数差了两个数量级,但真正要看清的是「量的是什么」:
复用那一档只量了「执行一条 SELECT 1」,新建那一档量的是「打开文件加执行同一条」。
两者差的就是「打开文件、建句柄」这件事——而它在 MySQL 上还要加上 TCP 握手和密码校验。
第二段是最有说服力的:连接占了大头,查询只占一小截。
所以在 PHP 应用里优化数据库,第一刀往往砍在「连接次数」上,而不是「SQL 写得好不好」上。
第三段的数要带着环境看:这台机器上一次落盘要一百多毫秒,明显比正常硬盘慢。
所以「差五百多倍」这个数字别照搬,但「包事务比逐条快一到两个数量级」这个结论换什么盘都对。
PHP 的 foreach 里逐条 mysqli_query,正是这个坑的典型形态。
30 秒实验
- 把第①段的
N改成 100,看倍数还是不是同一个量级——如果差很多,说明有缓存效应。 - 把第③段的两档顺序颠倒(先逐条、后包事务),看数会不会变。变了说明前面的操作影响了后面的。
- 在
tb_sess上加一个索引再重跑第③段,看逐条提交那档有没有变化。
常见误区
- 以为
$count++能统计访问量。每个请求都是新进程,变量从零开始。 要计数就得写文件、写库或写缓存。 - 以为 opcache 能让 PHP 变成常驻服务。它缓存的是编译后的 opcode,不是运行时状态。 进程还是照样起、照样灭。
- 照着老教程写
mysql_connect()。那套函数 PHP 7 就移除了。 见到不带i的写法,一律当成不能跑的老代码。
自测三问(点开对答案)
1. 「一个请求一个进程」这条模型,会带来哪三个后果? —— 状态必须外存(session、库、缓存三选一);数据库连接每次新建; 没有跨请求的内存缓存,只有 opcache 缓编译结果。 2. 为什么 PHP 优化数据库耗时,第一刀常砍在连接上? —— 因为连接的固定成本远高于一条简单查询。 演示里连接占了整次请求的绝大部分,而 MySQL 上还要多走 TCP 握手与账号校验,占比只会更高。 3. `mysqli` 和 `PDO` 该选哪个? —— 只连 MySQL、维护老代码,用 `mysqli` 够了;要换库、或想要统一的异常处理与命名参数,用 `PDO`。 两者都能做预处理,安全性上没有高下。第 21 章从这里开始。 下一篇讲具体动作:连库、选库、执行、取行、释放、关闭这六步,以及四种取行方式的差别。
- ☐ (本篇完,下一篇 21.2 PHP 操作 MySQL 数据库的基本步骤)