Elasticsearch:: 엘라스틱서치 내부 동작
🙈

Elasticsearch:: 엘라스틱서치 내부 동작

Created
Jun 17, 2024 02:22 AM
Last edited time
Last updated June 17, 2024
Tags
ElasticSearch
Language
URL

Intro::

엘라스틱서치에서의 쓰기/읽기 작업 동작과 검색 동작 흐름에 대해 알아봅시다.
 

쓰기 작업 시 엘라스틱서치 동작과 동시성 제어

쓰기 작업은 조정 단계(coordination stage), 주 샤드 단계(primary stage), 복제 단계(replica stage)의 3단계로 수행됩니다.
  • 조정 단계
    • 클러스터에 쓰기 요청이 들어오면 먼저 라우팅을 통해 어느 샤드에 작업을 해야 할지를 파악합니다. 몇번 샤드에 작업해야 하는지가 확인되면 해당 번호의 샤드 중에서 현재 주 샤드를 찾아 작업을 넘겨줍니다.
  • 주 샤드 단계
    • 주 샤드가 요청을 넘겨받은 이후 수행하는 작업들을 의미합니다. 주 샤드에서 작업 요청이 넘어오면 주 샤드는 이 요청이 문제가 있는 요청인지 검증합니다. 문제가 없다면 로컬에서 요청한 쓰기 작업을 수행합니다.
  • 복제 단계
    • 주 샤드 단계의 작업이 완료되면 각 복제본 샤드로 요청을 넘깁니다. 마스터 노드는 작업을 복제받을 샤드 목록을 관리하고 있는데 이 목록을 in-sync 복제본이라고 합니다. 주샤드는 in-sync 복제본에 병렬적으로 요청을 넘깁니다. 이후 모든 복제본이 작업을 성공적으로 수행하고 주 샤드에 응답을 돌려주면 주 샤드가 작업 완료 응답을 보냅니다.
조정, 주 샤드, 복제 단계는 이 순서대로 실행되지만 종료는 역순으로 동작합니다.
notion image

낙관적 동시성 제어

만약 한 문서의 views 필드 값을 1로 색인하고 이 변경 내용이 복제본 샤드에 완전히 적용되기 전에 다른 클라이언트로부터 주 샤드에 같은 문서의 views 필드 값을 2로 색인하는 변경이 발생했다고 가정해봅시다. 분산시스템 특성상 두 요청 중 어떤 요청이 먼저 복제본 샤드로 들어올지는 보장할 수 없습니다. 이러한 현상을 막기 위해 엘라스틱서치는 _seq_no가 존재합니다.
_seq_no는 각 주 샤드마다 들고 있는 시퀀스 숫자값이며 매 작업마다 1 씩 증가합니다. 엘라스틱서치는 _seq_no 값을 역전시키는 변경을 허용하지 않음으로써 요청 순서의 역전 적용을 방지합니다.
 
notion image
만약 주 샤드를 들고 있는 노드에 문제가 발생하여 해당 노드가 클러스터에서 빠진다면 엘라스틱서치는 복제된 샤드 중 하나를 주 샤드로 지정합니다. 엘라스틱서치는 이전 주 샤드에서 수행했던 작업과 새로 임명된 주 샤드에서 수행했던 작업을 구분하기 위해 _primary_term이라는 값을 도입했습니다. 이 값은 주 샤드가 새로 지정될 때 1씩 증가합니다.
_version_seq_no_primary_term처럼 동시성을 제어하기 위한 메타데이터로 모든 문서마다 붙는다. _version_seq_no_primary_term과 다른 점은 클라이언트가 문서의 _version 값을 직접 지정할 수 있다는 점입니다.

읽기 작업 시 엘라스틱서치 동작

클라이언트로부터 읽기 작업을 요청받은 조정노드는 라우팅을 통해 적절한 샤드를 찾아 요청을 넘김니다. 이때 쓰기 작업과는 다르게 주 샤드가 아니라 복제본 샤드로 요청이 넘어갈 수 있습니다. 문서 단건 조회라면 하나의 샤드에 요청이 넘어가겠지만 검색 등 다수의 샤드에 요청을 넘겨줄 수도 있습니다. 요청을 넘겨받은 각 샤드는 로컬에서 읽기 작업을 수행한 뒤 그 결과를 조정 노드로 돌려줍니다. 조정노드는 이결과를 모아서 클라이언트에게 응답합니다.
읽기 요청이 주 샤드, 복제본 샤드 구분 없이 분산되어 독립적으로 동작하는 이 흐름에서는 주 샤드에 색인이 완료됐지만 특정 복제본에는 반영이 완료되지 않은 상태의 데이터를 읽을 수도 있다는 점을 염두에 둬야 합니다.
notion image
 

체크포인트와 샤드 복구 과정

