移动数据库优化:领域与SQLite

在这个故事中,我将告诉您我们如何开发结构化方法来解决复杂的技术问题,并成功地将应用程序的性能提高了30倍。

我们开始收集具有技术要求的新企业应用程序的开发。 最初,我们发现我们的应用程序应包含以下主要功能:

  • 包含图表的报告
  • 一个简单的任务跟踪系统
  • 公司全体员工名单

所有这些数据也应可供脱机使用。 听起来不太难,对吧? 我们是这样认为的。 我们基于JSON进行了客户端与服务器之间的交互,并基于SQLite进行了脱机数据在移动设备上的存储,从而获得了最初的技术堆栈。 在iOS上,我们使用了几年前非常流行的CoreData包装器MagicalRecord。 今天,这绝对是一个错误的选择,因为它是用Objective-C编写的,并且在过去3年中没有任何更新。 但是,在2014年,它是免费的,开源的,并且在GitHub上拥有1万颗星,但是主要原因是缺少其他选择。 在Android上,出于相同的原因,我们决定使用ORMLite。

在开发过程的早期,一切都很好。 我们的开发服务器上有一些演示报告和数十名员工。 但是,在将产品卖给第一个客户之后,我们就遇到了严重的性能问题。 后端团队的同事将客户生产数据库的一部分导入了我们的测试环境。 而且我们发现我们的应用尚未准备好与20万名员工一起使用。 下载完整列表花费了5到10分钟,滚动性能很差,搜索根本不起作用。 这不是我们最终用户所期望的。 另一个问题是报告。 其中一些包含具有数百万个点的图表,因此我们遇到了同样的问题:两个平台上的下载速度缓慢且性能不佳。

我们试图进行一些修复,将分页引入客户端-服务器请求并将批处理写入本地数据库,但是我们无法将性能提高得足够高。 对我们来说幸运的是,我们的销售部门与客户签订了长达6个月的合同,第一部分是更新一些内部系统,因此我们需要一两个月的时间来解决问题。 我们了解到,我们需要对发生的问题进行全面研究,而不是编写混乱的错误修复程序。 下面我将详细介绍我们的方法。

这项研究

我们从优化员工名单绩效开始。 经过几次快速修复错误的失败尝试之后,我们决定将下载员工列表并将其显示在UI中的过程分成较小的步骤,并测量每个步骤的持续时间,以找出需要解决的问题。

最后,我们发现等待服务器响应(30秒)并写入本地数据库(5-10m)是整个过程中最长的两个部分,而其他部分仅花费了几百毫秒,因此不需要注意。

服务器响应

在进行任何优化之前,我们只需要下载一次完整的员工列表即可。 之所以选择这种方法,是因为此列表的任何一部分对最终用户都没有任何价值。 没有人希望看到姓氏以字母A开头的员工。我们的用户需要完整的列表才能访问所有联系人并执行离线搜索。

我们还具有喜欢的联系人功能,可通过我们的服务器在用户设备之间进行同步。 首先,我们将喜欢的数据移动到本地数据库中的单独请求和单独表中。 那并没有给我们带来巨大的性能提升,而是让我们在服务器端启用完整员工列表的内存存储。

响应时间优化的最后也是最重要的部分是引入增量更新。 如上所述,我们在服务器端将完整的员工列表存储在内存中。 它将响应时间从大约30秒减少到仅1-2秒,由于网络延迟,我们无法再将其缩短。 与后端和销售团队讨论了我们的解决方案之后,我们还发现员工名单通常每天仅可以更新一次,因此无需进行更频繁的更新。 因此,为了减少服务器端的负担并加快移动客户端上的数据库写入速度,我们引入了增量更新。 首次安装后,我们的应用程序立即下载完整列表,但第二天它要求服务器进行一次小的增量更新,以提供最新数据应用程序的版本(时间戳)。

本地数据库优化

