你在Firebase Realtime Database上,原子方式递增一个数值的速度有多快?

25

这里是firebaser

最近我在推特上发推,介绍Firebase实时数据库中的新increment()操作符,但我的一个团队成员问到increment()有多快。

我也一直在思考:使用increment(1)操作符可以有多快?与使用事务来递增值相比如何?


同样的规则也适用于Firestore吗?在写批处理中使用增量调用可以确保数据无误地写入吗?(假设每个文档限制为1次写入/秒) - Ayyappa
1个回答

35

TL;DR

以下是我测试的案例:

  1. 使用 transaction 调用增加一个值:

    ref.transaction(function(value) {
      return (value || 0) + 1;
    });
    
    使用新的increment操作符来增加一个值:
  2. ref.set(admin.database.ServerValue.increment(1));
    

虽然增量操作更快并不会让人感到惊讶,但实际上速度有多快呢?

结果:

  • 使用事务,我每秒可以增加大约60-70个值。
  • 使用“increment”运算符,我每秒可以增加大约200-300个值。

如何进行测试并得出这些数字

我在我的2016款MacBook Pro上运行了测试,并将以上内容包装在一个简单的Node.js脚本中,该脚本使用客户端Node SDK。操作的包装脚本也非常基本:

timer = setInterval(function() {
    ... the increment or transaction from above ...
}, 100);

setTimeout(function() {
  clearInterval(timer);
  process.exit(1);
}, 60000)

所以:每秒增加值10次,1分钟后停止。我使用以下脚本生成了此过程的实例:

for instance in {1..10}
do
  node increment.js &
done

这将使用increment操作符运行10个并行进程,每秒增加值10次,总共每秒增加100次。然后我更改了实例的数量,直到“每秒增量”达到峰值。

我随后在jsbin上编写了一个小脚本script on jsbin监听值,并通过简单的低通移动平均滤波器确定每秒增量。我在这里遇到了一些麻烦,所以不确定计算是否完全正确。根据我的测试结果,它们已经足够接近,但如果有人想编写一个更好的观察者:请随意。 :)

关于测试需要注意的事项:

  1. 我不断增加进程数,直到“每秒增量”似乎达到最大值,但我注意到这与我的笔记本电脑风扇全速运转相吻合。因此,我可能没有找到服务器端操作的真正最大吞吐量,而是考虑了我的测试环境和服务器的组合。因此,当您尝试重现此测试时,很可能(实际上很可能)会得到不同的结果,尽管当然increment吞吐量应始终显着高于transaction。无论您得到什么结果,请分享它们。:)
  2. 我使用了客户端Node.js SDK,因为这是最容易使用的。使用不同的SDK可能会产生稍微不同的结果,尽管我希望主要的SDK(iOS、Android和Web)与我的结果相当接近。
  3. 两个不同的团队成员立即问我是否在单个节点上运行了此操作,或者是否同时增加了多个值。同时并行增加多个值可以显示是否存在系统范围的吞吐量瓶颈,或者是否是特定于节点的(我期望是后者)。
  4. 如前所述:我的测试工具并不特别,但是我的jsbin观察器代码尤其值得怀疑。如果有人感觉编写相同数据的更好的观察程序很棒。

事务和增量操作符在内部如何工作

要理解transactionincrement之间的性能差异,真正有帮助的是了解这些操作在内部如何工作。对于Firebase实时数据库,“内部”意味着在客户端和服务器之间通过Web套接字连接发送的命令和响应。

事务在Firebase中使用比较并设置方法。每当我们开始像上面这样的事务时,客户端都会猜测节点的当前值。如果它以前从未看到过该节点,则猜测为null。它调用我们的事务处理程序,并将该猜测返回给我们的代码,然后我们的代码返回新值。客户端将猜测和新值发送到服务器,服务器执行比较并设置操作:如果猜测是正确的,请设置新值。如果猜测不正确,则服务器拒绝操作并将实际当前值返回给客户端。

在完美的情况下,初始猜测是正确的,并且立即将值写入服务器的磁盘上(之后发送到所有侦听器)。在流程图中,它看起来像这样:

            Client            Server

               +                   +
 transaction() |                   |
               |                   |
        null   |                   |
     +---<-----+                   |
     |         |                   |
     +--->-----+                   |
         1     |     (null, 1)     |
               +--------->---------+
               |                   |
               +---------<---------+
               |     (ack, 3)      |
               |                   |
               v                   v

