Cắt service thế nào: modular monolith, database-per-service, 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 đề
- Chatty services: 1 request của user = 13 network call. Latency cộng dồn, xác suất lỗi nhân lên: 13 call, mỗi call 99.9% thành công → cả request chỉ ~98.7%.
- Deploy cùng nhau: thêm field
discountphải sửa product, price, order cùng lúc và release đúng thứ tự. - Chung database: price-service đổi tên cột thì order-service (đang JOIN trực tiếp) gãy. Không ai thật sự "sở hữu" dữ liệu.
- Kết quả: một distributed monolith. Có mọi cái khó của hệ phân tán (network, partial failure) mà vẫn dính chặt như monolith.
3. Giải pháp
- Bắt đầu bằng modular monolith: một deployable, nhưng chia module theo nghiệp vụ, module chỉ gọi nhau qua interface public, không chạm bảng của module khác. Khi một module thật sự cần scale/deploy riêng thì tách ra dễ dàng.
- Cắt theo business capability (khả năng nghiệp vụ), không theo tầng kỹ thuật hay theo bảng: Ordering, Payment, Inventory, Catalog, Shipping. DDD gọi là bounded context: trong một context, từ "Product" có một nghĩa thống nhất.
- Data ownership: mỗi dữ liệu có đúng một service chủ. Chỉ chủ được ghi. Service khác đọc qua API hoặc giữ bản sao chỉ đọc cập nhật qua event.
- Database-per-service: mỗi service một schema/DB riêng, không JOIN chéo, không dùng chung bảng.
- Dữ liệu đi cùng hành vi: thứ hay thay đổi cùng nhau thì ở cùng service (price và product catalog gộp vào Catalog).
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
| Monolith | Modular monolith | Microservices | Distributed monolith | |
|---|---|---|---|---|
| Deploy | 1 khối | 1 khối | Độc lập | Phải deploy cùng nhau |
| Ranh giới module | Mờ | Rõ, kiểm soát bằng code | Rõ, bằng network | Có network nhưng không rõ |
| Transaction | ACID local | ACID local | Saga, eventual | Saga mà vẫn coupling |
| Lỗi mạng | Không | Không | Có | Có, nhiều nhất |
| Hợp với | Team nhỏ, sản phẩm mới | Đa số hệ thống | Nhiều team, cần scale/deploy riêng | Không ai muốn |
7. Khi nào nên dùng / không nên dùng
- Nên tách service khi: team riêng sở hữu, nhịp release khác nhau, yêu cầu scale khác hẳn (ví dụ catalog read-heavy), cần cô lập lỗi hoặc bảo mật (payment, PCI).
- Không nên tách khi: team nhỏ, domain chưa rõ, chỉ vì "microservices nghe xịn". Cắt sai sớm khó sửa hơn tách muộn.
- Dấu hiệu cắt sai: hai service luôn release cùng nhau, một use case gọi qua lại nhiều service, cần distributed transaction cho thao tác thường ngày.
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:
- API composition: một service (hoặc BFF/gateway) gọi API các bên rồi ghép trong code. Hợp với màn hình chi tiết, ít dữ liệu.
- Bản sao cục bộ qua event: order-service lưu
customer_namecần thiết, cập nhật khi nhậnCustomerUpdated. Đây là CQRS-lite. - Data warehouse / read model riêng cho báo cáo: CDC (Debezium) hoặc event đổ về một chỗ (ClickHouse, BigQuery, một Postgres reporting). JOIN thoải mái ở đó, không đụng DB nghiệp vụ.
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:
- Sửa một tính năng phải sửa và deploy nhiều service cùng lúc, theo đúng thứ tự.
- Nhiều service dùng chung một database hoặc chung bảng.
- Một request đi qua chuỗi đồng bộ dài (A → B → C → D), service nào chết cũng làm request chết.
- Chia sẻ thư viện chứa domain model chung (
shop-common-entities.jar), đổi một entity phải build lại mọi service. - Không chạy/test được một service mà không dựng cả hệ thống.
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
- Cắt theo capability, không theo bảng hay tầng.
- Mỗi dữ liệu một chủ; người khác đọc qua API/event, không đọc DB.
- Thay đổi cùng nhau thì ở cùng nhau; chatty = cắt sai.
- Modular monolith trước, tách khi có lý do thật.
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.