만약 문제가 생겨서 특정 노드가 재기동되었다면 그 노드가 들고 있던 샤드에 복구 작업이 진행됩니다. 이 과정에서 복구 중인 샤드가 현재 주 샤드의 내용과 일치하는지를 파악할 필요가 있습니다. 엘라스틱서치의 경우 _seq_no와 _primary_term를 조합하여 샤드와 샤드 사이에 어떤 반영 차이점이 있는지를 확인해 각 샤드의 로컬 체크포인트를 기록합니다. 주샤드에서는 각 샤드로 부터 응답받은 체크포인트 중 최소값을 글로벌 체크포인트로 기록해두며 몇번 작업까지 모든 샤드에 반영이 완료돼있는지 확인합니다.
문제가 발생해 샤드를 복구해야 할 경우가 생기면 엘라스틱서치는 샤드 간에 글로벌 체크포인트를 비교합니다. 주 샤드와 복제본 샤드의 글로벌 체크포인트가 같다면 이 샤드는 추가 복구 작업이 필요없지만, 만약 다르다면 두 샤드 간 체크포인트를 확인해서 필요한 작업만 재처리하여 복구합니다.
notion image
 
루씬 레벨에서 엘라스틱서치가 수행하는 쓰기 작업은 새 문서의 색인과 기존 문서의 삭제 작업, 두가지 뿐입니다. 업데이트 작업은 문서를 삭제하고 새 문서를 색인하는 작업에 불과합니다. 체크포인트를 비교하여 샤드를 복구하는 과정에서 작업을 재처리할 때 문서 색인 작업은 문제가 되지 않습니다. 루씬에 색인된 문서가 재처리에 필요한 정보를 모두 들고 있기 때문입니다. 그러나 데이터를 삭제하는 삭제 작업은 그렇지 않습니다. 따라서 엘라스틱서치는 논리적 삭제를 도입했습니다. 최근 삭제한 문서를 일정 기간 보존해 두고 작업 재처리에 활용합니다.
 

엘라스틱서치의 검색 동작

점선으로 그린 사각형 → 샤드 레벨에서 수행하는 작업 둥근 모서리를 가진 실선 사각형 → 조정 노드에서 수행하는 작업
notion image

엘라스틱서치 검색 동작 흐름 상세

TransportSearchAction

검색 요청과 현재 클러스터 상태를 분석해 상황에 맞는 적절한 검색 방법과 대상을 확정합니다. 인덱스를 확정하고 구체적으로 어떤 샤드에 검색 요청을 보낼지 정하게 됩니다. 먼저 라우팅을 이용해 몇 번 샤드에 요청을 보낼지를 확인합니다. 하지만 복제본 샤드가 있으니 같은 인덱스의 같은 번호 샤드도 여러 개가 있습니다. TransportSearchAction은 다양한 방법을 통해 여러 샤드 중 어떤 샤드에 우선해서 요청을 보낼것인지 순서를 확정합니다. TransportSearchAction은 요청을 보낼 샤드 목록을 ShardIterator 인터페이스의 형태로 작성합니다. 딱히 preference가 지정되지 않았다면 엘라스틱서치는 해당 샤드를 가진 노드 중 적절한 노드를 선정합니다.

CanMatchPreFilterSearchPhase

검색 요청의 search_type이 query_then_fetch이고 몇몇 특정 조건을 만족하면 엘라스틱서치는 본격적인 검색 작업에 들어가기 전에 CanMatchPreFilterSearchPhase를 거치며 몇가지 최적화를 수행합니다. CanMatchPreFilterSearchPhase는 검색 대상의 샤드 수가 128개를 초과하거나, 검색 대상이 읽기 전용 인덱스를 포함하거나, 첫 번째 정렬 기준에 색인된 필드가 지정된 경우 수행됩니다.
CanMatchPreFilterSearchPhase는 검색 대상 샤드에서 주어진 쿼리로 단 하나의 문서라도 매치될 가능성이 있는지 사전에 가벼운 점검을 수행해 확실히 검색 대상이 될 필요가 없는 샤드를 사전 제거합니다. 조정 노드에서 수행할 수 있는 점검을 마친 뒤에는 각 샤드별 점검 요청을 노드 단위로 묶어 만들어 transport 채널로 분산 전송합니다. 요청을 수신한 노드는 SearchServicecanMatch 메서드를 수행하며 사전 작업을 수행합니다. 이 사전작업은 비용이 낮은 편입니다.
CanMatchPreFilterSearchPhase의 최적화 과정에서는 인덱스의 메타데이터를 이용해 타임스탬프 필드 범위상 매치되는 문서가 확실히 없는지를 체크한다거나 첫 번째 정렬 기준으로 색인된 필드가 지정되어 있으면 각 샤드의 최솟값과 최댓값을 가지고 샤드를 정렬해 상위에 올라올 문서를 보유한 샤드가 먼저 수행되도록 최적화하는 등 다양한 방법이 사용됩니다.

