前置知识: SQL

事务控制(TCL 实战)

6 min中级

以歌姬应援值转账为主线讲 BEGIN/COMMIT/ROLLBACK/SAVEPOINT:原子性演示、CHECK 拦截与回滚的分工、DDL 隐式提交铁律、双会话隔离实验

知识点地图

  • 知识类别:SQL TCL / 事务控制语句(ACID 的理论在事务四特性,隔离级别深水区在隔离级别——本篇只管”四个语句怎么用对”)。
  • 解决什么问题:转账要”扣钱与到账同生共死”、批量导入要”错一条回一批”、复杂流程要”只回滚一半”——这些是 BEGIN/COMMIT/ROLLBACK/SAVEPOINT 四件套的岗位。
  • 什么时候用到:任何”多条写语句要么全成要么全不算”的场景;写迁移脚本;排查”我明明 ROLLBACK 了为什么没滚回去”。
  • 练习环境:vocaloid 曲库(歌姬表带 points 应援值字段,CHECK (points >= 0))。

心智模型:事务是”草稿模式”

text
没有事务:每条语句一个独立事务(autocommit),写完立即生效——一步做错无法整体撤销
开启事务:BEGIN 之后进入草稿模式,改动能看能改,外人看不见
            ├─ COMMIT:草稿转正,永久生效
            └─ ROLLBACK:草稿作废,一切如初

三个默认值要先背下来:

  1. autocommit 默认 ON:每条语句自成事务。手动 BEGIN 才能攒一批;
  2. 事务开启后必须以 COMMIT 或 ROLLBACK 收尾——忘收尾的连接挂着事务,锁不放、快照不推进(事故形态见 PG 连接池篇的 idle in transaction);
  3. 事务里不要放 DDL(原因见下文铁律一节)。

场景一:应援值转账(原子性现场)

需求:把初音未来的 200 点应援值转给洛天依。两条 UPDATE 必须同生共死——扣了没到账就是事故。

sql
-- 准备
ALTER TABLE vsinger ADD COLUMN points INT NOT NULL DEFAULT 1000
  , ADD CONSTRAINT chk_points CHECK (points >= 0);
UPDATE vsinger SET points = 1000 WHERE name IN ('初音未来', '洛天依');

-- 转账
START TRANSACTION;

UPDATE vsinger SET points = points - 200 WHERE name = '初音未来';
UPDATE vsinger SET points = points + 200 WHERE name = '洛天依';

-- 中间态检查(只有本会话看得见)
SELECT name, points FROM vsinger WHERE name IN ('初音未来', '洛天依');
-- 初音未来 800 / 洛天依 1200 —— 草稿状态

COMMIT;    -- 或 ROLLBACK:两条一起生效 / 一起消失

逐段讲解:

  • START TRANSACTION(MySQL)或 BEGIN(PG/标准):从这条起 autocommit 暂停,语句进草稿;
  • 两条 UPDATE 之间没有先后生效:第二条失败时第一条也必须不算——这正是”转账不能拆成两个 autocommit”的原因。拆开会发生什么:第一条提交后进程崩溃,200 点凭空蒸发;
  • 中间态 SELECT 是自检手段:提交前核对两边数字,养成”COMMIT 前看一眼”的习惯。

CHECK 拦截与回滚的分工——把转账金额改成 5000 试试:

sql
START TRANSACTION;
UPDATE vsinger SET points = points - 5000 WHERE name = '初音未来';
-- ERROR 3819: Check constraint 'chk_points' is violated.
-- 语句级拦截:这条 UPDATE 根本没执行,事务还开着
ROLLBACK;   -- 习惯性收尾(其实这里没有可回滚的东西)

分工口诀:CHECK 负责把非法语句挡在门外(语句级),ROLLBACK 负责把合法但业务上错误的改动撤销(事务级)。负余额轮不到 ROLLBACK 出场——约束先动手。反过来,“扣了 200 但转错了人”是合法数据,只有 ROLLBACK 能救。

场景二:SAVEPOINT(只回滚一半)

需求:批量导入 10 位歌姬,中途发现第 7 条数据有错,前 6 条要保留。

sql
START TRANSACTION;

