增删改的写法几乎一样,差别全在「执行完能拿到什么」:insert_id 和 affected_rows。
affected_rows 的口径各家不同:SQLite 数「处理过」,MySQL 数「真改了」。
拼接字符串的注入不一定是「删库」,更常见的是「越权读取」——一条 OR 就够了。
教材 21.3 讲添加、编辑、删除三个动作,21.4 是本章小结,21.5 是实践与练习。 三个动作的 PHP 代码几乎一模一样,所以这一篇把重点挪到两件更值钱的事上:返回值的口径, 和拼接字符串的安全边界。 沙箱里 PHP 跑不了,但 SQL 层的东西全都能真跑。 注入、预处理、转义、通配符、逐条提交的性能,都是实测。 其中有一条结论会让人意外:反斜杠转义在 SQLite 上直接语法错。 小结和实践与练习并入本篇末:小结改写进「下一篇」那段,练习题化成三问。
一、添加:两个返回值
插入成功之后,PHP 里能拿到两个值:
| 函数 | 给什么 | 什么时候用 |
|---|---|---|
mysqli_insert_id |
新行的自增主键 | 插完主表,要拿 id 去插子表 |
mysqli_affected_rows |
影响了几行 | 判断到底插没插进去 |
第一行是「主从插入」的关键:先插订单拿到 id,再拿这个 id 去插订单明细。
没有 insert_id,就得再查一次「刚才那条是哪个 id」——那是一次多余的往返,还可能有并发错乱。
演示里实测:插入一行后 lastrowid 是 201,因为前面已经灌了 200 行种子数据。
这个数说明 insert_id 是「本次连接上最后一次插入」的 id,不是「表里最大 id」——两回事,
多人同时插的时候尤其要分清。
二、编辑:affected_rows 的两种口径
这是三种数据库最容易分叉的地方。同一个 UPDATE,问「影响了几行」,答案可能不同:
| 口径 | 数什么 | 把值改成它自己 |
|---|---|---|
| 「处理过的行」 | WHERE 命中的行 |
照样算命中 |
| 「真正改变的行」 | 值确实变了的行 | 算 0 行 |
演示里实测 SQLite 是第一种:把 upass 改成它自己,仍然报命中 99 行。
而 MySQL 的 mysqli_affected_rows 是第二种,同样的情况返回 0。
要让 MySQL 也按第一种算,连接时要加 CLIENT_FOUND_ROWS 标志。
这个差别会直接生出 bug。
假设你的逻辑是「affected_rows > 0 就算保存成功」,
那么在 MySQL 上「用户点了保存但什么都没改」会被当成失败,弹一个「保存失败」的提示。
用户会以为系统坏了——而数据其实好好地存着。
正确的写法是:编辑前先不管 affected_rows,只判「语句有没有报错」;
要区分「没这条记录」和「没改动」,就单独查一次存在性。
三、删除:先查后删,还是直接删
删除在 PHP 里的写法最简单,只有一句 DELETE。麻烦的是「删之前要不要确认」。
| 做法 | 好处 | 风险 |
|---|---|---|
| 直接删 | 一次往返,代码短 | 删错了没得救 |
| 先查后删 | 能显示「你要删的是这条」 | 两次往返,中间可能被改 |
| 软删除(改状态) | 随时能恢复 | 所有查询都要加条件 |
第一行和第二行的差别,本质是「确认放在哪一层」:
放前端是一次确认框,放后端是查一次再删。两者都不能防「删错人」,只能防「手滑」。
第三行是生产环境的常见答案:不真删,改一个 is_deleted 字段。
代价是所有查询都要带上 WHERE is_deleted = 0——漏一个就是「删掉的又冒出来了」。
所以软删除一旦上了,就得在数据访问层统一加条件,不能靠每个开发者记得写。
四、拼接字符串:一次真实的注入
教材 21.3 的示例代码用「把变量拼进 SQL」的写法。这在教材那个年代很常见,现在不能这么写。 先看清楚它出什么事。假设有一个搜索框,用户输入的是商品名:
| 用户输入 | 拼出来的条件 | 结果 |
|---|---|---|
货品0001 |
name = '货品0001' |
1 行 |
货品0001' OR '1'='1 |
name = '货品0001' OR '1'='1' |
1000 行 |
第二行就是注入:OR '1'='1' 恒真,整句 WHERE 失效,全表都回来了。
实测 1000 行——而用户本来只该看见自己搜的那一条。
注意它的形态:没有删表、没有改数据,只是「多看见了不该看见的东西」。
真实事故里,这个形态最常见也最危险——因为它不留痕迹,没人会发现。
把 name = ... 换成 user_id = ...,同一个手法就变成「看见别人的订单」。
五、堆叠语句:被挡住了,但别指望它
很多人对注入的印象是「输入 1; DROP TABLE tb_g 把表删了」。这条路其实堵着:
| 驱动 | 多条语句 |
|---|---|
sqlite3 的 execute |
报错:一次只能执行一条语句 |
mysqli_query |
同样只跑第一条,要跑多条得用 mysqli_multi_query |
演示里实测:那条语句被拦住,表还在。但这不构成安全感。 因为真正致命的注入是上一步那种——它语法完全合法、只有一条语句、不碰表结构,任何驱动都拦不住。 所以防注入只有一条路:让「数据」永远不被当成「代码」。 这就是预处理干的事——SQL 文本先编译好,参数后填,填进去的东西永远是值,不是语法。
六、转义的边界:管得了引号,管不了通配符
在预处理普及之前,标准做法是 mysqli_real_escape_string:把引号和反斜杠转义掉。
它有用,但有两个边界,两个都值得记住。
第一个边界:转义规则是「引擎的约定」,不是通用的。
MySQL 认反斜杠转义,所以 O\'Reilly 是合法的;SQLite 不认,同一个字符串直接语法错。
SQLite 的规则是「双写单引号」:O''Reilly。
演示里两条都试了:反斜杠那版报 near "Reilly": syntax error,双写那版正常查出 1 行。
换引擎时照搬转义函数,轻则报错,重则留下一个自己以为安全的口子。
第二个边界:转义天生管不了 % 和 _。
因为这两个是 LIKE 的通配符,不是语法字符,转义函数根本不碰它们。
| 用户输入 | = '类1%' |
LIKE '类1%' |
|---|---|---|
类1% |
0 行 | 200 行 |
同一个值,= 查到 0 行,LIKE 查到 200 行。
用户本来只想找「类1」这一个类别,结果把 类10 到 类19 全带出来了。
这不是安全问题,是功能问题——但它同样出自「以为转义能解决一切」。
要真的搜「含 % 的文字」,得在 SQL 里写 ESCAPE 子句,或者查询前把 %、_ 手动转义。
这一条在用户搜索框上几乎必然遇到,只是大部分人没测过。
七、预处理:安全是第一价值,速度是附带
预处理的标准说法是「更快更安全」。这两条得分开看。
安全是真的:SQL 文本先编译成模板,参数填进去只当值用,OR '1'='1' 会被当成一个普通字符串。
演示里同一条输入,拼接返回 1000 行,
预处理返回 0 行——因为它就是查「名字等于这串怪字符」的记录。
速度这一条要看情况。实测 2000 次同样的查询:
| 写法 | 耗时 | 相对 |
|---|---|---|
| 拼字符串(每次文本不同) | 0.107 秒 | 1.43 倍 |
| 预处理(同一段文本) | 0.075 秒 | 1.00 倍 |
| 预处理但每次换文本 | 0.110 秒 | 1.46 倍 |
后两行是重点:同样是预处理,只要 SQL 文本变了,速度就掉回拼字符串的水平。
说明省下来的不是「参数绑定」的钱,是「同一条语句被反复编译」的钱。
所以在 PHP 上,「一律用预处理所以更快」是错的:
执行一次就丢的语句用预处理,反而多一次往返(prepare 一次、execute 一次)。
它的第一价值永远是安全,速度是「同一条语句跑很多次」时的附带收益。
八、动手 Demo:增删改的三个返回值,和一次真实的注入
这一段先看增删改各能拿到什么(自增 id、影响行数、SELECT 的特殊返回值),再演示一次真实的注入:
同样的输入,拼接返回全表、预处理返回零行。最后试转义的两种规则,以及 % 这个转义管不到的通配符。
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);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_g VALUES (?,?,?,?)",
[(i, u"货品%04d" % i, u"类%d" % (i % 5), i * 1.5)
for i in range(1, 1001)])
c.executemany(u"INSERT INTO tb_user VALUES (?,?,?)",
[(i, u"user%03d" % i, u"p%03d" % i) for i in range(1, 201)])
c.execute(u"COMMIT")
# ① 三个返回值
c.execute(u"INSERT INTO tb_user (uname, upass) VALUES ('newbie', 'x')")
print(u"insert_id =", c.execute(u"SELECT last_insert_rowid()").fetchone()[0])
cur = c.execute(u"UPDATE tb_user SET upass='y' WHERE uname LIKE 'user0%'")
print(u"affected_rows =", cur.rowcount)
cur = c.execute(u"DELETE FROM tb_user WHERE id = -1")
print(u"DELETE 没命中 =", cur.rowcount)
# ② UPDATE 成同值,还算不算影响
cur = c.execute(u"UPDATE tb_user SET upass = upass WHERE uname LIKE 'user0%'")
print(u"改成它自己 =", cur.rowcount)
# ③ 注入:拼接 vs 预处理
evil = u"货品0001' OR '1'='1"
q = u"SELECT COUNT(*) FROM tb_g WHERE name = '%s'" % evil
print(u"拼接 -> %d 行" % c.execute(q).fetchone()[0])
print(u"拼出来 -> %s" % q)
print(u"预处理 -> %d 行" % c.execute(
u"SELECT COUNT(*) FROM tb_g WHERE name = ?", (evil,)).fetchone()[0])
# ④ 堆叠语句
try:
c.execute(u"SELECT * FROM tb_g WHERE id = 1; DROP TABLE tb_g")
except sqlite3.Error as e:
print(u"堆叠 ->", e)
# ⑤ 两种转义规则
def esc(s):
return s.replace(u"\\", u"\\\\").replace(u"'", u"\\'")
raw = u"O'Reilly"
try:
c.execute(u"SELECT COUNT(*) FROM tb_g WHERE name = '%s'" % esc(raw))
except sqlite3.Error as e:
print(u"反斜杠转义 ->", e)
c.execute(u"INSERT INTO tb_g (id, name, cat, price) VALUES (9001, ?, 'x', 1)", (raw,))
print(u"双写单引号 -> %d 行" % c.execute(
u"SELECT COUNT(*) FROM tb_g WHERE name = 'O''Reilly'").fetchone()[0])
# ⑥ 转义管不到 %
lk = u"类1%"
print(u"= 号 -> %d 行" % c.execute(
u"SELECT COUNT(*) FROM tb_g WHERE cat = '%s'" % esc(lk)).fetchone()[0])
print(u"LIKE -> %d 行" % c.execute(
u"SELECT COUNT(*) FROM tb_g WHERE cat LIKE '%s'" % esc(lk)).fetchone()[0])
# ⑦ 拼接 vs 预处理的耗时
N = 2000
t = time.perf_counter()
for i in range(N):
c.execute(u"SELECT COUNT(*) FROM tb_g WHERE id = %d" % (i % 1000 + 1))
print(u"拼接 %.4f 秒" % (time.perf_counter() - t))
t = time.perf_counter()
for i in range(N):
c.execute(u"SELECT COUNT(*) FROM tb_g WHERE id = ?", (i % 1000 + 1,))
print(u"预处理 %.4f 秒" % (time.perf_counter() - t))
c.close()
实跑输出:
--------------------------------------------------------------------
增删改的返回值,和一次真实的注入
--------------------------------------------------------------------
**教材 21.3 讲添加、编辑、删除。**
**这三件事在 PHP 里的写法几乎一样,差别全在「执行完能拿到什么」。**
**这一段的重点不是 SQL,是「返回值」和「拼接字符串会出什么事」。**
① 四个返回值
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| <?php
| mysqli_query($link, "INSERT INTO tb_user (uname, upass) VALUES ('newbie', 'x')");
| echo mysqli_insert_id($link); // 新行的自增 ID
| echo mysqli_affected_rows($link); // 上一条语句影响了几行
| ?>
教材原码:插入之后拿 insert_id,改删之后拿 affected_rows
INSERT 新行 id = 201
UPDATE 影响 99 行
DELETE(没命中) 影响 0 行
SELECT 执行完是 -1,取完才知道 0 行
**最后一行的 `-1` 是个坑:`mysqli_affected_rows` 对 `SELECT` 返回的是「行数」,**
**但驱动层在结果集还没读完时给不出准确值。**
**所以「查有几行」要用 `mysqli_num_rows`,不要用 `affected_rows`——两个函数名字太像,**
**混用是常见错误。**
② UPDATE 成同一个值,算不算影响
--------------------------------------------------------------------
改成它自己 影响 99 行
条件不命中 影响 0 行
**第一行是真跑出来的:把 `upass` 改成它自己,仍然报命中。**
**因为 SQLite 数的是「被语句处理过的行」,不管值变没变。**
**MySQL 的 `affected_rows` 在这一点上不一样:它数的是「真正改变了值的行」,**
**把值改成它自己会返回 0。**
**要让 MySQL 也数「匹配的行」,连接时得加 `CLIENT_FOUND_ROWS` 标志。**
**这个差别会直接影响代码:如果你的逻辑是「`affected_rows > 0` 就算成功」,**
**在 MySQL 上「用户提交了表单但什么都没改」会被当成失败——这是一种很隐蔽的 bug。**
③ 拼接字符串的注入
--------------------------------------------------------------------
正常条件 1 行
拼进 OR 1000 行
**拼出来的 SQL 长这样:**
SELECT COUNT(*) FROM tb_g WHERE name = '货品0001' OR '1'='1'
**`OR '1'='1'` 恒真,于是 `WHERE` 整句失效,全表 1000 行全回来了。**
**注意这不是「删库」那种戏剧化的注入——**
**它只是把「只能看见自己的一条」变成了「能看见所有人的所有记录」。**
**真实事故里,这个形态最常见:不破坏数据,只越权读取。**
预处理同一条 0 行(整串被当成一个普通字符串)
④ 堆叠语句:被挡住了
--------------------------------------------------------------------
堆叠语句 You can only execute one statement at a time.
tb_g 还在吗 在
**这一条挡住了,但别把它当护身符。**
**`mysqli_query` 同样只允许一条语句(要用 `mysqli_multi_query` 才能一次跑多条),**
**所以「分号删库」这条路在两边都堵着。**
**真正堵不住的是上一步那种「改写 `WHERE` 条件」的注入——它在语法上完全合法,只有一条语句,**
**任何驱动都拦不住。**
**所以防注入的唯一办法是预处理,不是过滤分号。**
⑤ 转义:只管引号,还未必管对
--------------------------------------------------------------------
**`mysqli_real_escape_string` 干的事就是「在引号前加反斜杠」。**
**这在 MySQL 上是对的,因为 MySQL 认反斜杠转义。**
原值 转义后 在 SQLite 上执行
-----------------------------------------------------
O'Reilly O\'Reilly near "Reilly": syntax error
x OR 1=1 -- x OR 1=1 -- 0 行
**第一行直接语法错。原因是:SQLite 不认反斜杠转义,它认的是「双写单引号」。**
**同一个 `O'Reilly`,在 SQLite 上要写成 `O''Reilly` 才对:**
双写单引号 1 行(语法正确,查到了)
**这一条的意义不是「SQLite 更严格」,而是:转义规则是引擎的约定,不是通用的。**
**换引擎时照搬转义函数,轻则报错,重则留下一个自己以为安全的口子。**
**而且转义天生管不了第二类东西——通配符:**
等号比 '类1%' 0 行
LIKE 比 '类1%' 200 行
**同一个值,`=` 查到 0 行,`LIKE` 查到 200 行。**
**因为 `%` 是 `LIKE` 的通配符,转义函数根本不碰它。**
**用户搜 `类1%` 本来只想找这一个类别,结果把 `类10` 到 `类19` 全带出来了。**
**这不是安全问题,是功能问题——但它同样出自「以为转义能解决一切」。**
⑥ 拼接和预处理,差在哪
--------------------------------------------------------------------
写法 2000 次耗时 相对预处理
-------------------------------------------------
拼字符串(每次文本不同) 0.1118 秒 1.49 倍
预处理(同一段文本) 0.0750 秒 1.00 倍
预处理但每次换文本 0.1107 秒 1.48 倍
**后两行是关键:同样是预处理,只要 SQL 文本变了,速度就掉回拼字符串的水平。**
**说明省下来的时间不是「参数绑定」省下的,是「同一条语句被反复编译」省下的。**
**这一点在 PHP 上更明显:`mysqli_prepare` 一次的编译成本,**
**要分摊到后面 `execute` 很多次上才划算。**
**所以「预处理一定更快」是错的——**
**执行一次就丢的语句用预处理,反而多了一次网络往返。它的第一价值是安全,速度是附带的。**
第②段的数是这篇最该记住的:把值改成它自己,SQLite 仍报 99 行命中。
它数的是「WHERE 命中的行」,不是「值变了的行」。MySQL 正好相反,返回 0。
所以「affected_rows > 0 就算保存成功」这种写法,在 MySQL 上会误报失败。
第③段两条结果差 1000 倍:拼接 1000 行,预处理 0 行。
中间那行「拼出来」是全文最有价值的一行——看见 OR '1'='1' 直接接在条件后面,
就明白为什么它能绕过整句 WHERE。
第④段那条堆叠语句被拦住了,表也还在。
但这只说明「分号删库」这条路堵着,不说明注入被防住了——第③段那种才是主流形态。
第⑤段是全文最意外的一条:反斜杠转义在这里直接语法错。
因为 SQLite 不认 \',它认的是双写 ''。同一个函数换个引擎就不对了。
第⑥段说明转义管不到 %:同一个 类1%,等号查 0 行,LIKE 查 200 行。
这一条在搜索框上几乎必然出现,只是大部分人没测过。
30 秒实验
- 把第③段的输入换成
x' UNION SELECT ...,看拼接版本会不会多返回一批「造出来」的行。 - 把第⑥段的
LIKE改成LIKE ... ESCAPE '\'并手动转义%,看能不能只查回 0 行。 - 把第⑦段的
N改成 20 和 20000 各跑一次,看预处理的优势是随次数变大还是变小。
常见误区
- 用
affected_rows > 0判断「保存成功」。MySQL 上「没改动」返回 0,会被误判成失败。 判断成功应该看「有没有报错」。 - 以为转义函数是通用的。反斜杠转义是 MySQL 的约定,SQLite 要双写单引号。 换引擎照搬,轻则报错重则留口子。
- 以为防注入就是过滤分号。最致命的注入只有一条语句、语法完全合法。只有预处理能从根上解决。
自测三问(点开对答案)
1. 为什么插入之后要拿 `insert_id`? —— 为了「主从插入」:先插订单拿到自增 id,再用这个 id 去插订单明细。 不拿 id 就得再查一次「刚才那条是哪条」,多一次往返还可能有并发错乱。 2. `affected_rows` 在 SQLite 和 MySQL 上有什么不同? —— SQLite 数「WHERE 命中的行」,把值改成它自己也算命中; MySQL 数「值真的变了的行」,同样情况返回 0,除非连接时加 `CLIENT_FOUND_ROWS`。 3. 为什么说「预处理更快」这句话不完整? —— 省下来的是「同一条语句反复编译」的成本。SQL 文本一变就退回拼字符串的水平; 执行一次就丢的语句用预处理还多一次往返。它的第一价值是安全。第 21 章到这里结束,全书四部分的前三部分也走完了。 回顾这一章:六步骨架是死的,四种取行是形状问题,真正会出事的是「拼接」和「口径」这两件事。 教材 21.4 的小结一句话概括就是:先把连接管好,再把参数管好。下一篇进入第四部分「项目实战」, 第一个项目是 PHP 加 MySQL 做的物流配送信息网。
- ☐ (本篇完,下一篇 22.1 开发背景)