AbstractSearchAsyncAction

search_type의 기본값은 qeury_then_fetch입니다. 각 샤드에서 검색 쿼리를 수행하고 매치된 상위 문서를 수집할 때 유사도 점수 계산을 끝내는 가장 일반적인 형태의 검색입니다. 이 형태의 검색은 SearchQueryThenFetchAsyncAction 클래스에서 전체적인 흐름을 제어합니다.
search_typedfs_query_then_fetch로 지정하면 모든 샤드로부터 사전에 추가 정보를 모아 정확한 유사도 점수를 계산합니다. 이 경우 정확도는 올라가지만 성능은 떨어집니다. 이 형태의 검색은 SearchDfsQueryThenFetchAsyncAction 클래스에서 전체적인 흐름을 제어합니다. 여기서 DFSdistributed frequency search의 약자입니다.
두 클래스 모두 AbstractSearchAsyncAction 추상 클래스를 확장하고 있으며 해당 추상 클래스는 검색 대상 샤드별로 샤드 검색 요청을 만등어 분산 정송한 뒤 응답을 수집하고 다음 페이즈로 이동하는 등 검색의 전체적인 흐름을 제어하는 역할을 수행합니다.

SearchPhase

엘라스틱서치는 검색 동작을 페이즈 단위로 구분해 수행하기 위해 SearchPhase라는 추상 클래스를 사용합니다. 주로 한 SearchPhase의 작업이 끝나면 지정한 다음 SearchPhase로 넘어가서 작업을 이어가는 흐름으로 구현하고 있습니다.

SearchDfsQueryThenFetchAsyncAction

search_typedfs_query_then_fetch라면 검색은 SearchDfsQueryThenFetchAsyncAction로 시작합니다. SearchDfsQueryThenFetchAsyncAction은 점수 계산에 사용할 추가 정보를 각 샤드에서 가져오기 위해 샤드별 요청을 만들어 분산 전송합니다. 이 요청을 수신한 각 노드는 SearchServiceexecuteDfsPhase 메서드를 호출합니다.
이 메서드에서는 ReaderContext를 가져오거나 새로 생성합니다. 검색 요청을 만드는 단계에서 pit 등 기존 문맥을 활용하도록 지정되어 있었다면 이미 생성된 ReaderContext가 노드에 존재하므로 그것을 가져옵니다. 가져올 수 없는 경우 ReaderContext를 새로 생성해 노드에 일정 시간 동안 저장해 둡니다. ReaderContext에는 문맥을 생성하는 그 시점의 샤드를 대상으로 검색을 수행하는 루씬 IndexSearcher가 담깁니다. 즉 나중에 이 ReaderContext를 재활용하면 항상 같은 상태의 샤드를 대상으로 검색을 수행할 수 있고, 이 기능이 pit에서 활용하는 기능입니다.

DfsQueryPhase

DfsQueryPhase는 각 샤드에서 보낸 DfsPhase 작업 결과로부터 샤드별 본 검색 요청을 만들어 다시 각 노드로 분산 전송합니다. 이 요청을 수신한 노드는 SearchServieexecuteQueryPhase 메서드를 거쳐 QueryPhase에서 본격적인 쿼리 매치 작업을 수행합니다. 각 샤드의 QueryPhase의 작업 결과를 수신하면 다음 페이즈인 FetchSearchPhase로 넘어갑니다.

SearchQueryThenFetchAsyncAction

search_typequery_then_fetch라면 가장 일반적인 검색 방법인 SearchQueryThenFetchAsyncAction으로 시작합니다. SearchQueryThenFetchAsyncAction은 사전 작업 없이 바로 샤드별 검색 요청을 만들어 전송합니다. 마찬가지로 이 요청을 수신한 노드는 SearchServieexecuteQueryPhase 메서드 거쳐 QueryPhase에섯 본격적인 쿼리 매치 작업을 수행합니다.

QueryPhase

