E-Commerce

บันทึกวิศวกรรม: ดูแลแพลตฟอร์มอีคอมเมิร์ซทั้ง Backend เว็บ และแอปมือถือ

บันทึกจากงานปรับปรุงระบบอีคอมเมิร์ซที่ใช้งานจริง — Backend ที่พัฒนาด้วย Django, หน้าร้านเว็บ และแอป iOS/Android ที่สร้างด้วย Capacitor ทั้งหมดให้บริการลูกค้าจริงอยู่ตลอดเวลาที่ทำงาน

สองคำสั่งซื้อ สินค้าตัวเดียวกัน ที่อยู่จัดส่งเดียวกัน สั่งห่างกันแค่หนึ่งนาที ออร์เดอร์หนึ่งมาจากเว็บไซต์ อีกออร์เดอร์มาจากแอปมือถือ

เว็บไซต์จองขนส่งเอกชนในราคา ฿37 ส่วนแอปกลับเลือกจัดส่งแบบ in-house ในราคา ฿25 — ทั้งที่ที่อยู่นั้นทีมขนส่งภายในไม่มีทางไปส่งถึง ระบบไม่ error ไม่มี log แจ้งเตือนใด ๆ ลูกค้าสองคนได้ผลลัพธ์ที่ต่างกันอย่างเงียบ ๆ และเหตุผลเดียวที่จับได้คือทีมมีนิสัยตรวจสอบออร์เดอร์จริงเทียบกันเอง แทนที่จะเชื่อว่า "มันทำงานได้" เพียงเพราะทดสอบแค่แพลตฟอร์มเดียว

นี่คือรูปแบบความผิดพลาดที่นิยามอีคอมเมิร์ซแบบหลายแพลตฟอร์มได้ดีที่สุด ไม่ใช่บั๊กที่ทำให้ระบบล่ม แต่เป็นบั๊กที่ทำให้ระบบ "เห็นไม่ตรงกัน" อย่างเงียบ ๆ ต่างหาก — Backend ที่คำนวณราคา ส่วนลด และค่าจัดส่ง หน้าร้านเว็บที่ต้องแสดงผลลัพธ์เหล่านั้นให้ถูกต้อง แอปมือถือที่ต้องได้คำตอบ เดียวกัน โดยอิสระ บนคนละเฟรมเวิร์ก ภายใต้กฎเกณฑ์แพลตฟอร์มที่ต่างกัน แถมยังมีสองแอปสโตร์ที่พร้อมปฏิเสธการอัปโหลดแอปเพียงเพราะไฟล์ metadata หายไปไฟล์เดียว บทความนี้คือบันทึกการไล่ตรวจทั้งสามชั้นของระบบในรอบล่าสุด เพื่อตามหาความไม่ตรงกันเหล่านี้บนแพลตฟอร์มที่ใช้งานจริง งานแบบนี้คืองานที่เราทำที่ Simplico ให้กับลูกค้าอีคอมเมิร์ซ — ไม่ใช่ demo แต่เป็นระบบ production ที่มีลูกค้าสั่งซื้อจริงตลอดเวลาที่เราทำงาน

Backend: จุดที่ความถูกต้องเกิดขึ้นจริง

Backend คือจุดที่ตัดสินค่าจัดส่ง ผู้ให้บริการขนส่ง และส่วนลดของแต่ละออร์เดอร์ — และคำตอบที่ผิดตรงนี้คือบั๊กที่แพงที่สุด เพราะเป็นบั๊กที่ไปโผล่ที่ยอดเงินของลูกค้าโดยตรง

