三张基础表都被业务表引用,所以「删不掉」是常态,不是故障。
软删除的代价不是多一列,是「每个查询都要记得加条件」——所以得收进数据访问层。
车辆这张表的核心不是增删改,是状态机;状态该用 CHECK 约束守住。
教材 22.8 讲基础信息管理:管理员、客户、车辆三个部分。 三块的代码几乎一样,都是「列表 + 增 + 改 + 删」,照抄一遍就会。 所以这一篇不讲怎么写 CRUD,只讲「删」这一件事——因为它是三块里唯一有坑的地方。 这三张表都被业务表引用:客户被订单引用、车辆被订单引用、管理员被订单引用。 所以「删不掉」不是 bug,是设计在起作用。 演示里有两条值得记:一是外键怎么拦住「删有单的客户」、放行「删无单的客户」; 二是软删除之后,同一个系统里会并存两个客户数。
一、三张表,同一个难题
| 表 | 被谁引用 | 删得掉吗 |
|---|---|---|
tb_admin |
tb_order.admin_id |
有单就删不掉 |
tb_client |
tb_order.client_id |
有单就删不掉 |
tb_vehicle |
tb_order.vehicle_id |
有单就删不掉 |
三行结论一样,但原因不同:管理员和客户是「业务上不该删」,车辆是「物理上换手了才删」。 不过数据库不管业务动机,它只看「还有没有人引用我」。 所以基础信息管理的正确心态是:这三张表基本不删,只改。 客户不合作了,改一个状态;车辆卖了,改一个状态;管理员离职了,改一个状态。 「状态」比「删除」信息量大得多,而且可逆。
二、外键怎么拦:两种结果对照
同一个 DELETE,命中不同的行,结果完全不同:
| 删谁 | 结果 |
|---|---|
| 有历史订单的客户 | FOREIGN KEY constraint failed |
| 刚建、没有任何单的客户 | 删掉了 |
这就是 RESTRICT 的含义:还有别人引用你,就不许删。
它挡住了一个很常见的错误:删客户时只想着「他不合作了」,没想到「他名下还有历史单据」。
如果没有这个约束,那些单据的 client_id 就指向一个不存在的客户。
查询时显示成空白——数据没丢,但永远说不清是谁发的货。
这类数据在审计时是致命的:账对不上,而且没法补。
三、软删除:多一列,和多一个必须记得的条件
「不能删」的常见解法是软删除:加一列 is_deleted,删的时候改成 1。
这一列很便宜,但它带来一个必须付出的代价。
| 写法 | 客户数 | 问题 |
|---|---|---|
SELECT COUNT(*) FROM tb_client |
全部 | 把已删的也算进去了 |
... WHERE is_deleted = 0 |
少一个 | 对,但要记得写 |
演示里这两个数同时存在:一个 120,一个 119。
也就是说,同一个系统里两个页面可能显示不同的客户总数——取决于写代码的人记不记得加条件。
解决办法不是「提醒大家记得写」,而是「让它不可能忘」:
建一个视图,里面固定带上 WHERE is_deleted = 0,代码只查视图。
这样「忘了加条件」这件事在语法上就不存在了。
这个思路和「把 SQL 收进一个数据访问类」是一样的——用结构解决问题,不靠自觉。
四、车辆:状态机比 CRUD 重要
车辆表的字段里最关键的是 state:空闲、在途、维修。
业务规则都围绕它:空闲才能派单,在途不能改派,维修中完全不参与调度。
| 动作 | 允许的前置状态 |
|---|---|
| 派单 | 空闲 |
| 到达 | 在途 |
| 送修 | 空闲 |
| 出厂 | 维修 |
这张表的意义在于:它把「业务流程」变成了「状态迁移」。
代码里判断「能不能派单」,就是判断「状态是不是空闲」——一句话,不用查别的表。
而「状态值只能是这三个」这件事,最好交给 CHECK 约束:
写进建表语句之后,任何非法状态都插不进来,不管是从页面来的、从脚本来的、还是手工改的。
演示里实测:加完约束之后插一个「已报废」,直接被 CHECK constraint failed 拦住。
五、动手 Demo:基础信息管理:删一个人,为什么删不掉
这一段先对比「删有单的客户」和「删无单的客户」,看外键拦哪一个;
再做一次软删除,量出「加不加条件」导致的客户数差异;
最后给车辆表加 CHECK 约束,验证非法状态插不进来。
import sqlite3
c = sqlite3.connect(u"base.db", isolation_level=None)
c.execute(u"PRAGMA foreign_keys = ON")
c.executescript(u"""
CREATE TABLE tb_client (id INTEGER PRIMARY KEY, cname TEXT, contact TEXT);
CREATE TABLE tb_vehicle (id INTEGER PRIMARY KEY, plate TEXT UNIQUE,
state TEXT DEFAULT '空闲');
CREATE TABLE tb_order (id INTEGER PRIMARY KEY, order_no TEXT,
client_id INTEGER, vehicle_id INTEGER,
FOREIGN KEY (client_id) REFERENCES tb_client(id),
FOREIGN KEY (vehicle_id) REFERENCES tb_vehicle(id));
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_client VALUES (?,?,?)",
[(i, u"客户%03d" % i, u"联系人%d" % i) for i in range(1, 121)])
c.executemany(u"INSERT INTO tb_vehicle VALUES (?,?,?)",
[(i, u"鄂E%05d" % i, [u"空闲", u"在途", u"维修"][i % 3])
for i in range(1, 61)])
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?)",
[(i, u"WL%06d" % i, i % 120 + 1, i % 60 + 1)
for i in range(1, 101)])
c.execute(u"COMMIT")
# ① 删有单的客户 vs 删无单的客户
for cid in (2, 121):
n = c.execute(u"SELECT COUNT(*) FROM tb_order WHERE client_id=?",
(cid,)).fetchone()[0]
try:
c.execute(u"DELETE FROM tb_client WHERE id=?", (cid,))
print(u"客户 %-4d 有单 %d 张 -> 删掉了" % (cid, n))
except sqlite3.IntegrityError as e:
print(u"客户 %-4d 有单 %d 张 -> %s" % (cid, n, e))
# ② 软删除:加不加条件差多少
c.execute(u"ALTER TABLE tb_client ADD COLUMN is_deleted INTEGER DEFAULT 0")
c.execute(u"UPDATE tb_client SET is_deleted=1 WHERE id=3")
print(u"不加条件 %d 个,加了条件 %d 个" % (
c.execute(u"SELECT COUNT(*) FROM tb_client").fetchone()[0],
c.execute(u"SELECT COUNT(*) FROM tb_client "
u"WHERE is_deleted=0").fetchone()[0]))
# ③ 状态机:CHECK 约束拦非法状态
c.execute(u"ALTER TABLE tb_vehicle ADD CONSTRAINT ck_state "
u"CHECK (state IN ('空闲','在途','维修'))")
try:
c.execute(u"INSERT INTO tb_vehicle (plate, state) VALUES ('鄂E99999','已报废')")
print(u"插非法状态 -> 进去了")
except sqlite3.Error as e:
print(u"插非法状态 -> %s" % e)
c.close()
实跑输出:
--------------------------------------------------------------------
基础信息管理:删一个人,为什么删不掉
--------------------------------------------------------------------
**教材 22.8 讲基础信息管理:管理员、客户、车辆三张表。**
**这一节只讲一件事:删除。因为这三张表都被业务表引用,删错一次就断一条历史。**
① 三种删除,三种结果
--------------------------------------------------------------------
挑中客户 61 名下有 1 张单
再看客户 121 名下有 0 张单
动作 结果
-----------------------------------------------
删有单的客户 FOREIGN KEY constraint failed
删无单的临时客户 删掉了
**第一行被外键拦住了,第二行放行。这就是 `RESTRICT`(默认行为)的含义:**
**「还有别人引用你,就不许删」。**
**它挡住了一个很常见的错误:删客户时只想着「这个客户不合作了」,**
**没想到「他名下还有 1 张历史单据」。**
**如果没有这个约束,那 1 张单据的 `client_id` 就指向一个不存在的客户,**
**查询时显示成空白——数据没丢,但永远说不清是谁发的货。**
② 那到底怎么「删」?软删除
--------------------------------------------------------------------
硬删除 被外键挡住(有历史单据)
软删除 标记 is_deleted=1,行还在
**软删除的好处是「随时能恢复」,代价是「每个查询都要记得加条件」。**
**下面这个对比就是这个代价的实测:**
写法 客户数
-----------------------------
没加条件 120
加了 `is_deleted = 0` 119
**同一个系统里,两个数并存,客户端就会看到 120 个客户、后台看到 119 个。**
**所以软删除一旦上了,就得在「数据访问层」统一加条件——不能靠每个开发者记得写。**
**PHP 项目里常见的做法是建一个 `tb_client_view` 视图,里面固定带上 `WHERE is_deleted = 0`,**
**代码只查视图。**
**这样「忘了加条件」这件事就变成不可能,而不是「靠自觉」。**
③ 车辆:状态机比删除更重要
--------------------------------------------------------------------
状态 车辆数
------------
在途 20
空闲 20
维修 20
**车辆这张表很少真删——车卖了才会删,更多时候是改状态。**
**所以它的核心不是 CRUD,是状态机:空闲 → 在途 → 空闲,维修中不能被派单。**
**把状态机写进 `CHECK` 约束,是「让数据库替你守规矩」的做法:**
加 CHECK 约束 成功
插一个非法状态 CHECK constraint failed: ck_state
**这一条要说明白:老版本 SQLite 的 `ALTER TABLE` 只能加列、不能加约束,要加就得「建新表、**
**拷数据、删旧表、改名」四步。**
**但这次实测的 3.53.1 已经支持 `ADD CONSTRAINT` 了,所以这里一行就加上、而且立刻生效。**
**这类「教材写的时候不支持、现在支持了」的地方,只有真跑一遍才知道。**
**照抄结论的风险不在于错,在于「你以为它不支持,于是绕了一大圈写了个复杂的迁移脚本」。**
第一段两行的对照就是这一篇的核心:同一个 DELETE,一个有单、一个没单,结果完全不同。
注意报错是 FOREIGN KEY constraint failed,
它没告诉你是哪张表引用的——因为可能有七八张表都引用着它。
所以真实项目里删之前要先查一遍「谁在引用我」,靠报错反推太慢。
第二段两个数差 1。看着不起眼,但它意味着「两个页面显示不同结果」这种事情真的会发生。
软删除的代价从来不是那一列,是「以后每个查询都要记得加条件」。
第三段是这一篇里唯一「加了东西就变好」的地方:一行 CHECK 约束,把状态值的范围守住了,
而且不占代码。
顺便说一句:老版本 SQLite 不支持 ADD CONSTRAINT,得重建表;
3.53.1 支持了,所以这里一行就加上。
30 秒实验
- 先把
tb_order里所有引用客户 2 的行删掉,再删客户 2,看能不能删成功。 - 给
tb_client建一个WHERE is_deleted = 0的视图,然后用视图重跑第二段。 - 给
tb_vehicle.state加一个默认值,再插一行不带状态的记录,看默认值生效。
常见误区
- 以为删不掉是 bug。外键在拦住「删了会让历史单据悬空」这件事。 这三张表业务上基本不该删,只该改状态。
- 软删除只加一列,不管查询。结果是同一个系统里两个客户数并存。 解决办法是视图或数据访问层,不是靠自觉。
- 车辆状态只靠代码校验。页面校验能绕过,脚本导入更是完全不管。状态范围该用
CHECK约束守住。
自测三问(点开对答案)
1. 为什么删客户会被拦住,但删刚建的临时客户就行? —— 因为外键的 `RESTRICT` 只看「还有没有人引用我」。有历史订单的客户被订单表引用着,删了就悬空; 没有任何引用的临时客户可以删。 2. 软删除真正的代价是什么? —— 不是多一列,是以后每个查询都要记得加 `is_deleted = 0`。漏一处就出现两套数字,而且很难发现。 所以要用视图或数据访问层把它固定下来。 3. 为什么说车辆表的核心是状态机而不是 CRUD? —— 因为业务规则都落在状态上:空闲才能派单、在途不能改派。 代码判断「能不能派单」就是判断状态值,不用查别的表。状态范围该用 `CHECK` 约束守住。基础信息管理收尾,下一篇是本章最后一节:两个开发技巧——会话怎么存、手机号怎么验。 看起来无关,其实都在回答「不可信的东西进来之后怎么变得可信」。
- ☐ (本篇完,下一篇 22.9 开发技巧与难点分析)