Ein Node.JS-Prozess läuft nur auf einem einzigen physischen Kern. Aus diesem Grund muss bei der Entwicklung eines skalierbaren Servers besondere Aufmerksamkeit geschenkt werden.
Da es einen stabilen Satz an APIs und die Entwicklung nativer Erweiterungen zur Verwaltung von Prozessen gibt, gibt es viele verschiedene Möglichkeiten, eine Node.JS-Anwendung zu entwerfen, die parallelisiert werden kann. In diesem Blogbeitrag vergleichen wir diese möglichen Architekturen.
In diesem Artikel wird auch das Compute-Cluster-Modul vorgestellt: eine kleine Node.JS-Bibliothek, mit der sich Prozesse einfach verwalten und verteiltes Second-Line-Computing implementieren lassen.
Es sind Probleme aufgetreten
In unserem Mozilla Persona-Projekt müssen wir in der Lage sein, eine große Anzahl von Anfragen mit unterschiedlichen Eigenschaften zu verarbeiten, deshalb haben wir versucht, Node.JS zu verwenden.
Um das Benutzererlebnis nicht zu beeinträchtigen, erfordert die von uns entwickelte „Interaktive“ Anfrage nur einen geringen Rechenverbrauch, bietet aber eine schnellere Reaktionszeit, sodass die Benutzeroberfläche nicht hängen bleibt. Im Vergleich dazu dauert die Verarbeitung eines „Batch“-Vorgangs etwa eine halbe Sekunde, und es kann aus anderen Gründen zu längeren Verzögerungen kommen.
Für ein besseres Design haben wir viele Lösungen gefunden, die unseren aktuellen Anforderungen entsprechen.
Unter Berücksichtigung von Skalierbarkeit und Kosten listen wir die folgenden Hauptanforderungen auf:
Durch die oben genannten Punkte können wir klar und zielgerichtet filtern
Option 1: Direkt im Hauptthread verarbeiten.
Wenn der Hauptthread Daten direkt verarbeitet, ist das Ergebnis sehr schlecht:
Sie können die Vorteile von Multi-Core-CPUs nicht voll ausnutzen. Bei interaktiven Anfragen/Antworten müssen Sie warten, bis die aktuelle Anfrage (oder Antwort) verarbeitet wird, was unelegant ist.
Der einzige Vorteil dieser Lösung besteht darin, dass sie einfach genug ist
function myRequestHandler(request, response) [ // Let's bring everything to a grinding halt for half a second. var results = doComputationWorkSync(request.somesuch); }
Wenn Sie in einem Node.JS-Programm mehrere Anfragen gleichzeitig bearbeiten und diese synchron verarbeiten möchten, geraten Sie in Schwierigkeiten.
Methode 2: Ob asynchrone Verarbeitung verwendet werden soll.
Wird es eine große Leistungsverbesserung geben, wenn asynchrone Methoden im Hintergrund verwendet werden?
Die Antwort lautet nicht unbedingt: Es kommt darauf an, ob die Ausführung im Hintergrund sinnvoll ist
Zum Beispiel in der folgenden Situation: Wenn die Leistung bei Verwendung von JavaScript oder lokalem Code im Hauptthread zur Durchführung von Berechnungen nicht besser ist als die synchrone Verarbeitung, müssen Sie zur Verarbeitung nicht unbedingt asynchrone Methoden im Hintergrund verwenden
Bitte lesen Sie den folgenden Code
function doComputationWork(input, callback) { // Because the internal implementation of this asynchronous // function is itself synchronously run on the main thread, // you still starve the entire process. var output = doComputationWorkSync(input); process.nextTick(function() { callback(null, output); }); } function myRequestHandler(request, response) [ // Even though this *looks* better, we're still bringing everything // to a grinding halt. doComputationWork(request.somesuch, function(err, results) { // ... do something with results ... });
}
关键点就在于NodeJS异步API的使用并不依赖于多进程的应用
方案三:用线程库来实现异步处理。
只要实现得当,使用本地代码实现的库,在 NodeJS 调用的时候是可以突破限制从而实现多线程功能的。
有很多这样的例子, Nick Campbell 编写的 bcrypt library 就是其中优秀的一个。
如果你在4核机器上拿这个库来作一个测试,你将看到神奇的一幕:4倍于平时的吞吐量,并且耗尽了几乎所有的资源!但是如果你在24核机器上测试,结果将不会有太大变化:有4个核心的使用率基本达到100%,但其他的核心基本上都处于空闲状态。
问题出在这个库使用了NodeJS内部的线程池,而这个线程池并不适合用来进行此类的计算。另外,这个线程池上限写死了,最多只能运行4个线程。
除了写死了上限,这个问题更深层的原因是:
内建线程机制的组件库在这种情况下并不能有效地利用多核的优势,这降低了程序的响应能力,并且随着负载的加大,程序表现越来越差。
方案四:使用 NodeJS 的 cluster 模块
NodeJS 0.6.x 以上的版本提供了一个cluster模块 ,允许创建“共享同一个socket”的一组进程,用来分担负载压力。
假如你采用了上面的方案,又同时使用 cluster 模块,情况会怎样呢?
这样得出的方案将同样具有同步处理或者内建线程池一样的缺点:响应缓慢,毫无优雅可言。
有时候,仅仅添加新运行实例并不能解决问题。
方案五:引入 compute-cluster 模块
在 Persona 中,我们的解决方案是,维护一组功能单一(但各不相同)的计算进程。
在这个过程中,我们编写了 compute-cluster 库。
这个库会自动按需启动和管理子进程,这样你就可以通过代码的方式来使用一个本地子进程的集群来处理数据。
使用例子:
const computecluster = require('compute-cluster'); // allocate a compute cluster var cc = new computecluster({ module: './worker.js' }); // run work in parallel cc.enqueue({ input: "foo" }, function (error, result) { console.log("foo done", result); }); cc.enqueue({ input: "bar" }, function (error, result) { console.log("bar done", result); });
fileworker.js 中响应了 message 事件,对传入的请求进行处理:
process.on('message', function(m) { var output; // do lots of work here, and we don't care that we're blocking the // main thread because this process is intended to do one thing at a time. var output = doComputationWorkSync(m.input); process.send(output); });
Ohne den aufrufenden Code zu ändern, kann das Compute-Cluster-Modul in die vorhandene asynchrone API integriert werden, sodass mit der geringsten Codemenge eine echte Multi-Core-Parallelverarbeitung erreicht werden kann.
Betrachten wir die Leistung dieser Lösung unter vier Gesichtspunkten.
Mehrkern-Parallelfähigkeit: Der untergeordnete Prozess nutzt alle Kerne.
Reaktionsfähigkeit: Da der Kernverwaltungsprozess nur für das Starten untergeordneter Prozesse und die Zustellung von Nachrichten verantwortlich ist, ist er die meiste Zeit inaktiv und kann mehr interaktive Anfragen verarbeiten.
Selbst wenn die Maschine stark ausgelastet ist, können wir den Zeitplaner des Betriebssystems nutzen, um die Priorität des Kernverwaltungsprozesses zu erhöhen.
Einfachheit: Die asynchrone API wird verwendet, um die spezifischen Implementierungsdetails zu verbergen. Wir können dieses Modul problemlos in das aktuelle Projekt integrieren, ohne den aufrufenden Code zu ändern.
Jetzt wollen wir sehen, ob wir einen Weg finden können, damit die Effizienz des Systems auch bei einem plötzlichen Lastanstieg nicht ungewöhnlich abfällt.
Das beste Ziel bleibt natürlich, dass das System auch bei Druckanstiegen weiterhin effizient läuft und möglichst viele Anfragen bearbeiten kann.
Um gute Lösungen zu implementieren, verwaltet Compute-Cluster nicht nur untergeordnete Prozesse und leitet Nachrichten weiter, sondern verwaltet auch andere Informationen.
Es zeichnet die Anzahl der derzeit ausgeführten untergeordneten Prozesse und die durchschnittliche Zeit auf, die jeder untergeordnete Prozess für den Abschluss benötigt.
Anhand dieser Datensätze können wir vorhersagen, wie lange es dauern wird, bis der untergeordnete Prozess beginnt.
Dementsprechend können wir in Verbindung mit den vom Benutzer festgelegten Parametern (max_request_time) diejenigen Anfragen direkt schließen, bei denen eine Zeitüberschreitung ohne Verarbeitung auftreten kann.
Mit dieser Funktion können Sie Ihren Code ganz einfach auf der Benutzererfahrung basieren. Beispiel: „Benutzer sollten nicht länger als 10 Sekunden warten, um sich anzumelden.“ Dies entspricht in etwa dem Festlegen von max_request_time auf 7 Sekunden (die Netzwerkübertragungszeit muss berücksichtigt werden).
Nachdem wir den Persona-Dienst einem Stresstest unterzogen hatten, waren die Ergebnisse sehr zufriedenstellend.
Unter extrem hohen Druckbedingungen konnten wir weiterhin Dienste für authentifizierte Benutzer bereitstellen, haben außerdem einige nicht authentifizierte Benutzer blockiert und entsprechende Fehlermeldungen angezeigt.