개발

Anti-Caching A New Approach to Database Management System Architecture 논문 요약

우리로 2023. 8. 20. 10:38

너무 어려운 논문이다... DDIA 책을 읽다가 '레퍼런스도 챙겨보자!' 해서 읽은 논문인데 역시 논문은 너무 어렵더라. 진이 빠질대로 빠졌지만 지금 정리하지않으면 평생 안할거기 때문에 얼른 정리해본다

 

https://www.vldb.org/pvldb/vol6/p1942-debrabant.pdf

제목은 Anti-Caching: A New Approach to Database Management System Architecture 이다. Anti Caching 에 대해 다룬다.

 

우선 Database 와 DBMS 는 다르다. 흔히 얘기하는 MySQL, PostgreSQL, MongoDB 는 전부 DBMS 다. Database 를 관리해주는 시스템이다. DBMS 는 보통 접근 속도를 높이려고 Buffer pool 을 유지하는데 이 오버헤드가 상당하다. Buffer Pool 이 메모리에 있기 때문에 메모리가 가득찰 위험이 항상 있다. 메모리 --> 디스크로 이동시키는 Eviction 정책을 통해 메모리 용량을 관리하는데 이 정책을 유지하는 것도 리소스가 든다. 만약 버퍼풀이 가득찬다면 이 버퍼풀을 유지하는데만도 CPU 1/3 을 사용해야한다. 만약 내가 메모리에 모든 데이터를 올릴 수 있으면 당연히 Buffer pool 을 유지할 이유가 사라진다. 즉 Buffer pool 을 유지하는 데 필요한 오버헤드를 없앨 수 있다.

 

벤치마크상으로도 '당연히' 메모리만 이용한 DBMS 가 더 좋은 성능을 보인다. 하지만 이건 데이터가 메모리보다 적을 때 이야기고... 만약 데이터가 메모리보다 많아지면 어떡할까? 이 문제를 해결하려고 여러 시도가 있었다. 

논문에서 발췌

제일 왼쪽이 Buffer pool 을 사용한 모델이고 두번째가 분산 캐시를 사용하는 방법, 세번째가 이 논문에서 소개하는 Anti Cache 다. Anti Cache 는 '데이터가 메모리에 다 들어갈 수 없을 정도로 크지만 메모리를 Primary Storage 로 사용하겠다.' 는 컨셉이다. 보통 우리는 두 번째 컨셉과 같은 Two-Tier Architecture 를 사용한다. 즉 MySQL 과 같은 디스크 기반 DBMS 앞에 Memcached 같은 분산 캐시를 놔둔다. 이 방법은 두 가지 문제가 있다.

1. DBMS 버퍼풀과 메모리 양쪽에 데이터를 저장하기 때문에 저장공간을 많이 잡아먹는다.

2. 둘 사이의 싱크를 맞추는 로직이 꼭 필요하다. 

 

Anti caching 은 위와 같은 문제도 해결가능하다. 어찌보면 OS 의 가상메모리와 비슷한데, 가상 메모리와 비교해서 두 가지 장점이 있다.

1. Fine grained Eviction

사용하지 않는 Tuple 만 모아서 Eviction 시킬 수 있다. 반면 OS 의 가상메모리는 페이지 중 단 하나의 Tuple 만 자주 접근되더라도 페이지 전체가 계속 메모리에 남아있게된다. 

 

2. Non Blocking Fetches

OS에서 접근하려는 Page 가 메모리에 없으면 Page fault 가 발생한다. 디스크에서 데이터를 읽어와야하는데 이 때 OS 가 트랜잭션을 차단한다. 심지어 몇몇 DBMS 는 모든 트랜잭션의 동작을 중단시킨다. 가상 메모리 페이지가 메모리에 적재되면 다시 동작하는데 이 때 만약 접근하려는 페이지가 다양하다면, 그리고 그 페이지들이 메모리에 없다면 지속적으로 트랜잭션은 멈추게 된다. 예를 들어 Page 1, 3, 5 를 접근해야하는데 이 셋 모두 메모리에 없다면 

Page 1 접근 --> Page fault --> 트랜잭션 차단 --> Page 1 메모리 적재 --> 트랜잭션 재개 --> Page 3 접근 --> Page fault --> 트랜잭션 차단 --> Page 3 메모리 적재 --> 트랜잭션 재개 ...

Page 1 접근 --> Page fault --> 트랜잭션 차단 --> Page 1 메모리 적재 --> 트랜잭션 재개 --> 
Page 3 접근 --> Page fault --> 트랜잭션 차단 --> Page 3 메모리 적재 --> 트랜잭션 재개 ...

이런 식으로 진행된다. 

 

하지만 Anti Caching 은 트랜잭션을 잠시 중단하지만 다른 트랜잭션이 계속 동작할 수 있다. 또, 메모리에 없는 Block 을 접근하면 pre-pass 를 사용해서 이 transaction 안에 얼마나 많은 tuple 이 메모리에 없는지 파악해서 한번에 메모리에 적재한다. 연쇄적인 Page-Fault 를 막을 수 있다.  위와 같은 예시를 들면

Page 1 접근 --> Page fault --> 트랜잭션 중단 && pre-pass 발동 --> Page 1,3,5 메모리 적재
--> 트랜잭션 재개

이렇게 된다. 

 

Anti Cache 는 세 가지 컴포넌트로 구성되어있고 각 컴포넌트는 한번에 하나의 Transaction 만 접근할 수 있기 때문에 따로 Latch 가 필요하진 않다.

