[엘라스틱서치] Elasticsearch in action 정리(6) - 성능 극대화

2020. 8. 3. 15:58 Big Data/빅데이터

다른 모든것처럼 "얼마나 빠른가?"라는 질문에 대한 대답은 유스케이스, 하드웨어, 설정에 따라 달라진다. 유스케이스에 맞게 엘라스틱서치의 성능을 극대화할 수 있도록 설정하는 방법에 대해 알아보자. 언제나 성능 향상에는 상응하는 대가가 따른다. 따라서 먼저 무엇을 희생할지를 결정해야 한다. 

 

  • 애플리케이션 복잡도 - 어떻게 다수의 요청(색인, 갱신, 삭제, 조회, 검색 등의 요청)을 하나의 HTTP 요청으로 그룹화하는지에 대해 살펴본다.  이 그룹화는 때로는 애플리케이션에서 경계해야 할 때도 있지만, 전반적인 성능을 획기적으로 끌어올릴수 있는 방법이기도 하다. 네트워크 오버헤드가 더 적기 때문에 20~30배의 성능 향상을 기대할 수 있다.
  • 색인과 검색 중 어느 것의 성능에 초점을 맞출 것인가 - 엘라스틱서치가 루씬 세그먼트를 어떻게 관리하는지에 대해 자세히 살펴본다. 연관된 내용으로는 refresh, flush, merge 정책, 저장 정책에 대한 설정이 어떻게 동작하고 이들이 검색과 색인 성능에 어떤 영향을 미치는지를 알아본다. 종종 색인 성능을 위한 튜닝은 검색 성능에 부정적인 영향을 미치고, 반대의 경우도 마찬가지다.
  • 메모리 - 엘라스틱서치의 성능이 좋은 요인 중 하나는 바로 캐싱이다. 필터 캐시와 어떻게 그것을 적절히 사용할 수 있는지에 대해서 더 자세히 살펴볼 것이다. 또한 샤드 쿼리 캐시에 대해서도 알아보고 엘라스틱서치가 충분한 힙 공간을 사용하면서도 동시에 운영체제가 색인을 캐싱하기 위해 충분한 공간을 남겨놓는 법에 대해서도 알아본다. 준비되지 않은 캐시에 대한 검색 요청이 지나치게 느리게 수행된다면, 색인 워머를 통해 쿼리를 백그라운드에서 실행시켜 캐시를 미리 준비시켜 놓을 수 있을 것이다.
  • 유스케이스에 따라서 색인 시점에 텍스트를 분석하는 방법이나 사용하는 쿼리의 종류는 더 복잡한 것이어서 다른 작업을 느리게 하거나 메모리를 더 많이 사용할 수 있다. 데이터나 쿼리를 설계할때 마주하게 될 전형적인 트레이드오프도 살펴본다. 색인 시점에 더 많은 텀을 생성할 것인가? 혹은 검색 시점에 더 많은 텀들에 찾아볼 것인가? 스크립트의 이점을 활용할 것인가? 혹은 사용하지 않을 것인가? 깊은 페이징은 어떻게 처리할 것인가?

다수의 작업을 하나의 HTTP 요청으로 묶는 것은 일반적으로 성능을 향상시키는 가장 쉽고 가장 효과가 큰 방법이다. 이를 어떻게 할 수 있을지 먼저 살펴보고 벌크, 멀티겟, 멀티서치 API를 살펴본다.

 

요청을 그룹화하기

색인 성능을 높이기 위해 할 수 있는 것 중 하나는 여러 문서를 한번의 벌크 API로 색인 요청 하는 것이다. 이를 통해 네트워크 오베헤드를 줄여서 더 많은 색인 처리량을 확보할 수 있다. 하나의 벌크는 색인과 관련된 모든 동작을 포함할 수 있다. 예를 들어, 하나의 벌크에서 문서를 생성하거나 덮어쓸 수 있고, 갱신하거나 삭제할 수 있다. 이것은 색인에서만 적용되는 것은 아니다.

 

만약 애플리케이션 다수의 조회 혹은 검색 요청을 한번에 보내야 한다면, 이것을 위한 벌크 요청인 멀티겟이나 멀티서치 API도 있다. 먼저 벌크 API에 대해서 살펴본다. 프로덕션 환경의 대부분의 유스케이스에서는 이 방법으로 색인해야 한다.

 

(1) 벌크 색인, 갱신, 삭제

색인을 한번에 한 문서씩 하는게 문제는 없지만, 적어도 두가지 측면에서의 성능 패널티가 존재한다.

  • 애플리케이션이 다음 단계로 진행하기 이전에 엘라스틱서치의 응답을 기다려야 한다.
  • 각각의 문서 색인 요청마다 엘라스틱서치는 모든 데이터를 처리해야 한다.

높은 색인 성능이 필요하다면, 다수의 문서를 한번에 색인할 수 있는 엘라스틱서치의 벌크 API를 사용할 수 있다. HTTP를 이용해서 이를 수행할 수 있다. 응답 메시지에서 모든 색인 요청에 대한 결과를 확인할 수 있다.

 

 

벌크 단위로 색인하기

두개의 문서를 색인하는 상황을 살펴보겠다. 이를 위해서는 특정한 포맷으로 HTTP POST 요청을 _bulk 종단점으로 해야한다. 이 포맷은 다음과 같은 요구사항을 따라야 한다.

  • 각 색인 요청은 개행문자로 구분된 두 개의 JSON 문서로 이루어져 있어야 한다. 하나는 작업(이 경우 index)과 메타데이터(색인, 타입, ID)를 담은 것이고, 하나는 실제 문서의 내용을 담은 것이다.
  • JSON 문서는 한 행에 하나씩 있어야 한다. 즉, 각 행은 개행 문자로 끝나야 하는데, 이는 전체 벌크의 마지막행 역시 마찬가지다.
REQUESTS_FILE=/tmp/test_bulk
echo '{"index":{"_index":"get-together", "_type":"group", "_id":"10"}}
{"name":"Elasticsearch Bucharest"}
{"index":{"_index":"get-together","_type":"group","_id":"11"}}
{"name":"Big Data Bucharest"}' > $REQUESTS_FILE

curl -XPOST localhost:9200/_bulk --data-binary @$REQUESTS_FILE

 

두 색인 리퀘스트 각각을 보면, 첫번째 줄에는 요청 타입과 메타데이터가 있다. 주요 필드의 이름은 작업의 종류다. 이것은 엘라스틱서치에게 데이터를 가지고 무엇을 할지를 알려준다. 여기서는, index를 사용하여 색인을 수행하고, 이 작업은 같은 ID를 가진 문서가 이미 존재할 경우 이를 덮어쓴다. 작업을 create로 변경할 수도 있는데, 이 경우 문서는 덮어쓰지 않는다. 혹은 이후에 살펴보게 될 내용처럼 다수의 문서를 갱신 혹은 삭제할 수도 있다.

 

_index와 _type은 어디에 각 문서를 색인할지를 나타낸다. 색인과 타입 모두 혹은 색인 이름만을 URL에 입력할 수 있다. 이는 벌크의 모든 작업에 대한 기본 색인과 타입이 된다.

 

_id 필드는 색인하고자 하는 문서의 ID를 나타낸다. 이 값을 생략한 경우에는 엘라스틱서치가 자동으로 ID를 생성해주는데, 문서에 대한 고유 ID를 가지고 있지 않은 경우 이는 매우 유용하다. 예를 들어 로그의 경우는 자동으로 생성된 ID를 사용해도 괜찮을 것인데, 로그는 본질적으로 고유한 ID 값을 가지고 있지 않을 뿐만 아니라 로그를 ID를 통해 조회해야 할 필요도 없기 때문이다.

 

ID를 따로 입력하지 않아도 되고 모든 문서를 같은 색인의 같은 타입에 색인하고자 한다면, 벌크 요청은 다음 목록과 같이 매우 단순해 진다.

REQUESTS_FILE=/tmp/test_bulk
echo '{"index":{}}
{"name":"Elasticseawrch Bucharest"}
{"index":{}}
{"name":"Big Data Bucharest"}' > $REQUESTS_FILE
URL='localhost:9200/get-together/group'
curl -XPOST $URL/_bulk?pretty --data-binary @$REQUESTS_FILE

 

벌크 인서트 요청에 대한 응답은 JSON 형태의 문서로, 해당 벌크를 색인하는데 걸린 시간과 각 작업에 대한 응답을 포함하고 있다. 또한 실패한 작업이 있다면 그것을 보여주는 에러플래그 역시 포함하고 있다.

 

자동 생성된 ID를 사용하고 있으므로, 색인 작업은 문서 생성 작업으로 변경된 것을 확인할 수 있다. 특정 문서가 색인에 실패한 경우, 전체 벌크가 실패한 것을 의미하지는 않는다. 한 벌크에 있는 항목들끼리는 서로 독립적이기 때문이다. 벌크 전체에 대한 한 응답만이 아닌 각각의 작업에 대한 응답을 받을 수 있는것도 이 때문이다. 사용자 애플리케이션에서 JSON 응답을 통해 어떤 작업이 성공하고 어떤 작업이 실패했는지를 확인할 수 있다.

 

성능관점에서 보면, 벌크의 사이즈가 매우 중요하다. 벌크가 지나치게 크면 과다하게 메모리를 사용하게 되고, 벌크가 너무 작다면 네트워크 오버헤드가 매우 커질 것이다. 최적점은 문서 크기와 클러스터의 성능에 따라 달라진다. 한 벌크에는 큰 문서를 조금 담거나 작은 문서를 더 많이 담을 수 있을 것이고, 클러스터가 크고 장비가 좋을수록 큰 벌크와 검색을 빠르게 수행할 것이다. 결국, 각자가 테스트를 통해 자신의 유스케이스에 맞는 최적의 벌크 사이즈를 찾아야 한다. 작은 문서(예를 들어 로그)의 경우 1,000 같은 값으로 시작하여 더는 성능 이득이 없어지는 시점까지 값을 늘려갈 수 있다. 테스트하는 동안 반드시 클러스터 모니터링이 필요하다.

 

(2) 멀티서치와 멀티겟 API

멀티서치나 멀티겟을 사용함으로써 얻을 수 있는 이점은 벌크의 경우와 유사하다. 다수의 검색이나 조회 요청을 할 겨우, 이를 하나로 묶어 요청하는 것이 네트워크 레이턴시 비용을 줄여준다는 점이다.

 

 

멀티서치

다수의 검색 요청을 보내는 하나의 유스케이스로 여러 종류의 문서를 검색할 때를 생각해 볼 수 있다. 예를 들어, get-together 웹사이트에서 검색 기능을 제공하고 있다고 가정해본다. 검색이 그룹에 관한것일지 이벤트에 관한것일지 모르기 때문에, 두가지 모두를 겁색하고 UI를 통해 그룹과 이벤트라는 두 가지 항목들을 제공하여야 할 것이다. 이 두 가지 검색은 서로 완전히 다른 스코어링 기준을 가지고 있을 것이기 때문에 별도의 요청으로 실행하여야 하겠지만, 멀티서치 요청을 통해 이 두 가지 요청을 하나로 묶을 수도 있다.

 

  • _msearch 종단점을 호출하는데, URL에 색인과 타입을 명시해도 되고 명시하지 않아도 된다.
  • 각 요청은 JSON 문자열을 두 행씩 갖고 있어야 한다. 첫 행은 색인/타입/라우팅값/검색 타입과 같이 개별 요청시에도 URI에 일반적으로 입력하는 파라미터들을 포함하고 있어야 한다. 두번째 행은 일반적인 개별 요청의 페이로드에 해당하는 쿼리 바디를 포함하고 있어야 한다.

다음 목록은 엘라스틱서치에 관한 그룹과 이벤트에 대한 멀티서치 요청 예제이다.

echo '{"index": "get-together", "type": "group"}
{"query": {"match": {"name": "elasticsearch"}}}
{"index": "get-together", "type":"event"}
{"query": {"match": {"title": "elasticsearch"}}}' > request

curl localhost:9200/_msearch?pretty --data-binary @request

 

멀티겟

멀티겟은 외부 프로세스에서 검색은 수행하지 않고 다수의 문서를 조회할 필요만 있을때 유용하다. 예를 들어, 시스템 메트릭을 저장하고 있고 타임스탬프를 ID로 쓰고있는 경우, 특정 시점의 특정 메트릭을 필터링하지 않고 모두 조회해야 할 필요가 있을 수 있다. 이를 위해서는, mget 종단점을 호출할 때 조회하고자 하는 문서들의 색인/타입/ID를 포함한 객체의 배열을 전송할 수 있다.

 

curl localhost:9200/_mget?pretty -d '{
  "docs": [
    {
      "_index": "get-together",
      "_type": "group",
      "_id": "1"
    },
    {
      "_index": "get-together",
      "_type": "group",
      "_id": "2"
    }
  ]
}'

 

다른 대부분의 API처럼, 색인과 타입은 필수로 지정해야 하는 것은 아니다. 요청의 URL에 따로 입력할 수도 있기 때문이다. 모든 ID에 대해 색인과 타입이 같은 경우에는 URL에 색인과 타입을 지정하고 ID 배열에 ID 값들을 입력할 것을 권장한다.

curl localhost:9200/get-together/group/_mget?pretty -d '{
  "ids": ["1", "2"]
}'

 

다수의 작업을 멀티겟을 이용해서 같은 요청으로 묶는 것은 사용자 애플리케이션을 다소 복잡하게 만들 수도 있다. 하지만 이를 통해 추가적인 비용없이 작업을 더 빠르게 만들 수도 있다. 이는 멀티서치와 벌크 API의 경우에도 해당되는데, 이것들을 최적으로 사용하기 위해서는 요청의 크기를 변경해가며 실험을 한 후 어떤 크기가 자신의 하드웨어와 문서들에 대해 가장 적합한지를 파악해야 한다.

 

