关系模型、约束与事务:让错误状态存不进去
最后一件商品被两个人同时购买,应用代码都先读到库存为 1,然后各自下单。程序每一步看起来都合理,合起来却超卖。数据库知识不只是会写 SELECT,还包括怎样让并发操作遵守同一套规则。
1. 先设计不变量
主键标识一条记录,唯一约束防止某种业务身份重复,外键表达被引用记录的存在关系,CHECK 约束限制单行可接受的取值。NOT NULL 很重要:在 SQL 的三值逻辑下,某些 CHECK 表达式结果为未知时仍可能通过,别用 CHECK(quantity > 0) 替代所有非空要求。
金额通常采用精确数值或最小货币单位整数,并同时记录币种。最小单位并非所有币种都是“分”,多币种与税费舍入要单独定义。库存数量是否允许小数,也由具体业务决定。
-- PostgreSQL 教学表,假设商品数量均为整数。
CREATE TABLE stock (
sku text PRIMARY KEY,
available integer NOT NULL CHECK (available >= 0)
);
INSERT INTO stock(sku, available) VALUES ('book', 1);
UPDATE stock
SET available = available - 1
WHERE sku = 'book' AND available >= 1
RETURNING available;
返回一行表示这个条件更新成功;返回零行可能是 SKU 不存在或库存不足,要按业务区分处理。更新和创建订单应放在同一事务,否则扣库存后订单插入失败会留下半成品。
2. ACID 怎样帮助理解
原子性是事务内更新一起提交或回滚;一致性依赖约束与业务规则共同维护;隔离性规定并发事务怎样观察彼此;持久性涉及提交后的保存保证,并受配置与故障模型影响。数据库不会凭空知道“优惠券只能在每月第一天用”,这仍需业务表达。
事务越长,越可能占用连接、锁和旧版本资源。不要一边持锁一边等待几秒钟的外部支付响应。跨系统动作另用状态机与补偿设计,不把网络请求藏进一个超长本地事务。
3. 隔离级别不是越高越无脑
PostgreSQL Read Committed 通常按每条语句取得快照,因此一个事务连续查询两次,可以看到期间其他事务提交的新数据。PostgreSQL 的 Repeatable Read 使用事务快照,防止不可重复读及其实现中的幻读,但仍可能发生写偏斜等序列化异常;Serializable 可能中止某些并发事务来维持可串行化,需要重试整个业务事务。
数据库产品对隔离级别的实现不同。不要把 MySQL 的默认级别或锁行为直接套给 PostgreSQL。这里使用 PostgreSQL 18 文档作为参照。
4. 悲观锁、乐观锁与重试
悲观方式先锁定要修改的行,例如 SELECT ... FOR UPDATE;它会引入等待和死锁风险。乐观方式读取版本,更新时附加 WHERE version = expected,影响行数为零就说明预期已经失效。两种方式都需要定义冲突处理,而不是在服务端无限重试。
库存场景优先考虑上面的单条条件更新。涉及多行时按照稳定顺序访问,可减少某些死锁,但仍应处理数据库报告的死锁或序列化失败。失败重试时重新执行整个事务,不复用上次计算出的陈旧结果。
练习与参考
开两个数据库会话,同时扣减同一库存行,观察等待与返回行数。再故意在扣减后插入一个违反约束的订单,确认回滚后库存恢复。不要仅凭单线程成功用例判断并发正确。
手把手观察:两个人抢最后一件库存
使用一个可以丢弃的 PostgreSQL 练习库,先执行前面的建表和插入语句,再打开连接到同一库的两个 SQL 会话。不要在生产库存表上做实验。
会话 A 执行:
BEGIN;
UPDATE stock SET available = available - 1
WHERE sku = 'book' AND available >= 1 RETURNING available;
-- 此时看到 0,暂时不要提交。
会话 B 执行同一条更新。默认 Read Committed 下,它会等待 A 对该行的锁,而不是立刻以自己读到的旧值完成扣减。回到 A 执行 COMMIT;,B 恢复后检查更新过的行,条件已不满足,返回零行。两个请求都进入数据库,不等于都能买到商品。
重新把库存设为 1,再重复实验,但让 A 执行 ROLLBACK;。这次 B 可以继续成功扣减。两次实验只改变提交还是回滚,能直接看到“别人暂时改了”与“别人最终提交了”的区别。B 未显式开启事务时通常按自动提交执行;若你的客户端关闭了自动提交,也需要明确结束 B 的事务。
接着试验两个查询:A BEGIN; SELECT available FROM stock;,B 修改并提交,A 再查。Read Committed 下第二次查询可以看到新值;改为 BEGIN ISOLATION LEVEL REPEATABLE READ; 后,A 的后续查询沿用事务快照。若尝试更新已被其他事务更改的行,可能收到并发更新错误,不要据此推断它会自动重试。
最后记住一个反例:序列 nextval 消耗的值通常不会随业务事务回滚,订单 ID 有间隙并不自动说明丢单。用连续编号推断事务成功数,会得出错误结论。
