Mục lục Series | Chương trước: Part 5: Observability Trong Bộ Nhớ → | Chương tiếp theo: Part 7: Mô Hình Trích Xuất (Extraction Pattern) →

📖 Xem bản tiếng Anh trên tanhdev.com: Microservices to Monolith Migration: Strangler Fig

Answer-first: Hợp nhất vi dịch vụ về Modular Monolith sử dụng mẫu hình Reverse Strangler Fig kết hợp ghi kép Dual-Write và đồng bộ schema không thời gian chết. Việc hợp nhất các schema PostgreSQL (billing, inventory) bảo toàn tính độc lập của module và triệt tiêu độ phức tạp của giao dịch phân tán.


1. Mẫu Hình Reverse Strangler Fig: Khi Nào Cần Quay Đầu?

Trong kỹ nghệ phần mềm kinh điển, khi một doanh nghiệp muốn phân rã một hệ thống monolith cũ kỹ thành các vi dịch vụ, họ sử dụng mẫu hình Strangler Fig Pattern (Cây đa siết cổ) của Martin Fowler — bọc lấy hệ thống cũ và chuyển đổi dần từng tính năng ra bên ngoài.

Tuy nhiên, trong giai đoạn 2024–2027, khi hơn 42% doanh nghiệp nhận ra những tổn thất nặng nề về chi phí mạng, sự phức tạp trong quản trị và sự sụt giảm năng suất kỹ sư do microservices gây ra, một làn sóng ngược chiều đã bùng nổ: Reverse Strangler Fig Pattern (Cây đa siết cổ đảo ngược). Đây là kỹ thuật từng bước “nuốt chửng” các microservices phân mảnh và tái hợp nhất chúng vào bên trong một Modular Monolith vững chắc mà hoàn toàn không gây gián đoạn dịch vụ (zero downtime).

flowchart TD
    A["Mạng lưới Microservices Cũ<br/>(140+ Services phân tán)"] -->|"Giai đoạn 1: Dual-Write / CDC"| B[("CSDL Microservice Cũ")]
    A -->|"Giai đoạn 1: Dual-Write / CDC"| C[("Schema CSDL Monolith Mới")]
    C -->|"Giai đoạn 2: Backfill Bất Đồng Bộ"| D["Kiểm tra Khớp Dữ Liệu 14 Ngày (Zero Discrepancy)"]
    D -->|"Giai đoạn 3: Đảo Chiều API Gateway"| E["Modular Monolith Thống Nhất (Single Process)"]
    E -->|"Giai đoạn 4: Dừng Hoạt Động"| F["Xóa Bỏ Container Microservices Cũ & Database Cũ"]

2. Định Luật Conway: Chuẩn Bị Về Mặt Tổ Chức Nhân Sự

Di chuyển mã nguồn chỉ là một nửa bài toán; thách thức lớn nhất của việc hợp nhất microservices nằm ở yếu tố con người và văn hóa tổ chức.

Năm 1968, Melvin Conway đã phát biểu một định luật bất hủ:

“Bất kỳ tổ chức nào thiết kế một hệ thống sẽ tạo ra một thiết kế có cấu trúc là bản sao của cấu trúc giao tiếp của chính tổ chức đó.”

Nếu bạn duy trì các nhóm kỹ sư làm việc tách biệt trong các “ốc đảo” (silos) với những KPI riêng rẽ, việc hợp nhất microservices về Monolith chắc chắn sẽ thất bại do xung đột quyền sở hữu mã nguồn.

2.1 Kế Hoạch Tái Cấu Trúc Đội Ngũ (Organizational Action Plan)

  1. Chuyển từ Micro-Pods sang Domain Macro-Teams: Gom các nhóm nhỏ (ví dụ nhóm phụ trách Payment API, nhóm phụ trách Invoice API) thành một Domain Team lớn duy nhất chịu trách nhiệm toàn diện cho Bounded Context Thanh toán.
  2. Quy Chuẩn Hóa Monorepo Governance: Thiết lập bộ quy tắc đóng góp mã nguồn (Contribution Guidelines) và kích hoạt công cụ quản lý hàng đợi Merge Queue (như GitHub Merge Queue hoặc Bors) trên repository chung trước khi gộp code.
  3. Phân Quyền Bằng CODEOWNERS: Tận dụng file .github/CODEOWNERS để tự động chỉ định quyền review pull request cho từng thư mục module (internal/billing do team Billing duyệt, internal/inventory do team Kho duyệt).