엘라스틱서치가 어떻게 내부적으로 문서들을 벌크로 처리하는지에 대해 알아본다. 이는 루씬 세그먼트의 형태로 이루어지는데, 색인과 검색을 빠르게하기 위해 이 작업을 어떻게 튜닝할 수 있는지에 대해서도 살펴본다.

 

루씬의 세그먼트 관리를 최적화하기

엘라스틱서치가 애플리케이션으로부터 문서를 전달받으면, 이를 먼저 메모리에 있는 세그먼트라는 역 색인에 담는다. 이 세그먼트는 이따금씩 디스크로 쓰여진다. 이 세그먼트는 운영체제가 쉽게 캐싱할 수 있도록 하기 위해 삭제만 가능할뿐 변경이 불가하다. 또한 작은 세그먼트들은 역 색인을 병합하고 검색을 빠르게 하기 위해 주기적으로 큰 세그먼트로 생성되어 합쳐진다.

 

각 단계에서 엘라스틱서치가 세그먼트를 관리하는 방법에 영향을 미칠 수 있는 변수는 여러가지가 있다. 그리고 사용자의 유스케이스에 맞게 이를 설정하는 것을 통해 유의미한 성능 향상을 얻을 수 있다.

 

  • 얼마나 자주 리프레시와 플러시를 수행할 것인가 - 리프레시는 엘라스틱서치의 색인에 대한 뷰를 갱신시켜 줌으로써, 새로 색인된 데이터가 검색될 수 있도록 해준다. 플러시는 색인된 데이터를 메모리에서 디스크로 커밋한다. 두 가지 모두 성능 관점에서 비용이 크기 때문에 유스케이스맞게 적절히 수행하도록 설정해야 한다.
  • 머지 정책 - 루씬(혹은 엘라스틱서치)은 데이터를 세그먼트라는 불변 파일의 형태로 저장한다. 더 많은 데이터를 색인할수록 더많은 세그먼트가 생성된다. 여러 세그먼트에 걸친 검색은 느리므로, 작은 세그먼트들을 백그라운드에서 큰 세그먼트들로 머지하여 세그먼트의 수를 관리하게 된다. 머지는 성능 집약적인 작업으로 특히 I/O를 많이 사용한다. 머지 정책을 조정하여 머지의 빈도나 세그먼트의 최대 크기를 조절할 수 있다.
  • 저장과 저장 제한 - 엘라스틱서치는 머지 작업이 시스템의 I/O에 미치는 영향을 초당 일정 바이트로 제한한다. 사용자의 하드웨어 유스케이스에 따라, 이 값을 조절할 수도 있다. 엘라스틱서치가 어떻게 저장소를 사용하게 할 것인지에 관한 다른 옵션들도 있다. 예를 들어, 색인을 오직 메모리에만 저장하도록 할 수도 있다.

이들 중 일반적으로 봤을대 성능 이득을 가장 많이 얻을 수 있는 방법은 리프레시와 플러시 빈도에 대한 내용이다.

 

(1) 리프레시와 플러시 임계값

엘라스틱서치는 준 실시간이다. 왜냐하면 검색은 대부분 가장 최근에 색인한 데이터가 아닌 그에 근접한 데이터에 대해서 수행하기 때문이다. 이와 같은 준 실시간이라는 특성은 엘라스틱서치가 열린 색인에 대한 현재 시점의 뷰를 유지하고 있기 때문에, 다수의 검색이 같은 파일을 탐색하고 같은 캐시를 재사용한다는 점에서 적절한 용어다. 이 기간 동안 새롭게 색인된 데이터는 리프레시되기 전까지는 검색할 수 없다. 리프레시란 그 이름에서 유추해볼 수 있듯이 색인의 현재 시점의 뷰를 리프레시하여 새로 색인된 데이터를 검색할 수 있도록 해주는 것이다. 단점은 리프레시로 인한 성능 관점에서의 비용이 있다는 것이다. 일부 캐시는 무효화되어 검색이 느려지고, 이를 다시 갱신하는 작업은 더 많은 프로세싱 파워를 필요로 하여 색인을 느려지게 한다.

 

 

언제 리프레시할 것인가

기본적으로 모든 색인은 자동적으로 매 초마다 리프레시 한다. 각 색인의 설정을 런타임에 변경하여 리프레시 빈도를 조절할 수 있다. 아래 명령은 자동 리프레시 주기를 5초로 변경한다.

curl -XPUT localhost:9200/get-together/_settings -d '{
  "index.refresh_interval": "5s"
}'

변경이 반영되었는지 확인하려면, 다음의 curl 명령으로 모든 색인 설정을 조회하면 된다.

curl localhost:9200/get-together/_settings?pretty

 

refresh_interval를 증가시킬수록 색인 처리량은 늘어난다. 이는 리프레시에 시스템 자원을 덜 사용하게 되기 때문이다. 혹은 refresh_interval을 -1로 지정하여 자동 리프레시를 비활성화하고 수동으로 직접 리프레시를 호출하는 방법을 택할 수도 있다. 이는 색인이 배치를 통해 주기적으로만 변경이 일어나느 경우 적합하다. 예를 들면, 매일 밤 상품과 재고정보가 갱신되는 유통망 같은 경우를 생각해 볼 수 있다. 이런 갱신 작업이 빨리 처리되기를 원하기 때문에 색인 성능은 매우 중요할 수 있다. 하지만 갱신이 실시간으로 발생하지 않기 때문에 데이터의 신선도는 중요하지 않을 수 있다. 따라서 자동 리프레시는 비활성화하고 매일 밤 벌크 색인/갱신 수행이 끝난 후에 수동으로 리프레시를 수행할 수 있을 것이다.

 

수동으로 리프레시를 하고자 한다면, 리프레시 할 하나 혹은 여러 색인의 _refresh 종단점을 호출하면 된다.

curl localhost:9200/get-together/_refresh

 

 

언제 플러시할 것인가

이전 버전의 루씬이나 솔라에 익숙하다면, 리프레시가 발생할 때 직전 리프레시는 이미 디스크로 커밋되어 있으므로 모든 데이터는 메모리 내에 색인되어 있는 상태라고 생각할 것이다. 엘라스틱서치의 경우에는 리프레시 작업과 메모리 내의 세그먼트를 디스크로 커밋하는 것은 별개다. 실제로도 데이터는 처음에 메모리로 색인되고, 리프레시가 수행되고 나면 엘라스틱서치는 메모리 내의 세그먼트를 검색할 수 있다. 메모리 내의 세그먼트를 디스크 내의 실제 루씬 색인으로 커밋하는 작업을 플러시라고 부른다. 이 작업은 세그먼트가 검색 가능한지 여부와 무관하게 발생한다.

 

노드 장애나 샤드 재배치로 인해 메모리 내의 데이터가 유실되지 않도록 보장하기 위해서, 엘라스틱서치는 플러시 되지 않은 색인 요청들을 트랜잭션 로그에 기록한다. 플러시는 메모리 내의 세그먼트를 디스크로 커밋할 뿐만 아니라 트랜잭션 로그를 지우는 역할도 함께 한다.

 

플러시는 다음 조건 중 하나가 충족되면 발생한다.

  • 메모리 버퍼가 가득 찼을때
  • 마지막 플러시로부터 일정 시간이 지났을때
  • 트랜잭션 로그와 사이즈가 일정한 임계치를 넘었을때



 

플러시가 발생하는 빈도를 조정하기 위해서는 위 세 조건에 영향을 미칠 수 있는 설정값들을 조정해야 한다.

 

메모리 버퍼 크기는 elasticsearch.yml 설정 파일의 indices.memory.index_buffer_size 값을 통해 지정할 수 있다. 이 값을 통해 노드 전체의 버퍼 크기를 조절할 수 있다. 이 값은 10%처럼 전체 JVM 힙의 비율로 할 수도 있고, 100MB처럼 고정값으로도 할 수 있다.

 

트랜잭션 로그 설정은 색인별로 할 수 있고, 플러시를 발생시키게 될 크기(index.translog.flush_threshold_size)와 마지막 플러시로부터의 시간 간격(index.translog.flush_threshold_period)을 설정할 수 있다. 다른 대부분의 색인 설정이 그렇듯 런타임에 변경할 수 있다.

curl -XPUT localhost:9200/get-together/_settings -d '{
  "index.translog": {
    "flush_threshold_size": "500mb",
    "flush_threshold_period": "10m"
  }
}'

 

플러시가 수행되면, 하나 이상의 세그먼트가 디스크에 생성된다. 쿼리를 수행하면, 엘라스틱서치는 (루씬을 통해) 모든 세그먼트를 조회하고 결과를 샤드의 전체 응답에 포함시킨다. 그러고 나서, 샤드별 응답들은 전체 응답으로 집계되어 사용자 애플리케이션에 반환된다.

 

여기서 가장 중요한 점은 검색해야 할 샤드의 수가 많을 수록 검색이 느려진다는 것이다. 샤드의 숫자를 일정 수준으로 유지학 위해서 엘라스틱서치는 (역시 루신을 통해서) 백그라운드에서 다수의 작은 세그먼트들을 큰 세그먼트로 병합한다.

 

(2) 머지와 머지 정책

세그먼트는 불변 파일들의 집합으로 엘라스틱서치가 색인된 데이터를 저장하기 위해 사용한다. 세그먼트는 불변이기 때문에 쉽게 캐싱되고, 검색을 더 빠르게 수행하도록 할 수 있다. 또한 데이터셋의 문서 추가 등으로 인해 변경되더라도 기존의 세그먼트에 저장되어 있던 색인 데이터를 재구성해야 하는 것은 아니다. 이는 색인을 빠르게 만들어주는 것은 사링이나, 무조건적으로 좋은 것은 아니다. 문서를 갱신하는 것은 실제 해당 문서를 갱신하는 것이 아닌 새 문서를 하나 더 색인하는 것이다. 따라서 기존의 문서를 삭제해야할 필요도 있다. 삭제의 경우 역시 실제 세그먼트에서 문서를 직접 지우지는 모한다. 별도의 .del 파일에 지워졌다고 표시를 남길 뿐이다. 무서들을 세그먼트 머지 작업을 통해 실제로 삭제된다.

 

종합해보면 세그먼트 머지에는 크게 두가지 목적이 있는데, 그것은 바로 세그먼트의 총 숫자를 적절하게 유지하는 것(이를 통한 쿼리 성능 향상)과 삭제된 문서들을 제거하는 것이다.

 

세그먼트 머지는 정의된 머지 정책에 따라서 백그라운드에서 수행된다. 기본 머지 정책은 계층정 정책이다. 이는 세그먼트를 계층별로 나누고, 설정된 세그먼트 수의 최댓값을 넘는 세그먼트들이 하나의 계층에 있다면, 이 계층에서 머지를 실행한다.

 

다른 머지 정책들도 있으나, 기본값인 계층적 머지를 살펴본다. 대부분의 유스케이스에서는 이 정책이 효과적이다.

  1. 플러시 작업은 너무 많아지기 전까지 첫번째 계층에 세그먼트들을 추가한다. 너무 많은 것이 4개라고 해본다.
  2. 작은 세그먼트들은 큰 세그먼트로 합쳐진다. 플러시는 계속 새로운 작은 세그먼트들을 생성한다.
  3. 결국, 다음 계층에는 네 개의 세그먼트가 있게 된다.
  4. 네 개의 큰 세그먼트들은 더 큰 세그먼트로 합쳐지고, 이런 절차가 반복된다.
  5. 계층 내에서 임계치에 도달할 때까지 반복된다. 상대적으로 작은 세그먼트만이 병합되고, 최대 세그먼트들은 그대로 남아 있다.



 

 

머지 정책 관련 옵션 튜닝하기

머지의 전체적인 목적은 I/O와 CPU 시간을 조금 희생하여 검색 성능을 높이는데 있다. 머지는 문서를 색인/갱신/삭제하는 동시에 발생하기 때문에 머지가 많을수록 이런 작업들에 대한 비용이 커진다. 반대로 발해, 빠른 색인을 원한다면, 병합을 덜 함으로써 검색 성능을 조금 희생하기를 원할 것이다.

 

병합을 더 자주 혹은 덜 자주 실행하기 위해서 사용하게 될 옵션은 몇가지가 있다. 자주 사용되는 것들은 다음과 같다.

 

  • index.merge.policy.segments_per_tier - 이 값이 클수록, 하나의 계층에서 더 많은 세그먼트를 가지고 있을 수 있다. 이는 곧 더 적은 머지 작업과 더 향상된 색인 성능을 의미한다. 색인은 많이 일어나지 않고 검색 성능을 높이고자 한다면 이 값을 낮춰야 한다.
  • index.merge.policy.max_merge_at_once - 이 값을 통해 한번에 합쳐질 수 있는 세그먼트의 수를 제한할 수 있다. 일반적으로는 이 값을 segments_per_tier와 같은 값으로 사용한다. 머지 작업을 더 작게 만들기 위해 max_merge_at_once를 낮출 수도 있으나, 이를 위해서는 segments_per_tier 값을 높이는 것이 더 좋은 방법이다. max_merge_at_once는 segments_per_tier보다 큰 값을 갖지 않아야 하는데, 그럴 경우 지나치게 많은 머지 작업이 발생하기 때문이다.
  • index.merge.policy.max_merged_segment - 이 설정은 세그먼트 크기의 최댓값을 지정한다. 이 값보다 큰 세그먼트는 다른 세그먼트와 합쳐지지 않는다. 머지 작업을 줄이고 빠른 색인을 원한다면 이 값을 낮춤으로써 큰 세그먼트에 대한 머지작업이 일어나기 어려워지도록 만들 수 있다.
  • index.merge.scheduler.max_thread_count - 다머지 작업은 백그라운드의 별도의 스레드에서 수행되는데, 이 값은 머지 작업에 사용될 수 있는 스레드의 최대 갯수를 조절한다. 이 값은 동시에 일어날 수 있는 머지 작업의 숫자에 대한 엄격한 한계값이다. CPU가 많고 I/O가 빠르다면 이 값을 높여 적극적인 머지 정책을 가져갈 수도 있고, 반대로 느린 CPU와 I/O를 가진 환경이라면 이 값을 낮춰야 할것이다.

