เมื่อคุณทำธุรกิจนายหน้าซื้อขายรถยนต์ในอาเซอร์ไบจานและนำเข้ารถยนต์ซาก (salvage vehicles) จากสหรัฐอเมริกา ปัญหาด้านซอฟต์แวร์ของคุณจะแตกต่างจากสตาร์ทอัพใน Silicon Valley คุณไม่ได้กำลังปรับแต่งระบบเพื่อรองรับผู้ใช้งานพร้อมกันนับล้านคน แต่คุณกำลังปรับแต่งเพื่อความชัดเจน ความเสถียร (uptime) และความสามารถในการแก้ไขปัญหาด้วยตัวเองตอนเที่ยงคืน ในขณะที่ต้องประสานงานกับโรงประมูลที่อยู่ห่างออกไปถึง 12 เขตเวลา นั่นคือสถานการณ์ที่ผมเผชิญอยู่ตอนที่สร้าง AutoMakler แพลตฟอร์มนี้จัดการทุกอย่างตั้งแต่การทำ live auction scraping และการตรวจสอบ Carfax ไปจนถึงการประมาณการจัดส่งและการประมวลผลการชำระเงิน มันคือระบบที่ใช้งานจริงเพื่อลูกค้าจริงๆ และมันทำงานบนสิ่งที่นักพัฒนาส่วนใหญ่จะเรียกว่า "stack ที่น่าเบื่ออย่างยิ่ง" (aggressively boring stack)

Stack ที่ไม่มีใครอยากนำเสนอ

ไม่มี React ไม่มี Vue ไม่มี Redis ไม่มี Celery และไม่มี WebSocket server ส่วน backend คือ FastAPI ด้วย Python ล้วนๆ ฐานข้อมูลคือ PostgreSQL ส่วน frontend คือ HTML ที่เรนเดอร์จากฝั่งเซิร์ฟเวอร์โดยใช้ Jinja2 templates, Bootstrap และ vanilla JavaScript อีกเล็กน้อย สำหรับการ scraping ผมใช้ Playwright ทุกอย่างทำงานเป็น Python process เดียวที่ให้บริการ HTML โดยตรง

ไม่มีขั้นตอนการ build ไม่ต้องมีโฟลเดอร์ node_modules ให้ตรวจสอบ ไม่ต้องตั้งค่า transpilers และไม่ต้องคอยตามกระแสความเปลี่ยนแปลงของ frontend framework เมื่อผม deploy ผมแค่ย้ายไฟล์ Python และ templates ไม่ใช่การจัดการ pipeline ของ bundlers ความเรียบง่ายนั้นไม่ใช่การลดสเปก แต่มันคือหัวใจสำคัญของเรื่องนี้เลย

วิธีการคิวงาน (Queue Jobs) โดยไม่ต้องใช้ Message Broker

การ scraping การประมูลรถยนต์แบบสดไม่สามารถทำแบบ synchronous ได้ การ scrape เพียงครั้งเดียวอาจใช้เวลาหลายวินาทีเนื่องจาก Playwright ต้องโหลดหน้าเว็บ รัน JavaScript และดึงข้อมูลออกมา การทำให้ผู้ใช้ต้องรอในขณะที่กระบวนการนี้กำลังทำงานอยู่ไม่ใช่ทางเลือกที่ดี ตามตำรามาตรฐานบอกว่าให้ติดตั้ง Redis, ตั้งค่า Celery และรัน worker pool แต่ผมข้ามขั้นตอนทั้งหมดนั้นไป

แทนที่จะทำแบบนั้น AutoMakler ใช้ Postgres เป็น job queue ของตัวเอง เมื่อผู้ใช้เริ่มการ scrape แอปพลิเคชันจะเขียนแถวใหม่ลงในตาราง tasks พร้อมสถานะเป็น pending จากนั้น asyncio background task จะดึงแถวนั้นไปและเริ่มการ scrape ผ่านเบราว์เซอร์ ในขณะเดียวกัน เบราว์เซอร์จะทำการ polling ไปยัง endpoint ที่มีน้ำหนักเบาในทุกๆ 3 วินาทีเพื่อตรวจสอบสถานะ เมื่อแถวถูกอัปเดตเป็น completed หน้าเว็บจะรีเฟรชและแสดงผลลัพธ์

