第七章 事务与数据安全
7.1 事务的基本用法
什么是事务
事务(Transaction)就是把多条 SQL 语句打包成一个原子操作——要么全部成功,要么全部回滚,不会出现"执行了一半"的状态。
典型场景:转账
用户 A 余额 - 100 元
用户 B 余额 + 100 元
如果第一条执行了,第二条失败了 → 钱凭空消失了!
事务保证:两条语句要么一起成功,要么一起撤销。基本语法
sql
-- 开始事务
BEGIN TRANSACTION; -- 也可以简写为 BEGIN;
-- 执行一组操作
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 一切正常,提交
COMMIT;sql
-- 出现异常,回滚
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 发现余额不足或出错了...
ROLLBACK; -- 撤销所有操作,数据恢复原样SQLite 的自动事务
SQLite 有一个重要特性:每条独立的 SQL 语句自动包裹在一个事务中。
你写的: INSERT INTO users VALUES (1, '张三', 25);
SQLite 实际执行的:
BEGIN;
INSERT INTO users VALUES (1, '张三', 25);
COMMIT;
每条语句都是原子的——即使你不显式写 BEGIN/COMMIT。事务的性能优势
批量操作时,手动包裹事务可以大幅提升性能:
sql
-- ❌ 慢:1000 条 INSERT = 1000 次事务(每次都要 fsync 磁盘)
INSERT INTO logs VALUES (1, 'event_a');
INSERT INTO logs VALUES (2, 'event_b');
... -- 重复 1000 次
-- ✅ 快:1000 条 INSERT 包在一个事务里(只 fsync 一次)
BEGIN;
INSERT INTO logs VALUES (1, 'event_a');
INSERT INTO logs VALUES (2, 'event_b');
... -- 重复 1000 次
COMMIT;性能对比(1000 条 INSERT):
无显式事务:每条 INSERT 都是独立事务
→ 1000 次 fsync → 约 10~30 秒(机械硬盘)
有显式事务:所有 INSERT 打包成一个事务
→ 1 次 fsync → 约 0.05 秒
差距可达 100~500 倍!实用建议:批量写入数据时,一定要用
BEGIN/COMMIT包裹。这是 SQLite 性能优化中最简单也最有效的一招。
SAVEPOINT —— 事务中的存档点
SQLite 支持在事务内部设置"存档点",可以只回滚到某个存档点,而不是回滚整个事务:
sql
BEGIN;
INSERT INTO users VALUES (1, '张三', 25);
SAVEPOINT sp1; -- 设置存档点
INSERT INTO users VALUES (2, '李四', 30);
-- 发现这条有问题,只回滚到 sp1
ROLLBACK TO sp1;
-- 继续操作
INSERT INTO users VALUES (3, '王五', 28);
COMMIT;
-- 结果:只有 id=1 和 id=3 被插入,id=2 被回滚了7.2 ACID 在 SQLite 中的体现
ACID 是数据库事务的四大特性。来看 SQLite 是如何实现每一个特性的:
Atomicity(原子性)—— 要么全做,要么全不做
sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 如果这里断电了...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;断电时的处理:
┌────────────────────────────────────────────┐
│ 如果 COMMIT 之前发生故障: │
│ → 下次打开数据库时,自动回滚未完成的事务 │
│ → 数据恢复到 BEGIN 之前的状态 │
│ → 不会出现 "A 扣了钱但 B 没收到" 的情况 │
└────────────────────────────────────────────┘SQLite 通过日志文件(回滚日志或 WAL)来保证原子性。
Consistency(一致性)—— 数据始终满足约束
sql
-- 有 CHECK 约束:balance >= 0
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
balance REAL CHECK (balance >= 0)
);
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 如果余额变成负数 → CHECK 约束失败 → 语句报错
-- 整个事务可以 ROLLBACK,数据保持一致Isolation(隔离性)—— 并发事务互不干扰
SQLite 的隔离级别:
| 日志模式 | 隔离级别 | 行为 |
|---|---|---|
| DELETE(默认) | SERIALIZABLE | 最严格,事务串行执行 |
| WAL | SERIALIZABLE(读写之间) | 读写可以并发,但写还是串行 |
SQLite 的隔离性比大多数数据库更简单粗暴——因为它是数据库级别的锁,而不是行级锁。这意味着同一时间只有一个写事务。
Durability(持久性)—— 提交后数据不丢失
COMMIT 执行时:
1. 数据写入日志文件(回滚日志或 WAL)
2. 调用 fsync() 确保数据落盘到磁盘
3. 返回成功
只要 COMMIT 返回了,数据就安全了,
即使紧接着断电、操作系统崩溃也不会丢。PRAGMA synchronous —— 持久性 vs 性能的权衡
sql
PRAGMA synchronous = FULL; -- 最安全,每次 COMMIT 都 fsync(默认)
PRAGMA synchronous = NORMAL; -- 平衡模式(WAL 模式下推荐)
PRAGMA synchronous = OFF; -- 最快,但断电可能丢数据| 选项 | 安全性 | 速度 | 适用场景 |
|---|---|---|---|
FULL | ⭐⭐⭐ | 较慢 | 金融、订单等关键数据 |
NORMAL | ⭐⭐ | 较快 | 大多数应用(WAL 模式下推荐) |
OFF | ⭐ | 最快 | 可重建的缓存数据、测试环境 |
实际建议:使用 WAL 模式 +
synchronous = NORMAL是性能和安全的最佳平衡点。
7.3 日志模式:DELETE vs WAL
SQLite 通过日志文件来保证事务的原子性和持久性。有两种日志模式,理解它们的区别对于性能优化至关重要。
DELETE 模式(默认)—— 回滚日志
DELETE 模式的写入流程:
① 写之前:先把原始数据复制到 .db-journal 文件(回滚日志)
② 修改主数据库文件
③ COMMIT 成功后,删除 .db-journal 文件
┌──────────┐ ┌──────────────────┐
│ main.db │ ──→ │ main.db-journal │ ① 备份原始数据
└──────────┘ └──────────────────┘
│
↓ ② 直接修改
┌──────────┐
│ main.db │ (新数据写在这里)
└──────────┘
│
↓ ③ COMMIT 后
┌──────────────────┐
│ main.db-journal │ ← 被删除
└──────────────────┘
如果 ② 之后断电:
→ 下次打开时发现 .db-journal 存在
→ 自动用日志恢复数据(回滚)WAL 模式 —— 写前日志(Write-Ahead Logging)
WAL 模式的写入流程:
① 写操作追加到 .db-wal 文件(不修改主数据库)
② 读操作先查 WAL,再查主数据库
③ 定期把 WAL 的内容合并回主数据库(checkpoint)
┌──────────┐ ┌──────────────┐
│ main.db │ │ main.db-wal │ ① 新数据写到这里
│ (不动) │ │ (追加写入) │
└──────────┘ └──────────────┘
│
↓ ③ checkpoint 时
┌──────────┐ ┌──────────────┐
│ main.db │ ←── │ main.db-wal │ 合并回主文件
│ (更新了) │ │ (清空) │
└──────────┘ └──────────────┘切换到 WAL 模式
sql
-- 开启 WAL 模式(只需执行一次,效果永久保存在数据库文件中)
PRAGMA journal_mode = WAL;
-- 查看当前日志模式
PRAGMA journal_mode; -- 返回 'wal' 或 'delete'
-- 切回 DELETE 模式
PRAGMA journal_mode = DELETE;DELETE vs WAL 对比
| 对比维度 | DELETE 模式 | WAL 模式 |
|---|---|---|
| 写性能 | 较慢(要先备份再修改) | 较快(追加写入,顺序 I/O) |
| 读写并发 | 读写互斥 | 读写可以并发 ✅ |
| 读读并发 | 可以并发 | 可以并发 |
| 写写并发 | 不可以 | 不可以(同一时间只有一个写者) |
| 文件数量 | .db + .db-journal(临时) | .db + .db-wal + .db-shm |
| 数据库大小 | 正常 | WAL 文件可能较大(checkpoint 前) |
| 适合场景 | 简单应用、很少并发 | Web 应用、多线程、读多写少 |
WAL 模式的推荐配置
sql
-- 推荐的 WAL 模式配置组合
PRAGMA journal_mode = WAL; -- 开启 WAL
PRAGMA synchronous = NORMAL; -- 平衡安全与性能
PRAGMA wal_autocheckpoint = 1000; -- 每 1000 页自动 checkpoint
PRAGMA busy_timeout = 5000; -- 锁等待超时 5 秒一句话总结:大多数应用都应该用 WAL 模式。它的性能更好,而且支持读写并发。
7.4 并发控制与锁机制
SQLite 是进程/线程安全的,但它的并发模型和 MySQL / PostgreSQL 有本质区别——它用的是数据库级别的锁,而不是行级锁。
SQLite 的 5 种锁状态
锁的升级路径:
UNLOCKED → SHARED → RESERVED → PENDING → EXCLUSIVE
┌───────────┐
│ UNLOCKED │ 没有任何操作
└─────┬─────┘
↓ 开始读
┌───────────┐
│ SHARED │ 正在读取,允许其他连接同时读
└─────┬─────┘
↓ 准备写
┌───────────┐
│ RESERVED │ 准备写入,但还没真正写。其他连接仍可读
└─────┬─────┘
↓ 要 COMMIT 了
┌───────────┐
│ PENDING │ 等待所有 SHARED 锁释放
└─────┬─────┘
↓ 所有读者离开
┌───────────┐
│ EXCLUSIVE │ 独占!正在写入数据库文件
└───────────┘| 锁状态 | 含义 | 允许其他连接 |
|---|---|---|
| UNLOCKED | 无操作 | 任意 |
| SHARED | 正在读 | 读 ✅ 写 ❌ |
| RESERVED | 准备写 | 读 ✅ 新写 ❌ |
| PENDING | 等待写 | 新读 ❌ 新写 ❌ |
| EXCLUSIVE | 正在写 | 读 ❌ 写 ❌ |
DELETE 模式下的锁行为
连接 A(写) 连接 B(读)
BEGIN;
UPDATE ... SELECT ... ← B 可以读
(RESERVED) (SHARED)
COMMIT; SELECT ... ← 此时 B 被阻塞!
(EXCLUSIVE) (等待中...)
完成! SELECT ... ← A 完成后 B 才能继续
(UNLOCKED) (SHARED)WAL 模式下的锁行为
连接 A(写) 连接 B(读)
BEGIN;
UPDATE ... SELECT ... ← B 可以读(读主文件)
(写WAL) (读主文件)
COMMIT; SELECT ... ← B 仍然可以读!
(写WAL) (读主文件) 不被阻塞 ✅
写和读互不干扰,各走各的路径:
写 → 写入 WAL 文件
读 → 从主文件 + WAL 中获取最新数据常见错误与解决方案
错误 1:database is locked
原因:另一个连接持有锁,当前连接超时。
解决方案:
1. 设置 busy_timeout(等待一段时间再报错)
PRAGMA busy_timeout = 5000; -- 等待最多 5 秒
2. 切换到 WAL 模式(减少锁冲突)
PRAGMA journal_mode = WAL;
3. 缩短事务持续时间(尽快 COMMIT)
4. 检查是否有未关闭的连接或未提交的事务错误 2:database is locked(多进程写入)
sql
-- 在代码中使用 busy handler / 重试机制
-- Python 示例
import sqlite3
conn = sqlite3.connect('my.db', timeout=10) -- 10 秒超时
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA busy_timeout=5000')错误 3:database disk image is malformed
原因:数据库文件损坏(通常是非正常关闭或文件系统问题)
解决方案:
1. 检查完整性
PRAGMA integrity_check;
2. 尝试恢复
sqlite3 corrupt.db ".recover" | sqlite3 recovered.db并发最佳实践
┌─────────────────────────────────────────────────┐
│ SQLite 并发使用指南 │
│ │
│ 1. 始终使用 WAL 模式 │
│ 2. 始终设置 busy_timeout │
│ 3. 写操作尽快完成(短事务) │
│ 4. 一个进程内共享同一个连接(或使用连接池) │
│ 5. 不要跨网络访问 SQLite 文件(NFS 不可靠!) │
│ 6. 不要同时用多个进程大量写入 │
│ 7. 如果需要高并发写入 → 考虑 PostgreSQL │
└─────────────────────────────────────────────────┘本章小结
| 知识点 | 要点 |
|---|---|
| 事务语法 | BEGIN / COMMIT / ROLLBACK;SAVEPOINT 做局部回滚 |
| 自动事务 | 每条独立 SQL 自动包裹在事务中 |
| 性能技巧 | 批量操作用显式事务包裹,速度提升 100~500 倍 |
| ACID | SQLite 完整支持 ACID 四特性 |
| 日志模式 | DELETE(默认)vs WAL;推荐 WAL |
| WAL 配置 | journal_mode=WAL + synchronous=NORMAL + busy_timeout=5000 |
| 锁机制 | 数据库级别锁;DELETE 模式读写互斥;WAL 模式读写可并发 |
| 常见错误 | database is locked → 设 busy_timeout + 用 WAL + 短事务 |
下一章预告:探索 SQLite 的特色功能——JSON 操作、FTS5 全文搜索、窗口函数、生成列和 STRICT 表。