- โครงสร้างแบบไฟล์ของ SQLite นั้นเรียบง่าย แต่เมื่อมีงานเขียนหลายรายการพร้อมกัน อาจเกิด การชนกันของการล็อก (locking) ได้
- Jellyfin ใช้ SQLite มาเป็นเวลานาน แต่ในบางระบบพบปัญหาที่แอปพลิเคชันหยุดทำงานเนื่องจาก ข้อผิดพลาดฐานข้อมูลถูกล็อกระหว่างทรานแซกชัน
- ใช้ความสามารถ interceptor ของ EF Core เพื่อสร้าง กลยุทธ์การล็อก 3 แบบ (No-Lock, Optimistic, Pessimistic) เพื่อบรรเทาปัญหา
- แนวทางแบบ Optimistic ใช้การลองใหม่เพื่อลดผลกระทบต่อประสิทธิภาพให้น้อยที่สุด ส่วน แนวทางแบบ Pessimistic เพิ่มความเสถียรแต่ต้องแลกกับความช้าลง
- แนวทางนี้เป็น โครงสร้างที่ประยุกต์ใช้กับแอปพลิเคชัน EF Core อื่น ๆ ได้ง่าย และเป็นทางเลือกที่ใช้ได้จริงสำหรับการแก้ปัญหา concurrency ของ SQLite
โครงสร้างพื้นฐานและข้อจำกัดของ SQLite
- SQLite คือ เอนจินฐานข้อมูลเชิงสัมพันธ์แบบไฟล์ที่ทำงานอยู่ภายในแอปพลิเคชัน
- เก็บข้อมูลทั้งหมดไว้ในไฟล์เดียว และไม่จำเป็นต้องมีแอปพลิเคชันเซิร์ฟเวอร์แยกต่างหาก
- เนื่องจากแอปพลิเคชันจัดการไฟล์เดี่ยวนี้ทั้งหมดโดยตรง จึงมี ความเสี่ยงต่อการชนกันเมื่อหลายโปรเซสเข้าถึงพร้อมกัน
- ดังนั้นแอปพลิเคชันที่ใช้ SQLite ควรมี งานเขียนได้เพียงหนึ่งรายการในเวลาเดียวกัน
โหมด Write-Ahead-Log (WAL)
- SQLite ผ่อนคลายข้อจำกัดด้าน concurrency ด้วยความสามารถ WAL (Write-Ahead-Log)
- ไฟล์ WAL ทำหน้าที่เป็น ไฟล์ journal ที่บันทึกการเปลี่ยนแปลงของฐานข้อมูล
- สามารถจัดคิวงานเขียนหลายรายการแบบขนาน และนำการเปลี่ยนแปลงใน WAL มาปรับใช้ตอนอ่าน
- อย่างไรก็ตาม WAL ก็ไม่สมบูรณ์แบบ และใน บางสถานการณ์ยังคงเกิดการชนกันของการล็อก ได้
ปัญหาทรานแซกชันของ SQLite
- ทรานแซกชันมีหน้าที่ รับประกันความเป็นอะตอมมิกของการเปลี่ยนแปลง และ ควบคุมการบล็อกการอ่าน
- ในบางระบบของ Jellyfin พบว่า ระหว่างทรานแซกชัน SQLite ส่งคืนข้อผิดพลาด “database is locked” และหยุดทันที
- มีรายงานปัญหานี้ โดยไม่เกี่ยวกับระบบปฏิบัติการ ความเร็วของดิสก์ หรือการใช้ virtualization
- เนื่องจากเกิดขึ้นได้ยากและไม่สม่ำเสมอ จึงยากต่อการระบุสาเหตุ
วิธีที่ Jellyfin ใช้ SQLite และปัญหาที่เกิดขึ้น
- ในสภาพแวดล้อมที่แนะนำ (สตอเรจที่ไม่ใช่เครือข่าย, SSD) ปัญหานี้พบไม่บ่อย แต่ด้วย บั๊กการจำกัดงานขนานก่อนเวอร์ชัน 10.11 ทำให้
- งานสแกนไลบรารีทำงานแบบขนานมากเกินไปจนเกิด คำขอเขียนพร้อมกันนับพันรายการ
- เกินขีดจำกัดการลองใหม่และ timeout ของเอนจิน SQLite จนเกิด ภาระเกินของฐานข้อมูลและข้อผิดพลาด
- ทรานแซกชันที่ยาวนานและคิวรีที่ไม่มีประสิทธิภาพก็ยิ่งทำให้ปัญหารุนแรงขึ้น
แนวทางแก้ไขบนพื้นฐาน EF Core
- Jellyfin สามารถควบคุมโครงสร้างได้มากขึ้นหลังจาก ย้ายโค้ดเบสมาใช้ EF Core
- ใช้ Interceptors ของ EF Core เพื่อดักการทำงานของทุกคำสั่งและทรานแซกชัน แล้วสร้าง การควบคุมการล็อกแบบโปร่งใส
- มีการนำ กลยุทธ์การล็อก 3 แบบ มาใช้
- No-Lock: โหมดพื้นฐาน ไม่มีการล็อกเพิ่มเติม ใช้ในกรณีส่วนใหญ่เพื่อหลีกเลี่ยงผลกระทบต่อประสิทธิภาพ
- Optimistic Locking: หากล้มเหลวจะ ลองใหม่โดยใช้ไลบรารี Polly
- Pessimistic Locking: ก่อนทุกงานเขียน จะล็อกทั้งฐานข้อมูลด้วย ReaderWriterLockSlim
วิธีการทำงานของ Optimistic Locking
- เป็นแนวทางที่สมมติว่างานจะสำเร็จ และจะลองใหม่เมื่อเกิดความล้มเหลว
- หากงานเขียนสองรายการชนกัน ฝั่งหนึ่งจะล้มเหลว จากนั้นรอช่วงเวลาหนึ่งแล้วลองใหม่
- ใช้ ไลบรารี Polly เพื่อกำหนดให้ลองใหม่เฉพาะความล้มเหลวที่เกิดจากการล็อกเท่านั้น
- มี overhead ต่ำกว่าและสูญเสียประสิทธิภาพน้อยกว่า วิธีแบบ Pessimistic
วิธีการทำงานของ Pessimistic Locking
- เมื่อมีงานเขียน จะทำการ ล็อกทั้งฐานข้อมูล
- ระหว่างการเขียน งานอ่านและงานเขียนทั้งหมดจะถูกบล็อก
- วิธีนี้ เสถียรที่สุดแต่ก็ช้าที่สุด
- ตัวอย่างเช่น แม้จะสามารถอ่านตาราง “Alice” พร้อมกับเขียนตาราง “Bob” ได้ แต่แนวทางนี้ก็จะไม่อนุญาต
- ใช้ ReaderWriterLockSlim เพื่ออนุญาตให้อ่านได้หลายรายการ แต่ให้เขียนได้เพียงรายการเดียว
แผนในอนาคต: Smart Locking
- กำลังพิจารณาใช้ Smart Locking ที่ผสานแนวทาง Optimistic และ Pessimistic
- เพื่อรวมข้อดีของทั้งสองแบบและสร้างสมดุลระหว่างประสิทธิภาพกับความเสถียร
ผลลัพธ์และความเป็นไปได้ในการนำไปใช้
- ผลการทดสอบเบื้องต้นพบว่า โหมดการล็อกทั้งสองแบบ มีประสิทธิภาพในการแก้ปัญหา
- แม้สาเหตุรากของปัญหาจะยังไม่ชัดเจน แต่ ตอนนี้ผู้ใช้มีทางเลือกที่ช่วยให้ใช้งาน Jellyfin ได้อย่างเสถียรมากขึ้น
- บนอินเทอร์เน็ตก็มีรายงานข้อผิดพลาดลักษณะคล้ายกันจำนวนมาก แต่ ยังไม่มีทางแก้ที่สมบูรณ์
- การติดตั้งใช้งานของ Jellyfin เป็น โครงสร้างบนพื้นฐาน EF Core interceptor ที่คัดลอกและนำไปใช้ได้ง่าย
- ผู้เรียกใช้งานไม่จำเป็นต้องรับรู้พฤติกรรมการล็อกภายใน
- แอปพลิเคชัน EF Core อื่น ๆ ที่ประสบปัญหา concurrency ของ SQLite แบบเดียวกันก็สามารถนำไปใช้ได้ทันที
2 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เคยเจอ ปัญหาการบล็อกของ SQLite มาก่อน และสาเหตุคือ การกระจัดกระจายของดิสก์ (fragmentation)
ผู้ใช้ที่ใช้งานแอปบนแท็บเล็ต Android รุ่นเก่า ๆ วันละ 8 ชั่วโมงต่อเนื่องมาหลายปี เริ่มบ่นว่าช้าลงและเจอข้อผิดพลาดเกี่ยวกับล็อก
ตอนคัดลอกข้อมูลออกมารับไปตรวจสอบกลับไม่สามารถทำให้เกิดปัญหาซ้ำได้ แต่พอได้เครื่องมาลองตรวจเอง สุดท้ายพบว่าแค่คัดลอกไฟล์ DB ไปยังตำแหน่งใหม่แล้วเปลี่ยนกลับมาใช้ชื่อเดิมเพื่อ “ดีแฟรก” ปัญหาก็หายไปหมด
และก็เคยได้ประสิทธิภาพดีขึ้นจากวิธีเดียวกันนี้กับ DB ของ Jellyfin ด้วย
ธุรกรรมของ SQLite จะเริ่มต้นใน โหมด “deferred” โดยปริยาย
หมายความว่าจะยังไม่จับ write lock จนกว่าจะพยายามเขียนจริง
ข้อผิดพลาด
SQLITE_BUSYจะเกิดขึ้นเมื่อทรานแซกชันที่อ่านอยู่พยายามเปลี่ยนเป็นการเขียน แต่มีอีกทรานแซกชันหนึ่งจับ write lock ไปแล้ววิธีแก้คือกำหนด
busy_timeoutและให้ทรานแซกชันที่มีการเขียนเริ่มในโหมด “immediate”มีคำอธิบายที่ดีในบล็อกโพสต์นี้
SQLITE_BUSYเหมือนกัน มีการรวบรวมกรณีที่เกี่ยวข้องไว้ที่นี่SQLITE_BUSYเป็นเหมือน กลิ่นทางสถาปัตยกรรม แบบหนึ่ง ในโหมด WAL ควรออกแบบให้แยกพูลคอนเนกชันแบบอ่านอย่างเดียวออกจากพูลคอนเนกชันสำหรับเขียนเพียงชุดเดียว แบบนี้จะมองเห็นสถานะการครอบครองล็อกได้ชัด และออกแบบรับมือ สถานะการแย่งทรัพยากร ล่วงหน้าได้busy_timeoutใช้ไม่ได้ในกรณีนี้ ในโหมด WAL หน้าข้อมูลจะถูกต่อเพิ่มเข้าไฟล์ล็อกเดียว ดังนั้นถ้าพยายามสลับจากการอ่านไปเป็นการเขียนระหว่างนั้น SQLite จะล้มเหลวทันทีเพื่อ รับประกันการทำงานแบบอนุกรม และโหมด “immediate” จะช่วยป้องกันเรื่องนี้SQLITE_BUSYเลย น่าจะเป็นเพราะขาดการตั้งค่าบางอย่างไปดูเหมือนว่าคำอธิบายบางส่วนในบทความจะไม่ถูกต้อง
SQLite มี การจัดการล็อก ของตัวเองอยู่แล้ว จึงไม่จำเป็นที่แอปพลิเคชันจะต้องควบคุมการเข้าถึงไฟล์เอง
และ WAL ก็ไม่ได้รองรับ การเขียนแบบขนานหลายชุด มันแค่ทำให้การอ่านกับการเขียนเพียงชุดเดียวเกิดขึ้นพร้อมกันได้
SQLite เป็นฐานข้อมูลที่ยอดเยี่ยม แต่ก็น่าเสียดายที่ ค่าเริ่มต้น (defaults) ระมัดระวังเกินไป
ถ้าจะใช้กับงานโปรดักชันจริงต้องปรับ การตั้งค่า PRAGMA หลายอย่าง
ถ้า ฟีเจอร์ hctree ใหม่ของ SQLite เสถียรเมื่อไร หลังจากนั้นผมคงจะใช้แต่ SQLite อย่างเดียว
ชื่อ
hcน่าจะย่อมาจาก High Concurrencyลิงก์เอกสารทางการ
พออ่านบทความแบบนี้แล้วรู้สึกว่าเน้น วิธีแก้เฉพาะหน้า มากกว่าการวิเคราะห์ สาเหตุราก ของปัญหา
การดีบักและค้นคว้าให้ลึกกว่านี้เพื่อหาต้นเหตุที่แท้จริงน่าจะเป็นสิ่งที่มีคุณค่ากว่าในการนำมาแบ่งปัน
ดูเหมือนผู้เขียนจะยังไม่เข้าใจว่าโหมด WAL เองก็ยังเป็นโครงสร้างแบบ เขียนเดี่ยว อ่านหลายชุด
การเขียนแบบขนานทำไม่ได้ มันแค่ช่วยให้ทรานแซกชันอ่านไม่ถูกบล็อกจากการเขียนเท่านั้น
ถ้ามี MVCC เต็มรูปแบบก็คงดี แต่โครงสร้างปัจจุบันก็ทำงานได้ดีพอถ้าเข้าใจหลักการของมัน
ผมก็เคยเจอปัญหาคล้ายกันใน Jellyfin
โดยปกติใช้งานได้ดี แต่ในบางสถานการณ์ DB จะ ค้างพร้อมล็อกอยู่
ในล็อกมีเพียงข้อความ “database is locked” และสุดท้ายต้องรีสตาร์ตคอนเทนเนอร์ Docker ถึงจะหาย
มักเกิดตอนกดหลายปุ่มอย่างรวดเร็วใน UI บนทีวี
เป็นอีกประเด็นหนึ่ง แต่ถ้าใช้ SQLite in-memory DB กับงาน insert/delete จำนวนมาก การใช้หน่วยความจำจะค่อย ๆ เพิ่มขึ้น
เช่น ถ้าทำงานที่เพิ่มแล้วลบ 100,000 แถวทุก ๆ 5 นาทีต่อเนื่องหลายวัน บน macOS หน่วยความจำอาจขึ้นไปถึง 1GB
เลยสงสัยว่ามีการตั้งค่าอะไรที่พอจะจูนได้ในกรณีแบบนี้หรือไม่
auto_vacuumไว้หรือเปล่าเอกสาร VACUUM
SQLite ยอดเยี่ยมมาก แต่เวลาเจอปัญหาแบบนี้ก็มีช่วงที่รู้สึกว่าไปใช้ Postgres จะดีกว่าไหม
ถ้าไม่ได้ต้องการความพกพาแบบไฟล์เดียวหรือใช้แบบ embedded แล้ว Postgres จัดการปัญหา concurrency ได้ง่ายกว่า
มีจุดที่ทำให้งงอยู่ เลยรีบไปดูคอมเมนต์ก่อนทันที แล้วก็เป็นอย่างที่คิดไว้จริง ๆ...