3 คะแนน โดย GN⁺ 4 시간 전 | 2 ความคิดเห็น | แชร์ทาง WhatsApp
  • SQLite เป็น RDBMS แบบครบในตัวที่ใช้กันอย่างกว้างขวางสำหรับที่เก็บข้อมูลในเครื่องและโปรเจกต์แบบ embedded แต่เพราะ ความเข้ากันได้ย้อนหลัง จึงยังคงค่าเริ่มต้นที่เสียเปรียบด้านความปลอดภัยและประสิทธิภาพไว้
  • โดยค่าเริ่มต้น SQLite ไม่บังคับใช้ข้อจำกัด foreign key และสามารถนำ ROWID กลับมาใช้ซ้ำได้ ทำให้อาจเกิดปัญหาความถูกต้องสมบูรณ์ของข้อมูล เช่น โพสต์ของผู้ใช้ที่ถูกลบไปแล้วไปเชื่อมกับผู้ใช้อีกคนที่ได้รับ ID เดียวกัน
  • ตารางทั่วไปสามารถเก็บชนิดข้อมูลที่ต่างจากคอลัมน์ที่ประกาศไว้ได้ และแม้ ตาราง STRICT จะป้องกันเรื่องนี้ แต่ต้องระบุ strict ในแต่ละตาราง และยังขัดกับแนวทางที่เคยใช้ชื่อชนิดข้อมูลแบบกำหนดเอง
  • SQLITE_BUSY ที่เกิดขึ้นทันทีในการเขียนพร้อมกัน, WAL ที่ถูกปิดไว้ และการตั้งค่าการซิงโครไนซ์แบบอนุรักษนิยม สามารถปรับปรุงได้ด้วย pragma busy_timeout, journal_mode, และ synchronous ตามลำดับ
  • หากให้เลือกชุดค่าเริ่มต้นที่ปลอดภัยผ่าน edition ตามปี เช่น PRAGMA edition = 2026 ก็จะทำให้ค่าเริ่มต้นของ SQLite พัฒนาต่อไปได้โดยไม่ทำลายพฤติกรรมเดิม

จุดแข็งของ SQLite และค่าเริ่มต้นที่ล้าสมัย

  • SQLite เป็นฐานข้อมูลสำหรับโปรเจกต์ embedded และเรียกได้ว่าเป็นมาตรฐานอุตสาหกรรมของการเก็บข้อมูลในเครื่อง โดยซอฟต์แวร์ฝั่งเซิร์ฟเวอร์บางตัวอย่าง lobste.rs ก็รันบน SQLite อยู่ด้วย
  • ต่างจาก RDBMS แบบดั้งเดิมที่รันเป็นโปรเซสแยก SQLite เป็น RDBMS ในรูปแบบไลบรารี จึงช่วยให้ซอฟต์แวร์ยังคงเป็นแบบครบในตัวได้
  • ไม่จำเป็นต้องเขียน serializer และ parser แบบกำหนดเองเหมือนรูปแบบไฟล์ทั่วไป จึงให้ข้อดีของทั้ง RDBMS และวิธีเก็บข้อมูลแบบไฟล์
  • อย่างไรก็ตาม ยังมีปัญหาว่า ค่าเริ่มต้นหลายอย่างไม่เหมาะสม ซึ่งส่งผลต่อความถูกต้องสมบูรณ์ของข้อมูล, concurrency และประสิทธิภาพ

