🛢️ 第22章 PHP+MySQL实现物流配送信息网
📄 一句话概括

可选条件这件事必须拼 SQL:条件个数是变的,值永远不能拼。分清这两件事就不会写出注入。 分页要在 SQL 里做;OFFSET 到十万级会慢几十倍,因为前面那些行扫了、读了、又扔了。 要支持「跳到第 N 页」就得忍 OFFSET,要快就得放弃跳页。这是取舍,不是谁更好。

📝 本章和前面几章的关系

教材 22.6 讲车源信息查询:模块概述、技术分析、实现过程。 这个模块的功能很单纯:按车型、吨位、状态筛车,列出来,能翻页。 但单纯的功能里藏着两个通用问题:一是「可选条件」怎么写才安全, 二是「翻到后面会变慢」这个坑。 两个问题不只出现在这里,任何「查询列表」的页面都会遇到。 演示里灌到 300600 行,实测了「第一页」与「第三万页」的耗时, 也对比了 OFFSET 和「记住上一页最后一条」两种写法。

一、可选条件:拼形状,不拼值

用户可能只填车型,也可能填车型加吨位,还可能什么都不填。 所以 WHERE 子句的长度是可变的——这就必须拼字符串。 但拼的只能是「条件的形状」,不能是「条件的值」:

要拼的 例子 安全吗
条件的形状 vtype = ? AND tonnage >= ? 安全
条件的值 vtype = '厢式' 危险
排序字段名 ORDER BY plate 要白名单
表名 FROM tb_vehicle 要白名单

第一行和第二行的区别,是这一篇最重要的一句话。 因为「我在拼 SQL」这个动作本身没有错,错的是拼进去的是数据。 数据里可能有引号、有 OR 1=1,一拼进语法结构,它就变成了语法。 第三行和第四行容易被忽略:字段名和表名没法参数化,因为它们不是「值」。 所以处理办法是「白名单」——只允许几个固定的字符串通过,其余一律拒绝。 典型写法是 $order = in_array($order, ['plate','tonnage']) ? $order : 'plate';。 这一行看着笨,但它是唯一可靠的办法。

二、分页:必须让数据库来分

最常见的错误写法是「查全部,然后在 PHP 里切前十条」:

做法 代价
查全部再切 把几万行搬进内存,只用了十行
SQL 里 LIMIT 数据库只返回十行

第一行在小表上看不出问题,数据一多就撞 memory_limit——而这个错误的表现形式是「页面白屏」, 很难联想到分页。 更隐蔽的是它「看起来能跑」:几百行的时候完全正常。 第二行的写法是 LIMIT 10 OFFSET 20,意思是从第 21 行开始取 10 行。 注意 OFFSET 是「跳过多少行」,不是「从第几行开始」——这个差一错误很常见。

三、深翻页:为什么会突然变慢

OFFSET 300000 的执行过程是:先按顺序找出三十万行,扔掉,再取十行。 前面那三十万行是真的被扫过、读过的,只是最后没返回。

翻到 要扫多少行 实测耗时
第 1 页 10 0.07 毫秒
第 15001 页 150010 14 毫秒
第 30001 页 300010 29 毫秒

最后两行的耗时不长,但这是三百行的表——真实数据上会按比例放大。 而且它是「线性增长」:翻得越深越慢,最深的页面正好是最慢的。 这个问题跟数据库无关,任何用 OFFSET 的实现都一样。 用户不会翻到第三万页,但爬虫会、导出会、ORDER BY 写错时排序不稳定也会。

四、改法:记住上一页的最后一条

把 OFFSET 300000 换成 WHERE id > 300000,速度立刻回来——因为不用再数。 这种写法叫「键集分页」或者「游标分页」,前提是排序字段唯一且有序(通常是主键)。

写法 能跳页吗 速度
LIMIT n OFFSET m 能 越深越慢
WHERE id > 上页最后一条 不能 恒定

