ส่งข้อความให้เรียงลำดับ ในโลกที่ไม่มีอะไรเรียงลำดับ
10 พฤศจิกายน 2569 · ThaiPo
มีคนพิมพ์ย่อหน้ายาวๆ อย่างตั้งใจ แล้วอีกวินาทีถัดมาก็พิมพ์ว่า "ok?" ตัวแปลที่โพสต์คำแปลของ "ok?" ขึ้นก่อน ไม่ได้แปลบทสนทนา มันจัดเรียงบทสนทนาใหม่
ThaiPo แปลทุกข้อความในแชทแบบ concurrent และคำตอบสองคำกลับมาจาก language model เร็วกว่าย่อหน้าที่ส่งมาก่อนหน้ามันนานมาก LINE ไม่มีทางให้เรากำหนด timestamp ของข้อความที่เราโพสต์ ลำดับที่คนอ่านจึงเท่ากับลำดับที่เรา push เท่านั้นเอง และการทำเรื่องนี้ให้ถูก ต้องวางอยู่บน stack ที่ไม่มีอะไรเรียงลำดับเลยสักชั้น

ทุกอย่างข้างใต้ไม่มีลำดับ
- Webhook มาถึงสลับลำดับกันได้ และเมื่อ acknowledgement ช้า LINE จะส่งซ้ำ ซึ่งจากประสบการณ์ของเราคือหลายนาทีให้หลัง
- Log source of truth ของเราคือ object store โดย key ขึ้นต้นด้วยเวลาจากเครื่อง ingest ตัวที่รับ event นั้น ตามนาฬิกาของเครื่องเอง สองเครื่องจึงเห็นไม่ตรงกันได้ว่าอะไรมาก่อน ไม่มี total order ให้ไปถาม
- ตัวงานการแปลรันใน worker pool กับ model ที่ latency ต่างกันมาก ลำดับที่เสร็จจึงประมาณว่า "สั้นสุดเสร็จก่อน"
- Worker มัน crash โดน redeploy และ timeout ได้กลางข้อความ
รับบัตรคิวที่ประตูเข้า ดู frontier ที่ประตูออก
กลไกมีขนาดเล็ก เมื่อ event ถูก enqueue มันจะได้เลขถัดไปจาก counter ของแชทนั้น มีตัว enqueue อยู่สองตัว (fast path ที่ตรงมาจาก ingest และ dispatcher ที่กวาด log เป็นตาข่ายนิรภัย) เลขจึงถูกแจกภายใต้ guard แบบ enqueue-once ตัวเดียวกับที่กันไม่ให้ทั้งสองตัว queue ซ้ำ: ใครชนะ guard คนนั้นเป็นคนออกเลข ครั้งเดียวพอดี
จากนั้นการแปลก็รันแบบ parallel เต็มที่เหมือนเดิม มีแค่การ push ขั้นสุดท้ายที่ต้องผ่าน gate แต่ละแชทมี frontier: เลขสูงสุดที่ตัวมันเองและทุกเลขก่อนหน้าเสร็จหมดแล้ว worker ที่ถือข้อความที่ 7 จะรอจน frontier ไปถึง 6 แล้วจึงส่ง
เมื่อ job ไปถึง terminal state ใดก็ตาม มันจะมาร์กเลขของตัวเองว่าเสร็จ ถ้าข้อความที่ 8 เสร็จในขณะที่ 7 ยังคิดอยู่ 8 จะไปจอดรอใน set เล็กๆ ของพวก "เสร็จก่อนคิว" พอ 7 เสร็จ frontier จะขยับข้ามทุกเลขที่เสร็จแล้วและต่อเนื่องกันใน atomic script ครั้งเดียว แล้ว 8 ก็ถูกปล่อย ย่อหน้ายาวลงก่อน แล้วค่อยตามด้วย "ok?"
มีรายละเอียดสามข้อที่แบกน้ำหนักส่วนใหญ่ไว้:
- เสร็จ หมายถึงจบแบบไหนก็ได้ ตอบแล้ว ข้ามไป (ข้อความที่มีแต่ emoji ไม่ทำให้เกิด bubble) หรือ fail: ทั้งหมดขยับ frontier การปิดงานนี้รันแม้ตอนที่ worker กำลัง shutdown บน context ที่ไม่สนใจ cancellation เพราะการปิดงานที่ถูกกลืนหายจะทำให้ข้อความถัดไปนั่งรอสิ่งที่ไม่มีวันมา
- gate นี้ fail open ถ้าอ่าน state ของการเรียงลำดับไม่ได้ ข้อความจะออกไปทันที กรณีแย่ที่สุดคือพฤติกรรมแบบเดิม ไม่มีวันเป็นข้อความที่หายหรือค้าง
- มันแยกรายแชท และมันลืม state ของแชทหมดอายุหลังเงียบไปสิบนาที แล้วเริ่มนับจากศูนย์ใหม่ ไม่มีอะไรผูกลำดับของแชทหนึ่งกับของอีกแชท
ทุกการรอต้องมี timeout
gate ที่รอตัวก่อนหน้า คือ gate ที่รอไปตลอดกาลได้ ถ้าข้อความที่ 7 ค้างอยู่กับ timeout ของ model ข้อความที่ 8 ถึง 40 ต้องไม่ไปนั่งต่อคิวอยู่ข้างหลังมัน
การรอจึงมีขอบเขต: ค่าเริ่มต้นคือสี่วินาที เมื่อหมดเวลา worker ที่รออยู่จะดัน frontier ข้ามช่องว่างนั้นแล้วส่งเลย แชทไหลต่อ ข้อความที่ถูกทิ้งไว้ไม่ได้ถูกยกเลิก ถ้ามันเสร็จจริง มันจะเห็นว่า frontier ผ่านตัวเองไปแล้ว และโพสต์ทันที ช้าและผิดลำดับ นี่คือการจัดอันดับที่ตั้งใจ: ลำดับที่ถูกต้องดีกว่าข้อความที่ช้าไปนิด และข้อความที่ช้าดีกว่าแชทที่ค้าง
ต้นทุนมีอยู่จริง worker ที่รอถือ slot ใน pool ไว้หนึ่งช่อง timeout คือสิ่งที่ทำให้ต้นทุนนั้นเป็นแค่ไม่กี่วินาทีของ slot เดียว แทนที่จะเป็น outage
bug ที่ gate บังคับใช้อย่างซื่อสัตย์
บางครั้ง LINE มัด event หลายตัวมาใน webhook delivery เดียว ประมาณ 1% ของ delivery ของเรามีมากกว่าหนึ่ง event และตอนที่เราวัด batch แบบนี้ทุกอันเป็นข้อความจากแชทเดียวกัน ingest เก็บ event ของ delivery หนึ่งแบบ concurrent และเลขลำดับถูกออกให้ตอนเก็บเสร็จ ดังนั้นภายใน batch เลขจึงตกเป็นของ storage write ตัวที่กลับมาก่อน
แล้ว gate ก็ทำหน้าที่ของมันอย่างไร้ที่ติ ส่งคำแปลสองอันในลำดับที่ผิดเป๊ะ ระบบเรียงลำดับดีได้เท่ากับจังหวะที่คุณหยิบบัตรคิวเท่านั้น ตอนนี้ ingest fan out ตามแชท ไม่ใช่ตาม event: แชทต่างกันยังเก็บแบบ parallel ส่วน event ของแชทเดียวกันใน batch เข้าตามลำดับที่ LINE เรียงมาให้
งานที่มาช้า: ตัดสินใจก่อนสร้าง retry
retry, replay และการกวาดเก็บตกมีคุณสมบัติร่วมกันอยู่ข้อหนึ่ง: มันส่งงานเก่าในเวลาใหม่ เดิมทีเราถือว่าคำแปลที่มาช้าก็ยังเป็นคำแปลที่มีคนต้องการ
แล้วก็มี voice note ที่ประมวลผลรอบแรกล้มเหลว ถูกปล่อยออกจาก dead-letter queue ในสามวันต่อมา pipeline ทำสิ่งที่มันถูกสร้างมาให้ทำ และโพสต์ transcript ลงในกลุ่มที่ยังคุยกันอยู่ ซึ่งคุยเลยเรื่องนั้นไปนานแล้ว มองจากข้างในแชท มันเหมือนข้อความที่โผล่มาจากไหนไม่รู้ identifier ทุกตัวถูกต้อง จังหวะเวลาต่างหากที่ทำให้มันผิด
ตอนนี้จึงมี staleness gate ข้อความที่เวลาส่งผ่านมานานเกินขีดจำกัด (ค่าเริ่มต้นคือหนึ่งชั่วโมง) จะถูกปิดอย่างเรียบร้อยโดยไม่ถูกแปล และไม่โพสต์อะไรเลย อายุวัดจาก timestamp ตอนส่งของแพลตฟอร์มเอง ซึ่งไม่มีวันเปลี่ยน ไม่ใช่จากตอนที่เรา queue job ซึ่งทุก replay จะรีเซ็ตมัน คำสั่งได้ขีดจำกัดที่เข้มกว่าคือสิบนาที และเพราะการทิ้งข้อความเป็นสิ่งที่ผู้ส่งมองไม่เห็น มันจึงต้องไม่มีวันล่องหนสำหรับเรา: การทิ้งถูกนับ และถ้ามาเป็นชุดก็ page คน
ผลพวงหนึ่งข้อที่ต้องพูดตรงๆ: การ requeue รายการเก่าใน dead-letter ตอนนี้แทบเท่ากับการทิ้งมัน และ admin console ก็บอกแบบนั้น แทนที่จะรายงานว่าสำเร็จ
จอดแล้วต้องแปลว่าจอด
บทเรียนสุดท้ายมาในรูปเครื่องเคาะจังหวะ มี event ตัวหนึ่งถูกแปลซ้ำประมาณทุกสิบห้านาทียี่สิบวินาที ต่อเนื่องเป็นชั่วโมงๆ หลังจากที่มันถูกจอดไว้ใน dead-letter queue แล้ว
จังหวะนั้นคือเบาะแส dispatcher list log ใหม่จาก cursor ของมัน ลบด้วย overlap สองนาที เพื่อรองรับ clock skew ระหว่างเครื่อง enqueue guard กันไม่ให้มัน queue event เดิมซ้ำเป็นเวลาสิบห้านาที และสิ่งที่ยุติการอ่านซ้ำอย่างถาวรคือ marker เล็กๆ ว่า "processed" ที่ถูกเขียนเมื่องานเสร็จ การจอดเขียน dead-letter record แต่ไม่ได้เขียน marker ตัวนั้น cursor บังเอิญปักค้างอยู่ที่ entry ของ event นั้นเองพอดี ทุกครั้งที่ guard หมดอายุ dispatcher จึงเจองานที่ยังไม่เสร็จ แล้ว queue มันอีกรอบ เบรกกลายเป็นนาฬิกาไปเสียเอง
staleness gate ควรจะจบเรื่องนี้ได้ แต่ event พวกนั้นไม่มี timestamp ตอนส่ง และ gate จะ fail open เมื่อไม่มีมัน แถมแต่ละรอบยัง timeout ก่อนที่ model จะรายงาน usage ใดๆ ค่าใช้จ่ายจึงไม่เคยโผล่ในบันทึกต้นทุนรายแชทของเรา
วิธีแก้คือ write เดียว: ตอนนี้การจอดทิ้ง terminal marker แบบเดียวกับตอนสำเร็จ โดยติด tag ไว้ให้การ requeue แบบเจาะจงของ operator แยกสองกรณีนี้ออกจากกันได้
สรุป
- ลำดับเป็นคุณสมบัติของบทสนทนา ไม่ใช่ของระบบ global order จะแพงกว่านี้มาก และไม่ได้ซื้ออะไรที่ใครมองเห็นได้
- ตัดสินใจว่าจะทำอย่างไรกับงานที่มาช้า ก่อนสร้าง retry ไม่อย่างนั้น retry จะตัดสินใจแทนคุณ ในสามวันต่อมา ต่อหน้าผู้ใช้
- ทุกการรอต้องมี timeout และทุก timeout ต้องประกาศไว้ก่อนว่าใครชนะ
- terminal state ต้องมี flag เดียวที่ผู้อ่านทุกตัวเช็ก มีสอง flag ก็คือ loop ที่กำลังรอ cursor มาเจอ
- queue ทิ้งได้ แต่ log ทิ้งไม่ได้ state ของการเรียงลำดับทั้งหมดหายไปได้ และผลแย่ที่สุดคือข้อความสลับลำดับกันไม่กี่ข้อความ นั่นคือเหตุผลที่มันสร้างได้ในราคาถูก
ThaiPo ออกแบบ สร้าง และดูแลโดยนักพัฒนาคนเดียว Steven Acreman
ลองใช้ในแชทของคุณเอง
ThaiPo อยู่ใน LINE และแปลทุกข้อความให้สองทาง ฟรีตลอดไป เริ่มใช้ฟรี หรือ เพิ่มเพื่อน @thaipo.ai ใน LINE แล้วบอทจะส่งลิงก์สมัครให้ในแชทเลย