01_数据库设计与事务
关系表设计、约束、复合索引、事务、并发一致性和迁移。
数据库设计与事务
学习目标:能为小型业务设计表、约束、索引和事务,并用查询计划验证性能假设。
1. 从业务规则到表结构
先写清实体与约束,再选数据库。以笔记服务为例:用户拥有笔记,标题不能为空,每条笔记有创建和更新时间。
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id),
title TEXT NOT NULL CHECK (length(trim(title)) > 0),
body TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX notes_owner_created_idx ON notes(owner_id, created_at DESC, id DESC);
这段语法以 PostgreSQL 为例,其他数据库在自增、时间类型和函数上有所不同。应用层校验提升体验,数据库约束保证所有写入路径遵守底线。
2. 索引与查询
<span style="color:#1E90FF;font-weight:700;">索引(index)</span>加速特定查询,也占存储并增加写入成本。先确定查询条件和排序,再设计复合索引;不要给每一列都建索引。用 EXPLAIN/EXPLAIN ANALYZE 看实际计划,结合数据量和选择性判断,不能只凭 SQL 长短猜快慢。
分页时要固定排序。高页码的 OFFSET 可能成本较高,可按 (created_at, id) 使用游标分页;更新中的数据可能改变结果,客户端要接受分页视图不是全局快照。
3. 事务与并发
<mark style="background-color:#FFFF99;color:#1F2937;padding:0.08em 0.28em;border-radius:0.25em;">事务边界</mark>应对应一个业务不变量。例如转账的扣款与入账需同成同败。事务失败要回滚;并发下还需考虑唯一约束、锁或乐观版本号,不能只靠“先查再写”防重复。
BEGIN → 检查与写入 → COMMIT
└─ 任一步失败 → ROLLBACK
不同隔离级别对脏读、不可重复读、幻读的处理不同,具体还要看数据库实现。先能描述业务允许什么并发结果,再选择隔离策略。
4. 迁移与 ORM
表结构变更要有版本化迁移。大表加列、回填和创建索引可能影响线上读写,采用“先兼容旧版 → 回填 → 切换读取 → 清理旧字段”的步骤。ORM 能减少样板代码,但不能代替 SQL 理解;留意 N+1 查询、连接池耗尽和隐式事务。
<div style="border-left:4px solid #DC143C;padding:0.55em 0.8em;margin:0.8em 0;color:#DC143C;"><strong>易错点:</strong>不要把用户输入直接拼进 SQL。参数化查询防注入,逐资源授权则防止读到别人的数据;两者解决的问题不同。</div>
自测
- 只建
owner_id单列索引,是否一定最适合按created_at DESC列表查询?不一定,应看组合条件与查询计划。 - “先查询不存在,再插入”能否独立保证并发唯一?不能,应有数据库唯一约束。
5. 用真实查询反推索引
以下查询只读某个用户的笔记,并以时间和 ID 确保稳定排序。索引 (owner_id, created_at DESC, id DESC) 与过滤和排序方向匹配;是否真能获益仍需用实际数据量与查询计划验证。
SELECT id, title, created_at
FROM notes
WHERE owner_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20;
翻页可记录上一页最后一条的 (created_at, id),下一页增加行值比较条件 AND (created_at, id) < ($2, $3)。若允许修改创建时间,游标稳定性会受影响;一般创建时间应不可变。索引不等于权限:每个列表、详情、修改和删除查询都要基于当前认证用户限定 owner_id。
6. 乐观锁保护编辑冲突
给表增加 version BIGINT NOT NULL DEFAULT 1。客户端读取笔记时同时得到版本号,更新时提交旧版本;只有版本仍匹配才更新并自增。若影响行数为 0,要区分“不存在、无权限、版本冲突”的 API 策略。
UPDATE notes
SET title = $3, version = version + 1, updated_at = now()
WHERE id = $1 AND owner_id = $2 AND version = $4;
这个条件把检查与修改放在同一条语句里,避免先读后写的竞态。数据库迁移新增非空列时,要考虑大表回填和滚动发布期间旧应用的写入行为;开发环境能一次完成的 SQL 未必适合生产大表。
7. 事务实验
用两个数据库会话同时修改同一用户的两条记录,观察锁等待和最终结果;再给某一步故意制造唯一约束冲突,确认前面的写入是否回滚。记录所用数据库、隔离级别与 SQL,不要把在 SQLite 看到的行为直接套到 PostgreSQL 或 MySQL。