กลับไปที่บทความ
Architecture Microservices Node.js Kubernetes

การสร้างแพลตฟอร์ม B2B ที่ปรับขนาดได้ด้วย Microservices

พลากร วรมงคล
12 พฤศจิกายน 2567 9 นาที

“หลังจากส่งมอบแพลตฟอร์ม B2B SaaS สามแห่งตั้งแต่เริ่มต้น ฉันพบว่า microservices ไม่ใช่วิธีแก้ปัญหาสากล แต่เมื่อนำไปใช้อย่างถูกต้อง มันช่วยให้ทีมเล็กเดินหน้าได้เร็วโดยไม่ทำให้กันและกัน นี่คือสถาปัตยกรรมคู่มือที่ฉันกลับมาใช้ซ้ำแล้วซ้ำเล่า”

The Monolith Trap

แพลตฟอร์ม B2B ทุกแห่งที่ฉันเคยสืบทอดมา เริ่มต้นจากการเป็น monolith ไม่ใช่การวิจารณ์ monolith ที่มีโครงสร้างดีจะส่งมอบได้เร็วกว่าระบบแบบกระจายใดๆ ในปีแรก ปัญหาเกิดขึ้นในปีที่สอง เมื่อทีมการชำระเงินสามคนต้องการปล่อยรุ่น 6 ครั้งต่อวัน ในขณะที่ทีมรายงานห้าคนยังอยู่ในช่วงกลางของรอบทำงานเพื่อปล่อยประจำไตรมาส ทันใดนั้น คุณก็ประสานงานการปล่อยรุ่นเหมือนการทำสงคราม และทุกการแก้ไขด่วนจะกลายเป็นการสนทนาในระดับคณะผู้บริหาร

ทางออกนี้ไม่ได้หมายถึงการเขียนใหม่ทั้งหมด มันเกี่ยวกับการดึงขอบเขตที่เป็นสิ่งที่เจ็บปวดที่สุด

Identifying Service Boundaries

หลักการกฎหมายของ Conway นั้นจริง สถาปัตยกรรมของคุณจะสะท้อนแผนผังองค์กรของคุณไม่ว่าจะวางแผนหรือไม่ก็ตาม ฉันได้หยุดการต่อสู้กับเรื่องนี้และเริ่มใช้มันโดยเจตนา

ก่อนที่จะวาดขอบเขตบริการใดๆ ฉันจะเรียกใช้คำถามสามข้อกับทีม:

  1. ใครเป็นเจ้าของข้อมูลนี้เฉพาะเจาะจง หากทีมสองทีมโต้แย้งว่าใครเขียนลงในตาราง ตารางนั้นควรอยู่ในบริการข้อมูลที่ใช้ร่วมกัน ไม่ใช่ในตารางแยกกันสองตาราง
  2. ช่วงการเสื่อมราคาของการปล่อยรุ่นที่นี่คืออะไร Billing และ user-auth สมควรได้รับการแยก Static content rendering ไม่ได้
  3. ลักษณะมาตราส่วนคืออะไร บริการสร้าง PDF ที่เพิ่มขึ้นในช่วงสิ้นเดือน มีความต้องการทรัพยากรที่แตกต่างไปจากเกตเวย์ websocket แบบเรียลไทม์อย่างสิ้นเชิง

สำหรับแพลตฟอร์ม B2B ส่วนใหญ่ ฉันจบลงด้วยการแบ่งหลักที่คล้ายกันโดยประมาณ บริการ auth/identity บริการ billing/subscription บริการ core domain (สิ่งที่ผลิตภัณฑ์ทำจริง) บริการ notification และบริการ reporting/analytics ที่อ่านจากกระแสอีเวนต์แทนที่จะเป็น DB หลัก

The Communication Layer

การเลือกระหว่าง synchronous REST/gRPC และ asynchronous event streaming คือจุดที่ทีมส่วนใหญ่ทำผิดพลาดครั้งแรกที่ใหญ่ พวกเขาทุ่มเททั้งหมดให้กับอย่างใดอย่างหนึ่ง

Synchronous calls (ฉันชอบ gRPC สำหรับการสื่อสาร service-to-service ภายใน) จะถูกต้องเมื่อผู้เรียกต้องการการตอบสนองเพื่อดำเนินการต่อ เช่น การสร้างเซสชั่นชำระเงิน Async events เหมาะสำหรับทุกอย่างที่สามารถทนต่อความสอดคล้องที่ยังมา เช่น การส่งอีเมลต้อนรับหรือการอัปเดตตารางรายงานที่ denormalized

รูปแบบที่ฉันใช้คือ saga สำหรับการดำเนินการธุรกิจขั้นตอนหลายขั้นตอนที่ข้ามขอบเขต service ขณะที่ลูกค้าใหม่ลงทะเบียน การไหลไปดังนี้: auth-service สร้างตัวตน → emits user.created → billing-service provisions the free tier → emits subscription.created → notification-service ส่ง welcome email ไม่มี service ใดเรียกใช้ service อื่นโดยตรง แต่ละขั้นตอนสามารถลองใหม่ได้อย่างอิสระ

// Example: Publishing a domain event with metadata
interface DomainEvent<T = unknown> {
  id: string;
  type: string;
  aggregateId: string;
  payload: T;
  occurredAt: string;
  correlationId: string;
}

