บทความ › การเขียนโปรแกรม

SQLite บน production ปี 2026: ตั้ง WAL สำรองด้วย Litestream และเมื่อไรต้องย้าย

เว็บเล็กถึงกลางบนเซิร์ฟเวอร์เครื่องเดียว ใช้ SQLite แทน Postgres ได้เมื่อไร pragma ที่ต้องตั้ง เขียนทีละคนทำไมถึงพอ สำรองยังไง และจุดที่มันพัง พร้อมตัวเลขจริง

กลาง อ่าน 12 นาที 21 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • แอปเซิร์ฟเวอร์เครื่องเดียว อ่านเป็นหลัก เขียนไม่กี่ร้อยครั้งต่อวินาที SQLite รับได้ และตัดงานดูแลฐานข้อมูลทิ้งทั้งก้อน
  • ตั้ง journal_mode=WAL, synchronous=NORMAL, busy_timeout, foreign_keys=ON แล้วเริ่มทุก transaction ที่เขียนด้วย BEGIN IMMEDIATE
  • สำรองด้วย VACUUM INTO ทุกคืน และให้ Litestream ส่ง WAL ขึ้น object storage ต่อเนื่อง จะเสียข้อมูลไม่เกินไม่กี่วินาที
  • สัญญาณให้ย้าย: ต้องมีแอปหลายเครื่องเขียนไฟล์เดียวกัน เขียนชนกันจนรอคิวนาน หรือต้องใช้ extension ของ Postgres

ปี 2026 SQLite ไม่ใช่ฐานข้อมูล “สำหรับมือถือกับโปรแกรมทดลอง” อีกแล้ว เว็บแอปเล็กถึงกลางจำนวนมากรันบนไฟล์เดียวบนเซิร์ฟเวอร์เครื่องเดียว ไม่มี process ฐานข้อมูลแยก ไม่มี port ไม่มี user/password บทความนี้ตอบว่ามันเหมาะกับงานไหน ต้องตั้งค่าอะไร สำรองยังไง และสัญญาณไหนที่บอกว่าถึงเวลาย้ายไป PostgreSQL

ทดสอบบน Ubuntu 24.04, SQLite 3.45, Python 3.12, Litestream 0.5

เหมาะเมื่อไร

เอกสาร SQLite เองบอกว่าเว็บไซต์ที่มี ต่ำกว่า 100K hits ต่อวัน ใช้ SQLite ได้สบาย และเป็นตัวเลขแบบเผื่อไว้มาก ของจริงทดสอบได้ถึง 10 เท่าของนั้น เงื่อนไขที่เราใช้ตัดสินใจมีสามข้อ

  1. แอปรันบนเครื่องเดียว ฐานข้อมูลเป็นไฟล์ในดิสก์เครื่องนั้น ไม่ข้ามเครือข่าย
  2. อ่านเป็นหลัก หน้าเว็บ, API ดึงข้อมูล, dashboard
  3. เขียนตั้งแต่สิบถึงไม่กี่ร้อยครั้งต่อวินาที และยอมให้เขียนทีละ transaction ได้

ถ้าตรงทั้งสามข้อ สิ่งที่ได้คือ ไม่มี service ฐานข้อมูลให้ดูแล ไม่มี connection pool ไม่มี network ต่อ query (query ที่ตอบจากหน้าในแรมใช้เวลาระดับไมโครวินาที) และสำรองข้อมูลคือ copy ไฟล์

SQLite พอเซิร์ฟเวอร์เดียวอ่านเยอะ เขียนไม่มากสำรอง = ก๊อปไฟล์ไปใช้ Postgresหลายเครื่องเขียน DB เดียวเขียนพร้อมกันหนักต้องใช้ของเฉพาะ Postgres
คำถามเดียวที่ชี้ขาด: มีกี่เครื่องที่ต้องเขียนไฟล์เดียวกัน

การตั้งค่าที่สำคัญ

ค่าเริ่มต้นของ SQLite ออกแบบให้โปรแกรม desktop ไม่ใช่เว็บ มีสี่อย่างที่ต้องตั้งทุกครั้ง

pragmaค่าทำไม
journal_modeWALอ่านกับเขียนไม่บล็อกกัน ตั้งครั้งเดียวติดอยู่กับไฟล์
synchronousNORMALfsync เฉพาะตอน checkpoint ไม่ใช่ทุก commit เร็วขึ้นมาก
busy_timeout5000 (ms)ถ้าอีกคนถือล็อกเขียนอยู่ ให้รอ ไม่ใช่ error ทันที
foreign_keysONค่าเริ่มต้นคือ OFF และเป็นค่าต่อ connection ต้องตั้งทุกครั้งที่เปิด

