Microservices là gì? So sánh Monolithic và Microservices
Xem thửMột đội có một ứng dụng bán hàng chạy ổn nhưng build mất hai mươi phút và mỗi lần phát hành đều làm cả nhóm lo. Họ quyết định tách thành tám dịch vụ. Sáu tháng sau, thời gian phát hành của từng dịch vụ ngắn hơn thật, nhưng trang thanh toán chậm gấp đôi, đội vận hành phải theo dõi tám tiến trình thay vì một, và kế toán thấy vài đơn hàng đã trừ kho mà không có đơn.
Không điều nào trong số đó là bất ngờ nếu bạn đo trước. Bài này đặt cùng một nghiệp vụ (đặt hàng: trừ kho rồi lưu đơn) vào hai kiến trúc và đo: bộ nhớ, thời gian khởi động, độ trễ, điều xảy ra khi một bước lỗi giữa chừng, và điều xảy ra khi một dịch vụ dừng. Mục đích không phải để bảo bạn "đừng dùng microservices", mà để bạn biết chính xác mình trả giá gì và nhận lại gì.
Sau bài này, bạn có thể
- Mô tả nguyên khối và microservices bằng các thuộc tính đo được thay vì khẩu hiệu.
- Nêu bốn cái giá cụ thể của việc tách: tài nguyên, độ trễ, tính nhất quán, vận hành.
- Giải thích vì sao "cô lập lỗi" chỉ đúng một phần, bằng kết quả đo.
- Dùng một danh sách câu hỏi để quyết định có nên tách hay không.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Boot 4.1.1). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 14-microservices/01-monolith-va-microservices, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Spring Boot | 4.1.1 | khai báo ở parent trong pom.xml |
| Bộ nhớ trống | khoảng 1 GB | ba tiến trình Java chạy cùng lúc |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
01-monolith-va-microservices/
pom.xml parent Boot 4.1.1, webmvc, jdbc, restclient, H2
src/main/java/dev/canhdominich/micro/
monolith/MonolithApplication.java nguyên khối: bảng stock và orders cùng một cơ sở dữ liệu
monolith/ShopService.java placeOrder trong một giao dịch @Transactional
monolith/ShopController.java POST /orders, GET /stock/{sku}, GET /orders/count
inventory/InventoryApplication.java dịch vụ tồn kho với cơ sở dữ liệu riêng
inventory/InventoryController.java POST /reserve, GET /stock/{sku}
order/OrderApplication.java dịch vụ đơn hàng với cơ sở dữ liệu riêng
order/OrderController.java POST /orders gọi inventory rồi lưu đơn, GET /orders/count
ComparisonDemo.java khởi chạy từng tiến trình và đo
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven dùng spring-boot-starter-parent 4.1.1 (chương này không cần Spring Cloud). Phụ thuộc: spring-boot-starter-webmvc cho các dịch vụ HTTP, spring-boot-starter-jdbc (JdbcTemplate và giao dịch), spring-boot-starter-restclient (cung cấp bean RestClient.Builder để gọi dịch vụ khác), H2 làm cơ sở dữ liệu trong bộ nhớ của từng tiến trình (không cần Docker), và spring-boot-starter-test. Cả ba ứng dụng (monolith, inventory, order) nằm trong một dự án Maven cho gọn nhưng chạy thành các tiến trình riêng. Plugin exec-maven-plugin chạy ComparisonDemo bằng mvn -q compile exec:exec.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>micro-monolith-vs-microservices</artifactId>
<version>1.0.0</version>
<name>Microservices - Monolith va Microservices</name>
<properties>
<java.version>17</java.version>
<main.class>dev.canhdominich.micro.ComparisonDemo</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Các thành phần của bài
Bài có ba ứng dụng Spring Boot trong cùng một dự án Maven nhưng chạy thành các tiến trình riêng: một nguyên khối (monolith) và hai dịch vụ (inventory, order) thực hiện cùng nghiệp vụ.
MonolithApplication tạo hai bảng stock (10000 cuốn BOOK, đủ cho cả bài) và orders trong cùng một cơ sở dữ liệu H2:
src/main/java/dev/canhdominich/micro/monolith/MonolithApplication.java
package dev.canhdominich.micro.monolith;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
@SpringBootApplication
public class MonolithApplication {
@Bean
ApplicationRunner schema(JdbcTemplate jdbc) {
return args -> {
jdbc.execute("create table stock (sku varchar(20) primary key, quantity int not null)");
jdbc.execute("create table orders (id bigint auto_increment primary key, sku varchar(20) not null, quantity int not null)");
jdbc.update("insert into stock values ('BOOK', 10000)");
};
}
public static void main(String[] args) {
SpringApplication.run(MonolithApplication.class, args);
}
}
ShopService.placeOrder làm cả hai việc trong một phương thức @Transactional: trừ kho bằng một câu update ... where quantity >= ? rồi thêm một dòng vào orders. Tham số failAfterReserve ném lỗi sau hai bước đó, để mô phỏng một sự cố giữa chừng:
src/main/java/dev/canhdominich/micro/monolith/ShopService.java
package dev.canhdominich.micro.monolith;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ShopService {
private final JdbcTemplate jdbc;
public ShopService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void placeOrder(String sku, int quantity, boolean failAfterReserve) {
int updated = jdbc.update("update stock set quantity = quantity - ? where sku = ? and quantity >= ?", quantity, sku, quantity);
if (updated == 0) {
throw new IllegalStateException("out of stock");
}
jdbc.update("insert into orders (sku, quantity) values (?, ?)", sku, quantity);
if (failAfterReserve) {
throw new IllegalStateException("simulated failure after the stock was reserved");
}
}
public int stock(String sku) {
return jdbc.queryForObject("select quantity from stock where sku = ?", Integer.class, sku);
}
public int orderCount() {
return jdbc.queryForObject("select count(*) from orders", Integer.class);
}
}
ShopController mở ba đầu vào: POST /orders, GET /stock/{sku} và GET /orders/count:
src/main/java/dev/canhdominich/micro/monolith/ShopController.java
package dev.canhdominich.micro.monolith;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ShopController {
private final ShopService shop;
public ShopController(ShopService shop) {
this.shop = shop;
}
@PostMapping("/orders")
public String order(@RequestParam String sku, @RequestParam int quantity, @RequestParam(defaultValue = "false") boolean failAfterReserve) {
shop.placeOrder(sku, quantity, failAfterReserve);
return "ordered";
}
@GetMapping("/stock/{sku}")
public String stock(@PathVariable String sku) {
return String.valueOf(shop.stock(sku));
}
@GetMapping("/orders/count")
public String count() {
return String.valueOf(shop.orderCount());
}
}
Bây giờ là bản microservices. InventoryApplication có cơ sở dữ liệu riêng chỉ chứa stock:
src/main/java/dev/canhdominich/micro/inventory/InventoryApplication.java
package dev.canhdominich.micro.inventory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
@SpringBootApplication
public class InventoryApplication {
@Bean
ApplicationRunner schema(JdbcTemplate jdbc) {
return args -> {
jdbc.execute("create table stock (sku varchar(20) primary key, quantity int not null)");
jdbc.update("insert into stock values ('BOOK', 10000)");
};
}
public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}
}
InventoryController.reserve trừ kho bằng cùng câu update và trả 409 nếu hết hàng. Mỗi lời gọi là một giao dịch tự động commit của riêng dịch vụ này:
src/main/java/dev/canhdominich/micro/inventory/InventoryController.java
package dev.canhdominich.micro.inventory;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class InventoryController {
private final JdbcTemplate jdbc;
public InventoryController(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@PostMapping("/reserve")
public String reserve(@RequestParam String sku, @RequestParam int quantity) {
int updated = jdbc.update("update stock set quantity = quantity - ? where sku = ? and quantity >= ?", quantity, sku, quantity);
if (updated == 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "out of stock");
}
return "reserved";
}
@GetMapping("/stock/{sku}")
public String stock(@PathVariable String sku) {
return String.valueOf(jdbc.queryForObject("select quantity from stock where sku = ?", Integer.class, sku));
}
}
OrderApplication có cơ sở dữ liệu riêng chỉ chứa orders:
src/main/java/dev/canhdominich/micro/order/OrderApplication.java
package dev.canhdominich.micro.order;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
@SpringBootApplication
public class OrderApplication {
@Bean
ApplicationRunner schema(JdbcTemplate jdbc) {
return args -> jdbc.execute("create table orders (id bigint auto_increment primary key, sku varchar(20) not null, quantity int not null)");
}
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
OrderController.order gọi POST /reserve của inventory bằng RestClient, rồi mới lưu đơn. Với failAfterReserve, lỗi được ném sau lời gọi trừ kho và trước khi lưu đơn. Hai cơ sở dữ liệu không chung giao dịch nên không có gì hoàn tác được bước đã xong:
src/main/java/dev/canhdominich/micro/order/OrderController.java
package dev.canhdominich.micro.order;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
public class OrderController {
private final JdbcTemplate jdbc;
private final RestClient inventory;
public OrderController(JdbcTemplate jdbc, RestClient.Builder builder, @Value("${inventory.url}") String inventoryUrl) {
this.jdbc = jdbc;
this.inventory = builder.baseUrl(inventoryUrl).build();
}
@PostMapping("/orders")
public String order(@RequestParam String sku, @RequestParam int quantity, @RequestParam(defaultValue = "false") boolean failAfterReserve) {
inventory.post().uri("/reserve?sku={sku}&quantity={quantity}", sku, quantity).retrieve().toBodilessEntity();
if (failAfterReserve) {
throw new IllegalStateException("simulated failure after the stock was reserved");
}
jdbc.update("insert into orders (sku, quantity) values (?, ?)", sku, quantity);
return "ordered";
}
@GetMapping("/orders/count")
public String count() {
return String.valueOf(jdbc.queryForObject("select count(*) from orders", Integer.class));
}
}
ComparisonDemo khởi động từng ứng dụng thành một tiến trình Java riêng (cùng -Xmx128m -XX:+UseSerialGC), đo bộ nhớ thật của tiến trình bằng /proc/{pid}/status (dòng VmRSS), thời gian cho tới lúc sẵn sàng, độ trễ trung vị của 300 lần đặt hàng, rồi chạy kịch bản lỗi và kịch bản dừng dịch vụ:
src/main/java/dev/canhdominich/micro/ComparisonDemo.java
package dev.canhdominich.micro;
import dev.canhdominich.micro.inventory.InventoryApplication;
import dev.canhdominich.micro.monolith.MonolithApplication;
import dev.canhdominich.micro.order.OrderApplication;
import java.io.File;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ComparisonDemo {
private static final HttpClient HTTP = HttpClient.newHttpClient();
public static void main(String[] args) throws Exception {
run().forEach(System.out::println);
System.exit(0);
}
public static List<String> run() throws Exception {
List<String> lines = new ArrayList<>();
List<Node> nodes = new ArrayList<>();
try {
long t0 = System.nanoTime();
Node monolith = launch(nodes, MonolithApplication.class, "/stock/BOOK");
double monolithStart = (System.nanoTime() - t0) / 1e9;
long t1 = System.nanoTime();
Node inventory = launch(nodes, InventoryApplication.class, "/stock/BOOK");
Node order = launch(nodes, OrderApplication.class, "/orders/count", "--inventory.url=http://localhost:" + inventory.port);
double microStart = (System.nanoTime() - t1) / 1e9;
for (int i = 0; i < 30; i++) {
post(monolith.port, "/orders?sku=BOOK&quantity=1");
post(order.port, "/orders?sku=BOOK&quantity=1");
}
lines.add("-- cost of running it");
lines.add("processes: monolith 1, microservices 2");
lines.add("memory after warm-up: monolith " + round10(rssMb(monolith)) + " MB, microservices " + round10(rssMb(inventory) + rssMb(order)) + " MB");
lines.add("time until ready: monolith " + roundHalf(monolithStart) + " s, microservices (started one after the other) " + roundHalf(microStart) + " s");
lines.add("-- cost of every call");
long[] mono = timings(monolith.port, 300);
long[] micro = timings(order.port, 300);
lines.add("median of 300 orders: monolith " + mono[0] + " ms, microservices " + micro[0] + " ms");
lines.add("one extra network hop makes the median slower: " + (micro[0] > mono[0]));
lines.add("-- an order that fails after the stock was reserved");
int monoStockBefore = Integer.parseInt(get(monolith.port, "/stock/BOOK"));
int monoOrdersBefore = Integer.parseInt(get(monolith.port, "/orders/count"));
int monoStatus = post(monolith.port, "/orders?sku=BOOK&quantity=5&failAfterReserve=true");
lines.add("monolith: status " + monoStatus + ", stock change " + (Integer.parseInt(get(monolith.port, "/stock/BOOK")) - monoStockBefore)
+ ", orders change " + (Integer.parseInt(get(monolith.port, "/orders/count")) - monoOrdersBefore));
int microStockBefore = Integer.parseInt(get(inventory.port, "/stock/BOOK"));
int microOrdersBefore = Integer.parseInt(get(order.port, "/orders/count"));
int microStatus = post(order.port, "/orders?sku=BOOK&quantity=5&failAfterReserve=true");
lines.add("microservices: status " + microStatus + ", stock change " + (Integer.parseInt(get(inventory.port, "/stock/BOOK")) - microStockBefore)
+ ", orders change " + (Integer.parseInt(get(order.port, "/orders/count")) - microOrdersBefore));
lines.add("-- inventory service stops");
inventory.process.destroyForcibly();
inventory.process.waitFor();
lines.add("order service, placing an order: status " + post(order.port, "/orders?sku=BOOK&quantity=1"));
lines.add("order service, reading its own data: status " + status(order.port, "/orders/count"));
} finally {
for (Node node : nodes) {
node.process.destroyForcibly();
}
}
return lines;
}
private static long[] timings(int port, int count) throws Exception {
long[] values = new long[count];
for (int i = 0; i < count; i++) {
long start = System.nanoTime();
post(port, "/orders?sku=BOOK&quantity=1");
values[i] = (System.nanoTime() - start) / 1000;
}
Arrays.sort(values);
return new long[] {Math.round(values[count / 2] / 1000.0), Math.round(values[(int) (count * 0.95)] / 1000.0)};
}
private static long rssMb(Node node) throws Exception {
for (String line : Files.readAllLines(Path.of("/proc/" + node.process.pid() + "/status"))) {
if (line.startsWith("VmRSS:")) {
return Long.parseLong(line.replaceAll("\\D+", "")) / 1024;
}
}
return -1;
}
private static long round10(long value) {
return Math.round(value / 10.0) * 10;
}
private static String roundHalf(double seconds) {
return String.valueOf(Math.round(seconds * 2) / 2.0);
}
private static Node launch(List<Node> nodes, Class<?> main, String readyPath, String... extra) throws Exception {
int port;
try (ServerSocket socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}
List<String> command = new ArrayList<>(List.of(
System.getProperty("java.home") + "/bin/java", "-Xmx128m", "-XX:+UseSerialGC",
"-classpath", System.getProperty("java.class.path"), main.getName(),
"--server.port=" + port, "--spring.main.banner-mode=off", "--logging.level.root=WARN"));
command.addAll(List.of(extra));
File logs = new File("target/logs");
logs.mkdirs();
Process process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(new File(logs, main.getSimpleName() + ".log")).start();
Node node = new Node(process, port);
nodes.add(node);
long deadline = System.currentTimeMillis() + 90_000;
while (System.currentTimeMillis() < deadline) {
try {
if (status(port, readyPath) == 200) {
return node;
}
} catch (Exception ignored) {
Thread.sleep(100);
}
Thread.sleep(100);
}
throw new IllegalStateException(main.getSimpleName() + " did not start, see target/logs");
}
private static String get(int port, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).GET().build(), HttpResponse.BodyHandlers.ofString()).body();
}
private static int status(int port, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode();
}
private static int post(int port, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.discarding()).statusCode();
}
record Node(Process process, int port) {
}
}
Chạy bằng mvn -q compile exec:exec (khoảng 40 giây). Kết quả của một lần chạy (bộ nhớ làm tròn tới 10 MB, thời gian khởi động tới 0,5 giây):
@@OUTPUT@@
Cái giá thứ nhất: tài nguyên
Cách đọc hình: hàng trên là nguyên khối, hàng dưới là microservices, cùng máy và cùng tùy chọn JVM. Mỗi cột đặt hai con số cạnh nhau để so.
Kết quả của một lần chạy: nguyên khối dùng khoảng 200 MB, hai dịch vụ dùng khoảng 440 MB cộng lại, vì mỗi tiến trình Java tự mang theo JVM, Spring và Tomcat của nó. Thời gian sẵn sàng là khoảng 6 giây so với khoảng 14,5 giây khi khởi động hai dịch vụ lần lượt. Hai dịch vụ này gần như trống (chỉ một bảng mỗi dịch vụ): chi phí cố định theo tiến trình, không theo nghiệp vụ, nên với tám dịch vụ nó nhân lên tám lần. Con số tuyệt đối phụ thuộc máy bạn; thứ tự (microservices tốn hơn) thì ổn định và là điều bộ test của lab khẳng định.
Cái giá thứ hai: độ trễ
Trung vị của 300 lần đặt hàng là khoảng 5 ms ở nguyên khối và khoảng 10 ms ở microservices: thêm một bước nhảy HTTP làm mỗi lần đặt hàng chậm hơn khoảng gấp đôi trong bài này. Nguyên nhân là một lời gọi hàm trong tiến trình được thay bằng một lời gọi mạng: dữ liệu phải được tuần tự hóa, đi qua tầng mạng và bên kia phải xử lý một yêu cầu HTTP đầy đủ. Với một chuỗi mười dịch vụ gọi nhau đồng bộ, các khoản này cộng dồn lại; đó là lý do thiết kế đường gọi ngắn và đo từng bước nhảy.
Cái giá thứ ba: tính nhất quán
Cách đọc hình: hàng trên là nguyên khối: mọi bảng ở một nơi nên một câu JOIN và một giao dịch bao được tất cả. Hàng dưới là microservices: mỗi dịch vụ sở hữu dữ liệu của mình, muốn dữ liệu của dịch vụ khác phải gọi API hoặc giữ một bản sao, và không còn giao dịch chung.
Quy tắc "mỗi dịch vụ một cơ sở dữ liệu" là thứ cho các đội tự do đổi cấu trúc dữ liệu mà không phá nhau, nhưng đó cũng là nguồn của kết quả sau:
Cách đọc hình: hàng trên: nguyên khối, một giao dịch, lỗi làm hoàn tác cả hai thay đổi. Hàng dưới: hai dịch vụ, tồn kho đã commit trước khi order lỗi nên không ai hoàn tác nó.
Hai dòng kết quả của bài: monolith: status 500, stock change 0, orders change 0 và microservices: status 500, stock change -5, orders change 0. Cả hai đều trả 500 cho người dùng, nhưng ở microservices có 5 cuốn sách bị giữ cho một đơn không tồn tại. Chỉ @Transactional mới làm nên sự khác biệt: bỏ nó khỏi nguyên khối thì kết quả là stock change -5, orders change 1 (mỗi câu lệnh tự commit, nên cả việc trừ kho lẫn dòng đơn được giữ lại dù người dùng nhận lỗi); vì vậy ở nguyên khối sự nhất quán đến từ giao dịch chứ không phải từ việc "ở trong một ứng dụng". Mỗi lần lỗi lặp lại ở microservices làm mất thêm 5 cuốn (ba lần lỗi cho stock change -15).
Đang tải Animation…
Cái giá thứ tư, và lợi ích có thật: lỗi lan và lỗi cô lập
Cách đọc hình: dịch vụ tồn kho bị dừng đột ngột. Đặt hàng (cần tồn kho) lỗi 500; đọc dữ liệu đơn hàng của chính order-service vẫn trả 200.
Kết quả đo: order service, placing an order: status 500 và order service, reading its own data: status 200. Đây là lợi ích có thật: một dịch vụ hỏng không kéo theo toàn bộ hệ thống, các chức năng không cần nó vẫn sống. Nhưng đó cũng là giới hạn: chức năng quan trọng nhất (đặt hàng) vẫn hỏng vì nó cần dịch vụ đã dừng. "Lỗi được cô lập" chỉ đúng với phần không phụ thuộc vào dịch vụ lỗi; muốn đặt hàng sống cả khi tồn kho hỏng bạn phải thiết kế riêng (hàng đợi, đặt trước lạc quan, circuit breaker như chương Spring Cloud).
Khi nào nên tách, khi nào không
Bốn cái giá trên không có nghĩa là không nên tách; chúng nghĩa là cần một lý do đủ mạnh. Một danh sách câu hỏi thực dụng:
| Câu hỏi | Nghiêng về tách khi | Nghiêng về giữ nguyên khối khi |
|---|---|---|
| Có nhiều đội làm việc cùng một mã không? | Các đội giẫm lên nhau, phát hành phải phối hợp | Một đội nhỏ nắm toàn bộ |
| Các phần có nhu cầu mở rộng khác nhau không? | Một phần cần 20 bản, phần khác chỉ 1 | Tải khá đều |
| Ranh giới nghiệp vụ có rõ không? | Dữ liệu và quy tắc chia thành các khối tách bạch | Ranh giới còn đang thay đổi hằng tuần |
| Có thể chấp nhận nhất quán cuối cùng không? | Có, và có thiết kế bù trừ | Cần giao dịch chặt giữa các phần |
| Có năng lực vận hành không? | Có CI/CD, giám sát, theo dõi phân tán | Chưa có |
Một lựa chọn trung gian đáng cân nhắc là nguyên khối theo module: giữ một ứng dụng nhưng chia mã thành các module có ranh giới rõ. Bạn có được kỷ luật về ranh giới mà chưa trả các cái giá ở trên, và nếu sau này cần tách thì ranh giới đã sẵn. Bài này không đo lựa chọn đó.
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Kiến trúc | Nguyên khối | Đơn giản, giao dịch chung, phát hành cả khối |
| Kiến trúc | Microservices | Phát hành độc lập, mở rộng riêng, nhưng trả bốn cái giá đã đo |
| Dữ liệu | Một cơ sở dữ liệu chung | Dễ nhất quán nhưng các nhóm dính vào nhau qua lược đồ |
| Dữ liệu | Mỗi dịch vụ một cơ sở dữ liệu | Tự chủ nhưng không JOIN, không giao dịch chung |
| Giao tiếp | Đồng bộ qua HTTP | Đơn giản nhưng kéo lỗi và độ trễ theo |
| Ranh giới | Cắt theo nghiệp vụ | Ít gọi chéo hơn cắt theo tầng kỹ thuật |
Playbook: dấu hiệu phải xem lại việc tách
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| Trang chậm hơn hẳn sau khi tách | Số lời gọi liên dịch vụ trên một yêu cầu | Chuỗi gọi dài, gọi đồng bộ | Gộp, gọi song song, lưu bản sao cần thiết |
| Dữ liệu lệch giữa hai dịch vụ | Có bước nào đã commit trước khi lỗi không | Không có giao dịch chung | Thiết kế bù trừ và thử lại an toàn (bài sau) |
| Một dịch vụ chết kéo cả hệ thống | Lời gọi có timeout và ngắt mạch không | Gọi đồng bộ không bảo vệ | Timeout, circuit breaker, fallback |
| Mọi lần phát hành vẫn phải phối hợp nhiều dịch vụ | Hợp đồng giữa các dịch vụ | Ranh giới cắt sai, hợp đồng đổi liên tục | Xem lại ranh giới; cân nhắc gộp lại |
| Chi phí hạ tầng tăng nhanh | Bộ nhớ và số bản mỗi dịch vụ | Mỗi dịch vụ một JVM | Gộp dịch vụ nhỏ, dùng tối ưu bộ nhớ |
| Không tìm được nguyên nhân lỗi | Có mã theo dõi xuyên dịch vụ không | Thiếu tracing | Bật theo dõi phân tán (chương Spring Cloud) |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
did not start, see target/logs | Một tiến trình không khởi động trong 90 giây (máy chậm hoặc cổng bị chặn) | Mở target/logs/<tên>.log để xem lỗi, rồi chạy lại |
| Con số độ trễ hoặc bộ nhớ khác bài | Máy, số lõi và bộ nhớ khác nhau | So thứ tự giữa hai cách chứ không so con số tuyệt đối |
Bài thực hành
Làm trong dự án của bài (thư mục 14-microservices/01-monolith-va-microservices). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Chạy thí nghiệm.
mvn -q compile exec:exec. Tiêu chí:monolith: status 500, stock change 0, orders change 0vàmicroservices: status 500, stock change -5, orders change 0; bộ nhớ của microservices lớn hơn nguyên khối. - Lặp lại lỗi. Gọi thêm hai lần nữa kịch bản lỗi ở microservices. Tiêu chí: tổng
stock changelà -15 vàorders changevẫn 0; giải thích vì sao lỗi tích lũy. - Bỏ giao dịch. Xóa
@TransactionalkhỏiShopService.placeOrder. Tiêu chí: nguyên khối chostock change -5, orders change 1; viết hai câu về điều đó nói gì về nguồn gốc của tính nhất quán. - Dừng dịch vụ khác. Chạy riêng order-service mà không có inventory (hoặc dừng inventory như
ComparisonDemo). Tiêu chí:POST /orderstrả 500 vàGET /orders/counttrả 200. - Chạy bộ test (tùy chọn). Sau khi clone kho mã,
mvn testchoTests run: 6, Failures: 0, Errors: 0. Dọn dẹp bằngmvn clean.
Hiểu lầm và giới hạn
- "Microservices luôn mở rộng tốt hơn." Chúng cho phép mở rộng riêng từng phần, nhưng mỗi bản tốn bộ nhớ riêng (đo được khoảng 200 MB cho một ứng dụng Spring Boot tối thiểu trong cấu hình bài).
- "Cô lập lỗi nghĩa là hệ thống vẫn chạy." Chỉ phần không phụ thuộc vào dịch vụ lỗi.
- "Dữ liệu vẫn nhất quán nếu mọi dịch vụ đều đúng." Mỗi dịch vụ đều đúng mà dữ liệu vẫn lệch, như kết quả đo cho thấy.
- "Chậm hơn một chút không đáng kể." Với chuỗi gọi dài, độ trễ cộng dồn.
- Số đo thuộc về Spring Boot 4.1.1, JDK 17, H2 trong bộ nhớ, ba tiến trình trên một máy bốn lõi,
-Xmx128m -XX:+UseSerialGC; con số tuyệt đối thay đổi theo máy. Hai dịch vụ của bài rất đơn giản; hệ thống thật có nhiều bảng và nhiều thư viện hơn nên bộ nhớ lớn hơn. Bài chưa đo mở rộng theo tải, triển khai độc lập, nguyên khối theo module, hay chi phí của người (tổ chức, quy trình).
Tự kiểm tra
- Nêu bốn cái giá của microservices và con số đo được cho hai cái đầu.
- Vì sao nguyên khối nhất quán còn hai dịch vụ thì lệch trong cùng kịch bản lỗi?
- Bỏ
@Transactionalkhỏi nguyên khối thì điều gì xảy ra, và điều đó nói gì? - Vì sao đặt hàng vẫn lỗi dù "lỗi được cô lập"?
- Nêu ba câu hỏi bạn sẽ hỏi trước khi quyết định tách một nguyên khối.
Gợi ý đáp án. (1) Tài nguyên (khoảng 200 MB so với 440 MB), độ trễ (khoảng 5 ms so với 10 ms), cộng nhất quán và vận hành. (2) Nguyên khối dùng một giao dịch hoàn tác cả hai; hai dịch vụ có hai cơ sở dữ liệu nên bước đã commit không hoàn tác. (3) Kho -5 và đơn +1; nhất quán đến từ giao dịch, không từ việc cùng một ứng dụng. (4) Đặt hàng cần tồn kho; chỉ chức năng không cần dịch vụ lỗi mới sống. (5) Ví dụ: có nhiều đội giẫm chân nhau không, ranh giới nghiệp vụ đã rõ chưa, có năng lực vận hành và theo dõi chưa.
Điều cần nhớ
- Tách ra thì trả bốn cái giá: tài nguyên, độ trễ, nhất quán, vận hành; hai cái đầu đã đo: gấp đôi bộ nhớ và gấp đôi độ trễ trung vị trong bài.
- Giao dịch cơ sở dữ liệu là thứ làm nên nhất quán ở nguyên khối; tách dữ liệu là mất nó.
- Cô lập lỗi là một phần: chức năng cần dịch vụ lỗi vẫn hỏng.
- Quyết định tách dựa trên đội, ranh giới, nhu cầu mở rộng và năng lực vận hành, không dựa trên mốt.
Đọc thêm
- Martin Fowler: Microservices
- Martin Fowler: MonolithFirst
- Spring Boot Reference: Transactions with Spring
Bài tiếp theo giải quyết điều bài này vừa làm lộ ra: nếu không có giao dịch chung, làm sao giữ dữ liệu nhất quán giữa các dịch vụ.