但如果节点在服务器上已经有一个值,它会拒绝写入并发送实际的值回来,客户端会再次尝试:

            Client            Server

               +                   +
 transaction() |                   |
               |                   |
        null   |                   |
     +---<-----+                   |
     |         |                   |
     +--->-----+                   |
         1     |                   |
               |     (null, 1)     |
               +--------->---------+
               |                   |
               +---------<---------+
               |     (nack, 2)     |
               |                   |
         2     |                   |
     +---<-----+                   |
     |         |                   |
     +--->-----+                   |
         3     |      (2, 3)       |
               +--------->---------+
               |                   |
               +---------<---------+
               |      (ack, 3)     |
               |                   |
               |                   |
               v                   v

这并不太糟糕,只多了一次往返。即使Firebase使用悲观锁定,也需要这个往返,所以我们没有失去任何东西。

问题在于如果多个客户端同时修改相同的值,就会出现所谓的节点争用,它看起来像这样:

            Client            Server                Client
               +                   +                   +
 transaction() |                   |                   |
               |                   |                   | transaction()
        null   |                   |                   |
     +---<-----+                   |                   |  null
     |         |                   |                   +--->----+
     +--->-----+                   |                   |        |
         1     |                   |                   +---<----+ 
               |     (null, 1)     |                   |   1
               +--------->---------+    (null, 1)      |
               |                   |---------<---------+
               +---------<---------+                   |
               |     (nack, 2)     |--------->---------+
               |                   |     (nack, 2)     |
         2     |                   |                   |
     +---<-----+                   |                   |   2
     |         |                   |                   |--->----+
     +--->-----+                   |                   |        |
         3     |      (2, 3)       |                   |---<----+ 
               +--------->---------+                   |   3
               |                   |                   |
               +---------<---------+                   |
               |      (ack, 3)     |       (2, 3)      |
               |                   |---------<---------+
               |                   |                   |
               |                   |--------->---------+
               |                   |    (nack, 3)      |
               |                   |                   |   3
               |                   |                   |--->----+
               |                   |                   |        |
               |                   |                   |---<----+ 
               |                   |                   |   4
               |                   |       (3, 4)      |
               |                   |---------<---------+
               |                   |                   |
               |                   |--------->---------+
               |                   |     (ack, 4)      |
               |                   |                   |
               v                   v                   v

TODO: 更新上面的图表,以使服务器上的操作不重叠。

第二个客户端必须重新尝试其操作,因为在它的第一次尝试和第二次尝试之间,服务器端的值已被修改。我们有越来越多的客户端写入此位置,就越有可能看到重试。Firebase客户端会自动执行这些重试,但经过多次重试后,它会放弃并向应用程序引发Error:maxretry异常。

这就是为什么我每秒只能增加计数器60-70次的原因:如果有更多的写入,节点上的争用就太大了。

增量操作本质上是原子性的。您告诉数据库:无论当前值是什么,都将其增加x。这意味着客户端永远不必知道节点的当前值,因此它也不可能猜错。它只是告诉服务器要做什么。

使用increment时,我们具有多个客户端的流程图如下:

            Client            Server                Client

               +                   +                   +
  increment(1) |                   |                   |
               |                   |                   | increment(1)
               |  (increment, 1)   |                   |
               +--------->---------+   (increment, 1)  |
               |                   |---------<---------+
               +---------<---------+                   |
               |      (ack, 2)     |--------->---------+
               |                   |     (ack, 3)      |
               |                   |                   |
               v                   v                   v

这两个最后的流程图的长度已经足以解释为什么在这种情况下increment如此快了:该操作是为此而设计的,因此电线协议更接近于我们试图实现的目标。这种简单性导致在我的简单测试中,性能差异达到3倍至4倍,在生产场景中可能会更高。

当然,事务仍然有用,因为除了增量/减量操作之外还有许多原子操作。


1
你能在增加(1)之前检查值是否为空吗?我知道这会像交易一样,但我想利用速度。类似于increaseIfNotNull(1)的东西会很棒。 - feco
1
不,这确实需要一个事务。 - Frank van Puffelen
2
@FrankvanPuffelen 感谢您的详细帖子!非常感激。如果引用路径不可用会发生什么?它会自动创建并递增吗?顺便问一下,您是手动输入那个大流程图的吗? - Ayyappa
3
如果尚不存在任何值,则交易和增量的初始值均为0。是的,这是手动工作,所以额外的赞总是受欢迎的。 :) - Frank van Puffelen
如果客户端在发送“(increment, 1)”后不知何故失去连接,无法接收“(ack, 2)”,那该怎么办呢?在我的测试中,它只是简单地重试,有时会导致重复递增。这种情况在网络连接较差的情况下经常发生。:(这是一种预期行为吗? - Geraldo Neto
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接