WAL (write-ahead log) คือหัวใจ แทนที่จะเขียนทับไฟล์หลัก SQLite เขียนหน้าที่เปลี่ยนต่อท้ายไฟล์ -wal ผู้อ่านเห็น snapshot ของตัวเองโดยไม่รอผู้เขียน และผู้เขียนไม่รอผู้อ่าน ข้อแลกคือ เขียนได้ทีละ transaction เพราะมี WAL ไฟล์เดียว

เขียนทีละคน ทำไมถึงพอ

คิดเป็นเวลา ไม่ใช่จำนวนผู้ใช้ insert แถวเดียวบน SSD ใช้เวลาต่ำกว่า 1 ms ถ้าทุก transaction ถือล็อกไม่เกิน 2 ms ก็เขียนต่อกันได้ 500 ครั้งต่อวินาทีโดยไม่มีใครรอเกิน 2 ms สิ่งที่ทำให้ SQLite “ช้า” ไม่ใช่ตัวล็อก แต่คือ transaction ที่ถือล็อกไว้แล้วไปทำอย่างอื่น เช่น เรียก API ภายนอก

กับดักที่เจอบ่อยที่สุดคือ SQLITE_BUSY แม้ตั้ง busy_timeout แล้ว เกิดเมื่อ transaction เริ่มด้วย SELECT (snapshot อ่าน) แล้วค่อยเขียน SQLite ปฏิเสธการยกระดับล็อกโดยไม่รอ เพราะถ้ารอ ค่าที่อ่านไปแล้วอาจเก่า ทางแก้คือ ทุก transaction ที่จะเขียน เริ่มด้วย BEGIN IMMEDIATE จองล็อกเขียนตั้งแต่แรก busy_timeout จะทำงานตามที่หวัง (Rails 8 ทำให้ IMMEDIATE เป็นค่าเริ่มต้นด้วยเหตุนี้)

transaction เขียน = BEGIN IMMEDIATE เสมอ และอย่าทำอะไรที่ไม่ใช่ SQL ระหว่าง transaction

ตัวอย่าง Python: pragma ครบ และเขียนผ่านช่องเดียว

ใช้ sqlite3 ใน stdlib ไม่ต้องลงอะไรเพิ่ม

import sqlite3, threading

DB = "app.db"
_write_lock = threading.Lock()

def connect() -> sqlite3.Connection:
    con = sqlite3.connect(DB, timeout=5, isolation_level=None, check_same_thread=False)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("PRAGMA synchronous=NORMAL")
    con.execute("PRAGMA busy_timeout=5000")
    con.execute("PRAGMA foreign_keys=ON")
    return con

def write(con, fn):
    """fn(con) ทำงานใน transaction เขียนเดียว ถือล็อกสั้นที่สุด"""
    with _write_lock:                       # กั้นภายใน process เอง ไม่ต้องไปชนล็อกของ SQLite
        con.execute("BEGIN IMMEDIATE")       # จองล็อกเขียนทันที
        try:
            r = fn(con)
            con.execute("COMMIT")
            return r
        except Exception:
            con.execute("ROLLBACK")
            raise

# ใช้งาน
con = connect()
write(con, lambda c: c.execute(
    "INSERT INTO events(ts, kind, payload) VALUES (?,?,?)", (1760000000, "login", "{}")))

isolation_level=None ปิดการเปิด transaction อัตโนมัติของ Python เราคุม BEGIN/COMMIT เอง ส่วน _write_lock ทำให้การเขียนใน process เดียวเข้าคิวในแรมแทนที่จะแย่งล็อกไฟล์ รันหลาย worker process (gunicorn) ก็ยังถูกต้องเพราะ SQLite ล็อกข้าม process ให้ แค่มีรอบ้าง ฝั่ง Node better-sqlite3 เป็น synchronous การเขียนจึงต่อคิวเองอยู่แล้ว ตั้ง pragma ชุดเดียวกันและใช้ db.transaction(fn).immediate()

ตัวอย่างคำนวณ: 50 เขียน/วินาที ไฟล์โตเท่าไร

สมมติเก็บ event แถวละ 200 ไบต์ (รวม index แล้วประมาณ 300 ไบต์ต่อแถวจริง) ที่ 50 แถวต่อวินาทีตลอดวัน

ค่า
แถวต่อวัน50 × 86,400 = 4,320,000
ข้อมูลดิบต่อวัน4.32M × 200 B ≈ 864 MB
รวม index และ overhead หน้า≈ 1.3 GB ต่อวัน
ต่อปี≈ 470 GB

สองบทเรียน SQLite รับอัตรานี้ได้ (ไฟล์โตได้ถึง 17.5 TB ตามเอกสาร) แต่ ปัญหาคือดิสก์ VPS ไม่ใช่ SQLite ที่ 50 เขียน/วินาทีตลอด 24 ชั่วโมง ต้องมีแผน retention ไม่ว่าใช้ฐานข้อมูลอะไร แอปส่วนใหญ่เฉลี่ยจริงไม่ถึง 5 เขียน/วินาที คือ ~130 MB ต่อวัน

