Actual的核心是一个自定义同步引擎。最近,我实现了完全的端到端加密(还没有发布),它激励我审计整个过程的性能。将来我会写更多关于使用CRDT进行同步的博客,但现在我想谈谈PostgreSQL的一个特性,它使性能提高了9-10倍。
Actual完全是一个本地应用,同步在后台进行(使用CRDT)。这意味着服务器非常简单,它所要做的就是为客户端存储和获取消息。处理同步的整个代码只有200行左右的JavaScript代码。
我们需要处理大量消息才能保持快速同步。事实上,在进行这项工作时,发生了一些奇怪的事情:一个新用户在一天内产生了169,000条消息。这是一个很大幅度的异常值。例如,将1000个事务导入系统将生成约6000条消息,虽然这是合理的,但仍高于每个用户每天的平均消息数。我相信他们是通过使用API尝试批量导入大量数据来做到这一点的,我们对此有不同的API。尽管如此,我想,如果我以169,000为基准会怎么样?
我试图通过系统发送169,000条消息,但服务器崩溃了。请求超时,服务器仍在处理消息,导致其他一切变慢。我立刻就知道问题出在哪里。
它存储标记有时间戳的小二进制BLOB和它们所属的同步组。
服务器试图插入这么多行时会卡住。不幸的是,在添加消息时,我们不能简单地使用一组INSERT语句执行一个查询。我们的CRDT有几个限制:
我们需要根据是否添加了消息来更新Merkle Trie。
解决第一个问题很容易。因为我们将时间戳作为主键,所以我们可以插入到MESSAGES_BINARY(.)。值(.)。在冲突中什么都不做。ON CONFLICT子句告诉它在存在冲突时不要执行任何操作,并复制主键上的冲突。
更大的问题是#2。我们需要插入的结果才能知道是否插入了行。如果它已插入,我们还需要更新Merkle trie,如下所示:
非常重要的是,系统中的每个时间戳只插入Merkle Trie一次。TRIE负责保证系统中的一致性,并维护内容的散列。如果您没有为每个时间戳添加一次且仅添加一次,则散列(因此验证)是错误的。
更新数据库的整个代码如下所示(使用node-postgres上的一些抽象):
这大部分是真正的代码,唯一的区别是我们还会在失败时回滚事务。这是非常重要的,这发生在事务中,并且消息和Merkle Trie都是原子更新的。同样,Merkle trie验证消息内容,并且它们必须始终同步。如果没有同步错误,用户将看到这些错误。
问题一目了然:我们分别为每条消息执行INSERT查询。在我们的极端情况下,我们试图执行169,000条语句。PostgreSQL位于不同的服务器上(但很接近),仅进行如此多的网络调用就会降低性能,更不用说PG开销了。
我知道这很慢,但我没有意识到速度有多慢。让我们测试一个更合理数量的实际结束的消息。4000条消息需要6.9秒才能完成。这只是分析上面的代码,没有考虑网络传输。
这是一个巨大的用户体验问题。在此过程中,用户坐在那里观看同步图标旋转和旋转和旋转…。
我们可以检查哪些消息已经存在并将其过滤掉,但这将需要代价高昂的SELECT查询(可能需要分解查询,因为您不想传递169,000个参数)。我的另一个想法是插入具有唯一编号的消息,然后我可以查询哪些消息具有该唯一编号,因为只有新的消息才会具有该唯一编号。
关系数据库(与键-值相比)的优点在于,它们往往对这类问题有健壮的解决方案。必须有办法做到这一点,因为这种模式并不深奥。我学到的第一件事是如何用一条INSERT语句插入多行:
这比将多个INSERT语句连接到一个查询中要好,因为它可能更快,而且最重要的是,我们有希望得到关于发生了什么的信息。
在搜索文档时,我发现了INSERT语句的RETURNING子句。默认情况下,PostgreSQL在执行INSERT时不返回任何内容,除了更改的行数。但是,如果您确实插入到表(值)值(1)中,返回id,它将返回新行的id。
最大的问题是这是否如我所愿:当使用具有多个项的INSERT语句并且冲突时不执行任何操作时,它是否只返回实际插入的项的ID数组?我怀疑它可能会返回所有项目的ID,即使它们冲突(并且没有插入)。
我编写了一个快速脚本来测试该行为,并且:答对了。归来就是我想要的。下面是一个测试:
执行此查询时,如果时间戳为1的消息已经存在,则只会插入2和3,并返回数组[{id:';2';},{id:';3';}]。宾果,邦戈,邦戈。
返回允许我将所有这些工作减少到单个查询中。我可以使用结果确切地知道添加了哪些消息,并适当地更新Merkle trie。
新代码如下所示。我仍在审核PG-Promise助手的安全性:
您没看错:以前处理40000条消息需要59秒,现在只需要7.2秒。我们能够处理10倍数量的消息!
UPDATE:SQL生成过程中有一个错误,导致每条数据都比需要的大(二进制BLOB编码错误),因此生成的INSERT语句大约小了25%,现在处理40000条消息只需要大约5秒。
你可能想知道我们的基准169,000发生了什么?嗯,结果还是有上限的。这一次,我们达到了PostgreSQL的限制,没有快速解决的办法。
在处理169,000个数据时,第一个问题是节点崩溃。pg-Promise中的pgp.helpers.insert帮助器在传递该数量的项目时会导致崩溃。不知道确切的原因,但这不值得调查,因为还有其他问题。
首先,169,000个项目需要21Mb的上传有效负载。这是不可接受的,因为失败的可能性太大了。
如果我们将基准降低到100,000,我们会得到更远的结果。生成的多值INSERT语句是一个72MB的字符串。尝试执行这个庞大的查询字符串,简单地使用…。挂起整个服务器。我不确定问题出在哪里,或者PostgreSQL设置是否可以调整来处理它,但同样,我们根本无法处理这种大小的东西。
更好的解决方案是寻呼消息同步,并对每个请求设置上限。合理的限制似乎是40,000条消息。在这个大小下,上传的有效负载是5MB,处理需要7秒(它仍然会生成一个30MB的查询字符串,PostgreSQL会很高兴地处理它!)。要处理169,000条消息,我们将发送5个请求,每个请求40,000条消息(或任何剩余的消息)。处理所有这些请求的总时间将是169000/40000*7或29.6秒。只要我们向用户显示进度,对于如此巨大的变更集来说还不错。
这是最坏的情况。我们通常不处理以秒为单位的时间范围。最常见的同步操作处理10-200条消息,这些消息在20ms内同步。这绝对是最糟糕的情况,比如有人每秒进行数千次更改,然后试图稍后同步,这种情况几乎从来没有发生过。但是如果用户滥用API,我们应该能够处理它。
与上述问题无关的是,我想做最后一个改进。由于Merkle trie存储在数据库中,因此服务器需要获取它、更改它,然后将其存储回去。这意味着当我们正在处理Trie时,没有其他连接可以同时更改Trie。
当前的解决方案使用钝锤来解决它:互斥。互斥锁围绕同步逻辑锁定每个用户,因此用户可以并发同步,但如果同一用户在多个设备上同步,则它们将被序列化。这是必要的,以避免竞赛条件,同时更新Merkle Trie(请记住,这是非常重要的,它保持机智)。
看起来事务的可序列化隔离级别可以解决这个问题。您使用BEGIN TRANSACTION ISOLATION级别SERIALIZABLE启动事务,如果PostgreSQL检测到它们之间可能出现争用条件,它将中止事务。我不能100%确定它是否适用于我在同一事务中读取某些内容并稍后将其写回的用例,但如果可以,如果事务失败,我会重新启动它。因此,每个同步过程都会被序列化。如果你知道这件事,我很乐意听到你的消息。
我还没有把169,000个基准测试放在同步的客户端部分。客户端在同步时会做更多的工作,因为有很多其他的事情,比如撤销挂钩到系统中,所以仍然有很多需要优化的地方。我怀疑它现在是否能处理169,000条消息,但我确信它能处理40,000条消息。我认为将同步分成40,000个块的想法会很有效,而且很容易向用户显示到目前为止已经处理了多少条消息的反馈。
无论如何,针对极端情况进行优化是很棒的。这里9-10倍的改进可以渗透到占请求95%的小得多的案例中。现在,以前需要100毫秒的请求将需要大约10毫秒。太棒了!
致力于实际工作并引导它。我写的是我一路走来所学到的东西。