键值数据库LMDB在iOS应用中的辉煌与贫乏

键值数据库LMDB在iOS应用中的辉煌与贫乏

2019 年秋天,Mail.ru Cloud iOS 团队发生了期待已久的事件。 用于持久存储应用程序状态的主数据库对于移动世界来说已经变得非常奇特了 闪电内存映射数据库 (LMDB)。 下切,请您注意其分四部分的详细评述。 首先,让我们谈谈做出这样一个非平凡而艰难的选择的原因。 然后让我们继续考虑 LMDB 体系结构核心的三个鲸鱼:内存映射文件、B+ 树、用于实现事务和多版本控制的写时复制方法。 最后,甜点 - 实用部分。 在其中,我们将研究如何在低级键值 API 之上设计和实现具有多个表(包括索引表)的基本模式。

内容

  1. 实施动机
  2. 定位LMDB
  3. 三头鲸LMDB
    3.1. 鲸鱼 #1。 内存映射文件
    3.2. 鲸鱼 #2。 B+树
    3.3. 鲸鱼 #3。 写时复制
  4. 在键值 API 之上设计数据模式
    4.1. 基本抽象
    4.2. 表格建模
    4.3. 表之间的建模关系

1.实施动机

每年一次,在 2015 年,我们负责衡量一个指标,即我们的应用程序界面滞后的频率。 我们不只是这样做。 我们对有时应用程序停止响应用户操作这一事实有越来越多的抱怨:未按下按钮,列表未滚动等。 关于测量力学 我告诉 在 AvitoTech 上,所以这里我只给出数字的顺序。

键值数据库LMDB在iOS应用中的辉煌与贫乏

测量结果对我们来说成了冷水澡。 事实证明,冻结引起的问题比其他任何问题都多。 如果在意识到这一事实之前,质量的主要技术指标是无碰撞的,那么在关注之后 转移 无冷冻。

建成后 冻结的仪表板 并且花费了 定量的 и 质量 分析它们的原因,主要的敌人变得清晰了——在应用程序的主线程中执行繁重的业务逻辑。 对这种耻辱的自然反应是强烈希望将其推入工作流程。 为了系统地解决这个问题,我们求助于基于轻量级参与者的多线程架构。 我将她的改编献给了 iOS 世界 两个线程 在集体推特和 关于哈布雷的文章. 作为当前故事的一部分,我想强调影响数据库选择的决策方面。

系统组织的参与者模型假设多线程成为它的第二个本质。 其中的模型对象喜欢跨越线程边界。 他们不是有时在某些地方这样做,而是几乎无处不在。

键值数据库LMDB在iOS应用中的辉煌与贫乏

数据库是所呈现图表中的基石组件之一。 它的主要任务是实现一个宏模式 共享数据库. 如果在企业世界中它用于组织服务之间的数据同步,那么在 actor 架构的情况下,线程之间的数据。 因此,我们需要这样一个数据库,在多线程环境中使用它不会造成哪怕是最小的困难。 特别是,这意味着从它派生的对象必须至少是线程安全的,理想情况下根本不可变。 如您所知,后者可以从多个线程同时使用,而无需求助于任何类型的锁,这对性能有好处。

键值数据库LMDB在iOS应用中的辉煌与贫乏影响数据库选择的第二个重要因素是我们的云 API。 它的灵感来自 git 同步方法。 像他一样我们的目标 离线优先API,这看起来非常适合云客户端。 假设他们只会一次抽出云的完整状态,然后在绝大多数情况下同步将通过滚动更改发生。 las,这种可能性仍然只存在于理论领域,在实践中,客户还没有学会如何使用补丁。 这其中有很多客观原因,为了不耽误介绍,我们先不加括号。 现在更有趣的是关于当 API 说“A”而它的消费者没有说“B”时会发生什么的课程的指导性结果。

所以,如果你想象 git,它在执行 pull 命令时,不是将补丁应用到本地快照,而是将其完整状态与完整服务器状态进行比较,那么你将对 \u500b\u1b如何同步有一个相当准确的想法发生在云客户端。 很容易猜到,为了实现它,有必要在内存中分配两棵 DOM 树,其中包含有关所有服务器和本地文件的元信息。 事实证明,如果一个用户在云端存储了 XNUMX 万个文件,那么要同步它,就需要重新创建和销毁两棵具有 XNUMX 万个节点的树。 但是每个节点都是一个包含子对象图的聚合。 有鉴于此,分析结果是意料之中的。 事实证明,即使不考虑合并算法,创建然后销毁大量小对象的过程也花费了相当多的钱。基本同步操作包含在大量对象中这一事实使情况更加恶化用户脚本。 因此,我们确定了选择数据库的第二个重要标准——无需动态分配对象即可实现 CRUD 操作的能力。

其他要求更为传统,其完整列表如下。

  1. 线程安全。
  2. 多重处理。 由希望使用同一个数据库实例不仅在线程之间同步状态,而且在主应用程序和 iOS 扩展之间同步状态的愿望所决定。
  3. 将存储的实体表示为非可变对象的能力。
  4. CRUD 操作中缺少动态分配。
  5. 基本属性的交易支持 关键词:原子性、一致性、隔离性和可靠性。
  6. 加快处理最受欢迎的案例。

有了这组需求,SQLite 过去是,现在仍然是一个不错的选择。 然而,作为备选方案研究的一部分,我偶然发现了一本书 “LevelDB 入门”. 在她的领导下,编写了一个基准测试,比较了真实云场景中不同数据库的工作速度。 结果超出了最疯狂的预期。 在最流行的情况下——将游标放在所有文件的排序列表和给定目录的所有文件的排序列表上——LMDB 结果比 SQLite 快 10 倍。 选择变得显而易见。

键值数据库LMDB在iOS应用中的辉煌与贫乏

2.LMDB定位

LMDB 是一个库,非常小(只有 10K 行),它实现了数据库的最低基础层——存储。

键值数据库LMDB在iOS应用中的辉煌与贫乏

上图表明,将 LMDB 与实现更高级别的 SQLite 进行比较,通常并不比 SQLite 与 Core Data 更正确。 引用相同的存储引擎作为同等竞争对手会更公平——BerkeleyDB、LevelDB、Sophia、RocksDB 等。甚至有一些开发将 LMDB 作为 SQLite 的存储引擎组件。 2012年第一次这样的实验 我花 作者 LMDB 霍华德朱. 结果 结果非常有趣,以至于他的倡议被 OSS 爱好者接受,并在面对 LumoSQL. 2020 年 XNUMX 月,该项目的作者是 Den Shearer 呈现 它在 LinuxConfAu 上。