ระบบคำนวณค่าส่งล้มเหลวแบบเงียบ ๆ กับสินค้าส่วนใหญ่ในแคตตาล็อก แพลตฟอร์มคำนวณราคาขนส่งด้วยการจำลองการแพ็กสินค้าในตะกร้าลงกล่องมาตรฐานแบบ 3 มิติ (3D bin-packing) ก่อนจะยิงไปขอราคาจาก API ของผู้ให้บริการขนส่ง — จนกว่าจะเจอสินค้าที่ไม่มีข้อมูลความกว้าง สูง หรือยาวในแคตตาล็อก ซึ่งตอนนั้นระบบแพ็กกิ้งจะคืนค่ากล่องที่ใช้ได้เป็นศูนย์แบบเงียบ ๆ แทนที่จะแจ้ง error จากการสุ่มตรวจพบว่าเกิดขึ้นกับ 485 จาก 556 ตัวเลือกสินค้า (variant) ไม่มีกล่อง ไม่มีราคา และระบบ checkout ก็ตกไปใช้การจัดส่งแบบ in-house โดยไม่มี log อธิบายเหตุผลเลย เราเปลี่ยนขั้นตอนการแพ็กกิ้งใหม่ให้เป็นการคำนวณพัสดุรวมแบบยืดหยุ่น ที่จะให้ราคาที่ใช้งานได้เสมอจากข้อมูลมิติเท่าที่มีอยู่จริง — ข้อมูลที่ขาดหายในแคตตาล็อกตอนนี้จึงลดระดับปัญหาลงอย่างนุ่มนวล แทนที่จะเปลี่ยนเส้นทางขนส่งของออร์เดอร์แบบไม่รู้ตัว

การกระทบยอดการชำระเงินต้องมีแหล่งข้อมูลจริงเพียงจุดเดียว ระบบรองรับ payment gateway สองเจ้า พร้อม fallback ระหว่างกัน และมี side effect หลังชำระเงิน — ตัดสต็อก ส่งอีเมลยืนยัน จองขนส่ง — ที่ต้องทำงานเหมือนกันทุกครั้งไม่ว่า gateway ไหนเป็นคนประมวลผลจริง เรารวมโค้ดส่วนนี้ให้ใช้เส้นทางเดียวกัน และเพิ่มตัวบ่งชี้ payment gateway ที่มองเห็นได้ในแดชบอร์ดฝ่ายปฏิบัติการ เพื่อให้ทีมซัพพอร์ตรู้ได้ทันทีว่าออร์เดอร์นั้นถูกจัดการด้วย gateway ไหน

Checkout ล่ม สาเหตุมาจากข้อมูล ไม่ใช่โค้ด การค้นหาแคมเปญโปรโมชันใช้ .get() ในจุดที่โครงสร้างข้อมูลเปิดให้มีแถวซ้ำได้ — ไม่มีปัญหาอะไรจนกว่าแคมเปญหนึ่งจะมีข้อมูลซ้ำขึ้นมาจริง ๆ ซึ่งพอเกิดขึ้นก็ทำให้ checkout ทั้งหน้าล่มด้วย exception ที่มองจากภายนอกแล้วดูเหมือนการชำระเงินล้มเหลว เราแก้ให้การค้นหานี้ได้ผลลัพธ์ที่แน่นอนเสมอ แทนที่จะเสี่ยงต่อ exception

เว็บ: ทำให้เลเยอร์ที่ควร interactive ทำงานได้จริง

หน้า checkout ของหน้าร้านวางเฟรมเวิร์ก reactive แบบเบา ๆ อย่าง Alpine.js ทับบนเทมเพลต Django ที่ render จากฝั่งเซิร์ฟเวอร์ — เป็นแนวทางที่ใช้งานได้ดีในทางปฏิบัติ แต่มีจุดพลาดเฉพาะตัวถ้าไม่ระวัง: คุณสามารถสร้าง markup แบบ interactive เป็น string แล้วฉีดเข้าไปในหน้าเว็บ มันจะแสดงผลได้สวยงามสมบูรณ์แบบ แต่ไม่ทำงานอะไรเลยสักอย่าง นั่นคือสิ่งที่เกิดขึ้นกับปุ่มตัวเลือกวิธีจัดส่ง — มันถูกสร้างเป็น HTML string ดิบ ๆ แล้วฉีดเข้าไปด้วย directive แบบ innerHTML ซึ่งไม่มีวัน re-parse ตัว binding ที่ซ่อนอยู่ข้างในได้เลย ปุ่มพวกนี้ดูถูกต้องในทุกภาพสกรีนช็อต แต่ไม่ทำงานมานานโดยไม่มีใครรู้ เราสร้างใหม่ให้เป็น template markup ที่ compile ได้ถูกต้องจริง ๆ

บทเรียนแบบเดียวกันแต่ซ่อนเนียนกว่าเกิดขึ้นอีกสองจุด: เงื่อนไขที่ render จากฝั่งเซิร์ฟเวอร์กับเงื่อนไขที่ render จากฝั่งไคลเอนต์ ไม่สามารถซ้อนกันได้อย่างปลอดภัย ครั้งหนึ่งเกิดกับฟอร์มเพิ่มที่อยู่ที่จะแสดงเฉพาะลูกค้าที่ยังไม่มีที่อยู่บันทึกไว้เลย อีกครั้งเกิดกับบล็อกคูปองที่ต้องมีคูปองให้เลือกก่อนถึงจะแสดง — ทั้งสองกรณีทำให้เฟรมเวิร์กฝั่งไคลเอนต์ถือ reference ไปยังสิ่งที่เซิร์ฟเวอร์ไม่ได้ render จริง เราปรับโครงสร้างใหม่ให้เซิร์ฟเวอร์เป็นคนตัดสินว่าบล็อกนั้น "มีอยู่หรือไม่" ส่วนฝั่งไคลเอนต์มีหน้าที่แค่ตัดสินว่าจะ "แสดงผลอย่างไร" เท่านั้น

มือถือ: สองแพลตฟอร์ม สองกฎ หนึ่งผลิตภัณฑ์

งานฝั่งมือถือมีต้นทุนพิเศษที่ทีมเว็บไม่ต้องจ่าย นั่นคือเจ้าของแพลตฟอร์มเป็นทั้งคู่ค้าและผู้เฝ้าประตูในเวลาเดียวกัน และกฎของพวกเขาก็เปลี่ยนได้ตลอดโดยที่เราไม่รู้ตัว

App Store ปฏิเสธแอป รหัส ITMS-91061 Apple กำหนดให้ต้องมี privacy manifest ที่ระบุ เหตุผล การใช้ API บางกลุ่มที่ "มักถูกใช้ในทางที่ผิด" — และ SDK ที่รวมมากับแอปถึงห้าตัวไม่มีไฟล์นี้เลย วิธีแก้แบบตรงไปตรงมาที่สุดกลับใช้ไม่ได้กับ build pipeline จริง เพราะการติดตั้ง dependency จะสร้างโฟลเดอร์ที่เราวางไฟล์ไว้ใหม่ทุกครั้งอยู่ดี เราจึงสร้างวิธีแก้ที่ยั่งยืนกว่า — เก็บไฟล์ manifest ไว้ใน source control แล้วให้ระบบ inject และลงทะเบียนไฟล์เหล่านี้อัตโนมัติทุกครั้งที่ install พร้อมตรวจสอบแล้วว่าทำงานซ้ำได้แน่นอนไม่ว่าจะ build กี่รอบ

นโยบาย Google Play กับเดดไลน์ Android 16 มาพร้อมกันในสัปดาห์เดียว ปลั๊กอินกล้องของแอปขอสิทธิ์เข้าถึงคลังรูปภาพแบบกว้าง ๆ โดยไม่มีเงื่อนไข ซึ่งเป็นรูปแบบที่ผู้ตรวจสอบของ Play เริ่มจับผิดอย่างจริงจัง เราย้ายการเลือกรูปและวิดีโอไปใช้ system picker ของแพลตฟอร์มเอง ซึ่งไม่ต้องขอสิทธิ์ runtime เลยแม้แต่น้อย พร้อมอัปเดต target SDK เป็น Android 16 ก่อนถึงเดดไลน์บังคับของ Play ทั้งหมดนี้ตรวจสอบด้วย log จากอุปกรณ์จริง — เพราะสมมติฐานแรกเกี่ยวกับพฤติกรรมขอสิทธิ์ของปลั๊กอินเดิมนั้นผิด และมีแค่การไล่ดู log จากเครื่องจริงเท่านั้นที่จับได้

ระบบล็อกอินทำงานได้บน Android แต่ล้มเหลวแบบเงียบ ๆ บน iOS สาเหตุคือการตั้งค่าขาดไปบรรทัดเดียว — audience ที่ผิดของ identity token ที่ขอไป — ซึ่งมองไม่เห็นเลยจนกว่าจะตรวจดูเนื้อหาจริงข้างในตัว token

