인덱싱과 쿼리 최적화

niee
Written by niee on

개념실험

  • 두꺼운 요리책이 있다. 이 책은 5000장으로 이루어져 있으며 요리법에는 특별한 순서가 없다. 3,475페이지에는 호주의 오리 찜 요리가 있으며, 2페이지에는 자카테칸 타코가 있다. 인덱스가 없이 요리책에서 로즈메리 감자 요리법을 찾을 수 있을까? 유일한 방법은 그 요리법이 나올 때까지 처음부터 훑어 나가는 것이다.

  • 이에 대한 해결책이 인덱스를 생성하는 것이다.. 요리법을 찾기 위해서는 여러 가지 방법을 생각해 낼 수 있지만 가장 좋은 방법은 요리법의 이름으로 찾는 것이다.
    1. 티벳 야크 수페:45
    2. 구운 소금 덤플링 : 4,011
    3. 터키 알 라 킹 : 943
  • 만약 다른 기준으로 요리법을 찾기 위해서는 또 다른 인덱스가 필요하다. 만약 재료에 대한 인덱스를 만들게 된다면 캣슈: 3, 20, 42, 88… 콜리플라워: 2, 47, 89… 닭: 7, 9, 1111, 2222…

  • 이 인덱스는 유용할까? 요리법에 관한 어떤 다른 정보를 찾고 있다면 여전히 이 인덱스는 개선의 여지가 있다.

  • 예) 몇 개월 전 요리책에서 닭 요리법을 우연히 보았는데 이 요리법을 잊었다. 현재, 요리법에서 이름과, 재료에 대한 인덱스 2개가 있을 경우 닭 요리를 발견할 수 있는 방법은?

  • 사실 이것은 불가능하다. 재료에 대한 인덱스로 시작한다면 확인해볼 수 있는 페이지 번호의 리스트는 있지만 이 페이지 번호를 요리법 이름에 대한 인덱스와 연결할 수 있는 방법이 전혀 없다. 따라서 이 경우에 어느 하나의 인덱스만 사용할 수밖에 없고, 두 인덱스 중에서 재료에 대한 인덱스가 더 유용하다.

    • 쿼리당 하나의 인덱스 : 두 필드에 대해 검색할 때 2개의 인덱스를 별도로 만들 경우 알고리즘은 이렇다. 각각의 인덱스에서 검색어와 일치하는 페이지를 찾고 이 페이지 번호를 합친 리스트를 스캔하면서 두 검색어와 일치하는 요리법을 찾는다. 하지만 MongoDB는 그렇지 않다. MOngoDB는 복합 인덱스를 사용한다.
  • 복합인덱스 : 하나 이상의 키를 사용하는 인덱스(책 179p참조)

  • 복합인덱스는 순서가 중요하다. 재료-이름 인덱스를 생성했다고 가정하면, 재료부터 찾고 해당 재료인덱스에 있는 이름을 찾기 때문이다. 이 복합 인덱스가 존재하면 재료에 대한 단일 키 인덱스는 삭제해도 문제가 없다. 재료로 검색 할 때 재료-이름의 복합 인덱스를 사용하면 되기 때문이다.

  • 이 장의 목적은 비유를 통해 인덱스를 개념적으로 설명하자는 것이다. 이 비유로부터 다음과 같은 일반적인 규칙 몇 가지를 도출 할 수 있다.
  1. 인덱스는 도큐먼트를 가져오기 위해 필요한 작업량을 많이 줄인다. 적당한 인덱스가 없으면 질의 조건을 만족할 때까지 모든 도큐먼트를 차례로 스캔해야만 하고, 이것은 종종 컬렉션 전체를 스캔하는 것을 의미한다.
  2. 한 쿼리를 실행하기 위해서 하나의 단일 키 인덱스만 사용할 수 있다. 복합 키를 사용하는 쿼리에 대해서는 복합 인덱스가 적합하다.
  3. 재료-지역 요리에 대한 인덱스를 가지고 있다면 재료에 대한 인덱스는 없앨 수 있고 또 없애야만 한다. 좀 더 일반적으로 표현하자면, a-b에 대한 복합 인덱스를 가지고 있다면 a에 대한 인덱스는 중복이다.
  4. 복합 인덱스에서 키의 순서는 매우 중요하다.

인덱싱 핵심 개념

단일 키 인덱스

  • 단일 키 인덱스에서 인덱스 내의 각 엔트리는 인덱스되는 도큐먼트 내의 한 값과 일치한다. _id에 대해 디폴트로 생성되는 인덱스가 좋은 예인데, 각 도큐먼트의 _id는 빠른 검색을 위해 인덱스에 저장된다.

복합키 인덱스

  • MongoDB는 쿼리당 하나의 인덱스만 사용한다. products 컬렉션에 대해 2개의 인덱스를 생성한다고 가정한다. 한 인덱스는 제조사에 대한 것이고 다른 하나는 가격에 대한 인덱스이다. 이것은 완전히 다른 2개의 데이터 구조를 생성했다는 것을 의미하고 아래와 같이 저렬이 되어 있다.
AceOx12
AcmeOxFF
AcmeOxA1
AcmeOx0B
AcmeOx1C
BizOxEE
7999OxFF
7500Ox12
7500OxEE
7500OxA1
7499Ox0B
7499Ox1C
db.products.find({'details.manufacturer':'Acme', 'pricing.sale' : {$lt:7500}})
  • 위의 쿼리는 제조사가 Acme고 가격이 700보다 싼 것을 의미하는데 단일 키 인덱스에 대해 실행하면 2개의 인덱스 가운데 하나만 사용된다. 쿼리 옵티마이저는 두 인덱스 중에서 가장 효율적인 인덱스를 선택하지만 어떤 것도 좋은 결과를 가져다 주진 않는다. 이 인덱스를 사용하는 쿼리를 만족시키기 위해서는 각 인덱스를 따로 탐색해서 일치하는 디스크 위치의 리스트를 합쳐서 유니온을 계산해야 하지만 MongoDB는 이런 방식을 지원하지 않는다. 여러 가지 이유 중 하나는 복합 인덱스를 사용하는 것이 좀 더 효율적이기 때문이다.

  • 복합키를 생성하면 다음과 같은 순서를 같게된다.

Ace - 8000OxFF
Acme - 7999Ox12
Acme - 7500OxEE
Acme - 7499OxA1
Acme - 7499Ox0B
Biz - 8999Ox1C

쿼리를 수행하기 위해 쿼리 옵티마이저는 인덱스 내에서 제조사가 ‘Acme’이고 가격이 75불인 첫 번째 엔트리를 찾아야 한다. 그 엔트리부터 시작해서 제조사 값이 Acme가 아닌 엔트리를 발견할 때까지 인덱스 내의 엔트리를 스캔 함으로써 결과를 얻을 수 있다.

인덱스 효율

  • 쿼리 성능을 위해서는 인덱스가 반드시 필요하지만 각 인덱스는 유지 비용이 들어간다. 왜 그런지는 쉽게 이해할 수 있다. 어떤 컬렉션에 도큐먼트를 추가할 때마다 그 컬렉션에 대해 생성된 인덱스도 그 새로운 도큐먼트를 포함시키도록 수정해야 한다. 따라서 어떤 컬렉션이 10개의 인덱스를 가지고 있다면, 삽입 연산을 한 번 수행할 때마다 10개의 인덱스를 수정해야 한다.

  • 읽기 위주의 애플리케이션에서 인덱스 비용은 인덱스로 인해 얻을 수 있는 효과로 상쇄된다.

  • 인덱스가 적합하게 만들어졌다고 해도 쿼리를 더 빠르게 처리하지 못할 가능성이 여전히 존재한다는 점이다. 이것은 인덱스와 현재 작업 중인 데이터를 램에서 다 처리하지 못할 때 발생한다. MongoDB는 운영체제에게 mmap() 시스템 호출을 이용해 모든 데이터 파일을 메모리에 매핑하는ㄷ, 이 시점부터는 모든 도큐먼트, 컬렉션, 인덱스를 포함하는 데이터 파일이 페이지라고(page)라고 부르는 4KB의 청크로 운영체제에 의해 램으로 스왑된다. 해당 페이지에 대한 데이터가 요청 될 때마다 OS는 그 페이지가 램에 있는지 확인해야 한다. 만약 램에 없으면 페이지 폴트(page fault) 예외를 발생 시키고 메모리 관리자는 해당 페이지를 디스크로부터 램으로 불러들인다. 램이 충분하면 모든 데이터 파일이 램으로 로드가 된다. 모든 데이터를 램이 수용하지 못하는 경우 점점 페이지 폴트가 발생하고 모든 읽기와 쓰기에 대해서 디스크 액세스를 해야 하는 상환이 발생할 수도 있다. 이러한 현상을 스래싱(thrashing)이라고 하는데, 성능을 심각하게 저해한다.

  • 이상적으로는 인덱스와 현재 작업 중인 데이터가 모두 램에 존재해야 한다. 하지만 램의 필요한 크기를 추정하는 것이 항상 쉬운 것만은 아니다. 작업 데이터는 애플리케이션마다 다르기 때문에 어느 정도의 크기를 갖는지 명확히 알기가 어렵기 때문이다. 하드웨어와 관련된 성능 문제를 진단하기 위한 몇가지 구체적인 방법은 10장에 나온다.

B트리

  • MongoDB는 내부적으로 B트리(B-tree)로 인덱스를 생성한다. B트리는 데이터 베이스 인덱스에 이상적인 두 가지의 전반적인 특성을 가지고 있다. 첫 번째로 정확한 일치, 범위 조건, 정렬, 프리픽스 일치, 인덱스만의 쿼리 등 다양한 쿼리를 용이하게 처리하도록 해준다는 점이다. 두 번째로는 키가 추가되거나 삭제되더라도 밸런스된 상태를 계속 유지한다는 점이다.

B트리 위키

인덱싱의 실제

인덱스 타입

  • 고유 인덱스 고유 인덱스를 생성하기 위해서는 unique옵션을 지정한다
db.users.ensureIndex({username:1},{unique:true})

고유 인덱스는 컬렉션에 데이터가 존재하지 않을 때 생성하는 것이 좋다. 데이터가 있는 경우 고유 인덱스를 만들면, 컬렉션 내에서 중복 키가 존재할 가능성이 있기 때문이다. 하지만 데이터가 그다지 중요하지 않다면 데이터 베이스로 하여금 dropDups옵션을 이용해 중복 키를 가지고 있는 도큐먼트를 자동으로 삭제하도록 명령을 내릴 수 있다.

db.users.ensureIndex({username:1},{unique:true, dropDups:true})
  • 희소 인덱스 인덱스는 밀집(dense)하도록 기본 설정되어 있다. 밀집 인덱스란 컬렉션 내의 한 도큐먼트가 인덱스 키를 가지고 있지 않더라도 인덱스에는 해당 엔트리가 존재한다는 것을 의미한다. 하지만 밀집 인덱스가 바람직하지 않은 경우가 있다. 첫 번째는 모든 도큐먼트의 sku필드에 대해 고유 인덱스를 만든다고 가정하자. 하지만 sku필드에 대해 입력없이 등록할 수 있는 시스템이 있다. sku입력없이 여러개의 도큐먼트를 삽입하려고 할 때 sku가 null인 인덱스가 이미 존재하기 때문에 이 후 에러가 발생 할 수 있다. 이런 경우에 희소 인덱스(sparse index)가 필요하다.
db.users.ensureIndex({sku:1},{unique:true, sparse:true})
  • 다중 키 인덱스 필드의 값이 배열인 경우 인덱스하는 것이 다중 키 인덱스인데, 인덱스 내의 여러 개의 엔트리가 동일한 도큐먼트를 지시하게 된다. 아래와 같은 형태의 도큐먼트들이 있다고 가정하자.
{name:"Wheelbarrow",
 tags: ["tools", "gardening", "soil"]
}
  • tags에 대해 인덱스를 생성하면 이 도큐먼트의 태그 배열에 있는 각 값들은 인덱스에 나타한다. 이것은 이 배열의 값 중 어느 것으로도 도큐먼트를 찾을 수 있음을 의미한다.

인덱스 관리

  • 인덱스 생성과 삭제 일반적으로 인덱스를 생성하는데 헬퍼 메소드를 사용하는 것이 쉽지만 인덱스 규격을 직접 삽입(인덱스 헬퍼 메소드가 하는일)할 수도 있는데 ns, key, name이렇게 최소한의 키를 지정하기만 하면 된다.
spec = {ns:"green.users", key:{'addresses.zip':1}, name:'zip'}
db.system.indexes.insert(spec, true)

인덱스를 삭제하기 위해서 system.indexes에서 인덱스 도큐먼트를 삭제하면 될 것이라고 생각할지도 모르지만 이런 연산은 금지되어 있으며 deleteIndexes를 수행해서 인덱스를 삭제하면 된다.

  • **책에서는 인덱스 조회시 db.system.indexes를 사용하는데 현재 이 방법을 사용할 경우 오류는 나지 않지만 결과가 없다 검색을 해 보니 db.users.getIndexes() 함수가 존재했다.. 위의 방식으로 인덱스가 생성은 되나 검색결과 createIndex와 같은 함수로 인덱스를 생성할 수 있는 것으로 보아 버전업이 되면서 책과 차이가 좀 있는 것으로 보인다. **

  • 인덱스 선언 시 주의사항 인덱스를 선언하는 것이 너무 쉽기 때문에 의도치 않게 인덱스를 구축하는 것도 역시 아주 쉽다. 데이터가 대량일 경우 인덱스 구축은 오랜 시간이 걸린다. 실제 서비스 상황에서는 이것은 악몽과도 같다. 왜냐하면 인덱스 구축을 중지시키기가 쉽지 않기 때문이다. 만일 이런 일이 발생한다면, 세컨더리 노드가 있는 경우 이 노드로 서비스를 해야 할 것이다. 하지만 인덱스 구축을 일종의 데이터베이스 마이그레이션으로 취급하고 애플리케이션에서 인덱스 선언이 자동으로 되지 않도록 하는 것이 현명하다.

  • 백그라운드 인덱싱 데이터베이스가 실제 서비스되고 있고 데이터베이스에 대한 액세스를 중지시킬 수 없을 경우 사용하는 방법. 어플리케이션의 트래픽이 최소화되는 시간 내에 인덱스를 구축할 수 있다면 좋은 방법이 될 수 있다.

  • 오프라인 인덱싱 실제 서비스되괴 있는 데이터가 인덱스를 생성하는데 몇 시간으로는 부족할 정도로 큰 규모라면 다른 방법이 필요하다. 일반적으로 한 복제 노드를 오프라인 상태로 바꾸고, 그 노드에 대해 인덱스를 구축한 다음에 마스터 노드로부터 업데이트를 받는다. 업데이트를 완료하고 나면 이 노드를 프라이머리 노드로 변경하고, 다른 센컨더리 노드들은 오프라인 상태로 바꾼 후에 각자 인덱스를 구축한다. 이것은 오프라인 노드에서 인덱스를 구축하는 동안 데이터가 손상되는 것을 막을 정도로 복제 oplog가 충분히 크다는 가정을 전제로 한다. 이에 대해서는 다음장에서 자세히 다룬다.

  • 백업 인덱스는 구축하기 어렵기 때문에 백업을 해놓아야 한다. 백업이 인덱스를 포함하길 원한다면 MongoDB의 데이터 파일 자체를 백업해야 한다. 자세한 내용과 일반적인 백업 명령은 10장에서 다룬다.

  • 압축 어플리케이션에서 기존 데이터의 업데이트나 대량의 데이터 삭제가 자주 발생한다면 인덱스가 심각하게 단편화된다. B트리는 어느 정도 스스로 재구성하지만 대량의 삭제를 상쇄시킬 만큼 충분치는 않다. 단편화된 인덱스의 일차적인 징후는 주어진 데이터의 크기보다 인덱스의 크기가 훨씬 더 큰 것이고, 이로인해 인덱스가 램을 필요이상으로 많이 사용할 수 있다. 이런 경우에 하나 혹은 그 이상의 인덱스를 재구축하는 것을 고려해야 한다. 이 때 reIndex()명령을 수행하여 개별 인덱스를 삭제하고 재생성함으로써 가능하다.

쿼리 최적화

쿼리 최적화는 느린 쿼리를 찾아서 원인을 발견하고, 속도를 개선하기 위한 조치를 취하는 과정이다. 애플리케이션의 잘못된 설계, 적합하지 않은 데이터 모델, 부족한 하드웨어는 모두 공통적인 원인이고, 이런 문제점을 해결하기 위해서는 많은 시간이 요구된다. 이 장에서는 쿼리를 재구성하고 좀 더 유용한 인덱스를 구축함으로써 쿼리를 최적화하는 방안을 살펴본다.

느린 쿼리 탐지

요구사항이 애플리케이션마다 다르지만, 대부분 애플리케이션에서 쿼리가 100밀리초 이내에 실행되어야 한다고 가정하면 안전하다. MongoDB 로거는 이 가정에 근거, 질의를 포함해서 어떠한 연산이라도 100밀리초를 넘어서면 경고 메시지를 프린트한다.

http://mng.bz/ii49 위의 더미 데이터는 400만건 이상의 데이터가 있다..

db.values.find({"stock_symbol":"GOOG"}).sort({data:-1}).limit(1)

이 쿼리를 수행한 결과 2.332sec가 나왔다. 상당히 느린 것이다.

느린 쿼리를 확인하기 위해서 MongoDB의 로그를 사용할 수 있지만, 이러한 절차가 정교하지 못하기 때문에 개발이나 서비스 환경에서 시도해볼 수 있는 일종의 점검 장치로 생각해야한다. 문제가 발생하기 전에 느린 쿼리를 찾기 위해서는 정확한 툴이 필요한데, MongoDB에 내장된 쿼리 프로파일러가 바로 그것이다.

  • 프로파일러 사용 프로파일링은 디폴트로 사용 불가능 상태이므로 사용 가능 상태로 바꾸자
use stocks
db.setProfilingLevel(2)

먼저 프로파일하려는 데이터베이스를 선택해야 한다. 프로파일링은 항상 특정 데이터베이스에 국한된다. 프로파일링 수준을 2는 출력이 가장 많이 되는 수준인데, 프로파일러가 모든 읽기와 쓰기를 로그에 기록한다.

프로파일링 결과는 system.profile이라고 부르는 특수한 캡드(capped)컬렉션에 저장된다. 캡드 컬렉션은 크기가 고정되어 있고 데이터는 회전되는 방식으로 쓰여지기 때문에 컬렉션이 허용된 크기에 도달하면 새로운 도큐먼트는 가장 오래된 도큐먼트를 덮어쓰게 된다. 이 컬렉션은 128KB가 할당되는데, 이로인해 프로파일러가 리소스를 많이 사용하지 못한다.

150밀리초 이상이 소요된 모든 쿼리를 찾고 싶으면 다음과 같이 한다.

db.system.profile.find({millis:{$gt:150}})

느린 쿼리 분석

  • EXPLAIN()의 사용과 이해 explain명령은 주어진 쿼리의 경로에 대한 자세한 정보를 제공한다. 바로 들어가서, 앞서 수행했던 쿼리에 대해 explain을 수행하면 얻을 수 있는 결과를 보자
db.values.find({"stock_symbol":"GOOG"}).sort({data:-1}).limit(1).explain()
{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "stocks.values",
        "indexFilterSet" : false,
        "parsedQuery" : {
            "stock_symbol" : {
                "$eq" : "GOOG"
            }
        },
        "winningPlan" : {
            "stage" : "SORT",
            "sortPattern" : {
                "data" : -1.0
            },
            "limitAmount" : 1,
            "inputStage" : {
                "stage" : "SORT_KEY_GENERATOR",
                "inputStage" : {
                    "stage" : "COLLSCAN",
                    "filter" : {
                        "stock_symbol" : {
                            "$eq" : "GOOG"
                        }
                    },
                    "direction" : "forward"
                }
            }
        },
        "rejectedPlans" : []
    },
    "serverInfo" : {
        "host" : "LDI",
        "port" : 27017,
        "version" : "3.2.11",
        "gitVersion" : "009580ad490190ba33d1c6253ebd8d91808923e4"
    },
    "ok" : 1.0
}

인덱스 생성후 결과

db.values.createIndex({stock_symbol: 1})
{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "stocks.values",
        "indexFilterSet" : false,
        "parsedQuery" : {
            "stock_symbol" : {
                "$eq" : "GOOG"
            }
        },
        "winningPlan" : {
            "stage" : "SORT",
            "sortPattern" : {
                "data" : -1.0
            },
            "limitAmount" : 1,
            "inputStage" : {
                "stage" : "SORT_KEY_GENERATOR",
                "inputStage" : {
                    "stage" : "FETCH",
                    "inputStage" : {
                        "stage" : "IXSCAN",
                        "keyPattern" : {
                            "stock_symbol" : 1.0
                        },
                        "indexName" : "stock_symbol_1",
                        "isMultiKey" : false,
                        "isUnique" : false,
                        "isSparse" : false,
                        "isPartial" : false,
                        "indexVersion" : 1,
                        "direction" : "forward",
                        "indexBounds" : {
                            "stock_symbol" : [ 
                                "[\"GOOG\", \"GOOG\"]"
                            ]
                        }
                    }
                }
            }
        },
        "rejectedPlans" : []
    },
    "serverInfo" : {
        "host" : "LDI",
        "port" : 27017,
        "version" : "3.2.11",
        "gitVersion" : "009580ad490190ba33d1c6253ebd8d91808923e4"
    },
    "ok" : 1.0
}
  • MongoDB의 쿼리 옵티마이저와 HINT() 쿼리 옵티마이저는 해당 쿼리를 가장 효율적으로 실행하기 위해 어떤 인덱스를 사용할 지 결정하는 소프트웨어다. 쿼리에 가장 이상적인 인덱스를 선택하기 위해 쿼리 옵티마이저는 다음과 같이 아주 간단한 규칙을 사용한다.
  1. scanAndOrder를 피한다. 즉, 쿼리가 정렬을 포함하고 있으면 인덱스를 사용한 정렬을 시도한다.
  2. 유용한 인덱스 제한 조건으로 모든 필드를 만족시킨다. 즉, 쿼리 실렉터에 지정된 필드에 대한 인덱스를 사용하도록 노력한다.
  3. 쿼리가 범위를 내포하거나 정렬을 포함하면 마지막 키에 대해 범위나 정렬에 도움이 되는 인덱스를 선택하라

hint는 쿼리 옵티마이저로 하여금 강제로 특정 인덱스를 사용하도록 만드는 것이다. 특정 인덱스를 선택하지 않은 경우가 명확하지 않을 경우 사용하면 된다.

db.values.find({"stock_symbol":"GOOG"}).sort({data:-1}).limit(1).hint({stock_symbol:1}).explain()

쿼리 최적화는 항상 애플리케이션에 따라 다르게 수행되어야 하지만, 여기서 제시한 아이디어와 기법들은 쿼리를 튜닝하는데 도움이 될 것이다. 쿼리를 프로파일하고 explain을 실행하는 것을 습관화해야한다.

Comments

comments powered by Disqus