ปัญหาความถูกต้องสมบูรณ์จากการไม่บังคับใช้ foreign key

  • Foreign key เป็นกลไกสำคัญในการรักษาความสอดคล้องของฐานข้อมูลและป้องกัน reference ที่ขาดหาย แต่ SQLite ไม่บังคับใช้โดยค่าเริ่มต้น
  • แม้จะประกาศให้ posts.user_id อ้างอิง users.id แต่การตั้งค่าเริ่มต้นก็ไม่สามารถป้องกันพฤติกรรมต่อไปนี้ได้
    • สร้างโพสต์โดยไม่มี ID ผู้ใช้ที่ถูกต้อง
    • ลบผู้ใช้โดยยังทิ้งโพสต์ของผู้ใช้นั้นไว้
  • INTEGER PRIMARY KEY จะเป็น alias ของ ROWID ในตาราง และ อัลกอริทึมการจัดสรร ROWID ของ SQLite จะนำ ID กลับมาใช้ซ้ำในบางสถานการณ์
    • แม้ Bob จะเขียนโพสต์ด้วย ID 1 แล้วลบเฉพาะบัญชี ก็จะไม่เกิดข้อผิดพลาด foreign key
    • ต่อมาเมื่อ Alice ได้รับ ID 1 ที่ถูกนำกลับมาใช้ซ้ำ โพสต์เดิมของ Bob ก็จะถูกเชื่อมกับ Alice
    • การ query เองยังสำเร็จตามปกติ จึงตรวจพบปัญหาได้ยากกว่า reference ที่ขาดหายแบบง่าย ๆ
  • สามารถบังคับใช้ foreign key ได้โดยเปิดใช้ pragma ต่อไปนี้ในแต่ละ connection
PRAGMA foreign_keys = ON;
  • หากตั้งค่าแบบนี้ตั้งแต่แรก การลบผู้ใช้ที่ยังมีโพสต์อยู่จะหยุดลงด้วยข้อผิดพลาด FOREIGN KEY constraint failed

ตารางทั่วไปที่ยอมรับชนิดข้อมูลต่างจากที่ประกาศ

  • ค่าใน SQLite เป็นหนึ่งใน NULL, INTEGER, REAL, TEXT, BLOB แต่การประกาศคอลัมน์ในตารางทั่วไปไม่ได้จำกัดชนิดข้อมูลที่เก็บได้อย่างเข้มงวด เพียงแต่กำหนด type affinity เท่านั้น
  • คอลัมน์ที่มี affinity เป็น INTEGER จะใช้กฎการเก็บข้อมูลต่อไปนี้
    • TEXT ที่ตีความเป็นจำนวนเต็มได้จะถูกแปลงเป็น INTEGER
    • TEXT ที่ตีความเป็นจำนวนจริงได้จะถูกแปลงเป็น REAL ซึ่งเป็นเลขทศนิยมแบบ double precision
    • ค่าอื่น ๆ จะถูกเก็บด้วยชนิดข้อมูลเดิม
  • affinity แบบอื่นก็มีกฎการแปลงของตนเอง
    • affinity แบบ BLOB จะเก็บค่าไว้ตามเดิม
    • affinity แบบ TEXT จะคง BLOB, TEXT, NULL ไว้ตามเดิม และแปลงตัวเลขเป็น TEXT
    • affinity แบบ REAL ทำงานเหมือน affinity แบบ INTEGER แต่จะแปลงจำนวนเต็มเป็น REAL
  • ด้วยเหตุนี้ แม้แต่คอลัมน์ duration_sec INTEGER ก็สามารถเก็บสตริงที่ไม่ใช่ตัวเลขอย่าง 'Way too long, I mean come on' ได้
  • ในโปรเจกต์จริงก็เคยมีกรณีที่บันทึกสตริง '1', '0' แทนตัวเลขสำหรับ boolean อย่าง 1, 0 ทำให้ต้องมาจัดระเบียบข้อมูลและดีบักภายหลัง

ตาราง STRICT และความขัดแย้งกับชนิดข้อมูลแบบกำหนดเอง

  • ตาราง STRICT จะเกิด ข้อผิดพลาดด้านชนิดข้อมูล เช่น cannot store TEXT value in INTEGER column หากใส่ชนิดข้อมูลที่ไม่ถูกต้องลงในคอลัมน์
