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

填单要同时动三张表:主单、明细、车辆状态。不包事务,就会出现「只成功一半」。 回执防重复提交靠唯一约束,不靠「先查再插」——后者在并发下有洞。 删主单时明细怎么办,必须在建表时决定:级联删除、还是拦住不许删。

📝 本章和前面几章的关系

教材 22.7 是全书最长的模块,八个小节:概述、技术分析、填单、查询、删除、明细、打印、回执验收。 这一篇抓三个最容易出事的地方,其余用表格带过。 三个地方分别是:填单要多表同时写(事务)、回执要防重复(唯一约束)、 删除要连带明细(级联或限制)。 这三个都不是「语法问题」,是「设计问题」——写错了代码不会报错,只会慢慢产生脏数据。 演示里那个「中途出错」的分支是这一段的核心:主单没写、明细没留、车辆状态没改,三项全退。 如果不包事务,前两项已经生效了。

一、六个动作,一张表带过

动作 碰哪几张表 要点
填单 订单 + 明细 + 车辆 必须一个事务
查询 订单 + 客户 + 车辆 要连表,按角色过滤
删除 订单(+ 明细) 决定级联还是限制
明细 明细表 按 order_id 查,加索引
打印 订单 + 明细 + 客户 + 车辆 一次连表 + 一次明细
回执验收 回执 + 订单 唯一约束防重复

「填单」那一行的「必须一个事务」是这一篇的主线。 因为一次填单要写三处:主单一行、明细至少一行、车辆状态从「空闲」改成「在途」。 这三处要么全成,要么全不成——中间任何一步失败,系统里就多出一份说不清的数据。

二、填单:三张表必须一起动

事务的写法是固定的四步:BEGIN、若干语句、COMMIT、出错时 ROLLBACK。 听起来简单,但实际项目里最常见的错误是「忘了 ROLLBACK」,只写了 BEGIN 和 COMMIT。

写法 出错的后果
不包事务 主单写了、明细没写、车辆没改
包了但没 ROLLBACK 事务挂着,锁不释放,后面的人全卡住
包了且 ROLLBACK 三项全退,什么都没发生

第二行是隐性事故:不 ROLLBACK 的事务不会报错,它只是「一直开着」。 在 MySQL 上这会持有行锁,别的请求改同一行就会等,等到超时报「锁等待超时」。 这种故障最难查,因为报错的请求和出错的代码不是同一处。 所以「try / catch / rollback」这个模板要背下来,不能靠记性。 PHP 里的写法是 mysqli_begin_transaction 加 try 块,catch 里 mysqli_rollback, finally 里该提交的提交。

三、回执验收:唯一约束怎么防重复

回执是「一张单只签一次」的关系,所以 order_id 上要加唯一约束。 这样第二次签收会被数据库直接拒绝,不用写任何检查代码。

做法 并发下
SELECT 查有没有签过,没有再 INSERT 两个请求可能都查到「没签过」
INSERT 靠唯一约束拦 只有一个能成功

第一行的洞叫「检查与使用之间的时间差」:查和插是两步,两步之间别人可以插进来。 这个洞在单用户测试时永远测不出来,一上量就出现「一张单两条回执」。 第二行是唯一可靠的堵法。 注意它和「先查再插」不是「哪个更好」,是「在并发下前者根本不成立」。 单线程时两者等价,多线程时前者错。

四、删除:明细跟着走还是拦住

删主单的时候,明细有两种处理方式,都合理,但必须选一个:

选择 写法 后果
级联删除 ON DELETE CASCADE 明细跟着没,干净
限制删除 默认 RESTRICT 有明细就删不了,得先删明细

第一行适合「明细没有独立价值」的场景:发货单明细离开单据就没有意义。 第二行适合「明细要留档」的场景:比如财务凭证的明细行,删了就没法对账。 最怕的是第三种:建表时没写 ON DELETE,代码里也没删明细。 那样删完主单,明细就永远留在库里,谁都不会去看它——它变成了一种「看不见的垃圾」。 这种垃圾不会报错,只会让表越来越大、让统计越来越不准。 顺带说一句:有了级联删除,也不代表可以随便删。 业务上「已验收的单」通常不许删,这条规则要在代码层拦(或者用触发器), 数据库的级联只负责「删的时候把明细带上」。

五、打印:一次连表加一次明细

打印这一步技术上最简单,但它是「为什么拆表」这个问题最好的答案。 一张发货单上要显示客户名和车牌,而订单表里只有 client_id 和 vehicle_id。

要显示的 在哪 怎么拿
单号、路线、运费 订单表 直接取
客户名 客户表 JOIN
车牌 车辆表 JOIN
货物明细 明细表 另查一次

第四行是「另查一次」而不是 JOIN——因为一张单可能有多行明细, JOIN 会把主单信息重复输出多遍。 这就是「一对多」在查询上的表现:主表一次、子表一次,在代码里拼起来。 如果用 JOIN 一把查出来,也能拿到全部数据,但主单的运费会重复出现 N 次。 这时候必须靠代码去重,或者用 GROUP_CONCAT 把明细拼成一个字符串——后者在 MySQL 上很常用, 但要小心字符串长度限制。

