
6장. 키-값 저장소 설계
- key-value 저장소
- 비 관계형(non-relational) 데이터베이스
- 고유 식별자(identifier)를 키로 가져야 함
- pair: key, value간의 연결 관계
- ex. Amazon Dinamo, memcached, Redis
- key
- 유일
- 해당 key에 매달린 value는 key를 통해서만 접근 가능
- 성능 상, key는 짧을 수록 👍
- 연산
- put(key, value): 키-값 쌍을 저장소에 저장
- get(key): 인자로 주어진 key에 매달린 value를 꺼냄
1. 문제 이해 및 설계 범위 확정
- 완벽한 설계는 X
- 읽기, 쓰기, 메모리 사용량 사이에 균형 찾기
- 데이터 일관성 & 가용성 사이에서 타협적 결정
- 다음 특성을 갖는 키-값 저장소를 설계해보자!
- 키-값 쌍의 크기 <= 10KB
- 큰 데이터 저장 가능
- 가용성 ⬆️: 장애가 있더라도 빠른 응답
- 규모 확장성 ⬆️: 트래픽 양에 따라 자동적으로 서버 증설/삭제
- 데이터 일관성 수준 조정 가능
- 응답 지연시간 ⬆️
2. 단일 서버 키-값 저장소
- 키-값 쌍 전부를 메모리에 해시테이블로 저장
- 빠른 속도 보장
- 모든 데이터를 메모리 안에 두는 것은 불가능
- 개선책
- 데이터 압축 compression
- 자주 쓰이는 데이터만 메모리에 두고 나머지는 디스크에 저장
→ But, 한 대 서버로 부족해짐
3. 분산 키-값 저장소
CAP 정리
- Consistency, Availability, Partition Tolerance theorem
- 데이터 일관성
- 가용성
- 파티션 감내
→ 동시에 만족하는 분산 시스템을 설계하는 것은 불가능하다는 정리
- Consistency
- 모든 클라이언트는 어떤 노드에 접속했느냐에 관계없이 언제나 같은 데이터를 보게 됨
- Availability
- 모든 클라이언트는 일부 노드에 장애가 발생하더라도 항상 응답 받을 수 있어야 함
- Partition Tolerance
- Partition: 두 노드 사이에 통신 장애가 발생하였음을 의미
- 네트워크 Partition이 생기더라도 시스템은 계속 동작하여야 함
- 두 가지를 충족하려면 나머지 하나는 반드시 희생되어야 함 (CA, CP, AP)
- 통상 네트워크 장애는 피할 수 없는 일!
- 분산 시스템은 반드시 Partition Tolerance를 지원해야함
→ 실세계에 CA 시스템은 존재 X
- 네트워크 장애로 일부 서버가 고장났을 때
- CP: 데이터 불일치 피하기 위해 다른 서버에 쓰기 연산 중단
- CA: 낡은 데이터를 반환할 위험이 있더라도 계속 읽기 연산 허용
시스템 컴포넌트
- 키-값 저장소 구현에 사용될 핵심 컴포넌트 및 기술
- 데이터 파티션
- 데이터 다중화 replication
- 일관성 consistency
- 일관성 불일치 해소 inconsistency resolution
- 장애 처리
- 시스템 아키텍처 다이어그램
- 쓰기 경로 / 읽기 경로
데이터 파티션
- 데이터를 작은 파티션들로 분할한 다음 여러 대 서버에 저장
- 파티션 단위로 나눌 때 고려해야하는 것 (cf. 안정 해시)
- 데이터를 여러 서버에 고르게 분산할 수 있는가
- 노드가 추가, 삭제될 때 데이터의 이동을 최소화할 수 있는가
데이터 다중화
- 높은 가용성, 안정성을 확보하기 위해서는 데이터를 N개 서버에 비동기적으로 다중화 필요
- 해시 링 위에서 시계 방향으로 순회하면서 첫 N개 서버에 데이터 사본 보관
- 가상 노드라면? 물리 서버의 개수가 N보다 작아질 수 있음
- 물리 서버 중복 선택하지 않도록 해야 함
- 안정성을 담보하기 위해 데이터의 사본은 다른 센터의 서버에 보관
- 센터들은 고속 네트워크로 연결
데이터 일관성
- 적절한 동기화 필요
- Quorum Consensus(정족수 합) 프로토콜 사용 → read/write 연산 모두 일관성 보장
- N = 사본 개수
- W = write 연산에 대한 정족수
→ 성공한 것으로 간주되려면 적어도 W개의 서버로부터 쓰기 연산이 성공했다는 응답을 받아야 함
- R = read 연산에 대한 정족수
→ 성공한 것으로 간주되려면 적어도 R개의 서버로부터 응답을 받아야 함
- coordinator(중재자): 성공 응답 받음. 클라이언트-노드 간의 proxy 역할
- 요구되는 일관성 수준에 따라 W, R, N의 값을 조정
- R = 1, W = N: 빠른 읽기 연산에 최적화
- W = 1, R = N: 빠른 쓰기 연산에 최적화
- W + R > N: 강한 일관성이 보장 (보통 N = 3, W = R = 2)
- W + R <= N: 강한 일관성 보장 X
- 일관성 모델
- 키-값 저장소 설계 시 고려해야 할 중요한 요소
- 데이터 일관성의 수준을 결정
- 종류
- 강한 일관성: 모든 읽기 연산은 가장 최근에 갱신된 결과를 반환. out-of-date 데이터 X. 쓰기 연산이 완료되는 순간 모든 노드에 동기화가 보장.
- 약한 일관성: 가장 최근에 갱신된 결과를 반환하지 못할 수 있다.
- 최종 일관성 eventual consistency: 약한 일관성의 한 형태. 갱신 결과가 언젠간 모든 사본에 동기화되는 모델
- 강한 일관성을 달성하는 방법
- 모든 사본에 현재 write 연산의 결과가 반영될 때까지 해당 데이터에 대한 read/write 연산 금지
→ 고가용성 시스템에는 적합 X
- Dynamo, 카산드라: 최종 일관성 모델을 택하고 있음
- write 연산 병렬적으로 발생 시, 시스템에 저장된 값의 일관성이 깨질 수 있음
- 위 문제는 클라이언트에서 해결
→ 데이터의 버전 정보를 활용해 일관성이 깨진 데이터를 읽지 않도록
비 일관성 해소 기법: 데이터 버저닝
- 데이터 다중화 → 가용성 ⬆️, 사본 간 일관성 ⬇️
- 해결법: versioning, vector clock
- versioning
- 데이터를 변경할 때마다 해당 데이터의 새로운 버전을 만드는 것
- 각 버전의 데이터는 변경 불가능 immutable
- vector clock
- [서버 번호, 버전 카운터] 의 순서쌍을 데이터에 매단 것
- 어떤 것이 선행 버전인지, 다른 버전과 충돌이 있는지 판별하는데 사용
- [Si, vi] 가 있으면 vi 증가
그렇지 않으면, 새 항목 [Si, vi] 생성
충돌이 발생한 경우, 충돌 해소된 데이터 기록 [Si, vi + 1]
- 이전 버전 판단 방법
- 버전 Y에 포함된 모든 구성 요소의 값 >= 버전 X에 포함된 모든 구성요소 값
→ 버전 X가 버전 Y의 이전 버전
- 충돌 여부 판단 방법
- Y의 vector clock 구성요소 가운데 X의 vector clock 동일 서버 구성요소보다 작은 값을 갖는 것이 있는지 확인
- ex.
D([S0, 1], [s1, 2]) -충돌-D([S0, 2], [s1, 1])
- vector clock의 단점
- 충돌 감지 및 해소 로직이 클라이언트에 위임
→ 클라이언트 복잡성 ⬆️
- [서버, 버전] 의 순서쌍 개수가 굉장히 빨리 늘어남
→ threshold 설정 후, 오래된 순서쌍을 vector clock에서 제거하도록 해야 함
→ but, 버전 간 선후관계가 정확하게 결정될 수 없음
장애 처리
- failure detection → failture resolution
- 장애 감지
- 두 대 이상의 서버가 똑같이 서버 A의 장애를 보고 → 실제로 장애가 발생햇다고 간주
- 모든 노드 사이에 multicasting 채널 구축
- 서버 장애 감지하기 가장 손쉬운 방법
- 서버가 많으면 비효율적
- 분산형 장애 감지 솔루션이 효율적 (ex. gossip protocol)
- gossip protocol
- 각 노드는 membership list 유지
→ 각 memberId, heartbeatCounter 쌍의 목록
- 각 노드는 주기적으로 자신의 heartbeatCounter를 증가
- 각 노드는 무작위로 선정된 노드들에게 주기적으로 자신의 heartbeatCounter 목록을 전송
- 목록을 받은 노드는 membership list를 최신 값으로 갱신
- 어떤 멤버의 heartbeatCounter 값이 지정된 시간 동안 갱신되지 않으면 해당 멤버는 offline 상태인 것으로 간주 → 장애
- 일시적 장애 처리
- 장애를 감지한 시스템에 가용성을 보장하려면?
- 엄격한 정족수 접근법: read/wirte 연산 금지
→ 완화: sloppy 느슨한 정족수 접근법
- 느슨한 정족수 접근법
- 정족수 요구사항을 강제하는 대신, write 연산을 수행할 W개의 건강한 서버와 read 연산을 수행할 R개의 건강한 서버를 해시 링에서 고름
→ 장애 상태인 서버는 무시
- 장애 상태인 서버로 가는 요청을 다른 서버가 맡아 처리
- 그동안 발생한 변경사항은 해당 서버가 복구되었을 때 일괄 반영 → 데이터 일관성 보존
- 임시로 write 연산을 처리한 서버는 그에 대한 hint를 남겨둠
→ 단서 후 임시 위탁 기법 hinted handoff (일시적 장애 처리)
- 영구 장애 처리
- anti-entropy protocol
- 사본들을 비교하여 최신 버전으로 갱신하는 과정을 포함
- 사본 간의 일관성이 망가진 상태를 탐지
- 전송 데이터의 양을 줄이기 위해 merkle 트리 사용
- merkle tree
- hash tree
- 각 노드에 그 자식 노드들에 보관된 값의 해시, 또는 자식 노드들의 레이블로부터 계산된 해시값을 레이블로 붙여두는 트리
- 대규모 자료 구조의 내용을 효과적이면서도 보안상 안전한 방법으로 검증 가능
- 두 merkle tree 비교
- root 노드의 해시값을 비교하는 것으로 시작
- root 노드의 해시 값이 일치한다면 두 서버는 같은 데이터를 갖는 것
- 값이 다른 경우, 왼쪽 자식 노드의 해시 값을 비교, 그 다음으로 오른쪽 자식 노드
- 아래쪽으로 탐색하다보면 다른 데이터를 갖는 버킷 찾을 수 있음
→ 그 버킷들만 동기화
→ 동기화해야하는 데이터의 양: 존재하는 차이의 크기에 비례할 뿐. 데이터의 총량과는 무관해짐.
- 데이터 센터 장애 처리
- 정전, 네트워크 장애, 자연재해 등
- 데이터를 여러 데이터 센터에 다중화하는 것이 중요
시스템 아키텍처 다이어그램
- 주된 기능
- 클라이언트는 키-값 저장소가 제공하는 두 가지 단순한 API와 통신
get(key) put(key,value)
- 중재자(coordinator): 클라이언트에게 키-값 저장소에 대한 proxy 역할을 하는 노드
- 노드는 안정 해시의 해시 링 위에 분포
- 노드를 자동으로 추가, 삭제할 수 있도록 시스템은 완전히 분산된다
- 데이터는 여러 노드에 다중화
- 모든 노드가 같은 책임 → SPOF 존재 X
- 완전히 분산된 설계 채택했으니 위에 제시된 기능 전부 지원 필요
- 클라이언트 API, 데이터 충돌 해소, 다중화, 장애 감지, 장애 복구 매커니즘, 저장소 엔진, ...
- 쓰기 경로
- 쓰기 요청이 commit log 파일에 기록
- 데이터가 메모리 캐시에 기록
- 메모리 캐시가 가득차거나 임계치에 도달하면 데이터는 디스크에 있는 SSTable로 내보내기(Flush)
- SSTable: Sorted-String Table, <키,값>의 순서쌍을 정렬된 리스트 형태로 관리하는 테이블
- 읽기 경로
- 데이터가 메모리 캐시에 있는지 확인
- 없다면, SSTable에 찾는 키가 있는지 확인
- Bloom filter 검사
- 어떤 SStable에 키가 보관되어 있는지 확인
- SSTable에서 데이터 가져와서 클라이언트에게 반환
4. 요약
- 대규모 데이터 저장 → 안정 해시를 통해 서버들에 부하 분산
- 읽기 연산에 대한 높은 가용성 보장 → 데이터를 여러 데이터센터에 다중화
- 쓰기 연산에 대한 높은 가용성 보장 → 버저닝 및 벡터 시계를 사용한 충돌 해소
- 데이터 파티션 → 안정 해시
- 점진적 규모 확장성 → 안정 해시
- 다양성(heterogeneity) → 안정 해시
- 조절 가능한 데이터 일관성 → 정족수 합의
- 일시적 장애 처리 → 느슨한 정족수 프로토콜(sloppy quorum)과 단서 후 임시 위탁(hinted handoff)
- 영구적 장애 처리 → Merkle tree
- 데이터 센터 장애 대응 → 여러 데이터 센터에 걸친 데이터 다중화