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

六步骨架:连服务器、选库、执行、取行、释放、关闭。前两步在 MySQL 里是分开的。 四种取行方式只差「把行取成什么形状」:枚举、关联、两者都有、对象。 mysqli_query 失败返回 false 而不是抛异常——漏一个 if 就是一个静默失败。

📝 本章和前面几章的关系

教材 21.2 有十个小节:连接、选库、执行、返回到数组、取一行作为对象、取一行作为枚举数组、 取一行作为关联数组、取记录数、释放内存、关闭连接。 看着是十件事,其实是「六步骨架 + 四种取行 + 一个计数」。 这一篇按骨架重排,把十个小节归到原位——这样记的是六个动作,不是十个函数名。 沙箱里没有 PHP,mysqli_* 一个都跑不了。 所以原码照贴,行为用 Python 复刻:四种取行方式、num_rows 的缓冲问题、USE 库 的报错, 都是真跑出来的。

一、六步骨架,和十个小节的对应关系

步 动作 教材小节 函数
① 连服务器 连接 MySQL 服务器 mysqli_connect
② 选库 选择 MySQL 数据库 mysqli_select_db
③ 执行 执行 SQL 语句 mysqli_query
④ 取行 四种取法(4 个小节) fetch_*
⑤ 数行 获取记录数 mysqli_num_rows
⑥ 收尾 释放内存、关闭连接 free_result/close

十个小节里有四个都在讲第④步——因为「取一行」这件事在 PHP 里有四种取法。 剩下六节刚好对上六步。所以记住六步,十个小节自然就归位了。

二、第①②步:连服务器与选库是两件事

MySQL 的层次是「服务器 → 库 → 表 → 行」。连接只连到服务器这一层,库是连上之后再选的。 这个设计在 SQLite 里没有对应物——一个文件就是一个库,没有「服务器」这一层。 所以 USE shop 在复刻版里直接报错:

语句 沙箱的反应
USE shop near "USE": syntax error
USE mysql near "USE": syntax error

报错位置是 near "USE",说明连动词都不认。而 BEGIN、COMMIT 这类事务动词是认的—— 这个差别本身就说明了:SQLite 的「库」不是服务器里的一个命名空间,而是文件本身。 复刻版里这两步合并成了一句:连哪个文件,就是选了哪个库。 语义没变,只是把两层压成了一层。

三、第③步:执行 SQL 与三种失败形态

mysqli_query 有三种结果,写代码时必须分别处理:

情况 返回值 怎么判
查询成功 结果集对象 if ($result)
增删改成功 true 同一句 if 就能兜住
出错 false mysqli_error($link) 取原因

第二行容易忽略:INSERT 成功返回的是 true,不是结果集。 所以「成功就 fetch」这种写法,遇到 INSERT 会报「不能在 bool 上调用 fetch」。 现代写法一般用 PDO 并打开异常模式,把三种形态统一成「要么正常返回,要么抛异常」:

写法 失败时 漏判的后果
mysqli 默认 返回 false 静默失败,页面上少一块数据
mysqli 加 if 你自己 die 或记日志 要写几十个 if
PDO 异常模式 抛 PDOException 漏判也会中断,不会静默

第三行是重点:异常模式的价值不是「代码更短」,是「漏判不会变成静默失败」。 静默失败是最难查的一类 bug——页面上什么都没错,就是数据不对。

四、第④步:四种取行方式

函数 返回什么 取值方式 适用
fetch_row() 枚举数组 $r[0] 最短,要记列序
fetch_assoc() 关联数组 $r['name'] 可读,最常用
fetch_array() 两者都有 $r[0] 或 $r['name'] 键数翻倍
fetch_object() 对象 $r->name 写模板顺手

