성능 최적화 (Optimization)
성능 최적화 (Optimization)
Redis를 운영하다 보면 어느 순간 "왜 이렇게 느리지?"라는 질문을 하게 돼요. Redis는 보통 마이크로초 단위로 응답하는 아주 빠른 시스템이지만, 특정 조건에서 지연(latency)이 커질 수 있어요. 이 문서는 성능을 잡아먹는 원인이 무엇인지 찾아내는 방법과, 메모리와 지연을 최적화하는 전략을 다룹니다. Redis 공식 문서의 latency(지연 진단), latency monitoring(지연 모니터링), memory optimization(메모리 최적화), cpu profiling(CPU 프로파일링) 문서를 바탕으로 정리했어요.
지연 문제 진단 (Diagnosing latency issues)
여기서 말하는 지연(latency) 은 클라이언트가 명령 하나를 보내고, 그 명령에 대한 응답을 받을 때까지 걸리는 최대 지연 시간을 뜻해요. 평소에는 Redis가 명령 하나를 처리하는 데 걸리는 시간이 마이크로초 미만이라 문제가 없지만, 몇 가지 조건에서 지연 수치가 올라갈 수 있어요.
시간이 없으니 체크리스트부터
전체 문서가 낮은 지연으로 Redis를 운영하는 데 중요하긴 한데요, 바쁜 분들을 위해 빠른 체크리스트를 먼저 드릴게요. 아래 항목을 따라 해보고, 그래도 해결이 안 되면 문서 본문을 다시 읽어보시면 돼요.
- 서버를 블로킹하는 느린 명령(slow command)을 실행하고 있지 않은지 확인하세요. Redis의 Slow Log 기능으로 확인할 수 있어요.
- EC2 사용자라면 m3.medium 같은 HVM 기반의 최신 EC2 인스턴스를 사용하세요. 그렇지 않으면
fork()가 너무 느려요. - 커널의 transparent huge pages를 비활성화해야 해요.
echo never > /sys/kernel/mm/transparent_hugepage/enabled로 끄고, Redis 프로세스를 재시작하세요. - 가상 머신을 쓰고 있다면 Redis와 무관한 본질적인(intrinsic) 지연이 있을 수 있어요.
./redis-cli --intrinsic-latency 100으로 실행 환경에서 기대할 수 있는 최소 지연을 확인하세요. 이 명령은 클라이언트가 아니라 서버에서 실행해야 한다는 점을 기억해 두세요. - Redis의 Latency monitor 기능을 켜서, Redis 인스턴스에서 발생하는 지연 이벤트와 원인에 대한 사람이 읽을 수 있는 설명을 얻으세요.
그리고 내구성(durability)과 지연/성능 사이의 트레이드오프를 보여주는 표를 참고하세요. 강한 안전성 순서에서 지연이 낮은 순서로 나열했어요.
- AOF + fsync always: 매우 느려요. 무슨 일인지 정확히 아는 경우에만 쓰세요.
- AOF + fsync every second: 좋은 절충안이에요.
- AOF + fsync every second + no-appendfsync-on-rewrite 옵션을 yes로: 위와 동일하지만 rewrite 중에는 fsync를 하지 않아 디스크 부하를 낮춰요.
- AOF + fsync never: fsync를 커널에 맡기는 설정이라 디스크 부하와 지연 스파이크 위험이 더 줄어요.
- RDB: 설정하는 save 트리거에 따라 아주 다양한 트레이드오프 범위가 있어요.
지연 측정하기 (Measuring latency)
애플리케이션 안에서 지연을 측정하는 방법을 이미 알고 있거나, 지연 문제가 육안으로도 뚜렷할 수도 있어요. 하지만 redis-cli로도 Redis 서버의 지연을 밀리초 단위로 측정할 수 있어요.
redis-cli --latency -h <host> -p <port>
지연 기준선 (Latency baseline)
환경 자체에 본질적으로 포함된 지연이 있어요. 운영체제 커널이 만드는 지연이고, 가상화를 쓴다면 하이퍼바이저가 만드는 지연까지 포함해요. 이 지연은 제거할 수 없지만, 기준선(baseline) 이라서 반드시 알아둬야 해요. 다시 말해, 환경 안에서 실행되는 모든 프로세스가 커널이나 하이퍼바이저 때문에 겪는 지연보다 더 좋은 Redis 지연을 얻을 수는 없어요.
이걸 우리는 intrinsic latency(본질 지연) 이라고 부르는데, Redis 2.8.7부터 redis-cli로 측정할 수 있어요. redis-cli --intrinsic-latency 100에서 100은 테스트를 실행할 시간(초)이에요. 오래 실행할수록 지연 스파이크를 발견할 확률이 높아지죠. 100초면 보통 적당하지만, 시간대를 달리해서 여러 번 실행하는 것도 좋아요. 참고로 이 테스트는 CPU를 많이 사용해서 시스템의 코어 하나를 거의 포화시킬 수 있어요.
$ ./redis-cli --intrinsic-latency 100
Max latency so far: 1 microseconds.
Max latency so far: 16 microseconds.
...
Max latency so far: 115 microseconds.
여기서 중요한 점: redis-cli는 이 특별한 모드에서는 Redis가 실행되는 (또는 실행할) 서버에서 돌아야 해요. 클라이언트가 아니에요. 이 모드에서 redis-cli는 Redis 서버에 전혀 연결하지 않고, 커널이 redis-cli 프로세스 자신에게 CPU 시간을 주지 않는 가장 긴 구간만 측정해요.
위 예시의 시스템 지연은 115마이크로초(0.115밀리초)로 좋은 수치예요. 다만 지연은 시스템 부하에 따라 시간이 지나며 변할 수 있다는 걸 기억하세요.
가상화 환경은 특히 부하가 높거나 noisy neighbor(같은 물리 머신의 다른 VM)가 있을 때 좋은 수치를 보여주지 못해요. Linode 4096 인스턴스에서 Redis와 Apache를 실행한 결과를 보면 지연이 최대 9.7밀리초까지 올라가요. 이러면 Redis에 그 이상을 요구할 수 없어요. 다른 환경에선 부하가 높을 때 40밀리초까지 측정된 경우도 있었어요.
네트워크와 통신으로 인한 지연
클라이언트는 TCP/IP 연결 또는 Unix domain 소켓으로 Redis에 연결해요. 1Gbit/s 네트워크의 전형적인 지연은 약 200us이고, Unix domain 소켓은 30us까지 낮아질 수 있어요. 여기에 스레드 스케줄링, CPU 캐시, NUMA 배치 같은 시스템 부가 지연이 더해지죠. 가상화 환경에서는 이런 시스템 유발 지연이 물리 머신보다 훨씬 커요.
결과적으로 Redis가 대부분의 명령을 마이크로초 미만으로 처리해도, 왕복(roundtrip)을 많이 하는 클라이언트는 네트워크와 시스템 지연을 그대로 치르게 돼요. 효율적인 클라이언트는 여러 명령을 파이프라이닝(pipelining) 으로 묶어 왕복 횟수를 줄이려고 해요. 서버와 대부분의 클라이언트가 이를 지원하고요, MSET/MGET 같은 집계 명령도 같은 목적으로 쓸 수 있어요. Redis 2.4부터는 여러 명령이 모든 데이터 타입에 가변 인자(variadic parameter)를 지원하기도 해요.
몇 가지 지침을 정리하면:
- 여유가 된다면 서버를 VM보다 물리 머신에 두는 편이 좋아요.
- 서버에 접속/해제를 반복하지 마세요. 특히 웹 기반 애플리케이션에서 그런데, 연결은 가능한 오래 유지하세요.
- 클라이언트가 서버와 같은 호스트에 있다면 Unix domain 소켓을 사용하세요.
- 파이프라이닝보다는 집계 명령(MSET/MGET)이나 가변 인자 명령을 선호하세요.
- 왕복 시퀀스보다는 파이프라이닝을 선호하세요.
- 명령의 결과가 다음 명령의 입력이 되는 경우처럼 순수 파이프라이닝으로 처리하기 어려운 경우에는 Redis의 Lua 서버 사이드 스크립팅이 도움돼요.
리눅스에서는 프로세스 배치(taskset), cgroups, 실시간 우선순위(chrt), NUMA 설정(numactl), 저지연 커널 같은 걸로 더 나은 지연을 얻는 사람도 있어요. 다만 vanilla Redis는 단일 CPU 코어에 바인딩하기엔 적합하지 않아요. Redis는 BGSAVE나 BGREWRITEAOF처럼 CPU를 극도로 소모하는 백그라운드 작업을 fork로 만들 수 있는데, 이런 작업은 메인 이벤트 루프와 절대 같은 코어에서 실행되면 안 돼요. 이런 시스템 수준 최적화는 대부분의 경우 필요 없으니, 정말 필요하고 익숙할 때만 하세요.
Redis의 싱글 스레드 특성
Redis는 대부분 싱글 스레드 설계를 써요. 단일 프로세스가 멀티플렉싱(multiplexing) 이라는 기법으로 모든 클라이언트 요청을 처리해서, 어떤 순간에는 하나의 요청만 처리하고 모든 요청을 순차적으로 서비스해요. Node.js와 동작 방식이 비슷하죠. 그런데도 이 제품들이 느리게 느껴지지 않는 이유는, 개별 요청을 처리하는 시간이 짧기 때문이기도 하지만, 무엇보다 소켓에서 데이터를 읽거나 쓰는 같은 시스템 콜에서 블로킹되지 않도록 설계됐기 때문이에요.
"대부분" 싱글 스레드라고 한 이유는, Redis 2.4부터 주로 디스크 I/O 같은 느린 I/O 작업을 처리하기 위해 스레드를 백그라운드에서 사용하지만, 모든 요청을 단일 스레드로 서비스한다는 사실 자체는 변하지 않기 때문이에요.
느린 명령(slow command)으로 인한 지연
싱글 스레드의 결과로, 요청 하나를 느리게 처리하면 다른 모든 클라이언트가 그 요청이 끝날 때까지 기다려야 해요. GET, SET, LPUSH 같은 일반 명령은 상수(그리고 아주 짧은) 시간에 실행되므로 문제가 없지만, SORT, LREM, SUNION처럼 많은 요소를 대상으로 하는 명령은 다를 수 있어요. 예를 들어 두 개의 큰 set의 교집합(SUNION 등)은 꽤 오래 걸릴 수 있어요.
모든 명령의 알고리즘 복잡도는 문서화되어 있으니, 익숙하지 않은 명령을 쓸 때는 체계적으로 확인하는 습관이 좋아요. 지연이 걱정된다면 요소가 많은 값에 느린 명령을 쓰지 말거나, 느린 쿼리를 모두 실행하는 복제본(replica)을 두는 방법이 있어요. 느린 명령은 Redis Slow Log 기능으로 모니터링할 수 있어요.
또 top, htop, prstat 같은 프로세스별 모니터링 도구로 Redis 메인 프로세스의 CPU 사용률을 확인해 보세요. 트래픽은 많지 않은데 CPU 사용률이 높다면 보통 느린 명령이 쓰이고 있다는 신호예요.
중요한 주의사항: 상용 환경에서 느린 명령으로 인한 지연을 만드는 아주 흔한 원인은 KEYS 명령이에요. KEYS는 문서에도 나와 있듯 디버깅 목적으로만 써야 해요. Redis 2.8부터 키 공간이나 큰 컬렉션을 점진적으로 순회하는 SCAN, SSCAN, HSCAN, ZSCAN 명령이 도입됐으니 이걸 사용하세요.
fork로 인한 지연
RDB 파일을 백그라운드로 생성하거나, AOF 지속성을 쓴다면 Append Only File을 rewrite하려면 Redis가 백그라운드 프로세스를 fork해야 해요. 이 fork 연산(메인 스레드에서 실행) 자체가 지연을 만들 수 있어요.
fork는 대부분의 Unix 계열 시스템에서 비싼 연산이에요. 프로세스에 연결된 많은 객체를 복사해야 하고, 특히 가상 메모리 메커니즘과 관련된 page table을 복사하는 게 부담이 커요.
예를 들어 Linux/AMD64 시스템에서 메모리는 4kB 페이지로 나뉘고, 각 프로세스는 주소 공간의 페이지당 하나씩 포인터를 담은 page table을 저장해요. 그래서 24GB짜리 큰 Redis 인스턴스는 24GB / 4kB * 8 = 48MB의 page table이 필요해요. 백그라운드 저장을 수행하면 이 인스턴스를 fork하면서 48MB 메모리를 할당하고 복사해야 하죠. 특히 큰 메모리 청크의 할당과 초기화가 비싼 VM에서는 시간과 CPU를 많이 잡아먹어요.
시스템별 fork 시간
현대 하드웨어는 page table 복사가 꽤 빠르지만, Xen은 그렇지 않아요. Xen의 문제는 가상화 일반이 아니라 Xen 특유의 문제예요. VMware나 Virtual Box를 쓰면 느린 fork 시간이 생기지 않아요. 좋은 소식은 EC2 HVM 기반의 새 인스턴스 유형은 fork 시간이 훨씬 좋아서 물리 서버에 거의 필적한다는 것. 예를 들어 m3.medium 이상을 쓰면 좋은 결과를 얻어요.
- VMware의 Linux beefy VM: 6.0GB RSS를 fork하는 데 77ms (GB당 12.8ms)
- 물리 머신의 Linux (Unknown HW): 6.1GB RSS fork에 80ms (GB당 13.1ms)
- 물리 머신의 Linux (Xeon @ 2.27Ghz): 6.9GB RSS를 62ms에 fork (GB당 9ms)
- 6sync (KVM)의 Linux VM: 360MB RSS를 8.2ms에 fork (GB당 23.3ms)
- EC2 구형 인스턴스 유형 (Xen)의 Linux VM: 6.1GB RSS를 1460ms에 fork (GB당 239.3ms)
- EC2 신형 인스턴스 유형 (Xen)의 Linux VM: 1GB RSS를 10ms에 fork (GB당 10ms)
- Linode (Xen)의 Linux VM: 0.9GB RSS를 382ms에 fork (GB당 424ms)
보시다시피 Xen에서 돌아가는 일부 VM은 성능 손실이 한 자릿수에서 두 자릿수까지 커요. EC2 사용자에게 드리는 조언은 단순해요. HVM 기반 최신 인스턴스를 쓰세요.
transparent huge pages로 인한 지연
리눅스 커널에서 transparent huge pages가 활성화돼 있으면, 디스크에 지속성을 쓰기 위해 fork를 호출한 뒤 Redis가 큰 지연 패널티를 겪어요. huge pages가 이런 문제를 만드는데:
- fork가 호출되면 공유 huge pages를 가진 두 프로세스가 생성돼요.
- 바쁜 인스턴스에서 몇 번의 이벤트 루프 실행이 몇 천 개의 페이지를 대상으로 하는 명령을 만들어, 프로세스 메모리 거의 전체의 copy-on-write가 발생해요.
- 이로 인해 큰 지연과 큰 메모리 사용이 발생해요.
다음 명령으로 transparent huge pages를 반드시 비활성화하세요.
echo never > /sys/kernel/mm/transparent_hugepage/enabled
스와핑으로 인한 지연
리눅스(와 다른 많은 현대 운영체제)는 시스템 메모리를 효율적으로 쓰기 위해 메모리 페이지를 디스크로 옮겼다 돌려놓는 작업을 해요. 커널이 Redis 페이지를 메모리에서 스왑 파일로 옮기면, 그 페이지에 저장된 데이터를 Redis가 사용할 때(예: 그 메모리 페이지에 있는 키에 접근할 때) 커널이 Redis 프로세스를 중지시켜 페이지를 다시 메인 메모리로 가져와요. 이는 이미 메모리에 있는 페이지에 접근하는 것과 비교해 랜덤 I/O를 수반하는 느린 연산이라, Redis 클라이언트가 겪는 비정상적인 지연으로 이어져요.
커널이 Redis 메모리 페이지를 디스크로 옮기는 주된 이유는 세 가지예요.
- 실행 중인 프로세스들이 사용 가능한 물리 메모리보다 더 많은 메모리를 요구해서 시스템이 메모리 압박을 받는 경우. 가장 단순한 예는 그냥 Redis가 사용 가능한 메모리보다 더 많은 메모리를 쓰는 거예요.
- Redis 인스턴스 데이터 세트(또는 일부)가 거의 완전히 유휴 상태(클라이언트가 접근하지 않음)라 커널이 유휴 메모리 페이지를 디스크로 스왑할 수 있는 경우. 이 문제는 아주 드문데, 적당히 느린 인스턴스라도 모든 메모리 페이지를 자주 건드려 커널이 모든 페이지를 메모리에 유지하게 하기 때문이에요.
- 일부 프로세스가 시스템에 대량의 읽기/쓰기 I/O를 발생시키는 경우. 파일은 보통 캐시되므로 커널이 파일시스템 캐시를 늘리도록 압박하고, 결과적으로 스와핑 활동이 생겨요. 여기에는 큰 파일을 만드는 Redis RDB 및/또는 AOF 백그라운드 스레드도 포함돼요.
다행히 리눅스는 이 문제를 조사할 좋은 도구를 제공하니, 스와핑으로 인한 지연이 의심될 때 가장 간단한 일은 실제로 그런지 확인하는 거예요.
먼저 디스크에 스왑된 Redis 메모리 양을 확인해요. Redis 인스턴스의 pid를 얻어야 하는데:
$ redis-cli info | grep process_id
process_id:5454
이제 이 프로세스의 /proc 파일 시스템 디렉터리로 들어가요.
$ cd /proc/5454
여기서 smaps라는 파일을 볼 수 있는데, 이 파일은 Redis 프로세스의 메모리 레이아웃을 설명해요(리눅스 2.6.16 이상 가정). 이 파일은 프로세스 메모리 맵에 대한 아주 상세한 정보를 담고 있고, 그중 Swap 필드가 우리가 찾는 값이에요. 다만 Swap 필드가 하나만 있는 건 아니에요. smaps 파일에는 프로세스의 서로 다른 메모리 맵이 들어 있기 때문이에요.
우리가 관심 있는 건 프로세스가 스왑한 모든 메모리이므로, 파일 전체에서 Swap 필드를 grep해요.
$ cat smaps | grep 'Swap:'
Swap: 0 kB
Swap: 0 kB
...
Swap: 12 kB
Swap: 156 kB
Swap: 8 kB
...
모두 0 kB이거나 가끔 4k 항목이 보이는 정도면 완전히 정상이에요. 실제 웹사이트 예시처럼 몇몇 항목에서 더 많은 스왑 페이지가 보인다면, 실제 문제인지 확인하기 위해 메모리 맵의 크기도 함께 출력하는 명령으로 바꿔요.
$ cat smaps | egrep '^(Swap|Size)'
Size: 316 kB Swap: 0 kB
...
Size: 720896 kB Swap: 12 kB
Size: 4096 kB Swap: 156 kB
...
출력에서 720896kB 맵(12kB만 스왑)과 다른 맵의 156kB 스왑이 보이는데, 기본적으로 우리 메모리의 아주 일부만 스왑됐으니 전혀 문제가 되지 않아요.
대신 프로세스 메모리의 상당 부분이 디스크에 스왑됐다면 지연 문제가 스와핑과 관련됐을 가능성이 높아요. 그 경우 vmstat 명령으로 추가 확인할 수 있어요.
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu----
r b swpd free buff cache si so bi bo in cs us sy id wa
0 0 3980 697932 147180 1406456 0 0 2 2 2 0 4 4 91 0
0 0 3980 697428 147180 1406580 0 0 0 0 19088 16104 9 6 84 0
...
우리에게 필요한 흥미로운 부분은 si와 so 두 열인데, 스왑 파일에서 이리저리 옮겨진 메모리 양을 세요. 이 두 열에서 0이 아닌 값이 보이면 시스템에 스와핑 활동이 있다는 뜻이에요.
마지막으로 iostat 명령으로 시스템 전반의 I/O 활동을 확인할 수 있어요.
$ iostat -xk 1
avg-cpu: %user %nice %system %iowait %steal %idle
13.55 0.04 2.92 0.53 0.00 82.95
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 0.77 0.00 0.01 0.00 0.40 0.00 73.65 0.00 3.62 2.58 0.00
sdb 1.27 4.75 0.82 3.54 38.00 32.32 32.19 0.11 24.80 4.24 1.85
지연 문제가 Redis 메모리가 디스크에 스왑돼서 생긴 거라면, 시스템의 메모리 압박을 낮춰야 해요. Redis가 사용 가능한 메모리보다 더 많이 쓰고 있다면 RAM을 늘리거나, 같은 시스템에서 메모리를 많이 먹는 다른 프로세스 실행을 피하는 방식으로요.
AOF와 디스크 I/O로 인한 지연
또 다른 지연 원인은 Redis의 Append Only File(AOF) 지원이에요. AOF는 기본적으로 두 개의 시스템 콜로 작업을 수행해요. 데이터를 append only file에 쓰는 write(2), 그리고 사용자가 지정한 내구성 수준을 보장하기 위해 커널 파일 버퍼를 디스크로 비우는 fdatasync(2)가 그 두 개예요.
write(2)는 시스템 전체 sync가 진행 중일 때나, 출력 버퍼가 가득 차 커널이 새 쓰기를 받으려면 디스크로 flush해야 할 때 블로킹될 수 있어요.
fdatasync(2)는 더 심각한 지연 원인인데, 많은 커널·파일시스템 조합에서 몇 밀리초에서 몇 초까지 걸릴 수 있어서 특히 다른 프로세스가 I/O를 하고 있을 때 문제예요. 그래서 가능할 때 Redis는 2.4부터 fdatasync(2) 호출을 별도 스레드에서 수행해요.
AOF를 쓸 때 설정에 따라 지연의 양과 원인이 어떻게 달라지는지 볼게요. AOF는 appendfsync 설정 옵션으로 디스크에 fsync를 수행하는 방식을 세 가지로 정할 수 있는데, 이 설정은 CONFIG SET 명령으로 런타임에 바꿀 수도 있어요.
- appendfsync를 no로 설정하면 Redis는 fsync를 전혀 수행하지 않아요. 이 설정에서 유일한 지연 원인은
write(2)예요. 이 경우 보통 해결책이 없는데, 단순히 디스크가 Redis가 데이터를 받는 속도를 따라가지 못해서예요. 다만 디스크가 다른 프로세스의 I/O로 심하게 느려지지 않았다면 흔하지 않아요. - appendfsync를 everysec으로 설정하면 Redis는 매초 fsync를 수행해요. 별도 스레드를 쓰고, fsync가 아직 진행 중이면 버퍼를 사용해
write(2)호출을 최대 2초까지 지연시켜요(리눅스에서 같은 파일에 fsync가 진행 중이면 write가 블로킹되기 때문). 다만 fsync가 너무 오래 걸리면 결국 fsync가 진행 중이더라도write(2)호출을 수행하는데, 이것이 지연의 원인이 될 수 있어요. - appendfsync를 always로 설정하면 모든 쓰기 연산에서, 클라이언트에 OK 코드로 응답하기 전에 fsync를 수행해요(실제로는 Redis가 동시에 실행된 많은 명령을 단일 fsync로 묶으려 해요). 이 모드는 전반적으로 성능이 매우 낮아서, 빠른 디스크와 짧은 시간 안에 fsync를 수행할 수 있는 파일시스템 구현을 강력히 권장해요.
대부분의 Redis 사용자는 appendfsync를 no 또는 everysec으로 설정해요. 지연을 최소화하려면 같은 시스템에서 I/O를 하는 다른 프로세스를 피하라는 게 조언이고요. SSD 디스크가 도움이 되지만, 보통은 디스크에 여유가 있으면 SSD가 아니어도 append only file은 성능이 좋아요. Redis는 seek 없이 append only file에 쓰기 때문이에요.
append only file과 관련된 지연 문제를 조사하고 싶다면, 리눅스에서 strace 명령을 쓸 수 있어요.
sudo strace -p $(pidof redis-server) -T -e trace=fdatasync
위 명령은 Redis가 메인 스레드에서 수행하는 모든 fdatasync(2) 시스템 콜을 보여줘요. appendfsync가 everysec으로 설정됐을 때 백그라운드 스레드가 수행하는 fdatasync는 위 명령으로는 안 보이는데, 그걸 보려면 strace에 -f 스위치를 추가하면 돼요.
fdatasync와 write를 둘 다 보고 싶다면:
sudo strace -p $(pidof redis-server) -T -e trace=fdatasync,write
다만 write(2)는 클라이언트 소켓에 데이터를 쓰는 데도 쓰이므로, 디스크 I/O와 무관한 게 너무 많이 보일 거예요. strace에 느린 시스템 콜만 보여달라고 할 방법은 없어서, 보통 다음과 같은 명령을 써요.
sudo strace -f -p $(pidof redis-server) -T -e trace=fdatasync,write 2>&1 | grep -v '0.0' | grep -v unfinished
만료(expire)로 인한 지연
Redis는 만료된 키를 두 가지 방식으로 제거해요.
- 하나는 lazy(지연) 방식으로, 명령이 키를 요청했을 때 이미 만료된 걸 발견하면 그때 제거하는 거예요.
- 하나는 active(능동) 방식으로, 100밀리초마다 몇 개의 키를 만료시키는 거예요.
active 만료는 적응형(adaptive)으로 설계됐어요. 만료 사이클이 100밀리초마다(초당 10회) 시작되고 다음을 수행해요.
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP개의 키를 샘플링해서, 이미 만료된 키를 모두 제거해요.- 샘플링한 키 중 25% 이상이 만료된 것으로 발견되면 반복해요.
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP가 기본 20으로 설정돼 있고 프로세스가 초당 10회 실행되므로, 보통 초당 200개의 키만 능동 만료돼요. 이는 이미 만료됐지만 오랫동안 접근되지 않은 키도 lazy 알고리즘의 도움 없이 DB를 충분히 빨리 청소하기에 충분해요. 동시에 초당 200개의 키만 만료시키는 건 Redis 인스턴스의 지연에 영향을 주지 않아요.
그런데 이 알고리즘은 적응형이라, 샘플링한 키 집합에서 25% 이상이 이미 만료됐으면 루프를 반복해요. 알고리즘이 초당 10회 실행되므로, 이는 "무작위 샘플의 25% 이상이 같은 초에 만료되는" 불운한 사건을 뜻해요.
간단히 말하면 데이터베이스에 같은 초에 만료되는 키가 아주 많고, 그 키들이 만료 키 전체 인구의 25% 이상을 차지하면, Redis는 이미 만료된 키의 비율을 25% 아래로 낮추기 위해 블로킹할 수 있어요.
이 접근 방식은 이미 만료된 키에 너무 많은 메모리를 쓰지 않기 위해 필요하고, 보통은 완전히 무해해요. 큰 수의 키가 정확히 같은 초에 만료되는 건 드물기 때문이죠. 다만 사용자가 EXPIREAT을 같은 Unix 시간으로 대량으로 사용했다면 불가능한 일은 아니에요.
요약하자면, 같은 순간에 만료되는 키가 많으면 지연의 원인이 될 수 있다는 점을 알아두세요.
Redis 소프트웨어 워치독 (Software Watchdog)
Redis 2.6에 도입된 Redis Software Watchdog은, 이런저런 이유로 일반적인 도구로는 분석이 안 되는 지연 문제를 추적하기 위한 디버깅 도구예요.
소프트웨어 워치독은 실험적 기능이에요. 프로덕션 환경에서 쓰도록 설계됐지만, Redis 서버의 정상 실행과 예상치 못한 상호작용을 할 수 있으니 진행하기 전에 데이터베이스를 백업해야 해요.
다른 수단으로는 문제를 추적할 방법이 없을 때 마지막 수단으로만 쓰는 게 중요해요. 이 기능은 이렇게 동작해요:
- 사용자가
CONFIG SET명령으로 소프트웨어 워치독을 활성화해요. - Redis가 스스로를 계속 모니터링해요.
- Redis가 서버를 충분히 빨리 반환되지 않는 어떤 연산에 블로킹됐다고 감지하고, 그게 지연 문제의 원인일 수 있다고 판단하면, 서버가 어디서 블로킹됐는지에 대한 저수준 보고서를 로그 파일에 덤프해요.
- 사용자가 메시지에 워치독 보고서를 포함해 Redis Google Group에 개발자에게 메시지를 보내요.
이 기능은 이미 실행 중인 인스턴스에서 디버깅 목적으로만 활성화하도록 설계됐기 때문에, redis.conf 파일로는 활성화할 수 없어요. 활성화하려면 다음처럼 해요.
CONFIG SET watchdog-period 500
주기는 밀리초 단위예요. 위 예시에서는 서버가 500밀리초 이상의 지연을 감지했을 때만 지연 문제를 기록하도록 지정한 거예요. 설정 가능한 최소 주기는 200밀리초예요.
워치독을 다 쓰면 watchdog-period 파라미터를 0으로 설정해 꺼요. 중요: 이걸 꼭 기억하세요. 필요 이상으로 인스턴스에 워치독을 켜 두는 건 일반적으로 좋지 않아요.
워치독이 설정된 지연보다 긴 지연을 감지하면 로그 파일에 다음과 같은 내용이 출력돼요.
[8547 | signal handler] (1333114359) --- WATCHDOG TIMER EXPIRED ---
/lib/libc.so.6(nanosleep+0x2d) [0x7f16b5c2d39d]
...
./redis-server(main+0x2b6) [0x418556]
/lib/libc.so.6(__libc_start_main+0xfd) [0x7f16b5ba1c4d]
./redis-server() [0x411099]
------
예시에서는 서버를 블로킹하기 위해 DEBUG SLEEP 명령을 사용했어요. 서버가 다른 컨텍스트에서 블로킹되면 스택 트레이스도 달라져요. 워치독 스택 트레이스를 여러 개 수집했다면 Redis Google Group에 전부 보내는 걸 권장해요. 트레이스가 많을수록 인스턴스 문제를 이해하기 쉬워져요.
지연 모니터링 (Latency monitoring)
Redis는 인스턴스당 초당 많은 쿼리를 처리하면서도 평균 응답 시간과 최악의 지연에 엄격한 요구사항을 가지는, 까다로운 사용 사례에 자주 쓰여요. Redis는 인메모리 시스템이지만 디스크로 지속성을 쓸 때처럼 운영체제와 다양한 방식으로 상호작용하고, 명령 집합도 풍부해요. 어떤 명령은 상수나 로그 시간에 실행되는 빠른 명령이지만, O(N)인 느린 명령은 지연 스파이크를 만들 수 있어요.
또 Redis는 싱글 스레드예요. 코어당 수행할 수 있는 작업량과 제공하는 지연 수치 관점에서는 보통 장점이지만, 지연 관점에서는 도전 과제가 돼요. 단일 스레드가 키 만료 같은 특정 작업을, 서비스 중인 다른 클라이언트에 영향을 주지 않는 방식으로 점진적으로 수행해야 하기 때문이에요.
이런 이유로 Redis 2.8.13은 Latency Monitoring(지연 모니터링) 이라는 새 기능을 도입했어요. 사용자가 가능한 지연 문제를 확인하고 해결하는 데 도움을 주는 기능이에요. 지연 모니터링은 개념적으로 다음 요소들로 구성돼요:
- 다양한 지연 민감 코드 경로를 샘플링하는 latency hooks
- 이벤트별로 나눠 지연 스파이크를 기록하는 시계열(time series)
- 시계열에서 원본 데이터를 가져오는 보고 엔진(reporting engine)
- 측정값에 따라 사람이 읽을 수 있는 보고서와 힌트를 제공하는 분석 엔진(analysis engine)
지연 모니터링의 전반적인 주제에 대한 더 자세한 내용은 앞서 다룬 "지연 문제 진단" 문서를 참고하세요. 여기서는 모니터링 서브시스템의 세부 사항을 다룰게요.
이벤트와 시계열
모니터링되는 각각의 코드 경로는 서로 다른 이름을 가지는데 이를 이벤트(event) 라고 불러요. 예를 들어 command는 느리게 실행될 수 있는 일반 명령의 지연 스파이크를 측정하는 이벤트이고, fast-command는 O(1)과 O(log N) 명령을 모니터링하는 이벤트 이름이에요. 다른 이벤트들은 좀 덜 일반적이고 Redis가 수행하는 특정 연산을 모니터링해요. 예를 들어 fork 이벤트는 Redis가 fork(2) 시스템 콜을 실행하는 데 걸린 시간만 모니터링해요.
지연 스파이크(latency spike)는 설정한 지연 임계값보다 실행에 더 오래 걸리는 이벤트예요. 모니터링되는 이벤트마다 별도의 시계열이 연결돼요. 시계열은 이렇게 동작해요:
- 지연 스파이크가 발생할 때마다 해당 시계열에 기록돼요.
- 각 시계열은 160개의 요소로 구성돼요.
- 각 요소는 지연 스파이크가 측정된 Unix 타임스탬프와 이벤트 실행에 걸린 밀리초 수로 이루어진 쌍이에요.
- 같은 초에 발생한 같은 이벤트의 지연 스파이크는 최대 지연값을 취해 병합돼요. 낮은 임계값 때문에 특정 이벤트에 지속적인 지연 스파이크가 측정돼도, 최소 160초 분량의 히스토리가 유지돼요.
- 모든 요소에 대해 역대 최대 지연을 기록해요.
모니터링 서브시스템은 다음 이벤트의 실행 시간에서 지연 스파이크를 기록해요.
command: 일반 명령fast-command: O(1) 및 O(log N) 명령fork:fork(2)시스템 콜rdb-unlink-temp-file:unlink(2)시스템 콜aof-fsync-always:appendfsync allways정책이 호출한fsync(2)시스템 콜aof-write: AOF에 쓰기 —write(2)시스템 콜의 catchall 이벤트aof-write-pending-fsync: 보류 중인 fsync가 있을 때의write(2)시스템 콜aof-write-active-child: 활성 자식 프로세스가 있을 때의write(2)시스템 콜aof-write-alone: 보류 중인 fsync도 활성 자식 프로세스도 없을 때의write(2)시스템 콜aof-fstat:fstat(2)시스템 콜aof-rename:BGREWRITEAOF완료 후 임시 파일 이름을 바꾸는rename(2)시스템 콜aof-rewrite-diff-write:BGREWRITEAOF수행 중 누적된 차이를 쓰기active-defrag-cycle: 활성 조각 모음(active defragmentation) 사이클expire-cycle: 만료 사이클eviction-cycle: 제거(eviction) 사이클eviction-del: 제거 사이클 중 발생한 삭제
지연 모니터링 활성화하기
한 사용 사례에서 "높은 지연"인 것이 다른 사용 사례에서는 높은 지연이 아닐 수 있어요. 어떤 애플리케이션은 모든 쿼리가 1밀리초 미만으로 서비스되길 요구할 수도 있고, 어떤 애플리케이션은 소수의 클라이언트가 가끔 2초 지연을 겪는 걸 허용할 수도 있어요.
지연 모니터를 활성화하는 첫 단계는 지연 임계값(latency threshold) 을 밀리초 단위로 설정하는 거예요. 지정한 임계값보다 오래 걸리는 이벤트만 지연 스파이크로 기록돼요. 임계값은 자신의 요구에 맞게 설정해야 해요. 예를 들어 애플리케이션이 허용하는 최대 지연이 100밀리초라면, 서버를 100밀리초 이상 블로킹하는 모든 이벤트를 기록하도록 임계값을 설정하는 거예요.
프로덕션 서버에서 런타임에 지연 모니터를 활성화하는 명령은 다음과 같아요.
CONFIG SET latency-monitor-threshold 100
모니터링은 기본적으로 꺼져 있어요(임계값 0). 지연 모니터링의 실제 비용이 거의 0에 가까움에도 불구하고요. 메모리 요구사항이 아주 작지만, 잘 동작하는 Redis 인스턴스의 기본 메모리 사용량을 올릴 이유가 없기 때문이에요.
LATENCY 명령으로 정보 보고받기
지연 모니터링 서브시스템의 사용자 인터페이스는 LATENCY 명령이에요. 다른 많은 Redis 명령처럼 LATENCY도 동작을 바꾸는 서브명령을 받아요. 서브명령들은 다음과 같아요.
LATENCY LATEST: 모든 이벤트에 대한 최신 지연 샘플을 반환해요.LATENCY HISTORY: 주어진 이벤트에 대한 지연 시계열을 반환해요.LATENCY RESET: 하나 이상의 이벤트에 대한 지연 시계열 데이터를 초기화해요.LATENCY GRAPH: 이벤트의 지연 샘플을 ASCII-art 그래프로 렌더링해요.LATENCY DOCTOR: 사람이 읽을 수 있는 지연 분석 보고서로 응답해요.
각 서브명령의 자세한 내용은 각 명령 문서 페이지를 참고하세요.
메모리 최적화 (Memory optimization)
Redis의 메모리 사용량을 최적화하는 전략을 살펴볼게요.
작은 집계 데이터 타입의 특수 인코딩
Redis 2.2부터 많은 데이터 타입이 일정 크기까지는 더 적은 공간을 쓰도록 최적화돼 있어요. Hash, List, 정수로만 구성된 Set, Sorted Set은 요소 수가 주어진 수보다 작고 각 요소 크기가 최대 크기 이하일 때, 최대 10배까지 적게 메모리를 쓰는 방식으로 인코딩돼요(평균 절약은 5배). 이는 사용자와 API 관점에서 완전히 투명해요. CPU/메모리 트레이드오프이므로, 특수 인코딩 타입의 최대 요소 수와 최대 요소 크기를 redis.conf 지시문으로 조정할 수 있어요(기본값 표시).
Redis <= 6.2:
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
set-max-intset-entries 512
Redis >= 7.0:
hash-max-listpack-entries 512
hash-max-listpack-value 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512
Redis >= 7.2 에서는 다음 지시문도 사용할 수 있어요.
set-max-listpack-entries 128
set-max-listpack-value 64
특수 인코딩된 값이 설정된 최대 크기를 넘어서면, Redis는 자동으로 일반 인코딩으로 변환해요. 이 연산은 작은 값에서는 아주 빠르지만, 특수 인코딩 값을 훨씬 큰 집계 타입에 쓰도록 설정을 바꿨다면 변환 시간을 확인하기 위해 벤치마크와 테스트를 실행하라는 게 권장사항이에요.
32비트 인스턴스 사용하기
Redis가 32비트 타깃으로 컴파일되면 포인터가 작아서 키당 메모리를 훨씬 적게 써요. 다만 이런 인스턴스는 최대 메모리 사용량 4GB로 제한돼요. make 32bit로 32비트 바이너리로 컴파일할 수 있어요. RDB와 AOF 파일은 32비트와 64비트 인스턴스 간에(그리고 물론 little/big endian 간에도) 호환되므로, 문제없이 32비트와 64비트를 오갈 수 있어요.
비트·바이트 수준 연산
Redis 2.2는 새 비트·바이트 수준 연산인 GETRANGE, SETRANGE, GETBIT, SETBIT를 도입했어요. 이 명령들을 쓰면 Redis string 타입을 임의 접근 배열처럼 다룰 수 있어요. 예를 들어 사용자가 고유한 증가 정수로 식별되는 애플리케이션이 있다면, 비트맵으로 메일링 리스트 가입 여부를 저장할 수 있어요(가입자는 비트를 켜고, 탈퇴자는 비트를 끄는 식). 1억 명의 사용자라면 이 데이터는 Redis 인스턴스에서 단 12메가바이트만 차지해요. GETRANGE와 SETRANGE를 써서 사용자당 1바이트씩 저장할 수도 있어요. 이건 예시일 뿐이고, 이 새 프리미티브들로 여러 문제를 아주 작은 공간에 모델링할 수 있어요.
가능하면 해시(Hash)를 사용하세요
작은 해시는 아주 작은 공간으로 인코딩되므로, 가능하면 데이터를 해시로 표현하려고 해야 해요. 예를 들어 웹 애플리케이션의 사용자 객체가 있다면, name, surname, email, password를 각각 다른 키로 두지 말고 필요한 모든 필드를 단일 해시에 담으세요.
이 주제에 대해 더 알고 싶다면 다음 섹션을 읽어보세요.
해시로 Redis 위에 아주 메모리 효율적인 평범한 키-값 저장소 추상화하기
이 섹션 제목이 조금 무섭게 들릴 수 있는데, 자세히 설명해 드릴게요.
기본적으로 Redis로 값이 문자열뿐인 평범한 키-값 저장소를 모델링할 수 있는데, 이는 Redis 평범한 키보다 메모리 효율적일 뿐 아니라 memcached보다도 훨씬 메모리 효율적이에요.
몇 가지 사실부터 시작할게요. "키 몇 개"는 "필드 몇 개짜리 해시를 담은 키 하나"보다 훨씬 많은 메모리를 써요. 어떻게 가능할까요? 우리는 트릭을 씁니다. 이론적으로 상수 시간(빅오 표기로 O(1)) 안에 조회를 수행하려면 해시 테이블처럼 평균적으로 상수 시간 복잡도를 가진 데이터 구조가 필요해요.
하지만 해시는 대부분 필드가 몇 개뿐이에요. 해시가 작을 때는 대신 O(N) 데이터 구조로 인코딩할 수 있는데, 길이-접두사 키-값 쌍으로 이루어진 선형 배열 같은 방식이에요. N이 작을 때만 이렇게 하므로 HGET과 HSET 명령의 분할 상환(amortized) 시간은 여전히 O(1)이에요. 해시는 요소 수가 너무 커지는 순간 실제 해시 테이블로 변환되죠(제한은 redis.conf에서 설정 가능).
이는 시간 복잡도 관점에서만 잘 동작하는 게 아니라, 상수 시간 관점에서도 잘 동작해요. 키-값 쌍의 선형 배열은 CPU 캐시와 잘 어울리거든요(해시 테이블보다 캐시 지역성이 좋아요).
다만 해시 필드와 값은 항상 완전한 기능의 Redis 객체로 표현되는 게 아니므로, 해시 필드는 실제 키처럼 연관된 TTL(만료)을 가질 수 없고 문자열만 담을 수 있어요. 하지만 우리는 이걸 괜찮게 여겨요. 원래 해시 데이터 타입 API를 설계할 때 의도한 바였거든요(중첩 데이터 구조나 단일 필드 만료를 허용하지 않는 쪽, 즉 단순함을 기능보다 신뢰해요).
그러니 해시는 메모리 효율적이에요. 객체를 표현하거나 관련 필드 그룹이 있는 문제를 모델링할 때 유용하죠. 그런데 순수 키-값 비즈니스라면 어떨까요?
Redis를 많은 작은 객체의 캐시로 쓰고 싶다고 상상해 보세요. JSON 인코딩 객체, 작은 HTML 조각, 단순한 key -> boolean 값 등이 될 수 있어요. 기본적으로 작은 키와 값을 가진 string -> string 맵이라면 뭐든요.
이제 캐시하려는 객체에 번호가 매겨져 있다고 가정해요.
- object:102393
- object:1234
- object:5
이렇게 할 수 있어요. 새 값을 설정하기 위해 SET 연산을 할 때마다, 키를 두 부분으로 쪼개요. 한 부분은 키로, 다른 한 부분은 해시의 필드 이름으로 사용해요. 예를 들어 "object:1234"라는 객체는 실제로 이렇게 나뉘어요.
- Key: object:12
- Field: 34
즉 모든 문자에서 마지막 두 글자를 빼서 키로 쓰고, 마지막 두 글자를 해시 필드 이름으로 써요. 키를 설정하려면 다음 명령을 써요.
HSET object:12 34 somevalue
보시다시피 모든 해시는 결국 100개의 필드를 담게 되는데, 이것이 CPU와 절약된 메모리 사이의 최적 절충이에요.
이 스키마에서 또 중요한 점: 이런 방식은 캐시한 객체 수와 무관하게 모든 해시가 대략 100개의 필드를 가지게 돼요. 객체가 항상 숫자로 끝나고 랜덤 문자열이 아니기 때문이에요. 어떤 면에서 마지막 숫자는 암묵적 사전 샤딩(pre-sharding)의 한 형태로 볼 수 있어요.
작은 숫자는 어떨까요? object:2 같은 거요. 이 경우는 키 이름으로 "object:"만 쓰고, 숫자 전체를 해시 필드 이름으로 처리해요. 그래서 object:2와 object:10은 둘 다 "object:" 키 안에 들어가지만, 하나는 필드 "2", 다른 하나는 "10"으로 들어가요.
이렇게 하면 메모리를 얼마나 아낄까요? 다음 Ruby 프로그램으로 테스트할 수 있어요.
require 'rubygems'
require 'redis'
USE_OPTIMIZATION = true
def hash_get_key_field(key)
s = key.split(':')
if s[1].length > 2
{ key: s[0] + ':' + s[1][0..-3], field: s[1][-2..-1] }
else
{ key: s[0] + ':', field: s[1] }
end
end
def hash_set(r, key, value)
kf = hash_get_key_field(key)
r.hset(kf[:key], kf[:field], value)
end
def hash_get(r, key, value)
kf = hash_get_key_field(key)
r.hget(kf[:key], kf[:field], value)
end
r = Redis.new
(0..100_000).each do |id|
key = "object:#{id}"
if USE_OPTIMIZATION
hash_set(r, key, 'val')
else
r.set(key, 'val')
end
end
이것이 64비트 Redis 2.2 인스턴스에서의 결과예요.
- USE_OPTIMIZATION을 true로: 사용 메모리 1.7 MB
- USE_OPTIMIZATION을 false로: 사용 메모리 11 MB
한 자릿수 차이예요. 이 정도면 Redis가 아마도 현존하는 가장 메모리 효율적인 평범한 키-값 저장소라고 할 수 있어요.
경고: 이게 동작하려면 redis.conf에 다음과 같은 설정이 있어야 해요.
hash-max-zipmap-entries 256
또한 키와 값의 최대 크기에 맞춰 다음 필드도 설정해야 해요.
hash-max-zipmap-value 1024
해시가 지정된 요소 수나 요소 크기를 넘어설 때마다 실제 해시 테이블로 변환되고, 메모리 절약이 사라져요.
"왜 일반 키 공간에서 이걸 암묵적으로 처리해 줘서 내가 신경 안 쓰게 하지 않나요?"라고 물을 수 있어요. 이유는 두 가지예요. 하나는 우리가 트레이드오프를 명시적으로 만드는 경향이 있고, 이건 CPU, 메모리, 최대 요소 크기 사이의 분명한 트레이드오프이기 때문이에요. 두 번째는 최상위 키 공간이 만료, LRU 데이터 같은 흥미로운 것들을 많이 지원해야 하므로 일반적인 방식으로 이걸 처리하는 게 실용적이지 않기 때문이에요.
Redis의 철학(Redis Way)은 사용자가 시스템이 어떻게 동작할지 정확히 이해하고 최선의 절충안을 고를 수 있도록, 사용자가 시스템이 어떻게 동작하는지 이해해야 한다는 거예요.
메모리 할당
사용자 키를 저장하기 위해 Redis는 maxmemory 설정이 허용하는 만큼 최대 메모리를 할당해요(약간의 추가 할당은 가능하지만요). 정확한 값은 설정 파일에서 지정하거나 나중에 CONFIG SET으로 설정할 수 있어요(자세한 내용은 "메모리를 LRU 캐시로 사용하기" 참고). Redis가 메모리를 관리하는 방식에 주목할 점이 몇 가지 있어요.
- Redis는 키가 제거될 때 메모리를 항상 OS로 돌려주는(반환) 건 아니에요. 이건 Redis 특유의 일이 아니라 대부분의 malloc() 구현이 동작하는 방식이에요. 예를 들어 인스턴스를 5GB 데이터로 채운 뒤 2GB 상당을 제거하면, Redis가 사용자 메모리 약 3GB라고 주장해도 Resident Set Size(줄여서 RSS, 프로세스가 소비하는 메모리 페이지 수)는 여전히 5GB 정도일 수 있어요. 기본 할당자가 메모리를 쉽게 해제하지 못하기 때문인데, 제거된 키 대부분이 여전히 존재하는 다른 키와 같은 페이지에 할당돼 있었던 경우가 흔해요.
- 위 사실은 메모리를 최대 사용량(peak memory usage) 기준으로 프로비저닝해야 한다는 뜻이에요. 작업 부하가 가끔 10GB를 필요로 한다면, 대부분 5GB로 충분하더라도 10GB 기준으로 준비해야 해요.
- 다만 할당자는 똑똑해서 빈 메모리 청크를 재사용할 수 있어요. 5GB 데이터 세트 중 2GB를 해제한 뒤 다시 키를 추가하기 시작하면, 추가로 2GB 키를 추가하는 동안 RSS가 늘지 않고 안정적으로 유지되는 걸 볼 수 있어요. 할당자가 이전에 (논리적으로) 해제된 2GB 메모리를 재사용하려고 하기 때문이에요.
- 이 모든 이유로, 최대 사용량이 현재 사용 메모리보다 훨씬 큰 경우에는 조각화 비율(fragmentation ratio)이 신뢰할 수 없어요. 조각화는 실제로 사용된 물리 메모리(RSS 값)를 현재 사용 중인 메모리 양(Redis가 수행한 모든 할당의 합)으로 나눈 값으로 계산돼요. RSS가 최대 메모리를 반영하므로, (가상적으로) 사용 메모리가 낮고 키/값이 많이 해제됐는데 RSS가 높으면
RSS / mem_used비율이 매우 높아져요.
maxmemory가 설정되지 않으면 Redis는 마음대로 메모리를 계속 할당해서 결국 (점진적으로) 모든 여유 메모리를 먹어버릴 수 있어요. 그래서 일반적으로 어떤 제한을 설정하라고 권장해요. maxmemory-policy를 noeviction으로 설정할 수도 있는데, 이는 일부 구형 Redis 버전의 기본값이 아니에요. 이렇게 하면 제한에 도달했을 때 Redis가 쓰기 명령에 out-of-memory 오류를 반환해요. 이는 애플리케이션에 오류를 만들 수 있지만, 메모리 고갈로 인해 머신 전체가 죽는 일은 막아줘요.
CPU 프로파일링 (CPU profiling)
이번엔 on-CPU 프로파일링과 트레이싱을 위한 성능 엔지니어링 가이드를 다룰게요.
성능 체크리스트 채우기
Redis는 성능에 큰 중점을 두고 개발돼요. 모든 릴리스에서 아주 안정적이고 빠른 제품을 경험하도록 최선을 다해요. 그럼에도 Redis 효율을 개선할 여지를 찾고 있거나 성능 회귀 조사를 하고 있다면, Redis 성능을 모니터링하고 분석하는 간결하고 체계적인 방법이 필요해요.
이를 위해 다양한 방법론에 의존할 수 있는데, 의도하는 문제/분석 클래스에 따라 일부가 다른 것보다 더 적합해요. 선별된 방법론 목록과 단계는 Brendan Gregg가 다음 링크에 정리했어요. 병목이 무엇인지에 대한 질문에 답하기 위해 USE Method(Utilization, Saturation, Errors) 를 권장해요.
CPU가 병목인지 확인하기
이 가이드는 위 방법론 중 하나를 따라 시스템 건강을 완전히 점검하고 병목이 CPU임을 식별했다고 가정해요. 시간의 대부분이 I/O, 락, 타이머, paging/swap 등에 블로킹돼 보낸다고 식별했다면 이 가이드는 당신을 위한 게 아니에요.
빌드 전제조건
제대로 된 on-CPU 분석을 하려면 Redis(와 Redis Modules 같은 동적으로 로드된 라이브러리)가 트레이서가 사용할 수 있는 스택 트레이스를 제공해야 하는데, 먼저 이걸 고쳐야 할 수 있어요.
기본적으로 Redis는 -O2 스위치로 컴파일되는데(프로파일링 중에도 유지하려고 해요), 이는 컴파일러 최적화가 활성화됐다는 뜻이에요. 많은 컴파일러가 런타임 최적화로 프레임 포인터(레지스터 하나를 아끼기 위해)를 생략해서, 프레임 포인터 기반 스택 워킹을 깨뜨려요. 이렇게 하면 Redis 실행 파일이 빨라지지만, 동시에 Redis(와 다른 모든 프로그램)를 추적하기 어렵게 만들고, 훨씬 깊어질 수 있는(하지만 추적 불가능한) 콜 스택의 마지막 프레임 포인터에 on-CPU 시간을 잘못 집어넣을 수 있어요.
다음을 보장하는 게 중요해요.
- 디버그 정보가 있게: 컴파일 옵션
-g - 프레임 포인터 레지스터가 있게:
-fno-omit-frame-pointer - 프로덕션 실행 시간을 정확히 반영하도록 최적화로 실행:
-O2유지
Redis 메인 저장소 안에서 다음과 같이 할 수 있어요.
$ make REDIS_CFLAGS="-g -fno-omit-frame-pointer"
성능 회귀 및 잠재적 on-CPU 성능 개선 식별 도구
이 문서는 특히 on-CPU 리소스 병목 분석에 초점을 맞춰요. 즉, 스레드가 CPU를 사용하며 어디에서 CPU 사이클을 보내는지, 그리고 그 사이클이 계산에 효과적으로 쓰이는지 아니면 (블로킹이 아니라) 메모리 I/O, 캐시 미스 등을 기다리며 멈춰 있는지 이해하는 데 관심이 있어요.
이를 위해 perf, bcc 도구 같은 툴킷과 하드웨어 특화 PMC(Performance Monitoring Counters)에 의존해서:
- 핫스팟 분석(perf 또는 bcc 도구): 코드 실행을 프로파일링해 어떤 함수가 가장 많은 시간을 소비하는지, 즉 최적화 대상이 무엇인지 결정해요. 핫스팟을 수집·보고·시각화하는 두 가지 옵션을 perf 또는 bcc/BPF 트레이싱 도구로 보여줄게요.
- 콜 카운트 분석: 함수 호출을 포함한 이벤트를 세어 여러 콜/컴포넌트를 한 번에 연관시킬 수 있게 해주며, bcc/BPF 트레이싱 도구에 의존해요.
- 하드웨어 이벤트 샘플링: 메모리 I/O, 스톨 사이클, 캐시 미스를 포함한 CPU 동작을 이해하는 데 중요해요.
도구 전제조건
다음 단계는 Linux perf_events(일명 "perf"), bcc/BPF 트레이싱 도구, Brendan Gregg의 FlameGraph 저장소에 의존해요. 사전에 다음이 있다고 가정해요.
- 시스템에 perf 도구를 설치했음. 대부분의 리눅스 배포판은 커널 관련 패키지로 이걸 제공할 가능성이 높아요.
- bcc/BPF 설치 지침을 따라 bcc 툴킷을 설치했음.
- Brendan Gregg의 FlameGraph 저장소를 클론하고
difffolded.pl과flamegraph.pl파일에 접근 가능하게 해서, 접힌(folded) 스택 트레이스와 Flame Graph를 생성할 수 있게 했음.
perf 또는 eBPF로 핫스팟 분석 (스택 트레이스 샘플링)
일정 시간 간격으로 스택 트레이스를 샘플링해 CPU 사용률을 프로파일링하는 것은 성능에 중요한 코드 섹션(핫스팟)을 식별하는 빠르고 쉬운 방법이에요.
perf로 스택 트레이스 샘플링하기 — redis-server의 사용자 및 커널 레벨 스택을 특정 시간 동안, 예를 들어 60초 동안, 초당 999개 샘플 빈도로 프로파일링하려면:
$ perf record -g --pid $(pgrep redis-server) -F 999 -- sleep 60
기본적으로 perf record는 현재 작업 디렉터리에 perf.data 파일을 생성해요. 최소 콜 그래프 포함 임계값 0.5%로 콜 그래프(콜 체인, 스택 백트레이스) 출력으로 리포트하려면:
$ perf report -g "graph,0.5,caller"
Flame Graph로 기록된 프로파일 정보 시각화하기 — Flame graph는 빈번한 코드 경로를 빠르고 정확하게 시각화해줘요. Brendan Gregg의 오픈소스 프로그램으로 생성할 수 있는데, 접힌 스택 파일에서 인터랙티브 SVG를 만들어요. perf의 경우 생성된 perf.data를 캡처된 스택으로 변환하고 각각을 한 줄로 접어야 해요.
$ perf script > redis.perf.stacks
$ stackcollapse-perf.pl redis.perf.stacks > redis.folded.stacks
$ flamegraph.pl redis.folded.stacks > redis.svg
수집이 일어난 머신이 아닌 다른 머신에서 perf.data 내용을 분석할 수 있으려면, 기록 데이터 파일에서 찾은 build-id가 있는 모든 객체 파일을 perf.data와 함께 내보내야 해요. 이는 perf-archive.sh 스크립트로 쉽게 할 수 있어요.
$ perf-archive.sh perf.data
이제 perf report를 실행해야 하는 머신에서 다음을 실행하세요.
$ tar xvf perf.data.tar.bz2 -C ~/.debug
bcc/BPF의 profile로 스택 트레이스 샘플링하기 — perf와 비슷하게, 리눅스 커널 4.9부터 BPF 최적화 프로파일링이 완전히 가능해졌는데, 프로파일링 중 CPU(스택 트레이스가 커널 컨텍스트에서 빈도로 카운트되므로)와 디스크 I/O 리소스에 대한 오버헤드가 더 낮다는 장점이 있어요. 스택 트레이스 분석이 목표라면 bcc/BPF의 profile 도구만으로도 perf.data 및 중간 단계가 필요 없어요. 기존 방식은 접힌 형식을 직접 출력해서 flame graph를 만들 수 있어요.
$ /usr/share/bcc/tools/profile -F 999 -f --pid $(pgrep redis-server) --duration 60 > redis.folded.stacks
이렇게 하면 전처리를 모두 제거하고 단일 명령으로 on-CPU flame graph를 렌더링할 수 있어요.
$ flamegraph.pl redis.folded.stacks > redis.svg
bcc/BPF로 콜 카운트 분석
함수는 코드가 느리거나 자주 호출되기 때문에 많은 CPU 사이클을 소비할 수 있어요. 함수가 어떤 비율로 호출되는지에 답하기 위해 BCC의 funccount 도구로 콜 카운트 분석에 의존할 수 있어요.
$ /usr/share/bcc/tools/funccount 'redis-server:(call*|*Read*|*Write*)' --pid $(pgrep redis-server) --duration 60
Tracing 64 functions for "redis-server:(call*|*Read*|*Write*)"... Hit Ctrl-C to end.
FUNC COUNT
call 334
handleClientsWithPendingWrites 388
clientInstallWriteHandler 388
postponeClientRead 514
handleClientsWithPendingReadsUsingThreads 735
handleClientsWithPendingWritesUsingThreads 735
prepareClientToWrite 1442
Detaching...
위 출력은 트레이싱하는 동안 Redis의 call() 함수가 334번, handleClientsWithPendingWrites()가 388번 호출됐다는 걸 보여줘요.
PMC(Performance Monitoring Counters)로 하드웨어 이벤트 카운팅
많은 현대 프로세서는 PMC(Performance Monitoring Counters)를 노출하는 성능 모니터링 유닛(PMU)을 포함해요. PMC는 메모리 I/O, 스톨 사이클, 캐시 미스를 포함한 CPU 동작을 이해하는 데 중요하고, 다른 어디에서도 구할 수 없는 저수준 CPU 성능 통계를 제공해요.
PMU의 설계와 기능은 CPU 특유이므로 perf list로 CPU가 지원하는 카운터와 기능을 평가해야 해요. 60초 동안 redis 프로세스에 대해 사이클당 명령 수, 실행된 마이크로 옵 수, 마이크로 옵이 디스패치되지 않은 사이클 수, 메모리당 스톨을 포함한 메모리 스톨 사이클 수를 계산하려면:
$ perf stat -e "cpu-clock,cpu-cycles,instructions,uops_executed.core,uops_executed.stall_cycles,cache-references,cache-misses,cycle_activity.stalls_total,cycle_activity.stalls_mem_any,cycle_activity.stalls_l3_miss,cycle_activity.stalls_l2_miss,cycle_activity.stalls_l1d_miss" --pid $(pgrep redis-server) -- sleep 60
Performance counter stats for process id '3038':
60046.411437 cpu-clock (msec) # 1.001 CPUs utilized
168991975443 cpu-cycles # 2.814 GHz (36.40%)
388248178431 instructions # 2.30 insn per cycle (45.50%)
443134227322 uops_executed.core # 7379.862 M/sec (45.51%)
30317116399 uops_executed.stall_cycles # 504.895 M/sec (45.51%)
670821512 cache-references # 11.172 M/sec (45.52%)
23727619 cache-misses # 3.537 % of all cache refs (45.43%)
30278479141 cycle_activity.stalls_total # 504.251 M/sec (36.33%)
19981138777 cycle_activity.stalls_mem_any # 332.762 M/sec (36.33%)
725708324 cycle_activity.stalls_l3_miss # 12.086 M/sec (36.33%)
8487905659 cycle_activity.stalls_l2_miss # 141.356 M/sec (36.32%)
10011909368 cycle_activity.stalls_l1d_miss # 166.736 M/sec (36.31%)
60.002765665 seconds time elapsed
PMC는 카운팅(counting)과 샘플링(sampling)이라는 두 가지 아주 다른 방식으로 사용될 수 있고, 우리는 이 분석을 위해 PMC 카운팅에만 집중했다는 걸 알아두는 게 중요해요. Brendan Gregg이 다음 링크에서 명확히 설명합니다.