增量更新极大地减少了员工列表更新时间。 但是,我们仍然有两个问题:

  • 由于SQLite的写入速度,初始加载太慢
  • 由于完全相同的原因,应用少量增量更新(例如,在两周不活动之后)也花费了很多时间

在批量编写没有提高性能之后,我们决定尝试其他方法。 我们有一个假设,在我们的案例中,SQLite或MagicalRecord都是瓶颈。 因此,我们提出了两种可能的解决方案:

  • 切换ORM
  • 与其他数据库切换SQLite

我们没有找到MagicalRecord的替代品,因此决定使用CoreData。 作为SQLite的替代品,我们使用了Realm。 然后,我们开发了一个综合测试来衡量200K实体的书写性能 。 结果几乎相同。 这就是为什么我们还决定将MagicalRecord包含到我们的测试中的原因。 我们惊讶地发现所有3个测试都运行了几乎相同的时间。 在这里,我们终于找到了问题:在实体之间建立关系。 在我们的综合测试中,我们实现了对单个表的写入,而在生产应用程序中,我们有2个具有关联关系的表。 见下图:

每个员工大约有3个联系人:电子邮件,手机和办公电话。 因此,我们需要将20万名员工写入一个表,将60万个联系人写入另一个表,并在这两个表之间创建60万个关系。

我们更新了综合测试,发现CoreData(或SQLite本身)处理关系的速度非常慢 。 从CoreData切换到Realm,我们最多可以实现30倍的性能提升。 这样做的主要原因是Realm的非SQL性质。 您可以在此处找到更多技术细节和说明。 参见下图:

搜索效果

因此,我们能够实现出色的下载性能,从而将初始加载时间从几分钟减少到仅15秒。 滚动现在也很流畅。 但是,我们仍然对搜索性能不满意。 在某些情况下,要花费几分钟才能显示所有搜索结果。 下面我将向您解释为何如此复杂。

以下是2个不同表中所有搜索字段的列表:

需要对名字,中间名,姓氏,职位,公司,部门,电子邮件和电话这三者的任意组合进行全文搜索。 记住,我们的数据库中有20万名员工和近60万名联系人(包括电子邮件和电话)。 我们必须为每个用户的输入运行8个搜索查询。 处理数据库的最终响应花了太多时间。 另一个并发症是西里尔字母的广泛使用。 所有名称,职位,公司和部门均仅限西里尔文。 电子邮件是拉丁字符和数字的混合体,而电话仅是数字。 我将跳过有关电话分机的更详细的信息。

首先,我们决定将最小搜索查询限制为3个符号。 然后,我们对这些符号进行了一些基本分析。 如果有拉丁字符,则肯定是一个电子邮件地址。 因此,我们只运行一个查询,而不是运行8个查询。 我们对电话号码也一样。 但是,有些电子邮件包含数字,这就是为什么我们通过电子邮件和电话运行2个查询的原因。 通过这种优化,我们将搜索查询的数量减少了两倍。

我们仍然需要提高名称,职位,部门和公司名称的搜索性能。 在对不同网站上的最新出版物进行另一项研究之后,我们发现主要问题是不区分大小写的搜索。 由于只检查拉丁字符,例如a, à, á, â, ä, ã, æ, å, ā等字符,因此它对于仅使用拉丁字母的字符非常有效,而对所有其他字符则慢8到12倍。 有9个不同的A字母! 对于其他许多人也是一样。 因此,我们必须关闭不区分大小写的搜索,并在表中创建小写的字段。 根据特定的字词,我们的性能提高了8到12倍。

我们还利用我们对俄语文字的了解,删除了一些姓,名和姓氏搜索组合的情况。 用户通常使用7种组合之一:

  • 第一
  • 持续
  • 先到后
  • 最后的第一
  • 第一中
  • 第一中最后
  • 最后第一中

俄罗斯没有人会搜索“ Middle First或“ Middle Last组合。 该知识使我们可以删除例如在“中间名”字段上搜索单个单词的搜索。 通过此最终更新,我们又获得了1.5–2倍的性能提升。

