NoSQL MongoDB!

NoSQL 자료구조

1970년 Edgar Codd 제안한 관계형 DB 모델 을 기반으로 한 SQL 이 발표됨.

테이블은 attribute, tuple 로 구성되고 관계(relation) 를 가짐.
오늘날 웹에서 볼 수 있는 대부분의 서비스(온라인 게시물, 토론, 소셜 네트워크, 전자 상거래, 게임, SaaS) 여전히 관계형 데이터베이스를 통해 제공

2010년대에 NoSQL 은 대규모 데이터셋이나 매우 높은 쓰기 처리량 달성 뛰어난 확장성의 필요, 동적이고 표현력이 풍부한 데이터 모델, 무료 오픈소스 소프트웨어 RDB 와 같이 사용되며 다중 저장소 지속성( polyglot persistence) 이란 개념이 생겨남.

LSM Tree

대부분의 NoSQL 에선 LSM Tree(Log Structured Merge Tree) 방식을 사용해 데이터를 저장한다.

데이터의 CRUD 과정이 로그형태로 계속 로그 세그먼트 파일(이하 세그먼트)에 쌓이는 구조.
일정 용량의 세그먼트 가 완성되면 병합과 컴팩션 과정을 수행하면서 최신화된 데이터만 남기고 모두 삭제한다.

[Cassandra, HBase, DynamoDB] 등이 LSM Tree 기반의 엔진을 사용한다.

  • 고장 복구
    • 오프셋의 최신값과 세그먼트 를 해시색인으로 매핑시켜놓는데 이를 메모리에 저장해둔다. 세그먼트 해시맵이라 부름.
    • 이를 메모리에 빠르게 올리기 위해 스냅숏을 만들기도 함.
  • 레코드 쓰기 실패
    • 레코드를 쓰는 도중 프로세스가 종료될 경우 손상된 로그를 확인하기 위한 체크섬 운영
  • 동시성 제어
    • 컴펙션 중에는 디스크 대역폭으로인해 새로운 요청의 쓰기 성능에 영향이 발생한다.
    • 원활한 컴팩션 처리를 위해 유입속도 조절을 위한 모니터링이 필요하다,

SST

대부분의 NoSQL 에서 Sorted String Table(SST) 를 주로 사용한다.
LSM Tree 의 일종이지만 모든 데이터가 정렬되어 있으며, 정렬된 세그먼트를 병합하는 과정이 merge sort 같은 방법을 사용해 효율적이다.

SST 에선 쓰기요청을 바로 파일에 저장하지 않고 AVL 과 같은 트리구조를 사용해 모든 입력값에 대해 인메모리에서 정렬하는 과정을 먼저 거친다(memtable 이라 부름).

memtable 의 크기가 임곗값을 넘으면 파일로 디스크에 기록한다.

RDB 에서도 AVL 과 유사한 B-Tree Index 를 메모리에 저장해두고 사용하며, 일정 체크포인트 마다 디스크에 저장함으로 둘이 유사한 면이 있다.

고장 복구 해당 구조는 서버 장애시 인메모리에 입력된 쓰기값들이 모두 소실된다는 점. 따라서 로그 구조와 같이 운영한다.
아직 세그먼트로 저장되지 않은 내용은 인메모리와 로그파일 두군데에서 같이 운영된다.

데이터를 정렬시켜서 LSM 구조로 저장했기에 범위질의도 효율적으로 실행할 수 있다.

Bloom filter

Bloom filter 는 데이터베이스에 키의 존재여부를 알려주는, 해시함수와 bit array 를 기반으로 한 확률적 자료구조 이다.

1

그림처럼 데이터 {x, y, z} 에 대해서 3개의 해시함수에 통과시키고 모듈러 연산을 통해 24bit 공간에 체크한다.

동일한 연산을 데이터 {w} 에 하였을 때 3개의 해시함수 모듈러 연산 결과중 체크가 되지 않은 bit 가 있다면, 데이터 {w} 는 확실히 DB 에 존재하지 않는다.

해당 자료구조는 False Positive 는 발생해도 False Negative 는 발생하지 않는다.

  • False Positive: 실제로 존재하지 않는 원소를 존재한다고 잘못 판단
  • False Negative: 실제로 존재하는 원소가 존재하지 않는다고 잘못 판단

이런 특성 때문에 키값을 중복검사할때 효과적으로 사용할 수 있는 자료구조이다.

Bloom filter에서 100만 개의 데이터를 1% 의 False Positive 확률 목표로 한다면 약 9.6MB 크기가 적절하다.

Bloom filter 단점으론 추가만 가능하고 삭제는 불가능하기 때문에 시간이 지남에 따라 False Positive 가 늘어나게 된다.

