SQLite 并发的四种处理方式

2022-05-10 00:00:00 数据库 操作 上下文 方案 并发

SQLite 是一款轻型的嵌入式数据库它占用资源非常的低,处理速度快,高效而且可靠。在嵌入式设备中,可能只需要几百 K 的内存就够了。因此在移动设备爆发时,它依然是常见的数据持久化方案之一。不过即使 SQLite 已经非常成熟,但是我们在编程中依然会遇到一些问题,其中常见也难搞的就是 —— 并发。

就像其他类似的问题一样,SQLite 在移动端的并发处理也存在多种不同的设计。下面我们通过 iOS 中四个常用类库 (SQLite.swift, FMDB, GRDB, Core Data) 来看看这些设计。不过在此之前,我们需要明确 SQLite 在并发编程环境下到底存在哪些问题:

  1. 并发写操作:某一时刻可能存在对同一个数据库的写操作,而这是 SQLite 不允许的行为。
  2. 操作隔离:连续的两个数据库查询操作可能会出现结果差异,因为在并发环境下你无法保证着两个读操作中间不会出现写操作。
  3. 操作冲突:并发环境下数据库的新增和修改操作执行的时序并不一定与调用时序是一致的。这就导致一个可能的情形就是:数据库多个更新操作调用后可能存在一些意料之外的情形,而且你还难以追踪排除。

明确这些问题后,接下来我们就来看看这些类库做出了何种应对。

SQLite.swift 方案

SQLite.swift 采用了简单粗暴的一种方案,使用者只会得到一个数据库连接,所有的操作都是在该连接上串下执行,类库的作者并没有提供数据库连接池类似的特性。通过这种设计,任意时刻都只会存在一个线程对数据库拥有访问权限。也就是说上诉个并发问题被完美解决了。

然而改方案却无法应对第二个问题。例如,我们需要为数据库中的某位用户设置头像,如果该用户存在时则执行插入操作,对应代码如下:

let userAvatars = avatars.filter(userId == 1)
let insert = avatars.insert(userId <- 1, url <- avatarURL)
if db.scalar(userAvatars.count) == 0 {
    try db.run(insert)
}
复制代码

咋看之下代码逻辑并没有任何问题和缺陷,但是在并发环境下这里存在一个隐藏的问题。你无法保证在执行 * try db.run(insert)* 没有任何地方执行相同的操作。虽然这种情形很少见而且数据库在这种情形下也没有 Crash 出现,但是可能在一开始数据库在设定的时候就约定了每一个用户只能存在一条头像信息,这就导致了业务逻辑错误或者冲突。

当然这个问题我们可以在数据库定义时就能屏蔽掉,或者我们显式的通过事务对其进行处理:

try db.transaction {
    let userAvatars = avatars.filter(userId == 1)
    let insert = avatars.insert(userId <- 1, url <- avatarURL)
    if db.scalar(userAvatars.count) == 0 {
        try db.run(insert)
    }
}
复制代码

但是有些时候,开发人员可能因工期等等问题而忽略上诉,终埋下了隐患。对于第三个问题,类库并没有任何处理永远都是 the last write always win

FMDB 方案

FMDB 与 SQLite.swift 一样都是采用串行设计,只不过 FMDB 在此基础上做了些加强:FMDB 中使用者不会接触到数据库连接而是通过在 API 闭包中组织语句来实现数据库访问。

dbQueue.inDatabase { db in
    if db.intForQuery("SELECT COUNT ...") == 0) {
        db.executeUpdate("INSERT INTO avatars ...")
    }
}
复制代码

这种方式不仅解决了同时写的问题而且还非常平滑的解决了操作隔离问题,相比上一个方案明显更为友好。

GRDB 方案

此方案借鉴了 FMDB 中的 API 设计,使用者通过在闭包中组织语句来实现数据库访问。不过与前两个相比,GRDB 大的不同就是它不再使用串行队列设计。通过对 SQLite 本身 WAL 模式进行,GRDB 支持多线程同时进行读写操作。

注意:写操作依然是串行进行,WAL 依然需要遵守 SQLite 单写策略

try dbPool.write { db in
    if Int.fetchOne(db, "SELECT COUNT ...") == 0) {
        try db.execute("INSERT INTO avatars ...")
    }
}
复制代码

该模式大的特点在于,我们在进行数据库写操作的同时,依然能并行的执行读操作。这意味着,在特定线程运行费时的数据库同步写操作的时候用于更新 UI 的数据库读操作不会像前两种方案一样被阻塞住。也就是说,写操作对于读操作来说是透明的。

dbPool.read { db in
    // Those values are guaranteed to be equal:
    let count1 = User.fetchCount(db)
    let count2 = User.fetchCount(db)
}
复制代码

并且 GRDB 通过 DatabaseSnapshot 对数据库访问进行了读写分离实现,进一步提高了多线程访问的安全。

Core Data 方案

虽然 Apple 官方并没有说 Core Data 是 SQLite 的一个封装和实现,但是我们都知道其实它底层还是使用 SQLite 作为存储引擎。

为了解决文章前面提到的 SQLite 并发情形下的典型问题,Core Data 自己实现并维护了一套上下文管理逻辑。 SQLite.swift 关注的上下文是其执行期间的单个SQL语句。 对于FMDB和GRDB 关注的上下文环境则是闭包中的 SQL 语句块。 而 Core Data 托管上下文则是 NSManagedObjectContext 实例的整个生命周期,包含数据库修改和内存修改。

这让 Core Data 能够应对并发问题中的第三种情形,同一个对象如果在不同上下文中同时发生修改则会被检测出来(文档)。而前面三种方案只要 SQL 语句没有违背表定义都能进行记录更新而且后一个永远是赢家。

但是这种设计也存在缺点,首先扩大后的上下文管理是一件非常麻烦的事,另外所有的写操作都会被严格束缚而且冲突处理依然很棘手,后严格的上下文管理也让 Core Data 中编写正确的多线程代码也变得很困难。

总结

每一类库的作者都对 SQLite 并发处理有着自己的思考,所以没有这里并不存在一种标准处理方式。如果封装过于简单的话,那么对使用者的要求就会比较高否则就会出现很多意想不到的错误或崩溃。封装过于复杂的话则又有导致处理的灵活性变得很差。如果搞的大而全的话则有可能导致 SQLite 的执行效率变得很差。

总体而言,FMDB 和 GRDB 采用的方式从安全性和灵活性上会更好一点。顺便提一下,根据微信团队的文章他们采用的可能是 GRDB 那种方式,因为在微信的应用场景下写操作是瓶颈所在。

相关文章