Menu

SQLite 事务:BEGIN、COMMIT、ROLLBACK 详解

SQLite 中的事务如何工作:BEGIN、COMMIT、ROLLBACK、自动提交,以及决定何时加锁的 DEFERRED/IMMEDIATE/EXCLUSIVE 模式。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

事务是一个要么全做、要么全不做的整体

事务把几条语句组合在一起,让它们要么全部生效,要么全部不生效。如果中途出了任何问题,你可以回滚,数据库会完全回到开始时的状态。

经典的例子是转账:

这两条 UPDATE 是一体的。如果数据库在两条语句之间崩溃,Ada 会少了 2000 分,而 Boris 却什么也没多。用 BEGIN ... COMMIT 把它们包起来,就让这一对操作具备了原子性:要么都发生,要么都不发生。

自动提交:你一直在用的默认模式

到目前为止你执行过的每条 SQL 语句都是一个事务。SQLite 默认处于自动提交模式:每条语句外面都隐式地包着自己的 BEGIN 和 COMMIT。

三条插入,三个独立的事务,三次写盘同步(fsync)。偶尔写一次没问题,但批量导入时会很慢,而且这意味着你无法把一组语句当作一个整体撤销。BEGIN 会关闭自动提交,直到下一次 COMMIT 或 ROLLBACK。

ROLLBACK:就当什么都没发生

ROLLBACK 会丢弃自对应的 BEGIN 以来所做的一切,数据库恢复到事务开始前的状态。

UPDATE 和 DELETE 都消失了,表的样子和 BEGIN 之前一样。正是这张安全网,让应用代码在多语句操作进行到一半出错时能干净地中止。

顺便说一句,事务中违反约束并不会自动回滚整个事务。它只回滚出错的那条语句,事务仍然保持打开,等你决定怎么办。如果你想要全有或全无,应用就必须在发现错误时发出 ROLLBACK。

加速批量插入

因为每条自动提交的语句都要单独做一次 fsync,把一批语句放进同一个事务通常会快 100 倍:

只在 COMMIT 时同步一次磁盘,而不是每行一次。如果你导入几千行数据时发现慢得像在爬,答案几乎总是这个。

DEFERRED、IMMEDIATE、EXCLUSIVE

BEGIN 可以接收一个模式,控制 SQLite 在什么时候加锁:

  • BEGIN DEFERRED(默认):在你读或写之前完全不加锁。写锁是延迟获取的,在第一条写语句时才拿到。
  • BEGIN IMMEDIATE:立即获取写锁。其他连接仍然可以读,但不能开始写。
  • BEGIN EXCLUSIVE:和 IMMEDIATE 一样,另外其他连接也不能读。在 WAL 模式下它和 IMMEDIATE 的行为相同;只有在较老的回滚日志模式下,这个区别才有意义。
BEGIN DEFERRED;     -- same as plain BEGIN
BEGIN IMMEDIATE;    -- reserve the write lock now
BEGIN EXCLUSIVE;    -- reserve everything (rollback-journal mode)

这个选择会影响并发。使用普通的 BEGIN 时,两个连接可以各自开启事务,都顺利地读数据,然后在尝试写入时发生竞争:第二个申请写锁的连接会得到 SQLITE_BUSY,更糟的是,它已经做过的读取现在只能作废。

BEGIN IMMEDIATE 解决了这个问题:如果你知道自己要写,就先申请写锁。第二个连接会立刻阻塞(或快速失败),不会先做一堆之后要丢弃的工作。

经验法则:如果你的事务要写数据,就用 BEGIN IMMEDIATE。

事务中的读取看到的是快照

事务打开期间,你的读取看到的是数据库的一致快照:WAL 模式下是事务开始时的状态,回滚日志模式下是你第一次读取时的状态。其他连接提交的修改不会突然出现在你的查询结果里。

你能看到自己尚未提交的写入,其他连接看不到。一旦 COMMIT,新值对所有人可见。这就是人们说 SQLite 是“可串行化”(serializable)的含义:没有 READ COMMITTED 这样的开关可调,因为默认就已经是最强的级别。

应用代码中的事务

在真实的程序里,常见模式是用 try/except(或 try/catch)包住主体,在出错的分支里执行 ROLLBACK:

-- Pseudocode for any client library
BEGIN IMMEDIATE;
try:
    UPDATE accounts SET cents = cents - 2000 WHERE owner = 'Ada';
    UPDATE accounts SET cents = cents + 2000 WHERE owner = 'Boris';
    COMMIT;
except:
    ROLLBACK;
    raise;

大多数客户端库(Python 的 sqlite3、better-sqlite3 等)都会用 with 代码块或 transaction() 辅助函数帮你封装好。值得查一下你所用库的文档,默认行为不一定符合你的预期。尤其是 Python 的 sqlite3,它的自动提交行为历来有些古怪;较新的版本加入了正式的 autocommit 参数来解决这个问题。

容易踩的坑

  • 事务中可以执行 DDL。 CREATE TABLE、ALTER TABLE,甚至 DROP TABLE 都能回滚。SQLite 在这方面与众不同,很多数据库会自动提交 DDL。
  • VACUUM 不能在事务中执行。 另外几条维护命令也不行。请在自动提交模式下执行它们。
  • COMMIT 失败就是真的失败。 如果 COMMIT 返回 SQLITE_BUSY(少见但可能发生),事务并没有提交。你的代码需要处理这种情况,通常是重试。
  • 长事务会阻塞写入者。 一个打开好几分钟的事务,会让其他写入者等上好几分钟。晚点开启,尽快提交。

下一步:保存点

BEGIN 和 COMMIT 是全有或全无的。有时你只想回滚事务的一部分,比如放弃某个有风险的步骤,但保留其余部分。这正是保存点的用途,下一页就会讲到。

常见问题

SQLite 中如何开启事务?

执行 BEGIN;(或 BEGIN TRANSACTION;),完成你的操作,然后用 COMMIT; 保存,或用 ROLLBACK; 放弃。如果没有显式的 BEGIN,每条语句都会在各自自动提交的事务中执行。

BEGIN、BEGIN IMMEDIATE 和 BEGIN EXCLUSIVE 有什么区别?

BEGIN(等同于 BEGIN DEFERRED)要等到真正写入时才获取写锁,如果别人抢先一步,之后就可能因 SQLITE_BUSY 而失败。BEGIN IMMEDIATE 一开始就拿到写锁。BEGIN EXCLUSIVE 更进一步,连其他读取者也会被阻塞(只在非 WAL 模式下才有意义)。

SQLite 支持事务隔离级别吗?

不是 SQL 标准意义上的那种。SQLite 实际上就是 SERIALIZABLE:事务看到的是一致的快照,写入是串行的。它没有 READ COMMITTED 或 REPEATABLE READ 这样的开关,你能选的是 DEFERRED、IMMEDIATE 还是 EXCLUSIVE,它们控制的是何时加锁,而不是你能看到什么。

SQLite 能嵌套事务吗?

不能直接嵌套,你不能在一个 BEGIN 里再调用 BEGIN。要实现嵌套,请用 SAVEPOINT 和 RELEASE / ROLLBACK TO,它们可以在单个事务内做部分回滚。下一页会讲到。

Coddy 编程语言插图

和 Coddy 一起学编程

开始