3. Chiến Lược Đồng Bộ CSDL: Log-Based CDC vs Application Dual-Writing

Chuyển đổi logic code mang rủi ro thấp; nhưng sai sót trong quá trình di chuyển cơ sở dữ liệu sẽ dẫn tới thảm họa mất mát dữ liệu khách hàng không thể cứu vãn. Có hai phương pháp chính để đồng bộ dữ liệu giữa database của microservice cũ và schema mới của Monolith:

sequenceDiagram
    autonumber
    actor Client as "Khách hàng (Client Request)"
    participant Gateway as "API Gateway (Canary Weight 10%)"
    participant OldMS as "Microservice Cũ"
    participant OldDB as "CSDL Microservice Cũ"
    participant Monolith as "Modular Monolith Mới"
    participant MonoDB as "PostgreSQL Schema Monolith"
    participant Reconciler as "Reconciliation Cron Worker"

    Client->>Gateway: Gửi Request thanh toán
    alt Canary Route (90% Traffic)
        Gateway->>OldMS: Chuyển tiếp tới Service cũ
        OldMS->>OldDB: Ghi dữ liệu thanh toán
        OldMS-->>Gateway: Trả phản hồi thành công
        OldDB-.->|Debezium CDC Stream| MonoDB: Replicate dữ liệu bất đồng bộ
    else Canary Route (10% Traffic Mới)
        Gateway->>Monolith: Chuyển tiếp tới Module Monolith
        Monolith->>MonoDB: Ghi dữ liệu vào schema billing (Local TX)
        Monolith->>Monolith: Ghi Transactional Outbox Event
        Monolith-->>Gateway: Trả phản hồi thành công
        MonoDB-.->|Reverse CDC / Dual-write| OldDB: Đồng bộ ngược về CSDL cũ
    end

    loop Đối chiếu dữ liệu hàng đêm (14 ngày liên tục)
        Reconciler->>OldDB: Lấy Checksum dữ liệu cũ
        Reconciler->>MonoDB: Lấy Checksum dữ liệu mới
        Reconciler->>Reconciler: Xác minh Discrepancy Count == 0
    end

3.1 Bảng So Sánh Kỹ Thuật: Dual-Write vs Log-Based CDC

Tiêu Chí So SánhApplication-Level Dual-WritingLog-Based CDC (Debezium + Kafka)
Độ phức tạp mã nguồnRất cao (Phải sửa code của cả 2 hệ thống)Rất thấp (Không cần đụng vào code ứng dụng)
Tính an toàn giao dịchNguy cơ ghi một nửa (Partial Write Failure)Đảm bảo tuyệt đối thứ tự log giao dịch (WAL)
Ảnh hưởng độ trễTăng 15ms–35ms do gọi mạng 2 lầnDưới 1ms (Đọc không đồng bộ từ WAL của DB)
Chi phí hạ tầng phụ trợThấp (Tận dụng logic ứng dụng sẵn có)Cần cụm Kafka và Kafka Connect Debezium

3.2 Lộ Trình 4 Giai Đoạn Hợp Nhất Cơ Sở Dữ Liệu

  1. Giai đoạn 1 (Cụm CSDL Biệt Lập): Các microservice hoạt động trên các máy chủ CSDL RDS độc lập.
  2. Giai đoạn 2 (Đồng Bộ CDC 2 Chiều): Thiết lập đường truyền Debezium CDC sao chép dữ liệu thời gian thực từ CSDL cũ sang schema tương ứng trên cụm PostgreSQL của Monolith.
  3. Giai đoạn 3 (PostgreSQL Schemas Biệt Lập): Dữ liệu được gom về cùng một cluster PostgreSQL nhưng nằm trong các schema riêng biệt (billing.*, inventory.*), nghiêm cấm các lệnh SQL JOIN chéo.
  4. Giai đoạn 4 (Single Connection Pool): Ứng dụng Modular Monolith kết nối tới một Connection Pool thống nhất (qua PgBouncer), tối ưu hóa việc tái sử dụng tài nguyên RAM của database.

3.3 Công Thức Đối Chiếu Dữ Liệu 14 Ngày (Reconciliation Math)

Trước khi quyết định chuyển toàn bộ quyền đọc/ghi sang Modular Monolith, hệ thống chạy cronjob đối chiếu hàm băm (checksum hash) trên từng dòng dữ liệu mỗi đêm:

$$\text{Discrepancy Count} = \sum_{i=1}^{N} \left| \text{MD5}(\text{LegacyRow}_i) - \text{MD5}(\text{MonolithRow}_i) \right|$$

