GRDB.swift 并发指南:SQLite 并发访问的安全规则与同步/异步编程实践
2026/9/16 21:25:30 网站建设 项目流程

GRDB.swift 并发指南:SQLite 并发访问的安全规则与同步/异步编程实践

【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift

GRDB.swift 提供了一套完整的 SQLite 并发方案,帮助 Swift 应用把耗时的数据库操作移出主线程,同时保证数据不被并发访问破坏。本文以 GRDB/Documentation.docc/Concurrency.md 为核心骨架,结合DatabaseQueueDatabasePoolDatabaseReader/DatabaseWriter等源码实现,系统讲解两条必须遵守的并发规则、同步与异步访问的四种方式、安全访问的五重保证,以及 DatabasePool 特有的并发读进阶技巧。读完本文,你将能够正确设计多线程环境下的数据库访问代码,避免SQLITE_BUSY与事务边界错乱等典型问题。

说明:本文依据当前仓库GRDB/Documentation.docc/Concurrency.md(其历史版本位于 Documentation/Concurrency.md,仅保留跳转链接)。仓库只读,读者可在本地查看源码与示例进行验证。

两条并发规则:一切并发安全的地基

GRDB 对 SQLite 并发问题的核心回答,浓缩为两条强烈推荐的实践规则。SQLite 本身是一个健壮可靠、精心保护数据的数据库,遵守这两条规则,就是把它放到你这一边。文档原文明确指出:"In all cases, and first and foremost, follow the Concurrency Rules right from the start."

规则一:每个数据库文件只建立一次连接

Open one singleDatabaseQueueorDatabasePoolper database file, for the whole duration of your use of the database.

也就是说,不是为每一次数据库访问打开一个连接,而是为这个文件全部生命周期内的所有访问只打开一个DatabaseQueueDatabasePool实例。

为什么存在这条规则?因为 SQLite 不支持并行写入,每个DatabaseQueueDatabasePool正是通过内部调度保证:应用线程对数据库的写入一次一个、互不重叠。如果每个访问各自开一个新连接,就绕开了这层调度,两个连接同时写同一个文件时就会互相冲突。

实用建议:只使用单一数据库的应用,全生命周期连接一次即可;文档型应用则在每次打开文档时连接、关闭文档时断开。仓库中的 演示应用 展示了如何为 UIKit 或 SwiftUI 应用搭建单一数据库连接:以 GRDBDemo 为例,其 AppDatabase.swift 持有一个private let dbWriter: any DatabaseWriter,在AppDatabase初始化时通过 migrator 完成建表,之后所有读写都经由此单一 writer 进行。

不遵守的后果

  • 将无法使用 DatabaseObservation 相关特性(观察依赖对单一连接的持续跟踪);
  • 会看到 SQLite 的SQLITE_BUSY错误(不同连接争夺文件锁)。

规则二:管理好你的事务

数据库事务保证了"要么全部落盘、要么什么都不发生";只读事务保证了稳定、不可变的数据库视图,看不到并发写入带来的任何变化。

事务是你在代码中强制依赖数据库不变量(invariant)的唯一工具——例如"每个作者必须至少有一本书"这类约束。事务边界由你负责,在 Swift 代码中通过把数据库访问放进一对{ db in ... }花括号来划定:

try dbQueue.write { db in // Inside a transaction } try dbQueue.read { db // Inside a transaction }

(注:后一段代码出自原文档,正确写法为try dbQueue.read { db in ... },其中readDatabaseReader协议成员。)除此之外,你还可以显式开启事务或保存点(savepoint),详见 Transactions。

为什么存在这条规则?因为 GRDB 和 SQLite 无法猜出"哪里的边界能保护你的数据不变量"——这是你(应用作者)的任务。事务同时也能避免并发问题,见下文"安全与不安全的数据库访问"一节。

实用建议:花时间识别你数据库的不变量。一部分可以在数据库 schema 层面强制执行,例如"所有书籍必须有非空标题""所有书籍必须有所属作者"(见 DatabaseSchema);另一部分只能靠事务保证,例如"每笔账户入账必须对应一笔出账""每个作者至少有一本书"。

不遵守的后果:运行时或应用崩溃恢复后会看到被破坏的数据库不变量。这类 bug 会污染用户数据,且极难修复。

同步与异步数据库访问

你可以从任意线程访问数据库,同步或异步皆可。

同步访问:阻塞当前线程

同步访问会阻塞当前线程,直到数据库操作完成:

let playerCount = try dbQueue.read { db in try Player.fetchCount(db) } let newPlayerCount = try dbQueue.write { db -> Int in try Player(name: "Arthur").insert(db) return try Player.fetchCount(db) }

对应协议 API 见 DatabaseReader/read(_:) 与 DatabaseWriter/write(_:)。

在另一个数据库访问内部再发起同步访问属于程序员错误(可通过"安全与不安全的数据库访问"一节的 unsafe 手段解除限制):

try dbQueue.write { db in // Fatal Error: Database methods are not reentrant. try dbQueue.write { db in ... } }

运行时将直接触发 "Database methods are not reentrant." 致命错误。这一点在 DatabaseReader.swift 的文档注释中也有明确表述:"It is a programmer error to call this method from another database access method.",且在DatabasePool.unsafeRead中通过GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.")落地执行。

异步访问:不阻塞当前线程的四种方式

异步访问不阻塞当前线程,完成后通过回调、事件或await通知结果。GRDB 共提供四种异步访问方式:

1. Swift 并发(async/await)

let playerCount = try await dbQueue.read { db in try Player.fetchCount(db) } let newPlayerCount = try await dbQueue.write { db -> Int in try Player(name: "Arthur").insert(db) return try Player.fetchCount(db) }

注意方法名与同步版完全一致(readwrite),只是异步版本只在 async 函数中可用。从 DatabaseReader.swift 可以看到异步read的闭包要求为@Sendable (Database) throws -> T,返回值要求T: Sendable

异步访问尊重任务取消(task cancellation):一旦 async Task 被取消,读写会抛出CancellationError,进行中的事务被回滚。该错误由DatabaseWriter异步write的实现注释直接支持:"throws ... orCancellationErrorif the task is cancelled"。更多关于 GRDB 与 Swift 6 的细节见 SwiftConcurrency。

2. Combine publishers

let playerCountPublisher = dbQueue.readPublisher { db in try Player.fetchCount(db) } let newPlayerCountPublisher = dbQueue.writePublisher { db -> Int in try Player(name: "Arthur").insert(db) return try Player.fetchCount(db) }

对应readPublisher(receiveOn:value:)writePublisher(receiveOn:updates:)。这些 publisher直到被订阅(subscribe)时才真正访问数据库,默认在主派发队列(main dispatch queue)上完成。从源码看,readPublisher内部经由OnDemandFuture+asyncRead实现(DatabaseReader.swift),writePublisher则经由asyncWrite实现,并在尾部调用.receiveValues(on: scheduler),保证值不会在数据库派发队列上被消费(DatabaseWriter.swift)。此外writePublisher还有一个thenRead:变体:在使用DatabasePool时它通过spawnConcurrentRead让"写入后的回读"不阻塞并发写,从而降低写竞争。

3. RxSwift observables

使用配套库 RxGRDB(RxSwiftCommunity/RxGRDB)即可获得 observables 风格的异步访问。

4. Completion blocks

reader.asyncRead { dbResult in do { let db = try dbResult.get() let count = try Player.fetchCount(db) } catch { // Handle error } }

对应 DatabaseReader/asyncRead(_:) 与asyncWrite(_:completion:)。注意闭包参数是Result<Database, Error>,需要先get()取出连接再使用。

一次访问内的操作永远是同步的

在单次异步访问中,其中包裹的所有数据库操作(fetch、insert 等)都是同步执行的,这对四种异步方式全部成立:

// One asynchronous access... try await dbQueue.write { db in // ... always performs synchronous database operations: try Player(...).insert(db) try Player(...).insert(db) let players = try Player.fetchAll(db) }

这保证了来自不同并发访问的数据库操作不会被交错执行。例如,一次访问绝不能在另一个未完成的并发写中间发出COMMIT语句——否则数据库状态会被撕成两半。

安全与不安全的数据库访问

日常开发应使用安全的readwrite方法。这里的"安全"指 GRDB 提供如下五重并发保证:

串行化写入(Serialized Writes)

同一个DatabaseQueueDatabasePool实例执行的所有写入都是串行的。该保证杜绝了并发写导致的SQLITE_BUSY错误。

写事务(Write Transactions)

所有写入都被包在事务里。因此并发读(即使是其他进程发起的读)永远看不到部分写入的中间状态。源码印证:DatabaseWriter.swift 中write的默认实现就是在writeWithoutTransaction内用db.inTransaction { ... }包裹闭包,出错即回滚。

隔离读(Isolated Reads)

所有读取都被包在事务里。隔离读看到的是稳定、不可变的数据库状态,看不到并发写入的任何变化(同样包括其他进程的写入)。SQLite 官方文档 Isolation In SQLite 对快照隔离有更详细的说明。

禁止写(Forbidden Writes)

在读访问内部,一切写操作都会报错。这强制了读期间数据库的不可变性。实现层面,DatabasePool为读连接生成的readerConfiguration直接设置configuration.readonly = true(DatabasePool.swift),读连接在 SQLite 层面就是只读的;DatabaseReader.read的文档也注明 "attempts to write throw aDatabaseErrorwith resultCodeSQLITE_READONLY"。

不可重入(Non-Reentrancy)

数据库访问方法不可重入。这减少了死锁机会,并强化了"规则二"中清晰事务边界的实践。GRDB 会在违规时抛出 "Database methods are not reentrant" 致命错误。

放下安全网:Unsafe 方法族

某些应用为了达成特定的 SQLite 操作,需要放松这层安全网。此时把read/write替换为以下方法(每一条都注明被解除的保证):

  • 在事务之外写(解除:写事务保证)——所有名字带WithoutTransactionDatabaseWriter方法,例如writeWithoutTransaction(_:)asyncWriteWithoutTransaction(_:)barrierWriteWithoutTransaction(_:)
  • 在事务之外可重入写(解除:写事务、不可重入两条保证)——unsafeReentrantWrite(_:)。
  • 在事务之外读(解除:隔离读、禁止写两条保证)——所有名字带unsafeDatabaseReader方法,例如unsafeRead(_:)asyncUnsafeRead(_:)
  • 在事务之外可重入读(解除:隔离读、禁止写、不可重入三条保证)——unsafeReentrantRead(_:)。

Important: 使用以上任一方法,你就自己承担了应用的线程安全责任。请务必理解解除每条并发保证的后果。例如unsafeRead的源码警告明确写道:"Database operations may not be wrapped in a transaction... two identical requests performed by thevalueclosure may not return the same value" 以及 "Attempts to write in the database may succeed."

某些保证还可以按需恢复

  • 写事务 / 隔离读保证可以在任意时刻用显式事务或保存点恢复,例如:

    try dbQueue.writeWithoutTransaction { db in try db.inTransaction { ... } }
  • 禁止写保证只能用DatabaseQueue解除,并且可用PRAGMA query_only恢复(DatabasePool的读连接由 GRDB 强制只读,不存在此问题)。

DatabaseQueue 与 DatabasePool:串行与并发的取舍

尽管两者共享上述保证与规则,队列与连接池的行为有本质差异。

DatabaseQueue只打开一个 SQLite 连接,并串行化所有数据库访问(读与写)。任意时刻都至多一个线程在使用数据库。源码印证:DatabaseQueue.swift 的初始化器强制configuration.maximumReaderCount = 1,并附注释 "DatabaseQueue can't perform parallel reads"。下图展示了三个线程眼中的数据库随时间流逝的变化:

DatabasePool管理一组 SQLite 连接(一个写连接 + 一个读连接池),依靠 WAL 模式 实现并发读与写。池串行化所有写入(串行化写入保证),读取彼此隔离(隔离读保证),从而呈现完全不同的画面:

注意:使用连接池时,两个并发读可能在同一时刻看到不同的数据库状态。这可能看起来有些吓人——下一节的"并发思维"会给出让人放心的答案。

从源码看,DatabasePool的初始化器(DatabasePool.swift)做了三件关键的事:

  1. 为写连接创建SerializedDatabase(标签GRDB.DatabasePool.writer);
  2. 通过DatabasePool.readerConfiguration生成只读的读连接配置:强制readonly = true,并给读连接预设readonlyBusyMode = .timeout(10),以应对 WAL 模式下"最后连接关闭清理""崩溃恢复"等偶发SQLITE_BUSY
  3. Pool(maximumCount: configuration.maximumReaderCount, ...)建立读连接池,连接数受Configuration.maximumReaderCount约束。

并发思维:写出同时适配 Queue 与 Pool 的代码

尽管队列与连接池行为不同,你仍然可以写出在两者上都同样健壮的代码。这让你能按需在队列与连接池之间切换:

  • 演示应用 为磁盘上的连接池(供给 App 本体)和内存中的队列(供给测试与 SwiftUI 预览)共享同一套数据库代码,从而保证测试与预览不产生临时文件、运行飞快,且行为与正式 App 完全一致。这正是依赖 AppDatabase.swift 中any DatabaseWriter抽象获得的效果。
  • 执行慢写事务(例如从远程服务器保存大量数据)的应用,可以把队列换成连接池,让供 UI 读取的并发读得以并行执行。

所需的全部"并发思维"基于两个基本事实:

事实一:写入访问一定面对磁盘上最新的数据库状态。这由 SQLite 本身(无法并行写)与"串行化写入"保证共同强制。其他进程发起的写可能触发可捕获的SQLITE_BUSYDatabaseError。仓库测试 DatabaseQueueTests.swift 中专门有一组SQLITE_BUSY prevention用例,验证了当写锁被其他连接持有时 busy timeout 会如何表现,以及IMMEDIATE事务如何避免SQLITE_BUSY

事实二:任何从数据库访问中取出的数据,请立即视为"过期数据"。无论用DatabaseQueue还是DatabasePool都一样——因为没有任何东西能阻止其他线程或进程在你刚取完值后覆盖它:

// or dbQueue.write, for that matter let cookieCount = dbPool.read { db in try Cookie.fetchCount(db) } // At this point, the number of cookies on disk // may have already changed. print("We have \(cookieCount) cookies left")

这是否意味着什么都靠不住?当然不是:

  • 如果想把某个数据库值显示在屏幕上,请使用 ValueObservation:它最终总是通知数据库的最新状态。数据库在磁盘上被修改后,新值会被尽快取出,并在主线程上被通知,屏幕得以更新,应用不会长时间显示过期值。
  • 如前所述,下一个写入访问才是"真相时刻"

进阶 DatabasePool:asyncConcurrentRead 并发读

DatabasePool的并发能力很强:所有读可以并行,甚至可以与写并行;但写仍然串行——任意时刻至多一个线程在写库。

当应用"先修改数据库,再读取依赖这些修改的值"时,你可能不希望过长地阻塞并发写——尤其当那个回读很慢时:

let newPlayerCount = try dbPool.write { db in // Increment the number of players try Player(...).insert(db) // Read the number of players. Concurrent writes are blocked :-( return try Player.fetchCount(db) }

write内部整个处于一个写事务中,末尾的fetchCount会把其他写线程一直挡在外面。

解决方案是 asyncConcurrentRead(_:)。它必须从一次写访问内部、事务之外调用:

try dbPool.writeWithoutTransaction { db in // Increment the number of players try db.inTransaction { try Player(...).insert(db) return .commit } // <- Not in a transaction here dbPool.asyncConcurrentRead { dbResult in do { // Handle the new player count - guaranteed greater than zero let db = try dbResult.get() let newPlayerCount = try Player.fetchCount(db) } catch { // Handle error } } }

asyncConcurrentRead(_:)阻塞到它能为闭包参数提供一次隔离访问为止——这个访问看到的数据库,正好处于"上一个事务结束后的精确状态";随后它异步执行闭包,从而让其他写线程不被你这次较慢的回读阻塞。源码实现(DatabasePool.swift)用一把DispatchSemaphore在写派发队列上等待,直到读连接上的 DEFERRED 只读事务(快照)建立完毕;闭包内部的读事务在完成时会commitrollback并归还读连接到池中。如果从事务内部调用,GRDBPrecondition(!db.isInsideTransaction, ...)会直接给出引导性的致命错误提示。

下图中,条纹带显示的是读线程获取隔离所需的延迟;在这段延迟结束前,其他线程无法写入:

此外,遵守 TransactionObserver 协议的类型也可以在databaseDidCommit(_:)回调中使用这些方法,从而在处理数据库变更时不阻塞其他想写库的线程——这是ValueObservation等观察机制得以高效运转的底层通道之一。DatabaseWriter协议中还暴露了spawnConcurrentRead(_:)作为该能力的通用入口(DatabaseWriter.swift 中注明它用于 GRDBCombine 与 RxGRDB)。

源码级的并发实现剖析

SerializedDatabase:一切串行化的基石

无论是DatabaseQueue还是DatabasePool,其写连接与池内读连接最终都由 SerializedDatabase.swift 封装。它的设计要点:

  • 一个连接 + 一条专属串行DispatchQueue:所有访问都必须在这条队列上执行,从而在应用层实现"单连接单线程";
  • 由于连接只在串行队列内使用,GRDB 将 SQLite 线程模式从默认的Serialized改为Multi-threadconfig.threadingMode = .multiThread)——这是 SQLite 官方允许的优化(见文件内引用的 threadsafe.html);
  • 异步访问通过DispatchQueueActor承载,使 async/await 版本的read/write能安全地挂到这条串行队列上。

write 的事务包装与读写分流

DatabaseWriter.write的同步与异步实现(DatabaseWriter.swift)都在内部完成同一件事:writeWithoutTransaction+db.inTransaction { updates(db); return .commit }。而DatabaseReader.read的隔离性则由各实现落实——DatabasePoolasyncRead在从池中取出的读连接上先beginTransaction(.deferred)再执行闭包(DatabasePool.swift),这正是"隔离读 + 禁止写"两条保证的直接实现。

测试用例中的并发证据

仓库测试目录提供了大量并发行为的回归验证,可作为理解本文概念的活教材:

  • DatabaseQueueConcurrencyTests.swift 与 DatabasePoolConcurrencyTests.swift:断言并发访问下的SQLITE_BUSY结果码、串行化行为;
  • DatabaseQueueTests.swift:SQLITE_BUSY prevention用例组,演示 busy timeout 何时不足以避免SQLITE_BUSYIMMEDIATE事务如何真正规避写锁冲突;
  • DatabaseSnapshotTests.swift:覆盖DatabaseSnapshot(即DatabaseSnapshotReader,见 DatabaseReader.swift 中的协议定义)这一"永远看到同一数据库状态"的只读视图。

延伸阅读

  • SwiftConcurrency:GRDB 与 Swift 6 / 严格并发检查的完整集成指南,包括Sendable记录类型、简写闭包记法的编译期告警(可通过开启InferSendableFromCaptures消除)、以及同步/异步访问的选择建议;
  • DatabaseSharing:App 与其扩展共享数据库时的专用指南,建议在读完本文后继续阅读;
  • Transactions:显式事务与保存点的完整文档;
  • DatabaseObservation:基于本文并发保证之上的数据库观察能力;
  • DatabaseSchema:如何在 schema 层就固化部分数据不变量;
  • 源码入口:DatabaseReader.swift、DatabaseWriter.swift、DatabaseQueue.swift、DatabasePool.swift、SerializedDatabase.swift;
  • 实战范例:AppDatabase.swift(单一连接的any DatabaseWriter抽象)与 GRDBDemoTests。

【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询