앞서 언급된 모든 설정은 색인 수준의 설정으로 트랜잭션 로그나 리프레시 설정과 마찬가지로 런타임에 변경할 수 있다. 아래는 segments_per_tier를 5로 설정함으로써 머지를 더 자주 일어나도록 강제하고, 최대 세그먼트 크기를 1GB로 지정하며, 회전식 디스크에서 더 효율적으로 동작하도록 스레드 숫자를 1로 낮추고 있다.

curl -XPUT localhost:9200/get-together/_settings -d '{
  "index.merge": {
    "policy": {
      "segments_per_tier": 5,
      "max_merge_at_once": 5,
      "max_merged_segment": "lgb"
    },
    "scheduler.max_thread_count": 1
  }
}'

 

 

색인 최적화

리프레시나 플러시처럼, 머지 작업을 직접 실행시킬 수도 있다. 강제 머지 요청은 최적화라고 부른다. 보통 이 작업은 검색을 빠르게 하기 위해 더이상 변하지 않을 색인에 실행하여 세그먼트 개수를 적게 만들기 위해 사용하기 때문이다.

 

과도한 머지 작업의 경우와 유사하게, 최적화 작업은 I/O 집약적인 작업이고 또한 다수의 캐시를 무효화한다. 해당 색인에 색인, 문서 생성, 갱신, 삭제 작업 등을 계속하고 있다면, 새로운 세그먼트들이 지속적으로 생성될 것이고, 따라서 최적화의 장점을 얻을 수 없을 것이다. 또한, 지속적으로 변하는 색인의 세그먼트 수를 적게 유지하고 싶다면, 머지 정책을 튜닝해야 한다.

 

최적화는 정적 색인에 대해서도 효과적이다. 예를 들어, SNS 데이터를 일다누이 색인으로 색인하고 있는 경우, 필요에 의해 문서를 삭제해야 되는 경우가 아니라면 어제 만들어진 색인은 변하지 않을 것이라고 확신할 수 있다. 색인을 최적화하여 세그먼트 갯수를 적게 만드는 것은 검색 성능에 도움이 된다. 세그먼트의 총 크기를 줄여주고 캐시들이 다시 로될 경우 검색을 검색을 빠르게 만들어주기 때문이다.

 

최적화를 하려면, 색인, 혹은 다수의 색인의 _optimize 종단점을 호출하면 된다. max_num_segments 설정은 샤드별로 몇 개의 세그먼트로 병합되기를 원하는지를 의미한다.

 

curl localhost:9200/get-together/_optimize?max_num_segments=1

 

크기가 큰 색인의 경우 최적화 작업은 오랜 시간이 걸린다. wait_for_merge를 false로 설정하여 작업이 백그라운드에서 수행되도록 할 수 있다.

 

최적화(혹은 머지) 작업이 느려지는 한 가지 가능한 이유는 엘라스틱서치가 기본적으로 머지 작업이 사용할 수 있는 I/O 처리량을 제한하고 있기 때문이다. 이는 저장 제한이라고 물리우며, 다른 데이터 저장 관련 옵션들과 함께 살펴볼 것이다.

 

(3) 저장과 저장 제한

구버전 엘라스틱서치에서는 큰 머지 작업이 클러스터를 느리게 만들어 색인이나 검색 요청을 처리하는 데 지나치게 오랜 시간이 걸리게 되거나 노드가 아예 응답하지 않는 상황이 되기도 하였다. 이는 머지 작업이 I/O 처리량에 부하를 주고, 이는 새로운 세그먼트 쓰기 작업을 느리게 만들기 때문이었다. 또한, CPU 로그 역시 I/O 대기로 인해 더 높아졌었다. 

 

최근의 엘라스틱서치는 저장 제한(Store throttling)을 통해 머지 작업이 사용할 수 있는 I/O 처리량을 제한하고 있다. 기본적으로 노드 레벨 설정인 indices.store.throttle.max_bytes_per_sec가 있는데, 기본값은 1.5버전의 경우 20mb이다.

 

이와 같은 제한은 안정성 관점에서 대부분의 유스케이스에서 좋다고 볼 수 있지만, 모든 상황에서 적절한 것은 아니다. 빠른 장비들을 가지고 있고 색인이 굉장히 많이 발생하고 있다면, 가용한 CPU와 I/O 자원이 충분함에도 불구하고 머지가 이를 따라잡지 못할 수도 있다. SSD를 가진 노드의 경우, 일반적으로 제한 임계값을 100-200MB로 높여야 할 것이다.

 

 

저장 제한 임계값 변경하기

크기가 큰 디스크를 가지고 있고, 머지가 더 많은 I/O 처리량을 수행해야 한다면, 저장 제한 임계값을 높일 수 있다. 심지어 indices.store.throttle.type을 none으로 설정하여 임계값 전체를 없앨 수도 있다. 반대로, indices.store.throttle.type을 all로 설정하여 머지 작업뿐만 아니라 모든 엘라스틱서치 디스크 작업에 저장 제한 임계를 설정할 수도 있다.

 

이 설정값들은 elasticsearch.yml을 통해 각 노드에서 수정할 수 있다. 또한 러타임에도 클러스터 설정 갱신 API를 통해 변경할 수 있다. 일반적으로 머지와 다른 디스크 활동이 얼마나 일어나는지를 모니터링하면서 이 값들을 튜닝해 나가는 것이 좋다.

 

curl -XPUT localhost:9200/_cluster/settings -d '{
  "persistent": {
    "indices.store.throttle": {
      "type": "all",
      "max_bytes_per_sec": "500mb"
    }
  }
}'

 

 

저장 설정하기

플러시, 머지, 저장 제한에 대해 이야기할때, "디스크", "I/O"는 기본값들이다. 엘라스틱서치는 색인을 데이터 디렉토리에 저장하는데, RPM/DEB 배포판으로 엘라스틱서치를 설치했다면 기본값은 /var/lib/elasticsearch/data이고 tar.gz나 ZIP 압축본을 직접 내려받아 설치했다면 기본값은 data/ 디렉토리다. elasticsearch.yml의 path.data 속성을 통해 데이터 디렉토리를 수정할 수도 있다. 

 

기본 저장 구현은 색인 파일을 파일 시스템에 저장하고, 대부분의 유스케이스에서 이는 적합하게 동작한다. 루씬 세그먼트 파일에 접근하기 위해, 기본 저장 구현 방식은 텀 사전과 같이 일반적으로 크기가 크거나 랜덤 액세스가 필요한 파일들에 대해서는 루씬의 MMapDirectory를 사용한다. 저장된 필드와 같은 다른 종류의 파일의 경우에는, NIOFSDirectory를 사용한다.

 

MMapDirectory

MMapDirectory는 운영체제에게 필요한 파일들을 가상 메모리에 매핑시키도록 요청하고, 메모리에서 있는 파일에 직접 접근하는 방식으로 파일 시스템 캐시를 적극적으로 활용하고 있다. 엘라스틱서치 관점에서는 모든 파일이 메모리에서 사용 가능한 것처럼 여기는 것이지만, 항상 가능한 내용은 아니다. 만약 색인의 크기가 사용할 수 있는 물리 메모리에 비해 크다면, 운영체제는 캐시에서 사용되지 않은 파일들을 제거하고, 읽고자 하는 파일들을 위한 공간을 확보해준다. 만약 엘라스틱서치가 이 캐싱되지 않은 파일들을 다시 사용해야 한다면, 다른 사용되지 않은 파일들이 캐시에서 빠지고 이 파일들이 메모리에 로딩되어야 한다. MMapDirectory가 사용하는 가상 메모리는 운영체제가 다수의 애플리케이션을 지원하기 위해 사용되지 않는 메모리를 디스크로 내리는 것인 시스템의 가상메모리(스왑)와 유사하게 동작한다.

 

NIOFDirectory

메모리에 매핑된 파일 역시 오버헤드가 있는데, 애플리케이션이 운영체제에게 파일을 접근하기 이전에 이를 메모리에 매핑하라고 알려야 하기 때문이다. 이런 오버헤드를 줄이기 위해, 엘라스틱서치는 특정 형태의 파일들의 경우 NIOFSDirectory를 사용한다. NIOFSDirectory는 파일을 직접적으로 접근하지만, 읽을 데이터를 JVM 힙의 버퍼에 복제한다. MMapDirectory는 크기가 크고 무작위로 접근되는 파일일때 유리한 반면, NIOFSDirectory는 크기가 작고 연속적으로 접근할 파일의 경우 장점이 있다.

 

기본 저장 구현은 대부분의 경우 최적의 동작 방식이다. 하지만, 색인 설정의 index.store.type 값을 기본값 이외의 것으로 설정하여 다른 저장 구현을 사용할 수도 있다. 

 

저장 타입 설정은 색인 생성 시점에 설정되어야 한다. 다음은 메모리에 매핑되는 색인을 unit-test라는 이름으로 생성하고 있다.

curl -XPUT localhost:9200/unit-test -d '{
  "index.store.type": "mmapfs"
}'

새로 생성되는 모든 색인에 같은 저장 타입을 적용하고 싶다면, elasticsearch.yaml에서 index.store.type을 mmapfs로 설정하면 된다. 색인 템플릿을 통해 이름이 특정 패턴을 따르는 색인에게 적용할 색인 설정을 정의할 수 있다. 템플릿은 런타임에 변경할 수 있기도 하기 때문에 새로운 색인을 자주 생성한다면 elasticsearch.yml을 통해 정적으로 저장 타입을 설정하는 것보다는 이 방법이 권장된다.

 

일반적으로는 기본 저장 타입이 가장 빠른데, 이는 운영체제가 파일을 캐싱하는 방법과 연관이 있다. 캐시가 잘 동작하도록 하려면, 충분한 메모리 공간을 확보하고 있어야 한다.

 

 

오픈 파일과 가상 메모리 임계치

루씬 세그먼트는 디스크에 다수의 파일로 나누어져 저장된다. 검색이 수행되는 시점에 운영체제는 이것들 중 여러개를 열 수 있어야 한다. 또한 기본 저장 형태인 mmapfs를 사용하고 있다면, 운영체제는 이 저장된 파일들 중 일부를 실제로 메모리에 존재하지는 않지만, 애플리케이션에는 그렇게 보이도록 메모리에 올려야 한다. 이때 운영체제 커널이 이 파일들을 캐시에 로딩하고 내리는 것을 처리한다. 리눅스의 경우 애플리케이션이 열 수 있는 최대 파일의 개수나 메모리 매핑에 대해서 설정할 수 있는 제한이 있다. 이 제한은 일반적으로 엘라스틱서치가 필요한 것보다 보수적으로 설정되어 있기 때문에, 이 값들을 증가시킬 것을 권장한다. 만약 DEB나 RPM을 이용해 엘라스틱서치를 설치한다면, 이 설정들을 자동으로 증가시키기 때문에 별도로 고려하지 않아도 된다. 이 설정들을 /etc/default/elasticsearch 혹은 /etc/sysconfig/elastic-search에서 확인할 수 있다.

 

MAX_OPEN_FILES=65535
MAX_MAP_COUNT=262144

이를 직접 증가시키려면, 오픈 파일의 경우 ulimit -n 65535 명령을 엘라스틱서치를 실행한 사용자 계정에서 실행하고 가상 메모리의 경우 루트 사용자 계정에서 sysctl -w vm.max_map_count=262144를 실행하면 된다.

 

엘라스틱서치 2.0부터는 저장된 필드(그리고 source)의 index.codec 설정을 best_compression으로 설정하여 더 압축된 형태로 저장할 수 있다. 기본값의 경우 LZ4를 사용하여 저장된 필드들을 압축하지만, best_compression의 경우 deflaste를 사용한다. 더 높은 압축률은 _source를 필요로 하는 작업인 결과값 조회나 하이라이팅을 느리게 만들 것이다. 하지만 집계 같은 다른 작업은 적어도 더 느르지는 않을 것인데, 왜냐하면 색인 전체가 작아지고 더 쉽게 캐싱될 수 있기 때문이다.

 

캐시 최적화

엘라스틱서치의 장점 중 하나는 보통의 하드웨어를 가지고도 수십억건의 문서에 대한 검색을 1초 이내에 수행할 수 있다는 것이다. 이 것이 가능한 이유 중 하나는 엘라스틱서치의 캐싱 기능 때문이다. 방대한 양의 데이터를 색인한 후에, 두번째로 수행한 쿼리가 첫번째 쿼리에 비해서 빠르게 수행되는 경험도 하게 될것이다. 이것이 바로 캐시의 효과이다.

 

예를 들어, 필터와 쿼리를 혼합해서 사용하고 있다면, 필터 캐시는 검색을 빠르게 처리하는 데 있어 매우 중요한 역할을 하게 된다.

 

여기서는 필터 캐시와 두가지 다른 종류의 캐시에 대해 살펴본다. 하나는 샤드 쿼리 캐시로, 이것은 정적인 색인에 집계명령을 수행할때 유용하다. 전체 결과를 캐싱하기 때문이다. 다른 하나는 운영체제 캐시로, 색인을 메모리에 캐싱함으로써 I/O 처리량을 높여준다.

 

마지막으로 리프레시 시점에 색인 워머를 포함한 쿼리를 실행하여 이 캐시들을 준비시키는 방법에 대해 살펴본다. 먼저 엘라스틱서치 캐시의 대포적인 형태인 필터 캐시와 이를 가장 잘 활용하기 위해 어떻게 검색을 실행해야 할지 알아본다.

 

 

