Bon Voyage

'MongoDB in Action'으로 정리해보는 MongoDB의 인덱스 개념 본문

개념정리/데이터베이스

'MongoDB in Action'으로 정리해보는 MongoDB의 인덱스 개념

nangkyeong 2019. 9. 29. 04:14

 

MongoDB in Action https://book.naver.com/bookdb/book_detail.nhn?bid=6876243

 

MongoDB in Action 몽고디비 인 액션

MONGODB나 NOSQL에 경험 없는 개발자를 위한 쉽고 실전적인 입문서『MONGODB IN ACTION 몽고디비 인 액션』. 이 책은 MONGODB와 도큐먼트 지향 데이터베이스 모델을 소개한다. 적당한 속도로 진행되는 이 책은 개발자로서 필요한 큰 그림과 시스템 엔지니어를 만족시키기에 충분한 하위 수준의 상세한 내용을 동시에 제공한다. 수많은 예제들은 데이터 모델링의 중요한 분야에서 확신을 갖는 데 도움을 제공할 것이다. 또한 복제, 자동 샤딩, 배포

book.naver.com

8장 인덱스 파트를 요약 정리한 내용입니다.

 


 

인덱스 이론

1. 인덱싱 규칙

  1. 도큐먼트를 가져오기 위해 필요한 작업량을 많이 줄인다 → 없으면 컬렉션 전체 스캔해야 함
  2. 한 쿼리를 실행할 때 하나의 단일 키 인덱스만 사용 가능 → 여러개는 복합 인덱스로
  3. a-b의 복합 인덱스에서는 a에 대한 단일 인덱스의 존재는 중복이나 b에 대한 인덱스는 중복이 아님
  4. 복합 인덱스에서는 키의 순서가 중요함

2. 단순 인덱스Simple Index

하나의 필드 키를 사용하는 인덱스
하나의 쿼리에서 단일 키 인덱스를 두 개 사용하면, 쿼리 옵티마이저가 그 중 제일 효율적인 인덱스를 선택함
→ 그러나 그 결과가 항상 최선인 것은 아니다.

3. 복합 인덱스 Compound Index

하나 이상의 필드를 키로 사용하는 인덱스
복합 인덱스에서는 순서가 중요함:
— 첫 번째로 오는 인덱싱 조건은 '일치Equality' 이어야 하고
— 범위를 지정한 키는 그 다음에 와야 한다

3-1. 참고: 인덱스 교차 Index Intersection

— 각각의 필드에 대한 인덱스에서 검색어와 일치하는 페이지 번호를 찾은 후, 페이지 번호의 교차 지점 리스트를 스캔하며 두 검색어와 일치하는 도큐먼트를 찾는다
— MongoDB에서 교차점 계산하는 방식은 지원하지 않음

4. 인덱스 효율

  • 각 인덱스는 유지 비용이 든다:
    도큐먼트 CUD할 때마다 생성된 인덱스도 새로운 도큐먼트를 포함하도록 수정해야 함
    → 읽기 위주의 App에서 인덱스 비용은 상쇄할 수 있지만, 불필요한 인덱스는 없어야 함
  • 인덱스 & 작업 중인 데이터를 RAM에서 다 처리하지 못하는 경우,
    모든 인덱스가 적합하더라도 쿼리 처리 성능이 저하될 수 있음
    1. WiredTiger 스토리지 엔진에서는 모든 도큐먼트, 컬렉션, 인덱스 포함 데이터 파일이
      페이지page라는 4KB의 청크로 OS에 의해 RAM으로 스왑된다
    2. 해당 페이지의 데이터가 요청될 때마다 OS는 그 페이지가 RAM에 있는지 확인한다
      — CUD 연산은 RAM에서 발생하고 수정사항은 OS가 비동지적으로 Disk에 반영한다
    3. 없다면 페이지 폴트 Page Fault 예외를 발생시키고, 메모리 관리자가 Disk로부터 페이지를 RAM으로 불러들인다
    4. RAM이 충분하면 필요한 모든 데이터파일이 메모리에 로드된다
    5. 데이터를 다 수용하지 못하면 점점 페이지 폴트가 많이 발생하게 되고
      OS가 Disk를 액세스하는 횟수가 많아지면서 CRUD연산이 매우 느려지게 된다
      최악의 경우 Thrashing이 발생한다:
      데이터 크기가 가용한 RAM 크기보다 훨씬 커서 모든 CRUD에 대해 Disk Access가 필요해지는 상황이 발생할 수도 있음
  • 위와 같은 이유로, 정말 필요한 인덱스만 유지해야 할 필요성이 생긴다
  • 자주 발생하는 쿼리에 대한 커버링 인덱스 Covering Index를 생성하여 유용하게 사용 가능
  • 인덱스는 인덱스에 포함된 데이터와 함께 RAM에 별도로 저장되며 Clustered되지 않는다
    MongoDB에서는 클러스터 인덱스를 사용하지 않는다
    (인덱스 순서대로 데이터 순서를 정렬하지 않는다)\ 인덱스 키의 순서는 데이터의 실제 순서와 관계가 없다:
    모든 도큐먼트가 데이터 세트의 어느 위치에나 있을 수 있다, 지역성Locality을 보장하지 않는다\

