Cắt service thế nào: modular monolith, database-per-service, distributed monolith

4 câu hỏi~25 phútTừ khóa: modular monolith, business capability, bounded context, data ownership, database-per-service, chatty services, distributed monolith

Tiếp đi: ranh giới service. Mấy bài trước toàn xử lý lỗi khi gọi qua mạng, vậy cắt service sao cho ít phải gọi nhau?

Câu hỏi rất đúng chỗ. Mọi thứ ở 14.1–14.3 (timeout, retry, circuit breaker) là chi phí của việc gọi qua mạng. Cắt service sai thì trả chi phí đó mà không được lợi gì. Lý do chọn microservices và vai trò của Spring Cloud xem Bài 4.6.1; ở đây tập trung vào cắt ở đâu.

1. Bài toán

Team shop-app tách hệ thống theo tầng kỹ thuật / theo bảng:

order-service      product-service     customer-service    price-service
(bảng orders)      (bảng products)     (bảng customers)    (bảng prices)
        \                 |                   |                  /
         └──────────── cùng một PostgreSQL ─────────────────────┘

Tạo một đơn hàng:

order-service
  ├─► customer-service  GET /customers/42        (30ms)
  ├─► product-service   GET /products/7          (30ms)  × 5 sản phẩm
  ├─► price-service     GET /prices/7            (30ms)  × 5 sản phẩm
  ├─► inventory-service POST /reservations       (40ms)
  └─► payment-service   POST /payments           (200ms)

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: Cấu trúc modular monolith (một project Spring Boot):

shop-app/
 └─ src/main/java/com/shop/
     ├─ ordering/      api/ (OrderFacade)   internal/ (entity, repo)
     ├─ catalog/       api/ (CatalogFacade) internal/
     ├─ inventory/     api/                 internal/
     └─ payment/       api/                 internal/
Quy tắc: module chỉ import package api/ của module khác.

Bước 2: Config: mỗi service sau khi tách có DB riêng, credential lấy từ biến môi trường:

# order-service
spring:
  datasource:
    url: jdbc:postgresql://order-db:5432/orders
    username: ${ORDER_DB_USER}
    password: ${ORDER_DB_PASSWORD}

Bước 3: Code Java: Ordering giữ snapshot giá và tên sản phẩm tại thời điểm đặt, không gọi Catalog mỗi lần đọc đơn.

@Entity
public class OrderLine {
    @Id @GeneratedValue Long id;
    String productId;
    String productName;      // snapshot lúc đặt hàng
    BigDecimal unitPrice;    // snapshot: giá đổi sau này không ảnh hưởng đơn cũ
    int quantity;
}

// Bản sao chỉ đọc của Catalog, cập nhật qua event
@Component
public class ProductCacheUpdater {
    private final ProductViewRepository views;
    public ProductCacheUpdater(ProductViewRepository views) { this.views = views; }

    @KafkaListener(topics = "catalog.product-changed")
    public void on(ProductChanged e) {
        views.save(new ProductView(e.productId(), e.name(), e.price(), e.version()));
    }
}

@Service
public class PlaceOrder {
    private final ProductViewRepository views;
    private final InventoryClient inventory;   // critical, đồng bộ
    private final PaymentClient payment;       // critical, đồng bộ
    // ...
    public Order place(PlaceOrderCommand cmd) {
        List<OrderLine> lines = cmd.items().stream()
            .map(i -> views.findById(i.productId())          // đọc local, 0 network call
                    .map(v -> OrderLine.of(v, i.quantity()))
                    .orElseThrow())
            .toList();
        // ... reserve inventory, charge payment (xem 13.2, 13.3)
        return orders.save(Order.of(cmd.customerId(), lines));
    }
}

Bước 4: Test bằng curl

curl -s -X POST localhost:8081/orders -H 'Content-Type: application/json' \
  -d '{"customerId":"42","items":[{"productId":"7","quantity":2}]}'
# trace: order-service → inventory → payment = 2 call thay vì 13

5. Cách hoạt động

Cắt theo bảng (distributed monolith)       Cắt theo capability + data ownership

 order ─► customer                          Catalog ──(ProductChanged)──► Kafka
   ├────► product ×5                                                     │
   ├────► price ×5                          Ordering ◄── bản sao product ─┘
   ├────► inventory                            ├──► Inventory (reserve)
   └────► payment                              └──► Payment (charge)
 13 call, 1 DB chung                         2 call, mỗi service 1 DB

6. So sánh

MonolithModular monolithMicroservicesDistributed monolith
Deploy1 khối1 khốiĐộc lậpPhải deploy cùng nhau
Ranh giới moduleMờRõ, kiểm soát bằng codeRõ, bằng networkCó network nhưng không rõ
TransactionACID localACID localSaga, eventualSaga mà vẫn coupling
Lỗi mạngKhôngKhôngCóCó, nhiều nhất
Hợp vớiTeam nhỏ, sản phẩm mớiĐa số hệ thốngNhiều team, cần scale/deploy riêngKhông ai muốn

7. Khi nào nên dùng / không nên dùng

Database-per-service thì báo cáo cần JOIN dữ liệu của nhiều service làm sao? Cho order-service đọc thẳng DB của customer-service một chút có sao không?

Có sao. Đọc thẳng DB service khác là cửa sau làm mất data ownership: customer-service đổi schema thì order-service gãy, mà customer-service không hề biết ai đang đọc mình. Các cách đúng:

orders DB ──CDC/event──┐
customers DB ──────────┼──► reporting DB  ── SELECT ... JOIN ... GROUP BY
payments DB ───────────┘   (eventual, trễ vài giây đến vài phút)

Cái giá là dữ liệu báo cáo eventual, trễ một chút. Với báo cáo thì thường chấp nhận được. Chủ đề consistency này là bài sau.

Làm sao biết hệ thống mình đã thành distributed monolith rồi?

Đúng, đây là câu hay bị hỏi kèm. Một số dấu hiệu dễ thấy:

Cách thoát: gộp những service luôn đi cùng nhau thành một, chuyển phụ thuộc đồng bộ sang event cho phần non-critical, tách DB theo chủ sở hữu, chỉ chia sẻ contract (API, event schema) chứ không chia sẻ entity.

Phỏng vấn hỏi "chia microservices theo tiêu chí nào?" thì trả lời sao?

Q1. Bạn chia ranh giới service theo tiêu chí nào?

🇻🇳 Tiếng Việt:

Em chia theo business capability hoặc bounded context, không theo tầng kỹ thuật hay theo bảng. Mỗi service sở hữu dữ liệu của nó và là nơi duy nhất được ghi dữ liệu đó. Những thứ thay đổi cùng nhau và cần nhất quán mạnh với nhau thì để cùng service. Mục tiêu là một use case thường ngày chỉ cần ít call đồng bộ và không cần distributed transaction.

🇬🇧 English:

I split by business capability or bounded context, not by technical layer or by table. Each service owns its data and is the only one allowed to write it. Things that change together and need strong consistency stay in the same service. The goal is that a typical use case needs few synchronous calls and no distributed transaction.

Q2. Database-per-service là gì, vì sao cần?

🇻🇳 Tiếng Việt:

Mỗi service có schema hoặc database riêng, service khác chỉ truy cập qua API hoặc event. Nhờ vậy service có thể đổi schema và deploy độc lập, và lỗi hay tải ở DB của một service không lan sang service khác. Cái giá là không JOIN chéo và không có ACID transaction chung, nên phải dùng API composition, bản sao qua event, Saga, và chấp nhận eventual consistency.

🇬🇧 English:

Each service has its own schema or database, and others access that data only through its API or events. This lets the service change its schema and deploy independently, and keeps one database's load or failures from affecting others. The trade-off is no cross-service joins and no shared ACID transactions, so we use API composition, event-driven replicas and sagas, and accept eventual consistency.

Q3. Distributed monolith là gì? Modular monolith là gì?

🇻🇳 Tiếng Việt:

Distributed monolith là hệ thống tách thành nhiều service nhưng vẫn coupling chặt: chung database, phải deploy cùng nhau, gọi đồng bộ dây chuyền. Nó có mọi nhược điểm của hệ phân tán mà không có lợi ích. Modular monolith thì ngược lại: một deployable nhưng chia module rõ theo nghiệp vụ, thường là điểm khởi đầu tốt trước khi tách microservices.

🇬🇧 English:

A distributed monolith is split into services that are still tightly coupled: a shared database, lock-step deployments and long synchronous call chains. It has all the costs of a distributed system and none of the benefits. A modular monolith is the opposite: a single deployable with clear, business-aligned modules, and it is often the right starting point before extracting microservices.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao tách service theo bảng thường dẫn tới chatty services?

Một use case cần dữ liệu từ nhiều bảng, giờ mỗi bảng là một service nên phải gọi mạng nhiều lần.

13 call, mỗi call thành công 99.9%. Tỷ lệ thành công cả request?

0.999^13 ≈ 98.7%: càng nhiều call đồng bộ càng dễ lỗi.

Vì sao OrderLine lưu snapshot giá?

Đơn hàng cần giá tại thời điểm đặt, và đọc đơn không phải gọi Catalog.

Báo cáo cần JOIN dữ liệu nhiều service thì làm gì?

API composition, bản sao qua event, hoặc đổ dữ liệu (CDC/event) về reporting DB/warehouse.

Nêu 3 dấu hiệu của distributed monolith.

Chung DB; phải deploy cùng nhau; chuỗi gọi đồng bộ dài; chia sẻ domain entity chung.