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ầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Boot4.1.1khai báo ở parent trong pom.xml
Bộ nhớ trốngkhoảng 1 GBba tiến trình Java chạy cùng lúc
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
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
<?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

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

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

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

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

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

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

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

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):

text
@@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ỏiNghiêng về tách khiNghiê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ợpMộ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ỉ 1Tả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ạchRanh 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ánChư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 địnhLựa chọnĐánh đổi
Kiến trúcNguyên khốiĐơn giản, giao dịch chung, phát hành cả khối
Kiến trúcMicroservicesPhát hành độc lập, mở rộng riêng, nhưng trả bốn cái giá đã đo
Dữ liệuMột cơ sở dữ liệu chungDễ nhất quán nhưng các nhóm dính vào nhau qua lược đồ
Dữ liệuMỗi dịch vụ một cơ sở dữ liệuTự 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ớiCắ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ứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Trang chậm hơn hẳn sau khi táchSố lời gọi liên dịch vụ trên một yêu cầuChuỗ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ôngKhông có giao dịch chungThiế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ốngLời gọi có timeout và ngắt mạch khôngGọ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ụcXem lại ranh giới; cân nhắc gộp lại
Chi phí hạ tầng tăng nhanhBộ nhớ và số bản mỗi dịch vụMỗi dịch vụ một JVMGộp dịch vụ nhỏ, dùng tối ưu bộ nhớ
Không tìm được nguyên nhân lỗiCó mã theo dõi xuyên dịch vụ khôngThiếu tracingBậ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ứngNguyên nhânCách xử lý
did not start, see target/logsMộ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àiMáy, số lõi và bộ nhớ khác nhauSo 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.

  1. Chạy thí nghiệm. mvn -q compile exec:exec. Tiêu chí: monolith: status 500, stock change 0, orders change 0 và microservices: status 500, stock change -5, orders change 0; bộ nhớ của microservices lớn hơn nguyên khối.
  2. 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 change là -15 và orders change vẫn 0; giải thích vì sao lỗi tích lũy.
  3. Bỏ giao dịch. Xóa @Transactional khỏi ShopService.placeOrder. Tiêu chí: nguyên khối cho stock 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.
  4. 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 /orders trả 500 và GET /orders/count trả 200.
  5. Chạy bộ test (tùy chọn). Sau khi clone kho mã, mvn test cho Tests run: 6, Failures: 0, Errors: 0. Dọn dẹp bằng mvn 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

  1. Nêu bốn cái giá của microservices và con số đo được cho hai cái đầu.
  2. 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?
  3. Bỏ @Transactional khỏi nguyên khối thì điều gì xảy ra, và điều đó nói gì?
  4. Vì sao đặt hàng vẫn lỗi dù "lỗi được cô lập"?
  5. 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

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ụ.