Node.js를 빠르게 유지하기: 고성능 Node.js 서버를 만들기 위한 도구, 기술 및 팁

게시 됨: 2022-03-10
빠른 요약 ↬ Node는 매우 다양한 플랫폼이지만 가장 많이 사용되는 애플리케이션 중 하나는 네트워크로 연결된 프로세스를 생성하는 것입니다. 이 기사에서는 HTTP 웹 서버 중 가장 일반적인 프로파일링에 중점을 둘 것입니다.

충분히 오랫동안 Node.js로 무엇이든 구축해 왔다면 의심할 여지 없이 예상치 못한 속도 문제로 인한 고통을 경험했을 것입니다. JavaScript는 이벤트가 있는 비동기식 언어입니다. 이는 성능에 대한 추론 을 어렵게 만들 수 있습니다. Node.js의 급증하는 인기로 인해 서버 측 JavaScript의 제약 조건에 적합한 도구, 기술 및 사고의 필요성이 드러났습니다.

성능과 관련하여 브라우저에서 작동하는 것이 반드시 Node.js에 적합한 것은 아닙니다. 그렇다면 Node.js 구현이 빠르고 목적에 맞는지 확인하려면 어떻게 해야 할까요? 실습 예제를 살펴보겠습니다.

도구

노드는 매우 다재다능한 플랫폼이지만 주요 애플리케이션 중 하나는 네트워크 프로세스를 생성하는 것입니다. 우리는 HTTP 웹 서버 중 가장 일반적인 프로파일링에 집중할 것입니다.

성능을 측정하면서 요청이 많은 서버를 공격할 수 있는 도구가 필요합니다. 예를 들어 AutoCannon을 사용할 수 있습니다.

 npm install -g autocannon

다른 좋은 HTTP 벤치마킹 도구로는 Apache Bench(ab) 및 wrk2가 있지만 AutoCannon은 Node로 작성되었으며 유사한(또는 더 큰) 로드 압력을 제공하며 Windows, Linux 및 Mac OS X에 설치하기가 매우 쉽습니다.

점프 후 더! 아래에서 계속 읽기 ↓

기준 성능 측정을 설정한 후 프로세스가 더 빨라질 수 있다고 결정하면 프로세스의 문제를 진단할 방법이 필요합니다. 다양한 성능 문제를 진단하기 위한 훌륭한 도구는 npm과 함께 설치할 수도 있는 Node Clinic입니다.

 npm install -g clinic

이것은 실제로 도구 모음을 설치합니다. 우리는 진행하면서 Clinic Doctor와 Clinic Flame(0x 주변 래퍼)을 사용할 것입니다.

참고 : 이 실습 예제의 경우 Node 8.11.2 이상이 필요합니다.

코드

예제 사례는 단일 리소스가 있는 간단한 REST 서버입니다. /seed/v1 에서 GET 경로로 노출되는 큰 JSON 페이로드입니다. 서버는 package.json 파일( restify 7.1.0 에 따라 다름), index.js 파일 및 util.js 파일로 구성된 app 폴더입니다.

서버의 index.js 파일은 다음과 같습니다.

 'use strict' const restify = require('restify') const { etagger, timestamp, fetchContent } = require('./util')() const server = restify.createServer() server.use(etagger().bind(server)) server.get('/seed/v1', function (req, res, next) { fetchContent(req.url, (err, content) => { if (err) return next(err) res.send({data: content, url: req.url, ts: timestamp()}) next() }) }) server.listen(3000)

이 서버는 클라이언트에 캐시된 동적 콘텐츠를 제공하는 일반적인 경우를 나타냅니다. 이는 콘텐츠의 최신 상태에 대한 ETag 헤더를 계산하는 etagger 미들웨어를 통해 달성됩니다.