第三行默认是 MYSQLI_BOTH,也就是「两套键都给你」。 四列的结果集,取出来是八个键:0 和 id 指向同一个值,1 和 name 指向同一个值。 实测就是这样: fetch_array 取出的键依次是 0、id、1、name、2、cat、3、price,一共八个。 而 d[0] 和 d['id'] 确实是同一个值。 所以「默认用 fetch_assoc」不是风格偏好。fetch_array 白白多出一半的键, 只为了让你在两种写法里随便挑——这在十万行的结果集上是实打实的内存。 还有一点:四种方式混用是可以的,因为游标只有一根。 取一行就往前一格,不管用哪种方式取的。 演示里第四行用 fetch_object 取,取完之后再 fetch_row 就返回 NULL 了——因为已经取完了。

五、第⑤步:记录数,以及「缓冲」这个前提

mysqli_num_rows($result) 能直接给出总行数,前提是结果集已经「缓冲」了。 而 mysqli_query 默认就是缓冲的:函数返回的时候,结果已经全部搬到客户端内存里了。

模式 怎么开 num_rows 能用吗 内存
缓冲(默认) 直接 mysqli_query 能 整份结果集
非缓冲 加 MYSQLI_USE_RESULT 不能 一行

「不能」是字面意思:非缓冲模式下 num_rows 返回 0 或不准,因为服务器还在往外吐数据。 这时只能边取边数。演示里那一段就是「边取边数」——循环到 fetch 返回 NULL 为止。 这个取舍和「分页在 SQL 里做还是查完再切」是同一个道理: 能少搬就少搬,别把一百万行搬到 PHP 里数完再丢掉。 缓冲模式方便,但它把「内存」换成了「方便」——小结果集划算,大结果集会撞 memory_limit。

六、第⑥步:释放与关闭

这两步在「一个请求一个进程」的模型下,不写也能跑——进程退出,资源全回收。 所以很多教程会说「可以省略」。这句话在两种情况下是错的:

情况 不释放的后果
循环里反复查库 内存随循环次数线性上涨,最后撞 memory_limit
长驻进程(Swoole、队列 worker) 进程不退出,资源永不回收,几小时后崩

第一种在 PHP 项目里极常见,报错长这样:Allowed memory size of 134217728 bytes exhausted。 看到这个错,第一反应不该是「调大 memory_limit」,而是「循环里是不是忘了 free_result」。 调大只是把崩的时间往后推。

七、动手 Demo:六步走一遍,四种取行方式都试

这一段把六步一步步走完:先看 USE 库 为什么会报错、再走「执行—取行—数行—关闭」, 中间把四种取行方式挨个试一遍,最后对比缓冲与非缓冲下「总行数」拿不拿得到。

import sqlite3