第二行「不能跳页」是硬限制,不是实现缺陷。 因为「第 500 页」这个概念本身就依赖「从头数」,不数就不知道第 500 页从哪开始。 所以选哪种,取决于界面:滚动加载、下一页,用键集;页码导航,用 OFFSET。 还有第三种做法:先查一次总数,再按页码算 OFFSET。这样能显示「共 3000 页」,但翻页照样慢。 很多系统就是这么做的——因为「能跳页」对用户体验的价值,大于「第 3000 页慢两秒」。

五、动手 Demo:车源查询:多条件组合,和深翻页那个坑

这一段先按「车型 + 吨位」两个可选条件拼一次查询,再把订单表灌到三十万行, 量第一页和第三万页的差距,最后换成键集分页对比。

import sqlite3, time

c = sqlite3.connect(u"veh.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_vehicle (id INTEGER PRIMARY KEY, plate TEXT UNIQUE,
  vtype TEXT, tonnage REAL, driver TEXT, state TEXT);
CREATE TABLE tb_order (id INTEGER PRIMARY KEY, order_no TEXT,
  client_id INTEGER, vehicle_id INTEGER, state TEXT);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_vehicle VALUES (?,?,?,?,?,?)",
              [(i, u"鄂E%05d" % i, [u"厢式", u"平板", u"高栏"][i % 3],
                [2.0, 5.0, 10.0][i % 3], u"司机%d" % i, u"空闲")
               for i in range(1, 61)])
c.execute(u"COMMIT")

# ① 两个可选条件:只拼形状,值走占位符
conds, args = [], []
for col, val in ((u"vtype", u"厢式"), (u"tonnage", 2.0)):
    if val:
        conds.append(u"%s = ?" % col if col == u"vtype" else u"%s >= ?" % col)
        args.append(val)
sql = u"SELECT plate, vtype, tonnage FROM tb_vehicle WHERE " + u" AND ".join(conds)
rows = c.execute(sql, args).fetchall()
print(u"SQL: %s" % sql)
print(u"命中 %d 行,头两条 %r" % (len(rows), rows[:2]))

# ② 灌到三十万行,量深翻页
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?,?)",
              [(i, u"WL%08d" % i, i % 120 + 1, i % 60 + 1, u"待发车")
               for i in range(1, 300001)])
c.execute(u"COMMIT")
print(u"总行数 %d" % c.execute(u"SELECT COUNT(*) FROM tb_order").fetchone()[0])

def timed(sql, args=(), n=3):
    best = 1e9
    for _ in range(n):
        t = time.perf_counter()
        c.execute(sql, args).fetchall()
        best = min(best, time.perf_counter() - t)
    return best * 1000

for off in (0, 150000, 300000):
    ms = timed(u"SELECT * FROM tb_order ORDER BY id LIMIT 10 OFFSET ?", (off,))
    print(u"OFFSET %-7d %.2f ms" % (off, ms))

# ③ 键集分页
ms = timed(u"SELECT * FROM tb_order WHERE id > ? ORDER BY id LIMIT 10", (300000,))
print(u"id > 300000  %.3f ms" % ms)
c.close()

实跑输出:

--------------------------------------------------------------------
车源查询:多条件组合,和深翻页那个坑
--------------------------------------------------------------------

**教材 22.6 讲车源信息查询:概述、技术分析、实现过程。**
**这个模块的难点有两个:条件是可选的(用户可能只填一个,也可能填五个),**
**以及「翻到第 300 页」会突然变慢。**

  ① 可选条件:拼 SQL 但别拼值
  --------------------------------------------------------------------
拼出来的 SQL         SELECT plate, vtype, tonnage, driver, st…
命中               20 行
车牌      车型  吨位  司机    状态
----------------------------------
鄂E00003  厢式  2.0   司机3   空闲
鄂E00006  厢式  2.0   司机6   空闲
鄂E00009  厢式  2.0   司机9   空闲
鄂E00012  厢式  2.0   司机12  空闲
**注意这里拼的是「条件的形状」,不是「条件的值」:**
**`vtype = ?` 里的问号是占位符,值通过 `args` 传进去。**
**「条件个数可变」这件事必须拼字符串(SQL 语法没法参数化),但「条件的值」永远不能拼。**
**分不清这两件事,是新手最容易写出的注入点——因为「我在拼 SQL」这个动作本身没错,**
**错的是拼了什么。**

  ② 深翻页:第一页和第三万页差多少
  --------------------------------------------------------------------