Cassandra, HBase, DynamoDB 와 같은 분산형 NoSQL 데이터베이스에서 널리 사용됩니다

Cuckoo Filter

Cuckoo: 뻐꾸기, 기존 값을 밀어내는 특징 때문에 해당 이름을 사용 Cuckoo Filter 시각화 https://www.lkozma.net/cuckoo_hashing_visualization/

Bloom Filter 와 마찬가지로 존재 여부 검사에 사용, False Positive 가 발생할 수 있는 확률적 자료구조.
하지만 Cuckoo Filter 는 동적 삭제와 삽입이 가능하다는 장점이 있는 대신 연산과정이 복잡하고 가끔 삽입 실패 할 수 있다.

또한 Bloom Filter 와 같이 bit array 를 사용하지 않고 해시테이블지문(finger print) 값을 사용하기 때문에 큰 공간복잡성을 요구한다.

지문은 해시값의 일부, 축약된 데이터로 8 ~ 16 bit 정도 크기의 값.

두개의 해시함수, 두개의 해시테이블을 사용해 $O(N)$ 의 성능으로 해당 데이터가 존재하는지 여부를 파악할 수 있다.

  • Search 두개의 해시테이블 중 입력값의 지문이 있는지 확인.
  • Delete Search 과정을 거친 후 일치하는 값을 삭제.
  • Insert
    1. 첫번째 테이블에 동일한 지문이 있는지 확인, 없다면 첫번째 테이블에 삽입
    2. 피충돌(충돌되는) 지문이 있다면 피충돌 지문을 다른 테이블로 옮긴 뒤 삽입
    3. 피충돌 지문을 옮기는 과정에서 연쇄 충돌이 발생할 수 있으며 지정 충돌횟수를 넘어가면 삽입실패로 간주

Search, Delete 작업중 지문값의 충돌이 발생할 경우 False Positive 문제가 발생할 수 있다.

아래 3개 데이터를 직접 환경에 맞춰 지정할 수 있다.

  • 삽입 실패로 간주할 연쇄충돌 횟수
    보통 10~20회로 설정
  • 해시테이블 크기
    해시테이블 개수는 2개를 권장하며 저장하려는 데이터 크기의 1.3 ~ 2배 정도 해시테이블 크기를 권장한다.
  • 지문 크기 입력값 100만개 기준 지문 크기별 충돌확률(False Positive)은 아래와 같다.
    • 8비트 지문 ≈ 0.39%
    • 12비트 지문 ≈ 0.024%
    • 16비트 지문 ≈ 0.0015%

HyperLogLog

확률론적 카운팅 알고리즘.

데이터 집합에서 유니크한 값의 개수(카디널리티, Cardinality) 를 빠르게 추정하는 데 사용한다.
예를 들어, 수백만 사용자의 한달간 활성화된 접속량을 unique id 개수로 측정할 때 사용할 수 있다.

HyperLogLog 의 가장 큰 장점은 데이터의 크기와 상관없이 일정한 메모리만을 사용한다는 메모리 효율성이다.
1.5kb 메모리를 사용해 2% 표준 오차로 10^9(십억) 개의 데이터 집합에서 카운트를 측정할 수 있다.

해시 함수를 사용하여 고정길이 비트 패턴으로 변환 후 처음으로 나타나는 1의 위치를 기록, 그 위치의 로그 값으로부터 카디널리티를 추정한다.

Bob   : 001000110...(첫 1의 위치: 4)  
Alice : 000100101...(첫 1의 위치: 5)  
Patric: 001110000...(첫 1의 위치: 3)  

데이터들을 여러개의 버킷으로 나누고 각 버킷안에 들어간 데이터중 최대 첫 1의 위치값을 구한다.
만약 입력된 해시함수 중 0의 최대 개수가 5개라면 $2^5$ 개수의 요소가 해당 버킷에 입력되었다 추정할 수 있다.

설명에서 직관적으로 알 수 있겠지만 데이터 집합의 개수가 작으면 작을수록 오차가 커진다.

LogLog 알고리즘에서 카디널리티를 구하는 식은 아래와 같다.

\[2^{(기하평균)} * m * α\]

$m$: 버킷수, $a$: 계수(고정값)

HyperLogLogLogLog 에서 기하평균 대신 조화평균을 사용한다.

\[2^{(조화평균)} * m * α\]

작은값에 더 큰 가중치를 부여함으로서 과대추정을 줄이는데 효과적이다.

기하평균, 조화평균

산술평균을 일반적으로 알고 있는 합의 평균

\[산술평균 = m = \frac{x_1 + x_2 + \dots + x_n}{n}\]

기하평균은 곱의 평균, 도형의 닮은비율을 구하기 위한 기하의 비례식에서 유례,
성장률, 비율을 다룰 때 주로 사용한다.

