Elasticsearch 버전 작성 지원

Elasticsearch의 주요 원칙 중 하나는 데이터를 최대한 활용할 수 있도록 하는 것입니다. 과거에는 검색이 단일 소스의 데이터로 검색 엔진을 로드하는 읽기 전용 엔터프라이즈였습니다. 사용량이 증가하고 Elasticsearch가 애플리케이션의 중심이 됨에 따라, 여러 구성 요소에 의해 데이터를 업데이트해야 합니다. 여러 구성 요소는 동시성으로 이어지고 동시성은 충돌로 이어집니다. Elasticsearch의 버전 작성 시스템은 이러한 충돌에 대처하는 데 도움이 됩니다.

버전 작성의 필요성 - 예

상황을 설명하기 위해, 사람들이 티셔츠 디자인을 평가하는 데 사용하는 웹사이트가 있다고 가정해 보겠습니다. 웹사이트는 간단합니다. 모든 디자인을 나열해놓고 사용자가 디자인에 대해 엄지손가락을 치켜세워 좋아요를 표시하거나 엄지손가락 내리기 아이콘을 사용하여 싫어요 투표를 할 수 있도록 하는 것입니다. 모든 티셔츠에 대해, 그 웹사이트는 현재 찬성표와 반대표의 상황을 보여줍니다.

각 검색 엔진의 레코드는 다음과 같습니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1' -d'
{
"name": "elasticsearch",
"votes": 999
}'

보시다시피, 각 티셔츠 디자인에는 이름과 현재 상황을 추적할 수 있는 투표 카운터가 있습니다.

단순하고 확장 가능한 상태로 유지하기 위해, 웹사이트는 완전히 상태 비저장(stateless) 방식입니다. 누군가 페이지를 보고 투표 버튼을 클릭하면 서버에 AJAX 요청을 전송합니다. 이 요청은 Elasticsearch에게 카운터를 업데이트하도록 지시하게 됩니다. 이를 위해, 단순한 구현은 현재 투표 값을 가져와서 하나씩 증분한 다음 Elasticsearch로 이를 전송하게 됩니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1' -d'
{
"name": "elasticsearch",
"votes": 1000
}'

이 접근법에는 심각한 결함이 있습니다. 투표를 잃을 수도 있는 것입니다. 아담과 이브가 동시에 같은 페이지를 보고 있다고 가정해 보세요. 현재 페이지에는 999표가 표시됩니다. 둘 다 팬이기 때문에 둘 다 투표 버튼을 클릭합니다. 이제 Elasticsearch는 문서를 업데이트하기 위해 위 요청의 동일한 복사본 두 개를 갖습니다. 이로써 총 투표수가 1001표가 되는 대신에, 지금 투표수가 1000표가 됩니다. 이런.

물론 업데이트 api를 사용하면 특정 값으로 설정하는 대신 투표가 증분될 수 있다는 사실을 다음과 같이 보다 스마트하게 전달할 수 있습니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1/_update' -d'
{
"script" : "ctx._source.votes += 1"
}'

이렇게 하면, Elasticsearch는 먼저 내부적으로 문서를 검색하고 업데이트를 수행한 후 다시 색인합니다. 이로써 성공할 가능성을 훨씬 더 높아지긴 하지만, 여전히 전과 같은 잠재적인 문제를 안고 있습니다. 문서 검색과 색인 사이의 짧은 시간 간격 동안 문제가 발생할 수 있습니다.

위의 시나리오를 처리하고 보다 복잡한 시나리오들을 지원하기 위해, Elasticsearch에는 버전 작성 시스템이 내장되어 있습니다.

Elasticsearch의 버전 작성 시스템

Elasticsearch에 저장하는 모든 문서에는 관련 버전 번호가 있습니다. 해당 버전 번호는 1에서 2 63-1(포함) 사이의 양수입니다. 문서를 처음 색인할 때, 문서는 버전 1이 되고 Elasticsearch가 반환하는 응답에서 이를 확인하실 수 있습니다. 예를 들어, 다음은 이 블로그 포스팅의 첫 번째 cURL 명령의 결과입니다.

{
"ok": true,
"_index": "designs",
"_type": "shirt",
"_id": "1",
"_version": 1
}

색인이든, 업데이트이든, 삭제이든 관계없이 이 문서에 대한 모든 쓰기 작업에서, Elasticsearch는 버전을 1씩 증가시킵니다. 이 증분은 수행 도중 중단될 수 없는 동작이며 작업이 성공적으로 반환된 경우 발생합니다.

Elasticsearch는 또한 작업 가져오기의 응답과 함께 현재 버전의 문서들을 반환하게 되며(실시간임을 기억하세요) 모든 검색 결과와 함께 이를 반환하도록 지시할 수도 있습니다.

낙관적 락(Optimistic Lock)

우리의 아주 단순화되고 이해하기 쉬운 예시에서는 잠재적으로 두 명의 사용자가 동시에 동일한 문서를 업데이트하려는 시나리오에 대한 솔루션이 필요했습니다. 문서를 업데이트하기 전에 한 사람이 문서에 대한 잠금을 획득하고 업데이트를 수행한 후 잠금을 해제하는 것이 이러한 문제에 대한 일반적인 해결 방법입니다. 문서에 잠금이 설정되어 있으면, 누구도 그 문서를 변경할 수 없습니다.