5. B-트리 (Balanced Tree)

70년대 후반부터 DB의 레코드, 인덱스로 자주 이용되고 있는 B-Tree를 MongoDB도 인덱스 생성에 사용함

특징

  • 다양한 쿼리를 용이하게 함:
    일치 조건, 범위 조건, 정렬, Prefix 일치, Index만의 쿼리 등
  • 키가 추가되거나 삭제되더라도 Balanced 상태를 계속 유지한다
  • 각 키는 인덱스된 필드값을 나타내는 BSON 객체 형태 (Binary JSON)
  • 각 노드에 포함된 키는 두개의 포인터를 포함하고 있음:
    1) 자신이 속한 데이터 파일의 포인터
    2) 자식 노드에 대한 포인터
  • 노드 자체는 노드의 최솟값보다 작은 값을 갖는 노드를 가리키고 있다

인덱스 크기

  • MongoDB에서 구현된 B-Tree는 새 노드에 8KB(8192Byte)를 할당함

  • (노드 할당크기 - 노드 당 오버헤드) / (키의 크기 - 키 당 오버헤드) = 노드 당 가질 수 있는 키의 개수

    e.g. 키의 평균적인 크기가 30Byte 안팎이라면, (8192 - 40) / (30 - 18) = 169.8
    (노드 당 대략 170개의 키를 가질 수 있음)

  • B-Tree 노드는 일반적으로 60%만을 사용하도록 의도적으로 기본 설정되어 있다

 

 

MongoDB에 실제 구현된 인덱스

1. 인덱스 타입

1. 고유 인덱스 Unique Index

db.collection.createIndex({username: 1}, {unique: true})
  • 해당 도큐먼트에서 인덱스 필드 값의 고유한지 확인함
    실제로 _id 필드가 고유 기본 키라는 것을 보장하기 위해 MongoDB가 사용하고 있음
  • 인덱스의 모든 엔트리가 고유해야 함
    이미 존재하는 키 값을 삽입하려고 하면 예외 발생, 삽입이 실패함
  • 컬렉션에 데이터가 존재하지 않을 때 생성하는 것이 좋음: 처음부터 고유 제약 조건을 보장함
  • 기존에 있는 컬렉션에 생성해야 할 때는
    1) 반복적으로 수행하면서 중복되는 키 삭제
    2) dropDups: true 옵션으로 중복 키 자동 임의 삭제
    3) 새 컬렉션을 만들고, 고유 인덱스를 만든 다음, 도큐먼트를 복사하면서 중복 데이터 삭제

2. 희소 인덱스 Sparse Index

db.collection.createIndex({user_id: 1}, {sparse: true, unique: true})
  • 인덱스는 기본 설정이 밀집 dense
    → 컬랙션 내 한 도큐먼트가 인덱스 키 필드 값을 가지고 있지 않으면 null 값을 갖는 엔트리로 처리함
    문제점
    모든 도큐먼트가 다 가지고 있지 않은 필드에 고유 인덱스를 생성하게 되면,
    첫 번째 삽입은 null로 가능하지만 그 다음부터는 null 자체가 중복이라고 생각해서 삽입 연산 실패함
  • 희소 인덱스는 Sparse Index
    → 옵션 sparse: true : 인덱스의 키가 null이 아닌 값을 가지고 있는 도큐먼트만 존재하게 함
  • 많은 도큐먼트가 인덱스 키를 가지고 있지 않은데 키를 가진 대상으로 질의를 하는 경우
    e.g. 익명 상품평이 가능한 사이트에서 회원의 상품평을 대상으로 질의가 필요한 경우