\[기하평균 = \sqrt[n]{x_1 + x_2 + \dots + x_n}\]

조화평균은 산술평균의 역수. 비율의 평균을 구할 때 유용.

\[조화평균 = \frac{n}{\frac{1}{x_1} + \frac{1}{x_2} + \dots + \frac{1}{x_n}}\]

MongoDB

https://github.com/mongodb/mongo https://github.com/neelabalan/mongodb-sample-dataset 위 샘플데이터 설치 후 gui 툴에서 import 설정

대표적인 NoSQLMongoDB 에서 클러스터를 어떻게 처리하는지 개념적 요소에 대해 알아본다.

docker run -d --name mongodb -p 27017:27017 mongo
  • B-Tree 기반의 WiredTiger 스토리지 엔진을 사용
  • ObjectId 구조
    1
    • 머신 고유 프로세스당 생성되는 랜덤 5바이트 값
    • 프로세스마다 초기화되는 3바이트 카운터
  • BSON(Binary JSON)
    • 도큐먼트를 저장하며 아래와 같이 드라이버가 변환을 담당한다.
    • 클라이언트--json--(드라이버)--bson--서버
    • 경량 바이너리 형식으로 압축된 바이트 문자열을 빠르게 인코딩하고 디코딩하도록 설계되었다.
  • 비동기 I/O 기반 연결
    • NonBlocking 기반 소켓 통신
    • MongoDB 의 경우 연결 가능한 커넥션 개수 기본값은 65536개이다(maxIncomingConnections).

스키마 설계

다형성 패턴(Polymorphic)

모든 도큐먼트가 유사하지만 동일하지 않은 구조를 가질 때 적합
애플리케이션에서 실행가능한 공통 필드를 식별하는 것이 포함

아래와 같은 notifications 컬렉션에서 다양한 형태의 도큐먼트가 저장가능함.
type, user_id 필드만 고정이고 나머지 필드는 달라질 수 있음.

// 메시지 알림
{
    "_id": ObjectId("60d5dbf87f3e6e3b2f4b2b3a"),
    "type": "message",
    "user_id": 1,
    "message": "You have a new message from Alice",
    "timestamp": "2023-08-01T10:00:00Z",
    "sender_id": 2
}

// 친구 요청 알림
{
    "_id": ObjectId("60d5dbf87f3e6e3b2f4b2b3b"),
    "type": "friend_request",
    "user_id": 1,
    "message": "Bob sent you a friend request",
    "timestamp": "2023-08-01T10:05:00Z",
    "requester_id": 3
}

// 이벤트 초대 알림
{
    "_id": ObjectId("60d5dbf87f3e6e3b2f4b2b3c"),
    "type": "event_invite",
    "user_id": 1,
    "message": "You are invited to Sarah's birthday party",
    "timestamp": "2023-08-01T10:10:00Z",
    "event_id": 5,
    "location": "123 Main St"
}

상속구조를 흉내낼 수 있다.
모든 문서가 공통으로 가지는 필드는 부모 클래스에 해당하고, 각 유형별로 고유한 필드는 자식 클래스에 해당한다.

{
  "_id": "3",
  "type": "media",
  "mediaType": "book",  // 상위 클래스 필드
  "title": "1984",
  "author": "George Orwell",
  "pages": 328
}

속성 패턴

도큐먼트에 필드의 서브셋이 있는 경우
정렬, 쿼리할 필드가 서브셋에 있는 경우

{
    "product_id": 123,
    "name": "Smartphone",
    "brand": "TechCorp",
    "attributes": [
        { "key": "color", "value": "Black" },
        { "key": "storage", "value": "128GB" },
        { "key": "camera", "value": "12MP" }
    ]
}
db.products.createIndex({ "attributes.key": 1, "attributes.value": 1 });

새로운 속성이 추가되거나 기존 속성이 변경될 때에도 스키마를 변경하지 않고 쉽게 확장할 수 있다.

배열 요소가 추가될때마다 index 도 하나씩 늘어나기 때문에 인덱스 크기와 성능이 영향을 끼친다.

아래와 같이 key: color 인 속성에 대해서만 인덱스 생성처리를 할 수 있다.

db.products.createIndex(
    { "attributes.value": 1 },
    { partialFilterExpression: { "attributes.key": "color" } }
);

버킷 패턴

https://mongo.comcloud.xyz/ko-kr/docs/v6.0/tutorial/model-iot-data/

일정 기간 동안 스트림으로 유입되는 시계열 데이터에 적합한 패턴.

예로 센서가 초당 1개의 데이터를 생성한다고 가정할 때 한시간 동안의 데이터를 단일 도큐먼트 내 배열에 배치한다.