SearchQueryThenFetchAsyncAction이나 DfsQueryPhase의 검색 요청을 수신한 노드는 SearchServiceexecuteQueryPhase 메서드를 호출합니다. 여기서 ReaderContext를 가져오거나 새로 생성합니다. 요청에 pit가 지정됐다면 pit로부터, DfsPhase를 거쳐왔다면 DfsPhase에서 생성했던 ReaderContext를 가져와 재활용합니다. 마찬가지로 ReaderContext를 가져올 수 없다면 새로 생성하고 노드에 저장합니다.
ReaderContext를 확보한 다음엔 샤드 요청이 캐시 가능한 요청인지 확인합니다. 여기서의 캐시는 샤드 레벨에 저장되는 캐시입니다. 캐시 가능한 요청이라면 캐시에서 값을 불러와 바로 응답을 반환합니다. 캐시에 값이 없고 캐시 가능 요청이라면 QueryPhase의 주 작업을 수행하고 캐시에 결과를 저장합니다.
이후 QueryPhaseexecute에서 주 작업을 수행합니다. QueryPhase에서는 크게 검색, 제안, 집계의 세 작업을 수행합니다. 검색 작업은 루씬의 IndexSearcher, Query, Collector를 이용해 쿼리에 매치되는 상위 문서를 수집하는 작업입니다. 제안 작업은 오타 교정이나 자동 완성 등에 사용하는 기능으로 SuggestPhase클래스에서 수행합니다. 집계 작업은 AggregationPhase 클래스에서 수행합니다.

FetchSearchPhase와 FetchPhase

각 샤드가 수행한 QueryPhase 작업의 결과가 조정 노드에 모이면 FetchSearchPhase로 넘어갑니다. FetchSearchPhaseQueryPhase의 작업 결과를 모아 병합하고 각 샤드에 요청할 fetch 요청을 생성해 분산 전송합니다. 이 요청을 받은 각 노드는 SearchServiceexecuteFetchPhase를 호출합니다. executeFetchPhase는 먼저 ReaderContext를 찾고, 확보했다면 FetchPhaseexecute를 호출해 요청에 지정한 번호의 문서 내용을 실제로 읽습니다. 이후 ReaderContextpitscroll이 아닌 단발성 쿼리를 위한 문맥이었다면 ReaderContext를 해제합니다. 그리고 fetch 결과를 조정 노드로 반환합니다. 각 샤드의 FetchPhase 작업 결과가 조정 노드에 모이면 다음 페이즈인 ExpandSearchPhase로 넘어갑니다.

FetchSubPhase

FetchSubPhase 인터페이스는 FetchPhase 작업 중 문서 내용을 읽어 SearchHit을 만드는 과정에서 수행하는 여러 하위 작업을 구현하는 인터페이스입니다. FetchSubPhase 인터페이스의 구현체는 SearchModule에 등록됩니다. 커스텀 플러그인을 통해 직접 만든 FetchSubPhase를 등록할 수도 있습니다. 기본적으로 등록된 FetchSubPhase 인터페이스 구현체로는 _source를 읽어오는 FetchSourcePhase, _score를 다시 계산해 가져오는 FetchScorePhase, 검색 수행 중간 과정과 부분 유사도 점수를 상세 설명하는 _explanation을 만드는 ExplainPhase 등이 있다.

ExpandSearchPhase

ExpandSearchPhase는 필드 collapse를 위한 페이즈입니다. 필드 collapse는 지정한 필드 값을 기준으로 검색 결과를 그룹으로 묶은 뒤 그 안에서 다른 기준으로 상위 문서를 지정한 개수만큼 뽑을 때 사용하는 특수한 기능입니다.
ExpandSearchPhase에서는 필드 collapse를 수행하기 위해 본 검색의 hit 수만큼 새 검색 요청을 만들어 MultiSearchRequest에 담습니다. 이 MultiSearchRequest는 로컬에 전송되며 조정 노드 자신이 이를 다시 받습니다. 해당 작업은 TransportMultiSearchAction에서 처리하는데, 이 다중 검색의 세부 요청들은 다시 TransportSearchAction이 받아서 수행합니다. TransportSearchAction은 독립적인 새 검색을 수행하는 것과 동일한 절차로 검색 작업을 수행해 결과를 조정 노드에 돌려줍니다.
ExpandSearchPhase가 반환한 InternalSearchResponse를 최종 응답 모양인 SearchResponse로 변환하는 작업은 다시 AbstractSearchAsyncAction으로 돌아와 수행합니다. 이를 통해 AbstractSearchAsyncAction이 검색의 전체적인 흐름의 중심이라는 것을 확인할 수 있습니다.
 

궁금한 점

  • 왜 QueryPhase와 FetchPhase로 나눠서 각 샤드에 똑같은 문서에 대한 요청을 두번이나 보내는 걸까??
    • 각 샤드에서 QueryPhase를 통해 관련성 점수를 계산하여 상위 문서 ID 리스트(메타 데이터)를 조정 노드에 반환합니다. 조정 노드는 각 샤드에서 반환한 문서 ID 리스트들을 취합하여 전체 상위 문서 리스트를 생성한 뒤, 각 샤드에 해당 상위 문서의 실제 데이터를 요청합니다. 이때 FetchPhase를 통해 상위 문서의 실제 데이터를 반환받게 됩니다.
    •  

References::

엘라스틱서치 바이블 - 여동현 지음
 

Loading Comments...