LMDB 的主要用途是作为应用程序数据库的引擎。 该库的出现归功于开发人员 OpenLDAP的, 他们对将 BerkeleyDB 作为他们项目的基础非常不满。 远离简陋的图书馆 , Howard Chu 创造了我们这个时代最流行的替代品之一。 他将他非常酷的报告献给了这个故事,以及 LMDB 的内部结构。 “闪电内存映射数据库”. 列昂尼德·尤里耶夫(又名 耶利奥) 来自 Positive Technologies 在 Highload 2015 上的演讲 “LMDB 引擎是一个特殊的冠军”. 在其中,他在实现 ReOpenLDAP 的类似任务的背景下谈到了 LMDB,而 LevelDB 已经受到了比较批评。 作为实施的结果,Positive Technologies 甚至得到了一个积极开发的分支 MDBX 具有非常美味的功能、优化和 bug修复.

LMDB 也经常用作原样存储。 例如,Mozilla Firefox 浏览器 选择了 它满足多种需求,并且从版本 9 开始,Xcode 首选 它的 SQLite 用于存储索引。

该引擎还流行于移动开发领域。 它的使用痕迹可以 发现 在 Telegram 的 iOS 客户端中。 LinkedIn 更进一步,选择 LMDB 作为其自主研发的数据缓存框架 Rocket Data 的默认存储。 告诉 在 2016 年的一篇文章中。

LMDB正在BerkeleyDB转型后在甲骨文的控制下留下的小众市场中,成功地在阳光下争得一席之地。 该库因其速度和可靠性而广受喜爱,甚至与同类库相比也是如此。 如您所知,天下没有免费的午餐,我想强调在 LMDB 和 SQLite 之间进行选择时您将不得不面对的权衡。 上图清楚地展示了如何实现提高的速度。 首先,我们不为磁盘存储之上的额外抽象层付费。 当然,在一个好的架构中,你还是离不开它们,应用代码中也不可避免地会出现它们,只是它们会薄很多。 它们不会具有特定应用程序不需要的功能,例如,支持 SQL 语言的查询。 其次,可以优化地实现应用程序操作到磁盘存储请求的映射。 如果 SQLite 在我的工作中 来自普通应用程序的平均需求,那么作为应用程序开发人员的您很清楚主要的负载场景。 为了获得更高效的解决方案,您将不得不为初始解决方案的开发及其后续支持支付更高的价格。

3.三头鲸LMDB

鸟瞰了 LMDB 之后,是时候深入了解一下了。 接下来的三个部分将专门分析存储架构所依赖的主要鲸鱼:

  1. 内存映射文件作为处理磁盘和同步内部数据结构的机制。
  2. B+树作为一种存储数据的组织结构。
  3. 写时复制作为一种提供 ACID 事务属性和多版本控制的方法。

3.1. 鲸鱼 #1。 内存映射文件

内存映射文件是一个非常重要的架构元素,它们甚至出现在存储库的名称中。 缓存和同步访问存储信息的问题完全取决于操作系统。 LMDB 本身不包含任何缓存。 这是作者的一个有意识的决定,因为直接从映射文件读取数据可以让您在引擎的实现中走捷径。 以下是其中一些远非完整的列表。

  1. 当从多个进程使用数据时,维护存储中数据的一致性成为操作系统的责任。 在下一节中,将详细讨论此机制并附上图片。
  2. 没有缓存完全减轻了 LMDB 与动态分配相关的开销。 在实践中读取数据就是将指针设置为虚拟内存中的正确地址,仅此而已。 听起来很玄幻,但是在repository源码中,所有的calloc调用都集中在repository配置函数中。
  3. 没有缓存也意味着没有与同步相关联的锁来访问它们。 可以同时存在任意数量的读取器在访问数据的过程中不会遇到单个互斥锁。 正因为如此,读取速度在CPU数量方面具有理想的线性可扩展性。 在 LMDB 中,只有修改操作是同步的。 一次只能有一个作家。
  4. 最少的缓存和同步逻辑可以避免代码出现与在多线程环境中工作相关的极其复杂的错误类型。 Usenix OSDI 2014 会议上有两个有趣的数据库研究: “并非所有文件系统都是生来平等的:关于制作崩溃一致应用程序的复杂性” и 为了乐趣和利润而折磨数据库. 从他们那里,您可以获得有关 LMDB 前所未有的可靠性以及事务的 ACID 属性几乎完美实现的信息,这在同一个 SQLite 中超过了它。
  5. LMDB 的极简主义允许其代码的机器表示完全放置在处理器的 L1 缓存中,并具有由此产生的速度特性。

不幸的是,在 iOS 中,内存映射文件并不像我们希望的那样美好。 要更有意识地谈论与它们相关的缺点,有必要回顾一下在操作系统中实现这种机制的一般原则。

有关内存映射文件的一般信息

键值数据库LMDB在iOS应用中的辉煌与贫乏对于每个可执行应用程序,操作系统都将一个称为进程的实体关联起来。 每个进程都分配了一个连续的地址范围,其中放置了它需要工作的所有内容。 最低地址包含带有代码和硬编码数据和资源的部分。 接下来是向上增长的动态地址空间块,我们称之为堆。 它包含程序运行期间出现的实体的地址。 最上面是应用程序栈使用的内存区域。 它要么增长要么收缩,换句话说,它的大小也具有动态性。 为了栈和堆不互相压入和干扰,它们被分隔在地址空间的不同端,顶部和底部的两个动态部分之间有一个空洞。 操作系统使用中间部分的地址来关联各种实体的进程。 特别是,它可以将一组特定的连续地址映射到磁盘上的一个文件。 这样的文件称为内存映射文件。

分配给进程的地址空间是巨大的。 从理论上讲,地址的数量仅受指针大小的限制,而指针的大小由系统的位数决定。 如果物理内存以 1 合 1 的方式分配给它,那么第一个进程将吞噬整个 RAM,并且不存在任何多任务处理的问题。

然而,我们从经验中知道,现代操作系统可以同时运行任意数量的进程。 这是可能的,因为它们仅在纸面上为进程分配了大量内存,但实际上它们仅将此时此地需要的那部分加载到主物理内存中。 因此,与进程关联的内存称为虚拟内存。

键值数据库LMDB在iOS应用中的辉煌与贫乏

操作系统将虚拟内存和物理内存组织成一定大小的页面。 一旦需要某个虚拟内存页面,操作系统就会将其加载到物理内存中,并在一个特殊的表中记录它们之间的对应关系。 如果没有空闲插槽,则将先前加载的页面之一复制到磁盘,并由请求的页面取代它。 我们稍后将返回的这个过程称为交换。 下图说明了所描述的过程。 在其上,地址为 0 的页面 A 被加载并放置在地址为 4 的主内存页面上。这一事实反映在单元格编号 0 的对应表中。