3. 다중키 인덱스 Multikey Index

  • 여러 개의 엔트리가 동일한 도큐먼트를 지시함
    e.g. 한 도큐먼트가 여러 개의 태그를 가지고 있을 때
  • 배열을 가지는 필드에 인덱스를 생성하면: 배열의 어느 값으로도 다 해당 도큐먼트를 찾을 수 있다
  • 몇가지 예외를 제외하면 MongoDB에서 항상 사용할 수 있는 상태
  • 인덱스 필드가 배열을 포함하면, 배열의 각 값은 인덱스 내에서 각자 자신만의 엔트리를 가진다
  • 물론 CUD는 더 많은 비용이 든다

4. 해시 인덱스 Hashed Index

db.collection.createIndex({recipe_name: 'hased'})
  • 해시 값이 도큐먼트 엔트리 순서를 결정함: 이름 순으로 서로 가까이 있지 않을 가능성이 있음
  • Indexing 값은 원본 필드 값의 해시값이 됨

제한 사항

  • 동등 쿼리 Equality Queries는 거의 동일하게 작동하지만, 범위로 쿼리를 처리할 수는 없다
  • 다중 키 해시 인덱스가 허용되지 않음
  • 부동 소수점 값은 해싱 전에 정수로 변환되어, 예를 들어 4.2와 4.4는 같은 인덱스 해시 값을 가지게 됨

그럼 왜 쓰느냐?

  • 해시 인덱스의 엔트리가 균등하게 분배되기 때문
  • 키 데이터가 균일하지 않게 분포할 경우, 해시 인덱스 값은 일관성을 유지한다
  • _id의 가장 중요한 비트는 생성 시간을 기반으로 하는데,
    _id를 사용하는 인덱스로 샤드를 결정하게 되면 동 시간대에 대량의 쓰기 load가 생기면 문제 발생 가능성
  • 샤딩과 연관된 개념이므로 일단 간단하게 말하면 해시된 인덱스 키 값은 인덱스 엔트리의 지역성을 변경한다는 뜻, Sharded Collection에서 유용하게 사용될 수 있다

5. 지리공간적 인덱스 Geospatial Index

  • 저장된 위도값, 경도값에 따라 도큐먼트를 특정 위치에 '가까이' 배치하는 것이다
  • 지구의 곡률을 포함해 지리적 거리르 효율적으로 계산할 수 있는 인덱스

 

2. 인덱스 관리

1. 생성 및 삭제

생성
createIndex() 헬퍼 메서드를 직접 구현 가능: ns, key, name을 system.indexes 컬렉션에 넣으면 됨
ns: namespace, key: index field, name: index name

use db
idx = {ns: 'db.collection', key: {'field_name': 1}, name: 'index_name'}
db.system.indexes.insert(idx, true)

//생성된 인덱스 확인
db.system.indexes.find().pretty()

삭제
system.indexes에서 인덱스 도큐먼트를 직접 삭제하는 연산은 금지되어 있음

  1. deleteIndexes로 삭제 가능함
    runCommand 사용
     db.runCommand({deleteIndexes: 'collection_name', index:'idx_name'})
  2. dropIndex()
     db.collection.dropIndex('idx_name')

인덱스 확인

db.collection.getIndexes()

 

2. 인덱스 구축

데이터가 먼저 삽입되고 난 후에 인덱스를 구축하게 되는 경우

  1. DB를 MongoDB로 Migration할 때: 전송해 온 이후에 인덱스를 생성하면 균형&압축된 인덱스 생성 가능
  2. 새로운 쿼리에 인덱스를 최적화해야 할 때: 기능 추가 혹은 변경 시

대량의 데이터가 있는 경우, 인덱스를 생성하는데 몇시간 ~ 며칠까지도 걸릴 수 있다
진행 상황은 MongoDB 서버 로그를 확인해 보거나, db.currentOp() 로도 확인 가능함

인덱스 구축 단계

  1. 인덱스할 필드 값을 정렬함 → B-Tree에 추가
    정렬의 진턱도는 도큐먼트의 총 수 대비 정렬된 도큐먼트의 개수로 나타난다
  2. 정렬된 값들을 인덱스로 삽입한다
    인덱스 구축 완료까지 소요된 시간을 system.indexes 컬렉션에 삽입한다

인덱스 구축 시 문제점
인덱스 생성 시, 해당 데이터 베이스가 쓰기 Lock이 걸려 읽기/쓰기 모두가 불가능하다

