Node.js-Anwendungen profilieren
Es gibt viele Drittanbieter-Tools zur Profilierung von Node.js-Anwendungen, aber in vielen Fällen ist die einfachste Option die Verwendung des integrierten Profilers von Node.js. Der integrierte Profiler verwendet den Profiler in V8, der den Stack in regelmäßigen Abständen während der Programmausführung abtastet. Er zeichnet die Ergebnisse dieser Stichproben zusammen mit wichtigen Optimierungsereignissen wie JIT-Kompilierungen als eine Reihe von Ticks auf:
code-creation,LazyCompile,0,0x2d5000a337a0,396,"bp native array.js:1153:16",0x289f644df68,~
code-creation,LazyCompile,0,0x2d5000a33940,716,"hasOwnProperty native v8natives.js:198:30",0x289f64438d0,~
code-creation,LazyCompile,0,0x2d5000a33c20,284,"ToName native runtime.js:549:16",0x289f643bb28,~
code-creation,Stub,2,0x2d5000a33d40,182,"DoubleToIStub"
code-creation,Stub,2,0x2d5000a33e00,507,"NumberToStringStub"
In der Vergangenheit benötigten Sie den V8-Quellcode, um die Ticks interpretieren zu können. Glücklicherweise wurden seit Node.js 4.4.0 Tools eingeführt, die die Nutzung dieser Informationen erleichtern, ohne V8 separat aus den Quellen zu erstellen. Sehen wir uns an, wie der integrierte Profiler Einblicke in die Anwendungsleistung geben kann.
Um die Verwendung des Tick-Profilers zu veranschaulichen, werden wir mit einer einfachen Express-Anwendung arbeiten. Unsere Anwendung hat zwei Handler, einen zum Hinzufügen neuer Benutzer zu unserem System:
app.get('/newUser', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || users[username]) {
return res.sendStatus(400);
}
const salt = crypto.randomBytes(128).toString('base64');
const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
users[username] = { salt, hash };
res.sendStatus(200);
});
und einen weiteren zur Validierung von Benutzerauthentifizierungsversuchen:
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || !users[username]) {
return res.sendStatus(400);
}
const { salt, hash } = users[username];
const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
if (crypto.timingSafeEqual(hash, encryptHash)) {
res.sendStatus(200);
} else {
res.sendStatus(401);
}
});
Bitte beachten Sie, dass dies KEINE empfohlenen Handler für die Authentifizierung von Benutzern in Ihren Node.js-Anwendungen sind und nur zu Illustrationszwecken verwendet werden. Sie sollten im Allgemeinen nicht versuchen, Ihre eigenen kryptografischen Authentifizierungsmechanismen zu entwerfen. Es ist viel besser, bestehende, bewährte Authentifizierungslösungen zu verwenden.
Nehmen wir nun an, dass wir unsere Anwendung bereitgestellt haben und sich Benutzer über hohe Latenzzeiten bei Anfragen beschweren. Wir können die App einfach mit dem integrierten Profiler ausführen:
NODE_ENV=production node --prof app.js
und den Server mit ab
(ApacheBench) belasten:
curl -X GET "http://localhost:8080/newUser?username=matt&password=password"
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=matt&password=password"
und eine ab-Ausgabe erhalten:
Concurrency Level: 20
Time taken for tests: 46.932 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 5.33 [#/sec] (mean)
Time per request: 3754.556 [ms] (mean)
Time per request: 187.728 [ms] (mean, across all concurrent requests)
Transfer rate: 1.05 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 3755
66% 3804
75% 3818
80% 3825
90% 3845
95% 3858
98% 3874
99% 3875
100% 4225 (longest request)
Aus dieser Ausgabe sehen wir, dass wir es nur schaffen, etwa 5 Anfragen pro Sekunde zu bedienen und dass die durchschnittliche Anfrage knapp 4 Sekunden dauert. In einem realen Beispiel könnten wir viele Arbeiten in vielen Funktionen im Auftrag einer Benutzeranfrage ausführen, aber selbst in unserem einfachen Beispiel könnte Zeit durch das Kompilieren regulärer Ausdrücke, das Generieren zufälliger Salts, das Generieren eindeutiger Hashes aus Benutzerpasswörtern oder innerhalb des Express-Frameworks selbst verloren gehen.
Da wir unsere Anwendung mit der Option --prof
ausgeführt haben, wurde eine Tick-Datei im selben Verzeichnis wie Ihre lokale Ausführung der Anwendung generiert. Sie sollte das Format isolate-0xnnnnnnnnnnnn-v8.log
haben (wobei n eine Ziffer ist).
Um diese Datei zu interpretieren, müssen wir den im Node.js-Binary gebündelten Tick-Prozessor verwenden. Um den Prozessor auszuführen, verwenden Sie das Flag --prof-process
:
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
Das Öffnen von processed.txt in Ihrem bevorzugten Texteditor liefert Ihnen verschiedene Arten von Informationen. Die Datei ist in Abschnitte unterteilt, die wiederum nach Sprache unterteilt sind. Zuerst sehen wir uns den Zusammenfassungsabschnitt an und sehen:
[Summary]:
ticks total nonlib name
79 0.2% 0.2% JavaScript
36703 97.2% 99.2% C++
7 0.0% 0.0% GC
767 2.0% Shared libraries
215 0.6% Unaccounted
Dies sagt uns, dass 97 % aller gesammelten Stichproben im C++-Code stattfanden und dass wir beim Betrachten anderer Abschnitte der verarbeiteten Ausgabe am meisten auf die in C++ geleistete Arbeit achten sollten (im Gegensatz zu JavaScript). Vor diesem Hintergrund finden wir als Nächstes den Abschnitt [C++], der Informationen darüber enthält, welche C++-Funktionen die meiste CPU-Zeit beanspruchen, und sehen:
[C++]:
ticks total nonlib name
19557 51.8% 52.9% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
4510 11.9% 12.2% _sha1_block_data_order
3165 8.4% 8.6% _malloc_zone_malloc
Wir sehen, dass die obersten 3 Einträge 72,1 % der vom Programm benötigten CPU-Zeit ausmachen. Aus dieser Ausgabe sehen wir sofort, dass mindestens 51,8 % der CPU-Zeit von einer Funktion namens PBKDF2 beansprucht werden, was unserer Hash-Generierung aus dem Passwort eines Benutzers entspricht. Es ist jedoch möglicherweise nicht sofort ersichtlich, wie die unteren beiden Einträge in unsere Anwendung einfließen (oder wenn dies der Fall ist, werden wir aus Gründen der Veranschaulichung so tun, als wäre es nicht so). Um die Beziehung zwischen diesen Funktionen besser zu verstehen, werden wir uns als Nächstes den Abschnitt [Bottom up (heavy) profile] ansehen, der Informationen über die primären Aufrufer jeder Funktion enthält. Bei der Untersuchung dieses Abschnitts finden wir:
ticks parent name
19557 51.8% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
19557 100.0% v8::internal::Builtins::~Builtins()
19557 100.0% LazyCompile: ~pbkdf2 crypto.js:557:16
4510 11.9% _sha1_block_data_order
4510 100.0% LazyCompile: *pbkdf2 crypto.js:557:16
4510 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
3165 8.4% _malloc_zone_malloc
3161 99.9% LazyCompile: *pbkdf2 crypto.js:557:16
3161 100.0% LazyCompile: *exports.pbkdf2Sync crypto.js:552:30
Das Parsen dieses Abschnitts erfordert etwas mehr Arbeit als die Roh-Tick-Zahlen oben. Innerhalb jedes der oben genannten "Callstacks" gibt der Prozentsatz in der Spalte "parent" den Prozentsatz der Stichproben an, für die die Funktion in der Zeile darüber von der Funktion in der aktuellen Zeile aufgerufen wurde. Zum Beispiel sehen wir im mittleren "Callstack" oben für _sha1_block_data_order
, dass _sha1_block_data_order
in 11,9 % der Stichproben vorkam, was wir aus den Roh-Zahlen oben wussten. Hier können wir jedoch auch feststellen, dass es immer von der pbkdf2-Funktion innerhalb des Node.js-Kryptomoduls aufgerufen wurde. Wir sehen, dass _malloc_zone_malloc fast ausschließlich von derselben pbkdf2-Funktion aufgerufen wurde. Somit können wir anhand der Informationen in dieser Ansicht erkennen, dass unsere Hash-Berechnung aus dem Passwort des Benutzers nicht nur die 51,8 % von oben ausmacht, sondern auch die gesamte CPU-Zeit in den drei am häufigsten abgetasteten Funktionen, da die Aufrufe von _sha1_block_data_order
und _malloc_zone_malloc
im Auftrag der pbkdf2-Funktion erfolgten.
An diesem Punkt ist sehr deutlich, dass die passwortbasierte Hash-Generierung das Ziel unserer Optimierung sein sollte. Glücklicherweise haben Sie die Vorteile der asynchronen Programmierung vollständig verinnerlicht und erkennen, dass die Arbeit zur Generierung eines Hash aus dem Passwort des Benutzers auf synchrone Weise erledigt wird und somit die Ereignisschleife blockiert. Dies hindert uns daran, während der Berechnung eines Hash an anderen eingehenden Anfragen zu arbeiten.
Um dieses Problem zu beheben, nehmen Sie eine kleine Änderung an den oben genannten Handlern vor, um die asynchrone Version der pbkdf2-Funktion zu verwenden:
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || !users[username]) {
return res.sendStatus(400);
}
crypto.pbkdf2(
password,
users[username].salt,
10000,
512,
'sha512',
(err, hash) => {
if (users[username].hash.toString() === hash.toString()) {
res.sendStatus(200);
} else {
res.sendStatus(401);
}
}
);
});
Eine neue Ausführung des obigen AB-Benchmarks mit der asynchronen Version Ihrer App ergibt:
Concurrency Level: 20
Time taken for tests: 12.846 seconds
Complete requests: 250
Failed requests: 0
Keep-Alive requests: 250
Total transferred: 50250 bytes
HTML transferred: 500 bytes
Requests per second: 19.46 [#/sec] (mean)
Time per request: 1027.689 [ms] (mean)
Time per request: 51.384 [ms] (mean, across all concurrent requests)
Transfer rate: 3.82 [Kbytes/sec] received
...
Percentage of the requests served within a certain time (ms)
50% 1018
66% 1035
75% 1041
80% 1043
90% 1049
95% 1063
98% 1070
99% 1071
100% 1079 (longest request)
Juhu! Ihre App bedient jetzt etwa 20 Anfragen pro Sekunde, ungefähr 4-mal mehr als mit der synchronen Hash-Generierung. Zusätzlich ist die durchschnittliche Latenz von den vorherigen 4 Sekunden auf knapp über 1 Sekunde gesunken.
Hoffentlich haben Sie anhand der Leistungsuntersuchung dieses (zugegebenermaßen konstruierten) Beispiels gesehen, wie der V8-Tick-Prozessor Ihnen helfen kann, ein besseres Verständnis der Leistung Ihrer Node.js-Anwendungen zu erlangen.
Sie finden möglicherweise auch Wie man ein Flame Graph erstellt hilfreich.