六、动手 Demo:填单、回执、打印:一个事务和三道闸

这一段先做一次正常填单(主单 + 明细 + 车辆改状态),再做一次「第三步之后出错」的填单, 验证三项全退;然后演示唯一约束怎么拦重复签收,最后做一次级联删除和一次打印。

import sqlite3

c = sqlite3.connect(u"ord.db", isolation_level=None)
c.execute(u"PRAGMA foreign_keys = ON")
c.executescript(u"""
CREATE TABLE tb_client (id INTEGER PRIMARY KEY, cname TEXT);
CREATE TABLE tb_vehicle (id INTEGER PRIMARY KEY, plate TEXT, state TEXT);
CREATE TABLE tb_order (id INTEGER PRIMARY KEY, order_no TEXT UNIQUE,
  client_id INTEGER, vehicle_id INTEGER, freight REAL, state TEXT,
  FOREIGN KEY (client_id) REFERENCES tb_client(id),
  FOREIGN KEY (vehicle_id) REFERENCES tb_vehicle(id));
CREATE TABLE tb_order_detail (id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL, gname TEXT, qty REAL,
  FOREIGN KEY (order_id) REFERENCES tb_order(id) ON DELETE CASCADE);
CREATE TABLE tb_receipt (id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL UNIQUE, signer TEXT,
  FOREIGN KEY (order_id) REFERENCES tb_order(id));
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_client VALUES (?,?)",
              [(i, u"客户%03d" % i) for i in range(1, 121)])
c.executemany(u"INSERT INTO tb_vehicle VALUES (?,?,?)",
              [(i, u"鄂E%05d" % i, u"空闲") for i in range(1, 61)])
c.execute(u"COMMIT")

def fill(no, client, vehicle, goods, qty, fail=False):
    c.execute(u"BEGIN")
    try:
        c.execute(u"INSERT INTO tb_order (order_no, client_id, vehicle_id,"
                  u" freight, state) VALUES (?,?,?,?,?)",
                  (no, client, vehicle, 800.0, u"待发车"))
        oid = c.execute(u"SELECT last_insert_rowid()").fetchone()[0]
        c.execute(u"INSERT INTO tb_order_detail (order_id, gname, qty)"
                  u" VALUES (?,?,?)", (oid, goods, qty))
        c.execute(u"UPDATE tb_vehicle SET state='在途' WHERE id=?", (vehicle,))
        if fail:
            raise RuntimeError(u"模拟:第三步之后出错")
        c.execute(u"COMMIT")
        return oid, None
    except Exception as e:
        c.execute(u"ROLLBACK")
        return None, str(e)

# ① 正常填单
oid, err = fill(u"WL001", 1, 1, u"日用品", 12)
print(u"正常 主单 id=%s 车辆状态=%s" % (oid, c.execute(
      u"SELECT state FROM tb_vehicle WHERE id=1").fetchone()[0]))

# ② 中途出错,三项必须全退
bad, err = fill(u"WL002", 2, 2, u"建材", 30, fail=True)
print(u"出错 %s" % err)
print(u"主单 %d 条,孤儿明细 %d 条,车辆 2 状态 %s" % (
      c.execute(u"SELECT COUNT(*) FROM tb_order WHERE order_no='WL002'").fetchone()[0],
      c.execute(u"SELECT COUNT(*) FROM tb_order_detail d WHERE NOT EXISTS"
                u" (SELECT 1 FROM tb_order o WHERE o.id=d.order_id)").fetchone()[0],
      c.execute(u"SELECT state FROM tb_vehicle WHERE id=2").fetchone()[0]))

# ③ 重复签收被唯一约束拦住
for i in range(2):
    try:
        c.execute(u"INSERT INTO tb_receipt (order_id, signer) VALUES (?,?)",
                  (oid, u"张三"))
        print(u"第 %d 次签收 成功" % (i + 1))
    except sqlite3.IntegrityError as e:
        print(u"第 %d 次签收 %s" % (i + 1, e))

# ④ 级联删除
before = c.execute(u"SELECT COUNT(*) FROM tb_order_detail WHERE order_id=?",
                   (oid,)).fetchone()[0]
c.execute(u"DELETE FROM tb_receipt WHERE order_id=?", (oid,))
c.execute(u"DELETE FROM tb_order WHERE id=?", (oid,))
after = c.execute(u"SELECT COUNT(*) FROM tb_order_detail WHERE order_id=?",
                  (oid,)).fetchone()[0]
print(u"删除前明细 %d 条,删除后 %d 条" % (before, after))
c.close()

实跑输出:

--------------------------------------------------------------------
填单、回执、打印:一个事务和三道闸
--------------------------------------------------------------------

**教材 22.7 是全书最长的模块:填单、查询、删除、明细、打印、回执验收六步。**
**这一节抓三件最容易出事的地方:填单要多表同时写、回执要防重复提交、删除要连带明细。**

  ① 填单:三张表必须一起动
  --------------------------------------------------------------------
正常填单             订单 id=61,明细已写,车辆改在途
核对               主单 1 条,车辆状态 在途

  ② 中途出错:三张表必须一起退
  --------------------------------------------------------------------
出错               模拟:第三步之后出错
检查项    值    期望
----------------------------
主单条数  0     0
孤儿明细  0     0
车辆状态  维修  没被改成在途
**三行都对:主单没写、明细没留、车辆状态没改。**
**这就是事务的意义——不是「保证成功」,是「保证不会只成功一半」。**
**如果不包事务,第三条出错的那一刻,前两条已经生效了。**
**系统里会出现一张「没有明细的单」和一辆「莫名其妙在途的车」,**
**而且没人知道它为什么是这个状态。**
**这类脏数据最难查,因为它看上去是「业务上发生过的事」,只是没人记得发生过。**

  ③ 回执验收:防重复提交
  --------------------------------------------------------------------
第一次签收            成功
重复提交             UNIQUE constraint failed: tb_receipt.order_id
回执条数             1
**第二次被 `UNIQUE` 拦住了。**
**这一条约束替代了「先查有没有签过」那次查询,而且它不怕并发——两个请求同时进来,**
**数据库只让一个过。**
**「先查再插」在并发下是有洞的:两个请求同时查到「没签过」,然后都插进去。**
**这个洞叫「检查与使用之间的时间差」,唯一约束是唯一可靠的堵法。**

  ④ 删除:明细要跟着走
  --------------------------------------------------------------------
删除前明细            1 条
删除后明细            0 条
**明细跟着没了,因为建表时写了 `ON DELETE CASCADE`。**
**这一个词的差别很大:不写它,删主单会被外键拦住(因为明细还引用着);**
**写了它,删主单会连带删明细。**
**两种都合理,但必须选一个——最怕的是「没写,也没在 PHP 里删」,那明细就永远留在库里,**
**变成谁都看不见的垃圾。**

  ⑤ 打印:把主单和明细拼成一段文本
  --------------------------------------------------------------------
┌──────────────────────────────────────────────┐
│ 发货单  WL2026100009
客户:客户003   车牌:鄂E00003
路线:宜昌 → 武汉
货物:生鲜  8 件  12 公斤
运费:660 元
└──────────────────────────────────────────────┘
**打印这一步看着最没技术含量,其实是「一次连表查询 + 一次明细查询」的典型组合。**
**注意主单那条用了两次 `JOIN`——因为客户名和车牌号都不在订单表里。**
**这正是第 92 段「拆表要付的代价」,也是它值得付的原因:改客户名只改一行,**
**所有历史单据上的名字自动跟着变。**

第二段那三行是这一段的核心:主单 0 条、孤儿明细 0 条、车辆状态没被改成在途。 三项全退,靠的就是 except 里那句 ROLLBACK。 如果没有它,前两条已经写进去了,系统里就会多出一张没有明细的单。 第三段两次签收的结果值得对照:第一次成功,第二次被 UNIQUE 拦住。 注意这里没有写「先查有没有签过」——因为不需要,数据库会拒。 这一段在并发下同样成立,而「先查再插」在并发下会同时插进去两条。 第四段删除前要先删回执,是因为 tb_receipt 的外键没有写 ON DELETE CASCADE。 这个细节说明:级联是「逐表声明」的,不写就没有。 忘记一个,删除顺序就必须手工安排——而这类事情一旦靠人记,迟早会错。

30 秒实验

  1. 把 tb_receipt 的外键也加上 ON DELETE CASCADE,重跑第四段,看还需要先删回执吗。
  2. 把填单函数里的 ROLLBACK 去掉,跑第二段,看主单和明细各留下几条。
  3. 给 tb_order_detail.order_id 建索引,再删一次主单,看级联删除快了多少。

常见误区

  1. 填单不包事务。写三处地方,中间失败就留下半张单。这类脏数据不会报错,只会让状态对不上。
  2. 用「先查再插」防重复提交。单用户测不出问题,并发时两个请求都查到「没有」,然后都插进去。 唯一约束才可靠。
  3. 建表忘写 ON DELETE,代码里也没删明细。删完主单,明细永远留在库里。 它不报错,只是让表越来越大、统计越来越不准。
自测三问(点开对答案) 1. 填单为什么要包事务? —— 一次填单要写三处:主单、明细、车辆状态。 任何一步失败,另外两步就该撤销,否则系统里会出现「没有明细的单」或「莫名其妙在途的车」。 2. 为什么唯一约束比「先查再插」可靠? —— 「查」和「插」是两步,两步之间别的请求可以插进来,这叫「检查与使用之间的时间差」。 唯一约束由数据库保证原子性,并发下也只让一个成功。 3. 删主单时明细的两种处理方式,各适合什么场景? —— 级联删除适合「明细离开单据就没意义」的场景;限制删除适合「明细要留档」的场景。 最怕的是两个都没选,明细变成看不见的垃圾。

发货单是最重的模块,下一篇讲最基础的:管理员、客户、车辆三张表的维护。 重点只有一个——删除,因为这三张表都被业务表引用着。

相关笔记