- เมื่อ 3 ปีก่อน Notion ได้ปรับปรุงความเร็วของแอป Notion สำหรับ Mac และ Windows ได้สำเร็จ โดยใช้ฐานข้อมูล SQLite เพื่อแคชข้อมูลไว้ที่ฝั่งไคลเอนต์
- ครั้งนี้ก็สามารถนำการปรับปรุงแบบเดียวกันมาให้กับผู้ใช้ที่เข้าใช้งาน Notion ผ่านเบราว์เซอร์ได้แล้ว
- บทความนี้เป็นการวิเคราะห์เชิงลึกว่า Notion ปรับปรุงประสิทธิภาพบนเบราว์เซอร์อย่างไร โดยใช้
sqlite3 เวอร์ชันที่ทำงานบน WebAssembly (WASM)
- การใช้ SQLite ช่วยให้เวลาในการนำทางระหว่างหน้าในเบราว์เซอร์สมัยใหม่ทั้งหมดดีขึ้น 20%
- โดยเฉพาะสำหรับผู้ใช้ที่มีเวลาในการตอบสนองของ API ช้าเป็นพิเศษจากปัจจัยภายนอก เช่น การเชื่อมต่ออินเทอร์เน็ต ความแตกต่างยิ่งเห็นได้ชัด
- ตัวอย่างเช่น ผู้ใช้ในออสเตรเลียเร็วขึ้น 28%, ผู้ใช้ในจีนเร็วขึ้น 31% และผู้ใช้ในอินเดียเร็วขึ้น 33%
เทคโนโลยีหลัก: OPFS และ Web Workers
- ไลบรารี WASM SQLite ใช้ API เบราว์เซอร์สมัยใหม่ชื่อ Origin Private File System (OPFS) เพื่อเก็บข้อมูลให้คงอยู่ข้ามแต่ละเซสชัน
- OPFS ใช้งานได้เฉพาะใน Web Workers เท่านั้น
- Web Worker สามารถมองได้ว่าเป็นโค้ดที่รันอยู่บนเธรดแยกจาก main thread ซึ่งเป็นที่ที่ JavaScript ส่วนใหญ่ในเบราว์เซอร์ทำงาน
- Notion ถูก bundle ร่วมกับ Webpack ซึ่งมีไวยากรณ์ที่ใช้งานง่ายสำหรับการโหลด Web Worker
- ทางทีมได้ตั้งค่า Web Worker ให้สร้างไฟล์ฐานข้อมูล SQLite หรือโหลดไฟล์เดิมโดยใช้ OPFS และรันโค้ดแคชเดิมภายใน Web Worker นี้
- ใช้ไลบรารี Comlink เพื่อจัดการการส่งข้อความระหว่าง main thread กับ Worker ได้ง่ายขึ้น
แนวทางที่อิงกับ SharedWorker
- สถาปัตยกรรมสุดท้ายอ้างอิงจาก โซลูชันใหม่ที่ Roy Hashimoto เสนอไว้ในการพูดคุยบน GitHub
- เป็นแนวทางที่ให้มีเพียงแท็บเดียวเท่านั้นที่เข้าถึง SQLite ได้ในแต่ละครั้ง แต่ยังเปิดให้แท็บอื่นรัน SQLite query ได้ด้วย
- สถาปัตยกรรมใหม่นี้ทำงานอย่างไร?
- พูดแบบสั้น ๆ คือ แต่ละแท็บจะมี Web Worker เฉพาะของตัวเองที่สามารถเขียนลง SQLite ได้
- แต่ในทางปฏิบัติ จะมีเพียงแท็บเดียวเท่านั้นที่สามารถใช้ Web Worker ได้จริง
- SharedWorker ทำหน้าที่จัดการว่าแท็บใดคือ "แท็บที่ใช้งานอยู่"
- เมื่อแท็บที่ใช้งานอยู่ถูกปิด SharedWorker ก็จะรู้ว่าต้องเลือกแท็บที่ใช้งานอยู่แท็บใหม่
- เมื่อต้องรัน SQLite query แต่ละแท็บจะให้ main thread ส่ง query นั้นไปยัง SharedWorker แล้ว SharedWorker จะเปลี่ยนเส้นทางไปยัง Worker เฉพาะของแท็บที่กำลังใช้งานอยู่
- แต่ละแท็บสามารถรัน SQLite query พร้อมกันได้มากเท่าที่ต้องการ โดยทุกอย่างจะถูกส่งผ่านไปยังแท็บที่ใช้งานอยู่เพียงแท็บเดียวเสมอ
- แต่ละ Web Worker เข้าถึงฐานข้อมูล SQLite ผ่าน OPFS SyncAccessHandle Pool VFS implementation ซึ่งทำงานได้บนเบราว์เซอร์หลักทั้งหมด
ทำไมแนวทางที่ง่ายกว่าจึงใช้ไม่ได้ผล
- ก่อนจะสร้างสถาปัตยกรรมที่อธิบายไว้ข้างต้น ทางทีมเคยพยายามใช้ WASM SQLite ด้วยวิธีที่ง่ายกว่า คือให้แต่ละแท็บมี Web Worker เฉพาะของตัวเอง และให้แต่ละ Web Worker เขียนลงฐานข้อมูล SQLite
- แต่ไม่มีแนวทางไหนที่ตอบโจทย์ของ Notion ได้มากพอหากนำมาใช้ตรง ๆ
อุปสรรค #1: cross-origin isolation
- หากต้องการใช้ OPFS ผ่าน
sqlite3_vfs เว็บไซต์จะต้องอยู่ในสถานะ "cross-origin isolation"
- การเพิ่ม cross-origin isolation ให้กับหน้าเว็บต้องตั้งค่า security headers บางอย่างที่จำกัดว่าสคริปต์ใดบ้างที่โหลดได้
- การตั้งค่า headers เหล่านี้อาจเป็นงานที่ใหญ่พอสมควร
- Notion พึ่งพาสคริปต์จากบุคคลที่สามจำนวนมากเพื่อขับเคลื่อนฟังก์ชันต่าง ๆ ของโครงสร้างพื้นฐานเว็บ ดังนั้นการทำให้เกิด cross-origin isolation อย่างสมบูรณ์จึงต้องขอให้ผู้ให้บริการแต่ละรายตั้งค่า headers ใหม่และเปลี่ยนวิธีการทำงานของ iframe ซึ่งเป็นข้อกำหนดที่ทำได้ยากในทางปฏิบัติ
- ในการทดสอบ ทีมสามารถเก็บข้อมูลประสิทธิภาพที่สำคัญได้โดยปล่อยเวอร์ชันนี้ให้กับผู้ใช้บางส่วน ผ่าน Origin Trials สำหรับ SharedArrayBuffer ที่ใช้งานได้ใน Chrome และ Edge
- Origin Trials เหล่านี้ช่วยให้ข้ามข้อกำหนดเรื่อง cross-origin isolation ได้ชั่วคราว
อุปสรรค #2: ปัญหาความเสียหายของข้อมูล
- เมื่อปล่อย OPFS ผ่าน
sqlite3_vfs ให้ผู้ใช้จำนวนเล็กน้อยเริ่มใช้งาน ก็เริ่มเกิดบั๊กที่ร้ายแรงกับผู้ใช้บางราย
- ผู้ใช้เหล่านี้เห็นข้อมูลผิดบนหน้าเว็บ
- ตัวอย่างเช่น คอมเมนต์ที่ถูก assign ให้เพื่อนร่วมงานผิดคน หรือลิงก์ไปยังหน้าใหม่ที่พรีวิวกลับเป็นอีกหน้าหนึ่งโดยสิ้นเชิง
- เมื่อตรวจดูไฟล์ฐานข้อมูลของผู้ใช้ที่ได้รับผลกระทบ พบรูปแบบที่ชี้ว่าฐานข้อมูล SQLite เสียหายในบางลักษณะ
- การเลือกแถวจากบางตารางทำให้เกิดข้อผิดพลาด และเมื่อตรวจดูแถวจริงก็พบปัญหาความสอดคล้องของข้อมูล เช่น มีหลายแถวที่ใช้ ID เดียวกันแต่มีเนื้อหาต่างกัน
- ทีมคาดเดาว่าสถานะดังกล่าวของฐานข้อมูล SQLite น่าจะเกิดจากปัญหา concurrency
- เพราะมีหลายแท็บเปิดอยู่ และแต่ละแท็บมี Web Worker เฉพาะที่มีการเชื่อมต่อกับฐานข้อมูล SQLite แบบ active
- แอปพลิเคชันของ Notion เขียนลงแคชบ่อยครั้งทุกครั้งที่ได้รับอัปเดตจากเซิร์ฟเวอร์ หมายความว่าแท็บหลายแท็บอาจเขียนลงไฟล์เดียวกันพร้อมกัน
- แม้จะใช้แนวทาง transaction ที่ batch SQLite queries เข้าด้วยกันอยู่แล้ว แต่ทีมก็สงสัยอย่างมากว่าปัญหาความเสียหายนั้นเกิดจากการจัดการ concurrency ที่ไม่เพียงพอฝั่ง OPFS API
- จึงเริ่มทำ logging สำหรับข้อผิดพลาดเรื่องความเสียหายของข้อมูล และลองวิธีแก้เฉพาะหน้า เช่น เพิ่ม Web Locks และให้เฉพาะแท็บที่โฟกัสอยู่เท่านั้นที่เขียนลง SQLite
- การปรับเหล่านี้ช่วยลดอัตราความเสียหายลงได้ แต่ยังไม่เพียงพอที่จะเปิดฟีเจอร์นี้กลับมาใช้กับ production traffic
- อย่างน้อยก็ช่วยยืนยันได้ว่าปัญหา concurrency มีส่วนสำคัญต่อความเสียหายของข้อมูล
- ปัญหานี้ไม่เกิดขึ้นในแอปเดสก์ท็อปของ Notion
- เพราะบนแพลตฟอร์มนั้นมีเพียง parent process เดียวที่เขียนลง SQLite
- แม้ในแอปจะเปิดได้หลายแท็บตามต้องการ แต่จะมีเพียงเธรดเดียวเสมอที่เข้าถึงไฟล์ฐานข้อมูล
อุปสรรค #3: ทางเลือกใช้งานได้ครั้งละแท็บเดียว
- ทีมยังได้ประเมิน OPFS SyncAccessHandle Pool VFS variant ด้วย
- variant นี้ไม่ต้องใช้ SharedArrayBuffer จึงใช้งานได้ใน Safari, Firefox และเบราว์เซอร์อื่นที่ไม่มี Origin Trial สำหรับ SharedArrayBuffer
- ข้อเสียของ variant นี้คือสามารถทำงานได้ทีละแท็บเดียวเท่านั้น
- หากพยายามเปิดฐานข้อมูล SQLite จากแท็บถัดไป ก็จะเกิดข้อผิดพลาดทันที
- ในแง่หนึ่ง นั่นหมายความว่า OPFS SyncAccessHandle Pool VFS ไม่มีปัญหา concurrency แบบเดียวกับ OPFS ผ่าน
sqlite3_vfs
- ซึ่งยืนยันได้จากการทดลองปล่อยให้ผู้ใช้กลุ่มเล็ก โดยไม่พบปัญหาฐานข้อมูลเสียหาย
- แต่อีกด้านหนึ่ง ทีมไม่สามารถปล่อย variant นี้ตรง ๆ ได้ เพราะต้องการให้ทุกแท็บของผู้ใช้ได้รับประโยชน์จากการแคช
การแก้ปัญหา
- ความจริงที่ว่าไม่มี variant ไหนใช้ได้ตรง ๆ คือจุดที่ผลักดันให้ทีมสร้างสถาปัตยกรรม SharedWorker ที่อธิบายไว้ข้างต้น
- สถาปัตยกรรมนี้เข้ากันได้กับ SQLite variants เหล่านี้ตัวใดตัวหนึ่ง
- เมื่อใช้ OPFS ผ่าน
sqlite3_vfs จะมีเพียงแท็บเดียวที่เขียนได้ในแต่ละครั้ง จึงหลีกเลี่ยงปัญหาฐานข้อมูลเสียหายได้
- เมื่อใช้ OPFS SyncAccessHandle Pool VFS ทุกแท็บก็สามารถใช้การแคชได้ด้วย SharedWorker
- หลังจากยืนยันแล้วว่าสถาปัตยกรรมนี้ทำงานได้กับทั้งสอง variants, มีการปรับปรุงประสิทธิภาพที่เห็นได้ชัดในตัวชี้วัด และไม่มีปัญหาฐานข้อมูลเสียหาย ก็ถึงเวลาต้องตัดสินใจขั้นสุดท้ายว่าจะปล่อย variant ใด
- ทีมเลือก OPFS SyncAccessHandle Pool VFS เพราะไม่มีข้อกำหนดเรื่อง cross-origin isolation จึงไม่เป็นอุปสรรคต่อการปล่อยไปยังเบราว์เซอร์อื่นนอกจาก Chrome และ Edge
การบรรเทาผลกระทบด้านประสิทธิภาพ
- เมื่อเริ่มปล่อยการปรับปรุงนี้ให้ผู้ใช้ ก็พบการถดถอยด้านประสิทธิภาพบางอย่างที่ต้องแก้ เช่น เวลาโหลดที่ช้าลง
การโหลดหน้าช้าลง
- สิ่งแรกที่พบคือ แม้การย้ายไปมาระหว่างหน้าใน Notion จะเร็วขึ้น แต่การโหลดหน้าแรกกลับช้าลง
- จากการทำ profiling ทีมตระหนักว่าปกติแล้วการโหลดหน้าไม่ได้ติดคอขวดที่การดึงข้อมูล
- โค้ดบูตแอปของ Notion จะทำงานอื่นไปพร้อมกันระหว่างรอ API call เสร็จ เช่น parsing JS, ตั้งค่าแอป ฯลฯ จึงไม่ได้รับประโยชน์จากการแคชด้วย SQLite มากเท่ากับการนำทาง
- สาเหตุที่ช้าลงคือผู้ใช้ต้องดาวน์โหลดและประมวลผลไลบรารี WASM SQLite
- สิ่งนี้บล็อกกระบวนการโหลดหน้า ทำให้งานโหลดหน้าอื่น ๆ ไม่สามารถเกิดขึ้นพร้อมกันได้
- เนื่องจากไลบรารีนี้มีขนาดหลายร้อยกิโลไบต์ เวลาที่เพิ่มขึ้นจึงมองเห็นได้ชัดในตัวชี้วัด
- เพื่อแก้ปัญหานี้ ทีมได้ปรับวิธีการโหลดไลบรารีเล็กน้อย
- โดยโหลด WASM SQLite แบบ asynchronous เต็มรูปแบบ และไม่ให้บล็อกการโหลดหน้า
- นั่นหมายความว่าข้อมูลหน้าแรกแทบจะไม่ถูกโหลดจาก SQLite
- ซึ่งยอมรับได้ เพราะมีการประเมินเชิงวัตถุวิสัยแล้วว่าความเร็วที่ได้จากการโหลดหน้าแรกด้วย SQLite นั้นไม่ได้มากกว่าความช้าจากการดาวน์โหลดไลบรารี
- หลังจากใช้การเปลี่ยนแปลงนี้ ตัวชี้วัดการโหลดหน้าแรกก็เท่ากันระหว่างกลุ่มทดสอบและกลุ่มควบคุมของการทดลอง
อุปกรณ์ช้าไม่ได้ประโยชน์จากการแคช
- อีกสิ่งหนึ่งที่พบจากตัวชี้วัดคือ แม้ค่า median ของเวลาการย้ายจากหน้า Notion หนึ่งไปยังอีกหน้าจะเร็วขึ้น แต่เวลาที่ percentile ที่ 95 กลับช้าลง
- อุปกรณ์บางประเภท เช่น โทรศัพท์มือถือที่เปิดเบราว์เซอร์เข้า Notion ไม่ได้รับประโยชน์จากการแคช และกลับแย่ลงด้วยซ้ำ
- คำตอบของปริศนานี้มาจากการศึกษาก่อนหน้าที่ทีมมือถือเคยทำไว้
- ตอนนำการแคชนี้ไปใช้ในแอปมือถือแบบเนทีฟ พบว่าอุปกรณ์บางรุ่น เช่น โทรศัพท์ Android รุ่นเก่า อ่านข้อมูลจากดิสก์ได้ช้ามาก
- ดังนั้นจึงไม่สามารถสมมติได้ว่าการโหลดข้อมูลจาก disk cache จะเร็วกว่าโหลดข้อมูลเดียวกันจาก API เสมอไป
- ผลจากการศึกษาฝั่งมือถือทำให้รู้ว่าก่อนหน้านี้มี logic บางอย่างอยู่แล้วที่ให้คำขอสองตัวแบบ asynchronous (SQLite และ API) "แข่งกัน" ระหว่างการโหลดหน้า
- ทีมจึงนำ logic นี้กลับมาใช้ใหม่แบบตรงไปตรงมาใน code path สำหรับการคลิกนำทาง
- สิ่งนี้ทำให้ค่า percentile ที่ 95 ของเวลาการนำทางเท่ากันระหว่างสองกลุ่มในการทดลอง
บทสรุป
- การนำการปรับปรุงประสิทธิภาพของ SQLite มาใช้กับ Notion บนเบราว์เซอร์มีความท้าทายในแบบของมันเอง
- โดยเฉพาะเมื่อเกี่ยวข้องกับเทคโนโลยีใหม่ ๆ ทีมต้องเผชิญกับสิ่งที่ไม่รู้หลายอย่าง และได้บทเรียนบางข้อระหว่างทาง:
- โดยพื้นฐานแล้ว OPFS ไม่ได้จัดการ concurrency ได้อย่างสวยงาม นักพัฒนาจึงต้องตระหนักเรื่องนี้และออกแบบให้เหมาะสม
- Web Workers และ SharedWorkers (รวมถึงญาติของมันอย่าง Service Workers ที่ไม่ได้กล่าวถึงในบทความนี้) มีความสามารถต่างกัน และการนำมาผสานกันอาจมีประโยชน์เมื่อจำเป็น
- ณ ฤดูใบไม้ผลิปี 2024 การทำ cross-origin isolation ให้สมบูรณ์ในเว็บแอประดับซับซ้อนยังไม่ใช่เรื่องง่าย โดยเฉพาะหากมีการใช้สคริปต์จากภายนอก
- ผลจากการแคชข้อมูลด้วย SQLite บนเบราว์เซอร์ให้ผู้ใช้ ทำให้เห็นการปรับปรุงเวลาในการนำทาง 20% ตามที่กล่าวไว้ก่อนหน้า และไม่พบว่าตัวชี้วัดอื่นแย่ลง
- ที่สำคัญคือไม่พบปัญหาที่เกิดจาก SQLite เสียหาย
- ทีมมองว่าความสำเร็จและความเสถียรของแนวทางสุดท้ายนี้ต้องยกเครดิตให้กับทีมที่ดูแล WASM implementation อย่างเป็นทางการของ SQLite และ Roy Hashimoto ที่แบ่งปันแนวทางเชิงทดลองให้สาธารณะได้ใช้งาน
6 ความคิดเห็น
นี่แหละว่าทำไมบริการที่ต้องทำงานร่วมกับบุคคลที่สามควรเปิดใช้ cross-origin isolation ตั้งแต่การเปิดตัวครั้งแรก...
โอ๊ะ คุณ cometkim ยินดีที่ได้เจอนะครับ
พอฉันเปิดหน้า Notion ใน firefox มันค้างจนใช้งานไม่ได้เลย นี่อาจเป็นเพราะเรื่องนี้หรือเปล่านะ.. (แต่แอป Notion ใช้งานได้ดี เลยตอนนี้ใช้ตัวนั้นอยู่)
น่าจะเป็นแบบนั้นครับ Enda เองก็รองรับการเขียนไฟล์แบบโลคัลเฉพาะบน Chrome และ Edge เท่านั้น
ฉันเคยเจอแบบนี้บนแล็ปท็อปลินุกซ์เครื่องเก่าเหมือนกัน แต่พอเปิดด้วยโหมดไม่ระบุตัวตนก็ใช้งานได้