(1) 필터와 필터 캐시

get-together 웹사이트에서 지난달에 발생한 이벤트를 조회하고 싶다고 생각해보자. 이를 위해서, 범위 검색을 사용할 수도 있고 혹은 상응하는 범위 필터를 사용할 수도 있다.

 

필터는 캐싱되기 때문에 이를 사용할 것을 권장한다. 범위 필터는 기본적으로 캐싱된다. 하지만 _cache 플래그를 통해 필터를 캐싱할지 여부를 조정할 수도 있다.

 

엘라스틱서치 2.0은 기본 동작으로 자주 사용되는 필터와 큰 세그먼트(한번 이상 머지가 수행된)에 대해 수행되는 필터만을 캐싱한다. 이는 지나치게 적극적인 캐싱을 방지하면서 동시에 자주 사용되는 필터를 기억하고 최적화한다. 구현과 관련된 상세 내용은 엘라스틱서치의 필터캐싱 이슈(http://github.com/elastic/elasticsearch/pull/8573)와 루씬의 필터캐싱 이슈(http://issues.apache.org/jira/browse/LUCENE-6077)에서 확인할 수 있다. 이 플래그는 모두 필터에 적용된다. 예를 들어 다음의 예제는 "elasticsearch" 태그를 가진 이벤트에 대한 필터를 수행하지만 결과가 캐싱되지는 않는다.

 

curl localhost:9200/get-together/group/_search?pretty -d '{
  "query": {
    "filtered": {
      "filter": {
        "term": {
          "tags.verbatim": "elasticsearch",
          "_cache": false
        }
      }
    }
  }
}'

비록 모든 필터가 cache 플래그를 가지고 있지만, 모든 케이스에 적용되는 것은 아니다. 범위 필터의 경우, 경곗값 중 하나에 "now"를 사용할 경우 플래그는 무시된다. has_child와 has_parent 필터의 경우 _cache 플래그는 전혀 적용되지 않는다.

 

 

필터 캐시

캐시될 필터의 결과는 필터 캐시에 저장된다. 이 캐시는 노드 레벨에서 할당되는데, 이는 앞서 살펴본 색인 버퍼 크기의 경우와 유사하다. 기본적으로는 10%인데, 이값은 elasticsearch.yml에서 필요에 따라 변경할 수 있다. 필터를 자주 사용하고 캐싱한다면, 크기를 증가시켜야 할 수도 있다.

 

indices.cache.filter.size: 30%

필터 캐시를 더 많이(혹은 더 적게) 필요로 할지는 어떻게 파악할 수 있을까? 이를 위해서는 실제 사용량을 모니터링하여야 한다. 엘라스틱서치는 다양한 메트릭을 제공하고 있고, 이 중에는 실제로 사용 중인 필터 캐시의 크기나 캐시 축출(eviction)의 횟수 등도 포함되어 있다. 캐시 eviction은 캐시가 가득차서 공간을 확보하기 위해 엘라스틱서치가 LRU 정책에 의거 사용된지 가장 오래된 캐시 항목을 제거할 때 발생한다.

 

몇몇 유스케이스에서는 필터 캐시가 짧은 수명을 가질 수 있다. 예를 들어, 사용자가 get-together 이벤트를 특정 주제에 대해 필터하여 검색하고, 원하는 것을 찾을때까지 검색을 수행하다가 웹사이트를 떠났다고 가정한다. 다른 사용자는 이 주제에 대해 검색을 하지 않는다면, 이 캐시 항목은 아무 역할도 하지 않고 그저 머무르다가 evict 될 것이다. 가득 차있고 축출이 빈번하게 발생하는 캐시는 시스템 성능에 부정적인 영향을 미치는데, 왜냐하면 매번 검색할때마다 기존의 캐시를 축출하고 새로운 캐시를 집어넣기 위해 CPU 자원을 소모해야 하기 때문이다.

 

이러한 유스케이스에서는 축출하고 검색이 수행되는 시점에 동시에 수행되지 않도록 하기 위해서는 TTL(time to live)을 캐시에 설정하는 것이 유효할 수 있다. 이는 색인별로 적용할 수 있는데, index.cache.filter.expire를 조절하면 된다. 예를 들어 다음의 경우 필터 캐시는 30분 후에 만료될 것이다.

curl -XPUT localhost:9200/get-together/_settings -d '{
  "index.cache.filter.expire": "30m"
}'

필터 캐시를 위한 충분한 공간을 확보하는 것 이외에도, 이 캐시를 상황에 따라 적절하게 사용할 필요가 있다.

 

 

필터 결합하기

때로는 필터를 결합해서 사용해야할 경우가 있다. 예를 들어 특정한 시간 범위 내의 이벤트를 검색해야 하는데, 그와 동시에 특정 숫자의 인원이 참석한 것만을 검색하고자 할 경우가 있을 것이다. 최적의 성능을 얻기 위해, 필터를 결합해서 사용할때 이 필터들을 적절히 캐싱되도록 하고, 필터들이 적절한 순서로 적용되도록 해야할 것이다.

 

어떻게 최선으로 필터를 조합할 것인지를 이해하기 위해서 엘라스틱서치의 비트셋을 상기시켜볼 필요가 있다. 비트셋은 비트들의 작은 배열로 엘라스틱서치는 어떤 문서가 필터에 매치되는지 여부를 캐싱하기 위해서 이를 사용한다. 대부분의 필터(예를 들어 범위나 텀 필터)는 캐싱을 위해 비트셋을 사용한다. 다른 필터(예를 들어 스크립트 필터)의 경우 비트셋을 사용하지 않는 경우도 있는데, 엘라스틱서치는 필터 존재 여부와 무관하게 모든 순서를 순회해봐야 하기 때문이다.

 

필터 종류비트셋 사용 여부
termO
termsO (비트셋 가능하지만, 다르게 설정할 수도 있다)
exists/missingO
prefixO
regexpX
nested/has_parent/has_childX
scriptX
geo filtersX

 

비트셋을 사용하지 않는 필터의 경우, _cache를 true로 설정하여 바로 그 필터의 결괏값을 캐싱하도록 할 수 있다. 비트셋은 다음과 같은 특성으로 인해 단순히 결과를 캐싱하는 것과는 차이가 있다.

  • 비트셋은 크기가 작고 쉽게 만들 수 있다. 따라서 필터가 처음 수행될때 캐시를 생성하는 오버헤드가 매우 작다.
  • 비트셋은 각 필터별로 저장된다. 예를 들어, 서로 다른 두 쿼리 혹은 두 bool 필터에서 같은 텀 필터를 사용할 경우, 그 텀에 대한 비트셋은 재사용될 수 있다.
  • 비트셋은 다른 비트셋과 쉽게 결합해서 사용할 수 있다. 비트셋을 사용하는 두 쿼리를 사용할 경우, 쉽게 엘라스틱서치에게 AND 혹은 OR 비트연산을 수행하도록 하여 어떤 문서가 결합된 조건에 매칭되는지를 알아낼 수 있다.

비트셋의 장점을 활용하기 위해서는 비트셋을 활용하는 필터를 AND 혹은 OR 비트 연산을 수행할 bool 필터 내에서 결합하여야 한다. 그리고 이는 CPU 관점에서도 단순한 연산이다. Lee가 구성원이거나 elasticsearch 태그를 포함하는 그룹만을 조회하고 싶다면, 다음과 같이 할 수 있다.

{
  "filter": {
    "bool": {
      "should": [
        {
          "term": {
            "tags.verbatim": "elasticsearch"
          }
        },
        {
          "term": {
            "members": "lee"
          }
        }
      ]
    }
  }
}

 

필터를 결합하는 다른 방법은 and/or/not 필터를 사용하는 것이다. 이 필터들은 조금 다르게 동작하는데, bool 필터와는 다르게 이들은 AND 혹은 OR 비트연산을 사용하지 않기 때문이다. 이들은 처음의 필터를 수행하고, 매치되는 문서들을 다음 것으로 넘겨주는 방식으로 동작한다. 따라서, and/or/not 필터는 비트셋을 사용하지 않는 필터를 결합할때 유용하다. 예를 들어, 최소 3명의 구성원이 있고 이벤트가 2013년 7월에 주최된 적이 있는 그룹을 조회하고 싶다면, 필터는 다음과 같다.

{
  "filter": {
    "and": [
      {
        "has_child": {
          "type": "event",
          "filter": {
            "range": {
              "date": {
                "from": "2013-07-01T00:00",
                "to": "2013-08-01T00:00"
              }
            }
          }
        }
      },
      {
        "script": {
          "script": "doc['members'].values.length > minMembers",
          "params": {
            "minMembers": 2
          }
        }
      }
    ]
  }
}

 

만약 비트셋을 사용하는 필터와 그렇지 않은 필터를 함께 사용하는 경우, bool 필터에서 비트셋을 사용하는 것들을 결합하고, 이 bool 필터를 다른 비트셋을 사용하지 않는 필터들과 함께 and/or/not 필터에 포함시킬 수 있다. 예를 들어, 다음 목록은 적어도 2명의 멤버가 있고 Lee가 구성원이거나 그룹이 엘라스틱서치에 관한 그룹을 조회하고 있다.

curl localhost:9200/get-together/group/_search?pretty -d '{
  "query": {
    /* filtered 쿼리란 입력된 쿼리를 필터에 매치되는 문서에 대해서만
       수행하는 것을 의미한다. */
    "filtered": {
      "filter": {
        "and": [
          {
            /* bool 필터는 캐시되어 있을때 빠른데,
               이는 두 텀 필터의 비트셋을 사용하기 때문이다. */
            "bool": {
              "should": [
                {
                  "term": {
                    "tags.verbatim": "elasticsearch"
                  }
                },
                {
                  "term": {
                    "members": "lee"
                  }
                }
              ]
            }
          },
          {
            /* 스크립트 필터는 bool 필터에 매치되는 문서들에 대해서만 수행된다. */
            "script": {
              "script": "doc[\"members\"].values.length > minMembers",
              "params": {
                "minMembers": 2
              }
            }
          }
        ]
      }
    }
  }
}'

 

필터를 bool, and, or, not 중 어느 필터에서 결합하든지 간에, 필터가 수행되는 순서는 매우 중요하다. 텀 필터와 같이 비용이 작은 필터가 스크립트 필터처럼 비용이 큰 필터보다 앞에 위치해야 한다. 이는 비용이 큰 필터를 이전의 필터에 매치된 더 작은 문서의 집합에 대해 수행되도록 한다.

 

 

필드 데이터에 필터를 수행하기

비트셋과 캐시된 결과는 필터를 빠르게 만들어준다. 몇몇 필터는 비트셋을 사용하고, 몇몇은 전체 결과를 캐싱한다. 몇몇 필터는 필드 데이터 기반으로 동작하기도 한다. 필드 데이터는 문서가 어떤 텀들에 mapping 되는지를 저장하고 있는 메모리 내의 자료구조 이다. 이 대응관계는 텀이 어떤 문서에 mapping 되는지에 관한 것인 역 색인에 정반대의 것이다. 필드 데이터는 일반적으로 정렬/집계 과정에서 사용되기는 하지만, 텀 필터나 범위 필터 같은 몇몇 필터에서 사용하기도 한다.

 

메모리를 사용하는 필드데이터에 대한 대안책은 doc value가 있다. doc value는 색인 타임에 연산되어 색인과 함께 디스크에 저장된다. doc value는 수치 혹은 분석하지 않은 문자열 필드에서 동작한다. 엘라스틱서치 2.0의 경우 이런 필드들에 대해 doc value를 기본으로 사용하게 될 것인데, 왜냐하면 필드 데이터를 JVM 힙에 들고 있느 ㄴ것으로 얻을 수 있는 성능 이득에 비해서 비용이 크다고 보고 있기 때문이다.

 

텀 필터는 굉장히 많은 텀들을 갖을 수 있고, 넓은 범위의 범위 필터는 굉장히 많은 수치에 매치될 수 있다. (수치 또한 하나의 텀이다) 이런 필터들을 보통의 방법으로 수행하면 각각의 텀을 하나하나 매칭하여 고유한 문서들의 집합을 반환하게 된다.

텀즈 필터

많은 텀에 대해 필터를 사용하는 것은 비용이 큰데, 겹치는 목록이 많이 발생한 것이기 때문이다. 텀들의 숫자가 많다면, 하나씩 필드 값들을 확인하여 텀이 매치되는지를 확인하는 것이 색인 전체를 살펴보는 것보다 빠르다.

 

필드 데이터

이 필드 값들은 텀즈나 범위 필터에서 execution을 fielddata로 설정함으로써 필드 데이터 캐시 내에 로딩된다. 예를 들어, 다음의 범위 필터는 2013년에 발생한 이벤트를 조회하는데, 이는 필드 데이터 위에 수행된다.

{
  "filter": {
    "range": {
      "date": {
        "gte": "2013-01-01T00:00",
        "lt": "2014-01-01T00:00"
      }
    },
    "execution": "fielddata"
  }
}

필드 데이터를 이용하는 것은 필드 데이터가 기존의 정렬이나 집계 과정에서 사용되었을 경우 특히 유용한다. 예를 들어 tags 필드에 대해 텀 집계를 실행했다면, 이후의 태그에 대한 텀 필터는 필드 데이터가 이미 로딩되어 있기 때문에 매우 빠르게 동작하게 된다.

 

 

텀즈 필터의 다른 실행모드: bool, and, or

텀즈 필터는 다른 실행 모드를 가지고 있다. 기본 실행 모드(plain)가 전체 결과를 캐싱하기 위한 비트셋을 구성한다면, 이 값을 bool로 하여 각 텀에 대한 비트셋을 구성하도록 할 수 있다. 이는 여러 텀들을 공통적으로 갖는 서로 다른 텀즈 필터를 사용할 때 유용하다.

 