async function publishEvent<T>(
  topic: string,
  event: Omit<DomainEvent<T>, 'id' | 'occurredAt'>
): Promise<void> {
  const fullEvent: DomainEvent<T> = {
    ...event,
    id: crypto.randomUUID(),
    occurredAt: new Date().toISOString(),
  };
  await messageBus.publish(topic, fullEvent);
}
// Domain event interface and publisher
import java.time.Instant;
import java.util.UUID;

public record DomainEvent<T>(
    String id,
    String type,
    String aggregateId,
    T payload,
    String occurredAt,
    String correlationId
) {}

@Service
public class EventPublisher {
    private final KafkaTemplate<String, Object> kafkaTemplate;

    public <T> void publishEvent(String topic,
            String type, String aggregateId, T payload, String correlationId) {
        var event = new DomainEvent<>(
            UUID.randomUUID().toString(),
            type,
            aggregateId,
            payload,
            Instant.now().toString(),
            correlationId
        );
        kafkaTemplate.send(topic, aggregateId, event);
    }
}
# Domain event dataclass and publisher
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Generic, TypeVar

T = TypeVar("T")

@dataclass
class DomainEvent(Generic[T]):
    type: str
    aggregate_id: str
    payload: T
    correlation_id: str
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    occurred_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )

async def publish_event(topic: str, event_data: DomainEvent) -> None:
    await message_bus.publish(topic, event_data)
// Domain event record and publisher
using MediatR;

public record DomainEvent<T>(
    string Id,
    string Type,
    string AggregateId,
    T Payload,
    string OccurredAt,
    string CorrelationId
);

public class EventPublisher
{
    private readonly IMessageBus _messageBus;

    public EventPublisher(IMessageBus messageBus) => _messageBus = messageBus;

    public async Task PublishEventAsync<T>(
        string topic, string type, string aggregateId,
        T payload, string correlationId)
    {
        var evt = new DomainEvent<T>(
            Id: Guid.NewGuid().ToString(),
            Type: type,
            AggregateId: aggregateId,
            Payload: payload,
            OccurredAt: DateTimeOffset.UtcNow.ToString("O"),
            CorrelationId: correlationId
        );
        await _messageBus.PublishAsync(topic, evt);
    }
}

Deployment and Observability

นี่คือจุดที่ข้อผิดพลาดที่สองเกิดขึ้น: ทีมคิดว่าคุณต้องการเครื่องมือที่ซับซ้อนเพื่อจัดการ microservices คุณไม่ต้อง คุณต้องการการปล่อยรุ่นที่น่าเบื่อ observable และสามารถทำซ้ำได้ Kubernetes เป็นมาตรฐานอุตสาหกรรม แต่คุณสามารถสร้างสิ่งนี้ได้ด้วย Docker Swarm, ECS หรือแม้แต่การปล่อยรุ่นแบบมือเดินได้ตราบเท่าที่คุณมี:

  • Immutable deployments: แต่ละ artifact ถูกสร้าง versioned ทดสอบและส่งเสริม ไม่เคยสร้างใหม่หรือแท็กใหม่
  • Blue-green deploys: ให้ทั้งสองสภาพแวดล้อมทำงาน สลับการไหลของข้อมูลอย่างอะตอม Rollback ใน seconds หากจำเป็น
  • Health checks and graceful shutdown: Services ต้องส่งสัญญาณความพร้อมก่อนรับการไหลของข้อมูลและการระบายส่วนที่มีอยู่ระหว่าง shutdown
  • Logging to stdout: ไม่มีไฟล์ ผลักดัน logs ไปยังระบบรวมศูนย์เช่น DataDog หรือ ELK ให้โทษ 12-factor app manifesto หากใครโต้แย้ง

สำหรับแพลตฟอร์ม B2B ฉันพบว่ารูปแบบง่ายๆ ทำงาน: เรียกใช้ cron job ทุก 5 นาทีเพื่อเปรียบเทียบสถานะที่ต้องการ (กำหนดใน git) กับสถานะจริง (สิ่งที่กำลังทำงาน) หากไม่ตรงกัน ให้ใช้การเปลี่ยนแปลง น่าเบื่อ แต่มันอยู่รอดจากการหยุดชะงักจำนวนมากและการปล่อยรุ่นดึก ๆ โดยไม่มีเหตุการณ์ที่บังเอิญ

Growing Beyond This

เมื่อแพลตฟอร์มของคุณปรับขนาดขึ้น คุณจะเผชิญกับปัญหาใหม่: distributed tracing, rate limiting ข้ามขอบเขต service, circuit breakers และ compensation logic สำหรับ sagas ที่ล้มเหลว สิ่งเหล่านี้สามารถแก้ไขได้ด้วยเฟรมเวิร์ก mature (ฉันชอบ NestJS สำหรับทีม Node.js) แต่มันเป็นปัญหาที่คุณต้องการมี หมายความว่าผลิตภัณฑ์ของคุณเติบโตเร็วพอที่จะเข้าใจ

สถาปัตยกรรมที่ฉันอธิบายจะพาคุณไปถึงตัวเลขเจ็ดหลักในรายได้ประจำปีโดยไม่มีความเครียด เกินกว่านั้น แพลตฟอร์มมักจะแบ่ง: ชั้น API, ตรรมชาติ business logic และชั้น reporting แต่ละอันเป็น “universe” ของตนเอง ที่ปรับขนาดอย่างอิสระ นั่นคือปัญหาในอนาคต สำหรับตอนนี้ ให้มุ่งเน้นที่ความชัดเจนด้านการจัดการและความปลอดภัยในการปล่อยรุ่น

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง