数据放在哪里,怎样存才不容易弄错?
读解释、检查证据、修订自己的设计。正文与参考记录可离线阅读,真实网络观察需要联网或启动本机服务。
导出包含第 0–7 章作答。导入会替换八章全部作答,包括文件中未包含的章节;建议先导出备份。
先修:第二章的需求底稿 · 先判断数据,再读少量 SQL
3.1 “已经保存”要经得住哪一种变化?
小林已经决定让两台设备通过同一服务读写笔记。把图上一个方框写成“数据库”还不够:服务重启后数据是否保留?笔记和标签只写入一半怎么办?电脑上的旧页面能否把手机的新修改盖掉?这些问题需要不同的规则,不能用“用了数据库”一起回答。
本章分三步走:先确定数据留存的位置,再描述数据之间的关系,最后规定多步操作和不同编辑如何相处。把“任何已提交状态都不允许违反的规则”称为不变量。例如“标签关联不能指向不存在的笔记”。先写这种业务句子,再寻找数据库和应用可以怎样共同保证它。
完成一张存储与故障表、一张关系图、三条不变量,以及一个事务边界和一个旧版本处理规则。下方证据区是隔离实验的采集记录,不是当前网页正在写入数据库;先预测,再对照记录。
3.2 存储的选择从故障边界开始
程序中的列表很方便,但只存在于运行它的进程内存里。进程结束,列表本身就没有了,除非另有保存和恢复步骤。浏览器本地存储可以跨页面重开保留,却不会自行把内容送到另一台设备。文件能留在磁盘上,但文件格式、完整写入和多人修改规则仍需要设计。
数据库在存储之上提供查询、约束和事务等能力。SQLite 常把数据库保存在文件里,所以“文件”和“数据库”不是完全对立的两个地点。它也支持只在内存里存在的数据库;是否持久,要看连接的存储位置和配置,不能只看名字。SQLite 内存数据库说明
| 放在哪里 | 哪个变化值得检查 | 仍需补的责任 |
|---|---|---|
| 应用的内存列表 | 结束并重启应用 | 若要恢复,需要写入持久存储并重载 |
| 浏览器本地存储 | 换浏览器、清理网站数据 | 迁移、同步与备份路径 |
| 持久目录中的 SQLite 文件 | 提交后关闭连接,再打开同一文件 | 磁盘持久性、备份、权限和恢复验证 |
阅读记录时核对“同一数据库文件”这一条件。重开成功只支持所测试的重开场景;它没有验证断电、磁盘损坏或部署平台删除临时目录。将来上线时,数据库文件放在一次部署就会清空的位置,事务也不能替你保存那块目录。
已执行的 Python / SQLite 记录 · 不是当前 HTTP 请求
这里真正运行了数据库操作或应用函数。身份由测试程序提供,没有登录认证流程;状态码是函数返回的契约字段。原始前后快照供核对,不是发送给访问者的业务响应。
1. 提交后关闭连接,再打开同一个数据库文件
{
"操作": "INSERT → COMMIT → close → connect 同一文件 → SELECT",
"前": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"后": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"边界": "真实文件数据库与连接重开;不是断电、磁盘损坏或进程强杀测试。"
}2. 空标题约束
{
"SQL": "INSERT INTO notes(owner_id,title) VALUES (?,?)",
"参数": [
"alice",
""
],
"配置": "PRAGMA foreign_keys=ON",
"异常": "CHECK constraint failed: length(trim(title)) BETWEEN 1 AND 80",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"处理": "异常传播出事务块,回滚"
}3. 不存在的归属用户
{
"SQL": "INSERT INTO notes(owner_id,title) VALUES (?,?)",
"参数": [
"nobody",
"测试"
],
"配置": "PRAGMA foreign_keys=ON",
"异常": "FOREIGN KEY constraint failed",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"处理": "异常传播出事务块,回滚"
}4. 缺失归属用户
{
"SQL": "INSERT INTO notes(owner_id,title) VALUES (?,?)",
"参数": [
null,
"测试"
],
"配置": "PRAGMA foreign_keys=ON",
"异常": "NOT NULL constraint failed: notes.owner_id",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"处理": "异常传播出事务块,回滚"
}5. 缺失关联笔记
{
"SQL": "INSERT INTO note_tags VALUES (?,?)",
"参数": [
null,
1
],
"配置": "PRAGMA foreign_keys=ON",
"异常": "NOT NULL constraint failed: note_tags.note_id",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"处理": "异常传播出事务块,回滚"
}6. 缺失关联标签
{
"SQL": "INSERT INTO note_tags VALUES (?,?)",
"参数": [
1,
null
],
"配置": "PRAGMA foreign_keys=ON",
"异常": "NOT NULL constraint failed: note_tags.tag_id",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"处理": "异常传播出事务块,回滚"
}怎样复现与检查范围
下载底部实验包,运行 python3 evidence_lab.py。脚本在临时目录创建、检查和清理数据库;不会打开你的笔记库。运行结果只支持这些用例,不证明生产安全、并发压力、真实断电或云灾备。
记录对应源码 SHA-256:e7aecd720f9614777ee3d5c70996c518a381157d17f4988736b9061b7706481d。
CREATE TABLE users(id TEXT PRIMARY KEY);
CREATE TABLE notes(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 80),
body TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0));
CREATE TABLE tags(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, UNIQUE(owner_id,name));
CREATE TABLE note_tags(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(note_id,tag_id));
CREATE TABLE shares(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
reader_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY(note_id,reader_id));
INSERT INTO users VALUES ('alice'),('bob'),('eve');
INSERT INTO tags VALUES (1,'alice','学习'),(2,'bob','私有标签');
3.3 先找会互相牵连的信息,再拆成表
设想每条笔记都重复写入作者姓名和全部标签文字。小林把名字改了,旧笔记里仍是旧名;同一个标签有的写“系统设计”,有的写“系统 设计”。重复不是原罪,但如果这些文字表示同一个会变化的对象,分散复制就增加了保持一致的工作。
用稳定编号引用对象,可以让“显示名称改变”和“对象身份改变”成为两件事。下面是本项目的目标模型:一行表示一个对象或一个关系,一列表示它的一项属性。notes 是笔记表;owner_id 是笔记属于哪个用户的编号;version 稍后用来判断编辑是否基于旧内容。
users:id,显示名称 notes:id,owner_id,title,body,version tags:id,owner_id,name note_tags:note_id,tag_id 一个用户 → 多条笔记 一条笔记 ↔ 多个标签(由 note_tags 逐项记录)
一条笔记可以带多个标签,一个标签也可以用于多条笔记,所以关联表每行只记录一对编号。笔记标题可以重复,不能用标题当身份;同一笔记与同一标签的重复关联却没有意义,可以禁止。表怎样拆,由我们需要保持的关系决定,不以表越多越专业。
主键标识这一行;外键要求引用的对象存在;唯一约束拒绝某组值重复;检查约束拒绝不符合条件的值。NOT NULL 只排除缺失值,不会自动拒绝空字符串或全空格标题。标题是否合法要另定规则,也不能把“作者编号存在”误当成“本次请求者有权修改”。
本例还要求笔记与标签属于同一用户。单独检查两个编号存在,并不能保证这一点;可以由受验证的业务逻辑检查,或设计含所有者的组合约束。使用 SQLite 时,外键执行还需按连接明确启用并核对,不能只在表定义里写了 REFERENCES 就假设生效。官方启用说明
3.4 多步保存,先决定哪些必须一起成立
现在保存一条带标签的笔记,至少涉及新增笔记和新增关联两步。如果第一步先提交,第二步因标签不存在而失败,系统会留下用户没有确认过的半成品。也可以把“无标签也允许保存”设计成产品行为,但必须明确反馈,不能一边承诺整体保存,一边静默留下半份结果。
本例选择整体保存:在一个事务里完成这些写入,全部通过才提交;任何一步失败,就回滚本次事务。提交使这组结果成为已完成的数据变化,回滚撤销这组尚未提交的变化。读下面的步骤即可,不要求先记住整套 SQL 语法。
开始事务 检查目标标签与笔记所有者是否匹配 新增笔记 新增笔记与标签的关联 全部成功 → 提交 任一步失败 → 显式回滚,再返回失败
这里强调“显式回滚”:不要把所有数据库、所有错误都想成自动撤销整个事务。应用必须正确处理异常。事务通常也不能撤销已经发出去的邮件或写到另一个独立系统的内容;本例的边界是同一个数据库中的这些写入。SQLite 事务规则
在读记录前,先预测:故意让第二步失败,新增笔记和关联各应该剩多少?再看正常提交与失败回滚两条路径的前后数据,核对实际事务边界。实验若不符合承诺,应修实现;不能临时把“必须一起成功”改成“留下一半也可以”来解释通过。
已执行的 Python / SQLite 记录 · 不是当前 HTTP 请求
这里真正运行了数据库操作或应用函数。身份由测试程序提供,没有登录认证流程;状态码是函数返回的契约字段。原始前后快照供核对,不是发送给访问者的业务响应。
1. 创建笔记并关联已有标签:一起提交
{
"步骤": [
"应用检查标签1归属alice",
"BEGIN",
"INSERT notes",
"INSERT note_tags(已有标签1)",
"COMMIT"
],
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
}
],
"note_tags": [],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}2. 应用拒绝关联其他用户的标签
{
"标签": "2属于bob",
"操作者": "alice",
"异常": "tag_owner_mismatch",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"边界": "归属由这段应用检查;普通外键只验证对象存在,不自动验证同一所有者。"
}3. 第二步失败,应用回滚整个事务
{
"步骤": [
"BEGIN",
"INSERT notes",
"INSERT note_tags(不存在的标签999)",
"外键错误",
"ROLLBACK"
],
"异常": "FOREIGN KEY constraint failed",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}怎样复现与检查范围
下载底部实验包,运行 python3 evidence_lab.py。脚本在临时目录创建、检查和清理数据库;不会打开你的笔记库。运行结果只支持这些用例,不证明生产安全、并发压力、真实断电或云灾备。
记录对应源码 SHA-256:e7aecd720f9614777ee3d5c70996c518a381157d17f4988736b9061b7706481d。
CREATE TABLE users(id TEXT PRIMARY KEY);
CREATE TABLE notes(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 80),
body TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0));
CREATE TABLE tags(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, UNIQUE(owner_id,name));
CREATE TABLE note_tags(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(note_id,tag_id));
CREATE TABLE shares(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
reader_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY(note_id,reader_id));
INSERT INTO users VALUES ('alice'),('bob'),('eve');
INSERT INTO tags VALUES (1,'alice','学习'),(2,'bob','私有标签');
3.5 整体成功,仍可能覆盖另一份编辑
电脑与手机都读到了版本 1,内容是“周五复习”。电脑改成“周六复习”并保存为版本 2;手机仍显示旧内容,又改成“周五复习并做练习”。若手机直接替换整段正文,电脑刚确认的修改就可能丢失。两次保存都能各自完整提交,说明事务的整体成功没有回答“两个编辑怎样相处”。
本例增加一条业务规则:基于旧版本的编辑不能静默覆盖已确认的新版本。客户端保存时带上开始编辑时读到的版本。服务端将“版本仍相同”的条件与更新作为一个不可分开的数据库操作;命中才改正文并增加版本,未命中则保留用户草稿,提示重新读取和比较。
UPDATE notes
SET body = :new_body, version = version + 1
WHERE id = :note_id
AND owner_id = :current_user
AND version = :read_version;
WHERE 后是必须同时满足的条件;带冒号的名称表示由程序安全绑定的值。对已确认存在且有权限的同一笔记,版本条件更新了零行,表示不能按原条件保存。一般接口还须区分不存在、无权限或版本不符,不能把所有零行都宣布为编辑冲突。不要先查询版本、隔一会儿再无条件更新,否则检查与修改之间仍会被其他操作插入。UPDATE 条件更新说明
下面记录用可控顺序交错两个编辑,检查旧版本保存的结果。它能检验这一条保护路径,不等于已经完成大量用户同时操作的压力测试。冲突提示也不意味着第二份草稿毫无价值,界面应帮助用户比较或另存。
已执行的 Python / SQLite 记录 · 不是当前 HTTP 请求
这里真正运行了数据库操作或应用函数。身份由测试程序提供,没有登录认证流程;状态码是函数返回的契约字段。原始前后快照供核对,不是发送给访问者的业务响应。
1. 两个读者持有v1;先后执行带版本条件的更新
{
"A读取": {
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
"B读取": {
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "先说明保存位置",
"version": 1
},
"SQL": "UPDATE notes SET body=?,version=version+1 WHERE id=? AND version=?",
"A命中行数": 1,
"A提交后": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"B命中行数": 0,
"B尝试后": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
}
],
"边界": "实际条件更新,按指定顺序执行;不是多线程压力测试。应用必须把0行更新转为冲突反馈。"
}怎样复现与检查范围
下载底部实验包,运行 python3 evidence_lab.py。脚本在临时目录创建、检查和清理数据库;不会打开你的笔记库。运行结果只支持这些用例,不证明生产安全、并发压力、真实断电或云灾备。
记录对应源码 SHA-256:e7aecd720f9614777ee3d5c70996c518a381157d17f4988736b9061b7706481d。
CREATE TABLE users(id TEXT PRIMARY KEY);
CREATE TABLE notes(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 80),
body TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0));
CREATE TABLE tags(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, UNIQUE(owner_id,name));
CREATE TABLE note_tags(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(note_id,tag_id));
CREATE TABLE shares(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
reader_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY(note_id,reader_id));
INSERT INTO users VALUES ('alice'),('bob'),('eve');
INSERT INTO tags VALUES (1,'alice','学习'),(2,'bob','私有标签');
若产品明确允许最后写入覆盖,也可以采用那个规则,但它不满足本例“不静默丢编辑”的承诺。保留版本历史、人工合并、限定可自动合并的字段都是备选,各有存储或交互成本。同一请求因没收到回复而重发,属于第六章的重试去重问题。
3.6 把“数据库”改成一份可以检查的设计
为笔记工具补齐设计底稿:数据存在哪里;列出三条不变量与各自的检查位置;圈出创建笔记及关联的事务边界;写出两窗口旧版本保存时的处理和用户反馈。再换成读书清单:一本书与一段短评需要怎样的身份与关联?哪些规则仍成立,哪些不应照搬?
提示一:把坏数据写出来
试着写一行“引用不存在的笔记”的关联、一对重复关联,以及一次覆盖新版本的旧编辑。每一种错误由什么证据发现?
提示二:每条规则只找对应机制
存在性可用外键,重复关联可用唯一约束,多步整体保存用事务,旧版本覆盖用条件更新和反馈。它们互相配合,不能用一个名字代替全部规则。
提示三:一种合格答案与另一种选择
可选择持久目录中的 SQLite 文件;关联对象必须存在、同一关联不重复、旧编辑不能静默覆盖。创建与关联在一个事务内,冲突时保留草稿供比较。若第一版取消标签,模型和事务范围可以更小;但必须同时修改功能需求,不能悄悄放弃承诺。
精准选读:只补当前问题
- CS50 SQL · Designing:读 Normalizing、Relating 与 Table Constraints,追踪重复信息为什么产生维护问题。外键值可以重复;不要把外键等同于唯一键。
- PostgreSQL · Transactions:读开头、BEGIN/COMMIT 与 ROLLBACK 示例,找出“整体成功”的范围。本章记录使用 SQLite,不把两者的所有错误行为当成相同。
- SQLite · Enabling Foreign Key Support:检查启用发生在哪个连接;UPDATE 的 Details:理解为什么未命中条件可以更新零行。