Aplikasi produksi jarang sendirian: web server memiliki banyak worker, aplikasi desktop memiliki banyak thread, dan cron berjalan di belakangnya. Semua pihak itu boleh membaca bersamaan, tetapi SQLite hanya mengizinkan satu penulis pada satu waktu — aturan yang sudah kita buktikan pada Chapter Isolation dan Locking. Chapter ini menyusun strategi bertingkat agar aturan tersebut tidak pernah berubah menjadi insiden di produksi.

Baseline: WAL dan busy_timeout #

Konfigurasi berikut bukan opsi tuning, melainkan baseline setiap deployment yang serius. Ketiganya sudah dibedah mendalam di bab masing-masing; tabel ini cukup menjadi pengingat:

PengaturanNilai lazimEfekBahasan
PRAGMA journal_mode=walwalpembaca dan penulis tidak saling menungguChapter Journal Modes dan WAL
PRAGMA synchronous=NORMALNORMALfsync hanya pada checkpoint; throughput naikChapter Journal Modes dan WAL
PRAGMA busy_timeout=5000milidetikkoneksi menunggu hingga 5 detik, bukan gagal seketikaChapter Isolation dan Locking

Ingat sifat pentingnya: ketiganya berlaku per-koneksi, jadi setiap koneksi baru harus memasangnya lagi. Mode WAL menempel permanen pada berkas, tetapi synchronous dan busy_timeout tidak.

Pola 1: Satu Jalur Tulis #

Pola paling aman: seluruh operasi tulis dari aplikasi mengalir melalui satu pintu — satu koneksi yang dipegang satu worker khusus. Handler request cukup mengirim pesan “tulis ini” ke antrean, dan worker tunggal mengeksekusinya satu per satu.

Konsekuensinya radikal: dua penulis tidak pernah berhadapan, sehingga galat database is locked antar penulis tidak mungkin terjadi — engine tidak perlu bertindak sebagai wasit. Pembaca tetap boleh banyak dan paralel karena mode WAL memisahkan pembaca dari penulis. Pola ini sekaligus fondasi untuk batching yang kita bahas beberapa bagian di bawah.

Pola 2: Coba-Ulang saat Terkunci #

busy_timeout menunggu di dalam engine, tetapi kontensi bisa bertahan lebih lama dari batas waktunya — dan saat itu terjadi, koneksi tetap menerima galat. Pola penyelamatnya: aplikasi menangkap galat tersebut, menunggu sebentar, lalu mencoba lagi dengan jumlah percobaan yang dibatasi. Simpan sebagai program lengkap retry.py:

python
import sqlite3
import time


def tulis_dengan_retry(conn, sql, param, maks=3, jeda=0.05):
    """Jalankan sql; bila database terkunci, coba lagi hingga maks kali."""
    for percobaan in range(1, maks + 1):
        try:
            conn.execute(sql, param)
            return percobaan
        except sqlite3.OperationalError:
            if percobaan == maks:
                raise
            print(f"percobaan {percobaan}: database is locked, menunggu...")
            time.sleep(jeda)


# siapkan database demo
db = sqlite3.connect("toko.db")
db.executescript("""
    CREATE TABLE stok (id INTEGER PRIMARY KEY, nama TEXT NOT NULL, sisa INTEGER NOT NULL);
    INSERT INTO stok VALUES (1, 'Kopi Gayo 200g', 40);
""")
db.commit()
db.close()

# dua koneksi terpisah; timeout=0 berarti busy_timeout 0 ms
a = sqlite3.connect("toko.db", timeout=0)
b = sqlite3.connect("toko.db", timeout=0)

# A membuka transaksi tulis dan memegang kunci penulis
a.execute("BEGIN IMMEDIATE")
a.execute("UPDATE stok SET sisa = sisa - 5 WHERE id = 1")

# B mencoba menulis lewat pola retry
try:
    tulis_dengan_retry(b, "UPDATE stok SET sisa = sisa - 2 WHERE id = 1", ())
except sqlite3.OperationalError as e:
    print(f"menyerah: {e}")

# A menyelesaikan transaksinya, kunci penulis lepas
a.commit()

percobaan = tulis_dengan_retry(b, "UPDATE stok SET sisa = sisa - 2 WHERE id = 1", ())
print(f"percobaan {percobaan}: sukses")
b.commit()

print("sisa akhir:", b.execute("SELECT sisa FROM stok WHERE id = 1").fetchone()[0])

Output yang Dihasilkan:

text
percobaan 1: database is locked, menunggu...
percobaan 2: database is locked, menunggu...
menyerah: database is locked
percobaan 1: sukses
sisa akhir: 33

Mari kita bedah bagian-bagian pentingnya:

  1. def tulis_dengan_retry(...): fungsi pembungkus yang bisa dipakai untuk semua statement tulis; maks membatasi percobaan agar request tidak menggantung selamanya.
  2. timeout=0: parameter timeout pada sqlite3.connect() adalah busy_timeout dalam satuan detik — bawaannya 5.0. Nilai 0 dipakai di sini agar kegagalan terjadi seketika dan siklus coba-ulangnya terlihat jelas.
  3. BEGIN IMMEDIATE: kunci penulis diambil di awal, bukan saat COMMIT — trik dari Chapter Isolation dan Locking yang membuat kegagalan terjadi dini, bukan setelah kerja selesai.
  4. Dua baris percobaan N: database is locked: dua percobaan pertama gagal karena koneksi A masih memegang kunci; setelah a.commit() melepaskannya, percobaan berikutnya langsung sukses.
  5. sisa akhir: 33: kedua UPDATE benar-benar tersimpan — 40 - 5 oleh A, lalu - 2 oleh B.

Pola 3: Menulis Seperlunya dengan Batching #

Chapter Autocommit dan Perilaku Default mengukur bahwa 1.000 INSERT autocommit memakan waktu sepuluh kali lipat daripada 1.000 INSERT dalam satu transaksi, karena setiap komit menjalankan siklus journal plus fsync. Di produksi, batching memberi bonus: seratus peristiwa hanya menahan kunci penulis satu kali, bukan seratus kali.

Idenya: kumpulkan peristiwa di memori, lalu kosongkan seluruh antrian dalam satu transaksi:

python
import sqlite3

db = sqlite3.connect("kejadian.db")
db.execute("""CREATE TABLE log_kejadian (
  id INTEGER PRIMARY KEY,
  kejadian TEXT NOT NULL,
  pengguna TEXT NOT NULL
)""")

antrian = []  # penampung peristiwa di memori, belum menyentuh database


def catat(kejadian, pengguna):
    antrian.append((kejadian, pengguna))


def flush():
    """Kosongkan seluruh antrian dalam satu transaksi."""
    db.execute("BEGIN")
    db.executemany(
        "INSERT INTO log_kejadian (kejadian, pengguna) VALUES (?, ?)",
        antrian,
    )
    db.commit()
    print(f"flush: {len(antrian)} peristiwa dalam satu transaksi")
    antrian.clear()


catat("login", "budi")
catat("lihat-produk", "budi")
catat("checkout", "budi")
catat("login", "siti")
catat("logout", "budi")

flush()

for baris in db.execute(
    "SELECT id, kejadian, pengguna FROM log_kejadian ORDER BY id"
):
    print("|".join(str(kolom) for kolom in baris))

Output yang Dihasilkan:

text
flush: 5 peristiwa dalam satu transaksi
1|login|budi
2|lihat-produk|budi
3|checkout|budi
4|login|siti
5|logout|budi

Tiga bagian yang perlu diperhatikan:

  1. catat(): hanya menambah ke list — tidak ada transaksi, tidak ada kunci, tidak ada sentuhan disk.
  2. flush(): satu BEGIN … COMMIT untuk seluruh antrian lewat executemany. Pada sistem nyata, flush() dipicu oleh ukuran (misalnya 100 baris) atau timer (misalnya setiap 5 detik).
  3. antrian.clear(): antrian dikosongkan setelah commit agar peristiwa tidak terkirim dua kali.

Harganya pun jujur: peristiwa yang masih di memori akan hilang bila proses mati mendadak. Ukuran batch adalah kompromi antara performa dan toleransi kehilangan — dan untuk data yang tidak boleh hilang, panggil flush() segera.

Transaksi Pendek, Koneksi, dan Worker Web #

Dua aturan pelengkap menutup strategi ini. Pertama: jangan pernah menggantungkan transaksi pada input pengguna.

Kedua: satu koneksi bukan milik bersama. Modul sqlite3 Python secara bawaan menolak koneksi yang dipakai thread lain (check_same_thread=True) dan memunculkan ProgrammingError: SQLite objects created in a thread can only be used in that same thread. Solusi lazim: satu koneksi per thread, atau kembalikan ke Pola 1 dengan satu thread penulis. Pada aplikasi web, setiap worker proses membuka koneksi sendiri — lima worker berarti lima koneksi, dan tulisannya tetap mengalir lewat satu antrean.

Dengan baseline WAL dan busy_timeout, satu jalur tulis, retry, plus batching, batas satu penulis berubah dari risiko menjadi karakteristik yang terkendali. Risiko berikutnya justru datang dari luar: bagaimana mencadangkan berkas yang terus ditulis tanpa menghentikan aplikasi — bahasan Chapter Backup di Produksi.