事务
事务(Transaction)把一组数据库操作视为一个不可分割的工作单元:要么全部成功,要么全部失败。
最典型的例子是转账。把 100 元从 Alice 转给 Bob 至少包含两个操作:Alice 的余额减少 100,Bob 的余额增加 100。如果第一个操作成功后程序崩溃,而第二个操作没有执行,钱就凭空少了。事务可以避免数据库停留在这种只完成一半的状态。
一个完整的转账事务
假设账户表如下:
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
balance DECIMAL(12, 2) NOT NULL,
CHECK (balance >= 0)
);
一次转账可以写成:
BEGIN;
UPDATE accounts
SET balance = balance - 100.00
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100.00
WHERE id = 2;
COMMIT;
BEGIN开启事务;- 两条
UPDATE都暂时属于这次事务; COMMIT提交事务,让修改正式生效。
如果中途发现问题,就不应提交,而是回滚:
BEGIN;
UPDATE accounts
SET balance = balance - 100.00
WHERE id = 1;
-- 发现收款账户不存在
ROLLBACK;
ROLLBACK 会撤销当前事务中尚未提交的数据库修改。事务一旦成功提交,普通的 ROLLBACK 就不能再撤销它;之后若要纠正,只能执行新的补偿操作或恢复备份。
不同数据库和客户端使用的开启语句可能是 BEGIN、BEGIN TRANSACTION 或 START TRANSACTION。本文示例表达的是通用思路,实际语法应以所用数据库为准。
为什么不能只把 SQL 连续写在一起
如果没有显式事务,很多数据库客户端会以自动提交模式运行:每条语句各自成为一个事务并立即提交。
扣款成功并提交
↓
程序或网络发生故障
↓
加款语句没有执行
连续执行不等于原子执行。只有把相关操作放在同一个事务中,数据库才能在失败时整体回滚。
自动提交的具体行为由数据库和驱动配置决定。使用连接池或 ORM 时,还要确认事务是否始终绑定在同一个数据库连接上。
ACID:事务希望提供的四种性质
ACID 是理解事务的常用框架。
原子性(Atomicity)
事务中的操作作为一个整体完成。转账不能只扣款不加款。
原子性不表示一条 SQL 在物理上只有一个步骤,而是表示对外呈现的结果不会停留在“提交了一半”的状态。
一致性(Consistency)
事务应该让数据库从一个满足规则的状态进入另一个满足规则的状态。例如:
- 主键不能重复;
- 外键必须指向存在的记录;
- 余额不能小于 0;
- 转账前后的总金额应保持不变。
数据库约束能帮助维护一致性,但业务规则仍需要正确的事务逻辑。仅仅使用事务,并不会自动知道“总金额必须不变”这样的业务含义。
隔离性(Isolation)
多个事务并发执行时,一个事务不应随意看到另一个事务尚未完成的中间状态。数据库通过锁、多版本并发控制等机制,在正确性和并发性能之间取得平衡。
隔离并不总等于“所有事务完全串行”。不同隔离级别允许或阻止的并发现象不同。
持久性(Durability)
事务提交成功后,即使进程崩溃或机器重启,数据库也应能够恢复已提交的数据。数据库通常借助预写日志、磁盘刷新和恢复机制实现持久性。
持久性仍受部署配置影响。例如,异步复制可能导致主库刚提交、尚未复制的数据在故障切换时丢失。应用对持久性的要求应与数据库、存储和复制配置一起评估。
并发事务会遇到什么问题
假设两个请求同时读取和修改同一批数据,如果没有适当控制,结果可能与顺序执行不同。
脏读
事务 B 读到了事务 A 尚未提交的数据。如果事务 A 随后回滚,事务 B 读到的就是从未真正存在过的结果。
不可重复读
事务 A 两次读取同一行。在两次读取之间,事务 B 修改并提交了这行,所以事务 A 两次得到不同结果。
幻读
事务 A 两次执行同一个范围查询。在两次查询之间,事务 B 插入或删除了满足条件的行,因此结果集的行数发生变化。
丢失更新
两个事务都读取旧值,各自计算新值后再写回,后一次写入覆盖了前一次写入。
例如,库存原本为 10,两个请求都读到 10,并分别写回 9。卖出了两件商品,最终库存却是 9,而不是 8。
能直接在数据库中完成的计算,优先使用原子更新:
UPDATE products
SET stock = stock - 1
WHERE id = 10
AND stock >= 1;
应用还应检查受影响行数。如果结果为 0,可能表示商品不存在或库存不足。
隔离级别
SQL 标准定义了四个常见隔离级别:
| 隔离级别 | 直观理解 |
|---|---|
READ UNCOMMITTED | 允许看到更多并发中的变化,隔离最弱 |
READ COMMITTED | 每条语句只读取已提交数据 |
REPEATABLE READ | 同一事务重复读取时,尽量保持一致视图 |
SERIALIZABLE | 结果应等价于事务按某种顺序逐个执行 |
隔离越强,程序越容易推理,但等待、冲突或重试也可能越多。实际数据库对各级别的实现、默认值和防止的异常并不完全相同,不能只根据名称推断所有行为。
设置事务隔离级别的语法也因产品而异,常见形式是:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
生产代码应针对正在使用的数据库确认:
- 默认隔离级别是什么;
- 当前级别会阻止哪些并发现象;
- 冲突时是等待、报错,还是需要应用重试。
使用锁保护“先读后写”
转账前可能需要读取并锁定付款账户:
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
UPDATE accounts
SET balance = balance - 100.00
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100.00
WHERE id = 2;
COMMIT;
SELECT ... FOR UPDATE 通常会锁住选中的行,阻止其他事务同时进行冲突修改。它的支持范围和锁行为依数据库而异。
锁的顺序也很重要。两个转账事务如果以相反顺序锁定两个账户,可能形成死锁:各自拿着一个锁,又等待对方释放另一个锁。常见做法是始终按账户 ID 从小到大加锁,并在数据库报告死锁时重试整个事务。
保存点:只撤销事务的一部分
保存点(Savepoint)可以在事务内部设置一个可回退的位置:
BEGIN;
UPDATE orders
SET status = 'paid'
WHERE id = 101;
SAVEPOINT before_reward;
INSERT INTO reward_records (order_id, points)
VALUES (101, 20);
-- 积分写入有问题,只撤销保存点之后的操作
ROLLBACK TO SAVEPOINT before_reward;
COMMIT;
回滚到保存点不会结束整个事务,保存点之前的修改仍可继续提交。它适合处理可选步骤,但不能把本应整体成功的业务强行拆开。
不同数据库对保存点名称、释放保存点和错误后能否继续执行的规则有所不同。
事务边界应该放在哪里
一个事务应覆盖必须共同成功的数据库操作。例如创建订单时,下面这些步骤可能需要放进同一个事务:
- 检查并扣减库存;
- 创建订单;
- 创建订单明细;
- 记录本地待发送事件。
事务也不宜过长。长事务会长时间占用连接、保留旧版本或持有锁,增加阻塞和冲突。以下操作通常不应放在持有数据库事务的等待过程中:
- 等待用户输入;
- 调用响应时间不确定的外部 HTTP 服务;
- 执行耗时很长的文件处理;
- 无限制地循环重试。
比较稳妥的原则是:先准备好必要输入,开启事务,快速完成数据库读写,然后尽快提交或回滚。
数据库事务管不到外部系统
数据库回滚不能撤销已经发送的邮件、已经完成的支付请求或已经发布到消息队列的消息。
例如:
数据库创建订单成功
↓
消息发送成功
↓
数据库事务提交失败
此时消费者可能收到一个数据库中并不存在的订单消息。常见解决思路包括本地消息表(Transactional Outbox)、幂等消费者和补偿操作。核心目标是让数据库状态与外部副作用能够被可靠地协调,而不是假设一个普通数据库事务可以覆盖所有系统。
应用代码中的事务注意事项
无论使用原生驱动还是 ORM,都应注意:
- 事务中的所有 SQL 必须使用同一个事务对象或同一个连接;
- 任何一步抛出错误时都要回滚;
- 只有全部业务条件满足后才提交;
COMMIT自身也可能失败,不能在调用前就报告成功;- 死锁、序列化冲突和短暂网络故障可能需要重试整个事务;
- 重试逻辑应有次数上限,并确保事务中的操作可以安全重放。
不要把一个已经失败的事务对象继续用于后续业务。很多数据库在语句报错后会要求先回滚,再开始新事务。
常见误区
事务越大越安全
事务范围过大会增加锁竞争、连接占用和回滚成本。事务应只覆盖必须原子完成的工作。
开启事务就不会有并发问题
事务提供的隔离程度取决于隔离级别和具体 SQL。先读后写、范围检查、唯一性判断等场景仍需约束、锁、原子更新或重试策略。
捕获异常后忘记回滚
如果异常路径没有回滚,连接可能带着未结束事务返回连接池,影响后续请求。应使用语言或框架提供的事务封装,保证提交和回滚路径完整。
把失败重试成无限循环
冲突重试是正常工程手段,但必须设置次数、退避和监控。逻辑错误、约束错误和永久性故障不应反复重试。
小结
- 事务让一组相关数据库操作全部提交或全部回滚。
BEGIN、COMMIT和ROLLBACK定义了最基本的事务边界。- ACID 描述原子性、一致性、隔离性和持久性,但具体保证仍取决于数据库及部署配置。
- 并发正确性需要结合隔离级别、约束、原子更新、锁和重试来实现。
- 事务应尽量短,并且不能直接回滚数据库之外已经发生的副作用。