증상: 여러 작업이 같이 쓰는 SQLite 에 쓰는 배치가 중간에 database is locked 로 죽습니다.
원인: 잠금 대기(busy_timeout)가 8초로 짧았고, 외부 API를 부르는 동안 트랜잭션을 열어 두어 쓰기 잠금을 오래 쥐었습니다.
해결: busy_timeout 을 60000으로 늘리고, 결과는 메모리에 모았다가 짧게 한 번에 쓰고, 커밋에 재시도를 둡니다.
SQLite 는 파일 하나로 도는 가벼운 DB라 여러 크론이 같이 쓰기 편합니다. 그 대신 한 번에 한 쓰기만 허락하므로, 누군가 잠금을 오래 쥐면 다른 작업이 기다리다 실패합니다. 실제로 두 번 겪은 일을 적습니다.
두 가지 모습의 같은 오류
| 상황 | 보인 것 |
|---|---|
| 새 배치의 첫 실행 | 11편째에서 database is locked 로 죽음 |
| 다른 날 시험 실행 20개(8초) | 배치가 아니라 다른 작업 한 건이 같은 오류로 실패 |
| 상한 검사 | 4편 넣어야 할 곳에 2편만 들어감 |
첫 번째는 편마다 커밋하고 있어서 앞 10편은 남았습니다. 두 번째는 내 배치는 멀쩡했는데 옆에서 돌던 작업이 피해를 봤습니다.
이 DB는 화면용 자료를 모으는 작업, 예약 작업 등 여러 크론이 함께 쓰고 있었습니다. 각 작업을 따로 시험하면 문제가 없고, 시간대가 겹칠 때만 오류가 납니다. 그래서 새 배치를 만들 때 혼자 돌려 본 시험만으로는 이 문제를 미리 알기 어렵습니다. 오류가 내 작업에서 날지, 옆 작업에서 날지도 그때그때 다릅니다.
잠금 대기 8초
연결할 때 PRAGMA busy_timeout=8000 을 주고 있었습니다. 잠금이 풀리기를 8초까지 기다린다는 뜻입니다. 그런데 같은 DB를 쓰는 다른 크론이 8초 넘게 잠금을 잡는 일이 있었습니다.
API 를 부르는 동안 열린 트랜잭션
두 번째 배치는 외부 API 결과를 받을 때마다 한 줄씩 INSERT 하고, 500개마다 commit 했습니다. 파이썬 sqlite3 는 첫 INSERT 에서 트랜잭션을 열고 commit 할 때까지 쓰기 잠금을 쥡니다. API 를 기다리는 시간까지 잠금을 쥔 셈이라, 시험 20개 8초만으로도 다른 작업이 기다리다 실패했습니다.
미커밋 행을 두 번 셈
같은 연결에서 SELECT COUNT(*) 를 하면 방금 넣고 아직 커밋하지 않은 행도 셉니다. 여기에 "이번에 넣은 수"를 또 더하니 두 배로 세어, 상한의 절반만 넣고 멈췄습니다.
잠금 대기를 늘리고 트랜잭션을 짧게
1. 잠금 대기를 60초로 늘립니다. 다른 작업이 잠금을 길게 쥐더라도 바로 실패하지 않고 풀릴 때까지 기다리게 하려는 것입니다. 이것만으로 끝내지 않고 아래 2번과 3번을 함께 해야 내 작업이 남을 오래 기다리게 만드는 일도 줄어듭니다.
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA busy_timeout=60000")
2. 결과는 메모리에 모아 두었다가 쓸 때만 짧게 씁니다. API 를 부르는 동안에는 트랜잭션이 열려 있지 않게 합니다.
rows = []
for item in items:
rows.append(call_api(item)) # 이 동안 DB 잠금 없음
conn.executemany("INSERT INTO results(k, v) VALUES (?, ?)", rows)
commit_with_retry(conn)
3. 커밋이 잠금으로 실패하면 기다리는 시간을 5초씩 늘리며 5번까지 다시 합니다.
import time, sqlite3
def commit_with_retry(conn):
for i in range(5):
try:
conn.commit()
return
except sqlite3.OperationalError as e:
if "locked" not in str(e):
raise
time.sleep(5 * (i + 1))
raise RuntimeError("commit failed after 5 retries")
4. 상한 검사는 같은 연결의 COUNT 만 쓰고, 넣은 수를 따로 더하지 않습니다. 같은 연결의 COUNT 에는 이미 방금 넣은 행이 들어 있기 때문입니다. 이 실수는 오류가 나지 않고 결과만 절반이 되어서, 숫자를 직접 대조하기 전까지 알아채기 어려웠습니다.
오래 걸리는 배치라면 한 편 처리할 때마다 커밋하는 것도 도움이 됐습니다. 중간에 죽어도 앞부분은 남기 때문에 다시 돌릴 때 이어서 할 수 있습니다.
고쳐졌는지 확인하는 법
- 배치를 다른 크론이 도는 시간대에 일부러 겹쳐 돌려 보고, 자기 작업과 옆 작업 모두 오류가 없는지 로그를 봅니다.
- 상한이 있는 작업은 넣은 행 수를 커밋 뒤 새 연결로 세어 상한과 맞는지 봅니다.
sqlite3 /opt/app/shared.sqlite3 "PRAGMA busy_timeout;"
grep -n "database is locked" /opt/app/logs/*.log | tail -n 20
작은 재현도 해 봤습니다. 한 연결이 쓰기 잠금을 3초 동안 쥐게 해 두고 다른 연결로 INSERT 를 넣었습니다. busy_timeout 을 500으로 둔 연결은 바로 database is locked 로 실패했고, 60000으로 둔 연결은 2.6초 기다린 뒤 재시도 없이 성공했습니다. 커밋하지 않은 행 하나를 넣은 뒤 세어 보면 같은 연결은 5를, 새 연결은 4를 돌려줬습니다.
참고로 PRAGMA busy_timeout; 은 그 연결의 값만 보여 줍니다. 스크립트마다 연결할 때 따로 줘야 합니다.
크론 로그에서 이런 오류를 놓치지 않으려면 로그로 상태를 판정하다 틀리는 경우를 같이 보세요.
테스트한 환경
| 항목 | 내용 |
|---|---|
| 프로그램 | SQLite, 파이썬 sqlite3 모듈, 크론 여러 개가 같은 DB 사용 |
| 확인한 날짜 | 2026년 9월 14일, 9월 30일 |
관련 글: 파이썬 스크립트가 조용히 실패하는 경우, 리눅스 크론이 돈다고 찍히는데 실제로는 안 돌 때
이 글의 내용은 2026년 10월 5일에 마지막으로 확인했습니다. 틀린 곳을 발견하시면 연락처로 알려 주세요.