Skip to content

Profiling von Node.js-Anwendungen

Es gibt viele Drittanbieter-Tools für das Profiling von Node.js-Anwendungen, aber in vielen Fällen ist die einfachste Option die Verwendung des in Node.js integrierten Profilers. 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 Abtastungen zusammen mit wichtigen Optimierungsereignissen wie JIT-Kompilierungen als eine Reihe von Ticks auf:

bash
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 dass V8 separat aus dem Quellcode erstellt werden muss. Sehen wir uns an, wie der integrierte Profiler Einblicke in die Anwendungsleistung liefern kann.

Um die Verwendung des Tick-Profilers zu veranschaulichen, werden wir mit einer einfachen Express-Anwendung arbeiten. Unsere Anwendung wird zwei Handler haben, einen zum Hinzufügen neuer Benutzer zu unserem System:

javascript
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:

javascript
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:

bash
NODE_ENV=production node --prof app.js

und mit ab (ApacheBench) etwas Last auf den Server legen:

bash
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 erhalten eine ab-Ausgabe von:

bash
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 nur etwa 5 Anfragen pro Sekunde bearbeiten können und dass die durchschnittliche Anfrage etwas weniger als 4 Sekunden für die Hin- und Rückreise benötigt. In einem realen Beispiel könnten wir viele Aufgaben in vielen Funktionen im Auftrag einer Benutzeranfrage erledigen, 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 die Form isolate-0xnnnnnnnnnnnn-v8.log haben (wobei n eine Ziffer ist).

Um diese Datei zu interpretieren, müssen wir den Tick-Prozessor verwenden, der mit der Node.js-Binärdatei gebündelt ist. Um den Prozessor auszuführen, verwenden Sie das Flag --prof-process:

bash
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

Das Öffnen von processed.txt in Ihrem bevorzugten Texteditor liefert Ihnen einige verschiedene Arten von Informationen. Die Datei ist in Abschnitte unterteilt, die wiederum nach Sprache unterteilt sind. Zuerst betrachten wir den Zusammenfassungsabschnitt und sehen:

bash
[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 zeigt uns, dass 97 % aller gesammelten Samples in C++-Code aufgetreten sind und dass wir beim Betrachten anderer Abschnitte der verarbeiteten Ausgabe der Arbeit in C++ (im Gegensatz zu JavaScript) die größte Aufmerksamkeit schenken sollten. In diesem Sinne finden wir als Nächstes den Abschnitt [C++], der Informationen darüber enthält, welche C++-Funktionen die meiste CPU-Zeit beanspruchen, und sehen:

bash
 [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 Top-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 sich die unteren beiden Einträge in unserer Anwendung auswirken (oder wenn es so ist, werden wir es aus Gründen des Beispiels anders darstellen). Um die Beziehung zwischen diesen Funktionen besser zu verstehen, betrachten wir als Nächstes den Abschnitt [Bottom up (heavy) profile], der Informationen über die primären Aufrufer jeder Funktion liefert. Wenn wir diesen Abschnitt untersuchen, finden wir:

bash
  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 rohen Tick-Zahlen oben. Innerhalb jedes der obigen "Call Stacks" gibt der Prozentsatz in der Spalte parent den Prozentsatz der Samples an, für die die Funktion in der Zeile oben von der Funktion in der aktuellen Zeile aufgerufen wurde. In der mittleren "Call Stack" oben für _sha1_block_data_order sehen wir beispielsweise, dass _sha1_block_data_order in 11,9 % der Samples vorkam, was wir aus den rohen Zählungen oben wussten. Hier können wir jedoch auch erkennen, dass es immer von der pbkdf2-Funktion innerhalb des Node.js-Kryptomoduls aufgerufen wurde. Wir sehen, dass _malloc_zone_malloc ebenfalls fast ausschließlich von derselben pbkdf2-Funktion aufgerufen wurde. Daher können wir mit den Informationen in dieser Ansicht erkennen, dass unsere Hash-Berechnung aus dem Benutzerpasswort nicht nur für die 51,8 % von oben, sondern auch für die gesamte CPU-Zeit in den Top 3 der am häufigsten gesampelten Funktionen verantwortlich ist, da die Aufrufe von _sha1_block_data_order und _malloc_zone_malloc im Namen der pbkdf2-Funktion erfolgten.

An diesem Punkt ist sehr klar, 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 zum Generieren eines Hash aus dem Passwort des Benutzers synchron erfolgt und somit die Event-Schleife blockiert. Dies hindert uns daran, an anderen eingehenden Anfragen zu arbeiten, während wir einen Hash berechnen.

Um dieses Problem zu beheben, nehmen Sie eine kleine Änderung an den obigen Handlern vor, um die asynchrone Version der pbkdf2-Funktion zu verwenden:

javascript
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:

bash
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)

Yay! Ihre App bearbeitet jetzt etwa 20 Anfragen pro Sekunde, also etwa 4-mal mehr als mit der synchronen Hash-Generierung. Außerdem ist die durchschnittliche Latenz von den vorherigen 4 Sekunden auf etwas mehr als 1 Sekunde gesunken.

Hoffentlich haben Sie durch die Leistungsuntersuchung dieses (zugegebenermaßen konstruierten) Beispiels gesehen, wie der V8-Tick-Prozessor Ihnen helfen kann, ein besseres Verständnis für die Leistung Ihrer Node.js-Anwendungen zu gewinnen.

Möglicherweise finden Sie auch wie man eine Flame Graph erstellt hilfreich.