Quy tắc bất biến: Chỉ được phép ngắt kết nối microservice cũ khi Discrepancy Count duy trì chính xác bằng 0 trong 14 ngày sản xuất liên tục.


4. Hiện Thực Hóa Go 1.25 Transactional Outbox Worker

Để đảm bảo việc đồng bộ trạng thái và phát sự kiện trong giai đoạn di chuyển diễn ra đáng tin cậy mà không làm tắc nghẽn luồng xử lý HTTP, dưới đây là cài đặt hoàn chỉnh của một Worker xử lý Transactional Outbox bằng Go 1.25:

package main

import (
	"context"
	"database/sql"
	"fmt"
	"sync"
	"time"
)

// OutboxEvent đại diện cho một sự kiện cần đồng bộ được ghi vào CSDL
type OutboxEvent struct {
	ID          int64  `json:"id"`
	AggregateID string `json:"aggregate_id"`
	Topic       string `json:"topic"`
	Payload     string `json:"payload"`
	Status      string `json:"status"`
}

// OutboxWorker quản lý việc quét và phát sự kiện ra ngoài hệ thống
type OutboxWorker struct {
	db         *sql.DB
	eventsChan chan OutboxEvent
}

// NewOutboxWorker khởi tạo worker với kết nối CSDL và hàng đợi có đệm an toàn
func NewOutboxWorker(db *sql.DB) *OutboxWorker {
	return &OutboxWorker{
		db:         db,
		eventsChan: make(chan OutboxEvent, 100),
	}
}

// Run thực thi vòng lặp polling sự kiện định kỳ không gây rò rỉ goroutine
func (w *OutboxWorker) Run(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("[Outbox Worker] Nhận tín hiệu dừng, hoàn tất công việc êm dịu...")
			return

		case event, ok := <-w.eventsChan:
			if !ok {
				return
			}
			// Xử lý phát tán sự kiện tới subscriber hoặc Kafka
			fmt.Printf("[Outbox Dispatcher] Đã gửi thành công Event #%d (Topic: %s): %s\n",
				event.ID, event.Topic, event.Payload)

		case <-ticker.C:
			// Quét các bản ghi chưa xử lý trong CSDL bằng khóa bi quan chống tranh chấp (SKIP LOCKED)
			const pollQuery = `SELECT id, aggregate_id, payload FROM outbox_events WHERE status = 'PENDING' ORDER BY created_at ASC LIMIT 100 FOR UPDATE SKIP LOCKED;`
			if w.db != nil {
				rows, err := w.db.QueryContext(ctx, pollQuery)
				if err != nil {
					fmt.Printf("[Outbox Poller Lỗi] Truy vấn thất bại: %v\n", err)
					continue
				}
				for rows.Next() {
					var ev OutboxEvent
					if err := rows.Scan(&ev.ID, &ev.AggregateID, &ev.Payload); err == nil {
						ev.Topic = "OutboxPolledEvent"
						ev.Status = "PROCESSING"
						select {
						case w.eventsChan <- ev:
						default:
							// Tránh nghẽn nếu channel đầy
						}
					}
				}
				rows.Close()
			}
		}
	}
}

func main() {
	worker := NewOutboxWorker(nil)
	var wg sync.WaitGroup

	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()

	wg.Add(1)
	go worker.Run(ctx, &wg)

	// Mô phỏng việc nạp sự kiện đồng bộ từ một giao dịch đơn hàng mới
	worker.eventsChan <- OutboxEvent{
		ID:          101,
		AggregateID: "ORD-2026-9901",
		Topic:       "OrderMigrated",
		Payload:     `{"order_id": "ORD-2026-9901", "amount": 250000, "status": "CONSOLIDATED"}`,
		Status:      "PENDING",
	}

	worker.eventsChan <- OutboxEvent{
		ID:          102,
		AggregateID: "PAY-8812",
		Topic:       "PaymentMigrated",
		Payload:     `{"payment_id": "PAY-8812", "gateway": "VNPAY", "status": "CAPTURED"}`,
		Status:      "PENDING",
	}

	wg.Wait()
	fmt.Println("Hoàn tất kiểm tra worker di trú dữ liệu an toàn!")
}

5. Kịch Bản SQL Hợp Nhất Schema và Trigger Kiểm Toán Tự Động

