如果您熟悉 Core Data 或 Realm,那么 Dflat 在您的应用程序中占据着与它们相同的地位。 它可以帮助您将对象持久化到磁盘,并在应用程序需要时从磁盘检索对象。 与这两者不同的是,Dflat 拥有一组不同的特性,并做出了非常不同的权衡。 这些特性和权衡是基于在编写一些世界上最大的应用程序时的真实经验。 Dflat 也是使用 Swift 从头开始构建的,希望您会发现它在 Swift 语言中进行交互非常自然。
在过去的几年里,我一直在移动设备上编写不同的结构化数据持久化系统。 Dflat 是在构建这些专有系统时所吸取的经验教训的积累。 特别是在 iOS 上,首选方案长期以来一直是 Core Data。 它可以工作,并且是许多系统应用程序的内部数据持久化机制。
但是,当将结构化数据持久化系统部署到数亿台移动设备时,会面临某些挑战,包括数据持久化方式的内在挑战,以及更高级别的应用程序如何与此类系统交互的挑战。
Dflat 代码库仍处于非常早期的阶段。 但是,其基本原则已在其他专有系统中证明是成功的。 Dflat 实现了以下特性,排名不分先后:
该系统返回不可变的数据对象,这些对象可以传递给其他系统(例如,您的视图模型生成器);
所有查询和对象都可以被观察。 更新将通过回调或 Combine 框架发布;
变更只能在调用者几乎无法控制的单独线程上发生,因此是异步的;
数据获取可以由调用者选择在任何线程上并发且同步地发生;
支持 严格可序列化 的多写入器/多读取器模式,但如果用户需要,可以选择单写入器(因此,平凡的严格可序列化)/多读取器模式;
数据查询用 Swift 代码表达,并将由 Swift 编译器进行类型检查;
Schema 升级不需要对底层数据库进行写访问(SQLite 3.22 及更高版本可以实现严格的只读)。
与 Core Data 不同,Dflat 是使用 Swift 从头开始构建的。 您可以通过充分利用 Swift 语言来表达您的数据模型。 因此,原生支持 struct
(乘积类型)、enum
(和类型),并使用 Combine 进行类型检查的查询和观察。
Dflat 由两部分组成:
dflatc
编译器,它接受 flatbuffers schema 并从中生成 Swift 代码;
Dflat 运行时,具有非常小的 API footprint 来进行交互。
Dflat 运行时使用 SQLite 作为存储后端。 该设计本身可以支持其他后端,例如将来的 libmdbx。 唯一的硬依赖是 flatbuffers。
要使用 Dflat,您应该首先使用 dflatc
编译器从 flatbuffers schema 生成数据模型,将生成的代码包含在您的项目中,然后使用 Dflat 运行时与数据模型进行交互。
目前,Dflat 需要 Bazel。 更准确地说,Dflat 运行时可以使用 Swift Package Manager 或 Bazel 安装。 但是 dflatc
编译器需要 Bazel 才能构建相关部分。
您可以在 macOS 上按照 本指南 安装 Bazel。
如果您的项目已经由 Bazel 管理,Dflat 提供了从代码生成到库依赖项管理的完全集成工具。 只需将 Dflat 添加到您的 WORKSPACE
git_repository(
name = "dflat",
remote = "https://github.com/liuliu/dflat.git",
commit = "3dc11274e8c466dd28ee35cdd04e84ddf7d420bc",
shallow_since = "1604185591 -0400"
)
load("@dflat//:deps.bzl", "dflat_deps")
dflat_deps()
对于您的 swift_library
,您现在可以像这样添加一个新的 schema
load("@dflat//:dflat.bzl", "dflatc")
dflatc(
name = "post_schema",
srcs = ["post.fbs"]
)
swift_library(
...
srcs = [
...
":post_schema"
],
deps = [
...
"@dflat//:SQLiteDflat"
]
)
您可以使用 dflatc
编译器从 flatbuffers schema 手动生成代码。
./dflatc.py --help
您现在可以将生成的源代码添加到您的项目,然后继续使用 Swift Package Manager 添加 Dflat 运行时
.package(name: "Dflat", url: "https://github.com/liuliu/dflat.git", from: "0.4.1")
假设您在某个地方有一个 post.fbs
文件,如下所示:
enum Color: byte {
Red = 0,
Green,
Blue = 2
}
table TextContent {
text: string;
}
table ImageContent {
images: [string];
}
union Content {
TextContent,
ImageContent
}
table Post {
title: string (primary); // This is the primary key
color: Color;
tag: string;
priority: int (indexed); // This property is indexed
content: Content;
}
root_type Post; // This is important, it says the Post object will be the one Dflat manages.
然后,您可以使用 dflatc
编译器从 schema 手动生成代码
./dflatc.py compile -o ../PostExample ../PostExample/post.fbs
或者使用来自 Bazel 的 dflatc
规则
dflatc(
name = "post_schema",
srcs = ["post.fbs"]
)
如果一切正常,您应该在 ../PostExample
目录中看到生成的 4 个文件:post_generated.swift
、post_data_model_generated.swift
、post_mutating_generated.swift
、post_query_generated.swift
。 将它们添加到您的项目中。
现在,您可以对 Post
对象执行基本的创建-读取-更新-删除 (CRUD) 操作。
import Dflat
import SQLiteDflat
let dflat = SQLiteWorkspace(filePath: filePath, fileProtectionLevel: .noProtection)
创建
var createdPost: Post? = nil
dflat.performChanges([Post.self], changesHandler: { (txnContext) in
let creationRequest = PostChangeRequest.creationRequest()
creationRequest.title = "first post"
creationRequest.color = .red
creationRequest.content = .textContent(TextContent(text: "This is my very first post!"))
guard let inserted = try? txnContent.submit(creationRequest) else { return } // Alternatively, you can use txnContent.try(submit: creationRequest) which won't return any result and do "reasonable" error handling.
if case let .inserted(post) = inserted {
createdPost = post
}
}) { succeed in
// Transaction Done
}
读取
let posts = dflat.fetch(for: Post.self).where(Post.title == "first post")
更新
dflat.performChanges([Post.self], changesHandler: { (txnContext) in
let post = posts[0]
let changeRequest = PostChangeRequest.changeRequest(post)
changeRequest.color = .green
txnContent.try(submit: changeRequest)
}) { succeed in
// Transaction Done
}
删除
dflat.performChanges([Post.self], changesHandler: { (txnContext) in
let post = posts[0]
let deletionRequest = PostChangeRequest.deletionRequest(post)
txnContent.try(submit: deletionRequest)
}) { succeed in
// Transaction Done
}
您可以订阅查询或对象的更改。 对于对象,当对象被删除时,订阅结束。 对于查询,除非取消,否则订阅不会完成。 有两组用于此的 API,一组是基于 vanilla 回调的,另一组是基于 Combine 的。 我将在这里展示 Combine 的。
订阅一个实时查询
let cancellable = dflat.publisher(for: Post.self)
.where(Post.color == .red, orderBy: [Post.priority.descending])
.subscribe(on: DispatchQueue.global())
.sink { posts in
print(posts)
}
订阅一个对象
let cancellable = dflat.pulisher(for: posts[0])
.subscribe(on: DispatchQueue.global())
.sink { post in
switch post {
case .updated(newPost):
print(newPost)
case .deleted:
print("deleted, this is completed.")
}
}
Dflat 中的 schema 演化与 flatbuffers 完全一致。 唯一的例外是,一旦选择了主键,您就不能添加更多主键或将主键更改为不同的属性。 否则,您可以自由添加或删除索引、重命名属性。 要删除的属性应标记为 deprecated
,新属性应附加到表的末尾,并且您永远不应更改属性的类型。
只要您遵循 schema 演化路径,就不需要进行版本控制。 由于 schema 由 flatbuffers 维护,而不是 SQLite,因此 schema 升级不需要磁盘操作。 由于磁盘空间不足而导致的 schema 升级失败或由于病态情况而导致的 schema 升级时间过长不会是 Dflat 的问题。
Dflat schema 支持命名空间,flatbuffers schema 也是如此。 但是,由于 Swift 并不真正支持适当的命名空间,因此命名空间实现依赖于 public enum
和扩展。 因此,如果您有命名空间
namespace Evolution.V1;
table Post {
title: string (primary);
}
root_type Post;
您必须自己声明命名空间。 在您的项目中,您需要有一个 Swift 文件包含以下内容:
public enum Evolution {
public enum V1 {
}
}
它将起作用。 然后,您可以通过 Evolution.V1.Post
访问 Post
对象,或者 typealias Post = Evolution.V1.Post
。
Dflat 运行时具有非常小的 API footprint。 总共有大约 15 个来自 2 个对象的 API。
func Workspace.performChanges(_ transactionalObjectTypes: [Any.Type], changesHandler: @escaping (_ transactionContext: TransactionContext) -> Void, completionHandler: ((_ success: Bool) -> Void)? = nil)
该 API 采用一个 changesHandler
闭包,您可以在其中执行事务,例如对象创建、更新或删除。 这些变更通过 ChangeRequest
对象执行。
第一个参数指定您将要与之进行事务的相关对象。 如果您读取或更新此处未指定的任何对象,则会触发断言。
当事务完成时,将触发 completionHandler
闭包,它将让您知道事务是否成功。
事务将在后台线程中执行,具体是哪个线程不应该成为您关心的问题。 两个不同的对象可以并发执行事务,在这种情况下,它遵循严格可序列化协议。
func TransactionContext.submit(_ changeRequest: ChangeRequest) throws -> UpdatedObject
func TransactionContext.try(submit: ChangeRequest) -> UpdatedObject?
func TransactionContext.abort() -> Bool
您可以使用上述 API 在事务中与 Dflat 进行交互。 它通过 submit
处理数据变更。 请注意,可能会发生错误。 例如,如果您两次创建具有相同主键的对象(如果这是预期的,您应该使用 upsertRequest
)。 try(submit:
方法简化了 try? submit
的使用,以防您不想知道返回值。 如果存在冲突的主键,它将 fatal,否则将吞噬其他类型的错误(例如磁盘已满)。 当遇到任何其他类型的错误时,Dflat 将简单地使整个事务失败。 abort
方法将显式中止事务。 在此调用之前和之后的所有提交都将无效。
func Workspace.fetch(for ofType: Element.Type).where(ElementQuery, limit = .noLimit, orderBy = []) -> FetchedResult<Element>
func Workspace.fetch(for ofType: Element.Type).all(limit = .noLimit, orderBy = []) -> FetchedResult<Element>
func Workspace.fetchWithinASnapshot<T>(_: () -> T) -> T
数据获取同步发生。 您可以在 where
子句中指定条件,例如 Post.title == "first post"
或 Post.priority > 100 && Post.color == .red
。 返回的 FetchedResult<Element>
的行为非常像数组。 对象本身 (Element
) 是不可变的,因此,对象本身或 FetchedResult<Element>
都可以安全地在线程之间传递。
如果您要获取多个对象,fetchWithinASnapshot
提供了一致的视图
let result = dflat.fetchWithinASnapshot { () -> (firstPost: FetchedResult<Post>, highPriPosts: FetchedResult<Post>) in
let firstPost = dflat.fetch(for: Post.self).where(Post.title == "first post")
let highPriPosts = dflat.fetch(for: Post.self).where(Post.priority > 100 && Post.color == .red)
return (firstPost, highPriPosts)
}
这是需要的,因为 Dflat 可以在 firstPost
和 highPriPosts
的获取之间进行事务。 fetchWithinASnapshot
不会停止该事务,但会确保它只观察来自 firstPost
获取的视图。
func Workspace.subscribe<Element: Equatable>(fetchedResult: FetchedResult<Element>, changeHandler: @escaping (_: FetchedResult<Element>) -> Void) -> Subscription
func Workspace.subscribe<Element: Equatable>(object: Element, changeHandler: @escaping (_: SubscribedObject<Element>) -> Void) -> Subscription
以上是原生订阅 API。 它订阅 fetchedResult
或对象的更改。 对于对象,当对象被删除时,订阅结束。 订阅在事务触发的 completionHandler
之前触发。
func Workspace.publisher<Element: Equatable>(for: Element) -> AtomPublisher<Element>
func Workspace.publisher<Element: Equatable>(for: FetchedResult<Element>) -> FetchedResultPublisher<Element>
func Workspace.publisher<Element: Equatable>(for: Element.Type).where(ElementQuery, limit = .noLimit, orderBy = []) -> QueryPublisher<Element>
func Workspace.publisher<Element: Equatable>(for: Element.Type).all(limit = .noLimit, orderBy = []) -> QueryPublisher<Element>
这些是 Combine 对应的 API。 除了订阅对象或 fetchedResult
之外,它还可以直接订阅查询。 幕后发生的事情是查询将在 subscribe
时进行(因此,如果您执行了 subscribe(on:
,则在您提供的任何队列上进行),并从此开始订阅 fetchedResult
。
func Workspace.shutdown(completion: (() -> Void)? = nil)
这将触发 Dflat 的关闭。在此调用之后对 Dflat 进行的所有事务都将失败。在此之前启动的事务将正常完成。此后获取数据将返回空结果。在此调用之前触发的任何数据获取都将正常完成,因此有了 completion
部分。如果提供,completion
闭包将在 shutdown
之前启动的所有事务和数据获取完成后被调用。
对结构化数据持久化系统进行基准测试是出了名的困难。 Dflat 不会声称自己是最快的。但是,它力求达到可预测的性能。这意味着不应该存在任何病态情况,导致 Dflat 的性能意外降低。这也意味着 Dflat 在某些最佳情况下不会出人意料地快。
以下数据已收集,可以从以下位置重现:
./focus.py app:Benchmarks
我主要与 Core Data 进行了比较,并列出了来自 WCDB Benchmark (v1.0.8.2) 的 FMDB 和 WCDB 的数字,以便更好地了解您从测试设备中可以期望什么。
测试设备是一台具有 64GB 内存的 iPhone 11 Pro。
免责声明:您应该对任何基准测试数字持保留态度。我在此处展示的这些数字只是为了演示所涉及框架的一些病态情况。不应该脱离这种上下文来理解它们。在实践中,结构化数据持久化系统很少成为瓶颈。更重要的是了解您如何使用它,而不是在轻工作负载设备中显示的原始数字。
app:Benchmarks
的代码是在 Release 模式 (--compilation-mode=opt
) 下使用 -whole-module-optimization
编译的。WCDB Benchmark 是以 Release 模式编译的,无论这在他们的项目文件中意味着什么。
基准测试本身未经同行评审。在某些情况下,它代表了这些框架的最佳情况。在其他情况下,它代表了最坏情况。它并非旨在反映真实世界的工作负载。相反,这些基准测试旨在反映框架在极端情况下的特性。
首先,我们将 Dflat 与 Core Data 在对象插入、获取、更新和删除方面进行了比较。生成了 10,000 个对象,没有索引(只有标题在 Core Data 中被索引)。
获取 1,667 个对象 评估了两个框架按非索引属性查询的能力。
单独更新 10,000 个对象 评估了在单独的事务中更新 10,000 次不同的对象。
单独获取 10,000 个对象 评估了按标题(在 Core Data 中被索引,并且是 Dflat 中的主键)获取 10,000 次不同的对象。
这些显然不是做事的最佳方式(您应该在一个大的事务中更新对象,并在可能的情况下批量获取它们),但这些是我们之前讨论的有趣的病态情况。
在 Core Data 中进行多线程插入/删除的正确方法相当棘手,我还没有做到这一点。多线程插入 40,000 个对象 和 多线程删除 40,000 个对象 仅适用于 Dflat。
其中一些数字看起来好得令人难以置信。例如,在插入方面,Dflat 似乎比 Core Data 快两倍以上。其中一些数字没有直观意义,为什么多线程插入会更慢?重要的是把它放在合适的角度来看待。
该图表与从 WCDB Benchmark (v1.0.8.2) 中提取的数字进行比较,没有任何修改。它比较的是每秒操作数,而不是花费在获取 33,334 个对象上的时间。请注意,在 WCDB Benchmark 中,Baseline Read 确实获取了所有内容,这是 SQLite 中的最佳情况。它还比较了一个简单的表,只有两列,一个键和一个 blob 有效负载(100 字节)。
在我们的理想情况下,多线程写入确实较慢,因为 SQLite 本身无法并发执行写入。因此,我们的多写入器模式实际上只是意味着这些事务闭包可以并发执行。写入仍然在 SQLite 层串行发生。这仍然是有益的,因为在真实世界中,我们在事务闭包中花费大量时间进行数据转换,而不是 SQLite 写入。
写入的上限远高于 Dflat 所达到的水平。同样,WCDB 代表了一种理想情况,即您只有两列。Dflat 在真实世界中的数字也会低于我们这里的情况,因为我们将有更多的索引和具有多个字段的对象,甚至是数据数组。
由于 Dflat 没有为批量操作引入任何优化,因此 Dflat 的性能与数据集大小成线性关系应该不足为奇,如下面的图表所示。
每个框架在更改订阅的工作方式上都有略微不同的设计。Core Data 通过两种方式实现此目的:NSFetchedResultsController
委托回调和 NSManagedObjectContextObjectsDidChange
。从开发人员的角度来看,NSFetchedResultsController
可以被解释为 Dflat 端的 FetchedResult
订阅的对应物。两者都支持进行类似 SQL 的查询并发送结果集的更新。您可以基于 NSManagedObjectContextObjectsDidChange
通知在 Core Data 中构建 Dflat 对象订阅机制。为了客观起见,我将简单地观察在比较这两个时 NSManagedObjectContextObjectsDidChange
通知的延迟,假设底层传递到单个对象订阅是一个空操作。
基准测试有三个部分:
订阅对 1,000 个已获取结果的更改,每个结果观察完全一个对象(通过主键获取)。后续事务将更新 10,000 个对象,包括这些已订阅的 1,000 个对象。测量从保存时到传递更新时的时间延迟。对于 Core Data,设置了 viewContext 的子上下文,并在保存子上下文之前到传递时测量了延迟。这应该是在数据持久化之前(在保存子上下文之后调用了 viewContext.save()
)。在 Dflat 端,这发生在数据持久化之后。
订阅对 1,000 个已获取对象的更改。后续事务将更新 10,000 个对象,包括这些已订阅的 1,000 个对象。测量从保存时到传递更新时的时间延迟。对于 Core Data,为 viewContext
对象订阅了 NSManagedObjectContextObjectsDidChange
。它测量了从保存子上下文之前到传递通知时的时间延迟。
订阅对 1,000 个已获取结果的更改,每个结果观察大约 1,000 个对象(通过范围查询获取)。后续事务将更新 10,000 个对象,旋转每个已获取结果中的所有对象,同时保持每个结果 1,000 个对象。Core Data 上的测量设置与 1 相同。
对于两种已获取结果的观察,尤其是情况 1,该数字代表了所有这些中最病态的情况。对于 Dflat 来说尤其麻烦,因为从磁盘单独获取 1,000 个对象大约需要 20 毫秒。因此,如果我们采用 SQLite.swift 的方法,即识别哪个表已更改并简单地重新获取该表上的每个查询,我们可能会最终获得更高的性能。尽管对于情况 3,从磁盘重新获取肯定会更慢(对于 1,000 个查询,每个查询有 1,000 个对象,接近 6 秒)。
从基准测试来看,Core Data 遇到了类似的问题,但情况更糟。同样,这是一种极端情况。对于移动应用程序,您应该只有少数查询订阅,每个查询可能最多有数千个对象,并且在您导航到其他页面时取消订阅更改。这些极端情况几乎不现实,您不会仅仅因为有 10,000 个对象被更新并且您碰巧有 1,000 个表格视图需要更新而看到 Core Data 出现 35 秒的卡顿。在现实中,按主键订阅单个查询似乎是一个很大的禁忌。如果您想观察单个对象,您应该像情况 2 显示的那样订阅单个对象。
但是,它确实暴露了我们的消息排序和传递机制没有像我们预期的那样有效地工作。从根本上讲,Dflat 的更改订阅最适合增量更改,因为我们会针对与该对象相关的所有已获取请求订阅评估每个已更改的对象。这种设计避免了每次事务都访问磁盘,但也依赖于合理的实现来有效评估每个已更改的对象。
一项快速测试表明,在 Swift 中循环遍历 10,000 个对象并进行 1,000 次字符串相等性评估大约需要 30 毫秒。Profile 显示大部分时间都花在了对象的保留/释放和 Swift 运行时的函数调用上。有两种改进方法:
当前的评估依赖于具有关联类型的 Swift 协议。似乎某些 Swift 用法比其他用法具有更高的运行时成本。切换到更好的线性扫描,无论是使用解释的 VM 还是简单地优化评估过程,都可能会显示 5 到 10 倍的改进。
从算法上讲,可以改进它。当前的实现是天真的,因为我们会针对每个订阅的查询评估每个对象。从数据库实现的研究中,我们知道加速数据结构可能很有用。特别是,查询中的每个 FieldExpr
都可以用于构建排序集,并且可以使用这些排序集来加速 Comparable
查询。
两者都是完全可行的,但各自都有其挑战。对于 1,我们需要与 Swift 运行时作斗争,并且它的行为有时会不稳定,以便有可能获得明显的收益。因为我不打算将部分委托给 C,所以这使得一切变得更加困难。对于 2,虽然实现起来并不难,但我们在内部使用 3 值逻辑(以支持 isNull
/ isNotNull
查询),这意味着每次都需要使用 UNKNOWN
进行排序。拥有一个健壮且正确的此类实现意味着需要进行更多的单元测试才能感到放心。我们还需要平衡何时进行线性扫描以及何时使用加速数据结构,因为对于少量更改,从之前的经验研究来看,线性扫描可能会更快。
收集了新数据,以比较 Dflat 的 WorkspaceDictionay
与 UserDefaults
,作为 iOS 上方便的持久化键值容器。提醒一下,原始性能很少是移动应用程序上持久化键值容器的考虑因素。此处提供的数据有助于我们了解在设计 Dflat 的 WorkspaceDictionary
时的特性。
新数据来自具有 128GiB 存储空间的 iPhone 13 Pro。编译参数与其他基准测试相同。
对于较新的 iOS 版本,UserDefaults
不会区分是否使用 synchronize()
。WorkspaceDictionary
实现仍然会进行这种区分。因此,在 WorkspaceDictionary
情况下,插入和持久化到磁盘之间存在差异。另一方面,UserDefaults
对 plist 文件施加了 4MiB 的限制,因此,基准测试是在有限数量的键(总共 80,001 个键)的情况下完成的。
随着文件越来越大,UserDefaults
持久化所需的时间也越长。这并不令人意外。UserDefaults
的持久化机制非常简单,每次都将所有数据保存到一个 plist 文件中。而 Dflat 的 WorkspaceDictionary
使用 SQLite 作为底层存储,因此,插入后 40,000 个键与插入前 40,000 个键花费的时间相同。
毫不奇怪,当访问“热”键时,UserDefaults
和 WorkspaceDictionary
都非常快。两种实现都有一个内存组件,可以避免在请求先前访问过的键时访问磁盘。
当键是“冷”的时候,UserDefaults
和 WorkspaceDictionary
之间存在性能差距(0.0641 秒 对 0.205 秒)。这是因为对于 UserDefaults
,所有键值对都一次性加载,而对于 WorkspaceDictionary
,键值对是按需加载的。 这也解释了为什么 *读取 400 个 Int 100 次,冷启动* 和 *读取 40,000 个 Int,热启动* 之间没有明显的性能差异。冷启动加载 400 个项目的开销被之后的 99 次访问分摊了。 WorkspaceDictionary
的设计有意不进行批量加载。 这样做性能特征更可预测,没有大小限制,而且整体来说更简单。 我之前实现过启动时批量加载之类的技巧。虽然它在生产环境中有效,但这种性能提升只有在通过数亿用户的 A/B 测试中才能得到验证。如果没有足够的验证,很难有效地实现它。
与仅支持 plist 值的 UserDefaults
不同,Dflat 的 WorkspaceDictionary
支持 Codable
对象和新引入的 FlatBuffersCodable
对象。 基准测试验证了 FlatBuffersCodable
的性能优势。 在保存时,FlatBufersCodable
大约快 2 到 3 倍,而在加载时,FlatBuffersCodable
大约快 50%。 编码性能的提升是显而易见的,因为在设置时,WorkspaceDictionary
会同步进行编码。 这是一个实际的选择,因为 Codable
对象可能不是线程安全的。 另一方面,FlatBuffersCodable
对象是生成的,并且本身就是线程安全的。
与上面的 Dflat 相比,插入性能并不快。 *插入 40,000 个 Int 到磁盘* 花费了 2.7 秒。 如果相信其他指标,这应该花费不到 0.3 秒(如上面的 *插入 10,000 个对象*)。 这是可以理解的,因为每个单独的插入都是一个事务。 当查看 *单独更新 10,000 个对象* 时,数据更具可比性。
由于 WorkspaceDictionary
实际上是一个线程安全的字典,因此我们可以通过对字典进行分片来避免锁竞争,从而进行一个简单的改进。 事实证明这很有益处。
比较不分片(使用锁来保护内存中的字典)和 12 路分片(通过键哈希值,仅锁定 12 个字典中的一个),在更简单的情况下,例如插入整数,更少的锁竞争是有益的。 当存在锁竞争时,例如 *用 40,000 个 Int 更新 1 个键*,正如预期的那样,差异是最小的。
使用 Codable
时,差异更大,这很不幸。 设置时,我们仅在对象被编码后才释放锁。 这有一个复杂的原因(我们仅在将旧值与新值进行比较时才进行更新,并且旧值是在锁定状态下获取的。 因此,我们当前的序列是:用新值更新内存中的字典并获取旧值 -> 如果旧值 != 新值 -> 编码对象 -> 调度到后台线程进行持久化。 我们需要保护整个内存中的字典更新,直到调度到持久化,否则我们最终可能会得到一个内存中的字典,它是一个值,但在磁盘上,它是另一个值。 替代方案是将编码对象部分移动到更新内存中的字典之前。 这样错过了如果旧值 == 新值则完全跳过编码的机会)。 您看到的差异是我们可以并行进行编码与必须序列化编码时的情况。
上面的比较提出了关于何时使用 WorkspaceDictionary
的问题。 答案并不容易。 如果你已经在使用 Dflat,WorkspaceDictionary
是一种简单的方法,可以使用与 Dflat 相同的保证来持久化一些一次性数据。 您无需处理 UserDefaults
的 OS 差异,也无需担心大小限制。 当我今年晚些时候引入事务保证时,它将更有利于 Dflat。