// 버킷 패턴 적용: 한시간 데이터를 하나의 문서로 묶어서 저장
{
  "date": "2024-08-12",
  "sensorId": "sensor1",
  "readings": [
    {"time": "10:00:00", "value": 23.5},
    {"time": "10:01:00", "value": 23.8},
    {"time": "10:02:00", "value": 24.1},
    // ...한시간치 데이터
  ]
}

redis 나 메세지 큐를 저장공간 삼아서 버킷으로 저장하기 좋다.

MongoDB 5.0 부터 time-series 데이터구조를 제공함으로 고정된 데이터 형식 저장용도라면 버킷패턴보다 time-series 사용을 권장한다.

서브셋 패턴

단일 도큐먼트가 너무 커지거나 배열이 너무 길어져서 성능에 영향을 미칠 수 있을 때, 일부만을 저장하고 나머지 데이터를 다른 컬렉션에 분리해 저장하는 방법.

최근 댓글 몇 개만 comments 배열에 저장하고, 나머지 댓글은 별도의 컬렉션으로 분리.

// 서브셋 패턴 적용: 최근 댓글만 저장
{
  "_id": "user123",
  "username": "john_doe",
  "recentComments": [
    {"commentId": "cmt998", "text": "Recent comment!", "timestamp": "2024-08-01"},
    {"commentId": "cmt999", "text": "Another recent comment!", "timestamp": "2024-08-02"}
  ],
  "commentsSubset": true  // 추가 댓글이 별도로 존재함을 나타냄
}

// 나머지 댓글을 저장하는 별도 컬렉션
{
  "_id": "cmt_batch_1",
  "userId": "user123",
  "comments": [
    {"commentId": "cmt1", "text": "First comment!", "timestamp": "2024-01-01"},
    {"commentId": "cmt2", "text": "Another comment!", "timestamp": "2024-01-02"},
    // ...
  ]
}

확장된 참조 패턴(Extended Reference)

역정규화를 통해 자주 사용하는 연관 데이터의 중요한 필드를 중복하여 저장함으로써, 한번의 요청으로 데이터를 가져올 수 있도록 하는 패턴.

// 확장된 참조 패턴 적용
{
  "_id": "post123",
  "title": "My First Blog Post",
  "content": "This is the content...",
  "authorId": "user456",
  "authorName": "John Doe",  // 작성자 이름
  "authorEmail": "john.doe@example.com"  // 작성자 이메일
}

데이터 중복이 발생하고 데이터 일관성 관리가 힘들어진다.

부가적으로 여러 조건을 걸어 데이터 일관성 문제를 처리할 수 있다.

델타 패턴

확장된 참조 패턴과 같이 데이터 변경으로 인한 일관성을 해결하기 위한 패턴.

async function updateNickname(userId, newNickname) {
  // 먼저 사용자의 현재 데이터를 가져옵니다.
  const user = await User.findById(userId);
  
  if (!user) {
    throw new Error('User not found');
  }

  // 닉네임이 변경되었는지 확인
  if (user.nickname !== newNickname) {
    // 델타 패턴으로 변경 사항 기록
    user.deltas.push({
      field: 'nickname',
      oldValue: user.nickname,
      newValue: newNickname
    });

    // 닉네임 업데이트
    user.nickname = newNickname;

    // 변경 사항을 저장
    await user.save();

    console.log(`Nickname updated to ${newNickname} with delta recorded.`);
  } else {
    console.log('Nickname is the same, no update needed.');
  }
}

결국 참조하는 도큐먼트에 접근해야 함으로 쿼리개수를 줄이지 못하지만 아래와 같은 장점이 있다.

  • 실시간으로 데이터 변경을 확인해야 하는 경우
  • 지연 업데이트를 지원하기에 한번에 업데이트 빈도가 분산된다
  • 부분 조인 기능이 부가적으로 딸려온다

실시간성이 덜 필요한 UI/UX 의 경우에는 업데이트 관련 코드를 제거하고 기존 확장된 참조 패턴 으로 계속 사용하면 된다.

캐시와 같은 시스템을 사용하면 DB 가 조회역할을 수행하지 않아도 된다.

트리 패턴

https://mongo.comcloud.xyz/ko-kr/docs/v6.0/tutorial/model-tree-structures-with-parent-references/

Books > Programming > Databases > MongoDB
Books > Programming > Databases > MySQL
Books > Programming > Databases > dbm
Books > Programming > Languages > Java

위와같이 구조적으로 주로 계층적인 데이터가 있을 때 적용

상위 참조 형태로 데이터 구성

db.categories.insertMany( [
   { _id: "MongoDB", parent: "Databases" },
   { _id: "dbm", parent: "Databases" },
   { _id: "Databases", parent: "Programming" },
   { _id: "Languages", parent: "Programming" },
   { _id: "Programming", parent: "Books" },
   { _id: "Books", parent: null }
] )