Dưới đây là kịch bản SQL chuẩn mực khởi tạo các schema riêng biệt trong cụm PostgreSQL của Monolith, thiết lập bảng thanh toán và bảng outbox kiểm toán được tự động điền dữ liệu bằng trigger:

-- Tạo các schema riêng biệt bên trong cơ sở dữ liệu hợp nhất
CREATE SCHEMA IF NOT EXISTS billing;
CREATE SCHEMA IF NOT EXISTS inventory;

-- Bảng thanh toán thuộc schema billing
CREATE TABLE IF NOT EXISTS billing.payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id VARCHAR(50) NOT NULL,
    amount NUMERIC(15, 2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Bảng Transactional Outbox phục vụ kiểm toán và đồng bộ sự kiện di trú
CREATE TABLE IF NOT EXISTS billing.outbox (
    id BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR(50) NOT NULL,
    aggregate_id VARCHAR(50) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    processed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Chỉ mục tối ưu hóa cho câu lệnh quét sự kiện chưa xử lý
CREATE INDEX IF NOT EXISTS idx_billing_outbox_unprocessed 
ON billing.outbox (id) WHERE processed = FALSE;

-- Hàm trigger tự động ghi sự kiện vào outbox khi có thanh toán mới
CREATE OR REPLACE FUNCTION billing.queue_payment_event()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO billing.outbox (aggregate_type, aggregate_id, event_type, payload)
    VALUES (
        'Payment', 
        NEW.id::text, 
        'PaymentProcessed', 
        json_build_object(
            'order_id', NEW.order_id,
            'amount', NEW.amount,
            'status', NEW.status,
            'migrated_at', clock_timestamp()
        )
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Gắn trigger tự động kích hoạt sau mỗi lệnh INSERT vào billing.payments
DROP TRIGGER IF EXISTS trg_payment_processed ON billing.payments;
CREATE TRIGGER trg_payment_processed
    AFTER INSERT ON billing.payments
    FOR EACH ROW
    EXECUTE FUNCTION billing.queue_payment_event();

6. Chiến Lược Phân Luồng Canary Trên API Gateway (Envoy & NGINX)

Để giảm thiểu rủi ro khi đưa code mới vào sản xuất, các kỹ sư sử dụng cơ chế định tuyến theo trọng số (weighted routing) tại tầng API Gateway:

6.1 Cấu Hình Canary Phân Trọng Số Trên Envoy Gateway

Envoy cho phép chia tỷ lệ phần trăm lưu lượng truy cập giữa cụm microservice cũ và module Monolith mới một cách mượt mà:

# envoy.yaml: Cấu hình điều phối lưu lượng Canary giữa Microservice và Monolith
route_config:
  name: payment_route
  virtual_hosts:
    - name: api_service
      domains: ["api.example.com"]
      routes:
        - match:
            prefix: "/api/v1/payments"
          route:
            weighted_clusters:
              clusters:
                - name: legacy_payment_microservice
                  weight: 90 # 90% lưu lượng tiếp tục phục vụ bởi service cũ
                - name: modular_monolith_payment_module
                  weight: 10 # 10% thử nghiệm trên Modular Monolith

Lộ trình tăng tải chuẩn:

  • Ngày 1 – 3: 5% lưu lượng nội bộ nhân viên và đối tác thân thiết.
  • Ngày 4 – 7: 25% lưu lượng khách hàng ngẫu nhiên.
  • Ngày 8 – 12: 50% lưu lượng và theo dõi chỉ số độ trễ P99.
  • Ngày 14: 100% chuyển mạch hoàn tất.

7. Script Kiểm Tra Đối Chiếu Dữ Liệu Tự Động (Data Reconciliation)

Dưới đây là một đoạn mã script tự động hóa kiểm tra tính đồng nhất của dữ liệu giữa hai cơ sở dữ liệu dựa trên giá trị băm (hash checksum), chạy định kỳ vào 2 giờ sáng mỗi ngày:

import hashlib
import psycopg2

def verify_data_parity():
    # Kết nối tới CSDL Microservice cũ và Schema Monolith mới
    conn_old = psycopg2.connect("postgresql://user:pass@old-db:5432/payments_db")
    conn_new = psycopg2.connect("postgresql://user:pass@monolith-db:5432/app?options=-csearch_path=billing")
    
    cur_old = conn_old.cursor()
    cur_new = conn_new.cursor()
    
    query = "SELECT id, order_id, amount, status FROM payments ORDER BY id LIMIT 10000"
    
    cur_old.execute(query)
    cur_new.execute(query)
    
    rows_old = cur_old.fetchall()
    rows_new = cur_new.fetchall()
    
    discrepancies = 0
    for r_old, r_new in zip(rows_old, rows_new):
        hash_old = hashlib.md5(str(r_old).encode('utf-8')).hexdigest()
        hash_new = hashlib.md5(str(r_new).encode('utf-8')).hexdigest()
        if hash_old != hash_new:
            discrepancies += 1
            print(f"Lệch pha dữ liệu tại ID: {r_old[0]} (Old: {hash_old} != New: {hash_new})")
            
    print(f"Tổng số bản ghi kiểm tra: {len(rows_old)} | Số sai lệch: {discrepancies}")
    assert discrepancies == 0, "Dữ liệu bị sai lệch, tạm dừng quá trình chuyển mạch!"

if __name__ == "__main__":
    verify_data_parity()

8. Quy Trình Rollback Khẩn Cấp Không Thời Gian Chết (Emergency Runbook)

Nếu trong quá trình chuyển mạch lưu lượng (Cutover) xuất hiện lỗi phân kỳ dữ liệu bất thường hoặc độ trễ P99 tăng vọt, đội ngũ SRE kích hoạt ngay Quy trình Rollback 3 bước khẩn cấp:

  1. Đảo Chiều Gateway Tức Thì (0 giây): Gạt cờ tính năng (Feature Flag) trên API Gateway (Envoy/Kong) để chuyển ngay lập tức 100% lưu lượng truy cập quay trở lại các microservice cũ.
  2. Kích Hoạt Kênh CDC Đảo Ngược (Reverse CDC): Kích hoạt luồng Debezium đọc các bản ghi mới phát sinh từ schema Monolith để bù đắp ngược trở lại cơ sở dữ liệu của microservice cũ, đảm bảo không một giao dịch nào của khách hàng bị thất thoát.
  3. Kiểm Tra Bản Ghi Outbox Tồn Đọng: Truy vấn trực tiếp bảng billing.outbox để kiểm tra các sự kiện bị lỗi hoặc chưa kịp xử lý trước khi lên kế hoạch tái chuyển đổi lần hai.

❓ Câu Hỏi Thường Gặp (FAQ)

Mẫu hình Strangler Fig khác gì so với Reverse Strangler Fig?

Mẫu hình Strangler Fig ban đầu được dùng để dần dần trích xuất các tính năng nhỏ ra khỏi một monolith cũ để tạo thành các microservices độc lập. Ngược lại, Reverse Strangler Fig áp dụng quy trình ngược lại: từng bước tiếp nhận và chuyển dời các microservices phân tán quay trở về làm các module nội bộ bên trong một Modular Monolith thống nhất.

Tại sao nên ưu tiên Log-based CDC (Debezium) hơn việc ứng dụng tự ghi kép (Dual-writing)?

Log-based CDC đọc trực tiếp từ nhật ký Write-Ahead Log (WAL) của PostgreSQL nên hoàn toàn không làm tăng độ trễ cho mã nguồn ứng dụng và loại bỏ nguy cơ ghi dở dang (partial write). Nó đảm bảo tính toàn vẹn và thứ tự chính xác của các giao dịch mà không yêu cầu sửa đổi mã nguồn phức tạp trên các microservice cũ.

Làm thế nào việc gom schema PostgreSQL vừa tối ưu hạ tầng vừa giữ vững tính tự chủ của module?

Việc đặt các schema riêng biệt (billing, inventory) trong cùng một cụm PostgreSQL loại bỏ hoàn toàn độ trễ mạng và chi phí duy trì nhiều máy chủ CSDL độc lập. Đồng thời, bằng cách nghiêm cấm việc thiết lập khóa ngoại (Foreign Keys) hay thực thi truy vấn SQL JOIN chéo giữa các schema, ranh giới Bounded Context của Domain-Driven Design vẫn được duy trì nguyên vẹn.

Quy trình Rollback khẩn cấp xử lý các dữ liệu phát sinh trong thời gian chuyển mạch ra sao?

Khi kích hoạt Rollback, API Gateway ngay lập tức chuyển hướng lưu lượng về hệ thống cũ. Song song đó, hệ thống CDC đảo ngược sẽ đọc các giao dịch mới vừa được ghi nhận vào schema Monolith trong khoảng thời gian chuyển mạch và đẩy ngược về CSDL của microservice cũ, bảo toàn tính nhất quán dữ liệu mà không gây gián đoạn cho người dùng.

🔗 Chuyên Đề Tiếp Theo