CREATE TABLE music (
  id INTEGER PRIMARY KEY,
  name TEXT,
  duration_sec INTEGER
) strict;
  • ไม่มี pragma ที่ทำให้ทุกตารางเข้มงวดแบบ global ดังนั้นจึงต้องใส่ strict ให้ครบในทุกคำสั่ง CREATE TABLE

  • SQLite มีจุดยืนว่า การมี type ที่ยืดหยุ่นนั้นมีประโยชน์ แต่แม้ในตาราง STRICT หากระบุชนิดข้อมูล ANY ก็สามารถสร้างคอลัมน์ที่เก็บค่าได้ทุกชนิด

    • จึงสามารถแยกคอลัมน์ที่ยอมรับชนิดข้อมูลใดก็ได้ออกจากคอลัมน์ที่ต้องบังคับชนิดข้อมูลอย่างชัดเจน
  • ปัญหาความเข้ากันได้ที่เป็นจริงมากกว่าคือ ตาราง STRICT ไม่ได้แค่บังคับชนิดข้อมูล แต่ยัง เปลี่ยนกฎการตีความ type specifier ด้วย

  • วิธีที่ตารางทั่วไปตีความชื่อชนิดข้อมูล

    • ตาราง SQLite ทั่วไปจะกำหนด affinity ตาม สตริงชนิดข้อมูลที่ประกาศไว้
    • หากมี "INT" จะถือเป็น INTEGER
    • หากมี "CHAR", "CLOB", "TEXT" อย่างใดอย่างหนึ่ง จะถือเป็น TEXT
    • หากมี "BLOB" หรือไม่ระบุชนิดข้อมูล จะถือเป็น BLOB
    • หากมี "REAL", "FLOA", "DOUB" อย่างใดอย่างหนึ่ง จะถือเป็น REAL
    • หากไม่เข้าเงื่อนไขใดเลย จะถือเป็น NUMERIC
    • เมื่อรวม type แบบหลวมกับกฎนี้ จะสามารถติด ชื่อชนิดข้อมูลแบบกำหนดเอง เช่น DATETIME, KEY_VALUE_SET, COLOR ให้กับคอลัมน์ได้
    • database connector หรือ wrapper สามารถดูชื่อชนิดข้อมูลแล้ว serialize/deserialize ให้อัตโนมัติได้
    • แม้ไม่มีฟังก์ชันประมวลผลเพิ่มเติม ชื่อชนิดข้อมูลเองก็ทำหน้าที่บันทึกเอกสารว่าคอลัมน์นั้นคาดว่าจะเก็บข้อมูลแบบใด
    • หากเปลี่ยน STRICT ให้เป็นค่าเริ่มต้นไปเลย ก็จะต้องละทิ้งการใช้งานรูปแบบนี้ จึงจำเป็นต้องมี alias ของชนิดข้อมูลแบบกำหนดเองที่ระบุได้ชัดเจน
    CREATE TYPE KEY_VALUE_SET = TEXT;
    
    • หากใช้ alias อย่าง KEY_VALUE_SET ในตารางที่เข้มงวดได้ ก็จะบันทึกรูปแบบข้อมูลที่คาดหวังของคอลัมน์ TEXT ที่แอปพลิเคชันต้อง parse ไว้ใน schema ได้ด้วย
    • ฟีเจอร์ที่เชื่อม CHECK constraint เข้ากับชนิดข้อมูลแบบกำหนดเองก็น่าจะมีประโยชน์
    • มาตรฐาน SQL:1999 มีไวยากรณ์ alias ของชนิดข้อมูลที่รองรับถึง constraint อยู่แล้วในชื่อ CREATE DOMAIN ดังนั้น SQLite อาจนำไปใช้งานโดยรองรับคำสั่งมาตรฐานนี้ได้

การเขียนพร้อมกันและ SQLITE_BUSY

  • SQLite อนุญาตให้อ่านพร้อมกันได้หลายรายการ แต่การเขียนจะประมวลผลได้ครั้งละหนึ่งรายการเท่านั้น
  • ในการตั้งค่าเริ่มต้น เมื่อสองโปรเซสพยายามได้ write lock พร้อมกัน โปรเซสหนึ่งจะไม่ได้รอ แต่ได้รับ ข้อผิดพลาด SQLITE_BUSY ทันที
    • การเขียนที่ทำ disk I/O อาจช้าได้อยู่แล้ว ดังนั้นการรอให้ lock ถูกปล่อยเป็นระยะเวลาหนึ่งจึงเป็นพฤติกรรมที่เป็นธรรมชาติกว่า
    • เคยมีกรณีที่ระบบจริงหยุดทำงานเป็นครั้งคราวจนต้องเขียน retry loop เอง
  • การตั้งค่าต่อไปนี้จะลองขอ lock ซ้ำได้นานสูงสุด 5 วินาที ก่อนจะคืนค่า SQLITE_BUSY