해결책

  1. 백그라운드 인덱싱
     db.collection.createIndex({'idx_field': 1}, {background: true})
    백그라운드에서 인덱스 구축하면, 다른 읽기/쓰기 요청 시 잠시 구축을 멈춘다
    트래픽이 최소화되는 시간에 인덱스를 구축한다면 좋은 방식
  2. 오프라인 인덱싱
    1) 하나의 복제 노드를 오프라인으로 바꾸고, 인덱스 구축 후, 마스터에서 그 동안의 업데이트를 받아 완료함
    2) 새로운 인덱스로 업데이트 된 노드를 프라이머리로 변경하고, 다른 복제 노드들도 오프라인 인덱스 구축
    복제 oplog가 충분히 크다는 가정을 전제로 사용하는 방법
  3. 백업
    인덱스 구축은 어려우므로 백업이 필요함, but 모든 백업이 인덱스 데이터까지 다 포함하지는 않음
    → MongoDB 데이터 파일 자체를 백업해야 함
  4. Defragmentation 비단편화
    다량의 데이터 CUD로 인덱스의 단편화가 심해졌을 때: B-Tree가 스스로 재구성할 수준을 넘게 될 때
    주로 데이터 크기보다 인덱스의 크기가 훨씬 더 클 때 → 인덱스가 RAM을 필요 이상으로 차지함
     //해당 컬렉션의 모든 인덱스를 재구축함
     db.collection.reIndex()
    • 마찬가지로 재구축 되는동안 DB는 Lock이 걸림 → 오프라인 방식이 최적의 활용법

 

 

3. 쿼리 최적화

느린 쿼리를 찾아 원인을 발견하고 속도를 개선하려는 조치를 취하는 과정
자세히는 쿼리를 재구성하고 더 유용한 인덱스를 구축하는 과정

1. 느린 쿼리 탐지

느려졌다고 느끼면 쿼리 프로파일이 필요함
1. MongoDB 서버 로그 확인하기

  • 일반적으로 쿼리가 100밀리초 내에 실행되면 안전: Mongo 로거는 100밀리초를 넘는 쿼리에 경고 메세지
    → 느려질 땐 가장 먼저 로그를 확인하면 됨
      //시간으로만 찾아볼 수 있음
      grep -E '[0-9]+ms' mongod.log 
  • 100밀리초가 임계값으로 너무 높으면 MongoDB 시작 시--slowms 50 등의 서버 옵션으로 시작
  • 절차가 정교하지는 않으므로 로그 확인 절차는 일종의 점검 장치로만 생각함

2. 프로파일러 사용

  • 디폴트로 사용 불가능 상태라서 db.setProfilingLeve(2)으로 바꿀 수 있음
    (2: 프로파일러 가 모든 읽기/쓰기 로그에 기록, 1: 100ms 이상 느린 쿼리만 기록, 0: 사용 불가능)
    db.setProfilingLevel(1,50)과 같은 식으로 임계치를 지정할 수 있음

2-2. 사용 전략

  • 적당히 높은 임계치에서 점점 수치를 낮춰가며 수행한다
    e.g. 100보다 더 오래 걸리는 쿼리가 없는지 확인한 후 75로 낮춰서 확인하는 식
  • App의 실제 서비스 상황에서도 작동하기 위해, App의 모든 쿼리를 테스트해야 함
  • 실제 서비스 환경과 동일한 조건 하에서 테스트를 수행해야 함

3. 프로파일링 결과로 확인

  • 프로파일링 결과는 해당 DB의 system.profile 컬렉션에서 확인할 수 있음
    system.profile은 캡드capped 컬렉션
    → 고정된(허용된) 크기(128KB)에 도달하면 가장 오래된 도큐먼트부터 Overwrite
      //150 밀리초 이상 소요된 쿼리를 확인한다
      db.system.profile.find({millis: {$gt: 150}})

2. 느린 쿼리 분석

이유가 무엇일까?
EXPLAIN()의 사용과 이해

  • 쿼리 뒤에 .explain()을 추가하기만 하면 됨
  • cursor field: 스캔 방법
    BasicCursor → 컬렉션 전체 스캔
    BtreeCursor → 인덱스를 사용함
  • scanAndOrder 필트값이 true라면 쿼리 프로세서가 스캔하고 정렬까지 다 처리해야 한다는 말
  • n 필드와 nscanned 필드가 가능한 비슷한 값을 가져야 이상적이다
  • MongoDB 2버전에서 쿼리 최적화는 일반적으로 nscanned를 가능한 줄이는 것을 뜻함
  • MongoDB 3버전에서는 nReturned가 반환된 도큐먼트, totalDocsExamined 값이 낮을 수록 좋음

MongoDB 쿼리 옵티마이저
해당 쿼리를 가장 효율적으로 실행하기 위해 어떤 인덱스를 사용할 지 결정하는 소프트웨어

  • 이상적인 인덱스를 선택하기 위한 규칙
    1. scanAndOrder를 false로: 쿼리가 정렬을 포함하고 있으면 인덱스를 통해 정렬하도록 시도
    2. 쿼리 셀렉터 (질의 조건)에 지정된 필드는 최대한 인덱스를 생성하여 사용하도록
    3. 쿼리가 범위나 정렬을 내포하면, 마지막 키에 대헤 범위/정렬에 도움이 되는 인덱스를 선택해야 한다
  • 쿼리 옵티마이저의 인덱스 선택과정
    1. 쿼리가 최초로 실행될 때 각 인덱스에 대한 쿼리 수행 플랜을 생성함
    2. 병렬로 각 플랜을 실행한다
    3. 완료 후에 nscanned가 최솟값을 갖는 플랜을 선택
      (드물긴 하지만 전체 컬렉션 스캔을 선택할 때도 있음)
    4. 실행 중인 다른 플랜을 중지시키고, 선택된 플랜은 이후 사용을 위해 저장함

쿼리 플랜과 HINT() 사용하기

  • .explain(true)를 사용하면 쿼리 수행 플랜을 모두 확인할 수 있다
    → true: allPlansExecution / false: queryPlanner / +) executionStats도 있음
  • 사용되지 않은 플랜들은 MongoDB 3버전에서 rejectedPlans라고 함
  • .hint({'idx_field': 1})로 nscanned를 확인해서 왜 rejectedPlans인지 확인이 가능함
    → 쿼리 옵티마이저에게 idx_field를 인덱스로 사용하라고 명령함

쿼리 플랜 캐시
옵티마이저가 모든 쿼리마다 이 모든 플랜을 병렬로 실행하는 과정을 거치는 것은 비효율적

  • 성공적인 플랜 발견시, 쿼리 패턴, nscanned 값, 인덱스 규칙이 기록됨
      {
          pattern: {
              field_1: 'equality'. //일치 조건
              field_2: 'bound', //범위 조건
              index: {
                  'idx_field_name': 1
              },
              nscanned: ### //성공했을 때의 스캔된 도큐먼트 갯수
          }
      }
  • 해당 패턴과 일치할 때마다 해당 인덱스를 사용하게 될 것임
  • 다른 인덱스가 더 효율적인 것으로 판명나게 되면 즉각 그 플랜을 이용한다

쿼리 플랜 캐시 소멸

  • 컬렉션에 대해 100회의 쓰기가 실행 됐을 때
  • 컬렉션에 인덱스가 추가되거나 삭제 됐을 때
  • 쿼리 플랜 사용 쿼리가, 캐시 플랜에서의 nscanned값의 최소 10배를 능가하는 값을 표시할 때

3. 쿼리 패턴

1. 단일 키 인덱스의 사용

  1. 정확한 일치 (완전 일치)
  2. 정렬: 쿼리 셀렉터 없이 정렬할 거라면, 컬렉션 전체가 아니라면 제한을 설정하는 것이 좋음
  3. 범위 → 해당 범위를 정렬하려면 동일한 인덱스로 정렬 사용

2. 복합 키 인덱스
중요한 점: 쿼리 당 하나의 범위나 정렬을 할 때만 효율적이다

  1. 정확한 일치
  2. 범위 일치
    1) 정확한 일치를 위한 키가 오고 (필요하다면)
    2) 범위/정렬이 뒤따른다

3. 커버링 인덱스 Covering Index
인덱스를 사용하는 방식 중 하나,
질의로 찾으려는 데이터가 모두 인덱스 필드 값 내에 있을 때 = "인덱스가 쿼리를 커버한다"
Covered Index Query는 인덱스 키가 가리키는 도큐먼트를 참고하지 않아도 되므로,
인덱스만의 쿼리index-only query라고도 부르고 쿼리 성능이 향상될 수 있다
사용법:
인덱스에서 반환할 필드를 선택하고 _id 필드를 제외하면 된다 (_id는 인덱스의 일부가 아니기 때문)

db.collection.find({field_to_find: 1}, {field_in_idxfield: 1, _id: 0})

 

 

전체 요약

  • 쿼리 로그 확인, 쿼리 프로파일링, explain()으로 쿼리를 최적화하자
  • 인덱스는 검색에는 유용해도, CUD가 느려지는 비용이 있다
  • 일반적으로 쿼리에서 하나의 인덱스만 사용한다 → 여러 필드의 쿼리는 복합 인덱스를 활용
  • 트래픽이나 데이터가 너무 많아지기 전에 쿼리를 최적하고, 조기에 인덱스를 생성하자
  • 최적화는 스캔한 도큐먼트 수를 줄임으로써 가능하다. explain()nscanned를 확인하자
Comments