1. Block Table : 디스크에 상중하는 해시 테이블로서 evicted tuple block 을 가지고 있다.

----------------------------------------

2. Evicted Table : 메모리에 상주하며 evicted tuples: block ids 형태로 되어있다. 

3. LRU Chain : 메모리에 상주하며 각 테이블마다 Tuple 로 구성되어있다.

Evicted table 과 Block table

 

1. Block Table

- Block 의 헤더는 Block ID 와 이 Block 이 생성된 타임스탬프로 이루어져있다. 여기에 모여있는 튜플은 그 크기가 가장 앞에 있고, 데이터는 memory 형태로 직렬화되어있다. 

 

2. Evicted Table

- 디스크 Block 에 쓰인 Tuple 을 기록한다. Tuple 이 Block Table 의 어떤 Block 에 위치하는지 기록한다.

 

3. LRU Chain

- LRU 순서로 정렬되어있는 양방향 링크드 리스트이다. 만약 아주 자주 접근되는 테이블이 있으면 이 테이블에 evictable 라벨을 붙이지 않는다. 라벨이 붙지않은 테이블은 메모리에 상주한다.

 

구성 요소까지 봤으니 이제 가장 중요한 Eviction 을 어떻게 하는지에 대해 알아보자

우선 LRU Chain 의 머리 부분에서 Pop 을 해서 이 tuple 을 evicted block buffer 에 복사한다. 그 후 evicted table 에도 이 tuple 을 기록한다. 이로서 이 tuple 이 eviction 된 걸 알 수 있다. 모든 인덱스도 tuple 이 evicted table 에 기록된 위치를 가리키도록 한다. Evicted table 에 있는 tuple 은 header 부분에 evicted flag 가 있어서 이 tuple 이 eviction 된 걸 나타낸다. Block 이 가득찰 때까지 이 과정을 반복한다. 그 후 이 Block 을 디스크에 기록한다. 

 

이 과정은 쓰레드 하나만 동작하기 때문에 index 와 tuple 의 위치가 변경되지만 충돌은 발생하지 않고 만약 하드웨어 장애가 난다하더라도 충분히 복구할 수 있다. 

 

Block 조회 과정

(1) disk 에서 block 을 읽어오기위해 별도 쓰레드에서 non-blocking read  한다. 이렇게 읽어온 데이터를 query 에서 접근할 수 없는 별도 버퍼에 보관한다. 이 때 다른 트랜잭션에서 같은 데이터를 접근하더라도 마치 메모리에 없는 것처럼 처리한다. DIsk 에서 Block을 가져온다는 건 트랜잭션이 일시중단되었다는 걸 의미한다. 
(2) 데이터를 읽어온 후엔 transaction 을 rescheduled 하는데 이 과정 직전에 "Stop-and-Copy" 과정을 거친다. 해당 파티션에서 동작중인 모든 transaction 은 중단된다. 아까 buffer 에 있던 tuple 이 staging buffer 에서 regular table storage 로 병합된다. Evicted Table 에서 이 tuple 들을 삭제하고 테이블 인덱스가 이들을 가리키도록 한다. 

 

병합 방법은 아래 두 가지가 있다. 

 

Block merging

block 전체를 in memory 테이블에 병합한다 이 때 이 block 에서 조회된 tuple, 즉 요청된 Tuple 은 LRU Chain 의 끝에 넣고 그렇지 않은 tuple 은 LRU Chain 의 헤더에 넣어서 다음에 이 tuple 들이 먼저 eviction 되도록 한다. 예상하다시피 이건 필요한 tuple 이 하나인 경우에도 block 의 모든 tuple 을 병합해야하는 단점이 있다. 

 

Tuple Merging

Evicted Table 에 Tuple 에 대한 offset 이 있으니까 필요한 Tuple 만 Offset 을 활용해서 조회한다. 이 Tuple 을 In-Memory 에 Merge 하고 Block 의 나머지 튜플은 버린다. 그 후 Evicted Table 에서 Merge 된 tuple 에 대한 정보를 지운다. Evicted Table 에서만 정보를 제거했기 때문에 실제로 데이터는 메모리와 디스크, 양쪽에 있지만 접근은 In-Memory 쪽만 가능하다. 시간이 지날수록 Block 의 구멍이 커져서 lazy block compaction algorithm 을 추가했다. block 을 읽을 때 threshold 보다 구멍 크기가 크면 전체 block 을 머지한다.

Snapshots & Recovery

스냅샷을 뜨는 동안은 eviction 을 하지 않는다.

snapshot 용량을 줄이기위해 delta snapshots 을 한다. DBMS 는 단지 이전 snapshot 으로부터, 추가되거나 제거된 Block Table만 체크한다. 

Future Work

Larger-than-Memory Queries

Anti Caching 은 메모리보다 큰 DB 를 다룰 수 있게 해준다. 만약 엄청나게 많은 데이터를 조회해야할 땐 쓰기 작업과 어떻게 병행해야할까?
1. 전통적인 DB 처럼 쓰기 작업에 대해 Table Level Lock 을 걸고 쓰기 작업이 끝난 후 읽기를 수행한다.
2. historical mode 로 운영한다. 트랜잭션을 받을 때 타임스탬프값도 함께 받아서 이 시간 이후의 tuple 만 읽는다. 따라서 쓰기 작업의 overwrite 는 허용하지않는다. 단, before and after 이미지를 모두 유지해야한다.
3. dirty read 를 허용한다. 즉 읽기도중 변경된 tuple 도 그냥 다 읽는다.