3、数据库事务基础:ACID特性详解、事务隔离级别、MySQL/PostgreSQL中的事务实现
3.1 为什么做市系统离不开事务?
做市系统里,库存数据就是命根子。你想想看,一边是交易所的成交回报,一边是内部风控的限额检查,中间还夹着各种撤单、改单操作。如果数据不一致,轻则报错,重则直接穿仓。
我个人习惯,在设计库存模块时,第一件事就是确认数据库的事务支持。没有事务,后面所有的一致性方案都是空中楼阁。说白了,事务就是保证「要么全做,要么全不做」的机制。
3.2 ACID 特性详解
ACID 这四个字母,做技术的都背过。但真正在项目中用对,没那么简单。我踩过不少坑,今天一个个说清楚。
3.2.1 原子性(Atomicity)
原子性要求一个事务里的所有操作,要么全部成功,要么全部回滚。不能出现「扣了库存但没生成流水」这种半吊子情况。
我曾经在早期版本里,用业务代码自己控制回滚。结果有一次网络超时,扣库存成功了,但流水写入超时了。嗯,那天的对账搞到凌晨三点。后来我学乖了,所有库存变更必须在一个数据库事务里完成。
3.2.2 一致性(Consistency)
一致性不是数据库单方面能保证的。它要求事务执行前后,数据都满足业务定义的规则。比如「库存不能为负数」、「总库存 = 可用库存 + 冻结库存」。
数据库只能保证约束、触发器这些基础的一致性。但像「做市双边报价的库存不能同时被占用」这种业务规则,得靠应用层自己实现。我建议在事务提交前,加一个显式的校验步骤。
3.2.3 隔离性(Isolation)
隔离性解决的是并发问题。多个事务同时操作同一行库存数据,怎么保证不出乱子?
做市系统里,高频的撤单和改单操作,经常导致死锁。我记得有一次生产环境,两个线程同时修改同一个股票的库存,一个加一个减,结果互相等待,最后超时回滚。那天的做市亏损,就是因为隔离级别没选对。
3.2.4 持久性(Durability)
事务一旦提交,数据必须永久保存。哪怕数据库宕机、机器断电,也不能丢。
MySQL 的 redo log 和 PostgreSQL 的 WAL 日志,都是干这个用的。我个人习惯把 innodb_flush_log_at_trx_commit 设为 1,虽然性能差点,但做市系统不能冒丢数据的风险。
3.3 事务隔离级别
隔离级别有四种,从低到高分别是:读未提交、读已提交、可重复读、串行化。级别越高,数据越安全,但并发性能越差。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 做市场景适用性 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | ❌ 绝对不用 |
| 读已提交 | 不会 | 可能 | 可能 | ✅ 适合查询报表 |
| 可重复读 | 不会 | 不会 | 可能(MySQL InnoDB 通过间隙锁解决) | ✅ 库存扣减首选 |
| 串行化 | 不会 | 不会 | 不会 | ⚠️ 性能代价大,慎用 |
3.4 MySQL 中的事务实现
MySQL 的事务核心是 InnoDB 引擎。它通过 undo log 实现原子性和回滚,通过 redo log 实现持久性,通过锁和 MVCC 实现隔离性。
下面是一个典型的做市库存扣减事务:
START TRANSACTION;
-- 1. 查询当前可用库存(加行锁)
SELECT available_qty FROM inventory
WHERE symbol = 'BTCUSDT' AND account_id = 1001
FOR UPDATE;
-- 2. 检查是否足够
-- (业务逻辑:如果 available_qty < 10,则回滚)
-- 3. 扣减库存
UPDATE inventory
SET available_qty = available_qty - 10,
frozen_qty = frozen_qty + 10
WHERE symbol = 'BTCUSDT' AND account_id = 1001;
-- 4. 写入流水
INSERT INTO inventory_flow
(symbol, account_id, change_type, change_qty, create_time)
VALUES ('BTCUSDT', 1001, 'ORDER_LOCK', 10, NOW());
COMMIT;
这里要注意 FOR UPDATE 的使用。它会在查询时就加上行锁,防止其他事务同时修改。我曾经见过有人忘了加锁,结果两个线程同时读到库存为 10,各自扣了 5,最后库存变成 5,但实际应该只剩 0。这就是典型的并发问题。
3.5 PostgreSQL 中的事务实现
PostgreSQL 的事务模型和 MySQL 类似,但有一些细节差异。它使用 MVCC 实现隔离性,每个事务都有一个唯一的事务 ID(xid)。
PostgreSQL 的默认隔离级别是「读已提交」,而 MySQL 是「可重复读」。这个差异很重要。如果你从 MySQL 迁移到 PostgreSQL,记得检查隔离级别。
下面是对应的 PostgreSQL 版本:
BEGIN;
-- 1. 查询当前可用库存(加行锁)
SELECT available_qty FROM inventory
WHERE symbol = 'BTCUSDT' AND account_id = 1001
FOR UPDATE;
-- 2. 检查是否足够
-- (业务逻辑:如果 available_qty < 10,则回滚)
-- 3. 扣减库存
UPDATE inventory
SET available_qty = available_qty - 10,
frozen_qty = frozen_qty + 10
WHERE symbol = 'BTCUSDT' AND account_id = 1001;
-- 4. 写入流水
INSERT INTO inventory_flow
(symbol, account_id, change_type, change_qty, create_time)
VALUES ('BTCUSDT', 1001, 'ORDER_LOCK', 10, NOW());
COMMIT;
语法几乎一样,但 PostgreSQL 在并发控制上更严格。它没有 MySQL 的间隙锁,但通过序列化快照隔离(SSI)在串行化级别下解决了幻读。
3.6 事务实现的核心差异对比
| 特性 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 默认隔离级别 | 可重复读 | 读已提交 |
| MVCC 实现 | 基于 undo log | 基于 xmin/xmax 系统列 |
| 幻读处理 | 间隙锁(Gap Lock) | SSI(串行化级别下) |
| 死锁检测 | 自动检测并回滚代价较小的事务 | 自动检测并回滚 |
| 适合场景 | 高并发读写,库存扣减 | 复杂查询,数据一致性要求极高 |
3.7 知识体系结构图
下面这张图,是我梳理的事务知识体系。做市库存一致性,从 ACID 出发,到隔离级别选择,再到具体数据库实现,是一条完整的技术链路。
3.8 实战中的选择建议
说了这么多理论,最后给点实在的建议。
- 库存扣减场景: 用 MySQL InnoDB,隔离级别可重复读,配合 FOR UPDATE 行锁。这是做市系统最成熟的方案。
- 查询报表场景: 用 PostgreSQL,隔离级别读已提交,允许脏读无所谓,性能优先。
- 跨库事务场景: 别想了,数据库事务解决不了。得用分布式事务方案,比如 TCC 或 Saga,那是后面章节的内容。
公众号:蓝海数据掘金营,微信deep3321