정리표
데이터 시스템 — 데이터를 저장하고 다루는 소프트웨어
├ DBMS 저장해 두고, 원할 때 찾아 꺼낸다
│ ├ 데이터를 담는 형태 관계형(SQL) / 키-값 · 문서 · 그래프(NoSQL)
│ ├ 실행되는 형태 서버 / 라이브러리
│ └ 안의 한 층 스토리지 엔진
├ …
담는 형태와 실행 형태 두 축은 널리 쓰이는 구분이고, 맨 위 묶음과 배치는 이 글의 정리다. 합의된 단일 분류 체계는 없다.
서버 · 라이브러리 · 클라이언트는 모두 코드지만 같은 것이 아니다. 서버는 자기 프로세스로 따로 실행되고, 라이브러리와 클라이언트는 애플리케이션이 가져다 써서 애플리케이션과 같은 프로세스에서 실행된다. 그중 DBMS 는 서버와 라이브러리이고, 클라이언트는 아니다.
| 말 | 뜻 | 예 |
|---|---|---|
| DB | 규칙에 따라 모아 둔 데이터 자체 | /var/data/mydb 안의 파일들 |
| DBMS | 그 데이터를 두고 · 찾고 · 고치는 소프트웨어. 일상에서 "DB"라고 부르는 것은 대개 이쪽 | Redis, MySQL, LevelDB |
| SQL | 관계형 DB 에 무엇을 원하는지 적어 보내는 질의어. 이것을 쓰는 DBMS 를 관계형(RDBMS)이라 부른다 | SELECT * FROM seat WHERE id = 5 · MySQL, PostgreSQL, SQLite |
| NoSQL | 관계형 모델과 SQL 을 쓰지 않는 DB 들의 묶음. "아닌 것"으로 만든 말이라 안이 서로 다르다 | Redis, LevelDB, MongoDB, Cassandra |
| 서버 | DBMS 가 독립한 프로세스로 실행된다. 애플리케이션은 네트워크로 명령을 보낸다 | Redis, MySQL, PostgreSQL |
| 라이브러리 | DBMS 를 애플리케이션이 가져다 써서, 애플리케이션과 같은 프로세스에서 실행된다. 빌드할 때 애플리케이션 코드에 함께 묶는 것을 링크라고 한다. 애플리케이션은 함수를 호출한다. 쓰인 언어에 묶여서, 다른 언어에서 쓰려면 그 언어와 이어 주는 코드(바인딩)가 따로 필요하다 | LevelDB, RocksDB, SQLite |
| 클라이언트 | DBMS 가 아니다. 라이브러리와 같은 방식으로 애플리케이션 안에서 실행되지만, 하는 일은 서버에 명령을 보내고 응답을 받아 오는 것뿐이다. 서버가 정한 통신 형식만 맞추면 되므로 언어마다 따로 구현되어 있고, 애플리케이션은 그중 하나를 가져다 쓴다. MySQL 쪽에서는 드라이버라고 부른다 | Redis 클라이언트, Kafka 프로듀서 · 컨슈머, MySQL 드라이버 |
| 스토리지 엔진 | DBMS 안에서 데이터를 디스크에 어떻게 놓고 어떻게 찾을지를 맡는 층. 떼었다 붙일 수 있다 | MySQL 의 InnoDB, MyRocks |
Kafka Streams 는 클라이언트이면서 안에 라이브러리를 품는다. 브로커와 주고받는 것은 클라이언트로서 하는 일이고, 처리 중간 상태는 자기 디스크에 RocksDB 로 둔다.
DBMS
Database Management System. 데이터를 두고 · 찾고 · 고치는 일을 맡는 소프트웨어.
담는 데이터의 형태에 따라 갈린다.
| 갈래 | 담는 것 | 예 |
|---|---|---|
| 관계형(RDBMS) | 행과 열의 표, 표 사이를 키로 이음 | MySQL, PostgreSQL, Oracle, SQLite |
| 키-값 | 키 하나에 값 하나 | Redis, LevelDB, RocksDB |
| 문서 | JSON 같은 문서 하나가 한 건 | MongoDB |
| 와이드 컬럼 | 행마다 열 구성이 다를 수 있는 표 | Cassandra, HBase |
| 그래프 | 점과 점 사이의 선 | Neo4j |
| 시계열 | 시각이 붙은 측정값 | InfluxDB |
여기서 보는 넷 가운데 셋이 이 표에 든다. Redis 와 LevelDB 는 키-값, MySQL 은 관계형이다. Kafka 는 DBMS 가 아니라서 없다.
Redis
데이터의 원본을 메모리에 두는 키-값 스토어. 키 하나에 붙는 값이 바이트열만이 아니라 리스트 · 해시 · 정렬 집합 같은 자료구조.
| 축 | Redis |
|---|---|
| 실행 형태 | 독립 서버 프로세스. 애플리케이션 안에서 함께 실행되는 Redis 클라이언트(Java 는 Lettuce · Jedis, Go 는 go-redis, Python 은 redis-py)가 명령을 네트워크로 보내고 응답을 받아 온다 — SET user:1 subin |
| 원본 위치 | 메모리. 디스크는 재시작 복구용 사본 |
| 데이터 모델 | 키 → 자료구조. String · List · Hash · Set · Sorted Set, 형태마다 전용 명령 |
| 저장 구조 | 디스크 배치를 정하는 구조가 없다. 원본이 메모리에 있어 디스크에서 자리를 찾아갈 일이 없다. 사본은 메모리 전체를 파일 하나로 내리거나(RDB), 쓰기 명령을 파일 끝에 덧붙인다(AOF) |
| 읽는 방법 | 키를 지정하고 그 형태 전용 명령으로. 범위 읽기는 Sorted Set 처럼 형태가 지원할 때만 |
| 쓰기 확정 | 메모리까지. 메모리에 반영되면 응답하고 디스크 확정은 뒤로 미룬다 → 죽으면 최근 쓰기가 사라질 수 있다 |
자세한 것은 Data Systems / Redis 에 따로 정리한다.
MySQL
데이터를 행과 열의 표로 담고 SQL 로 다루는 관계형 DBMS. 표를 디스크에 어떻게 놓을지는 테이블마다 지정하는 스토리지 엔진이 결정.
| 축 | MySQL |
|---|---|
| 실행 형태 | 독립 서버 프로세스. 애플리케이션 안에서 함께 실행되는 드라이버(클라이언트)가 SQL 문을 네트워크로 보내고 결과를 받아 온다 — Java 는 Connector/J, Go 는 go-sql-driver/mysql |
| 원본 위치 | 디스크. 메모리(버퍼 풀)에는 최근 읽고 쓴 디스크 페이지를 올려 둔다 |
| 데이터 모델 | 표 — 행과 열. 열마다 타입이 미리 정해지고(스키마), 표와 표를 키로 잇는다 |
| 저장 구조 | 스토리지 엔진이 결정한다. 기본값 InnoDB 는 B-tree 를 쓰고, 값을 고칠 때 그 값이 있는 자리를 찾아가 고친다 |
| 읽는 방법 | SQL 로 원하는 것을 적어 보내면 서버가 경로를 정한다. 인덱스가 있으면 B+tree 를 타고, 없으면 표 전체를 훑는다 |
| 쓰기 확정 | 디스크까지. 커밋할 때 변경을 redo log 에 적어 디스크에 확정하고, 고친 페이지는 나중에 제자리에 덮어쓴다 |
테이블과 SQL
MySQL 은 데이터를 테이블에 담는다. 행과 열로 된 표다. 열의 이름과 타입은 만들 때 정하고, 행은 그 뒤로 늘어난다.
seat 테이블
┌────┬──────┬────────┐
│ id │ name │ status │ 열. 이름과 타입이 미리 정해져 있다
├────┼──────┼────────┤
│ 1 │ A1 │ 빈자리 │ 행. 한 건
│ 2 │ A2 │ 예매됨 │
└────┴──────┴────────┘
SQL 은 그 표에서 무엇을 꺼내고 넣을지 적어 보내는 언어다.
드라이버(클라이언트)가 SQL 을 서버로 보낸다. 서버는 그것을 해석해 어떤 데이터를 어느 순서로 가져올지 정한다. 파일에서 꺼내고 넣는 일은 스토리지 엔진이 맡는다.
테이블은 파일이다
테이블은 껐다 켜도 남아 있어야 한다. 그래서 파일로 저장된다. MySQL 에서 CREATE DATABASE 로 만드는 단위가 디렉터리 하나이고, 테이블이 그 안의 파일이다.
/var/lib/mysql/ MySQL 데이터 디렉터리
├── cgv/ 데이터베이스 하나
│ ├── seat.ibd 테이블 하나
│ └── booking.ibd
└── ib_logfile0 복구용 로그
$ ls -l /var/lib/mysql/cgv/
-rw-r----- 1 mysql mysql 114688 Sep 23 15:02 seat.ibd 숫자는 파일 크기
그런데 디스크는 파일을 모른다. 디스크가 받는 명령은 "몇 번 블록을 읽어라", "몇 번 블록에 써라" 뿐이다. 파일은 리눅스가 블록들을 묶어 만든 개념이고, 그 관리 체계가 파일시스템이다.
프로그램도 디스크를 직접 못 건드린다. 커널에 요청해야 하고, 그 요청이 시스템 콜이다.
MySQL 서버 프로세스
└ 스토리지 엔진 "seat.ibd 의 16384번째 위치에 이 16KiB 를 써라"
│ 시스템 콜 — open() · pwrite() · fsync()
▼
리눅스 커널
├ 파일시스템 (ext4) 파일 이름과 위치를 블록 번호로 바꾼다
└ 블록 계층 · 드라이버 그 블록을 디스크에 요청한다
▼
디스크 (하드웨어) 51234번 블록에 그 바이트들을 쓴다
스토리지 엔진
MySQL 은 파일 다루는 일을 자기 코드에 넣지 않았다. 함수 목록을 먼저 정해 두고, 그것을 구현한 코드에게 시킨다. 그 구현이 스토리지 엔진이다.
write_row(행) 이 행 하나 파일에 넣어라
index_read(id) 이 id 인 행을 찾아 줘라
rnd_next() 다음 행 하나 줘라
서버는 이 함수만 부른다. 엔진이 안에서 무엇을 하는지는 묻지 않는다. 구현은 여럿이고 테이블마다 지정한다.
CREATE TABLE seat (id INT, name VARCHAR(20)) ENGINE=InnoDB;
CREATE TABLE log (id INT, line VARCHAR(200)) ENGINE=ROCKSDB; -- MyRocks 의 엔진 이름
엔진이 할 일이 생기는 자리는 여기다. 파일은 표가 아니다. 바이트가 0번부터 늘어선 줄이고, 행과 열이라는 구분이 없다.
seat.ibd
오프셋 0 1 2 3 4 5 6 7 8 9 10 11 ... 숫자는 바이트 번호다
[00 00 00 01][02 41 31][03 ... ]
└─ id = 1 ─┘└─ 'A1' ─┘└─ '빈자리' ─┘
└──────────── 행 하나 ────────────┘ 수십 바이트
그래서 엔진이 결정하는 것은 둘이다.
- 쓸 때 — 이 행의 바이트들을 파일의 몇 번째 위치에 쓸지
- 읽을 때 —
id=5인 행을 요청받았을 때 몇 번째 위치를 읽을지
둘은 따로 정할 수 없다. 쓸 때 놓은 자리가 읽을 때 볼 자리를 정한다.
그 방식에 이름이 있다. InnoDB 가 쓰는 것이 B-tree(정확히는 행을 맨 아래 층에만 두는 B+tree), MyRocks 가 쓰는 것이 LSM-tree 다. 갈리는 지점은 하나다 — 새 행이 들어올 때 자리를 찾아가느냐.
B-tree — 자리를 정해 놓고 쓴다
행을 id 순으로 늘어놓는다. 그러면 중간에 끼워 넣을 때마다 뒤를 밀어야 하므로, 파일을 16KiB 씩 잘라 조각 안에서만 순서를 지킨다. 그 조각이 페이지다.
어느 페이지인지 알아내려고 페이지를 하나 더 둔다. 거기에는 행 대신 id 범위 → 오프셋 만 적는다.
seat.ibd — 파일은 일렬이다. 16KiB 씩 잘라 쓴다
메모리 (버퍼 풀)
┌────────────────┐ ┌────────────────┐ 읽어 온 페이지의 사본
│ 오프셋 0 사본 │ │ 오프셋 32768 사본│ 원본은 파일에 있다
└────────────────┘ └────────────────┘
▲ 사본이 없을 때만 │ 고친 페이지는 모아 두었다가
│ 파일에서 읽어 올린다 │ 파일에 내려쓴다
────────┼───────────────────────▼──────────────────────────────
파일 (seat.ibd)
오프셋 0 16384 32768 49152
┌───────────────┬───────────────┬───────────────┬───────────────┐
│ 루트 노드 │ 리프 노드 │ 리프 노드 │ 리프 노드 │
│ 행을 담지 않음 │ id 1, 2, 3 │ id 4, 5, 6 │ id 7, 8, 9 │
└───────────────┴───────────────┴───────────────┴───────────────┘
루트 노드 안 리프 노드 안 — 오프셋 16384 에서 32767 까지
id 1-3 → 오프셋 16384 페이지 머리 16384-16500 관리 정보
id 4-6 → 오프셋 32768 id 1 (1,A1,빈자리) 16500-16540 행 하나가 약 40바이트
id 7-9 → 오프셋 49152 id 2 (2,A2,예매됨) 16540-16580
id 3 (3,A3,빈자리) 16580-16620
오프셋만 적혀 있다 빈 공간 16620-32767 수백 행이 더 들어간다
행을 담는 페이지가 리프 노드, 오프셋만 적힌 페이지가 내부 노드다. 그중 맨 위가 루트 노드다. 행이 적을 때는 위 그림처럼 루트 하나가 리프를 바로 가리키고, 행이 늘면 그 사이에 내부 노드 층이 생긴다. 파일 자체는 일렬이고, 트리 모양은 페이지 안에 적힌 오프셋이 만든다.
읽든 쓰든 버퍼 풀을 먼저 본다. 사본이 있으면 디스크를 건드리지 않는다.
읽기 id=5 — 루트를 읽으면 4~6 은 32768 이라고 적혀 있다. 32768 을 읽으면 그 안에 행 5 가 있다.
쓰기 id=5 의 값 변경 — 같은 길로 32768 까지 간다. 그 16KiB 를 메모리에 올리고 5 만 바꾼다. 커밋하면 변경 내역을 redo log 에 적어 확정하고, 페이지는 나중에 내려쓴다.
행 추가 — 자리가 있으면 그 페이지만 고친다. 꽉 차면 페이지를 둘로 나누고 내부 노드에 줄을 추가한다.
행 하나를 바꾸는데 16KiB 를 통째로 다루는 이유는 둘이다. CPU 는 메모리에 올라온 것만 만질 수 있어서 고치려면 먼저 읽어 와야 하고, 디스크는 바이트 하나를 따로 주고받지 못해 덩어리로만 움직인다. 그 덩어리를 InnoDB 가 16KiB 로 정해 두었다.
B-tree — 읽기는 몇 페이지로 끝나고, 쓰기는 16KiB 를 내보낸다
읽기는 루트에서 리프까지 내려가면 끝난다. 행이 1억 개여도 거치는 페이지 수는 몇 개다. 위쪽 노드는 모든 조회가 지나가 버퍼 풀에 남으므로, 디스크까지 가는 것은 대개 리프 하나다.
쓰기는 그 읽기를 먼저 하고 하나를 더 한다. 루트와 리프를 읽어 메모리에 올리는 데까지는 읽기와 같고, 값을 고친 뒤 그 16KiB 를 디스크로 내보낸다. 40바이트짜리 행 하나를 고쳐도 나가는 것은 페이지 하나다.
나가는 페이지가 어디에 있느냐에 따라 걸리는 시간이 달라진다. 떨어진 자리를 오가는 접근을 랜덤, 한 자리에서 이어 가는 접근을 순차라고 한다. 디스크는 순차 쪽이 빠르다.
id 가 흩어져 들어오면 건드리는 페이지도 흩어진다. 같은 페이지를 다시 건드리지 않아 버퍼 풀에서 모아 쓸 수 없고, 수정 한 번마다 16KiB 가 그대로 나간다. id 를 UUID 같은 무작위 값으로 잡으면 새 행마다 다른 페이지로 가므로 이 상태가 된다.
보조 인덱스가 있으면 나가는 페이지가 는다. 인덱스마다 트리가 따로 있어서, 행 하나를 넣으면 그 트리에도 항목이 들어간다.
name 으로 인덱스를 걸어 두면 id 쪽은 파일 끝으로 이어 가더라도 name 쪽은 값이 흩어져 있어 그 트리에는 랜덤 쓰기가 생긴다.
B-tree 가 늘 랜덤으로 움직이는 것은 아니다. 무엇을 하느냐에 따라 갈린다.
| 하는 일 | 패턴 |
|---|---|
단건 읽기 — WHERE id = 5 |
랜덤 |
범위 읽기 — WHERE id BETWEEN 10 AND 100 |
순차. 리프끼리 이어져 있어 옆으로 넘어간다 |
전체 읽기 — SELECT * |
순차. 리프를 처음부터 끝까지 읽는다 |
id 를 1씩 늘려 넣는 삽입 |
순차. 새 행이 늘 마지막 페이지로 간다 |
무작위 id 삽입 · 흩어진 행 수정 |
랜덤 |
범위 읽기와 전체 읽기도 시간이 지나면 순차가 아니게 된다. 페이지 분할이 일어나면 새 페이지가 파일 끝에 생기므로 id 순서와 오프셋 순서가 어긋난다. 오래 쓴 테이블은 id 순으로 이어 읽어도 디스크에서는 흩어진 자리를 오간다. 이것을 단편화라고 한다.
LSM-tree — 자리를 정하지 않고 쓴다
들어온 값을 메모리에 모은다. 메모리 안에서는 id 순으로 정렬해 둔다. 꽉 차면 그 상태 그대로 파일 하나로 내려쓰고, 그 파일은 다시 고치지 않는다.
LevelDB · MyRocks(RocksDB)
메모리
┌──────────────────────────────┐
│ MemTable │ 아직 파일에 없는 최신 쓰기
│ id 4, 8 │ 사본이 아니라 원본이다
└──────────────────────────────┘
│ flush — 꽉 차면 정렬된 그대로 파일 하나로 내려쓴다
────────▼─────────────────────────────────────────────────────
파일 (SSTable) — 한 번 쓰면 고치지 않는다
↑ 최신
000009.ldb id 3, 6 id 3 의 유효한 값이 여기 있다
000007.ldb id 2, 4, 7
000005.ldb id 1, 3, 5, 9 여기 있는 id 3 은 옛 값이다
↓ 오래됨
읽을 때는 MemTable → 000009 → 000007 → 000005 순으로 뒤진다
먼저 나오는 값이 최신 값이다
│ compaction — 세 파일을 앞에서부터 읽어 하나로 병합한다
▼
000012.ldb id 1, 2, 3, 4, 5, 6, 7, 9 새로 만든다
다 쓴 뒤 000005 · 000007 · 000009 를 지운다
메모리 쪽이 MemTable, 내려쓴 파일이 SSTable 이다. 메모리를 파일로 내리는 것이 flush, 쌓인 파일을 합치는 것이 compaction 이다.
기존 파일을 고치지 않고 새 파일을 만드는 이유는 셋이다.
- 있던 파일을 고치려면 그 자리를 찾아가야 한다. 새 파일에 앞에서부터 쓰면 그 탐색이 없다.
- 새 파일을 다 쓴 뒤에 옛 파일을 지우므로, 중간에 죽어도 잃는 것이 없다.
- 누가 그 파일을 읽고 있어도 상관없다. 읽는 쪽이 보는 파일은 그대로 남아 있다.
000005.ldb id 1, 3, 5, 9 가 든 파일
오프셋 0 4096 8192 12288 16384
┌───────────┬───────────┬───────────┬───────────┬────────┐
│ 데이터 │ 데이터 │ 데이터 │ 인덱스 │ 푸터 │
│ 블록 1 │ 블록 2 │ 블록 3 │ 블록 │ │
│ id 1, 3 │ id 5 │ id 9 │ │ │
└───────────┴───────────┴───────────┴───────────┴────────┘
기본 4KiB 씩 블록마다 인덱스 블록이
블록 안에서 id 순 정렬 마지막 id 와 어느 오프셋에
그 블록 오프셋 있는지
파일 안도 오프셋으로 찾아간다. id=5 라면 푸터를 읽어 인덱스 블록 위치를 알아내고, 인덱스 블록을 읽어 그 id 가 든 데이터 블록 오프셋을 알아낸 뒤, 그 블록을 읽는다.
flush 는 언제 — MemTable 이 정해진 크기를 넘으면 한다. LevelDB 기본값은 4MB 다. 내려쓰는 동안에도 쓰기는 들어오므로 새 MemTable 을 하나 만들어 그쪽으로 받는다.
compaction 은 언제 — flush 로 갓 만들어진 파일이 일정 개수를 넘으면 한다. LevelDB 기본값은 4개다. 합친 결과는 아래 단계로 내려가고, 단계마다 크기 상한이 있어 그것을 넘으면 또 아래로 내려간다. 이 단계를 레벨이라고 부른다 — 이름이 LevelDB 인 이유다.
읽기 id=5 — MemTable 을 보고, 없으면 최신 파일부터 내려간다. 먼저 나오는 값이 답이다.
쓰기 — MemTable 에 넣으면 끝난다. 값을 바꿔도 이전 파일을 건드리지 않고, 지울 때도 지웠다는 표시를 새로 쓴다. MemTable 은 전원이 꺼지면 비므로, 넣기 전에 같은 내용을 로그 파일에 먼저 적는다. 죽으면 그 로그를 다시 읽어 MemTable 을 복원한다.
LSM-tree — 쓰기는 탐색이 없고, 읽기는 파일을 여럿 연다
쓰기는 어디에 쓸지 정하지 않는다. MemTable 에 넣으면 끝이고, 파일로 나갈 때는 처음부터 끝까지 이어서 쓴다. 쓸 자리가 늘 파일 끝이라 순차 쓰기다. 값을 고치는 것도 새로 넣는 것과 같아서, UPDATE 가 많아도 건드리는 자리가 흩어지지 않는다.
대신 디스크로 나가는 총량이 는다. 값 하나가 파일로 내려간 뒤 compaction 에 참여할 때마다 다시 쓰이므로, 같은 값이 여러 번 디스크로 나간다.
읽기는 두 가지 일을 한다.
- 열어 볼 곳이 파일 수만큼 있다. MemTable 에 없으면 최신 파일부터 내려가며 찾는다. 파일마다 다른 자리를 보므로 랜덤 읽기이고, 파일이 늘수록 횟수도 는다.
- 찾은 값이 최신인지 가린다. 같은
id가 여러 파일에 들어 있을 수 있어, 먼저 나온 것을 답으로 삼는다.
compaction 자체는 파일을 앞에서부터 읽어 새 파일에 앞에서부터 쓰므로 순차 읽기와 순차 쓰기다. 걸리는 것은 패턴이 아니라 양이다.
읽기와 쓰기에서 둘이 갈리는 지점
| B-tree (InnoDB) | LSM-tree (LevelDB · RocksDB) | |
|---|---|---|
| 쓸 때 하는 일 | 그 id 가 들어갈 페이지를 찾아 읽고, 고쳐서 내보낸다 |
MemTable 에 넣는다. 자리를 정하지 않는다 |
| 쓰기 패턴 | 자리가 흩어지면 랜덤 | 늘 순차 |
| 한 값이 디스크로 나가는 횟수 | 페이지 한 번. redo log 에 변경 내역이 따로 나간다 | flush 한 번에 compaction 마다 한 번씩. 로그 파일에도 따로 나간다 |
| 읽을 때 여는 곳 | 루트에서 리프까지 몇 페이지 | MemTable 과 파일 여럿 |
| 최신 값 판별 | 필요 없다. 그 자리에 있는 것이 최신이다 | 필요하다. 먼저 나온 것이 최신이다 |
| 시간이 지나면 | 페이지 분할로 단편화된다 | 파일이 쌓여 열어 볼 곳이 는다 |
쓸 때 B-tree 가 하는 일을 LSM-tree 는 하지 않고, 읽을 때 LSM-tree 가 하는 일을 B-tree 는 하지 않는다.
각자 무엇으로 디스크 접근을 줄이는가
양쪽 다 한 번 읽은 것을 메모리에 들고 있다가, 다음에는 디스크를 건드리지 않는다. 무엇을 담느냐와 무엇이 줄어드느냐가 다르다.
| 장치 | 담는 것 | 단위 | 줄어드는 접근 |
|---|---|---|---|
| InnoDB — 버퍼 풀 | 읽어 온 페이지 | 16KiB | 루트에서 리프까지 내려가며 읽는 것 |
| LevelDB · RocksDB — 블록 캐시 | 읽어 온 데이터 블록 | 약 4KiB | 파일 안에서 블록을 읽는 것 |
| LevelDB · RocksDB — 테이블 캐시 | 파일 핸들과 인덱스 블록 | 파일 하나 | 파일을 열고 인덱스 블록을 읽는 것 |
버퍼 풀은 쓰기도 줄인다. 고친 페이지를 모아 두었다가 한 번에 내려쓰므로, 같은 페이지를 여러 번 고쳐도 디스크로 나가는 것은 한 번이다.
MemTable 은 이 표에 들어가지 않는다. 캐시는 파일에 있는 것의 사본이지만, MemTable 은 아직 파일에 없는 원본이다.
LSM-tree 쪽에는 장치가 둘 더 있다. 읽을 때 여는 파일 수를 줄이는 것들이다.
블룸 필터 — 파일마다 하나씩 둔다. 그 id 가 없는 파일은 열지 않고 건너뛴다. 없다는 판정은 확실하고 있다는 판정은 틀릴 수 있어, 있다고 나올 때만 파일을 연다.
compaction — 파일 수 자체를 줄인다. 캐시와 블룸 필터는 없어도 동작하지만, compaction 은 없으면 LSM-tree 가 멈춘다. 열어 볼 파일이 끝없이 늘고, 지웠다고 표시한 데이터도 계속 남아 디스크가 회수되지 않는다.
이 구조를 구현해 둔 것이 LevelDB
flush 4MB, compaction 4개, 블록 4KiB, 블록 캐시와 테이블 캐시, 블룸 필터 — 지금까지 적은 수치와 장치는 전부 LevelDB 의 것이다. LSM-tree 는 구조의 이름이고, LevelDB 는 그 구조를 코드로 만들어 둔 라이브러리다.
LevelDB 2011, Google LSM-tree 로 디스크를 관리하는 라이브러리
│ fork
RocksDB 2012, Facebook 같은 구조에 서버에서 쓸 것을 더했다
│ MySQL 이 정해 둔 함수 목록에 맞춰 감싼다
MyRocks MySQL 의 스토리지 엔진 자리에 들어간다
RocksDB 는 LevelDB 를 fork 해 만들었다. 구조는 그대로이고 달라진 것은 이렇다.
| LevelDB | RocksDB | |
|---|---|---|
| compaction 을 도는 스레드 | 하나 | 여럿 |
| compaction 방식 | 레벨식 하나 | 레벨식 · 크기식(universal) · 기간식(FIFO) 중 선택 |
| 키 공간 | 디렉터리 하나에 하나 | 컬럼 패밀리 — 한 디렉터리 안에서 키 공간을 나누고 각각 따로 설정 |
| 값 갱신 | Put 으로 덮어쓴다 |
Merge — 읽지 않고 "여기에 이만큼 더해라"를 남겨 두고 읽을 때 적용한다 |
MyRocks — RocksDB 를 MySQL 의 엔진 자리에 넣은 것
LevelDB 는 처음부터 스토리지 엔진 역할만 하는 라이브러리로 만들어졌다. MySQL 에서 떼어 낸 것이 아니다. 하는 일이 MySQL 의 스토리지 엔진과 같을 뿐이다 — 파일 어디에 쓸지 정하고, 찾아 오고, 쌓인 것을 정리한다.
다른 점은 위에 아무것도 없다는 것이다. MySQL 이라면 서버가 SQL 을 해석해 write_row 를 불러 주지만, LevelDB 와 RocksDB 에는 그 서버가 없다. 애플리케이션이 Put 과 Get 을 직접 부른다.
MyRocks 는 그 빈자리에 MySQL 을 놓은 것이다. SQL 의 행을 키-값으로 바꿔 RocksDB 에 넘긴다.
seat 행 (5, 'A5', '예매됨')
│ MyRocks 가 바꾼다
▼
키 테이블 번호 + 기본 키 5
값 A5, 예매됨
보조 인덱스도 따로 키-값 쌍이 된다. 표로 담은 데이터를 키-값으로 바꿔 넣을 수 있다는 뜻이고, 그래서 담는 형태(표 / 키-값)와 저장 구조(B-tree / LSM-tree)는 서로 묶이지 않는다. ENGINE=ROCKSDB 테이블은 같은 SQL 을 받으면서 InnoDB 의 버퍼 풀이 아니라 RocksDB 의 블록 캐시와 테이블 캐시를 쓴다.
LevelDB
서버 없이 애플리케이션 안에서 함수 호출로 쓰는 임베디드 키-값 스토어. 디렉터리 하나를 받아 그 안의 파일들을 LSM-tree 로 관리하는 스토리지 엔진.
디렉터리를 열어 보면 파일이 그대로 보인다.
$ ls /var/data/mydb/
000005.ldb 000007.ldb 000009.ldb 000010.log MANIFEST-000001 CURRENT LOG LOCK
.ldb 하나하나가 flush 로 만들어진 SSTable 이다. 000010.log 는 지금 MemTable 에 든 것을 되살리기 위한 로그이고, 대문자 LOG 는 사람이 읽는 기록이라 이름만 비슷하고 하는 일이 다르다.
| 축 | LevelDB |
|---|---|
| 실행 형태 | 라이브러리. 애플리케이션과 같은 프로세스에서 실행되고, 네트워크가 아니라 함수 호출로 부른다. 한 번에 한 프로세스만 그 디렉터리를 연다 |
| 원본 위치 | 디스크 — 디렉터리 하나 안의 파일들. 메모리에는 아직 파일로 내려가지 않은 최근 쓰기와 캐시만 있다 |
| 데이터 모델 | 키-값. 키와 값 둘 다 바이트열이고, 키는 정렬된 순서로 저장된다 |
| 저장 구조 | LSM-tree. 있던 자리를 고치지 않고 새로 쓰고, 파일이 쌓이면 compaction 으로 합친다 |
| 읽는 방법 | 키를 지정하거나 정렬 순서를 따라 범위를 훑는다. 질의어도 실행 계획도 없다 |
| 쓰기 확정 | OS 까지. 로그 파일에 쓰지만 기본값은 디스크 확정을 미룬다. 쓸 때 sync 를 주면 매번 디스크까지 확정한다 |
자세한 것은 Data Systems / LevelDB 에 따로 정리한다.
Kafka
레코드를 파티션마다 순서대로 쌓아 두고, 읽는 쪽이 어디까지 읽었는지 기억하며 가져가는 서버. DBMS 로 분류되지 않는다.
| 축 | Kafka |
|---|---|
| 실행 형태 | 독립 서버 프로세스(브로커). 애플리케이션 안에서 함께 실행되는 프로듀서 · 컨슈머 클라이언트가 네트워크로 주고받는다 |
| 원본 위치 | 디스크 — 파티션마다 세그먼트 파일 |
| 데이터 모델 | 로그. 순서가 있는 레코드의 줄이다. 키-값도 표도 아니다 |
| 저장 구조 | 파일 끝에 덧붙이기만 하고 합치지 않는다. 보존 기간이 지나면 오래된 파일을 통째로 지운다 |
| 읽는 방법 | 오프셋(몇 번째인지)을 지정해 그 지점부터 순서대로 읽는다. 키로 찾는 수단이 없다 |
| 쓰기 확정 | 설정으로 고른다. 몇 대의 브로커가 받아야 성공으로 볼지 정하고, 디스크 확정은 기본적으로 OS 에 맡긴다 |
쓰기만 쌓이고 고치지 않는 데이터 — 로그, 이벤트, 이력 — 를 관계형 DB 에 두면 디스크로 나가는 양이 는다. 기본 키를 1씩 늘려 넣으면 행 자체는 파일 끝으로 가지만, 인덱스가 걸려 있으면 그 트리에도 같이 써야 한다. 그리고 치우는 수단이 행 단위 DELETE 뿐이라 오래된 것을 지우는 일도 쓰기가 된다.
Kafka 는 그런 데이터를 받는 자리다. 파티션 끝에 덧붙이기만 하고 합치지 않는다. 보존 기간이 지나면 행 단위가 아니라 오래된 파일을 통째로 지운다.
자세한 것은 Data Systems / Kafka 에 따로 정리한다.
여기까지가 소프트웨어가 고른 것이다. 원본을 메모리에 둘지 디스크에 둘지, 디스크에 둔다면 자리를 찾아가 쓸지 끝에 덧붙일지, 쓰기를 어디까지 확정하고 성공이라고 할지.
이 선택들의 근거는 전부 하드웨어의 성질에 있다. 순차 쓰기와 랜덤 쓰기의 속도가 다른 것, 메모리와 디스크의 속도가 벌어지는 것, fsync 가 느린 것.