db.categories.createIndex( { parent: 1 } )
// 직계 하위 노드를 찾기
db.categories.find( { parent: "Databases" } )

하위 참조 형태로 데이터 구성

db.categories.insertMany( [
   { _id: "MongoDB", children: [] },
   { _id: "dbm", children: [] },
   { _id: "Databases", children: [ "MongoDB", "dbm" ] },
   { _id: "Languages", children: [] },
   { _id: "Programming", children: [ "Databases", "Languages" ] },
   { _id: "Books", children: [ "Programming" ] }
] )

db.categories.findOne( { _id: "Databases" } ).children
db.categories.createIndex( { children: 1 } )
// 직계 상위 노드를 찾기
db.categories.find( { children: "MongoDB" } )

구체화된 경로 형태로 데이터 구성

db.categories.insertMany( [
   { _id: "Books", path: null },
   { _id: "Programming", path: ",Books," },
   { _id: "Databases", path: ",Books,Programming," },
   { _id: "Languages", path: ",Books,Programming," },
   { _id: "MongoDB", path: ",Books,Programming,Databases," },
   { _id: "dbm", path: ",Books,Programming,Databases," }
] )

db.categories.createIndex( { path: 1 } )
// 정규식 사용 하위항목들 검색
db.categories.find( { path: /,Programming,/ } )
db.categories.find( { path: /^,Books,/ } )

$graphLookup

스키마 버전 관리 패턴(Schema Versioning)

https://mongo.comcloud.xyz/ko-kr/docs/v6.0/tutorial/model-data-for-schema-versioning/

동일 컬랙션에서 다양한 형태로 데이터를 운영하고, 점진적으로 스키마를 업데이트할 수 있다.

{
    "_id": "<ObjectId>",
    "galactic_id": 123,
    "name": "Anakin Skywalker",
    "phone": "503-555-0000",
}
{
    "_id": "<ObjectId>",
    "galactic_id": 123,
    "name": "Darth Vader",
    "contact_method": {
        "work": "503-555-0210",
        "home": "503-555-0220",
        "twitter": "@realdarthvader",
        "skype": "AlwaysWithYou"
    },
    "schema_version": "2"
}

schema_version 필드를 통해 핸들러 함수를 추가하는 방식으로 동일한 컬렉션에 다양한 버저닝 데이터를 관리할 수 있다.

쿼리플랜

인덱스를 사용하지않으면 컬렉션 스캔(collection scan) 이 발생한다.

인덱스를 사용한 쿼리가 들어오면 쿼리 모양(query shape) 확인하고 매핑된 쿼리 플랜(query plan) 을 사용하여 조회한다.
쿼리 플랜을 결정하기 위해 아래 과정을 거친다.

  • 인덱스 5개 중 3개가 쿼리 후보로 식별됨.
  • 각 인덱스 후보에 하나씩 총 3개 쿼리 플랜 작성
  • 각각 인덱스를 사용하는 3개의 병렬 스레드에서 쿼리 플랜 실행
  • 각 플랜은 시범기간(trial period) 동안 경쟁, 승리한 쿼리 플랜을 산출
  • 쿼리 모양과 쿼리 플랜을 매핑
  • 쿼리 플랜은 캐시에 저장되고 서버가 재시작되거나 컬렉션/인덱스가 변경되면 캐시에서 삭제된다.
{
    "_id": ObjectId("585d817db4743f74e2da067c"),
    "student_id": 0,
    "scores": [...],
    "class_id": 127 // 0~500
}

위와 같은 문서가 10만개 존재할 때 아래 2개 인덱스를 생성

# 1: 오름차순, -1: 내림차순
db.students.createIndex({"class_id": 1})
db.students.createIndex({student_id: 1, class_id: 1})

그리고 아래와 같은 조회조건, 정렬조건을 설정해서 조회한다.

> db.students.find({student_id:{$gt:5000}, class_id:54})
    .sort({student_id:1})
    .explain("executionStats")

당연히 class_id 인덱스를 사용해 조회 후 인메모리에서 student_id 를 정렬하는게 효율적이지만 실제 그렇게 동작하지 않을 수 있다.
find 에 요청한 필드 목록만 봐도 {student_id: 1, class_id: 1} 인덱스를 사용해야 할것 같다.

실제 통계정보가 부족한 경우 해당 인덱스를 사용하여 10만건 가까운 데이터를 읽어야할 수 있다.

트랜잭션

도큐먼트의 최대 크키는 16MB.

갱신은 전체 도큐먼트를 다시 쓰며, 원자성 갱신은 도큐먼트 단위로 실행된다.
원자성이 필요하다면 한 도큐먼트 안에 여러 엔티티를 관리하면 좋다.

