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 通过间隙锁解决) ✅ 库存扣减首选
串行化 不会 不会 不会 ⚠️ 性能代价大,慎用
我的经验: 做市库存操作,用「可重复读」就够了。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。这就是典型的并发问题。

避坑指南: 我曾经在生产环境遇到过死锁,原因是两个事务以不同顺序锁定资源。比如事务 A 先锁 BTCUSDT 再锁 ETHUSDT,事务 B 先锁 ETHUSDT 再锁 BTCUSDT。解决方案是统一锁定顺序,比如按 symbol 字典序。

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)在串行化级别下解决了幻读。

我的建议: 做市系统如果选 PostgreSQL,隔离级别用「读已提交」+ 显式行锁(FOR UPDATE)就够了。没必要上串行化,性能代价太大。

3.6 事务实现的核心差异对比

特性 MySQL (InnoDB) PostgreSQL
默认隔离级别 可重复读 读已提交
MVCC 实现 基于 undo log 基于 xmin/xmax 系统列
幻读处理 间隙锁(Gap Lock) SSI(串行化级别下)
死锁检测 自动检测并回滚代价较小的事务 自动检测并回滚
适合场景 高并发读写,库存扣减 复杂查询,数据一致性要求极高

3.7 知识体系结构图

下面这张图,是我梳理的事务知识体系。做市库存一致性,从 ACID 出发,到隔离级别选择,再到具体数据库实现,是一条完整的技术链路。

做市库存事务知识体系 ACID 特性 原子性 一致性 隔离性 持久性 事务隔离级别 读未提交 读已提交 可重复读 串行化 数据库事务实现 MySQL (InnoDB) PostgreSQL 做市库存一致性保障:从理论到实践

3.8 实战中的选择建议

说了这么多理论,最后给点实在的建议。

  • 库存扣减场景: 用 MySQL InnoDB,隔离级别可重复读,配合 FOR UPDATE 行锁。这是做市系统最成熟的方案。
  • 查询报表场景: 用 PostgreSQL,隔离级别读已提交,允许脏读无所谓,性能优先。
  • 跨库事务场景: 别想了,数据库事务解决不了。得用分布式事务方案,比如 TCC 或 Saga,那是后面章节的内容。
最后提醒一句: 事务不是万能的。它保证的是数据库层面的一致性,但业务层面的一致性,比如「库存不能为负」,还得靠应用层自己校验。我曾经见过一个团队,完全依赖数据库事务,结果业务逻辑有 bug,库存还是变成了负数。嗯,那天晚上他们也没睡好。

公众号:蓝海数据掘金营,微信deep3321