11 คะแนน โดย GN⁺ 2025-11-02 | 2 ความคิดเห็น | แชร์ทาง WhatsApp
  • โครงสร้างแบบไฟล์ของ 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 แบบ มาใช้
    1. No-Lock: โหมดพื้นฐาน ไม่มีการล็อกเพิ่มเติม ใช้ในกรณีส่วนใหญ่เพื่อหลีกเลี่ยงผลกระทบต่อประสิทธิภาพ
    2. Optimistic Locking: หากล้มเหลวจะ ลองใหม่โดยใช้ไลบรารี Polly
    3. 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 ความคิดเห็น

 
GN⁺ 2025-11-02
ความคิดเห็นจาก Hacker News
  • เคยเจอ ปัญหาการบล็อกของ SQLite มาก่อน และสาเหตุคือ การกระจัดกระจายของดิสก์ (fragmentation)
    ผู้ใช้ที่ใช้งานแอปบนแท็บเล็ต Android รุ่นเก่า ๆ วันละ 8 ชั่วโมงต่อเนื่องมาหลายปี เริ่มบ่นว่าช้าลงและเจอข้อผิดพลาดเกี่ยวกับล็อก
    ตอนคัดลอกข้อมูลออกมารับไปตรวจสอบกลับไม่สามารถทำให้เกิดปัญหาซ้ำได้ แต่พอได้เครื่องมาลองตรวจเอง สุดท้ายพบว่าแค่คัดลอกไฟล์ DB ไปยังตำแหน่งใหม่แล้วเปลี่ยนกลับมาใช้ชื่อเดิมเพื่อ “ดีแฟรก” ปัญหาก็หายไปหมด
    และก็เคยได้ประสิทธิภาพดีขึ้นจากวิธีเดียวกันนี้กับ DB ของ Jellyfin ด้วย

    • นั่นน่าจะเป็น การเสื่อมสภาพของหน่วยความจำแฟลช มากกว่าการกระจัดกระจาย อยากรู้ว่าเป็นแท็บเล็ตราคาถูกที่ใช้ที่เก็บข้อมูลแบบ eMMC หรือเปล่า
    • สงสัยว่าใช้ ฟังก์ชัน VACUUM ของ SQLite จะให้ผลแบบเดียวกันได้ไหม
    • เป็นกรณีที่น่าสนใจ แต่คงให้ผู้ใช้มาดีแฟรกเองไม่ได้ จึงต้องมีทางแก้ที่ใช้ได้จริงมากกว่านี้
  • ธุรกรรมของ SQLite จะเริ่มต้นใน โหมด “deferred” โดยปริยาย
    หมายความว่าจะยังไม่จับ write lock จนกว่าจะพยายามเขียนจริง
    ข้อผิดพลาด SQLITE_BUSY จะเกิดขึ้นเมื่อทรานแซกชันที่อ่านอยู่พยายามเปลี่ยนเป็นการเขียน แต่มีอีกทรานแซกชันหนึ่งจับ write lock ไปแล้ว
    วิธีแก้คือกำหนด busy_timeout และให้ทรานแซกชันที่มีการเขียนเริ่มในโหมด “immediate”
    มีคำอธิบายที่ดีในบล็อกโพสต์นี้

    • ตอนแรกฉันก็คิดว่านี่เป็นปัญหา SQLITE_BUSY เหมือนกัน มีการรวบรวมกรณีที่เกี่ยวข้องไว้ที่นี่
    • ผมมองว่า SQLITE_BUSY เป็นเหมือน กลิ่นทางสถาปัตยกรรม แบบหนึ่ง ในโหมด WAL ควรออกแบบให้แยกพูลคอนเนกชันแบบอ่านอย่างเดียวออกจากพูลคอนเนกชันสำหรับเขียนเพียงชุดเดียว แบบนี้จะมองเห็นสถานะการครอบครองล็อกได้ชัด และออกแบบรับมือ สถานะการแย่งทรัพยากร ล่วงหน้าได้
    • busy_timeout ใช้ไม่ได้ในกรณีนี้ ในโหมด WAL หน้าข้อมูลจะถูกต่อเพิ่มเข้าไฟล์ล็อกเดียว ดังนั้นถ้าพยายามสลับจากการอ่านไปเป็นการเขียนระหว่างนั้น SQLite จะล้มเหลวทันทีเพื่อ รับประกันการทำงานแบบอนุกรม และโหมด “immediate” จะช่วยป้องกันเรื่องนี้
    • สุดท้ายแล้วใครก็ตามที่ใช้ SQLite สักวันหนึ่งก็มักจะต้อง โดนเล่นงาน กับปัญหานี้สักครั้ง และเสียเวลาไล่หาสาเหตุ
    • ในบล็อกโพสต์ไม่ได้พูดถึง SQLITE_BUSY เลย น่าจะเป็นเพราะขาดการตั้งค่าบางอย่างไป
  • ดูเหมือนว่าคำอธิบายบางส่วนในบทความจะไม่ถูกต้อง
    SQLite มี การจัดการล็อก ของตัวเองอยู่แล้ว จึงไม่จำเป็นที่แอปพลิเคชันจะต้องควบคุมการเข้าถึงไฟล์เอง
    และ WAL ก็ไม่ได้รองรับ การเขียนแบบขนานหลายชุด มันแค่ทำให้การอ่านกับการเขียนเพียงชุดเดียวเกิดขึ้นพร้อมกันได้

    • ผมเองก็ชอบ SQLite มาก แต่บทความนี้ผิดตั้งแต่แนวคิดพื้นฐานเรื่อง concurrency เลย เลยแนะนำต่อไม่ลง
  • SQLite เป็นฐานข้อมูลที่ยอดเยี่ยม แต่ก็น่าเสียดายที่ ค่าเริ่มต้น (defaults) ระมัดระวังเกินไป
    ถ้าจะใช้กับงานโปรดักชันจริงต้องปรับ การตั้งค่า PRAGMA หลายอย่าง

    • อยากรู้ว่ามี PRAGMA ไหนบ้างที่ควรเปิดไว้เป็นค่าปริยาย
    • ถ้าเป็นแบบนี้ บางที fork เพื่อสร้างชุดค่าเริ่มต้นใหม่ไปเลยอาจจะดีกว่าไหม
  • ถ้า ฟีเจอร์ hctree ใหม่ของ SQLite เสถียรเมื่อไร หลังจากนั้นผมคงจะใช้แต่ SQLite อย่างเดียว
    ชื่อ hc น่าจะย่อมาจาก High Concurrency
    ลิงก์เอกสารทางการ

  • พออ่านบทความแบบนี้แล้วรู้สึกว่าเน้น วิธีแก้เฉพาะหน้า มากกว่าการวิเคราะห์ สาเหตุราก ของปัญหา
    การดีบักและค้นคว้าให้ลึกกว่านี้เพื่อหาต้นเหตุที่แท้จริงน่าจะเป็นสิ่งที่มีคุณค่ากว่าในการนำมาแบ่งปัน

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

  • ผมก็เคยเจอปัญหาคล้ายกันใน Jellyfin
    โดยปกติใช้งานได้ดี แต่ในบางสถานการณ์ DB จะ ค้างพร้อมล็อกอยู่
    ในล็อกมีเพียงข้อความ “database is locked” และสุดท้ายต้องรีสตาร์ตคอนเทนเนอร์ Docker ถึงจะหาย
    มักเกิดตอนกดหลายปุ่มอย่างรวดเร็วใน UI บนทีวี

  • เป็นอีกประเด็นหนึ่ง แต่ถ้าใช้ SQLite in-memory DB กับงาน insert/delete จำนวนมาก การใช้หน่วยความจำจะค่อย ๆ เพิ่มขึ้น
    เช่น ถ้าทำงานที่เพิ่มแล้วลบ 100,000 แถวทุก ๆ 5 นาทีต่อเนื่องหลายวัน บน macOS หน่วยความจำอาจขึ้นไปถึง 1GB
    เลยสงสัยว่ามีการตั้งค่าอะไรที่พอจะจูนได้ในกรณีแบบนี้หรือไม่

    • แนะนำให้ตรวจสอบว่ามีการรัน VACUUM เป็นระยะหรือไม่ และเปิด auto_vacuum ไว้หรือเปล่า
      เอกสาร VACUUM
    • อาจเป็นพฤติกรรมปกติที่บัฟเฟอร์ถูก ปรับแบบไดนามิก ให้เข้ากับรูปแบบการใช้งานก็ได้
    • ถ้าเป็นกรณีที่ลบทุกแถวจริง ๆ การ ลบดรอปตารางแล้วสร้างใหม่ จะมีประสิทธิภาพมากกว่า
  • SQLite ยอดเยี่ยมมาก แต่เวลาเจอปัญหาแบบนี้ก็มีช่วงที่รู้สึกว่าไปใช้ Postgres จะดีกว่าไหม
    ถ้าไม่ได้ต้องการความพกพาแบบไฟล์เดียวหรือใช้แบบ embedded แล้ว Postgres จัดการปัญหา concurrency ได้ง่ายกว่า

    • แต่ Jellyfin เป็น มีเดียเซิร์ฟเวอร์แบบ self-hosted ถ้าบังคับให้ต้องใช้ Postgres การติดตั้งและการดูแลรักษาจะซับซ้อนขึ้น SQLite จึงเหมาะกว่า
    • Jellyfin ส่วนใหญ่ใช้ใน สภาพแวดล้อมภายในบ้านแบบผู้ใช้เดี่ยว ดังนั้น SQLite ก็เพียงพอ เพียงแต่การตั้งค่าปัจจุบันอาจยังไม่เหมาะที่สุด
    • การมองข้ามข้อดีของ SQLite แล้วบอกให้ไปใช้ Postgres ก็เหมือนกับการบอกว่า “จะไปตั้งแคมป์ทั้งที งั้นสร้างกระท่อมไปเลยสิ”
    • ถ้าใช้ Postgres นอกจากการติดตั้งแล้ว ยังต้องคอยดูแลเรื่อง การย้ายข้อมูลตอนอัปเกรดเวอร์ชัน อีกด้วย ขณะที่ SQLite ไม่มีภาระแบบนั้น
    • เมื่อไม่นานมานี้ Jellyfin ได้รีไรต์โค้ดส่วน DB ด้วย Entity Framework แล้ว จึงกำลังเตรียมให้รองรับการเลือกฐานข้อมูลได้ยืดหยุ่นมากขึ้นในอนาคต
 
ndrgrd 2025-11-03

มีจุดที่ทำให้งงอยู่ เลยรีบไปดูคอมเมนต์ก่อนทันที แล้วก็เป็นอย่างที่คิดไว้จริง ๆ...