하지만 도큐먼트가 커질수록 쓰기성능이 떨어짐으로 원자성이 필요하다고 무작적 크기를 늘릴 순 없다.

MongoDB 3.6 부터 여러개의 도큐먼트 수정을 하나의 트랜잭션으로 묶어 사용가능하다.

트랜잭션과 매핑될 논리세션 시작을 명시하여 트랜잭션을 시작한다.

// 세션 시작
try (ClientSession session = mongoClient.startSession()) {

    // 트랜잭션 본문 정의
    TransactionBody<String> txnBody = () -> {
        // 첫 번째 컬렉션에 문서 삽입
        InsertOneResult result1 = collection1.insertOne(session, new Document("name", "Alice").append("age", 25));
        System.out.println("Inserted document ID in collection1: " + result1.getInsertedId());

        // 두 번째 컬렉션에 문서 삽입
        InsertOneResult result2 = collection2.insertOne(session, new Document("name", "Bob").append("age", 30));
        System.out.println("Inserted document ID in collection2: " + result2.getInsertedId());

        return "Transaction successfully completed!";
    };

    // 트랜잭션 실행
    try {
        String result = session.withTransaction(txnBody);
        System.out.println(result);
    } catch (RuntimeException e) {
        System.out.println("Transaction aborted due to an error: " + e.getMessage());
    }
}

WAL(Write-Ahead Logging) 메커니즘을 사용하여 롤백을 처리한다.

  • 트랜잭션 중 발생한 모든 변경 사항을 먼저 로그에 기록한다.
  • 트랜잭션 중 에러가 발생하면 해당 로그를 참조하여 발생한 변경 사항을 취소한다.
  • 트랜잭션이 정상종료되면 변경 내용을 데이터베이스에 반영한다.
  • 트랜잭션은 스냅샷 격리레벨 처럼 동작하여 다른 세션에선 해당 트랜잭션은로 인해 변경된 데이터에 접근할 수 없다.

MongoDB 복제

1

  • config 복제, 샤딩을 위한 메타 데이터, 라우팅 테이블을 저장한다.
  • mongos config 서버의 메타 데이터를 이용해 각 mongod 에 데이터 라우팅
  • mongod
    MongoDB의 데이터 서버

클라이언트의 요청을 처리하는 프라이머리, 복사본을 갖는 세컨더리 여러대를 합쳐 복제 셋 으로 부른다.
MongoDB 는 단일 쓰기를 지원하며 프라이머리 failover 를 위한 elect를 제공한다.

클라이언트는 연결할 드라이버를 위한 시드목록(seed list)을 사용한다.
추가적인 복원력을 위해 아래와 같이 DNS Seed list 연결 형식을 권장한다.

mongodb://server-1:27017,server-2:27017,server-3:27017

기본적으로 드라이버는 모든 읽기 쓰기 요청을 프라이머리로 라우팅한다.
읽기요청모드는 아래와 같다.

  • primary(default)
    프라이머리로 읽기요청 고정, 장애로 인해 연결되지 않을경우 기본적으로 드라이버는 모든 요청을 처리하지 않는다.
  • primaryPreferred
    프라이머리 장애시 세컨더리로 읽기요청 전송
  • secondary
    세컨더리로 읽기요청 고정
  • secondaryPreferred
    모든 세컨더리 장애시 프라이머리로 읽기요청 전송
  • nearest
    가장 응답 지연율이 낮은 멤버에게 읽기요청 전송

읽기 요쳥의 부하분산을 위해 세컨더리 멤버에 읽기 요청을 보내는것은 권장하지 않는다.
해당 방식으로 부하를 복제셋의 모든 멤버가 처리하고 있다면 멤버의 failover 발생시, 데이터의 동기화에서 과부하가 발생하여 복제셋 전체 장애로 전파될 수 있다.

추천하는 읽기모드는 [primary, primaryPreferred, mearest] 이다.

부하분산을 원한다면 복제셋의 읽기요청을 분리하는것 보다 샤딩 사용을 권장한다.

동기화

세컨더리 인스턴스가 첫 생성될 때 초기동기화를 수행한다.
복제 대상으로 삼은 멤버의 각 컬렉션을 모두 스캔 및 삽입한다.

초기동기화가 종료되면 oplog 를 통해 실시간으로 데이터 복제를 수행한다.
프라이머리가 수행한 write 작업은 oplog 로 보관된다, 세컨더리는 oplog 의 동기화를 통해 복제를 수행한다.

1

oplog 의 동기화 작업은 멱등하지만 만약 oplog 와의 충돌이 발생하면 세컨더리 서버는 종료된다.

