置顶

澳五机器人 什么是关系型数据库:一次库存超卖事故的内核复盘

作者:admin | 分类:澳五机器人 | 浏览:2 | 日期:2026年09月22日

这篇是"底层玩家老张"今天(2026-09-22 11:38)发布在博客园的文章,用一次真实的秒杀超卖事故,把关系型数据库从定义到内核拆了个遍。[citation:62.49]


---

什么是关系型数据库:一次库存超卖事故的内核复盘

2026 年 9 月 22 日 | 作者:底层玩家老张


---

一、一个让人答不上来的问题

什么是关系型数据库?

> 标准答案:用关系模型组织数据、以二维表存储、用 SQL 操作、并靠事务保证一致性的数据库系统。


这句话面试够用,一上生产就露馅。


作者参与过一个秒杀系统,上线第一晚库存超卖了 300 多件。事后复盘,没人答得上来:数据库明明支持事务,为什么还是超了?[citation:62.49]


所以这篇不重复定义,而是回答定义背后那几个真正拉开差距的问题:

那张订单表里的"关系",到底约束的是什么?

敲下 COMMIT 之后,磁盘上到底发生了什么?

事务说好的隔离性,又是拿什么东西换来的?


---

二、"关系"的真正价值不是连表,而是约束


很多人把"关系"理解成表跟表的外键。这个理解不算错,但太浅。[citation:62.49]


真正值钱的是"能被约束"。


CREATE TABLE t_account (

 id BIGINT PRIMARY KEY,

 user_id BIGINT NOT NULL,

 balance NUMERIC(18,2) NOT NULL CHECK (balance >= 0)

);


类型约束、非空约束、唯一约束、外键约束、检查约束——它们把"数据不能乱"这件事写进了数据库。任何试图把余额写成负数的操作,在数据库层就直接被拦下来,而不是等到对账那天才发现。


---

三、一次 COMMIT 在磁盘上到底发生了什么


你的 APP → BEGIN → UPDATE → COMMIT

 ↓

 ① REDO 日志先落盘

 ② 释放事务持有的锁

 ③ 标记事务已完成

 ④ 数据页落盘是后面 checkpoint 的事


日志先于数据页落盘。就算下一秒机器断电,重启后也能靠日志把没写完的数据重做出来。这就是持久性。[citation:62.49]


反过来,没提交的事务要回滚,也是拿日志去逐步撤销。


但原子性管的是"要么全做要么全不做"——管不了两个事务同时读到同一个库存。 那是隔离性的活。


---

四、超卖的真正原因:隔离性没设对


-- 危险的写法:查和扣分两步,中间有窗口

SELECT stock FROM t_stock WHERE sku = 'A001';

-- ... 应用层判断 stock > 0 ...

UPDATE t_stock SET stock = stock - 1 WHERE sku = 'A001';


两个请求同时走到查询那一步,都读到库存为 1,都判断"够扣",于是都去扣——超卖就这么来的。 [citation:62.49]


作者强调,这不是事务没开,而是隔离级别默认"读已提交"下,普通 SELECT 是快照读,读不到别人未提交的修改,但先后两个事务之间没有阻塞。 [citation:62.4][citation:62.6]


正确做法:


① 压成一条原子 UPDATE

 UPDATE t_stock SET stock = stock - 1

 WHERE sku = 'A001' AND stock > 0;


② 或者显式加锁关掉窗口

 SELECT stock FROM t_stock WHERE sku = 'A001' FOR UPDATE;

隔离级别与实现


| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现代价 |

|:--------:|:---:|:---------:|:---:|:-------:|

| 读未提交 | 可能 | 可能 | 可能 | 最低 |

| 读已提交(默认) | 不会 | 可能 | 可能 | 较低 |

| 可重复读 | 不会 | 不会 | 理论上可能 | 较高 |

| 串行化 | 不会 | 不会 | 不会 | 最高 |


底层靠两条路实现隔离:[citation:62.49]

锁:两段锁协议,保证并发调度的结果可以串行化

MVCC(多版本并发控制):给数据保留多个版本,读操作去读某个时间点的快照,读写不互相阻塞


---

五、B+Tree 索引为什么快


两颗关键特性:[citation:62.49]

树矮:一个节点能放下几百个键,三到四层就能撑起上亿行数据,查一次最多几次磁盘 IO

叶子节点连成有序链表:范围查询顺着链表扫就行,不用回到根节点重走


实战经验:索引不是越多越好。 每个索引都会拖慢写入,因为写数据时得同步维护所有索引。见过一张表挂了十几个索引,写入慢得像蜗牛,砍掉一半直接翻倍。[citation:62.49]


---

六、关系型 vs 非关系型


| 维度 | 关系型 | 非关系型 |

|:----|:------|:--------|

| 数据模型 | 二维表,强结构 | 键值、文档、列族、图 |

| 事务 | 完整 ACID | 多数只保证单行或最终一致 |

| 查询语言 | 标准 SQL | 各家自有 API |

| 扩展方式 | 垂直扩展为主,可集群 | 原生水平扩展 |

| 适合场景 | 交易、账务、强一致业务 | 日志、缓存、海量稀疏数据 |


核心系统是关系型打底,非关系型做补充。缓存放 Redis,日志放 ES,账务和订单老老实实放关系型数据库。这不是保守,是权衡。[citation:62.49]


---

七、避坑清单


三份真金白银换来的教训:[citation:62.49]

先查库存再扣减,这种两步写法在秒杀场景就是事故源头。 能压成一条带条件的 UPDATE 就压,压不了就显式加锁,别指望默认隔离级别兜底。

COMMIT 返回成功,不等于数据已经安全落盘。 日志先于数据页落盘这个顺序,决定了哪些参数动了会丢数据。

索引别贪多。 每次写入都要同步维护所有索引,挂得越多写得越慢。建索引要跟着查询模式走,不能跟着直觉走。


---

八、一句话总结

超卖的根因不是事务没开,而是"先查后改"在默认的读已提交级别下有窗口。 压成一条原子 UPDATE 或者用 FOR UPDATE 显式加锁,根本不用等到复盘那天才知道问题在哪。[citation:62.49] 关系型数据库的核心价值,不是二维表和 SQL 的皮囊,而是约束、WAL、MVCC、B+Tree 这一整套历经数十年打磨的系统工程。