六步骨架:连服务器、选库、执行、取行、释放、关闭。前两步在 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 秒实验
- 把演示里那四种取行方式的输出都改成「打印整行」,看哪一种的输出最长、为什么。
- 在
tb_g里插一万行,再对比缓冲与非缓冲两段的耗时,看差多少。 - 把第⑤步改成先
fetchall()再取第一行,看和边取边数的结果是否一致。
常见误区
- 以为
INSERT也能fetch。增删改成功返回的是true,不是结果集。 对它调fetch_assoc会报错。 - 在非缓冲模式下用
num_rows。结果还在服务器那边传,数字不准。要么改成缓冲,要么边取边数。 - 循环里查库不释放结果集。小数据量看不出来,数据一多就撞
memory_limit。 调大限制只是推迟崩溃时间。
自测三问(点开对答案)
1. 为什么 MySQL 的「连服务器」和「选库」是两步? —— 因为一台服务器上有很多库,连接只连到服务器这一层,库是连上之后再选的。 SQLite 没有这一层,一个文件就是一个库,所以它的 `USE 库` 直接报语法错。 2. `fetch_array()` 和 `fetch_assoc()` 的实质差别是什么? —— `fetch_array` 默认同时给数字下标和列名两套键,四列变八个键;`fetch_assoc` 只给列名。 值不重复存,但哈希表要多占一倍,大结果集上很明显。 3. `num_rows` 为什么有时候拿不到准确值? —— 非缓冲模式下结果集还在从服务器往客户端传,客户端不知道总数。 `mysqli_query` 默认缓冲,所以通常可用;加了 `MYSQLI_USE_RESULT` 就不行了。六步走通了,下一篇讲往这六步里塞什么:添加、编辑、删除, 以及一件比语法重要得多的事——拼接字符串会出什么事。
- ☐ (本篇完,下一篇 21.3 管理 MySQL 数据库中的数据)