util.js 파일은 이러한 시나리오에서 일반적으로 사용되는 구현 부분, 백엔드에서 관련 콘텐츠를 가져오는 기능, etag 미들웨어 및 분 단위로 타임스탬프를 제공하는 타임스탬프 기능을 제공합니다.

 'use strict' require('events').defaultMaxListeners = Infinity const crypto = require('crypto') module.exports = () => { const content = crypto.rng(5000).toString('hex') const ONE_MINUTE = 60000 var last = Date.now() function timestamp () { var now = Date.now() if (now — last >= ONE_MINUTE) last = now return last } function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag }) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } } function fetchContent (url, cb) { setImmediate(() => { if (url !== '/seed/v1') cb(Object.assign(Error('Not Found'), {statusCode: 404})) else cb(null, content) }) } return { timestamp, etagger, fetchContent } }

절대로 이 코드를 모범 사례의 예로 삼지 마십시오! 이 파일에는 여러 개의 코드 냄새가 있지만 애플리케이션을 측정하고 프로파일링하면서 이를 찾습니다.

출발점에 대한 전체 소스를 얻으려면 느린 서버를 여기에서 찾을 수 있습니다.

프로파일링

프로파일링을 위해서는 두 개의 터미널이 필요합니다. 하나는 애플리케이션 시작용이고 다른 하나는 로드 테스트용입니다.

하나의 터미널에서 app 폴더 내에서 다음을 실행할 수 있습니다.

 node index.js

다른 터미널에서는 다음과 같이 프로파일링할 수 있습니다.

 autocannon -c100 localhost:3000/seed/v1

이렇게 하면 100개의 동시 연결이 열리고 10초 동안 서버에 요청이 쏟아집니다.

