Givepo
บล็อก

อธิบายสถาปัตยกรรมแบ็กเอนด์: REST API, Microservices และฐานข้อมูลที่ขยายได้

วิธีออกแบบ REST API ที่นักพัฒนาชอบใช้ เมื่อไรที่ Microservices คุ้มค่ากับต้นทุน วิธีออกแบบฐานข้อมูลที่รับโหลดหนักได้ และรูปแบบที่ทำให้ระบบแบ็กเอนด์เร็วและเสถียร

ทีมงาน Givepo
อ่าน 7 นาที
Illustration of a client sending a GET request to an API gateway that connects to three services and a databaseแบ็กเอนด์และ API

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

บทความนี้พาไปดูการตัดสินใจที่สำคัญที่สุด ได้แก่ การออกแบบ API การจัดโครงสร้างบริการ การออกแบบข้อมูล และการทำให้ทุกอย่างทำงานได้ราบรื่นเมื่อทราฟฟิกเพิ่มขึ้น

ออกแบบ REST API ที่นักพัฒนาชอบใช้

REST มองระบบเป็นทรัพยากร (resource) ซึ่งเป็นคำนาม ที่ client จัดการด้วยเมธอด HTTP มาตรฐานซึ่งเป็นคำกริยา หัวใจคือความสม่ำเสมอ เมื่อนักพัฒนาเรียนรู้ endpoint หนึ่งแล้ว ก็ควรเดา endpoint ที่เหลือได้

  • GET /v1/orders แสดงรายการคำสั่งซื้อ พร้อมการกรองและแบ่งหน้าผ่าน query parameter
  • GET /v1/orders/{id} ส่งคืนคำสั่งซื้อหนึ่งรายการ
  • POST /v1/orders สร้างคำสั่งซื้อ แล้วตอบกลับด้วย 201 Created และ header Location
  • PATCH /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 ซึ่งตรวจสอบอินพุตตั้งแต่ขอบเขตของระบบและตอบกลับด้วยสถานะโค้ดที่ถูกต้อง:

TypeScript
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) เป็นมาตรฐานที่รองรับอย่างกว้างขวาง:

JSON
{
  "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
SQL
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 ด้วย เพราะการเปิดการเชื่อมต่อฐานข้อมูลใหม่ทุกคำขอนั้นช้าและทำให้ทรัพยากรเซิร์ฟเวอร์หมดเร็ว

รูปแบบการขยายระบบที่ได้ผล

  1. ทำให้แอปพลิเคชันเซิร์ฟเวอร์ไม่มีสถานะ (stateless) (เก็บ session ในฐานข้อมูลหรือแคช เก็บไฟล์ใน object storage) เพื่อให้รันกี่สำเนาก็ได้หลัง load balancer
  2. แคชการอ่านที่มีต้นทุนสูงด้วยอายุสั้นๆ และตัดสินใจตั้งแต่แรกว่าแต่ละรายการในแคชจะถูกล้างอย่างไรเมื่อข้อมูลเปลี่ยน
  3. ย้ายงานที่ช้าไปไว้ในคิวเบื้องหลัง เช่น อีเมล รายงาน การประมวลผลรูปภาพ และการเรียกบริการภายนอก API ตอบกลับทันที แล้ว worker ค่อยทำงานตามหลัง
  4. เพิ่ม read replica เมื่อการอ่านมีสัดส่วนสูง และส่ง query สำหรับรายงานไปที่นั่นแทนฐานข้อมูลหลัก
  5. จำกัดอัตราการเรียก endpoint สาธารณะ เพื่อปกป้องระบบจากการใช้งานในทางที่ผิดและ client ที่ทำงานผิดพลาด
  6. แบ่ง 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
เขียนโดย
ทีมงาน Givepo

Givepo คือบริษัทไอทีที่พัฒนาเว็บไซต์ แอปมือถือ และซอฟต์แวร์เฉพาะทาง เพื่อผลักดันธุรกิจของคุณให้ก้าวไปข้างหน้า

แชร์บทความนี้

ปรึกษาฟรี 30 นาที

พูดคุยเรื่องไอเดีย ขอบเขตงาน และงบประมาณกับทีมของเรา

นัดหมายปรึกษา

บทความอื่นๆ

บล็อก
วิศวกรรมซอฟต์แวร์
·อ่าน 7 นาที

Clean Architecture และหลักการ SOLID: เขียนซอฟต์แวร์ที่ดูแลง่ายในระยะยาว

ทำไมโค้ดเบสจึงช้าลงเมื่อโตขึ้น และ Clean Architecture กฎการพึ่งพา และหลักการ SOLID ช่วยให้ตรรกะทางธุรกิจทดสอบง่ายและแก้ไขง่ายได้อย่างไร พร้อมตัวอย่าง TypeScript ที่สมบูรณ์

ดูรายละเอียด
พัฒนาเว็บ
·อ่าน 6 นาที

พัฒนาเว็บยุคใหม่ด้วย Next.js, React และ TypeScript: แนวทางปฏิบัติที่ดีที่สุด

Server Components กลยุทธ์การเรนเดอร์ ข้อมูลที่ปลอดภัยด้วยไทป์ และ Core Web Vitals คู่มือปฏิบัติจริงสำหรับสร้างเว็บแอปที่เร็วและดูแลง่าย ด้วยชุดเทคโนโลยีที่ทีมส่วนใหญ่เลือกใช้ในวันนี้

ดูรายละเอียด
AI และแมชชีนเลิร์นนิง
·อ่าน 8 นาที

AI และ Machine Learning ในการใช้งานจริง: สร้างคุณค่าทางธุรกิจได้ที่ไหนบ้าง

มองให้ทะลุกระแส เข้าใจว่า Machine Learning และ Generative AI ทำงานอย่างไรจริงๆ สร้างคุณค่าที่วัดผลได้ในงานแบบไหนบ้างในวันนี้ และจะทำโปรเจกต์ AI แรกให้ใช้งานได้จริงอย่างไร แทนที่จะจบแค่เดโม

ดูรายละเอียด