键值数据库LMDB在iOS应用中的辉煌与贫乏

对于内存映射文件,情况完全相同。 从逻辑上讲,据称它们是连续且完整地放置在虚拟地址空间中的。 但是,它们只能按需逐页进入物理内存。 此类页面的修改与磁盘上的文件同步。 因此,您可以执行文件 I/O,只需使用内存中的字节 - 所有更改都会由操作系统内核自动传输到原始文件。

下图演示了 LMDB 在使用来自不同进程的数据库时如何同步其状态。 通过将不同进程的虚拟内存映射到同一个文件,我们实际上迫使操作系统将它们的地址空间的某些块相互传递同步,这就是 LMDB 的样子。

键值数据库LMDB在iOS应用中的辉煌与贫乏

一个重要的细微差别是LMDB默认通过write系统调用机制修改数据文件,文件本身以只读模式显示。 这种方法有两个重要的含义。

第一个结果是所有操作系统共有的。 其本质是增加保护,防止错误代码无意中损坏数据库。 如您所知,进程的可执行指令可以从其地址空间中的任何位置自由访问数据。 同时,我们刚才还记得,以读写方式显示一个文件,意味着任何指令也可以对其进行附加修改。 如果她错误地这样做,例如,尝试实际覆盖一个不存在的索引处的数组元素,那么她可能会意外更改映射到该地址的文件,这将导致数据库损坏。 如果文件以只读模式显示,则尝试更改与其对应的地址空间将导致程序崩溃并发出信号 SIGSEGV,文件将保持不变。

第二个结果已经特定于 iOS。 作者和任何其他来源都没有明确提及它,但如果没有它,LMDB 将不适合在这个移动操作系统上运行。 下一节专门讨论它。

iOS 中内存映射文件的细节

2018年WWDC上有精彩报道 iOS 内存深入探究. 它表明在 iOS 中,位于物理内存中的所有页面都属于 3 种类型之一:脏的、压缩的和干净的。

键值数据库LMDB在iOS应用中的辉煌与贫乏

干净内存是可以安全地换出物理内存的页面集合。 它们包含的数据可以根据需要从其原始来源重新加载。 只读内存映射文件属于此类。 iOS 不怕随时从内存中卸载映射到文件的页面,因为它们保证与磁盘上的文件同步。

所有修改过的页面都会进入脏内存,无论它们最初位于何处。 特别是,通过写入与其关联的虚拟内存而修改的内存映射文件也将按这种方式分类。 用标志打开 LMDB MDB_WRITEMAP,修改之后,大家可以自己看看。

一旦应用程序开始占用过多的物理内存,iOS 就会压缩其脏页。 脏页和压缩页占用的内存集合就是应用程序所谓的内存占用。 当达到一定的阈值时,OOM killer系统守护进程追上来,强行终止。 与桌面操作系统相比,这是 iOS 的特殊性。 相比之下,iOS 并没有提供通过将页面从物理内存交换到磁盘来降低内存占用,原因只能猜测。 也许密集地将页面移动到磁盘和返回的过程对于移动设备来说太耗能了,或者 iOS 节省了在 SSD 驱动器上重写单元格的资源,或者也许设计者对系统的整体性能不满意,一切都在不断交换。 不管怎样,事实依然如此。

前面已经提到的好消息是 LMDB 默认不使用 mmap 机制来更新文件。 因此,渲染数据被 iOS 归类为干净内存,不会影响内存占用。 这可以使用名为 VM Tracker 的 Xcode 工具进行验证。 下面的屏幕截图显示了 iOS Cloud 应用程序在运行期间的虚拟内存状态。 一开始,里面初始化了2个LMDB实例。 第一个被允许将其文件映射到 1GiB 的虚拟内存,第二个 - 512MiB。 尽管这两个存储都占用一定数量的常驻内存,但它们都不会影响脏大小。

键值数据库LMDB在iOS应用中的辉煌与贫乏

现在是坏消息的时候了。 由于 64 位桌面操作系统中的交换机制,每个进程可以占用与硬盘上的可用空间允许其潜在交换一样多的虚拟地址空间。 在 iOS 中用压缩代替交换大大降低了理论上的最大值。 现在,所有活动进程都必须适合主(读取 RAM)内存,所有不适合的进程都将被强制终止。 如上所述 报告和在 官方文档. 因此,iOS 严格限制了可通过 mmap 分配的内存量。 这里 这里 您可以使用此系统调用查看可在不同设备上分配的内存量的经验限制。 在最现代的智能手机型号上,iOS 增加了 2 GB,在 iPad 的顶级版本上增加了 4 GB。当然,在实践中,你必须关注最年轻的受支持设备型号,那里的一切都很糟糕。 更糟糕的是,在 VM Tracker 中查看应用程序的内存状态,您会发现 LMDB 远非唯一声称内存映射内存的。 好的块会被系统分配器、资源文件、图像框架和其他较小的捕食者吃掉。

作为云端实验的结果,我们得出了以下由 LMDB 分配的内存折衷值:384 位设备为 32 兆字节,768 位设备为 64 兆字节。 此卷用完后,任何修改操作开始以代码完成 MDB_MAP_FULL. 我们在监控中观察到此类错误,但在现阶段它们足够小,可以忽略不计。

存储消耗过多内存的一个不明显的原因可能是长期事务。 要了解这两种现象之间的关系,将有助于我们考虑剩余的两条 LMDB 鲸鱼。

3.2. 鲸鱼 #2。 B+树

要在键值存储之上模拟表,其 API 中必须存在以下操作:

  1. 插入新元素。
  2. 搜索具有给定键的元素。
  3. 删除一个元素。
  4. 按排序顺序迭代关键间隔。

键值数据库LMDB在iOS应用中的辉煌与贫乏可以轻松实现所有四种操作的最简单的数据结构是二叉搜索树。 它的每个节点都是一个键,将子键的整个子集划分为两个子树。 左边是比父级小的,右边是比父级大的。 获得一组有序的键是通过一种经典的树遍历实现的。

二叉树有两个基本缺点,使它们无法作为有效的磁盘数据结构。 首先,它们的平衡程度是不可预测的。 获得树的风险很大,其中不同分支的高度可能有很大差异,与预期相比,这大大恶化了搜索的算法复杂性。 其次,节点之间大量的交叉链接剥夺了二叉树在内存中的局部性。关闭的节点(就它们之间的链接而言)可以位于虚拟内存中完全不同的页面上。 因此,即使是对树中几个相邻节点的简单遍历,也可能需要访问相当数量的页面。 即使当我们谈论二叉树作为内存中数据结构的有效性时,这也是一个问题,因为在处理器缓存中不断旋转页面并不便宜。 当谈到频繁从磁盘中提取与节点相关的页面时,事情变得非常糟糕。 可悲的