결과는 다음과 유사해야 합니다(Running 10s test @ https://localhost:3000/seed/v1 — 100 connections):

통계 평균 표준 데브 최대
대기 시간(밀리초) 3086.81 1725.2 5554
요청/초 23.1 19.18 65
바이트/초 237.98KB 197.7KB 688.13KB
10초 동안 231개 요청, 2.4MB 읽기

결과는 기계에 따라 다릅니다. 그러나 "Hello World" Node.js 서버가 이러한 결과를 생성한 해당 시스템에서 초당 3만 요청을 쉽게 처리할 수 있다는 점을 고려하면 평균 대기 시간이 3초를 초과하는 초당 23 요청은 참담합니다.

진단

문제 영역 발견

Clinic Doctor의 –on-port 명령 덕분에 단일 명령으로 애플리케이션을 진단할 수 있습니다. app 폴더 내에서 다음을 실행합니다.

 clinic doctor --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

그러면 프로파일링이 완료되면 브라우저에서 자동으로 열리는 HTML 파일이 생성됩니다.

결과는 다음과 같아야 합니다.

Clinic Doctor가 이벤트 루프 문제를 감지했습니다.
클리닉 의사 결과

닥터는 우리에게 이벤트 루프 문제가 있었던 것 같다고 말합니다.

UI 상단 부근의 메시지와 함께 이벤트 루프 차트가 빨간색이고 지속적으로 증가하는 지연을 보여 주는 것도 볼 수 있습니다. 이것이 의미하는 바를 더 자세히 알아보기 전에 먼저 진단된 문제가 다른 메트릭에 미치는 영향을 이해하겠습니다.

프로세스가 대기열에 있는 요청을 처리하기 위해 열심히 일하기 때문에 CPU가 일관되게 100% 이상임을 알 수 있습니다. Node의 JavaScript 엔진(V8)은 머신이 멀티 코어이고 V8이 2개의 스레드를 사용하기 때문에 이 경우 실제로 2개의 CPU 코어를 사용합니다. 하나는 이벤트 루프용이고 다른 하나는 가비지 컬렉션용입니다. 어떤 경우에는 CPU가 최대 120%까지 급증하는 것을 볼 때 프로세스가 처리된 요청과 관련된 개체를 수집하고 있습니다.

이것은 메모리 그래프에서 상관관계가 있음을 볼 수 있습니다. 메모리 차트의 실선은 사용된 힙 메트릭입니다. CPU에 스파이크가 있을 때마다 메모리가 할당 해제되고 있음을 나타내는 사용된 힙 행이 떨어지는 것을 볼 수 있습니다.

활성 핸들은 이벤트 루프 지연의 영향을 받지 않습니다. 활성 핸들은 I/O(예: 소켓 또는 파일 핸들) 또는 타이머(예: setInterval )를 나타내는 객체입니다. 우리는 AutoCannon에 100개의 연결을 열도록 지시했습니다( -c100 ). 활성 핸들은 103의 일관된 개수를 유지합니다. 다른 3개는 STDOUT, STDERR에 대한 핸들과 서버 자체에 대한 핸들입니다.

화면 하단의 권장 사항 패널을 클릭하면 다음과 같은 내용이 표시되어야 합니다.

클리닉 의사 추천 패널 열림
문제별 권장 사항 보기

단기 완화

심각한 성능 문제의 근본 원인 분석에는 시간이 걸릴 수 있습니다. 라이브 배포된 프로젝트의 경우 서버 또는 서비스에 과부하 보호를 추가할 가치가 있습니다. 과부하 보호의 개념은 이벤트 루프 지연(무엇보다도)을 모니터링하고 임계값을 초과하면 "503 서비스를 사용할 수 없음"으로 응답하는 것입니다. 이를 통해 로드 밸런서가 다른 인스턴스로 장애 조치되거나 최악의 경우 사용자가 새로 고쳐야 합니다. 과부하 보호 모듈은 Express, Koa 및 Restify에 대해 최소한의 오버헤드로 이를 제공할 수 있습니다. Hapi 프레임워크에는 동일한 보호를 제공하는 로드 구성 설정이 있습니다.

문제 영역 이해

Clinic Doctor의 간단한 설명에 따르면 이벤트 루프가 우리가 관찰하는 수준으로 지연되면 하나 이상의 기능이 이벤트 루프를 "차단"할 가능성이 매우 높습니다.

Node.js에서 이 기본 JavaScript 특성을 인식하는 것이 특히 중요합니다. 현재 실행 중인 코드가 완료될 때까지 비동기 이벤트가 발생할 수 없습니다.

이것이 setTimeout 이 정확할 수 없는 이유입니다.

예를 들어 브라우저의 DevTools 또는 Node REPL에서 다음을 실행해 보십시오.

 console.time('timeout') setTimeout(console.timeEnd, 100, 'timeout') let n = 1e7 while (n--) Math.random()

결과 시간 측정은 100ms가 되지 않습니다. 150ms에서 250ms의 범위에 있을 것입니다. setTimeout 은 비동기 작업( console.timeEnd )을 예약했지만 현재 실행 중인 코드는 아직 완료되지 않았습니다. 두 줄이 더 있습니다. 현재 실행 중인 코드를 현재 "틱"이라고 합니다. 틱이 완료되려면 Math.random 을 천만 번 호출해야 합니다. 이것이 100ms가 걸리면 타임아웃이 해결되기까지의 총 시간은 setTimeout 가 됩니다.

서버 측 컨텍스트에서 현재 틱의 작업이 완료되는 데 오랜 시간이 걸리면 요청을 처리할 수 없으며 현재 틱이 완료될 때까지 비동기 코드가 실행되지 않기 때문에 데이터 가져오기가 발생할 수 없습니다. 이는 계산 비용이 많이 드는 코드가 서버와의 모든 상호 작용을 느리게 한다는 것을 의미합니다. 따라서 리소스 집약적인 작업을 별도의 프로세스로 분할하고 주 서버에서 호출하는 것이 좋습니다. 이렇게 하면 거의 사용되지 않지만 비용이 많이 드는 경로에서 자주 사용되지만 저렴한 다른 경로의 성능이 느려지는 경우를 피할 수 있습니다.

예제 서버에는 이벤트 루프를 차단하는 코드가 있으므로 다음 단계는 해당 코드를 찾는 것입니다.

분석하는

성능이 좋지 않은 코드를 빠르게 식별하는 한 가지 방법은 플레임 그래프를 만들고 분석하는 것입니다. 플레임 그래프는 함수 호출을 시간이 지남에 따라가 아니라 집합적으로 서로의 위에 놓인 블록으로 나타냅니다. 이것을 '불꽃 그래프'라고 부르는 이유는 일반적으로 주황색에서 빨간색으로의 색 구성표를 사용하기 때문입니다. 블록이 붉을수록 함수가 "더 뜨거워집니다", 즉, 이벤트 루프를 차단할 가능성이 더 높아집니다. 플레임 그래프에 대한 데이터 캡처는 CPU 샘플링을 통해 수행됩니다. 즉, 현재 실행 중인 기능과 해당 스택의 스냅샷이 생성됩니다. 열은 주어진 함수가 각 샘플에 대해 스택의 맨 위에 있는 시간의 백분율(예: 현재 실행 중인 함수)에 의해 결정됩니다. 해당 스택 내에서 호출된 마지막 함수가 아닌 경우 이벤트 루프를 차단할 가능성이 높습니다.

clinic flame 을 사용하여 예제 애플리케이션의 Flame 그래프를 생성해 보겠습니다.

 clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

결과는 다음과 같이 브라우저에서 열립니다.

Clinic의 Flame 그래프는 server.on이 병목 현상임을 보여줍니다.
클리닉의 화염 그래프 시각화

블록의 너비는 전체 CPU에서 소비한 시간을 나타냅니다. 세 가지 주요 스택이 가장 많은 시간을 차지하는 것을 관찰할 수 있으며, 모두 server.on 을 가장 인기 있는 기능으로 강조 표시합니다. 사실, 3개의 스택은 모두 동일합니다. 프로파일링하는 동안 최적화된 함수와 최적화되지 않은 함수가 별도의 호출 프레임으로 처리되기 때문에 서로 다릅니다. * 접두사가 붙은 함수는 JavaScript 엔진에 의해 최적화되고 ~ 접두사가 붙은 함수는 최적화되지 않습니다. 최적화된 상태가 중요하지 않은 경우 병합 버튼을 눌러 그래프를 더 단순화할 수 있습니다. 그러면 다음과 유사한 보기가 표시됩니다.

병합된 화염 그래프
화염 그래프 병합

처음부터 문제가 되는 코드가 애플리케이션 코드의 util.js 파일에 있음을 유추할 수 있습니다.

느린 함수는 이벤트 핸들러이기도 합니다. 함수로 이어지는 함수는 핵심 events 모듈의 일부이고 server.on 은 이벤트 처리 함수로 제공되는 익명 함수의 대체 이름입니다. 또한 이 코드가 실제로 요청을 처리하는 코드와 동일한 틱에 있지 않다는 것을 알 수 있습니다. 그렇다면 핵심 http , netstream 모듈의 기능이 스택에 있습니다.

이러한 핵심 기능은 화염 그래프의 훨씬 더 작은 다른 부분을 확장하여 찾을 수 있습니다. 예를 들어 UI 오른쪽 상단의 검색 입력을 사용하여 send ( restifyhttp 내부 메서드의 이름)를 검색해 보세요. 그래프의 오른쪽에 있어야 합니다(함수는 알파벳순으로 정렬됨).

Flame 그래프에는 HTTP 처리 기능을 나타내는 두 개의 작은 블록이 강조 표시되어 있습니다.
HTTP 처리 기능에 대한 플레임 그래프 검색

모든 실제 HTTP 처리 블록이 얼마나 상대적으로 작은지 주목하십시오.

시안색으로 강조 표시된 블록 중 하나를 클릭하면 writeHead 와 같은 기능을 표시하도록 확장되고 http_outgoing.js 파일(Node 핵심 http 라이브러리의 일부)에 write 수 있습니다.

Flame 그래프가 HTTP 관련 스택을 보여주는 다른 보기로 확대되었습니다.
Flame 그래프를 HTTP 관련 스택으로 확장

모든 스택 을 클릭하여 기본 보기로 돌아갈 수 있습니다.

여기서 핵심은 server.on 함수가 실제 요청 처리 코드와 동일한 틱에 있지 않더라도 성능이 좋은 코드의 실행을 지연시켜 전체 서버 성능에 여전히 영향을 미친다는 것입니다.

디버깅

우리는 Flame 그래프에서 문제가 있는 함수가 util.js 파일의 server.on 에 전달된 이벤트 핸들러라는 것을 알고 있습니다.

한 번 보자:

 server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag })

직렬화( JSON.stringify )와 마찬가지로 암호화가 비용이 많이 드는 경향이 있다는 것은 잘 알려져 있지만 왜 플레임 그래프에 나타나지 않습니까? 이러한 작업은 캡처된 샘플에 있지만 cpp 필터 뒤에 숨겨져 있습니다. cpp 버튼을 누르면 다음과 같은 내용이 표시됩니다.

C++와 관련된 추가 블록이 플레임 그래프(메인 뷰)에서 공개되었습니다.
직렬화 및 암호화 C++ 프레임 공개

직렬화 및 암호화와 관련된 내부 V8 명령어는 이제 가장 핫한 스택으로 표시되며 대부분의 시간을 차지합니다. JSON.stringify 메서드는 C++ 코드를 직접 호출합니다. 이것이 JavaScript 함수가 표시되지 않는 이유입니다. 암호화의 경우 createHashupdate 와 같은 함수가 데이터에 있지만 인라인(병합된 보기에서 사라짐을 의미)하거나 렌더링하기에 너무 작습니다.

etagger 함수의 코드에 대해 추론하기 시작하면 잘못 설계되었다는 것이 금세 명백해집니다. 함수 컨텍스트에서 server 인스턴스를 가져오는 이유는 무엇입니까? 많은 해싱이 진행 중입니다. 이 모든 것이 필요한가요? 클라이언트가 신선도를 결정하기 위해 헤드 요청만 하기 때문에 일부 실제 시나리오에서 로드를 완화하는 구현에 If-None-Match 헤더 지원이 없습니다.

