8、事务处理机制:BEGIN/COMMIT/ROLLBACK、隐式事务与显式事务、ACID特性在SQLite中的实现

事务处理,说白了就是数据库的「后悔药」。

我刚开始用SQLite的时候,觉得单机数据库嘛,又没并发,要什么事务?直到有一次,我在一个嵌入式设备上批量写入5000条传感器数据,写到一半断电了。重启一看,数据文件损坏了,5000条数据一条都没剩下。嗯,从那以后,我再也不敢小看事务了。

8.1 什么是事务?一个简单的例子

先看一个转账的例子。假设你要从A账户扣100块,给B账户加100块。这两个操作必须「要么都成功,要么都失败」。如果扣款成功但加钱失败,那钱就凭空消失了——这显然不行。

在SQLite里,事务就是用来保证这种「原子性」的。你可以把一组SQL语句包起来,让它们像一个整体一样执行。

核心概念:事务是一组不可分割的数据库操作。要么全部执行成功,要么全部回滚到最初状态。

8.2 事务的三种操作:BEGIN / COMMIT / ROLLBACK

SQLite的事务控制就三个关键词,简单粗暴:

  • BEGIN:开启一个事务。从这之后,你的所有修改都先存在内存里,不会真正写入磁盘。
  • COMMIT:提交事务。把内存里的修改一次性写入磁盘,别人就能看到了。
  • ROLLBACK:回滚事务。放弃所有修改,回到BEGIN之前的状态。

看个代码示例:

-- 开启事务
BEGIN;

-- 扣钱
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- 加钱
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

-- 检查一下余额有没有负数(我习惯在提交前做一次校验)
SELECT balance FROM accounts WHERE id = 1;
-- 如果余额 >= 0,提交
COMMIT;

-- 如果余额 < 0,回滚
-- ROLLBACK;

我在项目中遇到过一个问题:有个同事在循环里逐条INSERT,每插一条就自动提交一次。结果插到一半程序崩溃了,数据只写了一半。后来改成先BEGIN,全部插完再COMMIT,问题就解决了。

小技巧:批量写入时,一定要用事务包起来。SQLite默认每条SQL都是一个独立事务,频繁提交会非常慢。我实测过,10000条INSERT,逐条提交要30秒,包在一个事务里只要0.3秒——差了100倍。

8.3 隐式事务与显式事务

SQLite有两种事务模式:隐式和显式。说白了就是「自动的」和「手动的」。

8.3.1 隐式事务(自动提交模式)

这是SQLite的默认模式。你每执行一条INSERT、UPDATE、DELETE,SQLite都会自动帮你BEGIN和COMMIT。你感觉不到事务的存在,但它确实在运行。

举个例子:

-- 这两条语句,每条都是一个独立的事务
INSERT INTO users(name) VALUES('Alice');
INSERT INTO users(name) VALUES('Bob');

如果第一条成功了,第二条失败了,第一条不会回滚。因为它们是两个独立的事务。

注意:隐式事务虽然方便,但如果你需要「要么全做,要么全不做」,就必须用显式事务。我曾经见过一个bug:批量插入用户数据时,中间有一条数据格式不对,结果前面的插进去了,后面的没插进去——数据就乱了。这就是隐式事务的坑。

8.3.2 显式事务(手动控制)

显式事务就是你自己调用BEGIN和COMMIT/ROLLBACK。这样你可以精确控制事务的边界。

-- 显式开启事务
BEGIN;

INSERT INTO orders(customer_id, amount) VALUES(1, 99.9);
UPDATE inventory SET stock = stock - 1 WHERE product_id = 10;

-- 检查库存是否足够
SELECT stock FROM inventory WHERE product_id = 10;
-- 如果库存 < 0,回滚
-- ROLLBACK;

-- 一切正常,提交
COMMIT;

我个人习惯在写复杂业务逻辑时,一律用显式事务。这样代码的可读性和可维护性都好很多。你想想看,半年后回来看代码,看到BEGIN和COMMIT,一眼就知道这段逻辑是原子的。

8.4 ACID特性在SQLite中的实现

ACID是事务的四大特性:原子性、一致性、隔离性、持久性。SQLite作为一个轻量级嵌入式数据库,是怎么实现这些的呢?