键值数据库LMDB在iOS应用中的辉煌与贫乏B 树是二叉树的一种演变,解决了上一段中指出的问题。 首先,它们是自我平衡的。 其次,他们的每个节点不是将子键集合拆分成2个,而是拆分成M个有序的子集,M的数目可以很大,在几百甚至几千的数量级。

从而:

  1. 每个节点都有大量已经排序的键,树很低。
  2. 这棵树在内存中具有局部性,因为值接近的键自然地位于一个节点或相邻节点上。
  3. 在搜索操作期间降低树时减少中转节点的数量。
  4. 减少为范围查询读取的目标节点数量,因为它们中的每一个都已经包含大量有序键。

键值数据库LMDB在iOS应用中的辉煌与贫乏

LMDB 使用称为 B+ 树的 B 树变体来存储数据。 上图显示了它包含的三种类型的节点:

  1. 顶部是根。 它具体化了存储库中数据库的概念。 在单个 LMDB 实例中,您可以创建共享映射虚拟地址空间的多个数据库。 他们每个人都从自己的根开始。
  2. 最低层是树叶(leaf)。 只有它们包含存储在数据库中的键值对。 顺便说一下,这就是 B+ 树的特性。 如果一个普通的 B-tree 在所有级别的节点存储值部分,那么 B+-variation 仅在最低的一个。 解决了这个问题后,接下来我们将把 LMDB 中使用的树的子类型简称为 B 树。
  3. 在根和叶之间,有 0 个或多个带有导航(分支)节点的技术级别。 他们的任务是在叶子之间划分已排序的键集。

在物理上,节点是预定长度的内存块。 它们的大小是操作系统中内存页面大小的倍数,我们在上面谈到过。 节点结构如下图所示。 标头包含元信息,其中最明显的是校验和。 接下来是有关偏移量的信息,其中包含数据的单元格位于其中。 数据的角色可以是键,如果我们谈论的是导航节点,或者在叶子的情况下是整个键值对。你可以在工作中阅读更多关于页面结构的信息 《高性能键值存储的评估》.

键值数据库LMDB在iOS应用中的辉煌与贫乏

处理完页面节点的内部内容后,我们将进一步以以下形式简化表示 LMDB B 树。

键值数据库LMDB在iOS应用中的辉煌与贫乏

具有节点的页面按顺序排列在磁盘上。 编号较大的页面位于文件末尾。 所谓的元页面(meta page)包含了关于偏移量的信息,可以用来找到所有树的根。 当一个文件被打开时,LMDB 会从末尾到开头逐页扫描文件以搜索有效的元页面,并通过它找到现有的数据库。

键值数据库LMDB在iOS应用中的辉煌与贫乏

现在,了解了数据组织的逻辑和物理结构,我们可以继续考虑 LMDB 的第三只鲸鱼。 正是在它的帮助下,所有存储修改都以事务方式发生并且彼此隔离,从而使数据库作为一个整体也具有多版本属性。

3.3. 鲸鱼 #3。 写时复制

一些 B 树操作涉及对其节点进行一系列更改。 一个示例是向已达到其最大容量的节点添加新密钥。 在这种情况下,首先,有必要将节点一分为二,其次,在其父节点中添加到新分离出的子节点的链接。 此过程可能非常危险。 如果由于某种原因(崩溃、断电等)仅发生了系列中的一部分更改,则树将保持不一致状态。

使数据库容错的一种传统解决方案是在 B 树旁边添加一个额外的基于磁盘的数据结构,即事务日志,也称为预写日志 (WAL)。 它是一个文件,在文件的末尾,严格地在 B 树本身的修改之前,写入了预期的操作。 因此,如果在自诊断过程中检测到数据损坏,数据库会查询日志以进行自我清理。

LMDB 选择了一种不同的方法作为其容错机制,称为写时复制。 它的本质是,它不是更新现有页面上的数据,而是首先完全复制它,并在副本中进行所有修改。

键值数据库LMDB在iOS应用中的辉煌与贫乏

此外,为了使更新的数据可用,有必要改变到与其相关的父节点中已变为最新的节点的链接。 由于这个也需要修改,所以也预先拷贝过来。 该过程一直递归地继续到根。 元页面上的数据是最后更改的。

键值数据库LMDB在iOS应用中的辉煌与贫乏

如果在更新过程中进程突然崩溃,那么要么不会创建新的元页面,要么直到最后才将其写入磁盘,并且其校验和将不正确。 在这两种情况中的任何一种情况下,新页面都将无法访问,而旧页面不会受到影响。 这消除了 LMDB 预写日志以保持数据一致性的需要。 事实上,磁盘上的数据存储结构,如上所述,同时发挥其功能。 没有显式事务日志是 LMDB 的特点之一,它提供了高数据读取速度。

键值数据库LMDB在iOS应用中的辉煌与贫乏

生成的构造称为仅附加 B 树,自然提供事务隔离和多版本控制。 在 LMDB 中,每个打开的事务都有一个与之关联的最新树根。 只要事务未完成,与之关联的树的页面将永远不会更改或重新用于新版本的数据。因此,您可以根据需要使用与当时相关的数据集工作多久事务打开的时间,即使此时存储继续被主动更新。 这是多版本化的本质,使 LMDB 成为我们心爱的人的理想数据源 UICollectionView. 开启交易后,你不需要增加应用程序的内存占用,匆忙将当前数据抽出到内存中的某个结构中,生怕什么都没有。 此功能将 LMDB 与相同的 SQLite 区分开来,后者不能夸耀这种完全隔离。 在后者打开了两个事务,删除了其中一个中的某条记录,在剩下的第二个中无法再获取相同的记录。

硬币的另一面是虚拟内存的消耗可能会显着增加。 该幻灯片显示了如果数据库结构同时被修改,同时有 3 个打开的读取事务查看不同版本的数据库,则该数据库结构将是什么样子。 由于 LMDB 无法重用与实际事务关联的根可到达的节点,因此存储别无选择,只能在内存中分配另一个四分之一根,并再次将修改后的页面克隆到其下。

键值数据库LMDB在iOS应用中的辉煌与贫乏

在这里回顾内存映射文件部分并不是多余的。 虚拟内存的额外消耗似乎不会让我们太在意,因为它不会增加应用程序的内存占用量。 但同时,也有人指出iOS在分配上非常吝啬,我们不能站在主人的肩上在服务器或桌面上提供一个1TB的LMDB区域而不考虑这个特性。 如果可能,您应该尽量缩短事务的生命周期。

4. 在键值 API 之上设计数据模式

让我们通过查看 LMDB 提供的基本抽象来开始解析 API:环境和数据库、键和值、事务和游标。

关于代码清单的注释

LMDB 公共 API 中的所有函数都以错误代码的形式返回它们的工作结果,但在所有后续清单中,为了简洁起见,省略了它的检查。在实践中,我们使用自己的代码与存储库进行交互。 C++ 包装器 LMDBXX,其中错误具体化为 C++ 异常。

作为将 LMDB 连接到 iOS 或 macOS 项目的最快方式,我提供了我的 CocoaPod POSLM数据库.

4.1. 基本抽象

环境

产品管理 MDB_env is 是 LMDB 内部状态的存储库。 前缀函数族 mdb_env 允许您配置它的一些属性。 在最简单的情况下,引擎的初始化看起来像这样。

mdb_env_create(env);​
mdb_env_set_map_size(*env, 1024 * 1024 * 512)​
mdb_env_open(*env, path.UTF8String, MDB_NOTLS, 0664);

在 Mail.ru Cloud 应用程序中,我们仅更改了两个参数的默认值。

第一个是存储文件映射到的虚拟地址空间的大小。 不幸的是,即使在同一台设备上,每次运行的具体值也会有很大差异。 考虑到 iOS 的这个特性,我们动态选择最大存储量。 从某个值开始,依次减半,直到函数 mdb_env_open 不会返回除以下以外的结果 ENOMEM. 理论上,有一种相反的方式——首先为引擎分配最少的内存,然后,当接收到错误时 MDB_MAP_FULL, 增加它。 然而,它更棘手。 原因是使用函数重新映射内存的过程 mdb_env_set_map_size 使先前从引擎接收到的所有实体(游标、事务、键和值)无效。 在代码中考虑这种事件的转变将导致其严重的复杂化。 尽管如此,如果虚拟内存对您来说非常重要,那么这可能是查看已经走得很远的分叉的原因。 MDBX,其中声明的功能中有“自动实时数据库大小调整”。

第二个参数,其默认值不适合我们,调节确保线程安全的机制。 不幸的是,至少在 iOS 10 中,线程本地存储支持存在问题。 出于这个原因,在上面的例子中,存储库是用标志打开的 MDB_NOTLS. 此外,还要求 C++包装器 LMDBXX在此属性中剪切变量。

数据库

数据库是我们上面讨论的 B 树的一个单独实例。 它的打开发生在一个事务中,乍一看可能有点奇怪。

MDB_txn *txn;​
MDB_dbi dbi;​
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn);​
mdb_dbi_open(txn, NULL, MDB_CREATE, &dbi);​
mdb_txn_abort(txn);

的确,LMDB中的一个事务是一个存储实体,而不是一个具体的数据库。 这个概念允许您对位于不同数据库中的实体执行原子操作。 从理论上讲,这开启了以不同数据库的形式对表进行建模的可能性,但有一次我采用了另一种方式,下面将详细描述。

键和值

产品管理 MDB_val 对键和值的概念进行建模。 存储库不知道它们的语义。 对她来说,不同的东西只是给定大小的字节数组。 最大密钥大小为 512 字节。

typedef struct MDB_val {​
    size_t mv_size;​
    void *mv_data;​
} MDB_val;​​

商店使用比较器按升序对键进行排序。 如果您不将其替换为您自己的,则将使用默认的,按字典顺序逐字节排序。

交易

交易设备在详细描述 上一章,所以在这里我将用简短的一行重复它们的主要属性:

  1. 支持所有基本属性 关键词:原子性、一致性、隔离性和可靠性。 我不禁注意到,就 macOS 和 iOS 的持久性而言,MDBX 中修复了一个错误。 你可以在他们的网站上阅读更多 读我.
  2. 多线程的方法由“单写入器/多读取器”方案描述。 作者互相屏蔽,但不屏蔽读者。 读者不会阻止作者或彼此。
  3. 支持嵌套事务。
  4. 多版本支持。

LMDB 中的多版本非常好,我想在实际中演示它。 下面的代码显示每个事务都与打开时相关的数据库版本完全一致,与所有后续更改完全隔离。 初始化存储库并向其添加测试记录没有意义,因此这些仪式被保留在剧透之下。

添加测试条目

MDB_env *env;
MDB_dbi dbi;
MDB_txn *txn;

mdb_env_create(&env);
mdb_env_open(env, "./testdb", MDB_NOTLS, 0664);

mdb_txn_begin(env, NULL, 0, &txn);
mdb_dbi_open(txn, NULL, 0, &dbi);
mdb_txn_abort(txn);

char k = 'k';
MDB_val key;
key.mv_size = sizeof(k);
key.mv_data = (void *)&k;

int v = 997;
MDB_val value;
value.mv_size = sizeof(v);
value.mv_data = (void *)&v;

mdb_txn_begin(env, NULL, 0, &txn);
mdb_put(txn, dbi, &key, &value, MDB_NOOVERWRITE);
mdb_txn_commit(txn);

MDB_txn *txn1, *txn2, *txn3;
MDB_val val;

// Открываем 2 транзакции, каждая из которых смотрит
// на версию базы данных с одной записью.
mdb_txn_begin(env, NULL, 0, &txn1); // read-write
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn2); // read-only

// В рамках первой транзакции удаляем из базы данных существующую в ней запись.
mdb_del(txn1, dbi, &key, NULL);
// Фиксируем удаление.
mdb_txn_commit(txn1);

// Открываем третью транзакцию, которая смотрит на
// актуальную версию базы данных, где записи уже нет.
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn3);
// Убеждаемся, что запись по искомому ключу уже не существует.
assert(mdb_get(txn3, dbi, &key, &val) == MDB_NOTFOUND);
// Завершаем транзакцию.
mdb_txn_abort(txn3);

// Убеждаемся, что в рамках второй транзакции, открытой на момент
// существования записи в базе данных, её всё ещё можно найти по ключу.
assert(mdb_get(txn2, dbi, &key, &val) == MDB_SUCCESS);
// Проверяем, что по ключу получен не абы какой мусор, а валидные данные.
assert(*(int *)val.mv_data == 997);
// Завершаем транзакцию, работающей хоть и с устаревшей, но консистентной базой данных.
mdb_txn_abort(txn2);

或者,我建议对 SQLite 尝试同样的技巧,看看会发生什么。

多版本化为 iOS 开发人员的生活带来了非常好的好处。 使用此属性,您可以根据用户体验考虑轻松自然地调整屏幕表单的数据源更新速率。 例如,让我们以 Mail.ru 云应用程序的这样一个功能为例,即从系统媒体库自动加载内容。 通过良好的连接,客户端可以每秒向服务器添加几张照片。 如果您在每次下载后更新 UICollectionView 对于用户云中的媒体内容,在此过程中您可以忘记 60 fps 和平滑滚动。 为了防止频繁的屏幕更新,您需要以某种方式限制基础中的数据更改率 UICollectionViewDataSource.

如果数据库不支持多版本控制并且只允许您使用当前当前状态,那么要创建一个时间稳定的数据快照,您需要将其复制到某个内存数据结构或临时表中。 这些方法中的任何一种都非常昂贵。 在内存存储的情况下,我们得到了存储构造对象引起的内存成本和与冗余 ORM 转换相关的时间成本。 至于临时表,这是一种更昂贵的乐趣,只有在非平凡的情况下才有意义。

多版本 LMDB 以非常优雅的方式解决了维护稳定数据源的问题。 只需打开一个事务就足够了,瞧 - 在我们完成它之前,数据集保证是固定的。 其更新速率的逻辑现在完全掌握在表示层手中,没有大量资源的开销。

光标

游标提供了一种通过遍历 B 树对键值对进行有序迭代的机制。 没有它们,就不可能有效地对数据库中的表进行建模,我们现在将转向它。

4.2. 表格建模

键排序属性允许您构建顶级抽象,例如在基本抽象之上的表格。 让我们以云客户端的主表为例来考虑这个过程,其中缓存了有关用户的所有文件和文件夹的信息。

表架构

应该锐化具有文件夹树的表结构的常见场景之一是选择位于给定目录内的所有元素。这种高效查询的良好数据组织模型是 邻接表. 要在键值存储之上实现它,有必要对文件和文件夹的键进行排序,使它们根据属于父目录进行分组。 此外,为了以 Windows 用户熟悉的形式显示目录的内容(先是文件夹,然后是文件,均按字母顺序排序),需要在键中包含相应的附加字段。

下图显示了根据任务,将键表示为字节数组的样子。 首先,放置具有父目录标识符(红色)的字节,然后是类型(绿色),最后是名称(蓝色)。由默认的 LMDB 比较器按字典顺序排序,它们按所需的方式。 顺序遍历具有相同红色前缀的键可以按照它们在用户界面中应显示的顺序(右)为我们提供与它们关联的值,而无需任何额外的后处理。

键值数据库LMDB在iOS应用中的辉煌与贫乏

序列化键和值

世界上有许多序列化对象的方法。 由于我们除了速度没有其他要求,所以我们为自己选择了尽可能快的 - 由 C 语言结构实例占用的内存转储。因此,目录元素的键可以通过以下结构建模 NodeKey.

typedef struct NodeKey {​
    EntityId parentId;​
    uint8_t type;​
    uint8_t nameBuffer[256];​
} NodeKey;

保存 NodeKey 在存储中需要在对象中 MDB_val 将指向数据的指针定位在结构的开头地址,并使用函数计算它们的大小 sizeof.

MDB_val serialize(NodeKey * const key) {
    return MDB_val {
        .mv_size = sizeof(NodeKey),
        .mv_data = (void *)key
    };
}

在关于数据库选择标准的第一章中,我提到将动态分配最小化作为 CRUD 操作的一部分作为一个重要的选择因素。 功能码 serialize 展示了在 LMDB 的情况下,当新记录插入数据库时​​如何完全避免它们。 来自服务器的传入字节数组首先被转换为堆栈结构,然后它们被简单地转储到存储中。 鉴于 LMDB 内部也没有动态分配,您可以按照 iOS 的标准获得一个奇妙的情况 - 仅使用堆栈内存来处理从网络到磁盘的数据!

使用二进制比较器排序键

键顺序关系由称为比较器的特殊函数给出。 由于引擎对它们包含的字节的语义一无所知,因此默认比较器别无选择,只能按字典顺序排列键,进行逐字节比较。 用它来安排结构类似于用雕刻斧剃须。 但是,在简单的情况下,我觉得这种方法是可以接受的。 下面描述了替代方案,但在这里我会注意到沿途散落的几个耙子。

首先要记住的是原始数据类型的内存表示。 因此,在所有 Apple 设备上,整型变量都以以下格式存储 小端. 这意味着最低有效字节将在左侧,您将无法使用逐字节比较对整数进行排序。 例如,尝试使用从 0 到 511 的一组数字执行此操作将导致以下结果。

// value (hex dump)
000 (0000)
256 (0001)
001 (0100)
257 (0101)
...
254 (fe00)
510 (fe01)
255 (ff00)
511 (ff01)

为了解决这个问题,整数必须以适合字节比较器的格式存储在键中。 来自家庭的功能将有助于进行必要的转变。 hton* (特别是 htons 对于示例中的双字节数字)。

如您所知,在编程中表示字符串的格式是一个整体 故事. 如果字符串的语义以及在内存中用来表示它们的编码表明每个字符可能有多个字节,那么最好立即放弃使用默认比较器的想法。

要记住的第二件事是 对齐原则 结构字段编译器。 因为它们,在字段之间的内存中可以形成具有垃圾值的字节,这当然会破坏字节排序。 要消除垃圾,您必须以严格定义的顺序声明字段,牢记对齐规则,或者使用结构声明中的属性 packed.

通过外部比较器进行按键排序

密钥比较逻辑对于二进制比较器来说可能过于复杂。 众多原因之一是结构内部存在技术领域。 我将在我们已经熟悉的目录元素的键示例中说明它们的出现。

typedef struct NodeKey {​
    EntityId parentId;​
    uint8_t type;​
    uint8_t nameBuffer[256];​
} NodeKey;

尽管它很简单,但在绝大多数情况下它会消耗太多内存。 标题缓冲区为 256 字节,但平均文件和文件夹名称很少超过 20-30 个字符。

优化记录大小的标准技术之一是修剪它以适合其实际大小。 其本质是将所有变长字段的内容存储在结构末尾的缓冲区中,并将它们的长度存储在单独的变量中。按照这种方法,关键 NodeKey 通过以下方式进行转换。

typedef struct NodeKey {​
    EntityId parentId;​
    uint8_t type;​
    uint8_t nameLength;​
    uint8_t nameBuffer[256];​
} NodeKey;

此外,在序列化期间,未指定为数据大小 sizeof 整个结构,所有字段的大小都是固定长度加上缓冲区实际使用部分的大小。

MDB_val serialize(NodeKey * const key) {
    return MDB_val {
        .mv_size = offsetof(NodeKey, nameBuffer) + key->nameLength,
        .mv_data = (void *)key
    };
}

