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)
- 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.
- 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.
- 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/billingdo team Billing duyệt,internal/inventorydo 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ánh | Application-Level Dual-Writing | Log-Based CDC (Debezium + Kafka) |
|---|---|---|
| Độ phức tạp mã nguồn | Rấ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ịch | Nguy 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ần | Dướ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
- 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.
- 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.
- 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. - 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:
- Đả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ũ.
- 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.
- 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?
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)?
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?
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?
🔗 Chuyên Đề Tiếp Theo
- Tiếp theo: Part 7: Mô Hình Trích Xuất (Extraction Pattern) – Khi Nào Nên Tách Monolith? — Khung 4 tín hiệu quyết định khi nào nên trích xuất microservice.
- Xem lại: Part 5: Observability Trong Bộ Nhớ – Giám Sát Modular Monolith — Tối ưu hóa giám sát in-process.
- Kỹ thuật CSDL: Pattern Transactional Outbox & Chống Lỗi Ghi Kép — Cẩm nang xử lý nhất quán dữ liệu.
