SQLite 에서의 원자적 커밋
SQLite 에서의 원자적 커밋 (Atomic Commit)
"원자적 커밋(atomic commit)"은 트랜잭션 데이터베이스의 핵심 기능이에요. 한 트랜잭션 안의 모든 데이터베이스 변경이 모두 일어나거나 전혀 일어나지 않는다는 뜻이죠. 이 글은 SQLite가 원자적 커밋의 착각을 만들어 내는 기법들을 설명해요.
본문
1. 소개 (Introduction)
SQLite 같은 트랜잭션 데이터베이스의 중요한 기능은 "원자적 커밋(atomic commit)"이에요. 원자적 커밋은 한 트랜잭션 안의 모든 데이터베이스 변경이 모두 일어나거나, 아니면 그중 어떤 것도 일어나지 않는 것을 의미해요. 원자적 커밋이 있으면, 데이터베이스 파일의 여러 다른 섹션에 대한 많은 서로 다른 쓰기가 순간적이고 동시에 일어난 것처럼 보여요. 실제 하드웨어는 대용량 저장 장치에 대한 쓰기를 직렬화하고, 단일 섹터를 쓰는 데도 유한한 시간이 걸려요. 그래서 데이터베이스 파일의 많은 서로 다른 섹터를 진정으로 동시에 그리고/또는 순간적으로 쓰는 것은 불가능해요. 하지만 SQLite 안의 원자적 커밋 로직은 트랜잭션의 변경이 모두 순간적이고 동시에 쓰여진 것처럼 보이게 해요.
SQLite는 트랜잭션이 운영 체제 크래시나 정전에 의해 중단되더라도 트랜잭션이 원자적으로 보이는 중요한 속성을 가져요.
이 글은 SQLite가 원자적 커밋의 착각을 만드는 데 사용하는 기법들을 설명해요.
이 글의 정보는 SQLite가 "롤백 모드(rollback mode)"로 동작할 때, 다시 말해 SQLite가 write-ahead log를 사용하지 않을 때만 적용돼요. SQLite는 write-ahead logging이 활성화되어 있어도 원자적 커밋을 지원하지만, 이 글에서 설명하는 것과는 다른 메커니즘으로 원자적 커밋을 달성해요. 그 맥락에서 SQLite가 원자적 커밋을 어떻게 지원하는지에 대한 추가 정보는 write-ahead log 문서를 참고해 주세요.
2. 하드웨어 가정 (Hardware Assumptions)
이 글 전체에서 대용량 저장 장치를 실제로는 플래시 메모리일지라도 "디스크"라고 부를게요.
우리는 디스크가 "섹터(sector)"라고 부르는 덩어리로 쓰여진다고 가정해요. 섹터보다 작은 디스크의 어떤 부분도 수정하는 것은 불가능해요. 섹터보다 작은 디스크 부분을 바꾸려면, 바꾸려는 부분을 담고 있는 전체 섹터를 읽어 들이고, 변경을 한 다음, 전체 섹터를 다시 써야 해요.
전통적인 스피닝 디스크에서 섹터는 읽기와 쓰기 양방향 모두의 최소 전송 단위예요. 하지만 플래시 메모리에서는 읽기의 최소 크기가 보통 쓰기의 최소 크기보다 훨씬 작아요. SQLite는 최소 쓰기량에만 관심이 있으므로, 이 글의 목적상 "섹터"라고 할 때는 대용량 저장 장치에 한 번에 쓸 수 있는 최소 데이터량을 의미해요.
SQLite 버전 3.3.14 이전에는 모든 경우에 512바이트 섹터 크기가 가정됐어요. 이를 바꾸는 컴파일 타임 옵션이 있었지만 그 코드는 더 큰 값으로 테스트된 적이 없었어요. 최근까지 모든 디스크 드라이브가 내부적으로 512바이트 섹터를 사용했기 때문에 512바이트 섹터 가정은 합리적으로 보였어요. 하지만 최근 디스크 섹터 크기를 4096바이트로 늘리려는 움직임이 있었어요. 또한 플래시 메모리의 섹터 크기는 보통 512바이트보다 커요. 이런 이유로 3.3.14부터 시작하는 SQLite 버전은 OS 인터페이스 계층에 기본 파일시스템을 조사해 진짜 섹터 크기를 찾는 메서드가 있어요. 현재 구현(3.5.0)에서 이 메서드는 여전히 하드코딩된 512바이트 값을 반환해요. Unix나 Windows에서 진짜 섹터 크기를 알아내는 표준 방법이 없기 때문이에요. 하지만 이 메서드는 임베디드 장치 제조사들이 자신들의 필요에 따라 조정할 수 있게 제공되어 있어요. 그리고 우리는 미래에 Unix와 Windows에서 더 의미 있는 구현을 채울 가능성을 열어 두고 있어요.
SQLite는 전통적으로 섹터 쓰기가 원자적이지 않다고 가정해 왔어요. 하지만 SQLite는 항상 섹터 쓰기가 선형적(linear)이라고 가정해요. "선형적"이란, 섹터를 쓸 때 하드웨어가 데이터의 한쪽 끝에서 시작해 다른 쪽 끝에 도달할 때까지 바이트 단위로 쓴다고 가정한다는 뜻이에요. 쓰기는 처음에서 끝으로, 또는 끝에서 처음으로 갈 수 있어요. 섹터 쓰기 도중에 정전이 발생하면 섹터의 일부는 수정되고 다른 일부는 변경되지 않은 채로 남을 수 있어요. SQLite의 핵심 가정은 섹터의 어떤 부분이 바뀌면 첫 번째 또는 마지막 바이트가 변경된다는 것이에요. 그래서 하드웨어는 절대 중간에서 섹터 쓰기를 시작해 양쪽 끝을 향해 작업하지 않아요. 이 가정이 항상 참인지는 모르지만 합리적으로 보여요.
이전 문단에서 SQLite가 섹터 쓰기가 원자적이라고 가정하지 않는다고 말했어요. 이것은 기본적으로 사실이에요. 하지만 SQLite 버전 3.5.0부터 가상 파일 시스템(VFS) 인터페이스라는 새 인터페이스가 있어요. VFS는 SQLite가 기본 파일시스템과 통신하는 유일한 수단이에요. 코드에는 Unix와 Windows용 기본 VFS 구현이 있고, 런타임에 새 커스텀 VFS 구현을 만드는 메커니즘이 있어요. 이 새 VFS 인터페이스에는 xDeviceCharacteristics라는 메서드가 있어요. 이 메서드는 기본 파일시스템을 조사해 파일시스템이 보일 수도 있고 아닐 수도 있는 다양한 속성과 동작을 알아내요. xDeviceCharacteristics 메서드는 섹터 쓰기가 원자적임을 나타낼 수 있고, 그렇게 나타낸다면 SQLite는 그 사실을 활용하려고 할 거예요. 하지만 Unix와 Windows 모두의 기본 xDeviceCharacteristics 메서드는 원자적 섹터 쓰기를 나타내지 않으므로 이 최적화는 보통 생략돼요.
SQLite는 운영 체제가 쓰기를 버퍼링하고, 쓰기 요청이 데이터가 실제로 대용량 저장 장치에 저장되기 전에 반환된다고 가정해요. SQLite는 또한 쓰기 연산이 운영 체제에 의해 재정렬될 것이라고 가정해요. 이런 이유로 SQLite는 핵심 지점에서 "flush" 또는 "fsync" 연산을 수행해요. SQLite는 flush나 fsync가, flush되고 있는 파일에 대한 모든 대기 중인 쓰기 연산이 완료될 때까지 반환되지 않는다고 가정해요. 일부 Windows와 Linux 버전에서 flush와 fsync 프리미티브가 고장났다고 들었어요. 이것은 유감스러운 일이에요. 그것은 SQLite를 커밋 도중 정전 후 데이터베이스 손상 가능성에 노출시켜요. 하지만 SQLite가 상황을 테스트하거나 해결할 수 있는 방법은 없어요. SQLite는 자신이 실행되고 있는 운영 체제가 광고된 대로 동작한다고 가정해요. 만약 그게 완전히 사실이 아니라면, 음, 어쨌든 너무 자주 전원이 꺼지지 않기를 바랄게요.
SQLite는 파일이 길어질 때 새 파일 공간이 원래는 쓰레기(garbage)를 담고 있다가 나중에 실제로 쓰여진 데이터로 채워진다고 가정해요. 다시 말해, SQLite는 파일 콘텐츠보다 파일 크기가 먼저 갱신된다고 가정해요. 이것은 비관적인 가정이고, SQLite는 파일 크기가 증가한 시점과 새 콘텐츠가 쓰여진 시점 사이에 정전이 발생해도 데이터베이스 손상이 생기지 않도록 하기 위해 약간의 추가 작업을 해야 해요. VFS의 xDeviceCharacteristics 메서드는 파일시스템이 항상 파일 크기를 갱신하기 전에 데이터를 쓴다고 나타낼 수도 있어요. (코드를 보는 독자를 위해 이것은 SQLITE_IOCAP_SAFE_APPEND 속성입니다.) xDeviceCharacteristics 메서드가 파일 크기가 증가하기 전에 파일 콘텐츠가 쓰여진다고 나타내면, SQLite는 과도하게 조심스러운 데이터베이스 보호 절차의 일부를 생략해 커밋을 수행하는 데 필요한 디스크 I/O 양을 줄일 수 있어요. 하지만 현재 구현은 Windows와 Unix의 기본 VFS에 대해 그런 가정을 하지 않아요.
SQLite는 사용자 프로세스의 관점에서 파일 삭제가 원자적이라고 가정해요. 즉, SQLite가 파일 삭제를 요청하고 삭제 연산 중에 정전이 발생하면, 전원이 복구된 후 파일이 모든 원래 콘텐츠가 변경되지 않은 채 완전히 존재하거나, 아니면 파일이 파일시스템에서 전혀 보이지 않을 것이라고 가정한다는 뜻이에요. 전원 복구 후 파일이 부분적으로만 삭제되었거나, 일부 데이터가 변경되거나 지워졌거나, 파일이 잘렸지만 완전히 제거되지 않았다면 데이터베이스 손상이 발생할 가능성이 높아요.
SQLite는 우주선, 열 잡음, 양자 요동, 장치 드라이버 버그, 기타 메커니즘에 의한 비트 오류의 검출 및/또는 정정이 기본 하드웨어와 운영 체제의 책임이라고 가정해요. SQLite는 손상이나 I/O 오류를 검출하기 위한 목적으로 데이터베이스 파일에 어떤 중복도 추가하지 않아요. SQLite는 읽는 데이터가 이전에 썼던 데이터와 정확히 같다고 가정해요.
기본적으로 SQLite는 바이트 범위를 쓰는 운영 체제 호출이, 쓰는 동안 정전이나 OS 크래시가 발생해도 그 범위 밖의 어떤 바이트도 손상시키지 않는다고 가정해요. 우리는 이것을 "powersafe overwrite" 속성이라고 불러요. 버전 3.7.9 (2011-11-01) 이전에는 SQLite가 powersafe overwrite를 가정하지 않았어요. 하지만 대부분의 디스크 드라이브에서 표준 섹터 크기가 512에서 4096바이트로 증가하면서, 역사적인 성능 수준을 유지하기 위해 powersafe overwrite를 가정하는 것이 필요해졌고, 그래서 최근 SQLite 버전에서는 powersafe overwrite가 기본적으로 가정돼요. powersafe overwrite 속성의 가정은 원한다면 컴파일 타임이나 런타임에 비활성화할 수 있어요. 자세한 내용은 powersafe overwrite 문서를 참고해 주세요.
3. 단일 파일 커밋 (Single File Commit)
단일 데이터베이스 파일에 대한 트랜잭션의 원자적 커밋을 수행하기 위해 SQLite가 취하는 단계의 개요부터 시작할게요. 정전으로 인한 손상을 방지하는 데 사용되는 파일 형식의 세부사항과 여러 데이터베이스에 걸쳐 원자적 커밋을 수행하는 기법은 이후 섹션에서 논의돼요.
3.1. 초기 상태 (Initial State)
데이터베이스 연결이 처음 열렸을 때 컴퓨터의 상태가 오른쪽 다이어그램에 개념적으로 표시돼요. 다이어그램의 맨 오른쪽 영역("Disk"로 표시)은 대용량 저장 장치에 저장된 정보를 나타내요. 각 직사각형은 섹터예요. 파란색은 섹터가 원본 데이터를 담고 있음을 나타내요. 중간 영역은 운영 체제의 디스크 캐시예요. 우리 예제의 시작 시점에 캐시는 차가우며, 이는 디스크 캐시의 직사각형을 비워 두는 것으로 나타내요. 다이어그램의 왼쪽 영역은 SQLite를 사용하는 프로세스의 메모리 내용을 보여줘요. 데이터베이스 연결이 막 열렸고 아직 어떤 정보도 읽히지 않았으므로 사용자 공간은 비어 있어요.
3.2. 읽기 잠금 획득 (Acquiring A Read Lock)
SQLite가 데이터베이스에 쓰기 전에, 먼저 데이터베이스를 읽어 이미 무엇이 있는지 봐야 해요. 단지 새 데이터를 추가하는 경우라도, SQLite는 INSERT 문을 파싱하는 방법과 새 정보가 데이터베이스 파일의 어디에 저장되어야 하는지 알아내기 위해 "sqlite_schema" 테이블의 데이터베이스 스키마를 읽어야 해요.
데이터베이스 파일에서 읽기 위한 첫 단계는 데이터베이스 파일에 공유 잠금(shared lock)을 얻는 것이에요. "공유" 잠금은 두 개 이상의 데이터베이스 연결이 동시에 데이터베이스 파일에서 읽을 수 있게 해 줘요. 하지만 공유 잠금은 우리가 읽는 동안 다른 데이터베이스 연결이 데이터베이스 파일에 쓰는 것을 막아요. 이것은 필요해요. 다른 데이터베이스 연결이 우리가 데이터베이스 파일에서 읽는 것과 동시에 데이터베이스 파일에 쓴다면, 우리는 변경 전의 일부 데이터와 변경 후의 다른 데이터를 읽을 수 있기 때문이에요. 이는 다른 프로세스가 만든 변경이 원자적이지 않은 것처럼 보이게 만들 거예요.
공유 잠금이 디스크 자체가 아니라 운영 체제 디스크 캐시에 있음을 주목하세요. 파일 잠금은 정말로 보통 운영 체제 커널 안의 플래그일 뿐이에요. (세부사항은 특정 OS 계층 인터페이스에 달려 있어요.) 그러므로 운영 체제가 크래시하거나 정전이 발생하면 잠금은 즉시 사라져요. 또한 보통 잠금을 만든 프로세스가 종료하면 잠금이 사라지기도 해요.
3.3. 데이터베이스에서 정보 읽기 (Reading Information Out Of The Database)
공유 잠금을 얻은 후 데이터베이스 파일에서 정보를 읽기 시작할 수 있어요. 이 시나리오에서는 캐시가 차갑다고 가정하므로, 정보를 먼저 대용량 저장 장치에서 운영 체제 캐시로 읽은 다음 운영 체제 캐시에서 사용자 공간으로 전송해야 해요. 이후 읽기에서는 일부 또는 모든 정보가 이미 운영 체제 캐시에서 발견될 수 있으므로 사용자 공간으로의 전송만 필요할 거예요.
보통 데이터베이스 파일의 페이지 중 일부만 읽혀요. 이 예제에서는 8개 페이지 중 3개가 읽히는 것을 보여줘요. 전형적인 애플리케이션에서 데이터베이스는 수천 개의 페이지를 갖고, 쿼리는 보통 그중 작은 비율만 건드려요.
3.4. 예약 잠금 획득 (Obtaining A Reserved Lock)
데이터베이스에 변경을 하기 전에 SQLite는 먼저 데이터베이스 파일에 "reserved(예약)" 잠금을 얻어요. 예약 잠금은 공유 잠금과 비슷한데, 예약 잠금과 공유 잠금 모두 다른 프로세스가 데이터베이스 파일에서 읽을 수 있게 하기 때문이에요. 단일 예약 잠금은 다른 프로세스의 여러 공유 잠금과 공존할 수 있어요. 하지만 데이터베이스 파일에는 단 하나의 예약 잠금만 있을 수 있어요. 그러므로 한 번에 한 프로세스만 데이터베이스에 쓰기를 시도할 수 있어요.
예약 잠금의 아이디어는, 프로세스가 가까운 미래에 데이터베이스 파일을 수정하려고 하지만 아직 수정을 시작하지 않았음을 알리는 것이에요. 그리고 수정이 아직 시작되지 않았으므로 다른 프로세스가 데이터베이스에서 계속 읽을 수 있어요. 하지만 다른 프로세스는 데이터베이스에 쓰기를 시작하려고 해서도 안 돼요.
3.5. 롤백 저널 파일 만들기 (Creating A Rollback Journal File)
데이터베이스 파일에 어떤 변경도 하기 전에, SQLite는 먼저 별도의 롤백 저널(rollback journal) 파일을 만들고, 변경될 데이터베이스 페이지의 원래 콘텐츠를 롤백 저널에 써요. 롤백 저널의 아이디어는 데이터베이스를 원래 상태로 복원하는 데 필요한 모든 정보를 담고 있다는 것이에요.
롤백 저널은 (다이어그램에서 녹색으로 표시된) 데이터베이스 파일의 원래 크기를 기록하는 작은 헤더를 담고 있어요. 그래서 변경으로 데이터베이스 파일이 커지더라도 우리는 여전히 데이터베이스의 원래 크기를 알 수 있어요. 롤백 저널에 쓰여진 각 데이터베이스 페이지와 함께 페이지 번호가 저장돼요.
새 파일이 생성될 때 대부분의 데스크톱 운영 체제(Windows, Linux, Mac OS X)는 실제로 디스크에 아무것도 쓰지 않아요. 새 파일은 운영 체제 디스크 캐시에만 생성돼요. 파일은 나중에, 운영 체제에 잠깐의 시간이 있을 때 대용량 저장 장치에 생성돼요. 이것은 사용자에게 실제 디스크 I/O로 가능한 것보다 I/O가 훨씬 빠르게 일어나는 인상을 줘요. 우리는 오른쪽 다이어그램에서 새 롤백 저널이 디스크 자체가 아닌 운영 체제 디스크 캐시에만 나타나는 것으로 이 아이디어를 보여줘요.
3.6. 사용자 공간에서 데이터베이스 페이지 변경 (Changing Database Pages In User Space)
원래 페이지 콘텐츠가 롤백 저널에 저장된 후, 페이지는 사용자 메모리에서 수정될 수 있어요. 각 데이터베이스 연결은 자신만의 비공개 사용자 공간 사본을 가지므로, 사용자 공간에서 만들어진 변경은 변경을 만드는 데이터베이스 연결에만 보여요. 다른 데이터베이스 연결은 아직 변경되지 않은 운영 체제 디스크 캐시 버퍼의 정보를 계속 보게 돼요. 그래서 한 프로세스가 데이터베이스 수정에 바쁘더라도 다른 프로세스는 원래 데이터베이스 콘텐츠의 자신의 사본을 계속 읽을 수 있어요.
3.7. 롤백 저널 파일을 대용량 저장 장치로 플러시 (Flushing The Rollback Journal File To Mass Storage)
다음 단계는 롤백 저널 파일의 콘텐츠를 비휘발성 저장 장치로 플러시하는 것이에요. 나중에 보겠지만 이것은 데이터베이스가 예상치 못한 정전을 견딜 수 있도록 보장하는 데 중요한 단계예요. 이 단계는 또한 많은 시간이 걸려요. 비휘발성 저장 장치에 쓰는 것이 보통 느린 연산이기 때문이에요.
이 단계는 보통 롤백 저널을 디스크에 플러시하는 것보다 더 복잡해요. 대부분의 플랫폼에서 두 번의 별도 flush(또는 fsync()) 연산이 필요해요. 첫 번째 flush는 기본 롤백 저널 콘텐츠를 써내려요. 그런 다음 롤백 저널의 헤더가 롤백 저널의 페이지 수를 보여주도록 수정돼요. 그런 다음 헤더가 디스크에 플러시돼요. 이 헤더 수정과 추가 플러시를 왜 하는지에 대한 세부사항은 이 글의 이후 섹션에서 제공돼요.
3.8. 배타적 잠금 획득 (Obtaining An Exclusive Lock)
데이터베이스 파일 자체에 변경을 하기 전에 데이터베이스 파일에 배타적 잠금(exclusive lock)을 얻어야 해요. 배타적 잠금을 얻는 것은 정말로 두 단계 과정이에요. 먼저 SQLite는 "pending(대기)" 잠금을 얻어요. 그런 다음 pending 잠금을 배타적 잠금으로 승격시켜요.
pending 잠금은 이미 공유 잠금을 가진 다른 프로세스가 데이터베이스 파일을 계속 읽을 수 있게 해 줘요. 하지만 새 공유 잠금이 설정되는 것을 막아요. pending 잠금의 아이디어는 큰 읽기 프로세스 풀에 의해 발생하는 쓰기 기아(writer starvation)를 막는 것이에요. 데이터베이스 파일을 읽으려고 하는 다른 프로세스가 수십, 심지어 수백 개 있을 수 있어요. 각 프로세스는 읽기를 시작하기 전에 공유 잠금을 획득하고, 필요한 것을 읽은 다음 공유 잠금을 해제해요. 하지만 많은 서로 다른 프로세스가 모두 같은 데이터베이스에서 읽고 있다면, 새 프로세스가 항상 이전 프로세스가 공유 잠금을 해제하기 전에 자신의 공유 잠금을 획득하는 일이 벌어질 수 있어요. 그래서 데이터베이스 파일에 공유 잠금이 없는 순간이 결코 없어서, 쓰기가 배타적 잠금을 잡을 기회가 결코 없을 수 있어요. pending 잠금은 기존 공유 잠금은 진행하게 하면서 새 공유 잠금 설정은 막아서 그 순환을 방지하도록 설계됐어요. 결국 모든 공유 잠금이 해소되고 pending 잠금이 배타적 잠금으로 승격될 수 있게 돼요.
3.9. 데이터베이스 파일에 변경 쓰기 (Writing Changes To The Database File)
배타적 잠금이 유지되면 다른 프로세스가 데이터베이스 파일에서 읽고 있지 않다는 것을 알며, 데이터베이스 파일에 변경을 쓰는 것이 안전해요. 보통 그 변경들은 운영 체제 디스크 캐시까지만 가고 대용량 저장 장치까지는 가지 않아요.
3.10. 변경을 대용량 저장 장치로 플러시 (Flushing Changes To Mass Storage)
모든 데이터베이스 변경이 비휘발성 저장 장치에 쓰여졌는지 확인하기 위해 또 다른 flush가 발생해야 해요. 이것은 데이터베이스가 손상 없이 정전을 견디도록 보장하는 중요한 단계예요. 하지만 디스크나 플래시 메모리에 쓰는 것의 고유한 느림 때문에, 이 단계는 위의 3.7 섹션의 롤백 저널 파일 플러시와 함께 SQLite에서 트랜잭션 커밋을 완료하는 데 필요한 대부분의 시간을 차지해요.
3.11. 롤백 저널 삭제 (Deleting The Rollback Journal)
데이터베이스 변경이 모두 안전하게 대용량 저장 장치에 있으면 롤백 저널 파일이 삭제돼요. 이것이 트랜잭션이 커밋되는 순간이에요. 이 지점 이전에 정전이나 시스템 크래시가 발생하면, 나중에 설명할 복구 프로세스가 데이터베이스 파일에 어떤 변경도 결코 이루어지지 않은 것처럼 보이게 해요. 롤백 저널이 삭제된 후에 정전이나 시스템 크래시가 발생하면 모든 변경이 디스크에 쓰여진 것처럼 보여요. 그래서 SQLite는 롤백 저널 파일의 존재 여부에 따라 데이터베이스 파일에 아무 변경도 하지 않은 것 또는 전체 변경 집합을 만든 것처럼 보여요.
파일 삭제는 정말로 원자적 연산은 아니지만, 사용자 프로세스의 관점에서는 원자적으로 보여요. 프로세스는 항상 운영 체제에게 "이 파일이 존재하나요?"라고 물을 수 있고, 예 또는 아니오 답을 받아요. 트랜잭션 커밋 중에 발생한 정전 후, SQLite는 운영 체제에게 롤백 저널 파일이 존재하는지 물어요. 답이 "예"라면 트랜잭션은 불완전한 것이고 롤백돼요. 답이 "아니오"라면 트랜잭션이 커밋됐다는 뜻이에요.
트랜잭션의 존재는 롤백 저널 파일의 존재 여부에 달려 있고, 파일 삭제는 사용자 공간 프로세스의 관점에서 원자적 연산으로 보여요. 그러므로 트랜잭션은 원자적 연산으로 보여요.
파일 삭제 행위는 많은 시스템에서 비싸요. 최적화로 SQLite는 저널 파일을 길이 0바이트로 잘라내거나 저널 파일 헤더를 0으로 덮어쓰도록 구성될 수 있어요. 어느 경우든 결과 저널 파일은 더 이상 롤백할 수 없으므로 트랜잭션은 여전히 커밋돼요. 파일을 길이 0으로 자르는 것은 파일 삭제처럼 사용자 프로세스의 관점에서 원자적 연산이라고 가정돼요. 저널 헤더를 0으로 덮어쓰는 것은 원자적이지 않지만, 헤더의 어떤 부분이 잘못 형성되면 저널은 롤백하지 않아요. 그래서 커밋은 헤더가 무효가 되도록 충분히 변경되는 즉시 발생한다고 말할 수 있어요. 보통 이것은 헤더의 첫 바이트가 0이 되는 즉시 발생해요.
3.12. 잠금 해제 (Releasing The Lock)
커밋 프로세스의 마지막 단계는 배타적 잠금을 해제해서 다른 프로세스가 다시 데이터베이스 파일에 접근할 수 있게 하는 것이에요.
오른쪽 다이어그램에서 우리는 잠금이 해제될 때 사용자 공간에 유지되던 정보가 비워지는 것을 보여줘요. 이것은 구형 SQLite 버전에서는 문자 그대로 사실이었어요. 하지만 더 최근 버전의 SQLite는 다음 트랜잭션 시작에 다시 필요할 수 있으므로 사용자 공간 정보를 메모리에 유지해요. 이미 로컬 메모리에 있는 정보를 재사용하는 것이 운영 체제 디스크 캐시에서 정보를 다시 전송하거나 디스크 드라이브에서 다시 읽는 것보다 더 싸요. 사용자 공간의 정보를 재사용하기 전에 먼저 공유 잠금을 다시 획득하고, 다른 프로세스가 우리가 잠금을 유지하지 않는 동안 데이터베이스 파일을 수정했는지 확인해야 해요. 데이터베이스 파일이 수정될 때마다 증가하는 데이터베이스 첫 페이지의 카운터가 있어요. 그 카운터를 확인하면 다른 프로세스가 데이터베이스를 수정했는지 알 수 있어요. 데이터베이스가 수정되었다면 사용자 공간 캐시를 비우고 다시 읽어야 해요. 하지만 보통 변경이 없어서 사용자 공간 캐시를 재사용해 상당한 성능 절약을 얻을 수 있어요.
4. 롤백 (Rollback)
원자적 커밋은 순간적으로 일어나는 것으로 되어 있어요. 하지만 위에서 설명한 처리는 분명히 유한한 시간이 걸려요. 위에서 설명한 커밋 연산 도중에 컴퓨터 전원이 끊겼다고 가정해 보세요. 변경이 순간적이었다는 착각을 유지하려면, 우리는 부분 변경을 "롤백"하고 데이터베이스를 트랜잭션 시작 전의 상태로 복원해야 해요.
4.1. 뭔가 잘못될 때... (When Something Goes Wrong...)
위의 3.10 단계 동안, 즉 데이터베이스 변경이 디스크에 쓰여지는 동안 정전이 발생했다고 가정해 보세요. 전원이 복구된 후 상황은 오른쪽에 보이는 것과 비슷할 수 있어요. 우리는 데이터베이스 파일의 3개 페이지를 바꾸려고 했지만 한 페이지만 성공적으로 쓰여졌어요. 다른 페이지는 부분적으로 쓰여졌고, 세 번째 페이지는 전혀 쓰여지지 않았어요.
전원이 복구되었을 때 롤백 저널은 디스크에 완전하고 손상되지 않은 채로 있어요. 이것이 핵심이에요. 3.7 단계의 flush 연산의 이유는 데이터베이스 파일 자체에 어떤 변경을 하기 전에 모든 롤백 저널이 안전하게 비휘발성 저장 장치에 있음을 절대적으로 확실하게 하기 위해서예요.
4.2. 핫 롤백 저널 (Hot Rollback Journals)
어떤 SQLite 프로세스가 데이터베이스 파일에 접근하려고 할 때마다 첫 번째로, 위의 3.2 섹션에서 설명한 대로 공유 잠금을 얻어요. 하지만 그런 다음 롤백 저널 파일이 존재한다는 것을 알아차려요. 그러면 SQLite는 롤백 저널이 "핫 저널(hot journal)"인지 확인해요. 핫 저널은 데이터베이스를 온전한 상태로 복원하기 위해 재생(play back)해야 하는 롤백 저널이에요. 핫 저널은 이전 프로세스가 트랜잭션을 커밋하는 도중이었을 때 크래시되거나 전원이 끊겼을 때만 존재해요.
롤백 저널은 다음이 모두 참일 때 "핫" 저널이에요:
- 롤백 저널이 존재한다.
- 롤백 저널이 빈 파일이 아니다.
- 메인 데이터베이스 파일에 reserved 잠금이 없다.
- 롤백 저널의 헤더가 잘 형성되어 있고, 특히 0으로 지워지지 않았다.
- 롤백 저널이 슈퍼 저널(super-journal) 파일의 이름을 담고 있지 않거나(아래 5.5 섹션), 슈퍼 저널 이름을 담고 있다면 그 슈퍼 저널 파일이 존재한다.
핫 저널의 존재는 이전 프로세스가 트랜잭션을 커밋하려 했지만 커밋 완료 전에 어떤 이유로 중단했음을 나타내요. 핫 저널은 데이터베이스 파일이 일관되지 않은 상태이며 사용되기 전에 (롤백으로) 수리되어야 함을 의미해요.
4.3. 데이터베이스에 배타적 잠금 획득 (Obtaining An Exclusive Lock On The Database)
핫 저널을 처리하는 첫 단계는 데이터베이스 파일에 배타적 잠금을 얻는 것이에요. 이는 두 개 이상의 프로세스가 같은 핫 저널을 동시에 롤백하려고 하는 것을 막아요.
4.4. 불완전한 변경 롤백 (Rolling Back Incomplete Changes)
일단 프로세스가 배타적 잠금을 얻으면 데이터베이스 파일에 쓸 수 있게 돼요. 그런 다음 롤백 저널에서 페이지의 원래 콘텐츠를 읽고, 그 콘텐츠를 데이터베이스 파일의 원래 위치에 다시 써요. 롤백 저널 헤더가 중단된 트랜잭션 시작 전의 데이터베이스 파일의 원래 크기를 기록한다는 것을 기억하세요. SQLite는 이 정보를 사용해, 불완전한 트랜잭션이 데이터베이스를 성장시킨 경우 데이터베이스 파일을 원래 크기로 잘라내요. 이 단계가 끝나면 데이터베이스는 중단된 트랜잭션 시작 전과 같은 크기이고 같은 정보를 담고 있어야 해요.
4.5. 핫 저널 삭제 (Deleting The Hot Journal)
롤백 저널의 모든 정보가 데이터베이스 파일에 재생된 후(그리고 또 다른 정전을 만날 경우를 대비해 디스크에 플러시된 후), 핫 롤백 저널을 삭제할 수 있어요.
3.11 섹션에서처럼, 파일 삭제가 비싼 시스템에서는 최적화로 저널 파일을 길이 0으로 잘라내거나 헤더를 0으로 덮어쓸 수 있어요. 어느 쪽이든 이 단계 후에 저널은 더 이상 핫하지 않아요.
4.6. 미완료 쓰기가 결코 없었던 것처럼 계속하기
마지막 복구 단계는 배타적 잠금을 공유 잠금으로 줄이는 것이에요. 이것이 발생하면 데이터베이스는 중단된 트랜잭션이 결코 시작되지 않았을 상태로 돌아가 있어요. 이 모든 복구 활동이 완전히 자동적이고 투명하게 발생하므로, SQLite를 사용하는 프로그램에게는 중단된 트랜잭션이 결코 시작되지 않은 것처럼 보여요.
5. 다중 파일 커밋 (Multi-file Commit)
SQLite는 하나의 데이터베이스 연결이 ATTACH DATABASE 명령을 통해 두 개 이상의 데이터베이스 파일과 동시에 통신할 수 있게 해 줘요. 단일 트랜잭션 안에서 여러 데이터베이스 파일이 수정될 때 모든 파일이 원자적으로 갱신돼요. 다시 말해 모든 데이터베이스 파일이 갱신되거나 아니면 그중 어떤 것도 갱신되지 않아요. 여러 데이터베이스 파일에 걸쳐 원자적 커밋을 달성하는 것은 단일 파일에 대해 그러는 것보다 더 복잡해요. 이 섹션은 SQLite가 그 마법을 어떻게 수행하는지 설명해요.
5.1. 각 데이터베이스에 대한 별도의 롤백 저널
트랜잭션에 여러 데이터베이스 파일이 관련되면 각 데이터베이스는 자신의 롤백 저널을 갖고 각각 별도로 잠겨져요. 오른쪽 다이어그램은 한 트랜잭션 안에서 세 개의 서로 다른 데이터베이스 파일이 수정된 시나리오를 보여줘요. 이 단계의 상황은 3.6 단계의 단일 파일 트랜잭션 시나리오와 유사해요. 각 데이터베이스 파일은 reserved 잠금을 가져요. 각 데이터베이스에 대해, 변경되고 있는 페이지의 원래 콘텐츠가 그 데이터베이스의 롤백 저널에 쓰여졌지만, 저널의 콘텐츠는 아직 디스크에 플러시되지 않았어요. 데이터베이스 파일 자체에는 아직 어떤 변경도 이루어지지 않았지만, 아마 사용자 메모리에 변경이 보유되고 있을 거예요.
간결성을 위해 이 섹션의 다이어그램은 이전 것들로부터 단순화되었어요. 파란색은 여전히 원본 콘텐츠를, 분홍색은 여전히 새 콘텐츠를 나타내요. 하지만 롤백 저널과 데이터베이스 파일의 개별 페이지는 표시되지 않고, 운영 체제 캐시의 정보와 디스크의 정보를 구분하지 않아요. 이 모든 요소는 다중 파일 커밋 시나리오에서 여전히 적용돼요. 다이어그램에서 많은 공간을 차지하고 새 정보를 추가하지 않으므로 여기서는 생략됐을 뿐이에요.
5.2. 슈퍼 저널 파일 (The Super-Journal File)
다중 파일 커밋의 다음 단계는 "super-journal" 파일을 만드는 것이에요. 슈퍼 저널 파일의 이름은 원래 데이터베이스 파일 이름(즉, sqlite3_open() 인터페이스로 열린 데이터베이스이지 ATTACHed 보조 데이터베이스가 아닌)에 "-mjHHHHHHHH" 텍스트를 붙인 것인데, 여기서 HHHHHHHH는 임의의 32-bit 16진수 숫자예요. 임의의 HHHHHHHH 접미사는 매 새 슈퍼 저널마다 바뀌어요.
(Nota bene: 이전 문단에서 주어진 슈퍼 저널 파일 이름 계산 공식은 SQLite 버전 3.5.0 기준 구현에 해당해요. 하지만 이 공식은 SQLite 명세의 일부가 아니며 향후 릴리스에서 바뀔 수 있어요.)
롤백 저널과 달리 슈퍼 저널은 어떤 원본 데이터베이스 페이지 콘텐츠도 담지 않아요. 대신 슈퍼 저널은 트랜잭션에 참여하는 모든 데이터베이스에 대한 롤백 저널의 전체 경로명을 담아요.
슈퍼 저널이 구성된 후, 어떤 추가 조치도 취하기 전에 그 콘텐츠가 디스크에 플러시돼요. Unix에서는 정전 후 슈퍼 저널 파일이 디렉토리에 나타나도록 보장하기 위해 슈퍼 저널을 담고 있는 디렉토리도 동기화돼요.
슈퍼 저널의 목적은 다중 파일 트랜잭션이 정전을 넘어서 원자적이도록 보장하는 것이에요. 하지만 데이터베이스 파일이 정전 이벤트를 넘어 무결성을 손상시키는 다른 설정(PRAGMA synchronous=OFF나 PRAGMA journal_mode=MEMORY 같은)을 가지면, 최적화로 슈퍼 저널의 생성은 생략돼요.
5.3. 롤백 저널 헤더 갱신 (Updating Rollback Journal Headers)
다음 단계는 모든 롤백 저널의 헤더에 슈퍼 저널 파일의 전체 경로명을 기록하는 것이에요. 슈퍼 저널 파일 이름을 담을 공간은 롤백 저널이 생성될 때 각 롤백 저널의 시작 부분에 예약됐어요.
각 롤백 저널의 콘텐츠는 슈퍼 저널 파일 이름이 롤백 저널 헤더에 쓰여지기 전과 후 양쪽 모두에서 디스크에 플러시돼요. 이 두 플러시를 모두 하는 것이 중요해요. 다행히 두 번째 플러시는 보통 단일 페이지(첫 페이지)만 변경되므로 대개 값싸요.
이 단계는 위에서 설명한 단일 파일 커밋 시나리오의 3.7 단계와 유사해요.
5.4. 데이터베이스 파일 갱신 (Updating The Database Files)
모든 롤백 저널 파일이 디스크에 플러시되면 데이터베이스 파일 갱신을 시작하는 것이 안전해요. 변경을 쓰기 전에 모든 데이터베이스 파일에 배타적 잠금을 얻어야 해요. 모든 변경이 쓰여진 후, 정전이나 운영 체제 크래시 시 보존되도록 변경을 디스크에 플러시하는 것이 중요해요.
이 단계는 이전에 설명한 단일 파일 커밋 시나리오의 3.8, 3.9, 3.10 단계에 해당해요.
5.5. 슈퍼 저널 파일 삭제 (Delete The Super-Journal File)
다음 단계는 슈퍼 저널 파일을 삭제하는 것이에요. 이것이 다중 파일 트랜잭션이 커밋되는 지점이에요. 이 단계는 롤백 저널이 삭제되는 단일 파일 커밋 시나리오의 3.11 단계에 해당해요.
이 지점에서 정전이나 운영 체제 크래시가 발생하면, 롤백 저널이 존재하더라도 시스템이 재부팅될 때 트랜잭션은 롤백되지 않아요. 차이는 롤백 저널 헤더의 슈퍼 저널 경로명이에요. 재시작 시 SQLite는 헤더에 슈퍼 저널 파일 이름이 없거나(단일 파일 커밋의 경우) 슈퍼 저널 파일이 여전히 디스크에 존재하는 경우에만 저널을 핫한 것으로 간주하고 그 저널을 재생해요.
5.6. 롤백 저널 정리 (Clean Up The Rollback Journals)
다중 파일 커밋의 마지막 단계는 개별 롤백 저널을 삭제하고 데이터베이스 파일의 배타적 잠금을 내려서 다른 프로세스가 변경을 볼 수 있게 하는 것이에요. 이것은 단일 파일 커밋 시퀀스의 3.12 단계에 해당해요.
이 지점에서 트랜잭션은 이미 커밋됐으므로 롤백 저널 삭제의 타이밍은 중요하지 않아요. 현재 구현은 단일 롤백 저널을 삭제한 다음 해당 데이터베이스 파일을 잠금 해제하고 다음 롤백 저널로 진행해요. 하지만 미래에는 모든 롤백 저널이 삭제된 후에야 어떤 데이터베이스 파일도 잠금 해제하도록 바꿀 수도 있어요. 롤백 저널이 해당 데이터베이스 파일이 잠금 해제되기 전에 삭제되기만 하면, 롤백 저널이 삭제되거나 데이터베이스 파일이 잠금 해제되는 순서는 상관없어요.
6. 커밋 프로세스의 추가 세부사항
위 3.0 섹션은 SQLite에서 원자적 커밋이 어떻게 동작하는지에 대한 개요를 제공해요. 하지만 많은 중요한 세부사항을 살짝만 다뤄요. 다음 하위 섹션들은 그 공백을 채우려고 해요.
6.1. 항상 완전한 섹터를 저널링 (Always Journal Complete Sectors)
데이터베이스 페이지의 원래 콘텐츠가 롤백 저널에 쓰여질 때(3.5 섹션에서 보여진 것처럼), SQLite는 데이터베이스의 페이지 크기가 섹터 크기보다 작더라도 항상 완전한 섹터의 데이터를 써요. 역사적으로 SQLite의 섹터 크기는 512바이트로 하드코딩됐고 최소 페이지 크기도 512바이트이므로 이것은 결코 문제가 되지 않았어요. 하지만 SQLite 버전 3.3.14부터 SQLite가 512바이트보다 큰 섹터 크기의 대용량 저장 장치를 사용하는 것이 가능해졌어요. 그래서 3.3.14부터 섹터 안의 어떤 페이지든 저널 파일에 쓰여질 때 그 같은 섹터의 모든 페이지가 함께 저장돼요.
섹터를 쓰는 동안 정전 후 데이터베이스 손상을 방지하려면 섹터의 모든 페이지를 롤백 저널에 저장하는 것이 중요해요. 페이지 1, 2, 3, 4가 모두 섹터 1에 저장되어 있고 페이지 2가 수정된다고 가정해 보세요. 페이지 2의 변경을 쓰려면 하드웨어가 완전한 섹터를 써야 하므로 페이지 1, 3, 4의 콘텐츠도 다시 써야 해요. 이 쓰기 연산이 정전으로 중단되면 페이지 1, 3, 4 중 하나 이상이 잘못된 데이터로 남을 수 있어요. 그러므로 데이터베이스에 지속적인 손상을 피하려면 그 모든 페이지의 원래 콘텐츠가 롤백 저널에 담겨 있어야 해요.
6.2. 저널 파일에 쓰여진 쓰레기 처리 (Dealing With Garbage Written Into Journal Files)
롤백 저널 끝에 데이터가 추가될 때 SQLite는 보통 파일이 먼저 유효하지 않은 "쓰레기" 데이터로 확장되고 나중에 올바른 데이터가 쓰레기를 대체한다는 비관적 가정을 해요. 다시 말해 SQLite는 파일 크기가 먼저 증가하고 그 후 콘텐츠가 파일에 쓰여진다고 가정해요. 파일 크기가 증가한 후 파일 콘텐츠가 쓰여지기 전에 정전이 발생하면 롤백 저널이 쓰레기 데이터를 담은 채 남을 수 있어요. 전원 복구 후 다른 SQLite 프로세스가 쓰레기 데이터를 담은 롤백 저널을 보고 그것을 원래 데이터베이스 파일로 롤백하려고 하면, 쓰레기의 일부를 데이터베이스 파일로 복사해 데이터베이스 파일을 손상시킬 수 있어요.
SQLite는 이 문제에 대해 두 가지 방어를 사용해요. 첫째, SQLite는 롤백 저널의 페이지 수를 롤백 저널 헤더에 기록해요. 이 숫자는 처음에 0이에요. 그래서 불완전한(그리고 아마 손상된) 롤백 저널을 롤백하려는 시도 동안, 롤백을 수행하는 프로세스는 저널이 0페이지를 담고 있음을 보고 데이터베이스에 어떤 변경도 하지 않아요. 커밋 전에 롤백 저널은 디스크에 플러시되어 모든 콘텐츠가 디스크에 동기화되고 파일에 "쓰레기"가 남지 않음을 보장하며, 그 후에야 헤더의 페이지 수가 0에서 롤백 저널의 페이지 수로 변경돼요. 롤백 저널 헤더는 항상 페이지 데이터와 별도의 섹터에 유지되어, 정전이 발생해도 데이터 페이지에 손상을 줄 위험 없이 덮어쓰고 플러시할 수 있어요. 롤백 저널이 두 번 플러시된다는 것을 주목하세요: 한 번은 페이지 콘텐츠를 쓰기 위해, 두 번째는 헤더의 페이지 수를 쓰기 위해요.
이전 문단은 synchronous pragma 설정이 "full"일 때 무슨 일이 일어나는지 설명해요.
PRAGMA synchronous=FULL;
기본 synchronous 설정은 full이므로 위가 보통 일어나는 일이에요. 하지만 synchronous 설정이 "normal"로 낮춰지면 SQLite는 페이지 수가 쓰여진 후 롤백 저널을 한 번만 플러시해요. 이것은 수정된(0이 아닌) 페이지 수가 모든 데이터보다 먼저 디스크 표면에 도달할 수 있기 때문에 손상 위험을 수반해요. 데이터가 먼저 쓰여졌겠지만, SQLite는 기본 파일시스템이 쓰기 요청을 재정렬할 수 있고 페이지 수가 마지막에 쓰여졌어도 먼저 구워질 수 있다고 가정해요. 그래서 두 번째 방어선으로 SQLite는 롤백 저널의 모든 데이터 페이지에 32-bit 체크섬도 사용해요. 이 체크섬은 4.4 섹션에서 설명한 대로 저널을 롤백하는 동안 각 페이지에 대해 평가돼요. 잘못된 체크섬이 보이면 롤백이 중단돼요. 체크섬은 데이터가 손상되었더라도 체크섬이 맞을 작지만 유한한 확률이 있으므로 페이지 데이터가 올바르다고 보장하지는 않는다는 점에 주목하세요. 하지만 체크섬은 적어도 그런 오류를 가능성이 낮게 만들어요.
synchronous 설정이 FULL이면 롤백 저널의 체크섬이 필요하지 않다는 점에 주목하세요. 우리는 synchronous가 NORMAL로 낮춰졌을 때만 체크섬에 의존해요. 그럼에도 체크섬은 결코 해가 되지 않으므로 synchronous 설정과 무관하게 롤백 저널에 포함돼요.
6.3. 커밋 전 캐시 유출 (Cache Spill Prior To Commit)
3.0 섹션에서 보여준 커밋 프로세스는 모든 데이터베이스 변경이 커밋할 때까지 메모리에 들어맞는다고 가정해요. 이것이 일반적인 경우예요. 하지만 때때로 더 큰 변경이 트랜잭션 커밋 전에 사용자 공간 캐시를 넘칠 수 있어요. 그런 경우 캐시는 트랜잭션이 완료되기 전에 데이터베이스로 유출(spill)되어야 해요.
캐시 유출 시작 시 데이터베이스 연결의 상태는 3.6 단계에 보인 것과 같아요. 원래 페이지 콘텐츠가 롤백 저널에 저장되었고 페이지의 수정이 사용자 메모리에 존재해요. 캐시를 유출하기 위해 SQLite는 3.7부터 3.9까지의 단계를 실행해요. 다시 말해 롤백 저널이 디스크에 플러시되고, 배타적 잠금이 획득되며, 변경이 데이터베이스에 쓰여져요. 하지만 나머지 단계는 트랜잭션이 정말 커밋될 때까지 연기돼요. 롤백 저널 끝에(자신의 섹터에) 새 저널 헤더가 추가되고 배타적 데이터베이스 잠금이 유지되지만, 그 밖의 처리는 3.6 단계로 돌아가요. 트랜잭션이 커밋되거나 또 다른 캐시 유출이 발생하면 3.7과 3.9 단계가 반복돼요. (첫 번째 통과로 인해 배타적 데이터베이스 잠금이 이미 유지되므로 3.8 단계는 두 번째 및 이후 통과에서 생략돼요.)
캐시 유출은 데이터베이스 파일 잠금이 reserved에서 배타적으로 승격되게 해요. 이것은 동시성을 줄여요. 캐시 유출은 또한 추가 디스크 플러시나 fsync 연산을 발생시키고 이 연산들은 느리므로, 캐시 유출은 성능을 심각하게 떨어뜨릴 수 있어요. 이런 이유로 캐시 유출은 가능할 때마다 피해져요.
7. 최적화 (Optimizations)
프로파일링은 대부분의 시스템과 대부분의 상황에서 SQLite가 대부분의 시간을 디스크 I/O를 하는 데 보낸다는 것을 나타내요. 따라서 디스크 I/O 양을 줄이기 위해 할 수 있는 것은 무엇이든 SQLite의 성능에 큰 긍정적 영향을 미칠 가능성이 높아요. 이 섹션은 원자적 커밋을 유지하면서 디스크 I/O 양을 최소로 줄이기 위해 SQLite가 사용하는 몇 가지 기법을 설명해요.
7.1. 트랜잭션 사이에 유지되는 캐시 (Cache Retained Between Transactions)
커밋 프로세스의 3.12 단계는 공유 잠금이 해제되면 데이터베이스 콘텐츠의 모든 사용자 공간 캐시 이미지를 버려야 함을 보여줘요. 공유 잠금이 없으면 다른 프로세스가 데이터베이스 파일 콘텐츠를 자유롭게 수정할 수 있어 그 콘텐츠의 어떤 사용자 공간 이미지든 구식이 될 수 있기 때문에 이렇게 해요. 결과적으로 각 새 트랜잭션은 이전에 읽었던 데이터를 다시 읽는 것으로 시작할 거예요. 읽히는 데이터가 운영 체제 파일 캐시에 여전히 있을 가능성이 높으므로 이것은 처음 들리는 것만큼 나쁘지 않아요. 그래서 "읽기"는 정말로 커널 공간에서 사용자 공간으로의 데이터 복사일 뿐이에요. 하지만 그런데도 여전히 시간이 걸려요.
SQLite 버전 3.3.14부터 불필요한 데이터 재읽기를 줄이려는 메커니즘이 추가됐어요. 더 최근 버전의 SQLite에서 사용자 공간 pager 캐시의 데이터는 데이터베이스 파일 잠금이 해제될 때 유지돼요. 나중에 다음 트랜잭션 시작 시 공유 잠금이 획득된 후, SQLite는 다른 프로세스가 데이터베이스 파일을 수정했는지 확인해요. 잠금이 마지막으로 해제된 이후 데이터베이스가 어떤 식으로든 변경되었다면 사용자 공간 캐시가 그 지점에서 지워져요. 하지만 보통 데이터베이스 파일은 변경되지 않아 사용자 공간 캐시를 유지할 수 있고, 일부 불필요한 읽기 연산을 피할 수 있어요.
데이터베이스 파일이 변경되었는지 판단하기 위해 SQLite는 모든 변경 연산 중에 증가하는 데이터베이스 헤더의 카운터(바이트 24~27)를 사용해요. SQLite는 데이터베이스 잠금을 해제하기 전에 이 카운터의 사본을 저장해요. 그런 다음 다음 데이터베이스 잠금을 획득한 후 저장된 카운터 값을 현재 카운터 값과 비교해, 값이 다르면 캐시를 지우고 같으면 캐시를 재사용해요.
7.2. 배타적 접근 모드 (Exclusive Access Mode)
SQLite 버전 3.3.14는 "배타적 접근 모드(Exclusive Access Mode)"라는 개념을 추가해요. 배타적 접근 모드에서 SQLite는 각 트랜잭션의 끝에서 배타적 데이터베이스 잠금을 유지해요. 이것은 다른 프로세스가 데이터베이스에 접근하는 것을 막지만, 많은 배포에서 단일 프로세스만 데이터베이스를 사용하므로 심각한 문제가 아니에요. 배타적 접근 모드의 장점은 디스크 I/O가 세 가지 방식으로 줄어들 수 있다는 것이에요:
- 첫 트랜잭션 이후의 트랜잭션에 대해 데이터베이스 헤더의 변경 카운터를 증가시킬 필요가 없어요. 이것은 종종 페이지 1의 쓰기를 롤백 저널과 메인 데이터베이스 파일 양쪽 모두에 저장하게 해 줘요.
- 다른 프로세스가 데이터베이스를 변경할 수 없으므로 트랜잭션 시작 시 변경 카운터를 확인하고 사용자 공간 캐시를 지울 필요가 전혀 없어요.
- 각 트랜잭션은 저널 파일을 삭제하는 대신 롤백 저널 헤더를 0으로 덮어써서 커밋될 수 있어요. 이는 저널 파일의 디렉토리 항목을 수정할 필요를 피하고 저널과 연관된 디스크 섹터를 할당 해제할 필요를 피해요. 게다가 다음 트랜잭션은 새 콘텐츠를 추가하는 대신 기존 저널 파일 콘텐츠를 덮어쓸 것이고, 대부분의 시스템에서 덮어쓰기가 추가보다 훨씬 빠르기 때문이에요.
세 번째 최적화인 저널 파일 삭제 대신 저널 파일 헤더를 0으로 만드는 것은 항상 배타적 잠금을 유지하는 것에 의존하지 않아요. 이 최적화는 아래 7.6 섹션에서 설명한 대로 journal_mode pragma를 사용해 배타적 잠금 모드와 독립적으로 설정할 수 있어요.
7.3. 프리리스트 페이지 저널링하지 않기 (Do Not Journal Freelist Pages)
SQLite 데이터베이스에서 정보가 삭제될 때, 삭제된 정보를 담는 데 사용되던 페이지가 "freelist"에 추가돼요. 이후 삽입은 데이터베이스 파일을 확장하는 대신 이 freelist에서 페이지를 가져올 거예요.
일부 freelist 페이지는 중요한 데이터, 특히 다른 freelist 페이지의 위치를 담고 있어요. 하지만 대부분의 freelist 페이지는 유용한 것을 담지 않아요. 후자의 freelist 페이지들은 "leaf" 페이지라고 불러요. 우리는 데이터베이스의 의미를 어떤 식으로든 바꾸지 않고 데이터베이스에서 leaf freelist 페이지의 콘텐츠를 자유롭게 수정할 수 있어요.
leaf freelist 페이지의 콘텐츠는 중요하지 않기 때문에 SQLite는 커밋 프로세스의 3.5 단계에서 leaf freelist 페이지 콘텐츠를 롤백 저널에 저장하는 것을 피해요. leaf freelist 페이지가 변경되고 그 변경이 트랜잭션 복구 중에 롤백되지 않더라도 생략으로 인해 데이터베이스는 해를 입지 않아요. 마찬가지로 새 freelist 페이지의 콘텐츠는 3.9 단계에서 데이터베이스에 다시 쓰여지지도 않고 3.3 단계에서 데이터베이스에서 읽히지도 않아요. 이 최적화는 여유 공간을 담고 있는 데이터베이스 파일에 변경을 할 때 발생하는 I/O 양을 크게 줄일 수 있어요.
7.4. 단일 페이지 갱신과 원자적 섹터 쓰기 (Single Page Updates And Atomic Sector Writes)
SQLite 버전 3.5.0부터 새 가상 파일 시스템(VFS) 인터페이스는 기본 대용량 저장 장치가 가질 수 있는 특별한 속성을 보고하는 xDeviceCharacteristics라는 메서드를 포함해요. xDeviceCharacteristics가 보고할 수 있는 특별한 속성 중에는 원자적 섹터 쓰기를 하는 능력이 있어요.
기본적으로 SQLite는 섹터 쓰기가 선형적이지만 원자적이지 않다고 가정한다는 것을 기억하세요. 선형적 쓰기는 섹터의 한쪽 끝에서 시작해 다른 쪽 끝에 도달할 때까지 바이트 단위로 정보를 변경해요. 선형적 쓰기 도중 정전이 발생하면 섹터의 일부는 수정되고 다른 쪽 끝은 변경되지 않을 수 있어요. 원자적 섹터 쓰기에서는 섹터 전체가 덮어써지거나 섹터의 어떤 것도 변경되지 않아요.
우리는 대부분의 현대 디스크 드라이브가 원자적 섹터 쓰기를 구현한다고 믿어요. 전원이 끊기면 드라이브는 커패시터와/또는 디스크 플래터의 각운동량에 저장된 에너지를 사용해 진행 중인 연산을 완료하기 위한 전력을 제공해요. 그럼에도 write 시스템 호출과 온보드 디스크 드라이브 전자장치 사이에는 너무 많은 계층이 있어서, 우리는 Unix와 w32 VFS 구현 모두에서 안전한 접근을 취해 섹터 쓰기가 원자적이지 않다고 가정해요. 다른 한편, 파일시스템에 대한 더 많은 제어권을 가진 장치 제조사는 하드웨어가 정말 원자적 쓰기를 한다면 xDeviceCharacteristics의 원자적 쓰기 속성을 활성화하는 것을 고려하고 싶어 할 수 있어요.
섹터 쓰기가 원자적이고 데이터베이스의 페이지 크기가 섹터 크기와 같으며, 단일 데이터베이스 페이지만 건드리는 데이터베이스 변경이 있을 때, SQLite는 저널링과 동기화 과정 전체를 건너뛰고 수정된 페이지를 데이터베이스 파일에 직접 쓰기만 해요. 데이터베이스 파일 첫 페이지의 변경 카운터는 별도로 수정돼요. 변경 카운터가 갱신되기 전에 전원이 끊겨도 해가 없기 때문이에요.
7.5. 안전한 추가 의미론을 가진 파일시스템 (Filesystems With Safe Append Semantics)
SQLite 버전 3.5.0에 도입된 또 다른 최적화는 기본 디스크의 "안전한 추가(safe append)" 동작을 활용해요. SQLite가 데이터가 파일(특히 롤백 저널)에 추가될 때 파일의 크기가 먼저 증가하고 콘텐츠가 두 번째로 쓰여진다고 가정한다는 것을 기억하세요. 그래서 파일 크기가 증가한 후 콘텐츠가 쓰여지기 전에 전원이 끊기면 파일이 유효하지 않은 "쓰레기" 데이터를 담은 채 남아요. 하지만 VFS의 xDeviceCharacteristics 메서드는 파일시스템이 "안전한 추가" 의미론을 구현한다고 나타낼 수 있어요. 이는 콘텐츠가 파일 크기가 증가하기 전에 쓰여져서 정전이나 시스템 크래시에 의해 롤백 저널에 쓰레기가 도입되는 것이 불가능함을 의미해요.
파일시스템에 대해 안전한 추가 의미론이 표시되면 SQLite는 롤백 저널 헤더의 페이지 수에 항상 특별한 값 -1을 저장해요. -1 페이지 수 값은 저널을 롤백하려는 어떤 프로세스에게도 저널의 페이지 수가 저널 크기로부터 계산되어야 함을 알려줘요. 이 -1 값은 결코 변경되지 않아요. 그래서 커밋이 발생할 때 우리는 단일 플러시 연산과 저널 파일 첫 페이지의 섹터 쓰기를 절약해요. 게다가 캐시 유출이 발생할 때 저널 끝에 새 저널 헤더를 추가할 필요가 더 이상 없어요. 우리는 단순히 기존 저널 끝에 새 페이지를 계속 추가하면 돼요.
7.6. 지속적인 롤백 저널 (Persistent Rollback Journals)
많은 시스템에서 파일 삭제는 비싼 연산이에요. 그래서 최적화로 SQLite는 3.11 섹션의 삭제 연산을 피하도록 구성될 수 있어요. 트랜잭션을 커밋하기 위해 저널 파일을 삭제하는 대신, 파일을 길이 0바이트로 자르거나 헤더를 0으로 덮어써요. 파일을 길이 0으로 자르는 것은 파일이 디렉토리에서 제거되지 않으므로 파일을 담고 있는 디렉토리를 수정할 필요를 줄여 줘요. 헤더를 덮어쓰는 것은 (많은 시스템의 "inode"에서) 파일의 길이를 갱신하지 않아도 되고 새로 해제된 디스크 섹터를 처리하지 않아도 된다는 추가 절약이 있어요. 게다가 다음 트랜잭션에서 저널은 파일 끝에 새 콘텐츠를 추가하는 대신 기존 콘텐츠를 덮어써서 생성되며, 덮어쓰기는 종종 추가보다 훨씬 빠르기 때문이에요.
SQLite는 journal_mode PRAGMA로 "PERSIST" 저널링 모드를 설정해, 저널 파일을 삭제하는 대신 저널 헤더를 0으로 덮어써 트랜잭션을 커밋하도록 구성될 수 있어요. 예를 들어:
PRAGMA journal_mode=PERSIST;
지속 저널 모드(persistent journal mode)의 사용은 많은 시스템에서 눈에 띄는 성능 향상을 제공해요. 물론 단점은 저널 파일이 트랜잭션 커밋 후에도 오랫동안 디스크에 남아 디스크 공간을 사용하고 디렉토리를 어지럽힌다는 것이에요. 지속 저널 파일을 삭제하는 유일한 안전한 방법은 저널링 모드를 DELETE로 설정해 트랜잭션을 커밋하는 것이에요:
PRAGMA journal_mode=DELETE;
BEGIN EXCLUSIVE;
COMMIT;
다른 수단으로 지속 저널 파일을 삭제하는 것을 주의하세요. 저널 파일이 핫할 수 있고, 그 경우 삭제하면 해당 데이터베이스 파일이 손상될 것이기 때문이에요.
SQLite 버전 3.6.4 (2008-10-15)부터 TRUNCATE 저널 모드도 지원돼요:
PRAGMA journal_mode=TRUNCATE;
truncate 저널 모드에서는 저널 파일을 삭제하는 대신(DELETE 모드처럼) 또는 헤더를 0으로 만드는 대신(PERSIST 모드처럼) 저널 파일을 길이 0으로 잘라내 트랜잭션을 커밋해요. TRUNCATE 모드는 저널 파일과 데이터베이스를 담고 있는 디렉토리를 갱신할 필요가 없다는 PERSIST 모드의 장점을 공유해요. 그래서 파일을 자르는 것은 종종 삭제하는 것보다 빠르고, TRUNCATE는 변화를 디스크에 동기화하기 위한 시스템 호출(예: fsync())이 뒤따르지 않는다는 추가 장점이 있어요. 그러면 더 안전할 수도 있겠지만요. 하지만 많은 현대 파일시스템에서 truncate는 원자적이고 동기적인 연산이므로 우리는 TRUNCATE가 정전을 견디는 데 보통 안전할 것이라고 생각해요. TRUNCATE가 여러분의 파일시스템에서 동기적이고 원자적일지 확신이 없고, truncation 연산 중에 발생하는 정전이나 운영 체제 크래시에서 데이터베이스가 살아남는 것이 중요하다면, 다른 저널링 모드를 사용하는 것을 고려해 보세요.
동기적 파일시스템을 가진 임베디드 시스템에서 TRUNCATE는 PERSIST보다 더 느린 동작을 낳아요. 커밋 연산은 같은 속도예요. 하지만 TRUNCATE 후에는 이후 트랜잭션이 더 느려요. 파일 끝에 추가하는 것보다 기존 콘텐츠를 덮어쓰는 것이 더 빠르기 때문이에요. 새 저널 파일 항목은 TRUNCATE 후에는 항상 추가되지만 PERSIST에서는 보통 덮어써져요.
8. 원자적 커밋 동작 테스트 (Testing Atomic Commit Behavior)
SQLite 개발자들은 자동 테스트 절차가 시뮬레이션된 정전에서 복구하는 SQLite의 능력에 대한 광범위한 검사를 하기 때문에 SQLite가 정전과 시스템 크래시에 견고하다고 확신해요. 우리는 이것을 "크래시 테스트(crash tests)"라고 불러요.
SQLite의 크래시 테스트는 정전이나 운영 체제 크래시 중에 발생하는 종류의 파일시스템 손상을 시뮬레이션할 수 있는 수정된 VFS를 사용해요. 크래시-테스트 VFS는 불완전한 섹터 쓰기, 쓰기가 완료되지 않아 쓰레기 데이터로 채워진 페이지, 순서가 어긋난 쓰기를 시뮬레이션할 수 있으며, 이 모두가 테스트 시나리오 동안 다양한 지점에서 발생해요. 크래시 테스트는 시뮬레이션된 정전이 발생하는 시점과 가해진 손상의 속성을 다양하게 바꾸면서 트랜잭션을 계속해서 실행해요. 각 테스트는 시뮬레이션된 크래시 후 데이터베이스를 다시 열고, 트랜잭션이 완전히 발생했거나 전혀 발생하지 않았으며 데이터베이스가 완전히 일관된 상태인지 검증해요.
SQLite의 크래시 테스트는 복구 메커니즘에서 여러 매우 미묘한 버그(지금은 수정됨)를 발견했어요. 그 버그 중 일부는 매우 난해해서 코드 검사와 분석 기법만으로는 발견될 가능성이 낮았어요. 이 경험에서 SQLite 개발자들은 비슷한 크래시 테스트 시스템을 사용하지 않는 다른 어떤 데이터베이스 시스템도, 시스템 크래시나 정전 후 데이터베이스 손상으로 이어질 검출되지 않은 버그를 포함할 가능성이 있다고 확신해요.
9. 잘못될 수 있는 것들 (Things That Can Go Wrong)
SQLite의 원자적 커밋 메커니즘은 견고한 것으로 입증됐지만, 충분히 창의적인 적대자나 충분히 고장난 운영 체제 구현에 의해 우회될 수 있어요. 이 섹션은 SQLite 데이터베이스가 정전이나 시스템 크래시로 손상될 수 있는 몇 가지 방법을 설명해요. (참고: How To Corrupt Your Database Files).
9.1. 고장난 잠금 구현 (Broken Locking Implementations)
SQLite는 한 번에 한 프로세스와 데이터베이스 연결만 데이터베이스를 수정하려고 하는지 확인하기 위해 파일시스템 잠금을 사용해요. 파일시스템 잠금 메커니즘은 VFS 계층에서 구현되며 운영 체제마다 다르고, SQLite는 이 구현이 올바르다고 의존해요. 뭔가 잘못되어 두 개 이상의 프로세스가 동시에 같은 데이터베이스 파일에 쓸 수 있게 되면 심각한 손상이 발생할 수 있어요.
우리는 Windows 네트워크 파일시스템과 NFS 양쪽 모두에서 잠금이 미묘하게 고장난 구현에 대한 보고를 받았어요. 이 보고를 검증할 수는 없지만, 네트워크 파일시스템에서 잠금을 올바르게 하는 것이 어렵기 때문에 의심할 이유가 없어요. 어쨌든 성능이 느려질 것이므로 네트워크 파일시스템에서 SQLite를 사용하는 것을 애초에 피하는 것이 좋아요. 하지만 네트워크 파일시스템에 SQLite 데이터베이스 파일을 저장해야 한다면, 네이티브 파일시스템 잠금 메커니즘이 오작동해도 같은 데이터베이스에 대한 동시 쓰기를 막는 이차 잠금 메커니즘을 사용하는 것을 고려해 보세요.
Apple Mac OS X 컴퓨터에 미리 설치되어 오는 SQLite 버전에는 Apple이 지원하는 모든 네트워크 파일시스템에서 동작하는 대안 잠금 전략을 사용하도록 확장된 SQLite 버전이 포함되어 있어요. Apple이 사용하는 이 확장은 모든 프로세스가 같은 방식으로 데이터베이스 파일에 접근하는 한 훌륭하게 동작해요. 안타깝게도 잠금 메커니즘들은 서로 배타적이지 않아서, 한 프로세스가 (예를 들어) AFP 잠금을 사용해 파일에 접근하고 다른 프로세스(아마 다른 머신에서)가 dot-file 잠금을 사용한다면, AFP 잠금이 dot-file 잠금을 배제하지 않거나 그 반대이기 때문에 두 프로세스가 충돌할 수 있어요.
9.2. 불완전한 디스크 플러시 (Incomplete Disk Flushes)
SQLite는 3.7 단계과 3.10 단계에서 보여준 것처럼 파일시스템 버퍼를 디스크 표면에 동기화하기 위해 Unix에서 fsync() 시스템 호출을, w32에서 FlushFileBuffers() 시스템 호출을 사용해요. 안타깝게도 이 두 인터페이스 모두 많은 시스템에서 광고된 대로 동작하지 않는다는 보고를 받았어요. FlushFileBuffers()가 일부 Windows 버전에서 레지스트리 설정으로 완전히 비활성화될 수 있다고 들었어요. 일부 역사적인 Linux 버전은 일부 파일시스템에서 no-op인 fsync() 버전을 담고 있다고 들었어요. FlushFileBuffers()와 fsync()가 동작한다고 말해지는 시스템에서조차, 종종 IDE 디스크 컨트롤러가 데이터가 휘발성 컨트롤 캐시에만 보유되고 있는데도 데이터가 디스크 표면에 도달했다고 거짓말해요.
Mac에서는 다음 pragma를 설정할 수 있어요:
PRAGMA fullfsync=ON;
Mac에서 fullfsync를 설정하면 데이터가 플러시 시 정말로 디스크 플래터로 밀려난다는 것을 보장해요. 하지만 fullfsync의 구현은 디스크 컨트롤러를 재설정하는 것을 수반해요. 그래서 그것은 엄청나게 느릴 뿐 아니라, 관련 없는 다른 디스크 I/O도 느리게 해요. 그래서 그 사용은 권장되지 않아요.
9.3. 부분 파일 삭제 (Partial File Deletions)
SQLite는 사용자 프로세스의 관점에서 파일 삭제가 원자적 연산이라고 가정해요. 파일 삭제 도중 전원이 끊기면, 전원 복구 후 SQLite는 원래 데이터가 모두 온전한 전체 파일을 보거나, 아니면 파일을 전혀 찾지 못하기를 기대해요. 이 방식으로 동작하지 않는 시스템에서는 트랜잭션이 원자적이지 않을 수 있어요.
9.4. 파일에 쓰여진 쓰레기 (Garbage Written Into Files)
SQLite 데이터베이스 파일은 일반 사용자 프로세스가 열고 쓸 수 있는 보통의 디스크 파일이에요. 불량 프로세스가 SQLite 데이터베이스를 열고 손상된 데이터로 채울 수 있어요. 손상된 데이터는 운영 체제나 디스크 컨트롤러의 버그, 특히 정전에 의해 촉발된 버그에 의해 SQLite 데이터베이스에 도입될 수도 있어요. SQLite가 이런 종류의 문제에 대해 방어할 수 있는 방법은 없어요.
9.5. 핫 저널 삭제 또는 이름 변경 (Deleting Or Renaming A Hot Journal)
크래시나 정전이 발생해 핫 저널이 디스크에 남는다면, 원래 데이터베이스 파일과 핫 저널이, 데이터베이스 파일이 다른 SQLite 프로세스에 의해 열리고 롤백될 때까지 원래 이름으로 디스크에 남아 있는 것이 필수적이에요. 4.2 단계의 복구 동안 SQLite는 열리고 있는 데이터베이스와 같은 디렉토리에서, 열리고 있는 파일의 이름에서 파생된 이름을 가진 파일을 찾아 핫 저널을 찾아요. 원래 데이터베이스 파일이나 핫 저널이 이동되거나 이름이 변경되면 핫 저널이 보이지 않고 데이터베이스가 롤백되지 않을 거예요.
우리는 SQLite 복구의 흔한 실패 모드가 이렇게 발생한다고 의심해요: 정전이 발생한다. 전원 복구 후 선의의 사용자나 시스템 관리자가 디스크에서 손상을 찾기 시작한다. 그들은 "important.data"라는 이름의 데이터베이스 파일을 본다. 이 파일은 아마 그들에게 친숙할 것이다. 하지만 크래시 후 "important.data-journal"이라는 이름의 핫 저널도 있다. 그러면 사용자는 시스템을 정리하는 데 도움이 된다고 생각하며 핫 저널을 삭제한다. 우리는 사용자 교육 외에는 이것을 막을 방법을 모른다.
데이터베이스 파일에 여러 (하드 또는 심볼릭) 링크가 있으면 저널은 파일이 열린 링크의 이름을 사용해 생성된다. 크래시가 발생하고 데이터베이스가 다른 링크를 사용해 다시 열리면 핫 저널이 위치하지 않고 롤백이 발생하지 않을 거예요.
때때로 정전은 파일시스템이 손상되어 최근에 변경된 파일 이름이 잊혀지고 파일이 "/lost+found" 디렉토리로 이동하게 만들어요. 그런 일이 발생하면 핫 저널이 발견되지 않고 복구가 발생하지 않아요. SQLite는 롤백 저널 파일 자체를 동기화할 때 롤백 저널을 담고 있는 디렉토리를 동시에 열고 동기화함으로써 이를 방지하려고 해요. 하지만 /lost+found로의 파일 이동은 메인 데이터베이스 파일과 같은 디렉토리에 관련 없는 파일을 만드는 관련 없는 프로세스에 의해 발생할 수 있어요. 그리고 이것은 SQLite의 통제를 벗어나므로 SQLite가 그것을 막을 수 있는 방법은 없어요. 이런 종류의 파일시스템 네임스페이스 손상에 취약한 시스템(대부분의 현대 저널링 파일시스템은 면역이라고 믿어요)에서 실행 중이라면, 각 SQLite 데이터베이스 파일을 자신의 전용 하위 디렉토리에 두는 것을 고려해 보세요.
10. 미래 방향과 결론 (Future Directions And Conclusion)
가끔씩 누군가 SQLite의 원자적 커밋 메커니즘의 새 실패 모드를 발견하고 개발자들이 패치를 넣어야 해요. 이것은 점점 덜 발생하고 있고 실패 모드는 점점 더 난해해지고 있어요. 하지만 SQLite의 원자적 커밋 로직이 완전히 버그가 없다고 가정하는 것은 여전히 어리석은 일이에요. 개발자들은 이 버그들이 발견되는 대로 가능한 한 빨리 고치기로 결심하고 있어요.
개발자들은 또한 커밋 메커니즘을 최적화할 새 방법을 찾고 있어요. Unix(Linux와 Mac OS X)와 Windows용 현재 VFS 구현은 그 시스템들의 동작에 대해 비관적인 가정을 해요. 이 시스템들이 어떻게 동작하는지에 대한 전문가와의 상의 후에, 우리는 이 시스템에 대한 가정 중 일부를 완화해 더 빠르게 실행되게 할 수도 있어요. 특히, 우리는 대부분의 현대 파일시스템이 안전한 추가 속성을 보이고 많은 것이 원자적 섹터 쓰기를 지원할 것이라고 의심해요. 하지만 이것이 확실히 알려질 때까지 SQLite는 보수적인 접근을 취해 최악을 가정할 거예요.