作为重构的结果,我们显着节省了按键占用的空间。 但由于技术领域 nameLength,默认的二进制比较器不再适用于键比较。 如果我们不将其替换为我们自己的,那么名称的长度将是排序中比名称本身更优先的因素。

LMDB 允许每个数据库都有自己的键值比较功能。 这是使用函数完成的 mdb_set_compare 严格在开业前。 出于显而易见的原因,数据库在其整个生命周期内都无法更改。 在输入端,比较器接收两个二进制格式的键,在输出端返回比较结果:小于 (-1)、大于 (1) 或等于 (0)。 伪代码为 NodeKey 看起来像那样。

int compare(MDB_val * const a, MDB_val * const b) {​
    NodeKey * const aKey = (NodeKey * const)a->mv_data;​
    NodeKey * const bKey = (NodeKey * const)b->mv_data;​
    return // ...
}​

只要数据库中的所有键都是同一类型,无条件地将它们的字节表示形式转换为键的应用程序结构的类型是合法的。 这里有一个细微差别,但将在“阅读记录”小节中稍微讨论一下。

值序列化

使用存储记录的键,LMDB 工作非常密集。 它们在任何应用程序操作的框架内相互比较,整个解决方案的性能取决于比较器的速度。 在理想情况下,默认的二进制比较器应该足以比较键,但如果你真的必须使用自己的,那么反序列化键的过程应该尽可能快。

数据库对记录(值)的值部分不是特别感兴趣。 它从字节表示到对象的转换仅在应用程序代码已经需要时才会发生,例如,将其显示在屏幕上。 由于这种情况相对较少发生,因此对这个过程的速度要求不是那么关键,并且在其实现中我们可以更加自由地关注便利性。例如,要序列化有关尚未下载的文件的元数据,我们使用 NSKeyedArchiver.

NSData *data = serialize(object);​
MDB_val value = {​
    .mv_size = data.length,​
    .mv_data = (void *)data.bytes​
};

但是,有时性能确实很重要。 例如,在保存有关用户云文件结构的元信息时,我们使用相同的对象内存转储。 生成其序列化表示的任务的亮点是目录的元素由类层次结构建模。

键值数据库LMDB在iOS应用中的辉煌与贫乏

它在C语言中的实现是将继承者的具体字段取出到单独的结构体中,并通过联合类型的字段来指定它们与基体的联系。 联合的实际内容通过类型技术属性指定。

typedef struct NodeValue {​
    EntityId localId;​
    EntityType type;​
    union {​
        FileInfo file;​
        DirectoryInfo directory;​
    } info;​
    uint8_t nameLength;​
    uint8_t nameBuffer[256];​
} NodeValue;​

添加和更新记录

序列化的键和值可以添加到商店中。 为此,使用函数 mdb_put.

// key и value имеют тип MDB_val​
mdb_put(..., &key, &value, MDB_NOOVERWRITE);

在配置阶段,可以允许或禁止存储库存储具有相同键的多条记录。 如果禁止重复键,则在插入记录时,可以确定是否允许更新已存在的记录。 如果磨损只能由于代码中的错误而发生,那么您可以通过指定标志来确保不会发生磨损 NOOVERWRITE

阅读记录

LMDB中读取记录的函数是 mdb_get. 如果键值对由先前转储的结构表示,则此过程如下所示。

NodeValue * const readNode(..., NodeKey * const key) {​
    MDB_val rawKey = serialize(key);​
    MDB_val rawValue;​
    mdb_get(..., &rawKey, &rawValue);​
    return (NodeValue * const)rawValue.mv_data;​
}

所提供的清单显示了通过结构转储进行序列化如何让您不仅在写入时而且在读取数据时都摆脱动态分配。 派生自函数 mdb_get 该指针精确地查看数据库存储对象字节表示的虚拟内存地址。 事实上,我们得到了一种 ORM,几乎免费提供非常高的数据读取速度。 鉴于该方法的所有优点,有必要记住与之相关的几个功能。

  1. 对于只读事务,指向值结构的指针保证仅在事务关闭之前保持有效。 如前所述,由于写时复制原则,对象所在的 B 树页保持不变,只要至少有一个事务引用它们。 同时,一旦与它们关联的最后一个事务完成,页面就可以重新用于新数据。 如果对象有必要在创建它们的事务中存活下来,那么它们仍然必须被复制。
  2. 对于读写事务,指向结果结构值的指针仅在第一个修改过程(写入或删除数据)之前有效。
  3. 尽管结构 NodeValue 不是完整的,而是经过修剪的(请参阅“通过外部比较器排序键”小节),通过指针,您可以轻松访问其字段。 最主要的是不要取消引用它!
  4. 在任何情况下都不能通过接收到的指针修改结构。 所有更改必须仅通过方法进行 mdb_put. 然而,尽管如此,它还是行不通,因为这个结构所在的内存区域是以只读模式映射的。
  5. 将文件重新映射到进程的地址空间,例如,使用函数增加最大存储大小 mdb_env_set_map_size 一般而言,使所有事务和相关实体完全无效,尤其是指向读取对象的指针。

最后,还有一个功能如此阴险,以至于揭露其本质不适合仅增加一点。 在关于 B 树的章节中,我给出了它的页面在内存中的组织结构图。 由此可见,带有序列化数据的缓冲区的起始地址可以是绝对任意的。 因此,指向它们的指针,在结构中获得 MDB_val 并转换为指向结构的指针通常是未对齐的。 同时,一些芯片的架构(在 iOS 中是 armv7)要求任何数据的地址是机器字大小的倍数,或者换句话说,是系统的位数(对于 armv7,这是 32 位)。 换句话说,像这样的操作 *(int *foo)0x800002 在他们身上等同于逃跑并导致死刑 EXC_ARM_DA_ALIGN. 有两种方法可以避免这种悲惨的命运。

第一个是事先将数据复制到已知对齐的结构中。 例如,在自定义比较器上,这将反映如下。

int compare(MDB_val * const a, MDB_val * const b) {
    NodeKey aKey, bKey;
    memcpy(&aKey, a->mv_data, a->mv_size);
    memcpy(&bKey, b->mv_data, b->mv_size);
    return // ...
}

另一种方法是提前通知编译器具有键和值的结构可能无法使用属性对齐 aligned(1). 在 ARM 上可以达到同样的效果 实现 并使用 packed 属性。 考虑到它也有助于优化结构占用的空间,这种方法在我看来更可取,虽然 приводит 增加数据访问操作的成本。

typedef struct __attribute__((packed)) NodeKey {
    uint8_t parentId;
    uint8_t type;
    uint8_t nameLength;
    uint8_t nameBuffer[256];
} NodeKey;