总行数              300600
翻到第几页   OFFSET  耗时
-----------------------------
第 1 页      0       0.069 ms
第 15001 页  150000  14.55 ms
第 30001 页  300000  28.90 ms
**同样是取 10 行,翻到后面慢了几十倍。原因不复杂:**
**`OFFSET 300000` 的意思是「先找出三十万行,扔掉,再取十行」。**
**前面那三十万行扫了、读了、丢了。**
**这个问题和数据库无关,任何带 `OFFSET` 的实现都躲不掉。**
**用户不会翻到第三万页,但爬虫会、导出会、`ORDER BY` 写错时也会。**

  ③ 改法:记住上一页最后一条,别再数
  --------------------------------------------------------------------
做法                 耗时
-----------------------------
`OFFSET 300000`      28.90 ms
`WHERE id > 300000`  0.068 ms
**快 422 倍。因为第二种写法是「从 300000 之后直接定位」,一步就够。**
**代价是「不能跳到第 N 页」——只能上一页、下一页。**
**所以这个改法适合「滚动加载」「下一页」这种形态;**
**要支持「跳到第 500 页」,就只能忍 `OFFSET`,或者另外建一套页码索引。**
**这是一个典型的取舍:不是「哪个更好」,是「你的界面需不需要跳页」。**

第一段的关键是那个 %s = ?: 列名用白名单式的字符串拼进去(这里只有 vtype 和 tonnage 两种),值走占位符。 如果图省事写成 u"%s = '%s'" % (col, val),那 val 就变成了语法的一部分。 第二段三个数说明 OFFSET 是线性变慢的:跳过十万行十五毫秒,跳过三十万行二十九毫秒。 这两个绝对值不大,但别忘了表只有三百行——真实数据上要按比例放大。 第三段那个数就是改法的效果:同样取十行,从「跳过三十万行」变成「从某个位置往后取」。 代价在功能上:客户端必须知道上一页最后一条的 id,服务端不能凭空算出「第 N 页」。

30 秒实验

  1. 把第一段的 tonnage >= 2.0 改成 > 2.0,看命中行数怎么变。
  2. 给 tb_order.vehicle_id 建索引,再跑第二段,看 OFFSET 有没有变快。
  3. 把第三段的 id > ? 改成 id > ? AND state = '待发车',看速度还在不在。

常见误区

  1. 把条件值拼进 SQL 字符串。「拼 SQL」本身没错,错的是拼进去的是数据。 形状可以拼,值必须走占位符。
  2. 查全部再在 PHP 里切前十条。几百行时完全正常,数据一多就白屏。分页必须写进 LIMIT。
  3. 以为深翻页慢是数据库性能问题。是 OFFSET 的实现方式决定的:跳过多少行就要扫多少行。 换数据库也一样。
自测三问(点开对答案) 1. 「拼形状不拼值」具体指什么? —— 条件的个数是可变的,所以 `WHERE` 的结构必须拼字符串;但条件里的值必须走占位符。 字段名和表名也属于「形状」,但它们没法参数化,只能用白名单。 2. 为什么 `OFFSET 300000` 会慢? —— 它要先把前三十万行按顺序找出来再扔掉,取最后十行。 扫的行数和 `OFFSET` 成正比,所以越往后越慢,而最深的页面正好最慢。 3. 键集分页为什么不能跳页? —— 因为「第 N 页」这个概念依赖「从头数」。 键集分页从「上一页最后一条」往后取,不数就不知道第 N 页从哪开始。 这是功能取舍,不是实现缺陷。

车源查询是最简单的一个模块,下一篇讲最重的一个:发货单管理。 填单要动三张表、回执要防重复提交、删除要连带明细。

相关笔记