ทุกแอปที่ให้ความรู้สึกเร็วและไว้ใจได้ ล้วนมีแบ็กเอนด์ที่ออกแบบมาอย่างดีอยู่เบื้องหลัง แบ็กเอนด์ดูแลกฎทางธุรกิจ ปกป้องข้อมูล และตอบคำขอนับพันต่อวินาทีโดยที่ไม่มีใครสังเกต การวางสถาปัตยกรรมให้ถูกตั้งแต่ต้นช่วยประหยัดเวลาแก้งานซ้ำได้หลายเดือน ส่วนการวางผิดจะปรากฏออกมาเป็นระบบล่ม หน้าเว็บช้า และฟีเจอร์ที่ใช้เวลาพัฒนานานเกินควร
บทความนี้พาไปดูการตัดสินใจที่สำคัญที่สุด ได้แก่ การออกแบบ API การจัดโครงสร้างบริการ การออกแบบข้อมูล และการทำให้ทุกอย่างทำงานได้ราบรื่นเมื่อทราฟฟิกเพิ่มขึ้น
ออกแบบ REST API ที่นักพัฒนาชอบใช้
REST มองระบบเป็นทรัพยากร (resource) ซึ่งเป็นคำนาม ที่ client จัดการด้วยเมธอด HTTP มาตรฐานซึ่งเป็นคำกริยา หัวใจคือความสม่ำเสมอ เมื่อนักพัฒนาเรียนรู้ endpoint หนึ่งแล้ว ก็ควรเดา endpoint ที่เหลือได้
GET /v1/ordersแสดงรายการคำสั่งซื้อ พร้อมการกรองและแบ่งหน้าผ่าน query parameterGET /v1/orders/{id}ส่งคืนคำสั่งซื้อหนึ่งรายการPOST /v1/ordersสร้างคำสั่งซื้อ แล้วตอบกลับด้วย201 Createdและ headerLocationPATCH /v1/orders/{id}อัปเดตคำสั่งซื้อบางส่วน ส่วนPUTแทนที่ทั้งหมดDELETE /v1/orders/{id}ลบคำสั่งซื้อ แล้วตอบกลับด้วย204 No Content
สถานะโค้ดเป็นส่วนหนึ่งของสัญญา API จึงควรใช้ให้แม่นยำ:
200 OK,201 Createdและ204 No Contentสำหรับความสำเร็จ400 Bad Requestสำหรับคำขอที่รูปแบบผิด และ422 Unprocessable Contentสำหรับคำขอที่รูปแบบถูกแต่ไม่ผ่านการตรวจสอบ401 Unauthorizedเมื่อผู้เรียกยังไม่ได้ยืนยันตัวตน และ403 Forbiddenเมื่อยืนยันตัวตนแล้วแต่ไม่มีสิทธิ์404 Not Found,409 Conflictสำหรับสถานะที่ขัดแย้งกัน เช่น ข้อมูลซ้ำ และ429 Too Many Requestsเมื่อถูกจำกัดอัตราการเรียก5xxใช้เฉพาะความผิดพลาดฝั่งเซิร์ฟเวอร์เท่านั้น ห้ามใช้กับอินพุตที่ผิด
นี่คือ endpoint ภาษา TypeScript ที่ใช้ Express ซึ่งตรวจสอบอินพุตตั้งแต่ขอบเขตของระบบและตอบกลับด้วยสถานะโค้ดที่ถูกต้อง:
import express from 'express'
import { z } from 'zod'
import { orders } from './orders'
const app = express()
app.use(express.json())
const CreateOrder = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
})
app.post('/v1/orders', async (req, res) => {
const parsed = CreateOrder.safeParse(req.body)
if (!parsed.success) {
return res.status(422).json({
type: 'https://api.example.com/problems/validation',
title: 'The request body is invalid',
status: 422,
errors: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })),
})
}
const order = await orders.create(parsed.data)
res.status(201).location(`/v1/orders/${order.id}`).json(order)
})ข้อผิดพลาดที่สอดคล้องกัน การแบ่งหน้า และการจัดการเวอร์ชัน
ส่งข้อผิดพลาดทุกแบบในโครงสร้างเดียวกัน เพื่อให้ client จัดการได้แบบทั่วไป รูปแบบ Problem Details (RFC 9457) เป็นมาตรฐานที่รองรับอย่างกว้างขวาง:
{
"type": "https://api.example.com/problems/out-of-stock",
"title": "Item is out of stock",
"status": 409,
"detail": "SKU TS-1042 has 0 units available; 3 were requested.",
"instance": "/v1/orders"
}- การแบ่งหน้า: ใช้การแบ่งหน้าแบบ cursor (
?after=ord_123&limit=50) สำหรับชุดข้อมูลขนาดใหญ่หรือเปลี่ยนแปลงเร็ว การแบ่งหน้าแบบ offset จะข้ามหรือแสดงรายการซ้ำเมื่อมีแถวใหม่ถูกเพิ่มระหว่างที่ client กำลังเลื่อนหน้า - การจัดการเวอร์ชัน: ใส่เวอร์ชันหลักไว้ใน path (
/v1/) และเพิ่มได้เฉพาะฟิลด์ใหม่ภายในเวอร์ชันเดียวกัน การลบหรือเปลี่ยนชื่อฟิลด์ถือเป็นการเปลี่ยนแปลงที่ทำให้เข้ากันไม่ได้ - Idempotency: ให้ client ส่ง header
Idempotency-KeyมากับคำขอPOSTซึ่งเป็นรูปแบบที่ API ด้านการชำระเงินทำให้แพร่หลาย เพื่อให้คำขอที่ส่งซ้ำไม่สร้างคำสั่งซื้อหรือการตัดเงินซ้ำ
Monolith หรือ Microservices?
Monolith คือแอปพลิเคชันเดียวที่ deploy ทั้งก้อน ส่วน Microservices แบ่งระบบเป็นบริการเล็กๆ ที่แต่ละบริการเป็นเจ้าของข้อมูลของตนเองและ deploy แยกกัน Microservices มักถูกนำเสนอว่าเป็นตัวเลือกมาตรฐานยุคใหม่ แต่จริงๆ แล้วมันแลกความซับซ้อนของโค้ดกับความซับซ้อนในการดูแลระบบ:
- สิ่งที่ได้: deploy แยกกันได้ ขยายเฉพาะจุดที่รับโหลดหนัก และทีมต่างๆ ทำงานได้โดยไม่ก้าวก่ายกัน
- สิ่งที่ต้องจ่าย: การเรียกผ่านเครือข่ายที่อาจล้มเหลวหรือช้า ข้อมูลกระจายอยู่หลายฐานข้อมูลโดยไม่มี transaction ร่วมกัน การ trace และดีบักแบบกระจาย และโครงสร้างพื้นฐานที่ต้องดูแลมากขึ้นอีกมาก
สำหรับผลิตภัณฑ์ส่วนใหญ่ จุดเริ่มต้นที่ถูกต้องคือ Modular Monolith คือ deploy เป็นก้อนเดียว แต่มีขอบเขตภายในที่เข้มงวด (คำสั่งซื้อ การเรียกเก็บเงิน แคตตาล็อก) ซึ่งสื่อสารกันผ่าน interface ที่กำหนดไว้เท่านั้น หากวันหนึ่งโมดูลใดต้องขยายหรือออกเวอร์ชันแยก ขอบเขตของมันก็สะอาดพอที่จะแยกออกมาเป็นบริการได้
แยกระบบตามขอบเขตทางธุรกิจเมื่อทีมหรือความต้องการด้านการขยายระบบบังคับให้ต้องทำ ไม่ใช่เพราะแผนภาพสถาปัตยกรรมดูสวยกว่า
เมื่อบริการต้องสื่อสารกัน ให้ใช้การเรียกแบบซิงโครนัส (REST หรือ gRPC) เมื่อผู้เรียกต้องการคำตอบทันที และใช้อีเวนต์แบบอะซิงโครนัสผ่าน message broker เมื่อบริการอื่นแค่ต้องตอบสนอง เช่น "มีคำสั่งซื้อใหม่" หรือ "ได้รับชำระเงินแล้ว" เพื่อส่งอีเวนต์อย่างน่าเชื่อถือ ให้เขียนอีเวนต์ลงตาราง outbox ภายใน transaction เดียวกับการเปลี่ยนแปลงทางธุรกิจ แล้วให้โปรเซสแยกต่างหากส่งต่อไปยัง broker มิฉะนั้น หากระบบล่มระหว่างสองขั้นตอน ข้อมูลและอีเวนต์จะไม่ตรงกัน
ออกแบบฐานข้อมูลให้ขยายได้
ปัญหาด้านประสิทธิภาพส่วนใหญ่คือปัญหาของฐานข้อมูล หลักการไม่กี่ข้อช่วยได้มาก:
- ออกแบบตามโดเมนก่อน แล้วจึงคิดถึง query เริ่มจากแบบ normalize ให้แต่ละข้อเท็จจริงอยู่ที่เดียว แล้วค่อย denormalize อย่างตั้งใจเฉพาะจุดที่ query ซึ่งวัดผลแล้วจำเป็นต้องใช้
- ให้ฐานข้อมูลบังคับใช้กฎ: constraint อย่าง
NOT NULL, foreign key,UNIQUEและCHECKจับบั๊กที่โค้ดแอปพลิเคชันพลาดไป - เก็บจำนวนเงินเป็นจำนวนเต็ม (หน่วยสตางค์) หรือชนิด decimal ห้ามใช้เลขทศนิยมแบบ floating-point
- สร้าง index สำหรับ query ที่ใช้จริง และตรวจสอบด้วย
EXPLAINทุก index ช่วยให้อ่านเร็วขึ้นแต่ทำให้เขียนช้าลง - เปลี่ยน schema ผ่าน migration ที่มีเวอร์ชัน ซึ่งรันอัตโนมัติ ห้ามแก้ด้วยมือบน production
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customers (id),
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Serves "latest orders for a customer" without scanning the whole table
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at DESC);ระวังปัญหา N+1 query คือการโหลดคำสั่งซื้อ 50 รายการ แล้วรัน query เพิ่มอีกหนึ่งครั้งต่อคำสั่งซื้อเพื่อดึงข้อมูลลูกค้า ให้ดึงข้อมูลที่เกี่ยวข้องใน query เดียวด้วย join หรือ WHERE id IN (…) แบบเป็นชุดแทน และใช้ connection pool ด้วย เพราะการเปิดการเชื่อมต่อฐานข้อมูลใหม่ทุกคำขอนั้นช้าและทำให้ทรัพยากรเซิร์ฟเวอร์หมดเร็ว
รูปแบบการขยายระบบที่ได้ผล
- ทำให้แอปพลิเคชันเซิร์ฟเวอร์ไม่มีสถานะ (stateless) (เก็บ session ในฐานข้อมูลหรือแคช เก็บไฟล์ใน object storage) เพื่อให้รันกี่สำเนาก็ได้หลัง load balancer
- แคชการอ่านที่มีต้นทุนสูงด้วยอายุสั้นๆ และตัดสินใจตั้งแต่แรกว่าแต่ละรายการในแคชจะถูกล้างอย่างไรเมื่อข้อมูลเปลี่ยน
- ย้ายงานที่ช้าไปไว้ในคิวเบื้องหลัง เช่น อีเมล รายงาน การประมวลผลรูปภาพ และการเรียกบริการภายนอก API ตอบกลับทันที แล้ว worker ค่อยทำงานตามหลัง
- เพิ่ม read replica เมื่อการอ่านมีสัดส่วนสูง และส่ง query สำหรับรายงานไปที่นั่นแทนฐานข้อมูลหลัก
- จำกัดอัตราการเรียก endpoint สาธารณะ เพื่อปกป้องระบบจากการใช้งานในทางที่ผิดและ client ที่ทำงานผิดพลาด
- แบ่ง partition หรือ shard ข้อมูลเป็นลำดับสุดท้าย เมื่อฐานข้อมูลเดียวที่ปรับแต่งดีแล้วรับไม่ไหวจริงๆ เท่านั้น
ความเสถียรและการสังเกตการณ์ระบบ
ระบบแบบกระจายล้มเหลวในแบบที่เกิดเพียงบางส่วนและชวนสับสน จึงต้องออกแบบเผื่อไว้ ตั้ง timeout ให้ทุกการเรียกออกไปภายนอก ลองใหม่ (retry) เฉพาะการทำงานแบบ idempotent โดยใช้ exponential backoff และหน่วงเวลาแบบสุ่ม (jitter) เพื่อไม่ให้ client ทุกตัวลองใหม่พร้อมกัน และใช้ circuit breaker เพื่อหยุดเรียกบริการที่เห็นชัดว่าล่มอยู่
จากนั้นทำให้ระบบสังเกตการณ์ได้ ด้วย log แบบมีโครงสร้าง ที่มี request ID, metric สำหรับความหน่วง ทราฟฟิก ข้อผิดพลาด และระดับการใช้ทรัพยากร และ distributed tracing เช่น ด้วย OpenTelemetry ที่แสดงว่าเวลาถูกใช้ไปที่ไหนระหว่างบริการต่างๆ แจ้งเตือนตามอาการที่ผู้ใช้รู้สึกได้ เช่น อัตราข้อผิดพลาดและความหน่วง แทนที่จะแจ้งทุกครั้งที่ CPU พุ่งขึ้น
ประเด็นสำคัญ
- ออกแบบ API รอบทรัพยากร ใช้สถานะโค้ดอย่างแม่นยำ และมีรูปแบบข้อผิดพลาดเดียวที่สอดคล้องกัน
- เริ่มจาก Modular Monolith แล้วแยกบริการตามขอบเขตทางธุรกิจเมื่อมีความจำเป็นจริง
- ให้ฐานข้อมูลรักษาความถูกต้องของข้อมูล สร้าง index ตาม query ที่ใช้จริง และหลีกเลี่ยง N+1 query
- ขยายระบบด้วยเซิร์ฟเวอร์แบบ stateless แคช และคิว พร้อมวาง timeout, retry และการสังเกตการณ์ระบบไว้ตั้งแต่ต้น
สรุป
แบ็กเอนด์ที่ขยายได้ไม่ได้มาจากเทคโนโลยีแปลกใหม่ แต่มาจากการตัดสินใจธรรมดาๆ ที่ทำอย่างสม่ำเสมอทุกจุด ได้แก่ สัญญา API ที่ชัดเจน ขอบเขตที่สะอาดระหว่างส่วนต่างๆ ของระบบ ฐานข้อมูลที่ออกแบบมาอย่างดี และนิสัยในการดูแลระบบที่ทำให้มองเห็นปัญหาได้ตั้งแต่เนิ่นๆ ทำสิ่งเหล่านี้ให้ถูก แล้วการเพิ่มกำลังรองรับจะเป็นแค่การเปลี่ยนค่าคอนฟิก ไม่ใช่การเขียนระบบใหม่
อ่านเพิ่มเติม
- #Architecture
- #Databases
- #Microservices
- #REST API
- #TypeScript