INSERT INTO vsinger (name, debut_year, company) VALUES ('姬A', 2020, '社A');
INSERT INTO vsinger (name, debut_year, company) VALUES ('姬B', 2021, '社B');
SAVEPOINT after_good_batch;          -- 里程碑:到此为止的都是好的

INSERT INTO vsinger (name, debut_year, company) VALUES ('坏数据', NULL, NULL);
-- 发现 debut_year 不该为空

ROLLBACK TO SAVEPOINT after_good_batch;   -- 撤销坏数据,保留前两条
RELEASE SAVEPOINT after_good_batch;       -- 用完删除里程碑(可选)

INSERT INTO vsinger (name, debut_year, company) VALUES ('姬C', 2022, '社C');
COMMIT;   -- 姬A、姬B、姬C 入库,坏数据无痕

要点:

  • SAVEPOINT 是事务内的里程碑,回滚到它不结束事务——与 ROLLBACK(整个作废)是两回事;
  • 里程碑可嵌套多个、可重复回滚;RELEASE 只是删除命名,不提交任何东西;
  • 别滥用:十几层 SAVEPOINT 的脚本说明流程设计有问题,拆成多个事务更清晰。

场景三:双会话隔离实验(亲眼看见隔离级别)

一个人也能做并发实验:开两个命令行窗口当两个用户。先确认会话都在 READ COMMITTED(或各自的默认档):

sql
-- 会话 A(先跑,不提交)
START TRANSACTION;
SELECT points FROM vsinger WHERE name = '初音未来';    -- 读到 1000

-- 会话 B(趁 A 未提交)
UPDATE vsinger SET points = 666 WHERE name = '初音未来';  -- 立即提交生效

-- 会话 A 再读
SELECT points FROM vsinger WHERE name = '初音未来';
-- READ COMMITTED:读到 666(不可重复读发生了)
-- REPEATABLE READ(MySQL 默认):仍读到 1000(快照读)

这个实验是理解隔离级别的最快路径:同一事务里两次相同查询结果不同,就是不可重复读。换 REPEATABLE READ 再跑一遍,A 会一直读 1000 直到 COMMIT 后。实验失败排查:会话 A 忘了 BEGIN(autocommit 下每次 SELECT 都是独立事务,永远读到最新值)——九成的”我怎么复现不出来”是这个。

铁律:事务里放 DDL = 隐式提交

sql
START TRANSACTION;
UPDATE vsinger SET points = 99 WHERE name = '初音未来';   -- 打算回滚的改动
CREATE TABLE ddl_probe (id INT);                            -- 手滑放了 DDL
ROLLBACK;

SELECT points FROM vsinger WHERE name = '初音未来';         -- 99!没回滚

原因:MySQL 的 DDL 执行前会隐式提交当前事务(DDL 自身也立即生效)。ROLLBACK 只能回滚”最后一个隐式提交之后”的语句——CREATE TABLE 之后的——UPDATE 已经转正。PG 的多数 DDL 可回滚(事务性 DDL 是它的优点),但跨方言脚本的纪律按最弱者执行:任何库的事务里都别放 DDL,混合脚本把 DDL 拆到事务外。

完整可复现实验与”改了再改回来”的写法见表结构变更的动手环节。

常见困惑

“COMMIT 之后还能反悔吗?“——不能。TCL 的撤销只到 COMMIT 为止;反悔已提交的数据是另一个话题:备份恢复(见逻辑备份与 PITR)、或补偿事务(再写一条反向 UPDATE)。

“嵌套事务是什么?“——SQL 标准没有真嵌套。SAVEPOINT 是官方的”伪嵌套”;有些客户端框架在应用层模拟嵌套(最外层才真提交)。看到框架文档说”支持嵌套事务”,先问实现是哪种。

“autocommit 关掉好不好?“——连接级 SET autocommit = 0 让所有语句进草稿直到显式 COMMIT。批处理脚本方便,但忘收尾 = 长事务 = 锁与快照灾难。宁显式 BEGIN,不长关 autocommit。

动手实践:把四个语句全部演一遍

