ElasticSearch 성능 최적화

Heap Size

일반적으로 메모리의 50%를 힙으로 할당하는 것을 권장하지만 힙 사이즈가 32G 이상으로 커질 경우 단점이 있다.

힙 사이즈가 크다고 무조건 성능이 좋아지는 것이 아니다. 오히려 32G 이상의 힙 사이즈는 성능을 저하시킨다고 한다.

이건 비단 ES 뿐만 아니라 JVM을 사용하는 모든 앱이라면 공통으로 적용되는 점이다.

이유

JVM은 OOPS(Ordinary Object Pointers) 라는 것으로 메모리의 주소를 관리한다. C의 포인터랑 비슷한 개념이다.

예전 32bit OS의 경우 메모리가 4G이므로 메모리 주소 찾는 게 그렇게 오래 걸리지 않았다.

하지만 64bit인 경우 메모리 주소를 찾는데 너무 오래 걸리므로 JVM은 Compressed OOPS라는 기법을 사용하여 메모리 주소를 관리하기로 한다.

원리는 32bit 주소에 offset bit를 3비트 추가하여 32bit * 8 개의 메모리 주소를 참조할 수 있게 하는 것으로 대략 32GB의 주소값까지 표현할 수 있게 된다.

하지만 힙 사이즈가 32G보다 커질 경우 이 Compressed OOPS 방식을 사용하지 않고 그냥 OOPS를 사용하여 메모리 주소를 찾는데 매우 오래 걸리게 된다.

Compressed OOPS 방식의 35bit 주소에서 offset 3bit를 버리기 위해 shift left 3 ( << 3 ) 연산을 하게 되는데 이때 메모리 주소의 시작점이 0이 아니라면 여기에 메모리 시작 주소를 더해야 하는 연산이 추가로 들어간다. (Non-zero based)

하지만 메모리 주소의 시작점이 0이라면 shift left 3 (<< 3)만 해도 원래 주소를 알아낼 수 있다. (Zero based)

이는 JVM으로 oops 방식을 확인하는 명령어로 확인해볼 수 있다.

  • 메모리가 64G인 시스템에서 힙을 31G로 준다면?
    1
    java -Xmx31G -XX:+UnlockDiagnosticVMOptions -Xlog:gc+heap+coops=debug -version
    1
    Heap address: 0x0000001000800000, size: 31744 MB, Compressed Oops mode: Non-zero disjoint base: 0x0000001000000000, Oop shift amount: 3

이런 식으로 base 주소가 0이 아님을 확인할 수 있고 Non-zero base라고 표시된다.

  • 이번엔 힙을 30G로 준다면
    1
    java -Xmx30G -XX:+UnlockDiagnosticVMOptions -Xlog:gc+heap+coops=debug -version
    1
    Heap address: 0x0000000080000000, size: 30720 MB, Compressed Oops mode: Zero based, Oop shift amount: 3

Zero based 모드라고 표시되는 것을 확인할 수 있다.

그리고 ES는 OS의 나머지 여유 메모리를 파일시스템 캐시로 이용하기 때문에 이 캐시의 크기를 더 늘리는 것이 더 좋은 성능을 낼 수 있다고 한다.


즉, OS의 메모리 절반을 힙 사이즈로 할당하면 나머지 절반은 ES가 캐시로 사용한다는 얘기.

또 다른 이유로는 힙 사이즈가 클 경우 gc가 빈번하게 일어나는 앱에서는 힙에 비례하여 cpu 사용량이 증가하고 stop the world 시간이 늘어나기 때문에 너무 큰 힙 사이즈는 좋지 않을 수 있다.

이건 JVM 애플리케이션의 공통 사항이다.


Mapping

인덱스를 생성하기 전에 인덱스 템플릿을 생성하고 매핑을 적절히 해주는 것이 좋다.

