以原子方式递增 Firebase 实时数据库上的值的速度有多快?

IT技术 javascript node.js firebase-realtime-database
2021-02-06 12:18:20

火力士在这里

当我最近在 Firebase 实时数据库中发布关于新运算符的推文时increment(),一位队友问它有多快increment()

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

1个回答

TL; 博士

我测试了这些案例:

  1. 通过transaction调用增加一个值

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

    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. 如前所述:我的测试工具没什么特别的,但我的 jsbin 观察者代码特别可疑。如果有人想在相同的数据上编写一个更好的观察者,那就太好了。


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

要了解transaction之间的性能差异increment,了解这些操作在幕后是如何工作的真的很有帮助。对于 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操作是为此而设计的,因此有线协议更接近地代表了我们要完成的任务。而这种简单性仅在我的简单测试中就导致了 3 到 4 倍的性能差异,在生产场景中甚至可能更大。

当然事务仍然有用,因为原子操作不仅仅是增加/减少。

@FrankvanPuffelen 感谢您的详细帖子!真的很感激。如果参考路径不可用会发生什么?它会自动创建并增加它吗?顺便说一句,你是手动输入那个大流程图吗?
2021-03-15 12:18:20
如果尚不存在任何值,则初始值0既用于事务又用于增量。是的,这是手动工作,所以总是很感激额外的支持。:)
2021-03-24 12:18:20
你能在增加(1)之前检查值是否为空?我知道这会像交易一样,但我想利用速度。像increaseIfNotNull(1) 这样的东西会很棒。
2021-03-25 12:18:20
这非常方便。一个建议:使 'increment(delta)' 接收一个函数,该函数获取当前值并返回一个布尔值。这将使这个新功能更加强大。有时您可能只想递减或递增直到某个天花板或地板
2021-04-01 12:18:20
不,这确实需要交易。
2021-04-07 12:18:20