特性 含义 SQLite的实现方式
原子性(Atomicity) 事务要么全做,要么全不做 使用日志文件(rollback journal或WAL)记录修改前的数据。如果崩溃,回滚时用日志恢复原数据。
一致性(Consistency) 事务前后,数据必须满足所有约束 依赖SQLite的约束检查(主键、外键、唯一、非空、CHECK)。事务提交前会校验所有约束。
隔离性(Isolation) 并发事务之间互不干扰 SQLite默认使用可串行化隔离级别。写操作会加锁,读操作可以并发(WAL模式下)。
持久性(Durability) 提交后数据不会丢失 提交时调用fsync()将数据强制刷入磁盘。WAL模式下,先写WAL文件,再定期合并到主数据库。

这里我重点说一下隔离性。SQLite的隔离级别是「可串行化」,这是最高的隔离级别。说白了就是:多个事务串行执行,一个完了另一个才能开始。

为什么会这样?因为SQLite是嵌入式数据库,通常用在单进程场景。它用锁来实现隔离:

  • 共享锁:多个读操作可以同时持有。
  • 排他锁:写操作必须独占,其他读写都得等着。

我在一个嵌入式项目中遇到过一个问题:多个线程同时写同一个SQLite数据库,结果频繁报「database is locked」。后来改成单线程写、多线程读,问题就解决了。如果你需要高并发写入,SQLite可能不是最佳选择——但大多数嵌入式场景,单线程写入完全够用。

避坑指南:我曾经在WAL模式下遇到过一个问题——读操作能看到未提交的数据。后来查文档才发现,WAL模式下读操作默认读取的是WAL文件中的最新版本,而不是已提交的版本。解决办法是使用「BEGIN IMMEDIATE」或「BEGIN EXCLUSIVE」来强制加锁。

8.5 事务的三种启动方式

SQLite支持三种事务启动方式:

-- 1. 默认方式(DEFERRED)
BEGIN;  -- 等价于 BEGIN DEFERRED;
-- 直到第一次写操作才获取锁

-- 2. 立即方式(IMMEDIATE)
BEGIN IMMEDIATE;
-- 立即获取写锁,但允许其他读操作

-- 3. 排他方式(EXCLUSIVE)
BEGIN EXCLUSIVE;
-- 立即获取排他锁,其他读写都不允许

我个人的建议是:如果你确定要写数据,直接用BEGIN IMMEDIATE。这样可以避免死锁。为什么?因为DEFERRED模式在第一次写操作时才尝试获取锁,如果此时有其他事务持有读锁,就会报错。而IMMEDIATE模式一开始就尝试获取写锁,如果获取不到就直接失败,不会出现「写到一半才发现锁冲突」的情况。

8.6 事务与性能

事务对性能的影响,我总结了几点经验:

  • 事务越小越好:一个事务里不要放太多操作。我一般控制在1000条以内。太大会占用大量内存,而且回滚代价高。
  • 不要在一个事务里做耗时操作:比如网络请求、文件读写。事务持有锁的时间越长,其他线程等待的时间就越长。
  • WAL模式比回滚日志模式快:WAL模式下,读操作不会被写操作阻塞。如果你的应用读多写少,强烈建议开启WAL模式。
-- 开启WAL模式
PRAGMA journal_mode=WAL;

-- 查看当前日志模式
PRAGMA journal_mode;

小提示:WAL模式虽然快,但有个缺点——WAL文件会不断增长。你需要定期执行CHECKPOINT来合并WAL文件。SQLite会自动做这件事,但如果你写入量很大,可以手动触发:

PRAGMA wal_checkpoint(TRUNCATE);

8.7 事务的嵌套

SQLite支持事务嵌套,但实现方式有点特殊——它用的是「保存点(SAVEPOINT)」机制。

-- 外层事务
BEGIN;
INSERT INTO log(msg) VALUES('start');

-- 内层事务(实际上是保存点)
SAVEPOINT sp1;
INSERT INTO log(msg) VALUES('inner');
-- 回滚到保存点
ROLLBACK TO sp1;
-- 释放保存点
RELEASE sp1;

-- 提交外层事务
COMMIT;

这里要注意:ROLLBACK TO保存点不会释放保存点,你需要手动RELEASE。而ROLLBACK(不带参数)会回滚整个事务,包括所有保存点。

我在项目中用过一次事务嵌套:一个批量导入功能,外层事务控制整个导入过程,内层保存点控制每一条数据的导入。如果某条数据有问题,只回滚到保存点,不影响其他数据。这样既保证了原子性,又保留了部分成功的数据。

8.8 总结

事务处理是数据库的基石。SQLite虽然轻量,但ACID特性一个不少。记住三点:

  • 批量操作一定要用显式事务
  • 写操作建议用BEGIN IMMEDIATE
  • 读多写少场景开启WAL模式

嗯,这些经验都是我用真金白银换来的。希望你能少踩一些坑。


公众号:蓝海资料掘金营,微信deep3321