잠시 동안 이 모든 점을 무시하고 server.on 에서 수행되는 실제 작업이 실제로 병목 현상이 있다는 결과를 검증해 보겠습니다. 이것은 server.on 코드를 빈 함수로 설정하고 새 화염 그래프를 생성하여 달성할 수 있습니다.

etagger 함수를 다음으로 변경합니다.

 function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => {}) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } }

server.on 에 전달된 이벤트 리스너 기능은 이제 작동하지 않습니다.

clinic flame 을 다시 실행해 보겠습니다.

 clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

그러면 다음과 유사한 화염 그래프가 생성됩니다.

Flame 그래프는 Node.js 이벤트 시스템 스택이 여전히 병목 상태임을 보여줍니다.
server.on이 빈 함수일 때 서버의 Flame 그래프

이것은 더 좋아 보이며 초당 요청이 증가했음을 확인해야 합니다. 그런데 왜 이벤트 방출 코드가 그렇게 뜨거운가요? 이 시점에서 HTTP 처리 코드가 CPU 시간의 대부분을 차지할 것으로 예상하고 server.on 이벤트에서 아무 것도 실행되지 않습니다.

이러한 유형의 병목 현상은 필요한 것보다 더 많이 실행되는 기능으로 인해 발생합니다.

util.js 상단에 있는 다음과 같은 의심스러운 코드가 단서일 수 있습니다.

 require('events').defaultMaxListeners = Infinity

이 줄을 제거하고 --trace-warnings 플래그로 프로세스를 시작하겠습니다.

 node --trace-warnings index.js

다음과 같이 다른 터미널에서 AutoCannon으로 프로파일링하면:

 autocannon -c100 localhost:3000/seed/v1

우리의 프로세스는 다음과 비슷한 것을 출력할 것입니다:

 (node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit at _addListener (events.js:280:19) at Server.addListener (events.js:297:10) at attachAfterEvent (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14) at Server. (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7) at call (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9) at next (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9) at Chain.run (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5) at Server._runUse (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19) at Server._runRoute (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10) at Server._afterPre (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10) (node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit at _addListener (events.js:280:19) at Server.addListener (events.js:297:10) at attachAfterEvent (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14) at Server. (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7) at call (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9) at next (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9) at Chain.run (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5) at Server._runUse (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19) at Server._runRoute (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10) at Server._afterPre (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)

노드는 많은 이벤트가 서버 개체에 연결되고 있다고 알려줍니다. 이벤트가 연결되었는지 확인한 다음 초기에 반환하는 부울 값이 있기 때문에 이상합니다.

attachAfterEvent 함수를 살펴보겠습니다.

 var afterEventAttached = false function attachAfterEvent (server) { if (attachAfterEvent === true) return afterEventAttached = true server.on('after', (req, res) => {}) }

조건부 확인이 잘못되었습니다! afterEventAttached 대신 attachAfterEvent 가 true인지 확인합니다. 이는 모든 요청에 ​​대해 새 이벤트가 server 인스턴스에 연결되고 이전에 연결된 모든 이벤트가 각 요청 후에 시작됨을 의미합니다. 이런!

최적화

이제 문제 영역을 발견했으므로 서버를 더 빠르게 만들 수 있는지 봅시다.

낮게 매달린 과일

빈 함수 대신 server.on 리스너 코드를 다시 넣고 조건부 검사에서 올바른 부울 이름을 사용하겠습니다. etagger 함수는 다음과 같습니다.

 function etagger () { var cache = {} var afterEventAttached = false function attachAfterEvent (server) { if (afterEventAttached === true) return afterEventAttached = true server.on('after', (req, res) => { if (res.statusCode !== 200) return if (!res._body) return const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') const etag = crypto.createHash('sha512') .update(JSON.stringify(res._body)) .digest() .toString('hex') if (cache[key] !== etag) cache[key] = etag }) } return function (req, res, next) { attachAfterEvent(this) const key = crypto.createHash('sha512') .update(req.url) .digest() .toString('hex') if (key in cache) res.set('Etag', cache[key]) res.set('Cache-Control', 'public, max-age=120') next() } }

이제 다시 프로파일링하여 수정 사항을 확인합니다. 하나의 터미널에서 서버 시작:

 node index.js

그런 다음 AutoCannon으로 프로파일링:

 autocannon -c100 localhost:3000/seed/v1

200배 개선된 결과를 볼 수 있습니다(10초 테스트 @ https://localhost:3000/seed/v1 — 100 연결 실행):

통계 평균 표준 데브 최대
대기 시간(밀리초) 19.47 4.29 103
요청/초 5011.11 506.2 5487
바이트/초 51.8MB 5.45MB 58.72MB
10초 동안 50k 요청, 519.64MB 읽기

잠재적인 서버 비용 절감과 개발 비용의 균형을 맞추는 것이 중요합니다. 우리는 우리 자신의 상황적 맥락에서 프로젝트 최적화에 얼마나 멀리 가야 하는지를 정의해야 합니다. 그렇지 않으면 80%의 노력을 20%의 속도 향상에 투입하는 것이 너무 쉬울 수 있습니다. 프로젝트의 제약이 이를 정당화합니까?

일부 시나리오에서는 매달린 과일로 200배 개선을 달성하고 하루라고 하는 것이 적절할 수 있습니다. 다른 곳에서는 가능한 한 빨리 구현하기를 원할 수 있습니다. 그것은 실제로 프로젝트 우선 순위에 달려 있습니다.

자원 지출을 통제하는 한 가지 방법은 목표를 설정하는 것입니다. 예를 들어, 10배 개선 또는 초당 4000개 요청. 비즈니스 요구 사항을 기반으로 하는 것이 가장 합리적입니다. 예를 들어 서버 비용이 예산을 100% 초과하는 경우 2배 개선 목표를 설정할 수 있습니다.

더 나아가

서버의 새로운 Flame 그래프를 생성하면 다음과 유사한 내용이 표시되어야 합니다.

Flame 그래프는 여전히 server.on을 병목 현상으로 표시하지만 병목 현상은 더 작습니다.
성능 버그 수정 후 Flame 그래프

이벤트 리스너는 여전히 병목 상태이며 프로파일링 중에 여전히 CPU 시간의 1/3을 차지합니다(너비는 전체 그래프의 약 1/3임).

추가로 얻을 수 있는 이점은 무엇이며 관련 중단과 함께 변경할 가치가 있습니까?

그럼에도 불구하고 약간 더 제한적인 최적화된 구현을 통해 다음과 같은 성능 특성을 달성할 수 있습니다(10초 테스트 @ https://localhost:3000/seed/v1 - 10개 연결 실행).

통계 평균 표준 데브 최대
대기 시간(밀리초) 0.64 0.86 17
요청/초 8330.91 757.63 8991
바이트/초 84.17MB 7.64MB 92.27MB
11초 동안 92k 요청, 937.22MB 읽기

1.6배 개선이 중요하지만 이러한 개선을 만드는 데 필요한 노력, 변경 및 코드 중단이 정당한지 여부는 상황에 따라 달라질 수 있습니다. 특히 단일 버그 수정으로 원래 구현에서 200배 개선된 것과 비교할 때.

이 개선을 달성하기 위해 프로파일, 화염 그래프 생성, 분석, 디버그 및 최적화의 동일한 반복 기술을 사용하여 최종 최적화 서버에 도달했습니다. 코드는 여기에서 찾을 수 있습니다.

8000 req/s에 도달하기 위한 최종 변경 사항은 다음과 같습니다.

  • 객체를 빌드한 다음 직렬화하지 말고 JSON 문자열을 직접 빌드하십시오.
  • 해시를 만드는 대신 콘텐츠에 대해 고유한 것을 사용하여 Etag를 정의합니다.
  • URL을 해시하지 말고 직접 키로 사용하십시오.

이러한 변경 사항은 약간 더 복잡하고 코드 기반에 좀 더 방해가 되며 Etag 값을 제공하는 경로에 부담을 주기 때문에 etagger 미들웨어는 유연성이 약간 떨어집니다. 그러나 프로파일링 머신에서 초당 3000개의 추가 요청을 달성합니다.

이러한 최종 개선 사항에 대한 플레임 그래프를 살펴보겠습니다.

Flame 그래프는 net 모듈과 관련된 내부 코드가 이제 병목 현상임을 보여줍니다.
모든 성능 개선 후의 건강한 화염 그래프

Flame 그래프의 가장 뜨거운 부분은 net 모듈에서 Node 코어의 일부입니다. 이것은 이상적입니다.

성능 문제 방지

마무리하기 위해 성능 문제를 배포하기 전에 방지하는 방법에 대한 몇 가지 제안 사항이 있습니다.

개발 중에 성능 도구를 비공식 체크포인트로 사용하면 성능 버그가 프로덕션에 적용되기 전에 걸러낼 수 있습니다. AutoCannon 및 Clinic(또는 이에 상응하는 것)을 일상적인 개발 도구의 일부로 만드는 것이 좋습니다.

프레임워크를 구매할 때 성능에 대한 정책이 무엇인지 알아보십시오. 프레임워크가 성능을 우선시하지 않는 경우 인프라 관행 및 비즈니스 목표와 일치하는지 확인하는 것이 중요합니다. 예를 들어, Restify는 분명히(버전 7 릴리스 이후) 라이브러리의 성능을 향상시키는 데 투자했습니다. 그러나 저렴한 비용과 빠른 속도가 절대적인 우선 순위라면 Restify 기여자가 17% 더 빠른 것으로 측정한 Fastify를 고려하십시오.

광범위하게 영향을 미치는 다른 라이브러리 선택에 주의하십시오. 특히 로깅을 고려하십시오. 개발자가 문제를 수정하면 향후 관련 문제를 디버그하는 데 도움이 되도록 추가 로그 출력을 추가하기로 결정할 수 있습니다. 성능이 좋지 않은 로거를 사용하면 끓는 개구리 우화의 유행 이후 시간이 지남에 따라 성능이 저하될 수 있습니다. pino 로거는 Node.js에서 사용할 수 있는 가장 빠른 줄바꿈으로 구분된 JSON 로거입니다.

마지막으로 이벤트 루프는 공유 리소스라는 것을 항상 기억하십시오. Node.js 서버는 궁극적으로 가장 핫한 경로에서 가장 느린 로직에 의해 제약을 받습니다.