SQLite WAL에서 SQLITE_BUSY_SNAPSHOT 해결하기
WAL 읽기 스냅샷이 오래되면 쓰기 전환이 실패하는 이유를 재현하고, 트랜잭션 재시도와 BEGIN IMMEDIATE의 차이를 살펴본다.
SQLite WAL 모드에서 읽은 값을 바탕으로 업데이트하다가 SQLITE_BUSY_SNAPSHOT이 발생하면, 현재 트랜잭션을 롤백하고 새 트랜잭션에서 읽기부터 다시 시작한다. 쓰기가 예정된 짧은 트랜잭션은 첫 조회 전에 BEGIN IMMEDIATE로 시작할 수 있다. Python의 로컬 연결 두 개로 오래된 읽기 스냅샷과 일반적인 쓰기 경합을 재현한다.
겹쳐진 청록색 데이터 판 중 새 버전 옆에 주황색 쓰기 표시가 있는 일러스트
읽기에 성공했는데 쓰기가 실패하는 이유
WAL에서 읽기 트랜잭션은 읽기를 시작했을 때의 일관된 스냅샷을 본다. 연결 A가 값 0을 읽고 있는 동안 연결 B가 10으로 바꾸고 커밋하면, A는 같은 트랜잭션에서 계속 0을 본다. 이때 A가 쓰기를 시도하면 SQLite는 이미 오래된 스냅샷에서 분기하는 쓰기를 허용하지 않고 SQLITE_BUSY_SNAPSHOT을 반환한다. B의 쓰기가 끝나도 A의 스냅샷은 그대로이므로, 대기 시간을 늘리는 것으로 해결할 수 없다. SQLite 격리 문서 · 확장 오류 코드
두 연결로 오류와 해결 절차 재현하기
아래 코드를 wal_snapshot.py로 저장하고 python3 wal_snapshot.py를 실행한다. Python 3.12의 autocommit=True를 사용하고, 여러 문장을 묶는 트랜잭션은 SQL의 BEGIN, ROLLBACK, COMMIT으로 제어한다. B의 단독 업데이트는 문장이 끝날 때 커밋된다. 두 연결은 같은 임시 파일을 사용한다. Python sqlite3 트랜잭션 문서
실행 결과에서 두 오류 구분하기
2026년 11월 12일 Linux에서 Python 3.12.14와 SQLite 3.53.1로 실행했다. A가 여전히 0을 볼 때 B가 10을 보는지, A의 쓰기 전환이 예상한 확장 오류 코드를 반환하는지 확인했다. 이후 A가 쓰기 트랜잭션을 먼저 시작하자 B의 BEGIN IMMEDIATE는 SQLITE_BUSY로 실패했다. A는 현재 값 10을 읽고 1을 더해 커밋했고, B가 새 값 11을 읽었다.
SQLITE_BUSY_SNAPSHOT에서는 롤백 후 업무 로직 전체를 다시 실행한다. 실패한 UPDATE만 반복하거나, 트랜잭션을 새로 열더라도 예전 조회값으로 업데이트하면 데이터가 틀어질 수 있다. 일반 SQLITE_BUSY는 이 예제에서 다른 쓰기 트랜잭션이 진행 중이라는 뜻이다. 쓰기 시작 시점에 경합을 처리할 수 있지만, 기다리는 시간에는 상한을 둔다. SQLite 트랜잭션 문서
쓰기를 먼저 확보할 때의 비용
BEGIN IMMEDIATE는 첫 조회 전에 쓰기 트랜잭션을 시작한다. WAL에서는 다른 연결이 계속 읽을 수 있지만 다른 쓰기는 기다리거나 실패할 수 있으므로, 트랜잭션 안에서 느린 네트워크 호출을 기다리지 않는다. 단순 증가처럼 단일 SQL 문으로 표현할 수 있다면 UPDATE counter SET value = value + 1로 별도 조회와 계산을 줄일 수 있다. 외부 API 호출처럼 롤백되지 않는 부작용이 포함된 작업은 트랜잭션 재시도와 별도로 중복 실행을 관리한다. 예제는 로컬 파일에서 연결 두 개의 실행 순서를 검증했다. 실제 부하나 재시도 지연은 측정하지 않았다. SQLite 격리 문서 · 트랜잭션 모드
English version ·
Fix SQLITE_BUSY_SNAPSHOT in SQLite WAL Mode