트랜잭션 관리: SQLite에서 Pager가 데이터베이스가 되는 경우
Source: Dev.to
Hello, I’m Maneshwar. I’m working on FreeDevTools online, currently building “one place for all dev tools, cheat codes, and TLDRs” — a free, open‑source hub where developers can quickly find and use tools without the hassle of searching all over the internet.
Up to now, we’ve treated the pager as a cache manager, a state machine, and a disciplined gatekeeper for disk I/O.
Today’s learning makes one thing explicit:
The pager is the transaction manager in SQLite.
Locks, journals, cache state, savepoints—all of them are coordinated here.
The lock manager may perform locking at the OS level, but the pager decides when, how, and in what mode those locks are taken and released.
SQLite follows strict two‑phase locking, which gives it serializable behavior even though everything is file‑based. As with any serious DBMS, its transaction management splits cleanly into two parts:
- Normal processing
- Recovery processing
In this post we stay firmly in normal processing, the path taken when things go right.
Source: …
Normal Processing: Transactions as a Controlled Flow
Normal processing은 일상적인 실행 중에 일어나는 일입니다: 페이지를 읽고, 수정하고, 작업을 커밋하고, 문장을 롤백하고, 저장점을 관리합니다.
Pager는 이 모든 작업을 조율하면서 백그라운드에서 캐시 압력을 조용히 관리합니다.
Important: None of this logic lives in the tree module.
The tree module asks for pages and mutates memory. The pager turns those requests into safe, recoverable operations.
그 흐름을 살펴보겠습니다.
Read Operations: Entering the Transaction World
페이지와의 모든 상호작용은 같은 방식으로 시작됩니다:
sqlite3PagerGet(page_number);
- 이 호출은 페이지가 아직 데이터베이스 파일에 존재하지 않더라도 반드시 필요합니다. 페이지가 현재 파일 크기를 초과하면, pager는 논리적으로 해당 페이지를 생성합니다.
- 이 호출의 첫 번째 책임은 잠금(locking) 입니다.
- 현재 잠금이 없거나 더 약한 잠금만 보유하고 있다면, pager는 데이터베이스 파일에 공유 잠금(shared lock) 을 획득하려 시도합니다.
- 다른 트랜잭션이 호환되지 않는 잠금을 보유하고 있어 획득하지 못하면, 읽기는
SQLITE_BUSY로 실패합니다.
- 공유 잠금이 확보되면, pager는 캐시 읽기를 진행합니다:
- 페이지가 이미 캐시되어 있으면, 고정(pinned)된 상태로 반환됩니다.
- 캐시되지 않았다면, pager는 빈 슬롯을 찾아(필요 시 다른 페이지를 내보내고) 디스크에서 페이지를 로드합니다.
이 시점에서 tree module은 메모리 내 페이지 이미지에 대한 포인터와 private space 라는 영역을 받게 됩니다. 이 private space는 페이지가 처음 메모리에 들어올 때 항상 0으로 초기화되며, 이후 tree layer가 자체 bookkeeping을 위해 재사용합니다.
Deferred Recovery: Fixing the Past Before the Present
첫 번째 공유 잠금 획득 안에 숨겨진 중요한 부수 효과가 있습니다.
pager가 데이터베이스 파일에 대해 처음으로 공유 잠금을 획득하면, 핫 저널 파일(hot journal file) 이 있는지 확인합니다. 이런 저널이 존재한다는 것은 이전 트랜잭션 중에 충돌, 전원 손실, 혹은 비정상 종료가 발생했음을 의미합니다.
핫 저널이 발견되면 복구가 바로 여기서 진행됩니다:
- pager는 미완료된 트랜잭션을 롤백합니다.
- 저널을 사용해 페이지를 복원합니다.
- 저널 파일을 최종 처리합니다.
데이터베이스가 일관된 상태로 돌아온 뒤에야 sqlite3PagerGet 이 호출자에게 반환됩니다.
Result: SQLite는 충돌 이후에도 절대 손상된 데이터베이스를 읽지 않는다는 것을 보장할 수 있습니다.
Cache Pressure During Reads
때때로 페이지를 읽는 것만으로도 쓰기가 발생합니다.
- 캐시가 가득 차서 pager가 요청된 페이지를 위한 슬롯이 필요하면, 희생 페이지(victim)를 선택해야 합니다.
- 그 희생 페이지가 더럽혀진(dirty) 상태라면, pager는 재사용하기 전에 해당 페이지를 디스크에 플러시합니다.
이 과정은 일반 처리 과정에서 투명하게 이루어지며, SQLite에서 캐시 관리와 트랜잭션 관리가 불가분인 이유 중 하나입니다.
Write Operations: Declaring Intent Before Action
쓰기에서는 규율이 중요합니다.
- 클라이언트는
sqlite3PagerGet으로 이미 페이지를 고정(pinned)하고 있어야 합니다. - 그 다음에 다음을 호출해야 합니다:
sqlite3PagerWrite(page);
sqlite3PagerWrite 디스크에 아무것도 쓰지 않으며, 단지 쓰기 의도 를 알리는 역할만 합니다.
- 트랜잭션 내의 어떤 페이지가 처음으로 쓰기 가능하게 되면, pager는 데이터베이스 파일에 예약 잠금(reserved lock) 을 획득하려 시도합니다. 이 잠금은 “나는 곧 쓸 것이지만 아직은 아니다” 라는 신호입니다.
- 한 번에 하나의 pager만 이 잠금을 가질 수 있습니다. 다른 트랜잭션이 이미 예약 잠금이나 배타 잠금(exclusive lock)을 보유하고 있으면, 호출은
SQLITE_BUSY로 실패합니다.
예약 잠금을 성공적으로 획득하면 중요한 전환점이 됩니다: 읽기 트랜잭션이 쓰기 트랜잭션 으로 변환됩니다.
- 롤백 저널이 생성되고 열립니다.
- 초기 저널 헤더가 기록되어, 데이터베이스 파일의 원래 크기가 저장됩니다.
이 시점부터 pager는 모든 작업을 되돌릴 수 있어야 합니다.
Journaling Pages: One Before‑Image Is Enough
페이지를 쓰기 가능하게 만들기 위해, pager는 페이지의 원본 내용(original contents) 을 롤백 저널에 기록합니다.
Source: …
journal을 새로운 로그 레코드로 기록합니다. 이는 페이지당 트랜잭션당 한 번 발생합니다.
- 새로 생성된 페이지는 로그에 기록할 필요가 없습니다—복구할 이전 상태가 없기 때문입니다.
저널링 후:
- 페이지가 dirty 상태로 표시됩니다.
- 변경 사항은 메모리에 남아 있습니다.
- 데이터베이스 파일은 그대로 유지됩니다.
이 보장은 트랜잭션이 캐시의 페이지를 업데이트하는 동안 다른 트랜잭션이 데이터베이스 파일을 안전하게 읽을 수 있음을 의미합니다. 파일은 여전히 이전 커밋된 상태를 반영하고 있기 때문입니다.
캐시 내 변이와 격리
sqlite3PagerWrite가 반환된 후, 트리 모듈은 페이지를 자유롭게 수정할 수 있습니다—한 번, 두 번, 혹은 백 번이라도. 페이지 관리자는 다시 알릴 필요가 없습니다.
변경 사항은 메모리 안에 누적되며, 다음에 의해 보호됩니다:
- 예약된 락 (reserved lock)
- 롤백 저널 (rollback journal)
- 페이지 관리자의 상태 머신
쓰기만으로는 캐시 플러시가 트리거되지 않으며, 디스크 I/O는 의도적으로 지연됩니다. 이것이 SQLite가 성능과 내구성을 균형 있게 유지하는 방식입니다.
복잡한 동시성 제어 없이 격리와 성능 확보
일반적인 처리 과정에서는:
- 페이지를 공유 락(shared locks) 아래에서 읽고
- 쓰기는 예약된 락(reserved locks) 로 상승시키며
- 이전 이미지가 안전하게 저널링(journaled) 되고
- 모든 수정 사항이 캐시(cache) 에 머무르고
- 데이터베이스 파일은 손상되지 않은(pristine) 상태를 유지합니다
아직 커밋되지 않았습니다. 아직 플러시되지 않았습니다.
하지만 복구에 필요한 모든 것이 이미 제자리에 있습니다.
다음 내용
다음 글에서는 이 트랜잭션을 자연스러운 결말까지 따라가 보겠습니다:
- 캐시 플러시 메커니즘
- 커밋 작업 (그리고 왜 단계별로 발생하는지)
- 문장 수준 트랜잭션 및 하위 트랜잭션
SQLite와 관련된 제 실험 및 직접 실행 예제는 여기에서 확인할 수 있습니다:
lovestaco/sqlite – SQLite examples
References
-
SQLite Database System: Design and Implementation – Sibsankar Haldar (n.d.)
-

👉 확인해 보세요: FreeDevTools
피드백이나 기여를 환영합니다!
온라인이며 오픈‑소스이고 누구든지 사용할 수 있습니다.
⭐ GitHub에서 별표 달기: HexmosTech/FreeDevTools