และออร์เดอร์ที่เราเกริ่นไว้ตอนต้น หน้า checkout บนมือถือมี logic ที่เว็บไม่มี นั่นคือเลือกตัวเลือกจัดส่งที่ถูกกว่าเสมอ ไม่ว่ากรณีไหน — รวมถึงกรณีที่ตัวเลือกราคาถูกกว่านั้นไปส่งไม่ถึงจริง ๆ เป็นบั๊กที่พลาดง่ายมากถ้าสร้างแต่ละแพลตฟอร์มแยกกัน แต่จับได้ง่ายถ้าเอาผลลัพธ์จริงของทั้งสองแพลตฟอร์มมาเทียบกันกับข้อมูลออร์เดอร์จริง ซึ่งก็เป็นวิธีที่ทำให้เจอบั๊กนี้พอดี

การเปลี่ยนโดเมนของ API ไม่ต้องผ่านการรีวิวแอปสโตร์อีกต่อไป เราเพิ่มขั้นตอน remote configuration ตอนแอปเริ่มทำงาน ทุกครั้งที่เปิดแอป ระบบจะเช็คโดเมน API จาก config endpoint ที่เบามาก แล้วนำมาใช้ทันที พร้อม fallback ที่ปลอดภัยหากเช็คไม่สำเร็จ สิ่งที่เคยต้องทำผ่านการ build ใหม่แล้วส่งรีวิวสโตร์ — ใช้เวลาอย่างน้อยหลายวัน — ตอนนี้กลายเป็นแค่การเปลี่ยนค่าฝั่งเซิร์ฟเวอร์บรรทัดเดียว ที่มีผลทันทีที่ลูกค้าเปิดแอปครั้งถัดไป

ประสิทธิภาพ: ส่วนที่ไม่มีใครมองเห็น

บั๊กเรื่องความถูกต้องมักจะถูกแจ้งเข้ามา — ลูกค้าเห็นราคาผิดก็จะทักมา แต่หน้าเว็บที่โหลดช้าไม่มีใครแจ้งเลยสักครั้ง ลูกค้าแค่เดินจากไปเงียบ ๆ และไม่มีอะไรใน log บอกเหตุผลเลย งานสายนี้จึงเริ่มต้นจากการทำ audit ทั้งระบบ ไม่ใช่แค่ไล่ล่าบั๊ก — ตรวจสอบพร้อมกันสามด้าน ทั้ง database query การทำ caching และ background job แล้วจึงจัดลำดับความสำคัญของการแก้ไขตามผลกระทบที่วัดได้จริง ไม่ใช่การเดา

ประวัติคำสั่งซื้อของลูกค้าคนหนึ่งใช้เวลาโหลดถึง 9.7 วินาที และยิง database query ถึง 1,746 ครั้ง เกิดจากปัญหา N+1 สองชั้นซ้อนกัน ชั้นแรกคือรายการสินค้าในออร์เดอร์และตัวสินค้าไม่ได้ถูก prefetch เลย ส่วนชั้นที่สอง — ซึ่งเป็นจุดที่ซับซ้อนกว่า — คือรายการที่ prefetch ไว้แล้วกลับถูกข้ามไปแบบเงียบ ๆ เพราะมีการต่อ .order_by() ท้าย query ซึ่งจะข้าม prefetch cache ของ Django แล้วยิง query ใหม่ไปที่ฐานข้อมูลอยู่ดี เราแก้ทั้งสองชั้น ผลลัพธ์คือ 3.5 วินาที 406 query — ลด database load ลง 77%

แคมเปญ flash sale ที่กำลังทำงานอยู่ทำให้หน้าหนึ่งช้าจนแทบใช้งานไม่ได้ สินค้าทุกชิ้นในหน้ารายการตามหมวดหมู่ต่างคำนวณแยกกันว่า "ตอนนี้แคมเปญไหนกำลัง active อยู่" ซึ่งเป็นงานเดียวกันเป๊ะ ๆ ที่ถูกทำซ้ำทีละสินค้าในทุกการโหลดหน้า เราเปลี่ยนให้คำนวณคำตอบนี้แค่ครั้งเดียวต่อ request แล้ว cache ไว้แทน หน้าหมวดหมู่หนึ่งหน้าลดเวลาจาก 10.2 วินาที เหลือ 0.85 วินาที