ฝั่ง WAL: checkpoint อัตโนมัติเมื่อถึง 1,000 หน้า ≈ 4 MB insert แถวเดียวแตะราว 3 หน้า (ตาราง + index 2 ตัว) = 12 KB ต่อ transaction

  • ทีละแถว 50 transaction/วินาที → 600 KB/วินาที → checkpoint ทุก ~7 วินาที
  • รวบ 50 แถวเป็น 1 transaction/วินาที → หน้าที่ซ้ำนับครั้งเดียว ~20 KB/วินาที → checkpoint ทุก ~3 นาที

batch จึงลดทั้งจำนวนครั้งที่ถือล็อกและขนาด WAL หลัง checkpoint ไฟล์ -wal ถูกเขียนทับวน ไม่หด ถ้าอยากให้หดตั้ง PRAGMA journal_size_limit

สำรองข้อมูล

วิธีที่ 1: VACUUM INTO สร้างสำเนาที่ consistent เป็น transaction เดียว ขณะแอปยังเขียนอยู่ และบีบขนาดให้ด้วย ไฟล์ปลายทางต้องยังไม่มี

# บนเซิร์ฟเวอร์ ตั้ง cron ทุกคืน
sqlite3 app.db "VACUUM INTO '/backup/app-$(date +%F).db'"

วิธีที่ 2: online backup API (sqlite3_backup_* หรือ .backup ใน CLI) copy ทีละหน้าโดยล็อกสั้น ๆ แต่ถ้าอีก process เขียนระหว่างทาง การสำรองจะเริ่มใหม่ เหมาะกับฐานข้อมูลที่เขียนไม่บ่อย ห้าม cp app.db ตอนแอปรันอยู่ จะได้ไฟล์ที่ไม่ตรงกับ -wal

วิธีที่ 3: Litestream คือตัวที่ทำให้ SQLite พร้อม production จริง เป็น process แยก (sidecar) อ่าน WAL ต่อเนื่องแล้วส่งขึ้น object storage (S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, GCS, Azure, SFTP, หรือโฟลเดอร์อื่น) ไม่ต้องแก้โค้ดแอป เวอร์ชัน 0.5 (2025) เปลี่ยนมาใช้รูปแบบไฟล์ LTX ที่ compact เป็นชั้น 30 วินาที / 5 นาที / 1 ชั่วโมง ทำให้ restore ย้อนไปจุดเวลาไหนก็ได้ ด้วยไฟล์ราวสิบกว่าไฟล์ และมี VFS สำหรับ read replica ที่อ่านตรงจาก storage (ยังใหม่ ตรวจเอกสารปัจจุบันก่อนใช้จริง)

# /etc/litestream.yml
dbs:
  - path: /srv/app/app.db
    replicas:
      - url: s3://my-bucket/app
# ย้อนข้อมูลกลับมาที่เครื่องใหม่
litestream restore -o app.db s3://my-bucket/app

ข้อจำกัด: Litestream 0.5 รองรับ replica ปลายทางเดียวต่อฐานข้อมูล และ restore จากไฟล์ของ 0.3 ไม่ได้ อัปเกรดต้องสร้าง snapshot ใหม่ ส่วนที่อาจเสียคือช่วงระหว่าง transaction ล่าสุดกับรอบ sync (ค่าเริ่มต้นวินาทีละครั้ง)

เทียบกับ PostgreSQL บน VPS 2 GB

SQLitePostgreSQL 16
แรมที่ใช้อยู่ใน process แอป cache เริ่มต้น ~2 MB ต่อ connection ที่เหลือพึ่ง page cache ของ OSprocess แยก shared_buffers เริ่มต้น 128 MB แนะนำ 25% ของแรม ≈ 512 MB + ~5-10 MB ต่อ connection
งานดูแลไม่มี service ไม่มี port ไม่มี roleติดตั้ง, pg_hba, อัปเกรด major version (pg_upgrade), vacuum tuning
สำรองcopy ไฟล์ / VACUUM INTO / Litestreampg_dump, pg_basebackup, WAL archiving
ค่าใช้จ่ายขั้นต่ำติดกับเซิร์ฟเวอร์เดิมใช้แรมบนเครื่องเดิมหลายร้อย MB หรือ managed DB ขนาดเล็กสุดราว $15/เดือน (ตรวจราคาปัจจุบัน)
หลายเครื่องเขียนพร้อมกันไม่ได้ได้
extensionFTS5, JSON มาในตัว ที่เหลือจำกัดPostGIS, pgvector, TimescaleDB ฯลฯ