PRAGMA busy_timeout = 5000;
  • ในการใช้งานปกติ การรวมการเขียนทั้งหมดไว้ในโปรเซสเดียว และถ้าเป็นไปได้ใน เธรดเดียว จะเหมาะสมกว่า และการเขียนพร้อมกันก็ไม่ได้ทำให้เร็วขึ้นได้
  • อย่างไรก็ตาม ก็มีกรณีใช้งานปกติที่จำเป็นต้องเขียนพร้อมกันอยู่
    • จัดการฐานข้อมูลแบบโต้ตอบด้วยเครื่องมือ command line sqlite3
    • รันสคริปต์สำหรับงานดูแลระบบที่พบไม่บ่อยและไม่มี frontend แยกต่างหาก
  • งานลักษณะนี้อาจทำให้เกิด SQLITE_BUSY ที่คาดไม่ถึงเพราะค่าเริ่มต้น และทำให้ซอฟต์แวร์ที่กำลังรันอยู่หยุดทำงานได้

WAL และค่าเริ่มต้นด้านประสิทธิภาพ

  • หากตั้งค่าอย่างถูกต้อง SQLite อาจเร็วพอที่จะทำหน้าที่บางส่วนที่เคยเป็นของเซิร์ฟเวอร์ขนาดใหญ่อย่าง PostgreSQL หรือ MySQL ได้ แต่ประสิทธิภาพเริ่มต้นนั้นไม่ดี
  • อ่านรายละเอียดการปรับแต่งสำหรับสภาพแวดล้อมเซิร์ฟเวอร์เพิ่มเติมได้ที่ Optimizing SQLite for servers
  • ปัญหาค่าเริ่มต้นที่สำคัญที่สุดคือ Write-Ahead Log หรือ WAL ถูกปิดใช้งาน อยู่
PRAGMA journal_mode = WAL;
  • WAL ช่วยเพิ่มความเร็วการเขียนได้มากในสถานการณ์ส่วนใหญ่
  • หากใช้การตั้งค่าต่อไปนี้ร่วมกัน จะลดจำนวนครั้งของการซิงโครไนซ์ดิสก์ลงได้มากโดยไม่มีความเสี่ยงต่อความเสียหายของข้อมูล
PRAGMA synchronous = NORMAL;

SQLite edition ตามปี

  • หากเปลี่ยนค่าเริ่มต้นเดิมโดยตรงตอนนี้ ซอฟต์แวร์เก่าอาจพัง และผู้ใช้อาจกลัวการอัปเกรด SQLite ในอนาคต
  • สามารถเพิ่ม super pragma หนึ่งตัวเพื่อให้เลือกใช้ค่าเริ่มต้นที่ปรับปรุงแล้วโดยยังรักษาความเข้ากันได้ย้อนหลัง
PRAGMA edition = 2026;
  • edition 2026 อย่างน้อยควรทำงานเป็น alias ของการตั้งค่าต่อไปนี้
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
  • ใน edition นี้ ควรใช้โหมด strict เป็นค่าเริ่มต้นสำหรับตารางใหม่ด้วย
  • เป็นวิธีเลือกพฤติกรรมเริ่มต้นแบบใหม่อย่างชัดเจน โดยยังรักษาพฤติกรรมของโค้ดเดิมไว้ คล้ายกับ Rust editions
  • ต่างจากโหมดเดียวอย่าง "use strict"; ของ JavaScript, edition ตามปีสามารถเปลี่ยนชุดค่าเริ่มต้นที่สมเหตุสมผลไปตามยุคสมัยได้
  • ตัวอย่างเช่น หาก WAL2 ของ Hctree เข้า main branch ในปี 2034 ก็สามารถกำหนดให้ PRAGMA edition = 2034 ในอนาคตเลือก journal_mode = WAL2 ได้
  • ระบบ edition อาจเป็นทางประนีประนอมที่ช่วยให้ SQLite พัฒนาค่าเริ่มต้นด้านความปลอดภัยและประสิทธิภาพ ได้โดยไม่ผูกติดกับการตั้งค่าในอดีต และไม่ทำลายพฤติกรรมเริ่มต้นของโปรแกรมเดิม

2 ความคิดเห็น

 
tomskang 2 시간 전

ในทางทฤษฎีมันดูดี แต่พอคิดถึงปัญหาความเข้ากันได้และโอเวอร์เฮดแล้ว น่าจะเสียมากกว่าได้
SQLite เป็น DBMS ที่ถือกำเนิดมาเพื่อทำงานในสภาพแวดล้อมแบบ embedded อย่าง egis
โลกของ embedded ก็คือการฟาด void** ตรง ๆ แล้วประกาศ __weak__ ก่อนค่อย override ฟังก์ชันกันอยู่แล้ว ในโลกแบบนั้นจะต้องทำสิ่งนี้กันจริง ๆ เหรอ ก็อดสงสัยไม่ได้

 
GN⁺ 4 시간 전
ความคิดเห็นจาก Lobste.rs
  • เมื่อวานเห็นข่าวว่า lobste.rs ย้ายไปใช้ SQLite (https://lobste.rs/s/ko1ji1/lobste_rs_is_now_running_on_sqlite) เลยเขียนบทความนี้ขึ้นมา
    ตอนอ่านบทสนทนานั้นก็นึกถึง ความไม่สะดวกเล็กๆ น้อยๆ ของค่าเริ่มต้นใน SQLite เลยสรุปเป็นบล็อกโพสต์

    • ผมก็อ่านบทสนทนานั้นแล้วได้ข้อสรุปเดียวกัน
      ถ้ามีแรงพอ ก็น่าจะแชร์บทความนี้ใน ฟอรัม SQLite ด้วย: https://sqlite.org/forum/forum
  • ใน SQL นั้น CREATE TYPE ใช้สำหรับสร้างชนิดข้อมูลแบบ composite และ Postgres รองรับทั้ง range, enum, array รวมถึง base type ที่ค่อนข้างซับซ้อนด้วย
    ฟีเจอร์ที่ต้องการดูจะใกล้เคียงกับ CREATE DOMAIN มากกว่า สามารถสร้าง newtype จากชนิดข้อมูล concrete พื้นฐาน พร้อมกำหนดค่าเริ่มต้นและเงื่อนไข constraint ได้ ทำให้สคีมาที่ต้องเขียน CHECK constraint เดิมซ้ำๆ ในหลายตารางกระชับลงมาก เช่นใช้ได้แบบ create domain mything as text; หรือ create domain mything as integer not null check (VALUE < 5);

    • ควรตรวจสอบก่อนว่ามี ฟีเจอร์ type alias อยู่แล้วหรือไม่ เดี๋ยวจะเพิ่มเนื้อหาที่เกี่ยวข้องลงในบทความ
  • ควรพูดถึงกับดักที่ค่อนข้างอันตรายด้วยว่า แค่ตั้ง busy_timeout อย่างเดียวไม่สามารถป้องกันข้อผิดพลาด SQLITE_BUSY ที่เกิดขึ้นทันทีได้
    เมื่อเริ่ม transaction ด้วย query แบบอ่านอย่างเดียว แล้วรัน query เขียนเพื่อพยายามยกระดับ lock ที่ถืออยู่ หากมีงานเขียนอื่นอยู่ จะล้มเหลวทันทีโดยไม่สนการตั้งค่า busy_timeout วิธีแก้คือต้องใช้ begin immediate

    • รู้ว่าไม่ใช่บั๊กจริงๆ แต่พูดตรงๆ มันเป็นพฤติกรรมที่ ให้ความรู้สึกแทบเหมือนบั๊ก
  • ต้องพิจารณาด้วยว่า ในบรรดาการตั้งค่าที่ผูกกับ edition ที่เสนอมา บางส่วนเป็น คุณสมบัติของ connection ในโมเดลของ SQLite ส่วนที่เหลือเป็นคุณสมบัติของไฟล์ฐานข้อมูลหรือของแต่ละตาราง
    PRAGMA journal_mode = WAL จะคงอยู่ในไฟล์ฐานข้อมูลต่อไป แต่ PRAGMA foreign_keys = ON ต้องตั้งค่าทุกครั้งสำหรับแต่ละ connection ส่วน STRICT เป็นคุณสมบัติของแต่ละตาราง จึงสามารถผสมตารางที่ strict กับตารางที่ไม่ strict ไว้ในฐานข้อมูลเดียวกันได้ ดังนั้นต้องกำหนดว่า PRAGMA แบบรวม นี้จะเป็นคุณสมบัติของไฟล์ฐานข้อมูลหรือของ connection กันแน่ และก็สงสัยด้วยว่ามีกลไกในตัวที่คอยเตือนไหม เพื่อไม่ให้ไลบรารีเวอร์ชันเก่าไปแก้ไฟล์ที่มีฟีเจอร์ที่ตัวเองไม่รู้จักจนพัง

  • แนวทางแบบ edition ดูเป็นการประนีประนอมที่ดีระหว่างเป้าหมายของทีมพัฒนา SQLite ที่ต้องการรักษาความเข้ากันได้ย้อนหลังเป็นหลัก กับค่าเริ่มต้นที่สับสนน้อยลงสำหรับผู้ใช้ที่ต้องการการตั้งค่าที่แนะนำในยุคปัจจุบัน

  • ช่วงนี้กำลังอินกับ RocksDB มากๆ มันเป็นไลบรารีฐานข้อมูลแบบฝังตัวเหมือน SQLite แต่การไม่มี SQL กลับเป็นข้อดีใหญ่ในความเห็นผม
    รู้สึกว่าข้อจำกัดที่ต้องแสดงทุกอย่างเป็น SQL มีแต่จะเป็นอุปสรรคในการจัดการฐานข้อมูล

    • RocksDB เป็น ฐานข้อมูลคีย์-ค่า ส่วน SQLite เป็น ฐานข้อมูลเชิงสัมพันธ์ จึงเป็นแนวคิดคนละแบบที่สร้างมาเพื่อวัตถุประสงค์ต่างกัน
      นึกภาพไม่ค่อยออกว่าจะย้ายระบบที่ใช้ SQL อย่าง lobste.rs ไปใช้ RocksDB หรือฐานข้อมูลคีย์-ค่าอื่นๆ ได้อย่างไร
  • ไม่เห็นด้วยว่าการตั้งค่า busy_timeout คือทางแก้ SQLite ทำงานได้ดีมากหากมี เธรดเขียนเดียว แล้ว batch ประมวลผล logical transaction ที่ไม่เกี่ยวข้องกัน
    วิธีการโดยละเอียดอธิบายไว้ที่ https://andersmurphy.com/2025/12/…

    • สำหรับ workload ทั่วไป ควร จำกัดงานเขียนให้เหลือหนึ่งเดียว และไม่ควรออกแบบโครงสร้างที่มีงานเขียนหลายตัวมาแย่งกันตลอดเวลา
      แต่ก็เป็นเรื่องปกติที่จะต้องแก้ฐานข้อมูลโดยตรงเพื่อทำงาน cleanup หรือรันงานดูแลระบบเป็นครั้งคราวผ่านสคริปต์ที่ยังไม่ถึงขั้นต้องสร้างหน้าจัดการแยกต่างหาก การที่เซิร์ฟเวอร์หรือแอปพลิเคชันอาจหยุดทำงานแบบสุ่ม เพียงเพราะโชคร้ายที่เวลารันไปชนกันพอดี จึงไม่ใช่เรื่องดี