많은 애플리케이션에서 이것은 또한 누군가가 문서를 수정하고 있는 경우 수정이 완료될 때까지 다른 사용자가 그 문서를 읽을 수 없음을 의미합니다. 이런 종류의 잠금은 확실히 효과가 있지만 비용이 듭니다. 고효율 시스템의 경우, 이러한 잠금에는 다음과 같은 두 가지 주요 단점이 있습니다.

  • 많은 경우, 이러한 잠금이 필요가 없습니다. 제대로 작업이 수행되면, 충돌이 거의 발생하지 않습니다. 물론 충돌이 발생하게 되지만 시스템이 수행하는 작업의 극히 일부에 대해서만 발생합니다.
  • 잠금은 여러분이 실제로 신경을 쓴다는 것을 가정합니다. 어느 웹 페이지만 렌더링하려는 경우, 시스템이 잠시 후에 변경된다는 것을 알고 있더라도 일부에서 약간 구식이지만 여전히 일관된 값을 얻는 것만으로도 여러분은 괜찮을 수 있습니다. 진행 중인 쓰기가 완료될 때까지 읽기가 항상 기다릴 필요는 없습니다.

Elasticsearch의 버전 작성 시스템을 통해 여러분은 낙관적 락이라는 또 다른 패턴을 쉽게 사용하실 수 있습니다. 매번 잠금을 획득하는 대신, 찾을 문서의 버전을 Elasticsearch에게 알려줍니다. 그 동안 문서가 변경되지 않았으면, 작업이 성공적으로 완료되고 잠금이 해제됩니다. 문서에서 변경된 내용이 있고 최신 버전이 있는 경우, Elasticsearch는 여러분이 이를 적절하게 처리할 수 있도록 여러분에게 신호를 보낼 것입니다.

위의 검색 엔진 투표 예로 돌아가자면, 이런 식으로 진행됩니다. 셔츠 디자인에 대한 페이지를 렌더링할 때, 문서의 현재 버전을 기록합니다. 이것은 페이지에 대해 수행한 get 요청에 대한 응답과 함께 반환됩니다.

curl -XGET 'http://localhost:9200/designs/shirt/1'
{
"_index": "designs",
"_type": "shirt",
"_id": "1",
"_version": 4,
"exists": true,
"_source": {
"name": "elasticsearch",
"votes": 1002
}
}

사용자가 투표를 한 후, Elasticsearch는 그동안 아무것도 변경되지 않은 경우에만 새 값(1003)을 색인하도록 지시할 수 있습니다. (추가적인 version 쿼리 스트링 매개 변수를 눈여겨 보세요)

curl -XPOST 'http://localhost:9200/designs/shirt/1?version=4' -d'
{
"name": "elasticsearch",
"votes": 1003
}'

내부적으로, Elasticsearch는 두 버전 번호를 비교하기만 하면 됩니다. 이것은 잠금을 획득하고 해제하는 것보다 훨씬 가벼운 작업입니다. 아무도 문서를 변경하지 않은 경우, 상태 코드 200 OK로 작업이 성공합니다. 그러나 다른 사용자가 문서를 변경한(따라서 내부 버전 번호가 증가한) 경우, 409 Conflict 상태 코드로 작업이 실패합니다. 이제 웹사이트에서 올바르게 응답할 수 있습니다. 새 문서를 검색하고, 투표 수를 늘린 후, 새 버전 값을 사용하여 다시 시도하게 됩니다. 이것은 성공할 가능성이 있습니다. 그렇지 않으면 절차를 반복합니다.

이 패턴은 대단히 일반적이기 때문에 Elasticsearch의 업데이트 엔드포인트에서 이를 수행할 수 있습니다. retry_on_conflict 매개 변수를 설정하여 버전이 충돌하는 경우 작업을 다시 시도하도록 지시할 수 있습니다. 스크립트로 작성된 업데이트와 함께 사용하면 특히 유용합니다. 예를 들어, 이 cURL은 다음과 같이 실패하기 전에 문서 업데이트를 최대 5번 시도하도록 Elasticsearch에게 지시합니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1/_update?retry_on_conflict=5' -d'
{
"script" : "ctx._source.votes += 1"
}'

버전 작성 확인은 완전히 선택 사항입니다. 특정 필드(예: votes)를 업데이트하는 동안 강제 적용하도록 선택하고 다른 필드(일반적으로 name과 같은 텍스트 필드)를 업데이트할 때는 무시할 수 있습니다. 이 모든 것은 애플리케이션의 요건과 장단점에 따라 달라집니다.

버전 작성 시스템이 이미 설치되어 있나요?