รูปแบบนี้ใช้งานได้ดีเพราะช่วงเวลาการ polling นั้นสั้นพอที่จะให้ความรู้สึกตอบสนองได้รวดเร็ว แต่ก็ยาวพอที่จะไม่ทำให้เซิร์ฟเวอร์ทำงานหนักเกินไป 3 วินาทีถือเป็นเวลาที่ยาวนานมากสำหรับคอมพิวเตอร์ แต่แทบจะไม่รู้สึกเลยสำหรับมนุษย์ที่กำลังรอข้อมูลจากเว็บไซต์ประมูลภายนอก ฐานข้อมูลจัดการเรื่อง concurrency ได้ในตัว และเนื่องจากงานต่างๆ เป็นเพียงแถวข้อมูลใน Postgres ผมจึงสามารถตรวจสอบคิวได้ด้วยคำสั่ง SQL ง่ายๆ แทนที่จะต้องไปไล่ดู Celery logs หรือ Redis keys

การรักษาเซิร์ฟเวอร์ให้ทำงานได้โดยไม่ต้องมี Worker Pool

การทำ browser automation กินทรัพยากรหน่วยความจำ (memory) สูงมาก หากรัน Playwright หลาย instance พร้อมกันมากเกินไป เซิร์ฟเวอร์ของคุณจะล่ม วิธีแก้ปัญหาแบบทั่วไปคือการใช้ managed worker pool ที่มีการจำกัด concurrency ซึ่งมักจะทำงานร่วมกับ Redis และ Celery แต่ผมใช้ Python เพียงบรรทัดเดียว นั่นคือ asyncio.Semaphore

semaphore จะจำกัดจำนวน browser instances ที่สามารถทำงานพร้อมกันได้ เมื่อมีการขอ scrape ใหม่เข้ามา มันจะจองช่องว่าง (slot) ทันที หรือไม่ก็ต้องรอจนกว่าจะมีช่องว่างว่างลง ทั้งหมดนี้เกิดขึ้นภายใน process เดียวกัน ไม่มี orchestrator ภายนอกที่จะล้มเหลว ไม่มี worker process ที่จะตายไปเงียบๆ และไม่มีโครงสร้างพื้นฐานเพิ่มเติมที่ต้องคอยตรวจสอบ หน่วยความจำของผมจึงคาดเดาได้ และโค้ดที่ทำหน้าที่ปกป้องเซิร์ฟเวอร์ก็อยู่ติดกับโค้ดที่ใช้งานมัน ไม่ได้ถูกซ่อนอยู่ใน deployment manifest

การจัดการเส้นทางการเงินด้วย Callback URL เพียงอันเดียว

การประมวลผลการชำระเงินนำมาซึ่งข้อจำกัดที่ผมไม่สามารถเปลี่ยนแปลงได้ Payment gateway ของผมอนุญาตให้มี callback URL ได้เพียงหนึ่งเดียวต่อหนึ่งบัญชีร้านค้า แต่ผมจำเป็นต้องประมวลผลธุรกรรมสำหรับสองโปรเจกต์ที่แยกกันผ่านบัญชีเดียวนี้ การสร้างโปรไฟล์ร้านค้าที่สองหมายถึงค่าธรรมเนียมที่เพิ่มขึ้น การปฏิบัติตามกฎระเบียบ (compliance) ที่มากขึ้น และงานเอกสารที่มากขึ้น ซึ่งธุรกิจนายหน้าขนาดเล็กไม่มีเวลามาจัดการ

วิธีแก้คือการเข้ารหัสชื่อโปรเจกต์ลงในสตริง order ID โดยตรงก่อนที่จะส่งลูกค้าไปยัง gateway เมื่อ callback ส่งมาถึงเซิร์ฟเวอร์ของผม AutoMakler จะถอดรหัส ID นั้น ระบุว่าการชำระเงินนี้เป็นของโปรเจกต์ไหน และส่งการแจ้งเตือนไปยัง internal handler ที่ถูกต้อง ตรรกะเดิมยังคงไม่ถูกแตะต้อง นี่คือการออกแบบเชิงเสริม (additive design): ผมไม่ได้เขียนระบบการชำระเงินใหม่ ผมแค่ทำให้ identifier มีบริบท (context) เพิ่มขึ้นอีกนิดเดียว มันเป็นเทคนิคแบบ hack ที่ดูเหมือนจะชัดเจนเมื่อมองย้อนกลับไป แต่มันช่วยประหยัดเวลาในการจัดการโครงสร้างสถาปัตยกรรมที่ซับซ้อนได้หลายชั่วโมง