c = sqlite3.connect(u"shop.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_g (id INTEGER PRIMARY KEY, name TEXT, cat TEXT, price REAL);
CREATE TABLE tb_user (id INTEGER PRIMARY KEY, uname TEXT, upass TEXT);
""")

# 第①②步:MySQL 里是「连服务器 + 选库」,这里合成一句
for q in (u"USE shop", u"USE mysql"):
    try:
        c.execute(q)
    except sqlite3.Error as e:
        print(u"%-10s -> %s" % (q, e))

# 第③步:执行
SQL = u"SELECT id, name, cat, price FROM tb_g WHERE id <= 4"
cur = c.execute(SQL)
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
print(u"列 %d,行 %d" % (len(cols), len(rows)))

# 第④步:四种取行方式(游标只有一根,所以拿四份数据分别试)
def fresh():
    return c.execute(SQL).fetchall()

r = fresh()[0]
print(u"row    ->", tuple(r))
print(u"assoc  ->", dict(zip(cols, r)))
both = {}
for i, v in enumerate(r):
    both[i] = v
    both[cols[i]] = v
print(u"array  -> 键 %r 共 %d 个" % (list(both), len(both)))
print(u"object -> 用属性取:id=%r name=%r" % (r[0], r[1]))

# 第⑤步:缓冲与非缓冲
cur = c.execute(u"SELECT id, name FROM tb_g")
print(u"非缓冲:执行完还数不出行数")
n = 0
while cur.fetchone() is not None:
    n += 1
print(u"  边取边数 -> %d 行" % n)
cur = c.execute(u"SELECT id, name FROM tb_g")
print(u"缓冲:一次取完 -> %d 行" % len(cur.fetchall()))

# 第⑥步:释放与关闭
c.close()
print(u"连接已关闭")

实跑输出:

--------------------------------------------------------------------
六步流程:连库、选库、执行、取行、释放、关闭
--------------------------------------------------------------------

**教材 21.2 讲了十个小节,其实是六步加四种取行方式。**
**六步是固定的骨架,四种取行方式只是「把行取成什么形状」。**

  ① 第一步和第二步:连库、选库
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | <?php
  | $link = mysqli_connect('localhost', 'root', '123456') or die('连接失败');
  | mysqli_select_db($link, 'shop') or die('选择数据库失败');
  | ?>
  教材原码:mysqli 的写法,先连服务器,再选库

**这两步在 MySQL 里是分开的,因为「服务器」和「库」是两个层次:**
**一台服务器上有很多库,连接只连到服务器,库是后选的。**
**SQLite 里没有这个层次——一个文件就是一个库,所以 `USE 库` 直接报错:**
USE shop       near "USE": syntax error
USE mysql      near "USE": syntax error
**所以复刻版里「选库」这一行被换成了「连哪个文件」。**
**这一步的语义没变:都在决定「后面的 SQL 作用在哪个库上」。**

  ② 第三步:执行 SQL
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | <?php
  | $result = mysqli_query($link, "SELECT id, name, cat, price FROM tb_g");
  | if (!$result) {
  |     die('查询失败:' . mysqli_error($link));
  | }
  | ?>
  教材原码:mysqli_query 返回结果集对象,失败返回 false

**注意这里的关键差别:`mysqli_query` 失败不抛异常,返回 `false`。**
**所以每个调用后面都得跟一句 `if (!$result)`,漏一句就是一个静默失败。**
**PDO 默认也是「失败返回 false」,但可以设 `ERRMODE_EXCEPTION` 改成抛异常。**
**现代 PHP 代码基本都会打开这个开关——少写几十个 if,也少几十个漏网的 bug。**

  ③ 第四步到第七步:四种取行方式
  --------------------------------------------------------------------
函数              返回什么                      适用
----------------------------------------------------------------
`fetch_row()`     枚举数组(下标 0,1,2…)       最短,但要记列序
`fetch_assoc()`   关联数组(键是列名)          可读,最常用
`fetch_array()`   两者都有(默认 MYSQLI_BOTH)  键数翻倍,占内存
`fetch_object()`  对象(属性是列名)            写模板时最顺手
列数 / 行数        4 列 / 4 行
fetch_row      (1, '货品0001', '类1', 1.5)
fetch_assoc    {'id': 2, 'name': '货品0002', 'cat': '类2', 'price': 3.0}
fetch_array    键 0, 'id', 1, 'name', 2, 'cat', 3, 'price',共 8 个
同一个值两处       d[0]=3 与 d['id']=3 相等
fetch_object   id=4 name='货品0004'(用点号取值)
再取一次           None  <- 取完了
**`fetch_array` 那一行是最值得看的:四个列变成了八个键。**
**`0` 和 `id` 指向同一个值,`1` 和 `name` 指向同一个值。**
**一个 4 列 1000 行的结果集,用 `fetch_array` 存下来,键的数量是 `fetch_assoc` 的两倍。**
**值不重复存(PHP 的数组值是引用的),但哈希表本身要占地方。**
**所以「默认用 `fetch_assoc`」不是风格问题——`fetch_array` 的默认值是 `MYSQLI_BOTH`,**
**它把内存翻了倍,只为了让你在两种写法里随便挑一种。**

  ④ 第八步:num_rows 什么时候能用
  --------------------------------------------------------------------
非缓冲            description 首列 'id',这时拿不到总行数
边取边数           共 1000 行
缓冲             一次取完 1000 行,num_rows 立刻可用
**PHP 的 `mysqli_query` 默认就是「缓冲」模式:**
**结果集在 `mysqli_query` 返回时已经全部搬到客户端了。**
**所以 `mysqli_num_rows($result)` 能直接给出总数。**
**代价是大结果集会吃内存。**
**要流式处理,得用 `MYSQLI_USE_RESULT` 标志,那时 `num_rows` 就不可用了。**
**这个取舍和「分页在 SQL 里做还是查完再切」是同一件事——**
**能少搬就少搬,别把一百万行搬到 PHP 里数完再丢掉。**

  ⑤ 第九步和第十步:释放与关闭
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | <?php
  | mysqli_free_result($result);   // 释放结果集占的内存
  | mysqli_close($link);           // 关闭连接
  | ?>
  教材原码:这两步在脚本结束时会被自动做,但显式写出来更稳

**这两步在「一个请求一个进程」的模型下其实不写也行——进程退出,资源全回收。**
**但在两种情况下必须写:一是长驻进程(如 Swoole、队列 worker),二是循环里反复查库。**
**循环里不释放,内存会随着循环次数线性上涨,**
**最后撞上 `memory_limit` 报一个「Allowed memory size exhausted」——**
**这个错误在 PHP 项目里出现的频率很高,根子往往就是循环里忘了 `free_result`。**

第一段两条 USE 全报错,位置都在 near "USE"。 这不是「SQLite 少个功能」,而是两种数据库对「库」的定义不同: MySQL 的库是服务器里的命名空间,SQLite 的库就是文件。 中间那四行取行方式是同一行数据的四种形状。 array 那行最值得看:四列变成八个键, 而且 0 和 id 指向同一个值——这就是 MYSQLI_BOTH 的实际效果。 最后一段两行对比是 num_rows 的前提:非缓冲模式下,语句刚执行完是数不出行数的, 必须一行行取到 NULL 才知道有几行;缓冲模式才能一次拿到总数。 PHP 的 mysqli_query 默认缓冲,所以 num_rows 通常可用——但大结果集会吃内存。

30 秒实验

  1. 把演示里那四种取行方式的输出都改成「打印整行」,看哪一种的输出最长、为什么。
  2. 在 tb_g 里插一万行,再对比缓冲与非缓冲两段的耗时,看差多少。
  3. 把第⑤步改成先 fetchall() 再取第一行,看和边取边数的结果是否一致。

常见误区

  1. 以为 INSERT 也能 fetch。增删改成功返回的是 true,不是结果集。 对它调 fetch_assoc 会报错。
  2. 在非缓冲模式下用 num_rows。结果还在服务器那边传,数字不准。要么改成缓冲,要么边取边数。
  3. 循环里查库不释放结果集。小数据量看不出来,数据一多就撞 memory_limit。 调大限制只是推迟崩溃时间。
自测三问(点开对答案) 1. 为什么 MySQL 的「连服务器」和「选库」是两步? —— 因为一台服务器上有很多库,连接只连到服务器这一层,库是连上之后再选的。 SQLite 没有这一层,一个文件就是一个库,所以它的 `USE 库` 直接报语法错。 2. `fetch_array()` 和 `fetch_assoc()` 的实质差别是什么? —— `fetch_array` 默认同时给数字下标和列名两套键,四列变八个键;`fetch_assoc` 只给列名。 值不重复存,但哈希表要多占一倍,大结果集上很明显。 3. `num_rows` 为什么有时候拿不到准确值? —— 非缓冲模式下结果集还在从服务器往客户端传,客户端不知道总数。 `mysqli_query` 默认缓冲,所以通常可用;加了 `MYSQLI_USE_RESULT` 就不行了。

六步走通了,下一篇讲往这六步里塞什么:添加、编辑、删除, 以及一件比语法重要得多的事——拼接字符串会出什么事。

相关笔记