🛢️ 第21章 PHP管理MySQL数据库中的数据
📄 一句话概括

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 秒实验

  1. 把第①段的 N 改成 100,看倍数还是不是同一个量级——如果差很多,说明有缓存效应。
  2. 把第③段的两档顺序颠倒(先逐条、后包事务),看数会不会变。变了说明前面的操作影响了后面的。
  3. 在 tb_sess 上加一个索引再重跑第③段,看逐条提交那档有没有变化。

常见误区

  1. 以为 $count++ 能统计访问量。每个请求都是新进程,变量从零开始。 要计数就得写文件、写库或写缓存。
  2. 以为 opcache 能让 PHP 变成常驻服务。它缓存的是编译后的 opcode,不是运行时状态。 进程还是照样起、照样灭。
  3. 照着老教程写 mysql_connect()。那套函数 PHP 7 就移除了。 见到不带 i 的写法,一律当成不能跑的老代码。
自测三问(点开对答案) 1. 「一个请求一个进程」这条模型,会带来哪三个后果? —— 状态必须外存(session、库、缓存三选一);数据库连接每次新建; 没有跨请求的内存缓存,只有 opcache 缓编译结果。 2. 为什么 PHP 优化数据库耗时,第一刀常砍在连接上? —— 因为连接的固定成本远高于一条简单查询。 演示里连接占了整次请求的绝大部分,而 MySQL 上还要多走 TCP 握手与账号校验,占比只会更高。 3. `mysqli` 和 `PDO` 该选哪个? —— 只连 MySQL、维护老代码,用 `mysqli` 够了;要换库、或想要统一的异常处理与命名参数,用 `PDO`。 两者都能做预处理,安全性上没有高下。

第 21 章从这里开始。 下一篇讲具体动作:连库、选库、执行、取行、释放、关闭这六步,以及四种取行方式的差别。

相关笔记