范围查询

为了遍历一组记录,LMDB 提供了游标抽象。 让我们使用我们已经熟悉的包含用户云元数据的表示例来了解如何使用它。

作为显示目录中文件列表的一部分,您需要找到与其子文件和文件夹关联的所有键。 在前面的小节中,我们对键进行了排序 NodeKey 以便它们首先按其父目录 ID 排序。 因此,从技术上讲,获取文件夹内容的任务被简化为将光标放在一组具有给定前缀的键的上边界,然后迭代到下边界。

键值数据库LMDB在iOS应用中的辉煌与贫乏

您可以通过顺序搜索找到“额头上”的上限。 为此,将光标置于数据库中整个键列表的开头,然后递增,直到具有父目录标识符的键出现在其下方。 这种方法有两个明显的缺点:

  1. 搜索的线性复杂性,尽管如您所知,在一般的树中,特别是在 B 树中,它可以在对数时间内完成。
  2. 徒劳的是,所需页面之前的所有页面都从文件中提升到主内存,这是非常昂贵的。

幸运的是,LMDB API提供了一种有效的方法来初始定位游标。为此,您需要形成这样一个键,其值肯定小于或等于位于区间上界的键. 比如对于上图中的列表,我们可以做一个key,其中的字段 parentId 将等于 2,其余全部用零填充。 这样一个部分填充的键被馈送到函数的输入 mdb_cursor_get 指示操作 MDB_SET_RANGE

NodeKey upperBoundSearchKey = {​
    .parentId = 2,​
    .type = 0,​
    .nameLength = 0​
};​
MDB_val value, key = serialize(upperBoundSearchKey);​
MDB_cursor *cursor;​
mdb_cursor_open(..., &cursor);​
mdb_cursor_get(cursor, &key, &value, MDB_SET_RANGE);

如果找到了键组的上限,则我们对其进行迭代,直到我们遇到或该键与另一个 parentId, 否则钥匙根本用不完。

do {​
    rc = mdb_cursor_get(cursor, &key, &value, MDB_NEXT);​
    // processing...​
} while (MDB_NOTFOUND != rc && // check end of table​
         IsTargetKey(key));    // check end of keys group​​

很棒的是,作为使用 mdb_cursor_get 进行迭代的一部分,我们不仅获得了键,还获得了值。 如果,为了满足选择条件,除其他事项外,有必要检查记录的值部分的字段,那么无需额外的手势即可完全访问它们。

4.3. 表之间的建模关系

迄今为止,我们已经设法考虑了设计和使用单表数据库的所有方面。 我们可以说一个表是由相同类型的键值对组成的一组排序记录。 如果将键显示为矩形,将其关联的值显示为框,则会得到数据库的可视化图表。

键值数据库LMDB在iOS应用中的辉煌与贫乏

然而,在现实生活中,鲜血很少能过得去。 通常在数据库中,首先,需要有多个表,其次,以不同于主键的顺序在其中进行选择。 最后一节专门讨论它们的创建和互连问题。

索引表

云应用程序有一个“图库”部分。 它显示来自整个云的媒体内容,按日期排序。 为了实现这种选择的最佳实施,您需要在主表旁边创建另一个具有新型键的表。 它们将包含一个包含文件创建日期的字段,该字段将作为主要排序标准。 因为新键引用与基础表中的键相同的数据,所以它们被称为索引键。 它们在下图中以橙色突出显示。

键值数据库LMDB在iOS应用中的辉煌与贫乏

为了在同一数据库中将不同表的键彼此分开,所有这些都添加了一个额外的技术字段tableId。 通过使其成为排序的最高优先级,我们将首先按表对键进行分组,并且已经在表内 - 根据我们自己的规则。

索引键引用与主键相同的数据。 通过将主键的值部分的副本与其相关联来直接实现此属性,从多个角度来看都不是最优的:

  1. 从空间占用的角度来看,元数据可以相当丰富。
  2. 从性能的角度来看,因为在更新节点元数据时,您将不得不覆盖两个键。
  3. 从代码支持的角度来看,毕竟,如果我们忘记更新其中一个键的数据,我们会在存储中出现数据不一致的微妙错误。

接下来,我们将考虑如何消除这些缺点。

表间关系的组织

该模式非常适合将索引表与主表链接起来 “键值”. 顾名思义,索引记录的值部分是主键值的副本。 这种方法消除了上面列出的与存储主记录的值部分的副本相关的所有缺点。 唯一的费用是要通过索引键获取值,您需要对数据库进行 2 次查询,而不是一次。 示意性地,生成的数据库模式如下所示。

键值数据库LMDB在iOS应用中的辉煌与贫乏

组织表之间关系的另一种模式是 “冗余密钥”. 它的本质是向键添加额外的属性,这些属性不是排序所需要的,而是重新创建关联键所需要的。在 Mail.ru Cloud 应用程序中有使用它的真实示例,但是,为了避免深入研究具体 iOS 框架的上下文,我会举一个虚构的,但更容易理解的例子。

云移动客户端有一个页面,显示用户与其他人共享的所有文件和文件夹。 由于此类文件相对较少,并且有很多与它们相关的公开信息(授予谁访问权限,具有什么权利等),因此用价值部分来负担它是不合理的主表中的记录。 但是,如果您想离线显示此类文件,那么您仍然需要将其存储在某个地方。 一个自然的解决方案是为它创建一个单独的表。 在下图中,它的键以“P”为前缀,“propname”占位符可以替换为更具体的值“public info”。

键值数据库LMDB在iOS应用中的辉煌与贫乏

为此创建新表的所有唯一元数据都被移动到记录的值部分。 同时,我不想复制已经存储在主表中的文件和文件夹的数据。 相反,冗余数据以“节点 ID”和“时间戳”字段的形式添加到“P”键。 多亏了它们,你可以构建一个索引键,通过它你可以获得主键,最后你可以通过它获得节点的元数据。

结论

我们积极评价 LMDB 实施的结果。 在此之后,应用程序冻结的数量减少了 30%。

键值数据库LMDB在iOS应用中的辉煌与贫乏

所做工作的结果在 iOS 团队之外得到了回应。 目前,Android 应用程序中的一个主要“文件”部分也已切换到使用 LMDB,其他部分也在进行中。 实现了key-value存储的C语言,对于C++语言初步实现围绕它的应用程序跨平台绑定起到了很好的帮助。 为了将生成的 C++ 库与 Objective-C 和 Kotlin 中的平台代码无缝连接,使用了代码生成器 吉尼 来自 Dropbox,但那是另一回事了。

来源: habr.com

添加评论