특히 string 필드는 ES에서 기본적으로 text와 keyword 타입을 둘다 생성하기 때문에 용량을 낭비하고 검색 성능이 저하된다.

따라서 텍스트 분석이 필요하고 검색 기능이 필요한 필드는 text만 매핑하고 나머지 필터링을 위한 필드는 keyword로 매핑하여 불필요한 연산이나 용량을 제거해주는 것이 좋다.

하지만 반대로 Mapping에 대해 자세히 이해하지 못하고 획일화된 Mapping만 사용한다면 NoSQL의 장점을 활용하지 못하게 된다.

인덱스의 Mapping을 변경하는 작업은 그렇게 어려운 일이 아니므로 조금씩 공부하면서 변경하는 것도 좋다.


Shard

샤드는 기본적으로 인덱스 1개와 매핑되어 있고 샤드의 갯수는 인덱스 1개당 1개 이상이다. (1대다 관계)

노드가 여러 개인 경우 샤드를 분산배치함으로써 클러스터링, 로드 밸런싱 등이 가능해진다.

샤드의 갯수

ES에서는 노드 1개당 샤드의 갯수가 너무 많으면 좋지 않다고 한다.

샤드의 갯수가 너무 많으면 마스터 노드가 샤드를 추적할 때 연산량이 많아져 성능이 저하된다.

일반적으로 Heap 사이즈 1GB 당 20개 미만 (예. Heap 8GB -> 샤드 160개) 으로 하는 것이 좋지만 이보다 더 적게 할 것을 권장한다고 한다.

샤드의 용량

샤드 1개의 용량은 약 20GB~40GB 정도로 유지하는 것이 좋다고 한다.

샤드에는 Lucene 엔진 인스턴스로서 정보를 보관하고 있는 세그먼트가 있다.

세그먼트는 데이터가 많을수록 커지기는 하는데 중요한 점은 데이터가 적을 때의 세그먼트와 데이터가 많을 때 세그먼트 크기는 별로 차이나지 않는 것이다.

이 말은, 샤드에 적재된 데이터가 적을 때 1GB 당 오버헤드가 데이터가 많을 때 1GB 당 오버헤드보다 월등히 크다는 것을 말한다.

이 때문에 50MB 용량의 샤드 1000개에서 쿼리하는 것보다 50GB의 샤드 1개에서 쿼리하는 것이 대부분의 경우 훨씬 더 빠르다.

50MB의 샤드와 50GB 샤드의 세그먼트 크기는 별 차이가 없는데, 전자는 1000개의 세그먼트를 합치는 과정에서 엄청난 리소스를 필요로 하기 때문이다.

그래서 이미 샤드의 갯수가 많아졌을 경우 강제 병합 과정을 통해서 세그먼트를 병합해 오버헤드를 줄여 성능을 개선할 수 있다.

하지만 또한, 50GB 이상으로 너무 크게 하지 않을 것을 권장하고 있다.


ElasticSearch >= 8.3 는 다르다!

해당 프로젝트를 진행한 날짜가 2022년이고 당시에 ES 7.10 버전을 기준으로 작성했었는데, 최근 ES 문서를 확인해보니까 8.3 버전 이상부터는 **샤드의 갯수 규칙 (힙 1GB 당 20개 미만)**이 다르다고 한다!

새로운 팁

8.3 버전 이상에서 ES의 경험 법칙에 따르면 각 데이터 노드의 인덱스 당 매핑된 필드 수 X 1kB + 0.5GB 를 권장한다고 한다.

예를 들어서, 데이터 노드가 1000개의 인덱스 샤드를 보유하고 있고, 각 인덱스가 4000개의 필드가 매핑되어 있다면

1000 x 4000 x 1kB = 4GB추가로 0.5GB를 할당해 4.5GB 이상의 힙 크기를 할당해줄 것을 권장하고 있다.

또, 샤드 당 데이터의 크기는 10GB~50GB를 권장하고 있다.