키 1만 개를 넣는 데 12초가 걸리던 것을 2초로 줄인 방법
문제 발생
캐시 예열 스크립트가 키를 하나씩 넣었습니다.
for (const item of items) { // 10,000개
await redis.set(`item:${item.id}`, JSON.stringify(item));
}Redis 서버는 한가한데 스크립트만 12초가 걸렸습니다. 같은 머신(로컬 루프백)에서 돌려도 크게 나아지지 않았습니다.
원인 분석
병목이 서버가 아니라 왕복입니다. Redis 문서가 이 구조를 설명합니다 — 클라이언트가 질의를 보내고 소켓에서 블로킹 방식으로 응답을 읽고, 서버가 처리해 응답을 돌려주는 요청/응답 프로토콜입니다. 그 왕복 시간이 RTT입니다.
문서의 계산이 직관적입니다 — RTT가 250ms라면 서버가 초당 10만 요청을 처리할 수 있어도 우리는 초당 최대 4개만 처리하게 됩니다. 루프백에서는 RTT가 훨씬 짧지만 연속 쓰기가 많으면 그것도 쌓입니다.
그리고 이유가 하나 더 있습니다 — 파이프라이닝은 RTT 비용만 줄이는 게 아니라 Redis 서버의 초당 처리량 자체를 크게 올립니다. 각 명령을 처리하는 데이터 구조 접근은 싸지만 소켓 I/O가 비쌉니다 — read()/write() 시스템 콜은 유저 랜드와 커널 랜드를 오가고, 그 컨텍스트 스위치가 큰 속도 손해이기 때문입니다.
해결 방안
- 한 번에 보내고 한 번에 읽습니다. 대부분의 클라이언트가 파이프라인 API를 제공합니다.
const pipeline = redis.pipeline();
for (const item of items) pipeline.set(`item:${item.id}`, JSON.stringify(item));
await pipeline.exec();-
배치 크기를 나눕니다. 문서의 경고 그대로 — 파이프라이닝으로 명령을 보내는 동안 서버는 응답을 메모리에 큐잉해야 하므로, 많은 명령을 보낼 때는 1만 개 정도의 배치로 나눠 보내고 응답을 읽은 뒤 다시 보내는 편이 낫습니다. 속도는 거의 같고 메모리 사용만 줄어듭니다.
-
읽고 계산해서 쓰는 흐름에는 스크립트가 맞습니다. 문서 설명대로 파이프라이닝은 읽기 결과가 있어야 다음 쓰기를 정할 수 있는 경우에는 도움이 되지 않습니다 — 그건
EVAL이 서버에서 처리할 일입니다. -
트랜잭션과 혼동하지 않습니다. 파이프라이닝은 원자성을 주지 않습니다. 여러 명령이 하나로 묶여야 한다면
MULTI/EXEC나 Lua가 필요합니다. -
루프백이라고 공짜가 아닙니다. 문서의 부록이 지적하듯, 같은 머신이어도 커널 스케줄러가 프로세스를 번갈아 깨워야 해서 네트워크와 비슷한 지연이 존재합니다.
-
먼저 측정합니다. 명령 수가 수십 개 수준이면 파이프라이닝의 이득은 미미합니다 — 수천 개 이상을 연속으로 보낼 때 의미가 있습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.