แชทที่ใช้งานได้โดยไม่ต้องใช้ WebSockets

แชทสนับสนุนลูกค้ามักจะเป็นจุดที่วิศวกรมักจะยอมแพ้และหันไปใช้ WebSockets ผมต้องการระบบส่งข้อความภายในแอป (in-app messaging) แต่ในขณะเดียวกันก็ต้องการรักษาขนาดของโครงสร้างพื้นฐาน (infrastructure footprint) ให้เล็กที่สุดเท่าที่จะเป็นไปได้ ผมจึงนำกลยุทธ์การทำ polling แบบเดียวกับที่ใช้ในการดึงข้อมูลการประมูล (auction scrapes) กลับมาใช้ใหม่

ข้อความจะถูกเก็บไว้ใน Postgres เมื่อผู้ใช้ส่งข้อความ ข้อมูลจะถูกเขียนลงในตาราง ฝั่ง client จะทำการ poll เพื่ออัปเดตข้อมูล และ UI จะแสดงข้อความใหม่รวมถึงสถานะการอ่าน (read receipts) แบบเกือบเรียลไทม์ (near real-time) เพื่อให้ระบบยังคงทำงานได้รวดเร็วแม้ตารางการสนทนาจะใหญ่ขึ้น ผมจึงเพิ่ม Postgres partial index ที่ครอบคลุมเฉพาะข้อความที่ยังไม่ได้อ่านสำหรับการสนทนาที่ยังใช้งานอยู่ (active conversations) ทำให้ฐานข้อมูลไม่ต้องเสียทรัพยากรไปกับการสแกนประวัติเก่าๆ และ query planner ก็สามารถจัดการการค้นหาแชทส่วนใหญ่ได้ด้วยการทำ index range scan ที่รวดเร็ว

สำหรับแชทสนับสนุนลูกค้าที่ความหน่วง (latency) เพียงไม่กี่วินาทีนั้นเป็นเรื่องที่ยอมรับได้ วิธีนี้ถือว่าเพียงพออย่างยิ่ง ผู้ใช้ได้รับข้อมูลตอบกลับที่ต้องการ และผมก็ไม่ต้องเสียเวลาไปกับการดีบั๊ก (debug) การเชื่อมต่อ WebSocket ที่ค้าง (stale connection) หรือต้องจัดการเซิร์ฟเวอร์ socket แยกต่างหาก

ข้อเสียตามความเป็นจริง

สถาปัตยกรรมนี้มีการแลกเปลี่ยน (trade-offs) ที่เกิดขึ้นจริง และการแสร้งทำเป็นว่าไม่มีก็คงเป็นการไม่ซื่อสัตย์ การทำ polling นั้นมีการรับส่งข้อมูลที่ถี่มาก (chatty) ทุกๆ สามวินาที client ที่ใช้งานอยู่ทุกคนจะส่งคำขอไปยังเซิร์ฟเวอร์ ทำให้การใช้ bandwidth และภาระของ query สูงกว่าที่การเชื่อมต่อ socket แบบคงค้าง (persistent socket connection) ต้องการ หากกระบวนการ Python (Python process) เริ่มต้นใหม่ งานเบื้องหลัง (background task) ที่กำลังทำงานอยู่จะหยุดลงทันที เพราะไม่มี worker ภายนอกมาคอยรับช่วงต่อ ผมยอมรับจุดนี้ได้เพราะงานเหล่านี้มีขนาดเล็กและค่าใช้จ่ายในการลองใหม่ (retry) นั้นต่ำ หากการดึงข้อมูลผ่านเบราว์เซอร์ (browser scrape) ล้มเหลว ผู้ใช้ก็แค่กดเริ่มใหม่อีกครั้งได้ง่ายๆ

นอกจากนี้ วิธีนี้ยังมีขีดจำกัดอยู่ หาก AutoMakler จำเป็นต้องรองรับการดึงข้อมูลพร้อมกัน (simultaneous scrapes) เป็นพันๆ รายการ โมเดลแบบ single-process ที่ใช้การ polling จะเริ่มรับไม่ไหว แต่นั่นไม่ใช่โมเดลธุรกิจที่ผมทำอยู่ ผมต้องการความน่าเชื่อถือสำหรับผู้ใช้ที่ใช้งานพร้อมกัน (concurrent users) เพียงไม่กี่สิบคน ไม่ใช่