또한 and/or 실행 모드도 유사하게 동작한다. 개별 텀 필터가 bool 필터가 아닌 and/or 로 감싸진다는 점을 제외하면 말이다.

 

일반적으로, and/or 방식은 bool 방식에 비해서 느린데, 이는 비트셋의 장점을 활용하지 않기 때문이다. and/or은 첫번째 텀필터가 적은 수의 문서에만 매치될 경우 이어지는 필터들을 빠르게 동작시키는 기능을 한다.

 

요약하자면, 필터를 실행하는 방법에는 세가지가 있다.

  • 필터 캐시에 캐싱하기, 이는 필터가 재사용될 경우가 될 경우에 매우 좋다.
  • 재사용되지 않는다면 캐싱하지 않기
  • 텀즈와 범위 필터를 필드 데이터 위에서 수행하는 이를 위한 필드 데이터가 이미 로딩되어 있고 많은 텀을 사용할 경우 좋다.

정적인 데이터에 대한 검색 요청 전체를 재사용하는 경우에 활용하면 좋은 샤드 쿼리 캐시를 알아본다.

 

(2) 샤드 쿼리 캐시

필터 캐시는 검색의 일부분인 캐싱을 위해 고안된 필터를 빠르게 수행하기 위해 의도적으로 만들어진 것이다. 이것은 또한 세그먼트별로 동작한다. 즉, 머지 작업에 의해 세그먼트가 제거되더라도, 다른 세그먼트의 캐시에는 영향이 없다. 이와는 대조적으로, 샤드 쿼리 캐시는 샤드 레벨에서 요청 전체와 이에 대한 결과를 갖고 있다. 만약 샤드와 같은 요청에 대해 이미 응답을 했었다면, 캐시로부터 이 요청을 처리할 수 있는 것이다. 1.4 버전에서는 샤드 레벨에 캐싱되는 결과는 매치되는 문서들의 갯수, 집계, 검색어 추천 결과만으로 제한되어 있었다. 이로 인해 샤드 쿼리 캐시는 search_type이 count인 경우에만 효과적이다.

 

URI 파라미터 중 search_type을 count로 하는 것은 엘라스틱서치의 검색 결과에는 관심이 없고 그 숫자에만 관심이 있다는 의미이다. 엘라스틱서치 2.0에서는 size를 0으로 설정하여 이 동작을 수행할 수 있고, search_type=count의 형태는 더이상 지원하지 않고 있다.

 

샤드 쿼리 캐시의 각 항목은 서로 다르다. 따라서 각각의 항목은 좁은 범위의 요청에만 적용된다. 다른 텀에 대해서 검색하거나 아주 조금 다른 집계를 수행할 경우, 캐시 미스가 발생할 것이다. 또한, 리프레시가 발생하여 샤드의 내용이 변경되었다면, 모든 샤드 캐시 쿼리는 무효화된다. 그렇지 않다면 새로 매치되는 문서들이 색인에 추가되었을 수 있으므로 캐시로부터 잘못된 응답을 받을 수 있기 때문이다.

 

이와 같이 좁은 캐시 항목 적용 범위로 인해 샤드 쿼리 캐시는 샤드가 잘 변하지 않고 많은 같은 요청을 반복적으로 수행할 경우에만 가치가 있다. 예를 들어, 시계열 색인에 로그를 색인하고 있고, 잘 변하지 않는 오래된 색인이 지워지기 전까지 자주 다수의 반복적인 집계를 수행하고 있을 수 있다. 이런 오래된 색인들이 샤드 쿼리 캐시를 사용하기에 이상적인 예라고 할 수 있다.

 

샤드 쿼리 캐시를 색인 레벨에서 활성화하기 위해서는 색인 설정 갱신 API를 다음과 같이 수행하면 된다.

curl -XPUT localhost:9200/get-together/_settings -d '{
  "index.cache.query.enable": true
}'

 

다른 색인 설정과 마찬가지로, 샤드 쿼리 캐시는 색인 생성 시에 활성화할 수 있다. 하지만 이는 새 색인에 쿼리를 많이 수행할 것이고 갱신은 자주 하지 않을 경우에만 효과적일 것이다.

 

각 쿼리에 대해 색인 레벨 설정을 오버라이드하여 샤드 쿼리 캐시를 활성화하거나 비활성화할 수 있는데, 이는 query_cache 파라미터를 추가하여 적용할 수 있다. 예를 들어, get-together 색인에 빈번하게 수행되는 top_tags 집계를 캐싱하기 위해서, 기본적으로는 비활성화되어 있음에도 불구하고 다음처럼 명령을 실행할 수도 있다.

URL="localhost:9200/get-together/group/_search"?
curl "$URL?search_type=count&query_cache&pretty" -d '{
  "aggs": {
    "tap_tags": {
      "terms": {
        "field": "tags.verbatim"
      }
    }
  }
}'

 

필터 캐시와 마찬가지로, 샤드 쿼리 캐시는 크기 설정 파라미터를 가지고 있다. 이 임계값은 노드의 elasticsearch.yml에서 indices.cache.query.size를 조절하여 변경할 수 있다. 기본값은 JVM 힙의 1%이다.

 

JVM 크기 자체를 조정할 때는 필터와 샤드 쿼리 캐시 모두가 적절한 공간을 확보할 수 있도록 하여야 한다. 만약 메모리(특히 JVM 힙)가 제한적이라면, 메모리 부족 에러를 방지하기 위해 캐시의 크기를 낮게 설정하여 색인이나 검색 요청을 처리하기 위해 필요한 메모리 공간을 충분히 확보할 수 있도록 해야 한다.

 

또한, JVM 힙 이외에도 충분한 여분의 메모리를 가지고 있어야 하는데, 운영체제가 디스크에 저장된 색인을 캐싱하기 위해 사용하기 때문이다. 그렇지 않다면, 지나치게 많은 디스크 탐색이 발생할 것이다.

 

다음으로는 JVM 힙과 운영체제 캐시간의 균형을 맞추는 방법과 중요한 이유에 대해 살펴본다.

 

(3) JVM 힙과 운영체제 캐시

엘라스틱서치가 어떤 요청을 처리할 충분한 힙을 가지고 있지 않다면, 메모리 부족 오류를 발생시키고, 이는 노드 장애로 이어져 클러스터로부터 떨어져 나가게 될것이다. 이 경우, 다른 노드에도 부담이 증가하게 되는데, 설정한 상태로 되돌리기 위해 샤드를 복제하고 이동시키기 때문이다. 일반적으로 노드들은 동등한 스펙을 갖고 있기 때문에, 이와 같은 과도한 부하는 다른 노드에 메모리 부족 에러를 발생시키게 될 것이다. 이런 도미노 효과는 클러스터 전체의 장애 현상으로 이어질 수도 있을 것이다.

 

로그에서 OOM에 관련된 것이 없을지라도 JVM 힙에 여유가 없다면, 노드는 응답이 없는 상태가 될 수도 있다. 메모리 부족 현상을 해결하고 메모리 공간을 확보하기 위해 가비지 컬렉션이 더 자주 그리고 더 오래 동작하기 때문이다. GC가 CPU 자원을 더 많이 소모하게 되면, 사용자의 요청을 처리하거나 마스터로부터의 ping에 응답할 컴퓨팅 자원이 부족하게 되고, 이는 클러스터로부터 노드가 떨어져 나가는 결과로 이어지게 된다.

 

 

GC가 너무 자주 발생하면, GC 튜닝을 시도한다.

GC가 CPU 자원을 과다하게 소모하고 있으면, 엔지니어는 모든 원인을 해결해줄 JVM 설정을 찾아내고 싶을 것이다. 대다수의 경우 이는 해결책을 찾아내기 위한 적절한 과정이 아닌데, 과도한 GC는 엘라스틱서치가 가진 것보다 더 많은 힙을 필요로 하기 때문에 나타나는 하나의 증상일 뿐이기 때문이다. 비록 힙 크기를 증가시키는 것이 쉬운 해결책일 수는 있으나, 이것이 언제나 가능한 것은 아니다. 더 많은 데이터 노드를 추가하는 것 역시 마찬가지다. 이보다는, 힙 사용률을 감소시킬 몇가지 방법에 대해 알아본다.

 

  • 색인 버퍼의 크기를 줄인다?
  • 필터 캐시와 샤드 쿼리 캐시의 크기를 줄인다?
  • 검색과 집계의 size 파라미터값을 줄인다(집계의 경우 shard_size도 고려)
  • 만약 size 값을 크게 사용해야 한다면 데이터 노드도 아니고 마스터 노드도 아닌 노드를 클라이언트처럼 사용할 수 있다. 이 노드들은 검색과 집계의 샤드별 결과를 종합하여 집계하는 역할을 하게 된다.

마지막으로 엘라스틱 서치는 자바가 가비지 컬렉션을 수행하는 방식을 회피하기 위해 또다른 형태의 캐시를 사용한다. 새로운 객체들이 할당되는 영 영역(young generation)이라는 공간이 있는데, 이 객체들은 오랜 기간 참조되었거나 새로운 객체들이 많이 할당되어 젊은 세대 공간이 가득 찾을 경우 올드 영역(old generation)으로 승격된다. 특히 방대한 문서를 찾아보고 다음 집계에서 사용될지도 모르는 객체를 아주 많이 생성하는 집계의 경우 특히 이러한 마지막 문제점이 나타날 수 있다. 

 

일반적으로는 어쩌다보니 영 영역이 가득 찰때 거기에 있엇던 랜덤하고 임시적인 객체보다는 이러한 잠재적으로 재사용 가능성이 있는 객체를 올드 영역에 두고 싶을 것이다. 이를 위해 엘라스틱서치는 PageCacheRecycler를 구현하여 집계에서 사용하는 큰배열을 가비지 컬렉션 대상이 되지 않도록 하고 있다. 이 페이지 캐시는 기본적으로 전체 힙의 10%를 차지하는데, 경우에 따라 이 값이 너무 큰 것일 수도 있다. 이 캐시의 크기는 elasticsearch.yaml의 cache.recycler.page.limit.heap을 통해 조절할 수 있다.

 

아직도 JVM 설정을 튜닝해야 할 경우는 더 있을 수 있는데(비록 기본 설정이 매우 좋기는 하지만), 예를 들어 메모리는 충분하지만 긴 GC가 발생하면 클러스터에 문제가 생기는 경우를 생각해볼 수도 있다. GC가 더 자주 수행되지만 Stop the World 현상은 덜 자주 발생하게 하는 선택 등을 생각해 볼 수 있는데, 이를 통해 효과적으로 전반적인 처리량에서 조금 손해를 볼지라도 응답 지연 시간 관점에서 이득을 취할 수 있다.

 

  • 전체 힙 대비 서바이버 영역을 늘리거나(-XX:SurvivorRatio를 낮춰서) 혹은 영 영역을 늘릴 것(-XX:NewRatio를 낮춰서) 각 영역들을 모니터링하여 이런 작업이 필요한지 확인할 수 있다. 더 많은 공간을 확보할수록 영 GC가 수명이 짧은 객체를 정리하여 GC가 발생하면 더 긴 Stop the World 시간이 발생하게 되는 올드 영역으로 승격시키기까지의 시간을 더 확보할 수 있다. 하지만 이 영역을 너무 크게 만들면 GC 부담이 커지고 더 비효율적이 되는데, 수명이 긴 객체들이 두 서바이버 영역 사이에서 지속해서 복제되기 때문이다.
  • G1 GC (-XX:+UseG1GC)를 사용할 것. 이는 각 영역에 대한 공간을 동적으로 할당하며, 큰 메모리를 사용하고 낮은 레이턴시 환경의 유스케이스에 가장 적합하다. 엘라스틱서치 1.5버전에서는 기본으로 사용되지는 않는데, 이는 32비트 장비에서 발생하는 버그들이 발견되고 있기 때문이다. 그러므로 G1을 프로덕션 환경에서 사용하기 전에 반드시 전체적으로 검증하도록 한다.

 

힙의 크기가 지나치게 클 수도 있는가?

지나치게 작은 크기의 힙이 좋지 않다는 것은 자명할 것이나, 지나치게 큰 힙 역시 좋지 않다. 32GB보다 큰 힙은 압축되지 않은 포인터를 사용하도록 만들어 메모리를 낭비하게 된다. 얼마나 많이 메모리가 낭비될까? 이는 유스케이스에 따라 다르다. 적게는 주로 집계(이는 적은 수의 포인터를 갖고 있는 큰 배열을 사용한다)를 사용한다면 32GB 중 1GB가 낭비될 것이고, 많게는 필터(이는 많은 수의 포인터를 갖고 있느 다수의 작은 항목들을 갖고 있다)를 많이 사용한다면 10GB 가량이 낭비될 수도 있다. 정말로 32GB 이상의 힙이 필요하다면, 때로는 한 장비에서 둘 이상의 노드를 각각 32GB보다 작은 힙으로 구동하고 데이터를 샤딩하여 분할하는 것이 나을 수 있다.

 

32GB를 넘지 않는 과도하게 큰 힙 역시 이상적이라고 볼 수 없다. (실제로는, 정확히 32GB에서는 이미 압축된 포인터를 사용하지 않는다. 따라서 최대 31GB를 사용해야 한다고 생각하는 것이 적절하다) JVM에게 점유되지 않은 서버의 램은 보통 운영체제 캐시가 디스크에 저장된 색인들을 캐싱하기 위해 사용된다. 이는 특히 자기 디스크나 네트워크 저장소를 사용하는 경우 중요한데, 쿼리 수행 시점에 디스크로부터 데이터를 가져오는 것은 쿼리에 대한 응답을 지연시키게 될 것이기 때문이다. 빠른 SSD를 사용하는 경우에도, 저장해야 할 데이터의 양이 운영체제 캐시 크기에 적합할 때 최적의 성능을 얻을 수 있다.

 

힙의 크기가 너무 작을 경우 메모리 부족 이슈가 있을 수 있기 때문에 좋지 않을 수 있고, 또한 지나치게 큰 힙도 운영체제 캐시를 비효율적으로 만들기 때문이 좋지 않을 수 있다. 그렇다면 좋은 힙 크기는 무엇일까?

 

 

이상적인 힙 크기: 절반 규칙을 따른다.

각각의 유스케이스에 따른 실제 힙 사용량을 고려하지 않고 이야기하면, 기본 원칙은 32GB를 초과하지 않는 선에서 노드의 램 용량의 절반을 할당하는 것이다. 이 절반 규칙(half rule)은 대게 힙 크기와 OS 캐시 간에 적절한 균형을 이루도록 해준다.

 

실제 힙 사용량을 모니터링할 수 있다면, 좋은 힙 크기란 일반적인 사용량과 예상되는 급증에 대비하기에 적절한 크기다. 메모리 사용량이 급증하는 경우가 있을 수 있는데, 예를 들어 어떤 사용자가 텀즈 집계를 사이즈 0 설정으로 굉장히 많은 고유한 텀을 가진 분석 필드에 대해서 수행하는 경우를 생각해볼 수 있다. 이는 엘라스틱서치에게 모든 텀들을 메모리에 로딩하여 계산하도록 만들 것이다. 어느 정도 메모리 급증이 발생할지 예상하기 어렵다면, 가장 좋은 규칙은 이 역시 절반 규칙이라고 할 수 있다. 즉, 평소의 사용량보다 50% 큰 값으로 힙 크기를 설정하는 것이다.

 

운영체제 캐시의 경우, 대체로 서버 자체의 RAM을 사용하게 된다. 하지만, 운영체제 캐싱을 효과적으로 활용하도록 색인을 설계할 수 있다. 예를 들어, 애플리케이션 로그를 색인하는 경우, 대부분의 색인이나 검색이 최근 데이터에 고나한 것이 될거라고 예상할 수 있다. 시계열 색인을 사용함으로써, 최근의 색인이 오래된 데이터보다 운영체제 캐시에 포함될 확률을 높여서 대부분의 작업을 빠르게 만들 수 있다.

 

오래된 데이터에 대한 검색은 자주 디스크를 탐색하게 되지만 사용자는 이와 같은 긴 시간 범위에 대한 검색의 경우 느린 처리 시간을 예상하고 조금 더 인내심을 가질 것이라고 기대할 수 있다. 일반적으로 더 "핫한" 데이터를 시계열 색인, 사용자 기반 색인, 혹은 라우팅 등을 사용하여 같은 색인이나 샤드에 보관함으로써 운영체제 캐시를 더 효율적으로 사용할 수 있다.

 

필터 캐시, 샤드 쿼리 캐시, 운영체제 캐시는 보통 쿼리가 처음 수행될때 만들어진다. 캐시를 로딩하는 작업이 첫번째 쿼리를 느리게 수행되도록 하고, 데이터의 양이나 쿼리의 복잡도에 따라 느려지는 정도가 커질 수 있다. 이 느려지는 현상이 문제가 된다면, 다음에서 살펴볼 색인 워머를 사용하여 캐시를 미리 준비시켜 놓는 것을 생각해 볼 수 있다.

 

(4) 워머로 캐시를 준비시키기

워머는 어떤 종류의 검색에 대해서든 정의할 수 있다. 이는 검색, 필터, 정렬 기준, 집계 등을 포함할 수 있다. 한번 정의되면, 워머는 리프레시 작업이 수행될 때마다 엘라스틱서치가 뭐리를 수행하도록 해준다. 이는 리프레시를 느리게 하긴 하지만, 사용자는 언제나, 준비된 캐시를 사용할 수 있다.

 

워머는 최초의 쿼리가 느리게 수행될 것이기 때문에 사용자가 이를 기다리기보다는 리프레시 작업을 통해 미리 수행해 놓기를 원하는 경우에 유용하다. get-together 웹사이트 예제에서 만약 이벤트가 수백만건이 있고 안정적인 검색 성능이 중요하다면, 워머가 유용할 수 있다. 리프레시 작업이 느린 것이 크게 문자가 되지는 않을 것이다. 그룹이나 이벤트들이 자주 변경되지는 않지만 빈번하게 검색될 것이라고 예상하기 때문이다.

 

기존 색인에 워머를 정의하기 위해서는 색인의 URI에 _warmer라는 타입과 워머 이름에 해당하는 ID를 함께 입력하여 PUT 요청을 하면 된다. 원하는 만큼 많이 워머를 생성할 수 있으나 워머가 많아질수록 리프레시가 느려진다는 점에 주의해야 한다. 예를 들어 다음 목록에서는 두 워머를 생성하고 있는데, 하나는 다가오는 이벤트에 관한 것이고 다른 하나는 인기 있는 그룹 태그에 관한 것이다.

curl -XPUT 'localhost:9200/get-together/event/_warmer/upcoming_events' -d '{
  "sort": [
    {
      "date": {
        "order": "desc"
      }
    }
  ]
}'
curl -XPUT 'localhost:9200/get-together/group/_warmer/top_tags' -d '{
  "aggs": {
    "top_tags": {
      "terms": {
        "field": "tags.verbatim"
      }
    }
  }
}'

 

이후에는 _warmer 타입에 GET 요청을 보내 색인에 대한 모든 워머의 목록을 조회할 수 있다.

curl localhost:9200/get-together/_warmer?pretty?

 

워머의 URI로 DELETE 요청을 보내 워머를 지울 수도 있다.

curl -XDELETE localhost:9200/get-together/_warmer/top_tags

 

다수의 색인을 사용하고 있다면, 워머를 색인 생성 시점에 등록하는 것이 효과적일 수 있다. 이를 위해서는 다음 목록에서 볼 수 있듯이 워머 키를 매핑이나 세팅을 정의했던 것과 유사하게 정의하면 된다.

curl -XPUT 'localhost:9200/hot_index' -d '{
  "warmers": {
    /* 워머의 이름 */
    "date_sorting": {
      "types": [], // 이 워머가 실행될 타입들, 빈 값은 모든 타입을 의미한다.
      "source": {  // 이 워머는 일자별로 정렬하는 것이다.
        "sort": [
          {
            "date": {
              "order": "desc"
            }
          }
        ]
      }
    }
  }
}'

만약 시계열 색인을 사용하는 경우에서처럼 새로운 색인이 자동으로 생성된다면, 워머를 색인 템플릿에 정의하여 새롭게 생성되는 색인들에 자동으로 적용되도록 할수 있다.

 

지금까지는 일반적인 방법에 대해 살펴보았다. 어떻게 캐시를 미리 준비시킬것인가. 어떻게 효율적으로 활용하여 검색을 바르게 할 것인가. 어떻게 검색을 그룹화하여 네트워크 지연을 줄일 것인가. 어떻게 세그먼트 리프레시와 플러시, 저장에 대한 설정을 변경하여 클러스터에 미치는 부하를 줄일까 등이 앞서 살펴본 것이들이다.

 

이외의 성능에 관련된 트레이드오프

어떤 동작의 성능을 높이고자 할 경우 무언가를 희생해야 할 것이다. 예를 들어, 리프레시의 빈도를 낮춰서 색인 속도를 높이고자 하였다면, 최근에 색인된 데이터는 조회가 불가능해지는 비용을 감수했어야 했었다. 이 절에서는 계속해서 구체적인 사례를 통해 다음 주제에 대한 답을 확인하며 이러한 트레이드오프에 대해서 살펴볼 것이다.

 

  • 부정확한 매치 - 싱글과 엔그램을 색인 시점에 사용하여 더 빠른 검색을 하기를 원하는가? 혹은 퍼지나 와일드카드를 사용하는 것이 더 좋은가?
  • 스크립트 - 색인 시점에 가능한 많은 연산을 미리 해두는 방법을 택해서 유연성을 조금 포기할 수 있는가? 그렇지 않다면, 어떻게 스크립트의 성능을 조금이라고 개선할 수 있는가?
  • 분산 검색 - 더 정확한 점수 집계를 위해 네트워크 라운드트립을 사용해도 괜찮은가?
  • 깊은 페이징 - 100개의 페이지를 조회하기 위해 더 많은 메모리를 사용해도 괜찮은가?

 

(1) 색인과 비용이 큰 검색

일부 부정확한 검색결과여도 괜찮다면(예를 들어 오타를 허용할 수 있는 경우) 다음과 같은 쿼리들을 사용할 수 있다.

 

  • 퍼지 쿼리 - 이 쿼리는 원본과 편집 거리(edit distance)가 일정값 이내인 텀들을 찾는다. 예를 들어, 한 문자가 추가되거나 빠진 경우, 편집 거리는 1이다.
  • 접두사 쿼리 혹은 필터 - 이 쿼리를 통해 입력한 부분 문자열로 시작하는 텀들에 대해 검색할 수 있다.
  • 와일드카드 - 이는 ?와 *를 통해 하나 혹은 다수의 문자를 나타낼 수 있도록 해준다.

위 쿼리들은 상당한 유연성을 가져다주지만, 텀 쿼리와 같은 기본적인 검색에 비해서 오히려 더 무겁기도 하다. 정확하게 매치되는 쿼리의 경우 엘라스틱서치는 텀사전에서 오직 하나만을 찾아보면 되지만, 퍼지/접두사/와일드카드 쿼리의 경우 주어진 패턴에 매치되는 모든 텀을 찾아봐야 한다.

 

오탈자나 부정확한 검색을 해결하기 위해 적용할 수 있느 ㄴ다른 방법은 엔그램이 있다. 엔그램은 단어의 각 부분에 대한 토큰을 생성해주는 것이다. 색인 시점과 쿼리 시점에 이를 사용한다면, 아래와 같이 퍼지 쿼리와 유사한 기능을 얻을 수 있다.

 



퍼지 쿼리 스트림과 엔그램 사용 쿼리 스트림의 비교

 

어떤 방법이 성능 면에서 가장 유리할까? 여기에는 트레이드오프가 있고, 무엇을 희생하고 무엇을 추구할 것인지를 선택해야 한다.

  • 퍼지 쿼리는 검색을 느리게 하지만 색인 자체는 정확한 매칭을 하는 경우와 같다.
  • 반대로 엔그램은 색인의 크기를 증가시킨다. 엔그램과 텀 크기에 따라 엔그램을 포함한 색인의 크기는 몇배 더 커진다. 또한 엔그램 설정을 변경하려면 전체 데이터를 리색인 해야 한다. 즉, 덜 유연하다고 볼 수 있다. 하지만 엔그램의 경우 검색은 일반적으로 더 빨라진다.

엔그램은 일반적으로 쿼리 지연이 중요하거나 동시에 수행되는 쿼리가 많아 각 쿼리가 적은 CPU 자원을 소모하기를 원하는 경우에 유용하다. 엔그램은 색인을 커지게 만들기 때문에 사용자의 디스크가 충분히 크거나, 색인이 여전히 운영체제 캐시에 적합한 크기여야 할 것이다. 그렇지 않다면 큰 색인으로 인해 성능 문제가 발생할 것이다.

 

퍼지 접근법은 반대로 색인 크기가 문제가 되거나 성능이 낮은 디스크를 사용하고 있어 색인 처리량이 중요한 경우 유용하다. 퍼지 쿼리는 또한 쿼리가 자주 변경될 경우에도 유용하다. 왜냐하면 변경 시에도 데이터를 리색인할 필요 없이 수정 거리를 변경하는 등의 방법을 통해 쿼리를 변경할 수 있기 때문이다.

 

 

접두사 쿼리와 엣지 엔그램

부정확한 검색의 경우, 종종 앞부분의 검색어는 맞았을 것이라고 가정할 수 있다. 예를 들어, "elastic"이라는 검색은 아마도 "elasticsearch"를 찾기 위한 것이었을지도 모른다. 퍼지 쿼리처럼, 접두사 쿼리는 일반적인 텀 쿼리보다 비용이 크다. 더많은 텀들을 살펴봐야 하기 때문이다. 대안적인 방법은 엣지 엔그램이 될 수 있다. 

 

퍼지 쿼리나 엔그램의 경우와 마찬가지로, 접두사 쿼리 역시 그 유연성과 색인 크기 간에 트레이드오프가 있는데, 이에 관해서는 접두사 쿼리를 통한 접근이 이점이 있고, 쿼리 지연과 CPU 사용률 간의 트레이드 오프 면에서는 엣지 엔그램이 이점이 있다.

 

 

와일드카드

elastic*처럼 마지막에 와일드카드를 넣는 와일드카드 쿼리는 접두사 쿼리와 같은 방식으로 작동한다. 같은 목적을 달성하기 위해 사용할 수 있는 다른 방법으로는 엣지엔그램이 있다.

 

만약 와일드카드를 e*search처럼 중간에 사용한다면, 색인 시점에 취할 수 있는 대안적 방법은 존재하지 않는다. 여전히 엔그램을 사용하여 제시된 문자열은 e와 search에 매칭할 수 있지만, 와일드카드가 어떻게 사용될지 알 수  없다면, 와일드카드 쿼리만이 유일한 해법이다.

 

만약 와일드카드를 항상 시작 부분에서 사용한다면, 일반적으로 이것은 끝부분에 와일드카드를 사용하는 것보다 비용이 크다. 매치되는 텀을 찾기 위해 텀사전에서 어떤 부분을 찾아봐야 할 것인지에 관한 단서가 전혀 없기 때문이다. 이런 경우라면, 살펴보았었던 역 토큰 필터를 엣지 엔그램과 함께 사용하는 것이 대안이 될 수 있다.

 



 

 

구문 쿼리와 싱글

인접한 단어에 걸쳐 검색을 수행해야 할 경우, 매치 쿼리의 타입을 phrase로 설정하여 사용할 수 있다. phrase 쿼리는 텀들의 텀 자체뿐만 아니라 문서에서 텀들의 위치까지고 살펴봐야 하기 때문에 더 느리다.

 

색인 시점에 사용할 수 있는 구문 쿼리에 대한 대안은 singles이다. 싱글은 엔그램과 유사하지만, 문자 단위가 아닌 텀 단위의 것이다. 결과적으로 기능은 구문 쿼리와 유사하다고 볼 수 있다. 성능 측면에서는 앞서 살펴봤던 엔그램과 유사하다. 왜냐하면, 싱글은 쿼리를 빠르게 만들기 위해 색인 크기를 크게하고 색인을 느리게 만들기 때문이다.

 

두 방법이 정확히 같지는 않다. 마치 와일드카드와 엔그램이 정확히 같지는 않은 것처럼 말이다. 구문 쿼리의 경우와 마찬가지로, 구문에 다른 단어가 등장하지 않는 것을 허용하는 slop을 지정할 수 있다. 예를 들어, slop값이 2일 경우 "buy phone"이라는 쿼리는 "buty the best phone"이라는 문자열에 매칭된다. 왜냐하면 검색 시점에 엘라스틱서치는 각 텀의 위치를 알고 있는데, 싱글의 경우 실제로는 하나의 텀이 되기 때문이다.

 

싱글이 하나의 텀이라는 점으로 인해 이것을 복합어에 매칭되도록 사용할 수도 있다. 예를 들어, 많은 사람은 아직도 엘라스틱서치를 "elastic search"라고 부를 것인데, 이를 매칭하기는 꽤 까다로울 것이다. 싱글을 사용한다면, 기본값인 공백이 아니라 빈 문자열을 구분자로 사용하여 이 문제를 해결할 수 있다.

 



싱글, 엔그램, 퍼지, 와일드카드 쿼리에서 살펴보았듯이, 많은 경우 문서를 검색하기 위한 방법은 여러 가지가 있을 수 있으나, 여러 방법들이 동등한 것이라는 의미는 아니다. 성능이나 유연성이라는 관점에서 최적의 방법은 유스케이스에 따라 달라진다. 이어서는 스크립트에 대해서 더 깊게 알아볼 것이다. 스크립트는 앞서 말한 것이 더 그렇다고 말할 수 있다. 같은 결과를 얻기 위해 여러 방법을 사용할 수 있으나, 각각의 방법에는 장단점이 존재한다.

 

(2) 스크립트를 튜닝하거나 사용하지 않기

스크립트는 갱신 작업이나 정렬을 위해서 사용되기도 한다. 또한 검색 시점에 가상의 필드를 만들어내는 용도로도 사용할 수 있다. 스크립트를 사용하여 유연성을 확보할 수 있는 것은 좋지만, 이 유연성에는 성능에 미치는 부정적인 영향이라는 반대급부가 있다. 스크립트의 결과는 절대로 캐싱되지 않는데, 엘라스틱서치가 스크립트가 무엇인지에 관해 알 수 없기 때문이다. 스크립트에는 무작위 숫자처럼 외부적인 것이 있을 수도 있고, 이는 이번에 매치된 문서가 다음 번 실행 시에는 매치되지 않을 수도 있다는 것을 의미한다. 따라서 엘라스틱서치는 언제나 같은 스크립트를 모든 대상이 되는 문서들에 걸쳐 직접 수행하게 된다.

 

스크립트를 사용할 경우, 많은 경우 스크립트가 검색에서 CPU 자원을 가장 많이 소모하는 부분이다. 쿼리를 빠르게 수행하고자 한다면, 스크립트를 되도록 사용하지 않는 것이 좋을 것이다. 만약 이것이 불가능하다면, 성능을 향상시키기 위해서는 최대한 네이티브 코드에 가깝게 쿼리를 작성하여야 한다.

 

어떻게 스크립트를 삭제하거나 혹은 최적화할 수 있을까? 유스케이스에 따른 베스트 프랙티스를 살펴보자.

 

 

스크립트 사용 회피하기

스크립트를 사용하여 스크립트 필드를 생성하고 있었다면, 이를 색인 시점에 할 수도 있다. 문서를 그대로 색인하고 그룹 구성원의 수를 스크립트에서 배열의 길이를 통해 계산하는 방법 대신에, 그룹 구성원의 수를 색인 시점에 계산하여 새로운 필드로 추가할 수 있다.

 

엔그램에서와 마찬가지로, 이와 같은 연산을 색인 시점에 하는 방법은 쿼리의 레이턴시가 색인 처리량보다 우선순위가 높을 경우 효과가 있다. 

 



 

미리 연산을 하는 것과 별개로, 스크립트의 성능을 최적화하는 일반적인 규칙은 엘라스틱서치에 존재하는 기존 기능들을 최대한 많이 재사용하는 것이다. 스크립트를 사용하기 이전에, 함수 스코어 쿼리를 사용할 수 있는 조건을 충족할 수 있는지 확인해보고, 함수 스코어 쿼리는 스코어를 조작할 수 있는 여러 방법을 제공해 준다. "elasticsearch" 이벤트에 대해 쿼리를 실행하려고 하는데, 아래와 같은 가정에 기반하여 점수에 가중치를 주려고 한다고 생각해보자. 

 

  • 최근에 발생한 이벤트일수록 더 관련성이 높다. 최대 60일 이전에 발생한 이벤트에 대해서 이벤트가 오래 전에 발생했을수록 점수를 지수적으로 낮게 줄 수 있을 것이다.
  • 이벤트가 인기가 많고 참석자가 높을수록 더 관련성이 높다. 이벤트의 참석자가 많을수록 선형으로 점수를 높게 줄 수 있다.

이벤트 참석자를 색인 시점에 미리 계산할 수 있다면(attendees_count라는 필드로), 스크립트를 사용하지 않고도 두 기준을 모두 중족할 수 있을 것이다.

{
  "function_score": {
    "functions": [
      {
        "linear": {
          "date": {
            "origin": "2013-07-25T18:00",
            "scale": "60d"
          }
        }
      },
      {
        "field_value_factor": {
          "field": "attendees_count"
        }
      }
    ]
  }
}

 

 

네이티브 스크립트

스크립트를 최적의 성능으로 사용하고 싶다면, 스크립트를 자바로 작성하는 것이 바람직한 방법이다. 이런 네이티브 스크립트는 엘라스틱서치의 일종의 플러그인이라고 볼 수 있다. 네이티브 스크립트를 사용하는 가장 큰 단점은 이 스크립트가 모든 노드의 엘라스틱서치 클래스패스에 저장된다는 점이다. 따라서 스크립트를 변경하기 위해서는 클러스터의 모든 노드를 업데이트하고 재시작해야 한다. 쿼리를 자주 바꾸지 않는 경우에는 이것이 큰 문제가 아닐 수도 있다.

 

검색에서 네이티브 스크립트를 실행하고자 할 경우, lang을 native로 지정하고 스크립트 내용에 스크립트의 이름을 입력하면 된다. 예를 들어, numberOfAttendes라는 이름의 스크립트 플러그인을 가지고 있고 이것은 이벤트 참석자의 수를 집계하기 위한 것이라면, 다음처럼 통계 집계에 이것을 함께 사용할 수 있다.

{
  "aggregations": {
    "attendees_stats": {
      "stats": {
        "script": "numberOfAttendees",
        "lang": "native"
      }
    }
  }
}

 

 

루씬 표현식

스크립트를 자주 바꿔야 하거나 클러스터 재시작없이 변경하기를 원할 경우, 그리고 스크립트가 수치값에 대한 것일 경우, 루씬 표현식은 최적의 선택일 수 있다.

 

루씬 표현식을 사용할 경우, 쿼리 시점에 자바스크립트 표현식을 입력하면 엘라스틱서치가 이를 네이티브 코드로 컴파일하여 네이티브 스크립트만큼 빠르게 동작하게 된다.

 

이 방법의 큰 한계점은 색인된 수치값에 대해서만 수행할 수 있다는 점이다. 또한, 문서가 대상 필드를 가지고 있지 않을 경우, 0이란 값을 자동으로 사용하게 되는데, 유스케이스에 따라 이것이 결과를 왜곡하게 될 수도 있다.

 

루씬 표현식을 사용하려면 스트크립트에서 lang을 expression으로 설정하면 된다. 예를 들어, 참석자의 수를 이미 알고 있는데 이 중 절반만이 실제로 참석하여서 이 숫자들에 대한 일종의 통계를 구해보는 경우를 살펴보자.

 

{
  "aggs": {
    "expected_attendees": {
      "stats": {
        "script": "doc['attendees_count'].value/2",
        "lang": "expression"
      }
    }
  }
}

 

수치가 아닌 혹은 색인이 되어 있지 않은 필드에 대한 작업을 수행하고자 하거나 혹은 스크립트를 쉽게 바꿀 수 있기를 원한다면 그루비를 사용하면 된다. 그루비는 엘라스틱서치 1.4에서부터 디폴트 스크립트 언어로 사용되고 있다. 그루비 스크립트를 어떻게 최적화할 수 있는지 알아보자.

 

 

텀 통계

점수를 튜닝해야 한다면, 스크립트 내의 스코어 자체를 다룰 필요없이 루씬 수준의 텀 통계를 사용할 수 있다. 예를 들어, 문서에서 텀이 등장한 횟수에 기반하여 점수를 계산하고자 하는 경우이다. 엘라스틱서치의 기본 동작과 다르게, 문서에서 등장한 필드의 길이나 텀이 다른 문서에 등장한 횟수 등에는 관심이 없을 수도 있다. 이런 연산을 위해서는 다음 목록에서 확인할 수 있는 것처럼 텀빈도(문서 내에서 텀이 등장한 횟수)만 명시한 스크립트 점수를 사용할수 있다.

curl 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "function_score": {
      "filter": {
        "term": {
          /* 제목 필드에 'elasticsearch'라는 텀을 가진 문서만 필터한다. */
          "title": "elasticsearch"
        }
      },
      "functions": [
        {
          "script_score": {
            /* 필드에 속해 있는 텀에 속한 tf() 함수를 통해 텀 빈도에 접근 */
            "script": "_index[\"title\"][\"elasticsearch\"].tf()+_index[\"description\"][\"elasticsearch\"].tf)_",
            "lang": "groovy"
          }
        }
      ]
    }
  }
}'

 

 

필드 데이터 접근하기

실제 문서의 필드들을 스크립트에서 다뤄야 한다면, 한 가지 방법은 _source 필드를 이용하는 것이다. 예를 들어, organizer 필드는 _source['organizer']로 접근할 수 있다. _source 이외에도 개별 필드를 별도로 저장하는 방법도 존재한다. 개별 필드가 저장되어 있다면, 그 저장된 값들을 사용할 수도 있다. 예를 들어, 주최자 필드는 _fields['organizer']로도 접근할 수 있을 것이다.

 

_source나 _fields를 사용할 때의 문제점은 이처럼 특정 필드를 조회하기 위해 디스크를 탐색해야 하는 비용이 많이 든다는 점이다. 이런 성능 면에서의 비용이 엘라스틱서치가 내장된 정렬이나 집계에서 필드의 내용에 접근해야 할 때 필드 데이터를 필요로 하는 이유라고 할 수 있다. 필드 데이터는 랜덤 액세스에 맞게 튜닝되어 있고, 따라서 스크립트 내에서 사용하기에도 적합하다. 스크립트는 많은 경우 스크립트가 최초로 실행될 때 필드 데이터가 로딩되어 있지 않더라도 _source나 _fields를 사용하는 것보다 훨씬 빠르다.

 

organizer 필드를 필드 데이터로 접근하고자 한다면, doc['organizer']를 참조하면 된다. 예를 들어, 주최자가 구성원으로 속해 있지 않은 그룹을 조회하여, 자신의 그룹에 참여하라고 요청할 수도 있을 것이다.

curl 'localhost:9200/get-together/group/_search?pretty' -d '{
  "query": {
    "filtered": {
      "filter": {
        "script": {
          "script": "return doc.organizer.values.intersect(doc.members.values).isEmpty()"
        }
      }
    }
  }
}'

 

_source['organizer'] 혹은 _fields의 그것을 사용하는 대신에 doc['organizer']를 사용하는 경우에 한가지 주의해야 할 점이 있다. 바로 문서의 원본 필드가 아닌 텀을 사용한다는 점이다. 만약 주최자가 "Lee"이고 필드가 기본 분석기로 분석되는 경우, _source에서는 "Lee"를 갖고 있는 반면 문서에서는 "lee"를 갖고 있게 된다. 어디에서나 트레이드오프가 있게 마련이다.

 

이어서, 어떻게 분산 검색이 동작하는지와 정확한 스코어를 계산하는 것과 낮은 레이턴시 검색을 수행하는 것 사이에서 균형 잡힌 선택을 하기 위해 어떤 검색 타입을 사용할 수 있는지에 대해 더 깊이 알아본다.

 

(3) 네트워크 비용을 더 사용하여 적은 데이터를 전송하고 더 좋은 분산 스코어링을 얻기

엘라스틱서치 노드가 검색 요청을 받으면 그 노드는 요청과 관련된 모든 샤드로 분배하여 개별 샤드의 응답으로부터 애플리케이션을 반환할 하나의 최종 응답으로 집계한다.

 

이것이 어떻게 동작하는지에 대해 더 깊이 알아본다. 나이브한 접근법은 N개의 문서를 모든 관련된 샤드로부터 조회하고(N은 문서 수의 크기), HTTP 요청을 받은 노드에서 이를 정렬하여(이를 조정 노드라 부름), 상위 N개의 문서를 선택한 후 사용자 애플리케이션으로 반환하는 것일 것이다. 기본값인 5개의 샤드를 가진 색인에 기본 문서 크기 값인 10이라는 요청을 했다고 가정한다. 이는 조정 노드가 각 샤드로부터 10개의 문서를 얻어와서 정렬하고, 50개의 문서로부터 상위 10개를 반환해준다는 것을 의미한다. 하지만 샤드가 10개여서 문서가 100개라면 어떨까? 문서는 전달하는 네트워크 오버헤드와 조정 노드에서 이것들을 처리하는 메모리 오버헤드가 너무 클 것이다. 마치 집계에서 큰 shard_size 값을 사용하는 것이 성능에 악영향을 미치는 것처럼 말이다.

 

