填单要同时动三张表:主单、明细、车辆状态。不包事务,就会出现「只成功一半」。 回执防重复提交靠唯一约束,不靠「先查再插」——后者在并发下有洞。 删主单时明细怎么办,必须在建表时决定:级联删除、还是拦住不许删。
教材 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 秒实验
- 把
tb_receipt的外键也加上ON DELETE CASCADE,重跑第四段,看还需要先删回执吗。 - 把填单函数里的
ROLLBACK去掉,跑第二段,看主单和明细各留下几条。 - 给
tb_order_detail.order_id建索引,再删一次主单,看级联删除快了多少。
常见误区
- 填单不包事务。写三处地方,中间失败就留下半张单。这类脏数据不会报错,只会让状态对不上。
- 用「先查再插」防重复提交。单用户测不出问题,并发时两个请求都查到「没有」,然后都插进去。 唯一约束才可靠。
- 建表忘写
ON DELETE,代码里也没删明细。删完主单,明细永远留在库里。 它不报错,只是让表越来越大、统计越来越不准。
自测三问(点开对答案)
1. 填单为什么要包事务? —— 一次填单要写三处:主单、明细、车辆状态。 任何一步失败,另外两步就该撤销,否则系统里会出现「没有明细的单」或「莫名其妙在途的车」。 2. 为什么唯一约束比「先查再插」可靠? —— 「查」和「插」是两步,两步之间别的请求可以插进来,这叫「检查与使用之间的时间差」。 唯一约束由数据库保证原子性,并发下也只让一个成功。 3. 删主单时明细的两种处理方式,各适合什么场景? —— 级联删除适合「明细离开单据就没意义」的场景;限制删除适合「明细要留档」的场景。 最怕的是两个都没选,明细变成看不见的垃圾。发货单是最重的模块,下一篇讲最基础的:管理员、客户、车辆三张表的维护。 重点只有一个——删除,因为这三张表都被业务表引用着。
- ☐ (本篇完,下一篇 22.8 基础信息管理模块设计)