oplog 크기 기본값은 990MB ~ 디스크 여유 공간의 5%(최대 50GB) 으로 쌓이는 log 속도에 따라 유동적이다.

삭제나 다중갱신처럼 여러 도큐먼트에 영향을 미치는 연산은 여러 개의 oplog 항목으로 분해된다, oplog 크기가 빠르게 증가하기에 사용량이 많아 50GB 가 부족하다면 더 늘릴 수 있다, 어플리케이션 사용 특성에 맞춰 oplog 크기를 설정하면 된다.

복제사슬

MongoDB 에선 자동복제사슬(automatic replication chaining) 을 사용한다, 세컨더리 노드들은 ping 시간을 기준으로 동기화할 대상을 결정한다.
멤버는 가장 가깝고 자신보다 앞서있는 멤버를 찾아 동기화 대상으로 선정하기에 복제 순환이 발생하지 않는다.

복제사슬의 depth 가 길어질수록 복제셋의 동기화 완료되는데 시간이 오래걸린다.
명령어로 복제대상을 수동으로 지정할 수 있다.

chainingAllowed: false 설정으로 프라이머리로부터의 복제를 강제할수 있다.

failover

Raft 합의 프로토콜을 기반으로하는 복제 프로토콜 v1 사용하여 프라이머리 failover 를 지원한다.

정족수를 요구하는 합의 알고리즘 특성상 홀수개 인스턴스 지원을 권장한다.

Raft 합의 프로토콜로 리더선출하기 전, 프라이머리 failover 를 지원하기 위해 여러가지 기믹을 사용한다.

  • priority 값
    • 0 ~ 100 사이의 값으로 기본값은 1
    • priority: 0 으로 설정된 인스턴스는 passive member 라 부르며 프라이머리가 될 수 없다.
    • priority 값이 높은 멤버는 언제나 프라이머리로 선출된다. 만약 선출되지 않았다면 동기화가 완료된 후 다시 선출되도록 요청한다.
  • hidden member
    • priority: 0 으로 설정되며 read 요청을 받지 않고 복제서버(백업용)로만 동작한다.
    • 다른 세컨더리는 hidden member 를 복제 대상으로 삼지 않는다.
  • 아비터 선출
    • 복제서버를 리소스 부족으로 짝수개 구성해야할 때 사용
    • 아비터 인스턴스는 프라이머리 선출에만 참여하며 데이터 복제는 수행하지 않는다.
    • 아비터 인스턴스는 짝수에서 홀수로 가기위해 하나만 사용하는것을 권장하다.

각 멤버는 복제셋 내부 모든 멤버에게 heartbeat 요청을 보낸다.
자신을 포함하여 복제셋 내부 멤버들을 바라볼때 아래와 같은 상태값을 가진다.

  • PRIMARY, SECONDARY
    • 정상동작중인 쓰기 읽기 가능한 멤버 상태
  • STARTUP
    • 멤버를 처음 시작할 때의 상태
  • STARTUP2
    • 구성 정보가 로드된 상태, 초기동기화 과정 전반에 걸쳐 지속
  • RECOVERING
    • 동기화 완료후 세컨더리가 되기 전 거쳐가는 단계, 프로세스 준비중이라 읽기 작업은 수행할 수 없는 상태
  • ARBITER
    • 아비터 모드로 동작중인 멤버상태
  • DOWN
    • 네트워크 서버 등 각종 문제로 접근할 수 없는 상태
  • UNKNOWN
    • 한번도 연결되어본적이 없는 상태
  • REMOVED
    • 복제셋에서 제거된 상태
  • ROLLBACK
    • 롤백 진행중인 상태

롤백

Raft 에선 key-value 의 커밋과정(과반수 쓰기 성공), 로그 인덱스의 최신화 여부를 체크하고 투표하기 때문에 항상 최신의 노드가 리더로 선출된다.

하지만 MongoDB 에선 oplog 의 커밋과정이 따로 없기 때문에 구식 oplog 를 가진 멤버가 프라이머리 멤버로 과반수 투표를 받을 수 있다.

이 경우 최신 oplog 를 가졌었던 멤버가 다시 복구되었을 때 충돌이 발생함으로 롤백을 수행하고 다시 동기화를 수행해야한다.

롤백이 발생하면 특수한 롤백 파일에 기록된다, 복구할 수 있지만 수동 개입이 필요하 다.

만약 롤백이 발생하며 안되는, 복제보증이 필요한 쓰기 요청의 경우 애플리케이션에서 writeConcern(majority) 을 사용하여 과반수 멤버에 쓰기가 완료되는것을 강제할 수 있다.

config 서버의 메타데이터 쓰기작업이 majority 로 되어있다.