조정(coordinating) 노드에서 정렬하기 위해 필요한 메타데이터와 이 50개 문서들의 ID값만을 반환하는 것은 어떨까? 정렬 이후, 조정 노드는 샤드로부터 필요한 상위 10개 문서만을 조회할 수 있게 된다. 이는 대부분의 경우 네트워크 오버헤드를 줄여주지만 두 번의 라운드 트립을 필요로 한다.

 

엘라스틱서치의 경우 검색의 search_type 파라미터를 통해 두가지 모두를 사용할 수 있다. 모든 연관된 문서를 조회하는 나이브한 구현은 query_and_fetch이고, 기본값인 두 단계로 구성된 방법은 query_then_fetch이다.

 

기본값인 query_then_fetch는 크기 파라미터에서 더 큰 값을 입력하여 더 많은 샤드를 조회해야 할 경우, 그리고 문서가 클 경우 더 좋은데, 이 방법은 네트워크로 전송하는 데이터가 훨씬 적기 때문이다. query_and_fetch 방법은 하나의 샤드를 조회할 때만 성능이 더 빠르다. 그렇기 때문에 검색이 라우팅을 사용하여 하나의 샤드만을 조회하거나 카운드만을 조회할 때는 내부적으로 이것이 사용된다. 지금은 query_and_fetch를 명시적으로 지정할 수도 있지만, 2.0에서는 이런 특정 유스케이스별로 내부적으로만 사용되게 될 것이다.

 



 

 

분산 스코어링

기본적으로 스코어는 샤드 별로 계산되기 때문에 정확하지 않을 수 있다. 예를 들어, 텀에 대해서 검색하는 경우, 계산에 필요한 요소들 중 하나는 모든 문서에서 입력한 텀이 몇번 등장했는지를 의미하는 문서 빈도(DF: Document Frequency)다. 이 "모든 문서"란 기본적으로 해당 샤드 내의 모든 문서를 의미한다. 만약 어떤 텀의 DF가 샤드에 따라 많이 다르다면, 스코어링이 실제 점수를 정확하게 반영하지 못할 수도 있다. 샤드 내에서 이 텀을 가진 문서의 수가 적어 문서 2가 문서 1에 비해 높은 점수를 얻는 경우가 생길 수도 있다.

 



 

충분히 많은 수의 문서가 있다면, DF 값이 샤드 간에 자연스럽게 균형을 이룰 것이고, 기본 동작 방식이 올바르게 작동할 것이라고 예상할 수 있다. 하지만 점수의 정확성이 우선순위가 높거나 DF가 사용자의 유스케이스에서 균등하게 분포하지 않는 경우, 다른 접근법이 필요할 것이다.

 

그 방법은 바로 검색 타입을 query_then_fetch에서 dfs_query_then_fetch로 바꾸는 것이 될 수 있다. dfs 부분이 조정 노드(coordinating node)에 검색 대상 텀에 관한 문서 빈도를 수집하기 위해 샤드에 추가적인 요청을 하도록 한다. 집계된 빈도가 점수를 계산하는데 사용되어, 문서 1과 문서 2의 순위를 정확하게 집계하게 된다.

 



DFS 쿼리는 추가적인 네트워크 통신으로 인해 더 느리다는 점을 이미 알게 되었을 수도 있을 것이다. 따라서 스위칭이 일어나기 이전에 더 정확한 점수를 얻을 수 있도록 하는 것이 좋다. 만약 레이턴시가 낮은 네트워크를 사용하고 있다면 이 오버헤드는 무시해도 좋을 만큼 작을 것이다. 반대로, 네트워크가 충분히 빠르지 않거나 동시에 수행되는 쿼리가 많을 경우, 오버헤드가 상당히 커질 수 있다.

 

 

개수만 반환하기

하지만 점수는 전혀 필요하지 않을 뿐 아니라 문서의 내용도 필요없다면 어떻게 해야 좋을까? 예를 들어, 문서의 수나 집계값만이 필요할 수도 있다. 이럴 때 추천할 검색 타입은 카운트다. 카운트는 검색이 수행되는 샤드에게 매칭되는 문서의 수만을 요청하고 이 값들을 합쳐 준다.

 

(4) 메모리를 써서 깊은 페이징하기

size와 from을 이용하여 쿼리의 결과를 페이징할 수 있다. 예를 들어, get-together 데이터를 "elasticsearch"로 검색하여 5번째 페이지의 100개 결괏값을 얻고자 한다면 다음처럼 요청할 수 있다.

 

curl 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "match": {
      "title": "elasticsearch"
    }
  },
  "from": 400,
  "size": 100
}'

이는 결과적으로 상위 500개의 결과를 얻어오고 정렬하여, 마지막 100개만을 반환하게 될 것이다. 페이징이 많아질수록 이것이 얼마나 비효율적이 될지를 짐작할 수 있다. 예를 들어, 매핑을 변경하고 새로운 색인으로 현재 데이터를 다시 색인할 경우, 마지막 페이지를 반환하기 위해 모든 결과를 정렬하는 과정에서 메모리가 부족할 수도 있다.

 

이런 시나리오라면 get-together 그룹을 모두 조회하기 위해 아래와 같이 scan이라는 검색 타입을 사용해볼 수 있다. 최초의 응답은 스크롤 ID만을 반환하는데, 이 값은 요청에 대한 고유 식별자로 이미 반환한 페이지들이 무엇인지를 기억하고 있다. 조회를 시작하고자 할 경우, 요청을 스크롤 ID와 함께 보내면 된다. 다음 페이지를 조회하고자 할 경우에는 같은 요청을 반복하면 되는데, 충분히 데이터를 조회하였거나 혹은 hits 배열이 빈값인 상황, 즉 더는 반환할 데이터가 없어질 때까지 반복할 수 있다.

 

curl "localhost:9200/get-together/event/_search?pretty&q=elasticsearch\
&search_type=scan\
&scroll=1m\   /* 엘라스틱서치는 다음 요청을 1분간 대기한다 */
&size=100"    /* 각 페이지의 크기 */
{
  "_scroll_id": "913be0c10ee7a64131f8689d9cd8fdd6",  // 이후 요청에서 사용할 스크롤 ID를 반환한다.
  ...
  "hits": {
    /* 결과는 아직 조회하지 못하고, 아직 숫자만 조회한다. */
    "total": 7,
    "max_score": 0,
    "hits": []
  }
}

 

이전에 받은 스크롤 ID를 통해 첫번째 페이지를 조회하고, 다음 요청까지의 타임아웃을 지정한다.

curl 'localhost:9200/_search/scroll?scroll=1m&pretty' -d '913be0c10ee7a64131f8689d9cd8fdd6'

 

다음 요청을 위한 다른 스크롤ID를 반환받는 동시에, 결과를 갖고있는 페이지가 조회된것을 알수 있다.

{
  "_scroll_id": "bd53de12fff96dd07d984937dabc364a",
  ...
  "hits": {
    "total": 7,
    "max_score": 0.0,
    "hits": [ {
      "_index": "get-together",
  }
}

 

마지막 스크롤 ID를 통해 hit 배열이 다시 빈 값이 되기 전까지 페이지를 계속 조회한다.

curl 'localhost:9200/_search/scroll?scroll=1m&pretty' -d 'bd53de12fff96dd07d984937dabc364a'

 

다른 검색에서처럼 스캔 검색은 페이지 크기를 조절하기 위한 파라미터를 지정할 수 있다. 주의할 점은, 이는 샤드별 크기를 의미하기 대문에, 실제로 반환되는 크기는 (입력한 페이지 크기 * 샤드 수)라는 점이다. 각 요청의 타임아웃 파라미터로 주어지는값은 새 페이지를 조회할 때마다 갱신된다. 따라서 매 요청마다 다른 타임아웃 값을 지정할 수도 있다.

 

스캔 타입의 검색은 언제나 결과를 정렬 기준과 무관하게 색인을 순회하며 조회하는 순서에 따라서 반환한다. 다수의 페이징과 정렬이 동시에 필요하다면, 스크롤 파라미터를 일반적인 검색 요청에 추가할 수 있다. 스크롤 ID로 GET 요청을 보내면 다음 페이지의 결괏값을 조회할 수 있다. 이번에는 샤드의 수와 상관없이 크기 값이 정확하게 적용된다. 일반적인 검색에서의 경우와 마찬가지로 첫번째 요청의 첫번째 페이지 역시 조회할 수 있다.

curl 'localhost:9200/get-together/event/_search?pretty&scroll=1m' -d '{
  "query": {
    "match": {
      "title": "elasticsearch"
    }
  }
}'

 

성능 관점에서는 일반적인 검색에 스크롤을 추가하는 것이 스캔 검색을 사용하는 것보다 비용이 큰데, 결괏값이 저장될 때 메모리에 추가로 저장해야 하는 정보가 있기 때문이다. 그렇긴 하지만, 많은 페이징에 경우에는 기본 검색보다 효과적인데 왜냐하면 현재 페이지를 반화하기 위해 엘라스틱서치가 이전 페이지에 대한 결과를 정렬하지 않아도 되기 때문이다.

 

스크롤은 사전에 다수의 페이징 작업을 수행할 것이라는 것을 알고 있을 때만 유용하다. 소수의 페이징 결과만을 필요로 할 경우에는 이를 추천하지 않는다. 성능 향상을 위해 지불해야 하는 비용이 있기 때문이다. 스크롤의 경우, 비용은 현재 검색에 대한 정보를 스크롤이 만료되거나 더 이상의 검색결과가 없을때까지 메모리에 저장하는 것이다.

 

타임아웃값을 크게 하여 처리하는 동안 만료되는 일이 없도록 하고 싶을 수도 있다. 하지만 스크롤이 활성화되어 있고 사용하지는 않는 경우, 이는 자원을 낭비하게 되는 문제점이 있다. 예를 들어 JVM 힙의 경우 현재 페이지를 기억해 놓기 위한 자료구조를 저장하고 있어야하고, 디스크의 경우 스크롤이 완료 혹은 만료되기 전까지 루씬 세그먼트를 머지에 의해 지워지지 않도록 해야 한다는 점에서 그러하다.

 

벌크 API를 사용하여 다수의 색인, 문서 갱성, 갱신, 삭제 요청을 하나의 요청으로 처리하기

 

다수의 조회나 검색 요청을 합치기 위해서는 멀티겟이나 멀티서치 API를 각각 사용할 수 있다.

 

플러시 작업은 색인 버퍼가 가득찼거나, 트랜잭션 로그가 일정 수준 이상으로 크거나, 마지막 플러시로부터 일정 이상의 시간이 지났을 때 메모리 내의 루씬 세그먼트들을 디스크로 커밋해 준다.

 

리프레시는 플러시와는 무관하게 검색에서 사용할 수 있는 새 세그먼트들을 생성한다. 색인 부하다 클 때는 리프레시 빈도를 낮추거나 리프레시를 아예 비활성화하는 것이 바람직하다.

 

머지 정책은 필요에 따라 다수 혹은 소수의 세그먼트를 사요하도록 튜닝할 수 있다. 소수의 세그먼트를 사용하는 것은 검색을 빠르게 해주나 머지 작업이 더 많은 CPU 자원을 사용하게 된다. 다수의 세그먼트를 사용할 경우 머지에 자원을 덜 사용하므로 색인은 더 빨라지지만, 검색이 느려지게 된다.

 

최적화 작업은 강제로 머지를 수행하는데, 다수의 검색 요청을 받는 정적 색인에게는 효과적인 방법이다.

 

저장 제한은 머지 작업이 뒤쳐지도록 만들어 색인 성능을 제한하게 될 수도 있다. 빠른 I/O 성능을 가진 시스템을 사용하고 있다면, 임계값을 올리거나 혹은 임계값을 없애는 것이 좋을 것이다.

 

bool 필터에서 비트셋을 사용하는 필터들을, in/or/not 필터에서 비트셋을 사용하지 않는 필터들을 결합해서 사용할 것

 

정적인 색인을 사용한다면 카운트나 집계를 샤드 쿼리 캐시에 캐싱할것

 

JVM 힙을 모니터링하고 여유 공간을 충분히 두어 과도한 가비지 컬렉션이나 메모리 부족 예외를 방지할 것, 또한 운영체제 캐시를 위한 램 여유 공간을 둘 것

 

처음 실행될 쿼리가 너무 느린데 색인이 느려지는 것은 크게 문제가 없다면 색인 워머를 사용할 것.

 

색인의 크기가 커져도 괜찮다면, 퍼지/와일드카드/구문 쿼리 대신 엔그램과 싱글을 사용하는 것이 검색을 빠르게 해준다.

 

많은 경우 필요한 데이터를 색인하기 이전에 새로운 필드로 생성함으로써 스크립트 사용을 줄일 수 있다.

 

적합한 모든 곳에서 루씬 표션식, 텀 통계, 스크립트에서의 필드 데이터를 사용할 것.

 

스크립트를 자주 변경하지 않아도 된다면 엘라스틱서치 플러그인 형태로 네이티브 스크립트를 작성할 수 있는지에 대해 이해할 것

 

샤드별로 문서 빈도가 균일하지 않다면 dfs_query_then_fetch를 사용할 것

 

힛 자체는 필요하지 않을 경우 카운트 검색을 사용할 것, 많은 문서를 탐색해야 한다면 스캔 검색을 사용할 것



출처: https://12bme.tistory.com/483?category=737765 [길은 가면, 뒤에 있다.]