บน VPS 2 GB ที่ต้องแบ่งให้แอป, proxy และ cache (บทความเซิร์ฟเวอร์เล็กเล่าการจัดวางไว้) การตัด Postgres คืนแรม 400-600 MB และตัดงานดูแลไปทั้งก้อน ถ้าระบบของคุณต้องมี Redis กั้นหน้าฐานข้อมูล การอ่าน SQLite ใน process เดียวกันมักเร็วพอให้ cache บางส่วนหายไปได้

จุดที่มันพัง

  • แอปหลายเครื่องเขียนไฟล์เดียว ทำไม่ได้ WAL ต้องอยู่เครื่องเดียว ไม่มี network filesystem
  • เขียนชนกันหนัก หลาย process เขียนตลอดด้วย transaction ยาว จะเห็น busy_timeout หมดเป็นประจำ
  • ฐานข้อมูลใหญ่มาก หลายร้อย GB ยังเปิดได้ แต่ VACUUM, backup, restore ใช้เวลาตามขนาด และเมื่อหน้าไม่พอในแรม query ที่ไม่ตรง index จะช้า
  • reader ค้างนาน connection ที่เปิด transaction อ่านทิ้งไว้จะกัน checkpoint ไฟล์ -wal โตไม่หยุด ปิด cursor ให้ครบ
  • ต้องการ extension เช่น geospatial จริงจัง, vector search ขนาดใหญ่, time-series แบบ hypertable
  • ชนิดข้อมูลหลวม SQLite รับค่าผิดชนิดได้ถ้าตารางไม่ประกาศ STRICT (มีตั้งแต่ 3.37) ใช้ STRICT ตั้งแต่ตารางแรก

เส้นทางย้ายออกเมื่อโตเกิน

  1. SQL เป็นกลางตั้งแต่ต้น INTEGER PRIMARY KEY, เวลาเป็น ISO-8601 TEXT หรือ epoch INTEGER, boolean เป็น 0/1, ไม่ใช้ฟังก์ชันเฉพาะ SQLite
  2. ย้ายข้อมูลด้วย pgloader ซึ่งอ่าน SQLite สร้างตาราง map ชนิด และย้ายแถวให้ หรือ export CSV แล้ว COPY ถ้า schema เล็ก
  3. ตัดด้วย downtime สั้น หยุดเขียน → ย้าย → เปลี่ยน connection string → เปิด ฐานข้อมูลหลาย GB ย้ายเสร็จในหลักนาที ไม่ต้องทำ dual-write
  4. ตรวจ query ที่พึ่ง behavior ของ SQLite เช่น cast อัตโนมัติ, GROUP BY ที่เลือกคอลัมน์นอก aggregate (Postgres ไม่ยอม)

เริ่มจาก SQLite ได้ ถ้าตารางเป็น STRICT และ SQL เป็นกลาง วันที่ต้องย้าย งานหนักคือตรวจ query ไม่ใช่ย้ายข้อมูล

แหล่งอ้างอิง

คำถามที่พบบ่อย

SQLite เขียนได้ทีละคน แล้วเว็บที่มีผู้ใช้หลายร้อยคนจะไหวเหรอ

ไหว เพราะ "ทีละคน" คือทีละ transaction ไม่ใช่ทีละผู้ใช้ transaction เขียนหนึ่งครั้งใช้เวลาไม่ถึงมิลลิวินาทีถึงไม่กี่มิลลิวินาที บนดิสก์ SSD เขียนต่อกันได้หลายร้อยถึงหลายพันครั้งต่อวินาที ส่วนการอ่านใน WAL mode ไม่ต้องรอผู้เขียนเลย คอขวดจริงคือเวลาที่แต่ละ transaction ถือล็อกไว้ ไม่ใช่จำนวนผู้ใช้

synchronous=NORMAL เสียข้อมูลได้ไหม

ได้เฉพาะกรณีไฟดับหรือเครื่องดับกะทันหัน transaction ล่าสุดที่ยังไม่ถึงรอบ checkpoint อาจย้อนกลับ แต่ไฟล์ไม่พัง ถ้าแอป crash เองโดย OS ยังอยู่ ไม่เสียอะไร ถ้ารับไม่ได้แม้แต่รายการเดียว ตั้ง FULL แล้วยอมช้าลงตอน commit

ใช้กับ Docker หรือ NFS ได้ไหม

Docker ได้ ถ้า volume อยู่บนดิสก์เครื่องเดียวกัน แต่ห้ามวางไฟล์บน network filesystem (NFS, SMB) เอกสาร SQLite บอกตรง ๆ ว่า WAL ใช้ข้ามเครื่องไม่ได้ ทุก process ต้องอยู่เครื่องเดียวกัน

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