前十二篇都在讨论”一个数据库文件”内部发生的事:一个文件的页面(第
2、4 篇)、一个文件的锁状态机(第 9
篇)、一个文件的事务与隔离(第 10
篇)、一个文件的查询计划(第 11、12
篇)。ATTACH DATABASE
打破了这个前提:一个连接可以同时打开多个独立的数据库文件,SELECT/JOIN/事务都能跨文件进行。SQLite
素来以”单文件”作为核心卖点,但 ATTACH
是这个叙事里一个必须正面回答的张力——逻辑上一个连接看到的是统一的命名空间,物理上仍然是若干个各自独立、各自加锁的文件。
常见误区是想象 ATTACH
之后的跨库事务和单文件事务具有完全相同的原子性保证,或者把它当成某种轻量级分布式事务。官方
Atomic Commit In SQLite 与 ATTACH DATABASE
文档合在一起,给出的答案比直觉更精确,也更容易被忽视:这份原子性保证是有条件的,条件之一恰好是本系列第
8 篇的主角——WAL。本文钉三件事:
ATTACH/DETACH的命名空间规则与本机跨库SELECT、跨库事务提交的真实结果。- 多文件提交依赖的 super-journal 机制,以及它为什么只在 rollback journal 模式下工作——这正是”单文件叙事”在 ATTACH 场景下出现裂缝的具体位置。
- 每个附加库独立加锁、写事务要对所有涉及文件”拿齐”锁的具体含义,与第 9 篇锁阶梯的闭合。
本文是「SQLite 内核」系列第 13 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 12 篇 · 索引与 covering scan 覆盖索引判定、自动索引边界 第 13 篇 · ATTACH / 多库边界 跨库事务、super-journal、WAL 下的原子性退化 第 14 篇 · 完整性检查与损坏恢复 PRAGMA integrity_check
版本锚定:官方 ATTACH DATABASE(sqlite.org/lang_attach.html)、DETACH(sqlite.org/lang_detach.html)、Atomic Commit In SQLite §5 Multi-file Commit(sqlite.org/atomiccommit.html)。多文件原子提交机制自 SQLite 3.5.0 起的实现细节(super-journal 命名规则)文档明确标注”is not part of the SQLite specification and is subject to change”,本文只引用其行为性结论,不依赖具体文件名格式。本文实测锚定本机 SQLite 3.53.2。
一、ATTACH DATABASE:命名空间与本机实测
ATTACH DATABASE 'file' AS name
把另一个数据库文件加入当前连接的命名空间。官方文档划定的边界很直接:main
和 temp 是保留名,不能被
ATTACH/DETACH;表引用用
schema-name.table-name,如果表名在所有已挂载库里唯一,可以省略前缀;如果多个库里有同名表且省略前缀,取的是”最近挂载”的那一个(“the
table chosen is the one in the database that was least
recently attached”——注意官方原文用的是 least
recently,即最早挂载的那个,不是最近的)。
本机实测(复现脚本:reproduce/13-attach.sh),两个独立文件
sk13a.db(users 表)与
sk13b.db(logs 表):
$ sqlite3 sk13a.db "ATTACH DATABASE 'sk13b.db' AS logsdb; PRAGMA database_list;"
0|main|/tmp/sk13a.db
2|logsdb|/tmp/sk13b.db
database_list 的编号不连续(0
和 2,中间跳过 1)——1
保留给 temp
库,即便当前连接还没有用过临时表也占着这个编号,这是内部槎位分配的具体表现,不是
bug。
跨库 JOIN 直接可用:
$ sqlite3 sk13a.db <<'SQL'
ATTACH DATABASE 'sk13b.db' AS logsdb;
SELECT users.name, logsdb.logs.msg
FROM users JOIN logsdb.logs ON users.id = logsdb.logs.user_id
ORDER BY logsdb.logs.id;
SQL
alice|login
bob|login
alice|logout
对应的
EXPLAIN QUERY PLAN(本机实测)证实这就是第 11
篇讲过的普通嵌套循环连接,只是两个循环分别落在不同文件上:
QUERY PLAN
|--SCAN logsdb.logs
`--SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
计划器不区分”表在哪个文件”,跨库 JOIN 在计划层面和同库 JOIN 走的是同一套 SEARCH/SCAN 判定(第 11 篇)——多库只是改变了物理存储位置,不改变查询计划的决策逻辑。
二、跨库事务:实测提交成功,DETACH 会被锁挡住
在一个显式事务里同时写两个已挂载的库:
$ sqlite3 sk13a.db <<'SQL'
ATTACH DATABASE 'sk13b.db' AS logsdb;
BEGIN;
INSERT INTO users VALUES(3,'carol');
INSERT INTO logsdb.logs VALUES(4,3,'signup');
COMMIT;
SQL
$ sqlite3 sk13a.db "SELECT * FROM users;"
1|alice
2|bob
3|carol
$ sqlite3 sk13b.db "SELECT * FROM logs;"
1|1|login
2|2|login
3|1|logout
4|3|signup
一次 COMMIT
让两个物理文件各自新增了一行,且两边都成功——这是本篇标题里”跨库事务
COMMIT 成功”的直接证据,本机 3.53.2 rollback journal
模式(默认
journal_mode=delete)下真实跑通。
再验证 DETACH 在事务进行中的行为:
$ sqlite3 sk13a.db <<'SQL'
ATTACH DATABASE 'sk13b.db' AS logsdb;
BEGIN;
INSERT INTO logsdb.logs VALUES(5,1,'test');
DETACH DATABASE logsdb;
SQL
Error near line 4: database logsdb is locked
logsdb 因为事务里已经写过、持有写锁而无法被
DETACH——这与第 9
篇锁阶梯的行为完全一致:logsdb.db
这个文件此刻处于 RESERVED(至少)锁状态,DETACH
本质上要关闭这个文件的连接句柄,SQLite
不允许在文件被锁定期间做这个操作。
sequenceDiagram
participant Conn as Connection (main + logsdb)
participant A as sk13a.db (main)
participant B as sk13b.db (logsdb)
Conn->>A: BEGIN
Conn->>A: INSERT INTO users ...
Conn->>B: INSERT INTO logsdb.logs ...
Note over A,B: both files now hold a write lock<br/>for this one connection
Conn->>A: COMMIT
Note over A,B: super-journal coordinates commit<br/>across both files (section 3)
A-->>Conn: committed
B-->>Conn: committed
三、原子性边界:super-journal 与 WAL 的裂缝
官方 Atomic Commit In SQLite §5
完整描述了多文件提交如何保持”要么全改、要么全不改”:每个参与事务的库各自维护自己的
rollback journal(第 7
篇已详细拆解单文件版本),此外还会创建一个额外的
super-journal 文件(文档旧称
master-journal),命名规则是主数据库文件名加上
-mjHHHHHHHH(随机 32
位十六进制数,命名细节文档标注不属于规范、可能变化)。super-journal
不存原始页内容,只存”本次事务涉及哪些库各自的
rollback journal 路径”。
多文件提交的关键判定与单文件版本(第 7 篇)完全平行:
If a power failure or operating system crash occurs at this point [after super-journal is written but before it is deleted], the transaction will not rollback when the system reboots even though there are rollback journals present. The difference is the super-journal pathname in the header of the rollback journal. Upon restart, SQLite only considers a journal to be hot… if the super-journal file still exists on disk.
也就是说:单个 rollback journal 里如果记了 super-journal 的路径,它是否算”hot”(需要回放)不再只看自己,还要看这个 super-journal 文件是否存在——删除 super-journal 这一个动作,同时判定了所有参与文件的提交状态。这是多文件事务原子性的具体实现:把”N 个文件是否都提交”这个问题,收敛成”1 个 super-journal 文件是否存在”这一个可以原子判定的问题。
flowchart TB
begin["Multi-file transaction begins"] --> j1["Each DB writes its own<br/>rollback journal<br/>(original pages)"]
j1 --> sj["Create super-journal:<br/>lists paths of all<br/>participating rollback journals"]
sj --> hdr["Write super-journal path<br/>into each rollback journal's header"]
hdr --> wr["Write changes into<br/>all database files,<br/>flush to disk"]
wr --> del["Delete super-journal<br/>= COMMIT POINT for ALL files"]
del --> clean["Delete each rollback journal,<br/>release locks"]
这套机制只在 rollback journal 模式下存在。官方 ATTACH DATABASE 文档给出了本文最关键的一条边界,直接引用:
Transactions involving multiple attached databases are atomic, assuming that the main database is not “:memory:” and the journal_mode is not WAL. If the main database is “:memory:” or if the journal_mode is WAL, then transactions continue to be atomic within each individual database file. But if the host computer crashes in the middle of a COMMIT where two or more database files are updated, some of those files might get the changes where others might not.
翻译成具体后果:如果任一参与事务的库(尤其是主库)使用 WAL,跨库事务的原子性承诺退化为”每个文件各自原子,但文件之间不再联合原子”——每个库的 WAL 提交仍然是该库自身的原子操作(第 8 篇),但没有类似 super-journal 这样的跨文件协调结构。如果崩溃恰好发生在”库 A 的 WAL 已经写入 commit 帧,库 B 还没来得及提交”这个窗口,重启后 A 的改动生效、B 的改动消失——两个文件各自看是一致的,合起来看这次”跨库事务”却只完成了一半。
flowchart LR
subgraph rj ["rollback journal mode"]
r1["super-journal coordinates<br/>all files' journals"] --> r2["one commit point<br/>= all files atomic together"]
end
subgraph wal ["WAL mode (main or any attached db)"]
w1["each file's WAL commits<br/>independently"] --> w2["crash mid-commit:<br/>some files updated,<br/>others not"]
end
这条边界是”单文件叙事”在 ATTACH
场景下最实质的裂缝:SQLite
的核心卖点之一是”文件存在即已提交、不存在即未提交”这种简单可推理的崩溃语义(第
7 篇),但这个简单性是单文件粒度的保证;一旦用
ATTACH 把多个文件绑进同一个逻辑事务,且其中用了
WAL,简单性就不再自动跨文件传递——除非应用自己承担协调责任(比如都用
rollback
journal,或者接受”最终一致”并在应用层做补偿),SQLite
本身不提供比”各文件独立原子”更强的保证。
四、锁:每个库各自锁,跨库写要拿齐
第 9 篇钉住的五态锁阶梯(UNLOCKED → SHARED → RESERVED →
PENDING →
EXCLUSIVE)是按文件定义的。ATTACH
之后,一个连接同时持有对多个文件的锁视图,Atomic
Commit §5.1 直接说明:
When multiple database files are involved in a transaction, each database has its own rollback journal and each database is locked separately.
跨库写事务的锁序列因此是”对每个参与文件分别走一遍第 9 篇的锁升级路径”,§5.4 补充了写入阶段的具体要求:
Once all rollback journal files have been flushed to disk, it is safe to begin updating database files. We have to obtain an exclusive lock on all database files before writing the changes.
“拿齐”锁的含义就是这句话字面写的:写入任何一个文件之前,必须先在所有参与文件上都拿到
EXCLUSIVE 锁,不存在”先写完 A 再去竞争 B
的锁”这种交错写入——这也是为什么第二节的 DETACH
实测会失败:logsdb
参与了未提交事务,锁还没释放,DETACH
试图关闭这个文件的连接句柄,与”锁按文件独立跟踪”这条设计直接冲突。
一个连接能同时 ATTACH
多少个库有硬上限:SQLITE_LIMIT_ATTACHED(通过
sqlite3_limit()
设置),这是工程上对”命名空间可以无限扩张”这句话的具体约束,本文不展开具体默认值,因为它属于可编译期调整的限制,不是固定的协议边界。
五、DETACH
的其他约束
DETACH DATABASE schema-name
的官方语义很短:撤销一次此前的
ATTACH。文档补充了一条容易被忽视的细节:非
shared-cache 模式下,同一个文件可以用不同名字被
ATTACH 多次,DETACH
一个名字只影响那一个绑定,文件本身和其他绑定不受影响;而在
shared-cache 模式下,同一个文件重复 ATTACH
会直接报错——这是 shared-cache(第 9
篇第五节已标注为官方认定的过时功能)在多库场景下又一处需要单独考虑的边界,本文不重复展开
shared-cache 本身。
main 与 temp 不能被
DETACH,这与”不能被
ATTACH“是同一条规则的两面:它们是连接固有的命名空间,不是通过
ATTACH 动态添加的。
六、常见误解
「ATTACH 之后的跨库事务,无论什么
journal_mode,都和单库事务一样是完全原子的。」 见第三节:这条原子性保证明确排除了 WAL 与main库为:memory:的情形。官方文档用”But if the host computer crashes… some of those files might get the changes where others might not”这句话直接否定了”任何情况下都全有或全无”的直觉——这是本篇最容易被忽略、也最值得记住的一条边界。「ATTACH 实现的是分布式事务,等价于两阶段提交(2PC)跨两个独立的 SQLite 服务。」
ATTACH涉及的所有文件由同一个进程内的同一个连接统一协调,锁、journal、super-journal 全部在这一个进程的地址空间和文件系统视野内完成,不涉及网络、不涉及多个独立协调者之间的消息协议。这和 2PC 解决的”多个独立事务参与者之间协调”完全是两类问题——ATTACH的多文件原子提交本质上仍是单进程本地事务的一种扩展形式,不是分布式事务。「DETACH 语句随时可以执行,只要数据库文件存在。」 第二节实测直接给出反例:事务进行中,参与该事务的库因为持有写锁而无法被
DETACH,报错database logsdb is locked。DETACH的可执行性取决于该库当前的锁状态,不是”文件存在就能操作”这种静态条件。「多个库里有同名表,省略 schema 前缀时 SQLite 会报歧义错误。」 官方文档给出的规则是确定性选择,不是报错:取”最早挂载”(least recently attached)的那个库里的表。这是一条容易被忽视的隐式行为——依赖它意味着表名冲突时的实际执行结果取决于
ATTACH语句的先后顺序,生产代码里更稳妥的做法是始终写全schema-name.table-name,不依赖这条默认选择规则。
七、学术谱系、工程间隙与开放问题
谱系:Atomic Commit In SQLite
§5
描述的多文件提交机制是一份工程规范文档,不是学术论文,但它明确交代了自己要解决的具体问题——“如何让本来只为单文件设计的原子提交判定(文件是否存在)扩展到
N 个文件”,答案是把 N 个文件的联合判定收敛成 1 个
super-journal
文件的存在性判定,这个”收敛判定点”的设计思路与第 7、9
篇里”PENDING
锁把复杂的并发协调收敛成一个简单状态位”是同一类工程手法:把多元协调问题坍缩成一个可以原子读写的单一判定点,而不是引入分布式共识协议。这条设计哲学贯穿本系列已经拆过的锁、日志、事务机制,ATTACH
只是把它应用到了”多文件”这个新维度上。
工程间隙:论文式的原子提交设计通常假设”参与者集合”是显式声明和管理的(比如经典 2PC 需要协调者持久记录参与者列表);SQLite 的 super-journal 机制隐含假设了”所有参与文件都用同一种日志模式(rollback journal)“,一旦某个参与文件切到 WAL,协调机制就不再覆盖它——这不是实现疏漏,是官方文档明确承认并划出边界的设计取舍:WAL 的读写并发优势(第 8 篇)与 super-journal 式跨文件协调,在当前实现里是互斥的能力,应用需要在”WAL 的并发性”和”ATTACH 跨库事务的强原子性”之间做选择,不能同时全要。
开放问题:如果应用场景确实需要跨多个独立
SQLite 文件(不只是同进程内的
ATTACH,而是分布在不同进程甚至不同机器上)的强一致事务,SQLite
本身没有给出方案——这正是”单文件、零 IPC”这条第 1
篇就定下的设计基调所划定的能力边界:需要跨进程/跨机器协调的场景,答案在这个系列之外(对照
FoundationDB
这类借用了 SQLite B-Tree 页面结构、却在其上叠加了 Raft/2PC
式分布式协调层的系统)。本系列第 17
篇会收束选型讨论,这里只标出问题的边界:ATTACH
解决的是”一个进程管理多个本地文件”,不是”多个节点管理一份逻辑数据”,两者是不同量级的问题,不能把前者的原子性经验直接套用到后者。
八、小结
ATTACH DATABASE ... AS name把另一个文件加入连接的命名空间,main/temp是保留名;同名表省略前缀时取”最早挂载”的库;本机实测证实跨库SELECT/JOIN与跨库事务COMMIT都能成功,计划器对跨库 JOIN 用的是与同库 JOIN 相同的 SEARCH/SCAN 判定。- 多文件事务的原子性靠 super-journal 收敛判定:它列出所有参与文件各自的 rollback journal 路径,删除它这一个动作同时判定所有文件的提交状态——但官方文档明确写明,这套机制只在 rollback journal 模式下工作;main 库或任一参与库切到 WAL,跨库事务的保证会退化为”每个文件各自原子,文件之间不再联合原子”,崩溃可能导致部分文件生效、部分文件不生效。
- 锁按文件独立跟踪(第 9
篇),跨库写事务必须在所有参与文件上都拿到 EXCLUSIVE
锁才能写入;本机实测证实事务进行中的库无法被
DETACH,这与锁状态机的行为完全一致——“单文件”简单性在ATTACH场景下的真实边界,是”每个文件仍然简单,文件之间的协调需要额外机制,且这个机制对日志模式有隐性要求”。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, ATTACH DATABASE(sqlite.org/lang_attach.html)——命名空间规则、同名表解析、多文件原子性的 WAL 边界条件。
- SQLite Documentation, DETACH(sqlite.org/lang_detach.html)——DETACH 语义、shared-cache 模式下的重复 ATTACH 限制。
- SQLite Documentation, Atomic Commit In SQLite,§5 Multi-file Commit(sqlite.org/atomiccommit.html)——super-journal 机制、跨文件提交判定、锁按文件独立跟踪。
实验(A 级,本机实测)
- 本机
sqlite3 3.53.2:两库ATTACH后跨库SELECT/JOIN、跨库事务COMMIT、事务中DETACH锁冲突;脚本reproduce/13-attach.sh,输出见第一、二节。
站内
- Rollback Journal 模式——单文件 journal 格式与提交点,本篇 super-journal 机制的前提。
- 锁状态与 shared cache——五态锁阶梯,本篇”每个文件各自锁”的直接依据。
- WAL 与 checkpoint——WAL 单文件提交机制,本篇第三节”退化”的对照对象。
- 查询计划器与统计——SEARCH/SCAN 判定,跨库 JOIN 沿用同一套规则。
- FoundationDB 内核 · Redwood——借用 SQLite B-Tree 页面结构、在其上叠加分布式协调层的对照系统。
- 本系列 index、PLAN.md。
上一篇:索引与 covering scan 下一篇:完整性检查与损坏恢复
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。
【SQLite 内核】Pager 与 Page Cache
拆解 Pager 作为 B-Tree 与操作系统文件之间的契约层:page cache 命中/未命中路径、脏页生命周期与提交前的可回滚保证,并与 PG shared_buffers、InnoDB Buffer Pool 的跨进程共享模型对照。
【SQLite 内核】Rollback Journal 模式:写前拷贝与提交点
钉住 SQLite 默认的原子提交机制:写前把原页拷进 -journal、DELETE/TRUNCATE/PERSIST 三种提交点如何实现、cache spill 何时把 journal 变成真正的 hot journal;用本机 3.53.2 实测崩溃恢复全过程,WAL 对照留给第 8 篇。
【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发
钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。