แชทเดียวที่หนักอาจทำให้ API ของเราล่มได้อย่างไร
วันที่ 28 กันยายน ประมาณ 17 นาทีหลังการปล่อยเวอร์ชัน เซิร์ฟเวอร์ API ในโปรดักชันทั้งสองตัวหน่วยความจำหมดในเวลาเดียวกัน รีสตาร์ท และเป็นเวลาราว 5 นาทีที่ load balancer ไม่มีเซิร์ฟเวอร์ที่ปกติให้ส่งทราฟฟิกไปหา การแก้ไขแรกของเราให้ API มีหน่วยความจำเป็นสองเท่า สาเหตุจริงปรากฏในภายหลังของวันนั้น: การเปิดแชทหนึ่งโดยเฉพาะทำให้ API โหลดข้อความ 53 MB ในคำขอเดียว ตอนนี้ประวัติแชทโหลดเป็นหน้า จำกัดทั้งตามขนาดและจำนวน และแชทนั้นเปิดด้วยหน้าแรก 5 MB แทนการล่ม
อะไรล่มบ้าง
API ของเรารันเป็นคอนเทนเนอร์บนเซิร์ฟเวอร์สองตัวหลัง load balancer คอนเทนเนอร์แต่ละตัวมีขีดจำกัดหน่วยความจำ 384 MiB ขณะไม่มีงาน มันใช้ 130–140 MiB ดังนั้นขีดจำกัดดูเหมือนมีพื้นที่เหลือมาก
คอนเทนเนอร์ทั้งสองถึงขีดจำกัดพร้อมกันและล่มด้วยข้อความ “JavaScript heap out of memory” heap คือหน่วยความจำที่โปรเซส Node.js ใช้สำหรับอ็อบเจกต์ที่มันทำงานอยู่ เพราะเซิร์ฟเวอร์ทั้งสองล้มพร้อมกัน จึงไม่มีที่เหลือให้ส่งคำขอไป นั่นคือการล่มที่เลวร้ายที่สุดสำหรับระบบที่มีสองเซิร์ฟเวอร์: การสำรองช่วยได้ก็ต่อเมื่อความล้มเหลวไม่เกิดขึ้นพร้อมกัน
การแก้ไขแรก: เพิ่มพื้นที่
เวลาชี้ไปที่การปล่อยเวอร์ชัน หลังการปล่อย รอบของเอเจนต์ที่ถูกการปล่อยขัดจังหวะจะถูกกลับมาทำต่อและบันทึกบทถอดความของมันอีกครั้ง และงานที่หนาแน่นนั้นดูเหมือนแหล่งที่ชัดเจนของการพุ่งของหน่วยความจำ
ดังนั้นการเปลี่ยนแปลงแรกจึงเพิ่มขีดจำกัดของ API จาก 384 MiB เป็น 768 MiB เราใช้มันแบบสด ๆ บนเซิร์ฟเวอร์ทั้งสองในวันเดียวกัน และ commit ลงในการตั้งค่าการดีพลอยเพื่อให้การดีพลอยครั้งถัดไปไม่ย้อนกลับไปเป็น 384 อย่างเงียบ ๆ การแก้ที่ทำด้วยมือบนเซิร์ฟเวอร์ที่การดีพลอยครั้งถัดไปย้อนกลับ เป็นวิธีทั่วไปที่ทำให้ล่มแบบเดิมกลับมา
หน่วยความจำที่มากขึ้นเป็นเผื่อที่สมเหตุสมผล แต่มันไม่ใช่คำอธิบาย
สาเหตุจริง: หนึ่งคำขอ 71 MB
ต่อมาในวันนั้นเราสร้างการล่มซ้ำได้บนสำเนาของแชทหนึ่ง โดยใช้ขีดจำกัดหน่วยความจำเก่าของโปรดักชัน สำเนานั้นทำให้ API ล่มด้วยตัวเอง โดยไม่มีการดีพลอยเกี่ยวข้อง
เมื่อคุณเปิดโปรเจกต์ เว็บแอปจะขอประวัติของแชทจาก API endpoint ที่เคยคืน 30 ข้อความล่าสุดแบบเต็ม สำหรับแชทส่วนใหญ่ 30 ข้อความถือว่าเล็ก แต่สำหรับแชทนี้ 30 ข้อความล่าสุดมีขนาด 53 MB ในฐานข้อมูล และ response 71 MB คำตอบบางส่วนของเอเจนต์มีขนาด 4–10 MB ต่อรายการ ส่วนใหญ่เป็นบทถอดความของเอเจนต์ย่อย ซึ่งเป็นผู้ช่วยที่เอเจนต์หลักเริ่มขึ้นเพื่อทำส่วนหนึ่งของงานแบบขนานกัน งานทั้งหมดของมันถูกบันทึกไว้พร้อมกับคำตอบ
การสร้าง response นั้นใช้ heap มากกว่าที่ API มี และทุกครั้งที่เปิดหรือรีเฟรชโปรเจกต์ คำขอจะถูกส่งอีกครั้ง แชทเดียวจึงทำให้เซิร์ฟเวอร์ตัวใดก็ตามที่มันไปถึงล่มได้ และเพราะหน้าเว็บรีโหลดได้ มันจึงไปถึงทั้งสองตัวได้
“30 ข้อความ” ไม่เคยเป็นขีดจำกัดขนาดจริงเลย มันเป็นขีดจำกัดจำนวนที่เล็กโดยบังเอิญสำหรับแชทปกติ
การแก้ไข: หน้าที่จำกัดตามขนาดด้วย ไม่ใช่แค่จำนวน
หน้าของประวัติตอนนี้มี 30 ข้อความ หรือประมาณ 4 MB ของข้อความเหล่านั้น แล้วแต่อย่างใดจะถึงก่อน
มันทำงานเป็นสองขั้นตอน:
- ขอขนาดก่อน API ขอให้ฐานข้อมูลส่ง ID และขนาดที่เก็บไว้ของข้อความล่าสุด โดยไม่มีเนื้อหา การคิวรีนั้นถูกมากไม่ว่าข้อความจะใหญ่แค่ไหน
- โหลดเฉพาะที่พอดี เดินจากใหม่ไปเก่า เพิ่มข้อความจนกระทั่งข้อความถัดไปจะทำให้หน้าเกิน 4 MB จากนั้นจึงโหลดเฉพาะแถวเหล่านั้นทั้งหมด
กฎสองข้อทำให้มันปลอดภัย:
- ข้อความล่าสุดจะอยู่ในหน้าเสมอ ไม่ว่าจะมีขนาดเท่าไร หน้าที่ไม่ส่งอะไรกลับมาจะทำให้แชทค้าง โดยไม่มีทางเลื่อนกลับ
- ไม่มีการตัดออก ไม่มีข้อความถูกตัดทอนหรือทิ้ง ส่วนที่เหลือของแชทโหลดผ่านตัวควบคุม “เลื่อนขึ้นเพื่อดูข้อความก่อนหน้า” ที่ข้อความเก่ากว่าใช้อยู่แล้ว ทีละหน้า
เราพบอีกจุดที่มีรูปแบบเดียวกัน เมื่อคุณหยุดรอบ API จะตรวจข้อความตอบกลับไม่กี่รายการล่าสุดว่าว่างเปล่าและควรถูกล้างออกหรือไม่ มันทำแบบนั้นโดยโหลดข้อความแปดรายการเต็ม ๆ ในแชทที่มีคำตอบหลาย MB นั่นเป็นปัญหาเดียวกันในรูปแบบที่เล็กกว่า ตอนนี้มันถามฐานข้อมูลเป็นคำถามใช่หรือไม่ — ข้อความนี้มีเนื้อหาไหม — โดยไม่โหลดตัวเนื้อหาเอง
วิธีที่เราตรวจสอบ
บนสำเนาของแชท ที่รันด้วยขีดจำกัด 384 MiB เดิม:
- หน้าแรกมีขนาด 5 MB ก่อนหน้านี้คำขอเดียวกันทำให้ API ล่ม
- เมื่อเปิดแชทพร้อมกันห้าแท็บ หน่วยความจำสูงสุดอยู่ที่ 154 MB
- การเลื่อนย้อนกลับไปทั้งแชทคืนข้อความ 190 ข้อความ ครบทุกรายการ แต่ละรายการหนึ่งครั้ง เรียงตามลำดับ
ตรรกะการเลือกหน้ายังมี unit test รวมถึงการทดสอบที่สร้างจากขนาดจริงของข้อความล่าสุดของแชทนั้น หน้าแรกคืนหนึ่งข้อความ บอกว่ามีมากกว่านั้น และหน้าถัดไปเริ่มที่ข้อความถัดจากนั้น
สิ่งที่เราได้เรียนรู้
- จำนวนไม่ใช่ขีดจำกัดขนาด endpoint ใดที่คืน “N รายการล่าสุด” ของสิ่งที่โตได้ไม่จำกัด ต้องมีงบไบต์ด้วย ข้อมูลจะไปเจอช่องว่างนั้นในที่สุด
- สาเหตุที่น่าสงสัยที่สุดไม่ใช่เสมอไป การดีพลอยเป็นตัวกระตุ้นของงานพิเศษจริง และหน่วยความจำที่มากขึ้นก็มีค่า แต่การล่มทำซ้ำได้โดยไม่มีการดีพลอย และหากไม่ทำซ้ำ เราคงหยุดแค่การเพิ่มหน่วยความจำ
- เซิร์ฟเวอร์สองตัวล้มพร้อมกันเมื่อได้คำขอเดียวกัน การสำรองป้องกันเซิร์ฟเวอร์ตัวเดียวตาย แต่ไม่ป้องกันคำขอที่ฆ่าเซิร์ฟเวอร์ทุกตัวที่มันไปถึง โดยเฉพาะคำขอที่ผู้ใช้จะลองใหม่ตามธรรมชาติด้วยการรีเฟรช
- แก้ในการตั้งค่า ไม่ใช่แค่บนเครื่อง การเพิ่มหน่วยความจำถูกใส่ลงในการตั้งค่าการดีพลอยในวันเดียวกัน ดังนั้นการดีพลอยครั้งถัดไปจึงคงไว้
นี่เป็นเรื่องความจำเรื่องที่สองล่าสุดที่เกี่ยวข้องกับผลลัพธ์ของเอเจนต์ เรื่องแรกคือป้ายสถานะที่จัดคิวสตริง 690 MB หากคุณใช้เอเจนต์ย่อยบน Mythex คุณอ่านได้ว่ามันทำงานอย่างไรในเอกสาร