เมนูที่แสดงอยู่ทุกหน้ากลับกลายเป็นสิ่งที่กินทรัพยากรเซิร์ฟเวอร์มากเป็นอันดับสอง การทำ profiling พบว่าประมาณหนึ่งในสี่ของเวลา render ทั้งหมดของหน้าหนึ่งหมดไปกับการสร้างเมนู dropdown แบบ 209 หมวดหมู่ใหม่ทั้งหมดแบบ recursive ในทุก request ทั้งที่โครงสร้างหมวดหมู่แทบไม่เคยเปลี่ยนเลย เมนูนี้อยู่ใน base template ที่ทุกหน้าสืบทอดมา การ cache fragment เดียวจึงแก้ปัญหาได้ครั้งเดียวจบ และผลลัพธ์ก็กระจายไปทั้งเว็บไซต์ ไม่ใช่แค่หน้าที่ถูก profiling เท่านั้น

และรูปภาพต้นฉบับที่ไม่ได้ปรับขนาดที่พูดถึงไปก่อนหน้านี้ — แบนเนอร์สไลด์โชว์ขนาด 3.9MB ที่ตอนนี้ส่งแค่ 14.9KB — ก็คือหลักคิดเดียวกันนี้ ถูกนำไปใช้กับอีกเลเยอร์หนึ่ง รูปภาพก็เป็นเหมือน query อีกแบบหนึ่งที่จะแพงถ้าไม่มีการ cache หรือปรับขนาดให้

ไม่ใช่ทุกการแก้ไขที่เสนอไปจะคุ้มค่าเสมอ และเราเลือกจะพูดตรง ๆ ดีกว่าปัดตัวเลขให้ดูดี มีการแก้ไขด้าน caching จุดหนึ่งที่ทำไปแล้ว วัดผลแล้ว และพบว่าไม่ได้ช่วยอะไรกับหน้าที่ตั้งใจแก้เลย เพราะค่าที่ cache ไว้ไม่เคยถูกอ่านเกินหนึ่งครั้งต่อ request อยู่แล้ว จึงไม่มีอะไรให้ลดการทำซ้ำ แต่เราก็ยังคงการแก้ไขนี้ไว้ เพราะระหว่างการวัดผลไปเจอบั๊กด้านความถูกต้องตัวจริงและแก้ไปด้วย ตัวเลขประสิทธิภาพของจุดนี้คือศูนย์ตรง ๆ และเราก็บันทึกไว้แบบนั้น

เส้นด้ายที่ร้อยทุกอย่างเข้าด้วยกัน

ไม่มีอะไรในนี้ที่พิสดารเลยสักจุด การตั้งค่าที่ขาดไปบรรทัดเดียว ปลั๊กอินที่ขอสิทธิ์เกินความจำเป็น ทางลัดแบบ innerHTML การเปรียบเทียบราคาที่ใช้ได้แค่ครึ่งเดียวของพื้นที่ให้บริการ สิ่งที่ทำให้แต่ละจุดนี้คุ้มค่าที่จะตามหา คือมันซ่อนอยู่ตรง "รอยต่อ" เสมอ — ระหว่าง backend กับ frontend ระหว่างเว็บกับมือถือ ระหว่างสิ่งที่แพลตฟอร์มเจ้าของสโตร์เรียกร้องในปีนี้กับสิ่งที่โค้ดเคยสมมติไว้เมื่อปีที่แล้ว แยกกันแต่ละจุดดูเล็กน้อยมาก แต่รวมกันแล้วคือความต่างระหว่างหน้าร้านที่ "ใช้งานได้เกือบตลอด" กับหน้าร้านที่ "เชื่อถือได้จริง" — คือความต่างระหว่างลูกค้าสองคนได้ราคาต่างกันสำหรับออร์เดอร์เดียวกัน กับการที่มันไม่เกิดขึ้นอีกเลย

งานเชื่อมรอยต่อแบบนี้ — ทำให้ backend เว็บ และมือถือ ตรงกันอยู่เสมอ บนระบบ production จริง — คืองานที่เราทำที่ Simplico ถ้าคุณกำลังดูแลแพลตฟอร์มอีคอมเมิร์ซอยู่ และอยากได้ทีมที่มองว่า "ใช้งานได้บนแพลตฟอร์มเดียว" เป็นแค่รายงานบั๊ก ไม่ใช่ความสำเร็จ นั่นคือสิ่งที่ บริการวิศวกรรมอีคอมเมิร์ซ ของเราถูกสร้างมาเพื่อตอบโจทย์