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

July 27, 2026

บันทึกจากงานปรับปรุงระบบอีคอมเมิร์ซที่ใช้งานจริง — 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 ถ้าคุณกำลังดูแลแพลตฟอร์มอีคอมเมิร์ซอยู่ และอยากได้ทีมที่มองว่า "ใช้งานได้บนแพลตฟอร์มเดียว" เป็นแค่รายงานบั๊ก ไม่ใช่ความสำเร็จ นั่นคือสิ่งที่ บริการวิศวกรรมอีคอมเมิร์ซ ของเราถูกสร้างมาเพื่อตอบโจทย์

Ready to talk about your project?

Share goals and constraints. We'll assemble architects and engineers to move fast with you.

Get in touch