您在Firebase实时数据库上原子递增值的速度有多快



此处的火焰发生器

当我最近在推特上发布关于Firebase实时数据库中新的increment()操作员的消息时,一位队友问increment()有多快

我一直在想同样的问题:使用increment(1)可以以多快的速度递增一个值?这与使用事务来增加价值相比如何?

TL;DR

我测试了这些案例:

  1. 使用transaction调用增加值:

    ref.transaction(function(value) {
    return (value || 0) + 1;
    });
    
  2. 使用新的increment运算符增加一个值:

    ref.set(admin.database.ServerValue.increment(1));
    

增量更快的事实并不令人惊讶,但。。。多少钱?

结果:

  • 使用事务,我能够每秒增加大约60-70次值
  • 使用increment运算符,我能够每秒增加大约200-300次值

我是如何进行测试并获得这些数字的

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

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上写了一个小脚本来侦听值,并通过一个简单的低通移动平均滤波器来确定每秒的增量数量。我在这里遇到了一些麻烦,所以不确定计算是否完全正确。考虑到我的测试结果,他们已经足够接近了,但如果有人想写一个更好的观察者:请客串。:)

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

  1. 我一直在增加进程的数量,直到"每秒的增量"似乎达到了最大值,但我注意到这与我的笔记本电脑风扇全速运转相吻合。因此,我很可能没有找到服务器端操作的真正最大吞吐量,而是我的测试环境和服务器的结合。因此,当你试图重现这个测试时,你很可能(事实上也很可能)会得到不同的结果,尽管increment的吞吐量应该总是明显高于transaction。无论你得到什么结果:请分享。:)

  2. 我使用了客户端Node.js SDK,因为它最容易工作。使用不同的SDK可能会得到略有不同的结果,尽管我预计主要的SDK(iOS、Android和Web)与我得到的非常接近。

  3. 两个不同的团队成员立即问我是在一个节点上运行这个,还是并行地增加多个值。并行地增加多个值可能会显示是否存在系统范围的吞吐量瓶颈,或者它是否是特定于节点的(我预计)。

  4. 如前所述:我的测试工具并没有什么特别之处,但我的jsbinobserver代码特别可疑。如果有人想在相同的数据上编码出一个更好的观察者,那就太好了。


事务和增量运算符如何在后台工作

要了解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在这种情况下要快得多:increment操作就是为此而进行的,因此有线协议更接近于我们试图实现的目标。这种简单性仅在我的简单测试中就导致了3x-4倍的性能差异,在生产场景中可能会更大。

当然,事务仍然很有用,因为有比增量/减量多得多的原子操作。

最新更新