Durable Objects กับ WebSocket เดิมต่างกันตรงไหนตอนสร้างแอปแชทเรียลไทม์

สรุปสั้น ๆ
Durable Objects คือหน่วยประมวลผลที่มีสถานะ (stateful) ของ Cloudflare ที่รับประกันว่าจะมี instance เดียวต่อ object id หนึ่งตัวเสมอ ทำให้เหมาะกับงานที่ต้องการ coordination แบบรวมศูนย์ เช่นห้องแชท เกมเรียลไทม์ หรือ WebSocket connection จำนวนมาก โดยมีกลไก Hibernation ช่วยลดต้นทุนตอนไม่มีข้อความส่งผ่าน
Workers ปกติถูกออกแบบมาให้ไม่มีสถานะ (stateless) แต่ละ request ที่เข้ามาอาจถูกประมวลผลโดย instance คนละตัวกัน ไม่มีการรับประกันว่า request สองครั้งติดกันจะไปตกที่ instance เดียวกัน ซึ่งเหมาะกับงาน HTTP API ทั่วไปที่แต่ละ request ทำงานจบในตัวเอง แต่ไม่เหมาะกับงานที่ต้องการหน่วยความจำร่วมกันระหว่าง request หลายครั้ง
ปัญหานี้ชัดเจนที่สุดตอนสร้างแอปแชทเรียลไทม์ เพราะระบบต้องรู้ว่าใครอยู่ในห้องไหน ข้อความล่าสุดคืออะไร และต้อง broadcast ข้อความไปยังทุกคนในห้องเดียวกันพร้อมกัน ถ้าใช้ Workers แบบ stateless ตรง ๆ ทีมต้องพึ่งฐานข้อมูลภายนอกเก็บสถานะทุกอย่าง ซึ่งเพิ่ม latency ทุกครั้งที่ต้องอ่านเขียนสถานะห้องแชท
Durable Objects เข้ามาแก้ปัญหานี้ด้วยการให้ instance เดียวที่คงอยู่ (durable) รับผิดชอบ object id หนึ่งตัวเสมอ ทำให้เก็บสถานะไว้ในหน่วยความจำของ object นั้นได้โดยตรง บทความนี้จะพาไปดูว่าแนวคิดนี้ทำงานยังไง ต่างจากการใช้ WebSocket เดิมร่วมกับฐานข้อมูลภายนอกอย่างไร และมีข้อจำกัดอะไรที่ต้องรู้ก่อนออกแบบระบบจริง
Durable Objects คืออะไร และแก้ปัญหา State ให้ Workers ยังไง
Durable Object คือคลาสที่เขียนขึ้นเพื่อรันเป็น instance เดี่ยวที่คงอยู่ตลอดตราบเท่าที่มีการเรียกใช้งาน แต่ละ Durable Object มี id เฉพาะตัวที่ทีมกำหนดเอง เช่นใช้ room id ของห้องแชทเป็น id ของ object นั้น เมื่อ Workers เรียกไปหา Durable Object ด้วย id เดิม ระบบจะการันตีว่าส่ง request ไปหา instance เดียวกันเสมอ ไม่ว่า request จะมาจากผู้ใช้ในภูมิภาคไหนก็ตาม
ความต่างสำคัญจาก Workers ทั่วไปคือ Durable Object มีหน่วยความจำในตัวที่คงอยู่ระหว่าง request หลายครั้ง ทำให้เก็บสถานะอย่างรายชื่อผู้ใช้ในห้อง ข้อความล่าสุด หรือสถานะเกมได้โดยตรงในหน่วยความจำ โดยไม่ต้องอ่านเขียนฐานข้อมูลภายนอกทุกครั้งที่มี event เกิดขึ้น ซึ่งลด latency ได้มากสำหรับงานที่ต้องการความเร็วสูง
นอกจากหน่วยความจำชั่วคราวแล้ว แต่ละ Durable Object ยังมี Storage API ในตัวสำหรับเก็บข้อมูลแบบถาวรที่รอดจากการรีสตาร์ท ทำให้สามารถออกแบบให้ object เก็บทั้งสถานะชั่วคราวในหน่วยความจำและข้อมูลถาวรในที่เดียวกันได้ ลดความซับซ้อนของการซิงก์ข้อมูลระหว่างสองระบบ
การรับประกันว่ามี Instance เดียวต่อ Object ID สำคัญยังไง
หัวใจของ Durable Objects คือการรับประกันว่า object id หนึ่งตัวจะมี instance เดียวทำงานอยู่เท่านั้นในเวลาเดียวกันทั่วทั้งระบบ ไม่ว่าจะมี request เข้ามาพร้อมกันกี่ครั้งจากผู้ใช้กี่คน ทุก request ที่ระบุ id เดียวกันจะถูกส่งไปประมวลผลที่ instance เดียวกันเสมอ ทำให้ไม่ต้องกังวลเรื่อง race condition แบบที่มักเจอในระบบ distributed ทั่วไป
การรับประกันนี้มีค่าใช้จ่ายแฝงที่ต้องเข้าใจ คือ request ทั้งหมดที่ส่งไปหา object id เดียวกันจะถูกประมวลผลแบบเรียงลำดับ (serialized) ไม่ใช่ประมวลผลพร้อมกันแบบขนาน ถ้าห้องแชทหนึ่งห้องมีผู้ใช้ส่งข้อความพร้อมกันจำนวนมาก object นั้นจะกลายเป็นคอขวดที่ต้องประมวลผลทีละ request จนกว่าจะจบ
จุดนี้เองที่ทำให้การออกแบบว่าจะแบ่ง object อย่างไรสำคัญมาก ถ้าออกแบบให้ทั้งแอปมี object เดียวรับทุกอย่าง ระบบจะไม่ scale ได้เลย แต่ถ้าแบ่ง object ตามหน่วยที่เหมาะสม เช่นหนึ่งห้องแชทต่อหนึ่ง object ระบบจะ scale ได้ตามจำนวนห้องที่เพิ่มขึ้น เพราะแต่ละ object ทำงานอิสระจากกัน
WebSocket Hibernation ลดต้นทุนการเชื่อมต่อค้างได้จริงไหม
แอปแชทเรียลไทม์ทั่วไปที่ใช้ WebSocket ต้องเปิด connection ค้างไว้ตลอดเวลาที่ผู้ใช้ออนไลน์ แม้ผู้ใช้จะไม่ได้พิมพ์ข้อความอยู่ก็ตาม ปัญหาคือ compute ที่รันอยู่เบื้องหลังการเปิด connection ค้างไว้เฉย ๆ กินทรัพยากรและมีต้นทุนแม้จะไม่มีข้อมูลส่งผ่านจริง ถ้ามีผู้ใช้ออนไลน์พร้อมกันจำนวนมากแต่ไม่ได้ active ตลอดเวลา ต้นทุนส่วนนี้จะสูงเกินความจำเป็น
Durable Objects แก้ปัญหานี้ด้วยกลไกที่เรียกว่า WebSocket Hibernation คือระบบสามารถ 'พัก' compute ของ object ไว้ชั่วคราวเมื่อไม่มีข้อความส่งผ่าน โดยที่ connection ของ WebSocket ยังคงเปิดอยู่จากมุมมองของผู้ใช้ แล้วปลุก object กลับมาทำงานอีกครั้งเมื่อมีข้อความใหม่เข้ามา ทำให้ทีมไม่ต้องจ่ายค่า compute สำหรับช่วงเวลาที่ connection เงียบ
กลไกนี้เหมาะกับแอปแชทที่ผู้ใช้ส่วนใหญ่เปิดหน้าจอทิ้งไว้แต่ไม่ได้พิมพ์ตลอดเวลา เช่นแอปแชทสนับสนุนลูกค้าที่ต้องรอการตอบกลับ หรือห้องแชทที่มีกิจกรรมเป็นช่วง ๆ ไม่ต่อเนื่อง ตัวอย่างสมมติเช่นห้องแชทที่มีผู้ใช้ออนไลน์ 500 คนพร้อมกันแต่มีข้อความจริงส่งผ่านเพียงไม่กี่สิบข้อความต่อนาที การใช้ Hibernation ช่วยลดต้นทุน compute ได้มากเมื่อเทียบกับการรัน compute เต็มรูปแบบตลอดเวลา
Storage API ในตัว Durable Object ต่างจากฐานข้อมูลภายนอกยังไง
ตารางด้านล่างเปรียบเทียบมุมมองการใช้งานระหว่าง Storage API ที่ติดมากับ Durable Object และการต่อฐานข้อมูลภายนอกอย่าง D1 หรือ Postgres แยกต่างหาก เพื่อช่วยตัดสินใจว่าข้อมูลส่วนไหนควรอยู่ตรงไหน
| ลักษณะข้อมูล | เหมาะกับ Storage API ในตัว Durable Object | เหมาะกับฐานข้อมูลภายนอก |
|---|---|---|
| สถานะที่ผูกกับ object เดียวโดยเฉพาะ เช่นรายชื่อคนในห้องแชท | ใช่ อ่านเขียนเร็วเพราะอยู่ในหน่วยความจำเดียวกัน | ทำได้แต่มี latency เพิ่มจากการเรียกข้ามระบบ |
| ข้อมูลที่ต้อง query ข้าม object หลายตัวพร้อมกัน | ไม่เหมาะ เพราะแต่ละ object เห็นเฉพาะข้อมูลของตัวเอง | เหมาะกว่า เพราะ query ข้ามหลายแถวได้ในคำสั่งเดียว |
| ข้อมูลที่ต้อง report หรือวิเคราะห์ภาพรวมทั้งระบบ | ต้องดึงจากหลาย object มารวมเองในโค้ด | เหมาะกว่า เพราะรวมข้อมูลไว้ที่เดียวอยู่แล้ว |
| ข้อมูลที่เปลี่ยนแปลงถี่มากในช่วงเวลาสั้น เช่นตำแหน่งในเกม | เหมาะมาก เพราะไม่มี network overhead ไปฐานข้อมูลภายนอก | อาจช้ากว่าถ้าต้องเขียนถี่ระดับหลายครั้งต่อวินาที |
Use Case ที่เหมาะกับ Durable Objects: ห้องแชท เกม และ Coordination
นอกจากแอปแชท Durable Objects ยังเหมาะกับงานประเภทเกมเรียลไทม์ที่ต้องมี game state กลางที่ทุกผู้เล่นในห้องเดียวกันเห็นตรงกัน เช่นตำแหน่งผู้เล่น คะแนน หรือสถานะรอบเกม การมี object เดียวรับผิดชอบห้องเกมหนึ่งห้องช่วยให้ logic การตัดสินผลแพ้ชนะทำงานถูกต้องโดยไม่ต้องกังวลเรื่อง race condition จากผู้เล่นหลายคนส่ง action พร้อมกัน
อีกกลุ่ม use case ที่เหมาะคืองาน coordination ที่ต้องการ lock หรือลำดับการทำงานที่แน่นอน เช่นระบบจองคิวที่ต้องมั่นใจว่าไม่มีสองคนจองคิวเดียวกันพร้อมกัน หรือระบบ rate limiting ที่ต้องนับจำนวน request ต่อผู้ใช้แบบแม่นยำ การใช้ Durable Object เป็นจุดศูนย์กลางของการนับหรือ lock ช่วยแก้ปัญหา concurrency ที่ยากจะทำให้ถูกต้องด้วยระบบ distributed ทั่วไป
ทีมที่กำลังออกแบบระบบ background job ควบคู่ไปด้วย ควรอ่านเรื่อง การใช้ Queues จัดการ background job และ event processing ประกอบ เพราะบางระบบเหมาะกับการผสมทั้งสองแนวทาง คือใช้ Durable Objects สำหรับ state ที่ต้อง coordination แบบเรียลไทม์ และใช้ Queues สำหรับงานที่ทำเป็น batch ทีหลังได้
ตัวอย่างโครงสร้างแอปแชทเรียลไทม์ด้วย Durable Objects
ตัวอย่างสมมติต่อไปนี้เป็นโครงสร้างระดับแนวคิดสำหรับสร้างห้องแชท ไม่ใช่โค้ดที่รันได้ตรง ๆ แต่ช่วยให้เห็นภาพลำดับการทำงานทั้งระบบ
- กำหนดให้แต่ละห้องแชทมี room id ที่ไม่ซ้ำกัน แล้วใช้ room id นั้นเป็น key ในการหา Durable Object ที่รับผิดชอบห้องนั้นโดยเฉพาะ
- เมื่อผู้ใช้เปิด WebSocket connection เข้ามาที่ Worker ให้ส่งต่อ (proxy) connection ไปยัง Durable Object ของห้องนั้น แล้วให้ object เก็บ reference ของ connection ไว้ในหน่วยความจำ
- เมื่อมีข้อความใหม่ส่งเข้ามาจากผู้ใช้คนใดคนหนึ่ง ให้ object นั้น broadcast ข้อความไปยังทุก connection ที่เก็บไว้ในห้องเดียวกัน โดยไม่ต้องผ่านฐานข้อมูลภายนอกเพื่อความเร็ว
- บันทึกประวัติข้อความล่าสุดไว้ใน Storage API ของ Durable Object เพื่อให้ผู้ใช้ที่เพิ่งเข้าห้องเห็นข้อความย้อนหลังได้ทันทีโดยไม่ต้องรอโหลดจากฐานข้อมูลภายนอก
- เปิดใช้ WebSocket Hibernation เพื่อให้ object พักการทำงานเมื่อห้องเงียบ แล้วปลุกกลับมาทำงานอัตโนมัติเมื่อมีข้อความใหม่ ลดต้นทุน compute ในช่วงที่ไม่มีกิจกรรม
ข้อจำกัดเรื่อง Scale เมื่อ Object เดียวรับโหลดหนักเกินไป
เพราะ request ที่ส่งไปหา object id เดียวกันถูกประมวลผลแบบเรียงลำดับ ถ้าออกแบบระบบให้ห้องแชทหนึ่งห้องรองรับผู้ใช้ได้ไม่จำกัด และมีห้องหนึ่งที่ดังเป็นพิเศษจนมีคนเข้ามาพร้อมกันหลักหมื่นคน object เดียวนั้นจะกลายเป็นคอขวดที่ทำให้ทั้งห้องช้าลงพร้อมกัน แม้ห้องอื่นในระบบจะทำงานปกติดีก็ตาม
วิธีรับมือที่ทีมมักใช้คือกำหนดเพดานจำนวนผู้ใช้ต่อห้องตั้งแต่การออกแบบ เช่นถ้าห้องหนึ่งเต็มแล้วให้สร้างห้องใหม่ (shard) แทนที่จะยัดทุกคนไว้ใน object เดียว วิธีนี้คล้ายกับการแบ่ง database เป็นหลาย shard ตามที่เคยพูดถึงใน D1 คือแทนที่จะพยายามทำให้หน่วยเดียวรับทุกอย่างได้ ให้ออกแบบให้แบ่งเป็นหลายหน่วยที่ทำงานอิสระจากกันตั้งแต่ต้น
อีกแนวทางคือแยกความรับผิดชอบระหว่าง object ที่ทำ coordination กับ object ที่เก็บข้อมูลจำนวนมาก เช่นให้ object หลักรับผิดชอบแค่การ broadcast และจัดการ connection ส่วนข้อมูลที่ต้องเก็บระยะยาวและ query ย้อนหลังบ่อย ให้ส่งออกไปเก็บใน D1 หรือฐานข้อมูลภายนอกแทน ไม่ยัดทุกอย่างไว้ใน object เดียว
ทำแบบนี้แล้วพัง เพราะออกแบบ Object ID ผิดตั้งแต่ต้น
ปัญหาที่พบบ่อยที่สุดคือทีมออกแบบ object id แบบหยาบเกินไป เช่นใช้ object เดียวรับผิดชอบทั้งแอปทั้งหมดโดยไม่แบ่งตามห้องหรือหน่วยย่อยใด ๆ ทำให้ทุก request ในระบบต้องรอคิวผ่าน object เดียว ระบบจะช้าลงทันทีเมื่อมีผู้ใช้เพิ่มขึ้น แม้จะยังไม่ถึงขีดจำกัดของ Cloudflare เองก็ตาม เพราะคอขวดเกิดจากการออกแบบ ไม่ใช่ข้อจำกัดของแพลตฟอร์ม
อีกอาการที่พบคือทีมลืมว่า object แต่ละตัวเห็นเฉพาะข้อมูลของตัวเองเท่านั้น เมื่อต้องการ feature อย่างการค้นหาข้ามห้อง หรือรายงานสรุปทั้งระบบ กลับพบว่าไม่มีทางดึงข้อมูลจากหลาย object พร้อมกันได้ง่าย ๆ ต้องเขียนโค้ดวนเรียกทีละ object ซึ่งช้าและซับซ้อน ทางแก้คือตั้งแต่ต้นควรส่งข้อมูลสรุปที่จำเป็นออกไปเก็บในฐานข้อมูลกลางคู่ขนานไปด้วย ไม่ใช่พึ่ง Durable Object เป็นแหล่งข้อมูลเดียวสำหรับทุกอย่าง
อาการที่สามคือทีมไม่ได้ทดสอบพฤติกรรมตอน object ถูกปลุกกลับมาหลัง Hibernation แล้วสมมติว่าหน่วยความจำเดิมยังอยู่ครบเหมือนก่อนพัก ในความเป็นจริงต้องออกแบบให้ object โหลดสถานะที่จำเป็นกลับมาจาก Storage API ทุกครั้งที่ตื่นขึ้นใหม่ ไม่ใช่พึ่งพาหน่วยความจำชั่วคราวเพียงอย่างเดียวสำหรับข้อมูลที่ต้องคงอยู่ข้ามรอบการพักตัว
สรุป
Durable Objects แก้ปัญหาพื้นฐานที่ Workers แบบ stateless ทำไม่ได้ คือการมีหน่วยความจำที่คงอยู่และรับประกันว่า id เดียวกันจะมี instance เดียวเสมอ ทำให้เหมาะกับงานที่ต้องการ coordination แบบเรียลไทม์อย่างห้องแชท เกม หรือระบบจองคิว โดยไม่ต้องพึ่งฐานข้อมูลภายนอกสำหรับทุก event
จุดที่ต้องระวังคือการออกแบบ object id ตั้งแต่ต้น เพราะ request ที่ส่งไปหา id เดียวกันถูกประมวลผลแบบเรียงลำดับ ถ้าออกแบบให้หน่วยเดียวรับทุกอย่างจะกลายเป็นคอขวดทันทีเมื่อโหลดสูงขึ้น การแบ่งเป็นหน่วยย่อยที่เหมาะสมตั้งแต่แรกจึงสำคัญกว่าการพยายามแก้ปัญหาทีหลัง
- Durable Object รับประกันว่า id เดียวกันมี instance เดียวเสมอ เก็บสถานะในหน่วยความจำได้โดยตรง
- WebSocket Hibernation ช่วยลดต้นทุน compute ในช่วงที่ connection เงียบไม่มีข้อความส่งผ่าน
- Storage API ในตัวเหมาะกับข้อมูลที่ผูกกับ object เดียว ไม่เหมาะกับ query ข้าม object หลายตัว
- request ไปหา object id เดียวกันถูกประมวลผลแบบเรียงลำดับ ต้องออกแบบการแบ่ง shard ตั้งแต่ต้น
- ควรโหลดสถานะสำคัญกลับจาก Storage API ทุกครั้งที่ object ตื่นจาก Hibernation ไม่พึ่งหน่วยความจำชั่วคราวอย่างเดียว
คำถามที่พบบ่อย
Durable Objects ต่างจาก Workers ทั่วไปตรงไหนชัดที่สุด
Workers ทั่วไปไม่มีสถานะและอาจถูกรันโดย instance คนละตัวในแต่ละ request ส่วน Durable Object รับประกันว่า id เดียวกันจะมี instance เดียวเสมอ ทำให้เก็บสถานะในหน่วยความจำระหว่าง request หลายครั้งได้โดยตรง
WebSocket Hibernation ทำให้ผู้ใช้รู้สึกว่า connection หลุดไหม
โดยหลักการไม่ควรรู้สึก เพราะจากมุมมองผู้ใช้ connection ยังคงเปิดอยู่ตลอด สิ่งที่ถูกพักคือ compute เบื้องหลังในช่วงที่ไม่มีข้อความส่งผ่าน แล้วจะถูกปลุกกลับมาทำงานทันทีเมื่อมีข้อความใหม่เข้ามา
ควรเก็บข้อมูลอะไรใน Storage API ของ Durable Object กับข้อมูลอะไรที่ควรอยู่ฐานข้อมูลภายนอก
ข้อมูลที่ผูกกับ object เดียวโดยเฉพาะและต้องอ่านเขียนเร็ว เช่นสถานะห้องแชทปัจจุบัน เหมาะกับ Storage API ในตัว ส่วนข้อมูลที่ต้อง query ข้ามหลาย object หรือใช้ทำรายงานภาพรวม ควรส่งไปเก็บในฐานข้อมูลภายนอกอย่าง D1 หรือ Postgres
ห้องแชทที่มีคนเข้าพร้อมกันหลักหมื่นคนใช้ Durable Object เดียวได้ไหม
ไม่แนะนำ เพราะ request ทั้งหมดที่ส่งไปหา object เดียวกันถูกประมวลผลแบบเรียงลำดับ ควรกำหนดเพดานจำนวนผู้ใช้ต่อห้องแล้วสร้างห้องใหม่ (shard) เมื่อเต็ม แทนที่จะยัดทุกคนไว้ใน object เดียว
ข้อมูลใน Durable Object หายไปไหมถ้า object ถูกพักตัวหรือรีสตาร์ท
ข้อมูลที่เก็บผ่าน Storage API จะยังอยู่ครบเพราะเป็นการเก็บแบบถาวร แต่ข้อมูลที่อยู่ในหน่วยความจำชั่วคราวเฉย ๆ อาจหายไปเมื่อ object รีสตาร์ท จึงควรออกแบบให้โหลดสถานะสำคัญกลับมาจาก Storage API ทุกครั้งที่ object เริ่มทำงานใหม่
เหมาะกับแอปขนาดเล็กที่ยังไม่มีผู้ใช้เยอะไหม
ใช้ได้ตั้งแต่ขนาดเล็ก เพราะไม่ต้องดูแล infrastructure เพิ่มเติมเหมือนการตั้งเซิร์ฟเวอร์ WebSocket เอง แต่ควรออกแบบโครงสร้าง object id ให้รองรับการแบ่งเป็นหน่วยย่อยตั้งแต่ต้น เพื่อไม่ต้องรื้อสถาปัตยกรรมใหม่เมื่อผู้ใช้เพิ่มขึ้นในอนาคต
อ่านต่อแบบเจาะลึก
วัด Conversion LINE ทุกแบรนด์ รวมศูนย์ที่เดียว
องค์กรที่มีหลายแบรนด์ หลายสาขา หลาย LINE OA — linli รวม Tracking และส่ง Conversion กลับทุกแพลตฟอร์ม พร้อม Custom Limit เริ่มฟรี 14 วัน
ติดต่อทีม Salesบทความที่เกี่ยวข้อง

ทำไม Background Job ที่เคยรันบน Cron ถึงเริ่มพังตอนโหลดสูง

R2 เก็บไฟล์แบบไม่มีค่า Egress แต่ยังมีเงื่อนไขที่ต้องรู้ก่อนย้ายจาก S3