내부 지원 외에도, Elasticsearch는 다른 시스템에서 유지 관리하는 문서 버전과 잘 작동합니다. 예를 들어, 버전 작성을 유지 관리하는 다른 데이터베이스에 데이터를 저장하거나 버전 작성 방법을 지정하는 애플리케이션 관련 로직을 사용할 수 있습니다. 이 경우에도 Elasticsearch의 버전 작성 지원을 사용하여 external 버전 유형을 사용하도록 지시할 수 있습니다. 문서의 모든 변경사항에 따라 증가하는 것이 보장되는 한 Elasticsearch는 모든 숫자 버전 작성 시스템(1:263-1 범위)에서 작동합니다.

Elasticsearch에서 외부 버전 작성을 사용하도록 하려면, 데이터를 변경하는 모든 요청에서 version 매개 변수와 함께 version 매개 변수를 추가합니다. 예를 들면, 다음과 같습니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1?version=526&version_type=external' -d'
{
"name": "elasticsearch",
"votes": 1003
}'

버전 작성을 다른 곳에서 유지 관리한다는 것은 Elasticsearch가 버전의 모든 변경 사항을 반드시 알고 있는 것은 아니라는 것을 의미합니다. 이는 버전 작성 구현 방식에 미묘한 영향을 미칩니다.

위의 색인 명령을 고려해 보세요. internal 버전 작성의 경우, "현재 버전이 526인 경우에만 이 문서 업데이트를 색인한다”는 의미입니다. 버전이 일치하는 경우, Elasticsearch는 버전을 하나씩 늘리고 문서를 저장합니다. 그러나 외부 버전 작성 시스템에서는 이 요구 사항을 강제 적용할 수 없습니다. 아마도 그 버전 작성 시스템은 매번 하나씩 증가하지는 않을 것입니다. 아마도 임의의 숫자로 건너뛸 수 있습니다(시간 기반 버전 작성을 생각해 보세요). 또는 모든 버전 변경 사항을 Elasticsearch에 일일이 전달하는 것이 어려울 수도 있습니다. 이러한 모든 이유로 인해, external 버전 작성 지원 기능은 약간 다르게 작동합니다.

version_type을 external로 설정하면, Elasticsearch는 지정된 대로 버전 번호를 저장하며, 증분하지 않게 됩니다. 또한 Elasticsearch는 정확한 일치 여부를 확인하는 대신 현재 저장된 버전이 색인 명령의 버전보다 크거나 같은 경우에만 버전 충돌 오류를 반환합니다. 이는 사실상 "그 동안 아무도 동일한 버전이나 보다 최신 버전을 제공하지 않은 경우에만 이 정보를 저장"하는 것을 의미합니다. 구체적으로, 저장된 버전 번호가 526보다 작으면 위 요청이 성공합니다. 526 이상이면 요청이 실패합니다.

중요: external 버전 작성을 사용할 때는 항상 현재 version(및 version_type)을 인덱스에 추가하거나 호출을 업데이트 또는 삭제해야 합니다. 잊어버린 경우, Elasticsearch는 내부 시스템을 사용하여 요청을 처리하므로 버전을 잘못 증분하게 됩니다.

마지막으로 삭제에 대해 몇 말씀 드리자면...

데이터를 삭제하는 것은 버전 작성 시스템에서 문제가 있습니다. 데이터가 사라지면, 시스템은 새 요청이 날짜가 지정되었는지 또는 실제로 새 정보를 포함하고 있는지 여부를 올바르게 알 수 없습니다. 예를 들어, 다음을 실행하여 레코드를 삭제한다고 가정해 보겠습니다.

curl -XDELETE 'http://localhost:9200/designs/shirt/1?version=1000&version_type=external'

해당 삭제 작업은 문서의 버전 1000이었습니다. 우리가 이에 대해 알고 있는 모든 것을 삭제해 버린다면, 동기화되지 않은 다음 요청은 잘못된 작업을 하게 됩니다.

curl -XPOST 'http://localhost:9200/designs/shirt/1/?version=999&version_type=external' -d'
{
"name": "elasticsearch",
"votes": 3001
}'

문서가 존재했다는 것을 우리가 잊는다면, 우리는 이 호출을 수락하고 새 문서를 만들 것입니다. 그러나 작업 버전(999)은 실제로 이것이 오래된 정보이며 문서가 삭제된 상태로 유지되어야 함을 알려줍니다.

모든 것을 삭제하는 것이 아니라 삭제 작업, 참조한 ID 및 버전을 계속 기억해 두시는 것이 좋습니다. 이렇게 하면 실제로 이 문제를 해결해 주지만 대가가 따릅니다. 사람들이 문서를 색인하고 삭제하는 작업을 반복하면 곧 리소스가 고갈될 것입니다.

Elasticsearch 검색은 이 둘 사이의 균형을 잡아줍니다. 삭제 기록을 유지하지만, 1분 후에는 삭제 기록을 잊어버립니다. 이를 삭제 가비지 컬렉션(Garbage Collection, GC)이라고 합니다. 대부분의 실용적인 사용 사례의 경우, 시스템이 따라잡고 지연된 요청이 도착하는 데 60초면 충분합니다. 이 방법으로 해결할 수 없는 경우, 인덱스의 index.gc_deletes를 다른 시간 범위로 설정하여 이를 변경할 수 있습니다.