try { 
  db.products.insertOne(
    { "_id": 10, "item": "envelopes", "qty": 100, type: "Self-Sealing" }, 
    { writeConcern: { "w" : "majority", "wtimeout" : 100 } } 
  ); 
} catch (e) { 
  print (e);
}

프라이머리는 쓰기 작업이 과반수에 복제될 때까지 응답하지 않는다.
쓰기 작업이 wtimeout 이내에 완료되지 않으면 에러메세지를 응답할 수 있으며 쓰기 작업이 실제로 완료되었지만 클라이언트 측에서 타임아웃 오류가 발생할 수 있다.
타임아웃 발생하더라도 재시도 시 멱등성 있는 동작을 하도록 구성하거나 실패에 대한 예외처리 방안을 구성해야한다.

MongoDB 샤딩

MongoDB 에선 구조를 추상화하고 시스템 관리를 간단하게 하는 자동샤딩(auto sharding) 을 지원한다.
샤드키, 컬렉션, 샤드를 호스팅하는 복제셋, 라우팅 테이블 등 샤딩 클러스터 운영에 필요한 데이터는 config 서버에 저장된다.

클라이언트는 데이터베이스 접근시 mongos 로만 통신한다, 아래와 같은 구조적 특징이 있음.

  • mongosconfig 서버를 통해 샤드가 몇개로 데이터가 분리되어 있건, 라우팅 프로세스를 사용해 샤드 클러스터가 하나의 서버로 보이게 지원한다.
  • mongos 는 최대한 다양한 샤드노드에 가까운 공간에 위치해야 한다.
  • mongos 는 쿼리 수행시 샤드키를 사용해 특정 샤드에서 데이터를 수집하고, 샤드키를 사용하지 않는 쿼리는 모든 샤드로 분산한 다음 결과를 수집한다.

클라이언트는 mongos 로 인해 클러스터에서 분리되어 아래 소개할 청크분할, 밸런서 과정을 알지 못하고, 해당 과정에서 발생하는 오버헤드로 인해 delay 만 발생할 뿐 오류가 발생하진 않는다.

MongoDB 4.2 이후부터 샤딩 클러스터 환경에서 분산 트랜잭션을 지원한다.
특정 샤드 멤버를 Coordinator 로 선정하고 2PC 기법을 통해 분산 트랜잭션을 진행한다.

샤딩키, 청크 분할

컬렉션을 샤딩할 때 인덱스가 설정된 필드를 기준으로 샤드키를 설정한다.

1

역기서 minKey 와 maxKey 는 음의무한대, 양의무한대라 볼 수 있다.

샤딩 설정된 프라이머리 노드들은 분할 임계치를 관리하여 샤딩키가 설정된 도큐먼트를 청크 단위로 분할한다.

config 서버에선 청크와 샤드가 매핑된 테이블을 관리하며 모든 config 서버가 동작중일 때 분할이 이루어진다.

config 서버가 정상동작 하지 않으면 분할 임계치를 넘어서고 계속 config 서버에 분할을 요청하는 분할소동(split storm)

  • 오름차순 샤드키
    • date, objectid 등 꾸준히 증가하는 타입
    • 마지막의 추가되는 오름차준 샤드키 특정상 최대 청크($maxKey 가 있는 마지막 청크)만 커진다.
    • 최대 청크의 분할과 마이그레이션이 지속적으로 일어날 수 있다.
  • 무작위 분산 샤드키(randomly distributed shard key)
    • 해시 샤딩키를 사용
    • 해시 범위를 청크로 나누고 데이터를 분산 배치한다.
    • 모든 청크가 고르게 커진다.
    • 샤드키 범위 조회시 모드 워크로드의 흩어져있는 데이터를 모으는 과정에서 많은 데이터를 필요로한다.
  • 위치기반 샤드키
    • IP, 위경도, 주소
    • 국가별 IP 위치정보를 기반으로 샤딩, 글로벌 서비스 제공시 사용하면 좋다.

샤드키로 사용할 필드는 자주 사용하는 인덱스, 카디널리티가 넓은(시간, 이메일) 필드를 사용하는것을 권장한다.

밸런서, 청크 마이그레이션

프라이머리 멤버의 백그라운드 프로세스로 청크 개수가 특정 마이그레이션 임계치에 이를 때 실행된다.

구성서버가 각 샤드의 청크 수를 모니터링한다 청크수가 마이그레이션 임계치 를 넘어가면 청크 마이그레이션을 위해 부하 샤드 멤버의 밸런서 를 실행시킨다.

청크개수가 많은 샤드에서 개수가 작은 샤드로 청크를 옮기기 시작한다. 그리고 config 서버에 메타데이터를 업데이트한다.

해당 과정 긴 시간동안 수행되며, 그사이에 mongos 가 이전 청크위치 요청 수행시 retry 처리 될 수 있다.

카테고리:

업데이트: