SQLite ก็ต้องการ Edition แบบ Rust เช่นกัน
(mort.coffee)- SQLite เป็น RDBMS แบบครบในตัวที่ใช้กันอย่างกว้างขวางสำหรับที่เก็บข้อมูลในเครื่องและโปรเจกต์แบบ embedded แต่เพราะ ความเข้ากันได้ย้อนหลัง จึงยังคงค่าเริ่มต้นที่เสียเปรียบด้านความปลอดภัยและประสิทธิภาพไว้
- โดยค่าเริ่มต้น SQLite ไม่บังคับใช้ข้อจำกัด foreign key และสามารถนำ
ROWIDกลับมาใช้ซ้ำได้ ทำให้อาจเกิดปัญหาความถูกต้องสมบูรณ์ของข้อมูล เช่น โพสต์ของผู้ใช้ที่ถูกลบไปแล้วไปเชื่อมกับผู้ใช้อีกคนที่ได้รับ ID เดียวกัน - ตารางทั่วไปสามารถเก็บชนิดข้อมูลที่ต่างจากคอลัมน์ที่ประกาศไว้ได้ และแม้ ตาราง STRICT จะป้องกันเรื่องนี้ แต่ต้องระบุ
strictในแต่ละตาราง และยังขัดกับแนวทางที่เคยใช้ชื่อชนิดข้อมูลแบบกำหนดเอง SQLITE_BUSYที่เกิดขึ้นทันทีในการเขียนพร้อมกัน, WAL ที่ถูกปิดไว้ และการตั้งค่าการซิงโครไนซ์แบบอนุรักษนิยม สามารถปรับปรุงได้ด้วย pragmabusy_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 ที่ขาดหายแบบง่าย ๆ
- แม้ Bob จะเขียนโพสต์ด้วย ID
- สามารถบังคับใช้ foreign key ได้โดยเปิดใช้ pragma ต่อไปนี้ในแต่ละ connection
PRAGMA foreign_keys = ON;
- หากตั้งค่าแบบนี้ตั้งแต่แรก การลบผู้ใช้ที่ยังมีโพสต์อยู่จะหยุดลงด้วยข้อผิดพลาด
FOREIGN KEY constraint failed
ตารางทั่วไปที่ยอมรับชนิดข้อมูลต่างจากที่ประกาศ
- ค่าใน SQLite เป็นหนึ่งใน
NULL,INTEGER,REAL,TEXT,BLOBแต่การประกาศคอลัมน์ในตารางทั่วไปไม่ได้จำกัดชนิดข้อมูลที่เก็บได้อย่างเข้มงวด เพียงแต่กำหนด type affinity เท่านั้น - คอลัมน์ที่มี affinity เป็น
INTEGERจะใช้กฎการเก็บข้อมูลต่อไปนี้TEXTที่ตีความเป็นจำนวนเต็มได้จะถูกแปลงเป็นINTEGERTEXTที่ตีความเป็นจำนวนจริงได้จะถูกแปลงเป็นREALซึ่งเป็นเลขทศนิยมแบบ double precision- ค่าอื่น ๆ จะถูกเก็บด้วยชนิดข้อมูลเดิม
- affinity แบบอื่นก็มีกฎการแปลงของตนเอง
- affinity แบบ
BLOBจะเก็บค่าไว้ตามเดิม - affinity แบบ
TEXTจะคงBLOB,TEXT,NULLไว้ตามเดิม และแปลงตัวเลขเป็นTEXT - affinity แบบ
REALทำงานเหมือน affinity แบบINTEGERแต่จะแปลงจำนวนเต็มเป็นREAL
- affinity แบบ
- ด้วยเหตุนี้ แม้แต่คอลัมน์
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 ได้ด้วย - ฟีเจอร์ที่เชื่อม
CHECKconstraint เข้ากับชนิดข้อมูลแบบกำหนดเองก็น่าจะมีประโยชน์ - มาตรฐาน 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 แยกต่างหาก
- จัดการฐานข้อมูลแบบโต้ตอบด้วยเครื่องมือ command line
- งานลักษณะนี้อาจทำให้เกิด
SQLITE_BUSYที่คาดไม่ถึงเพราะค่าเริ่มต้น และทำให้ซอฟต์แวร์ที่กำลังรันอยู่หยุดทำงานได้
WAL และค่าเริ่มต้นด้านประสิทธิภาพ
- หากตั้งค่าอย่างถูกต้อง SQLite อาจเร็วพอที่จะทำหน้าที่บางส่วนที่เคยเป็นของเซิร์ฟเวอร์ขนาดใหญ่อย่าง PostgreSQL หรือ MySQL ได้ แต่ประสิทธิภาพเริ่มต้นนั้นไม่ดี
- อ่านรายละเอียดการปรับแต่งสำหรับสภาพแวดล้อมเซิร์ฟเวอร์เพิ่มเติมได้ที่ Optimizing SQLite for servers
- ปัญหาค่าเริ่มต้นที่สำคัญที่สุดคือ Write-Ahead Log หรือ WAL ถูกปิดใช้งาน อยู่
PRAGMA journal_mode = WAL;
- WAL ช่วยเพิ่มความเร็วการเขียนได้มากในสถานการณ์ส่วนใหญ่
- หากใช้การตั้งค่าต่อไปนี้ร่วมกัน จะลดจำนวนครั้งของการซิงโครไนซ์ดิสก์ลงได้มากโดยไม่มีความเสี่ยงต่อความเสียหายของข้อมูล
PRAGMA synchronous = NORMAL;
- พฤติกรรมโดยละเอียดของ
synchronousสรุปไว้ใน เอกสาร pragma ของ SQLite
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 ความคิดเห็น
ในทางทฤษฎีมันดูดี แต่พอคิดถึงปัญหาความเข้ากันได้และโอเวอร์เฮดแล้ว น่าจะเสียมากกว่าได้
SQLite เป็น DBMS ที่ถือกำเนิดมาเพื่อทำงานในสภาพแวดล้อมแบบ embedded อย่าง egis
โลกของ embedded ก็คือการฟาด
void**ตรง ๆ แล้วประกาศ__weak__ก่อนค่อย override ฟังก์ชันกันอยู่แล้ว ในโลกแบบนั้นจะต้องทำสิ่งนี้กันจริง ๆ เหรอ ก็อดสงสัยไม่ได้ความคิดเห็นจาก 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 ได้ ทำให้สคีมาที่ต้องเขียนCHECKconstraint เดิมซ้ำๆ ในหลายตารางกระชับลงมาก เช่นใช้ได้แบบcreate domain mything as text;หรือcreate domain mything as integer not null check (VALUE < 5);ควรพูดถึงกับดักที่ค่อนข้างอันตรายด้วยว่า แค่ตั้ง
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 มีแต่จะเป็นอุปสรรคในการจัดการฐานข้อมูล
นึกภาพไม่ค่อยออกว่าจะย้ายระบบที่ใช้ SQL อย่าง lobste.rs ไปใช้ RocksDB หรือฐานข้อมูลคีย์-ค่าอื่นๆ ได้อย่างไร
ไม่เห็นด้วยว่าการตั้งค่า
busy_timeoutคือทางแก้ SQLite ทำงานได้ดีมากหากมี เธรดเขียนเดียว แล้ว batch ประมวลผล logical transaction ที่ไม่เกี่ยวข้องกันวิธีการโดยละเอียดอธิบายไว้ที่ https://andersmurphy.com/2025/12/…
แต่ก็เป็นเรื่องปกติที่จะต้องแก้ฐานข้อมูลโดยตรงเพื่อทำงาน cleanup หรือรันงานดูแลระบบเป็นครั้งคราวผ่านสคริปต์ที่ยังไม่ถึงขั้นต้องสร้างหน้าจัดการแยกต่างหาก การที่เซิร์ฟเวอร์หรือแอปพลิเคชันอาจหยุดทำงานแบบสุ่ม เพียงเพราะโชคร้ายที่เวลารันไปชนกันพอดี จึงไม่ใช่เรื่องดี