我们创建了一个综合测试来衡量搜索效果。 它包含约100个不同的搜索查询,主要用于姓名和位置搜索,就像在现实生活中一样。 这是我们的结果:

应用所有新的搜索优化算法后,我们将搜索性能提高了23倍!

报告解决方法

在成功完成了雇员清单绩效改进之后,我们开始了向Realm的全面过渡。 最终,我们发现某些报告仍然运行缓慢。 我们在服务器端实现了相同的存储模型:将每个报告存储在4-5个不同的表中。 我们有5种不同的报告类型,每种都有不同的数据结构。 例如,其中一份报告由各章组成,各章包含图表,图表包含系列,而系列包含点。 我们创建了5个表来存储所有这些数据。 有些图表包含数百万个点,这确实是大量数据。

在进行时间测量研究之后,我们发现在这种情况下,我们的瓶颈再次是数据库。 但是,我们已经切换到Realm,这给我们带来了一些性能上的提高,实际上,我们没有其他数据库可以切换到。 我们发现最简单的解决方案是将映射的数据从服务器直接发送到UI,并在后台线程中写入DB以备将来使用。 它应该工作非常快,但是会造成体系结构混乱。 考虑一下,每次尝试从数据库中提取数据时,都需要将DB的托管对象映射到一些普通对象。 由于受管对象的行为,创建一些协议(接口)并不是可取的选择:它随时可能更改甚至删除,我们需要观察一下。 但是,普通对象将始终保持不变。

最后,我们发现,如果可以在UI中使用普通对象,则根本不需要托管对象。 我们可以将原始JSON数据直接存储在数据库中,并根据需要进行映射。 我们在报表表上仍然有一些字段,例如标题,更新日期,作者等,但是报表的正文不包含与数据库其他部分的任何关系,因此可以将其存储为原始JSON。 这种调整使我们提高了300(是,三百)倍的性能,并获得了惊人的用户体验。

得到教训

我们花了将近3周的时间来优化CoreData查询,但失败了。 但是,我们能够停止这些尝试并找到了更好的解决方案。 切换数据库非常困难。 完成此任务大约花了2个月的时间,但我仍然不确定是否可以更快地完成此任务。 在我们的iOS应用程序中,我们使用VIPER架构并实现了面向服务的方法,但是由于CoreData的本质(我们广泛使用NSFetchedResultsController来提高性能),我们在Interactors中仍然拥有部分服务(数据库)层。 但是,就VIPER而言,Interactor对使用的特定数据库适配器一无所知,这要求我们放弃NSFetchedResultsController使用。 由于性能我们无法承受。 是的,它是一个循环参考。🙂因此,我们决定将服务层的一部分留在Interactors中,用Realm通知机制替换NSFetchedResultsController 。 这是最困难的部分,我们根本没有更改数据库实体,只是用RealmObject替换了NSManagedObject继承。

我的iOS和Android团队都非常喜欢Realm。 它工作迅速,易于学习且易于使用。 与旧的ORMLite甚至是新的Room相比,它所需的代码少得多,尤其是在Android上。

我们学到了什么:

  • 在进行任何优化之前,请确保您知道需要改进的特定位置
  • 如果您不确定该怎么做,请不要尝试压缩所有性能
  • 做实验并尝试新技术
  • 始终为您的特定任务寻找合适的解决方案

也许Realm不能像对我们一样适合您的特定任务,但是在做出选择之前,请尝试对您的任务进行一些性能测试。 切换本地数据库的成本将始终大于运行一些测试的成本。

底线

领域通常会更快,比起CoreData快得多,这是因为它具有非SQL性质。 主要优点是非常快速地管理表之间的关系。 而且,与iOS和Android平台上的现有SQL ORM相比,使用起来容易得多。 但是,您可能会遇到一些错误,并且在更深的领域中缺少文档。 就个人而言,我将在下一个应用程序中使用Realm。