任务:

  1. 复现场景一的转账,分别以 COMMIT 和 ROLLBACK 收尾,核对两次结果差异;
  2. 制造”扣 5000”的 CHECK 拦截,确认事务状态仍可继续使用(再执行别的语句试试);
  3. 用 SAVEPOINT 搭一个”导入 5 行、第 3 行错、重导第 3 行”的流程;
  4. 复现 DDL 隐式提交实验,再用”DDL 放事务外”的写法修正它;
  5. 双会话实验:READ COMMITTED 下复现不可重复读;切 REPEATABLE READ 再复现一次对比。
参考实现(先自己写再展开)
sql
-- 1
START TRANSACTION;
UPDATE vsinger SET points = points - 200 WHERE name = '初音未来';
UPDATE vsinger SET points = points + 200 WHERE name = '洛天依';
COMMIT;          -- 查两边 800/1200
-- 重置后重来一次改用 ROLLBACK,两边都回到 1000

-- 2
START TRANSACTION;
UPDATE vsinger SET points = points - 5000 WHERE name = '初音未来';   -- ERROR 3819
UPDATE vsinger SET points = points + 1   WHERE name = '洛天依';      -- 正常,事务没死
COMMIT;    -- 只有 +1 生效

-- 3
START TRANSACTION;
INSERT INTO vsinger (name, debut_year, company) VALUES ('T1', 2020, 'S');
INSERT INTO vsinger (name, debut_year, company) VALUES ('T2', 2021, 'S');
SAVEPOINT sp1;
INSERT INTO vsinger (name, debut_year, company) VALUES ('T3bad', NULL, 'S');
ROLLBACK TO SAVEPOINT sp1;
INSERT INTO vsinger (name, debut_year, company) VALUES ('T3', 2022, 'S');
COMMIT;

-- 5(会话隔离实验正文原样执行;切级别)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

判读要点:任务 2 说明语句报错不自动回滚整个事务(MySQL 默认),事务继续可用——这与某些框架”抛异常即回滚”的应用层行为不同,别混淆。任务 5 的两档差异要能一句话说出:读到的值变没变。

检验清单

  • 能说出 autocommit 默认值与”忘收尾事务”的两种后果;
  • 能用转账场景讲清原子性,并解释”拆成两条 autocommit 哪里会坏”;
  • 能区分 CHECK 拦截(语句级)与 ROLLBACK(事务级)的分工;
  • 会用 SAVEPOINT 做局部回滚,并知道它与 ROLLBACK 的区别;
  • 能复现 DDL 隐式提交实验并给出规避写法;
  • 能搭双会话实验复现不可重复读。

下一步

参考与致谢

提交的保证

COMMIT 成功后,数据库保证:

  • 变更已写入重做日志(redo log / WAL)
  • 即使系统崩溃,变更也不会丢失
  • 其他事务可以看到这些变更

链式提交

sql
-- PostgreSQL:COMMIT AND CHAIN 自动开始新事务
COMMIT AND CHAIN;
-- 等价于 COMMIT; BEGIN;

隐式回滚

sql
-- 以下情况事务自动回滚:
-- 1. 连接断开
-- 2. 语句执行错误(部分数据库)
-- 3. 死锁被选中牺牲

-- PostgreSQL:错误后事务进入 aborted 状态
BEGIN;
INSERT INTO orders VALUES (1, 99.99);
INSERT INTO orders VALUES ('invalid', 99.99);  -- 类型错误
-- 事务进入 aborted 状态,后续语句都被忽略
INSERT INTO orders VALUES (2, 49.99);  -- 被忽略
ROLLBACK;  -- 必须显式回滚

多级保存点

sql
BEGIN;
SAVEPOINT level1;

INSERT INTO table_a VALUES (1);

SAVEPOINT level2;

INSERT INTO table_b VALUES (2);

SAVEPOINT level3;

INSERT INTO table_c VALUES (3);

-- 回滚到 level2,level3 的变更被撤销
ROLLBACK TO SAVEPOINT level2;

-- level1 和 level2 的变更仍然保留
COMMIT;

SQL Server 嵌套事务

sql
-- SQL Server 支持嵌套 BEGIN TRANSACTION
BEGIN TRANSACTION outer;
INSERT INTO table_a VALUES (1);

BEGIN TRANSACTION inner;
INSERT INTO table_b VALUES (2);
COMMIT TRANSACTION inner;  -- 减少嵌套计数

COMMIT TRANSACTION outer;  -- 真正提交