3、数据库回滚策略:事务与ACID、SQL回滚语句、数据库迁移回滚(Flyway/Liquibase)
数据库回滚,说白了就是给数据操作买个「后悔药」。我见过太多线上事故,都是因为一条SQL写错了,或者一个迁移脚本跑偏了,结果数据乱成一锅粥。今天咱们就聊聊,怎么把这颗后悔药吃明白。
3.1 事务与ACID:回滚的基石
先问个问题:为什么数据库能回滚?
答案就在事务里。事务的ACID特性,尤其是原子性(Atomicity),保证了「要么全做,要么全不做」。你想想看,如果没有原子性,一个转账操作扣了钱但没加上,这账怎么算?
ACID 四个特性:
- 原子性(Atomicity):事务不可分割,要么全部成功,要么全部回滚。
- 一致性(Consistency):事务前后,数据完整性约束不被破坏。
- 隔离性(Isolation):并发事务之间互不干扰。
- 持久性(Durability):事务一旦提交,数据永久保存。
我个人习惯把事务想象成一个「操作盒子」。你把一堆SQL扔进去,要么盒子完整打开,所有变更生效;要么盒子直接锁死,所有变更撤销。嗯,这里要注意,回滚只对未提交的事务有效。一旦你执行了 COMMIT,那就真的「覆水难收」了。
避坑指南:我曾经在项目里遇到一个同事,在存储过程里写了1000行SQL,中间没有加任何事务控制。结果第500行报错,前面的499行已经提交了。那叫一个惨烈。所以我的建议是:任何涉及多步数据修改的操作,必须显式开启事务。
3.2 SQL回滚语句:ROLLBACK 的正确姿势
SQL里的回滚语句很简单,就一个关键字:ROLLBACK。但怎么用好它,这里头有门道。
先看个基础例子:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 检查余额是否足够
IF (SELECT balance FROM accounts WHERE id = 1) < 0
BEGIN
ROLLBACK;
PRINT '余额不足,回滚';
END
ELSE
BEGIN
COMMIT;
PRINT '转账成功';
END
这段代码的逻辑很清晰:先扣钱,再加钱,然后检查余额。如果扣成负数了,直接回滚。我在项目中遇到过类似场景,当时没做余额检查,结果用户刷了个漏洞,把账户扣成了负一万。嗯,从那以后我再也不敢省略这步了。
再说说保存点(SAVEPOINT)。有时候你不想回滚整个事务,只想回滚到某个中间状态。这时候保存点就派上用场了:
BEGIN TRANSACTION;
INSERT INTO logs VALUES ('操作开始');
SAVEPOINT sp1;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 发现有问题,回滚到保存点
ROLLBACK TO sp1;
-- 这里 logs 表的数据还在,但 accounts 表的更新被撤销了
COMMIT;
注意:保存点回滚后,事务并没有结束。你还可以继续执行其他操作,最后统一提交或回滚。但要注意,保存点只适用于同一个事务内,不能跨事务使用。
3.3 数据库迁移回滚:Flyway 与 Liquibase
日常开发中,我们经常要修改表结构、加字段、建索引。这些操作如果手动执行,很容易出错。更可怕的是,如果迁移脚本写错了,怎么回退?
这时候就需要数据库迁移工具了。我用得最多的是 Flyway 和 Liquibase。它们俩的思路其实差不多:把数据库变更写成版本化的脚本,按顺序执行。如果某个版本出了问题,可以回滚到上一个版本。
3.3.1 Flyway:简单粗暴
Flyway 的理念是「约定大于配置」。你只需要按照命名规范写SQL脚本,比如 V1__create_user_table.sql、V2__add_email_column.sql,Flyway 会自动检测并执行未运行过的脚本。
回滚怎么办?Flyway 本身不支持自动回滚。它只记录哪些脚本执行过了,不会自动生成回滚脚本。所以你需要手动写撤销脚本,比如 U1__drop_user_table.sql。
我个人习惯的做法是:
- 每个迁移脚本都配一个对应的回滚脚本
- 回滚脚本的命名用
U前缀,比如U1__drop_user_table.sql - 回滚时手动执行
flyway undo命令
-- V1__create_user_table.sql
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100)
);
-- U1__drop_user_table.sql
DROP TABLE IF EXISTS users;
避坑指南:我曾经在项目里忘了写回滚脚本,结果线上迁移出问题后,只能手动写SQL去恢复。那叫一个手忙脚乱。所以我的建议是:写迁移脚本的同时,必须写回滚脚本,这是铁律。
3.3.2 Liquibase:功能更全
Liquibase 比 Flyway 更灵活。它支持多种格式的变更集(XML、YAML、JSON、SQL),而且内置了回滚机制。
Liquibase 的每个变更集(changeset)都有一个 rollback 标签,你可以在这里定义回滚逻辑:
<changeSet id="1" author="zhangsan">
<createTable tableName="users">
<column name="id" type="int">
<constraints primaryKey="true"/>
</column>
<column name="name" type="varchar(100)"/>
</createTable>
<rollback>
<dropTable tableName="users"/>
</rollback>
</changeSet>
执行回滚时,只需要运行 liquibase rollbackCount 1,Liquibase 就会自动执行回滚逻辑。你想想看,这比 Flyway 的手动回滚方便多了吧?
不过 Liquibase 也有缺点:学习曲线比 Flyway 陡峭。XML 格式的变更集写起来比较啰嗦,而且团队里不是所有人都熟悉 XML。
3.3.3 选型建议
| 特性 | Flyway | Liquibase |
|---|---|---|
| 上手难度 | 低(纯SQL) | 中(需学习XML/YAML) |
| 回滚支持 | 手动写回滚脚本 | 内置回滚机制 |
| 多数据库支持 | 好 | 非常好 |
| 版本控制 | 基于文件名 | 基于变更集ID |
| 团队协作 | 简单直接 | 需要规范约束 |
我个人建议:如果团队规模小、追求简单,选 Flyway;如果团队大、需要精细控制,选 Liquibase。但不管选哪个,回滚脚本必须写,这是底线。
注意:无论是 Flyway 还是 Liquibase,回滚操作都有风险。比如你回滚了一个删字段的脚本,但其他服务已经依赖了这个新字段,那回滚后反而会引发新的问题。所以我的建议是:回滚前一定要确认上下游依赖,最好在预发环境先演练一遍。
好了,数据库回滚策略就聊到这儿。记住一句话:事务是回滚的基石,SQL回滚是基本功,迁移工具是效率保障。三者缺一不可。