Giới thiệu Spring Cloud và Service Discovery

Xem thử

Một đội giữ địa chỉ của dịch vụ thanh toán trong tệp cấu hình: payment.url=http://10.0.3.17:8080. Một đêm hạ tầng co giãn tự động thay máy, địa chỉ cũ không còn ai nghe, và mọi đơn hàng báo lỗi Connection refused cho đến khi có người sửa tệp và triển khai lại. Vài tuần sau, đội chạy thêm một bản thanh toán thứ hai để chịu tải, nhưng toàn bộ lưu lượng vẫn đổ vào bản đầu vì địa chỉ trong cấu hình chỉ có một.

Cả hai sự cố đến từ cùng một thói quen: gọi dịch vụ bằng địa chỉ. Khi một ứng dụng tách thành nhiều dịch vụ, số bản và địa chỉ của chúng thay đổi liên tục, nên bên gọi cần gọi bằng tên và để một hệ thống khác trả lời "tên này hiện nằm ở đâu". Đó là service discovery. Bài này dựng nó thật bằng Eureka, đo cách nó chia tải, và đo điều ít sách nói: nó hỏng ra sao khi một bản chết đột ngột.

Sau bài này, bạn có thể

  • Giải thích sổ đăng ký, hợp đồng thuê (lease), nhịp tim và việc loại bản hết hạn.
  • Dựng sổ đăng ký Eureka, hai bản của một dịch vụ và một bên gọi bằng tên http://catalog.
  • Đo chia tải phía client và phân biệt tắt êm với sập đột ngột.
  • Chỉnh các tham số quyết định thời gian lỗi, và nhận ra hai bẫy cấu hình khi trộn Eureka với @LoadBalanced.

Spring Cloud là gì và bài này dùng phiên bản nào

Spring Cloud là bộ thư viện xây trên Spring Boot cho các vấn đề của hệ phân tán. Các thành phần chính của chương này:

Thành phầnVai trò
EurekaSổ đăng ký: dịch vụ đăng ký, bên gọi tra cứu
Spring Cloud LoadBalancerChọn một bản trong danh sách, phía client
OpenFeignGọi dịch vụ khác bằng một interface khai báo
Spring Cloud GatewayCổng vào duy nhất cho các dịch vụ
Config ServerCấu hình tập trung
Micrometer TracingTheo dõi một yêu cầu đi qua nhiều dịch vụ

Spring Cloud phát hành theo "release train" và mỗi train chỉ đi với một dòng Spring Boot nhất định. Các chương trước dùng Spring Boot 4.1.1; chương này dùng cặp Spring Boot 4.0.8 và Spring Cloud 2025.1.3 (Eureka 5.0.2, LoadBalancer 5.0.3), đúng cặp đã chạy trong các lab. Trước khi nâng cấp Boot trong một dự án có Spring Cloud, hãy đối chiếu bảng tương thích trên trang Spring Cloud thay vì giả định bản mới nhất chạy được.

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.0.8, Spring Cloud 2025.1.3). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 13-spring-cloud/01-service-discovery, 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.0.8khai báo ở parent trong pom.xml; Spring Cloud 2025.1.3 khớp với dòng Boot 4.0
Cổng mạngcổng ngẫu nhiênmỗi tiến trình tự xin cổng trống nên không đụng cổng 8080 hay 8761
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
01-service-discovery/
  pom.xml                       parent Boot 4.0.8, BOM Spring Cloud 2025.1.3, Eureka server và client, LoadBalancer
  src/main/java/dev/canhdominich/cloud/
    registry/RegistryApplication.java    @EnableEurekaServer: sổ đăng ký
    catalog/CatalogApplication.java      dịch vụ được tra cứu, tự đăng ký
    catalog/CatalogController.java       GET /whoami trả về instance-id của bản đang chạy
    shop/ShopApplication.java            hai RestClient.Builder: một thường (@Primary) và một @LoadBalanced
    shop/ShopController.java             GET /call gọi http://catalog/whoami, GET /instances đếm bản thấy được
    DiscoveryDemo.java                   mở ba loại tiến trình, đo từng tình huống
    SelfPreservationDemo.java            đo thời gian sổ đăng ký loại một bản đã sập khi bật và tắt self-preservation

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven dùng spring-boot-starter-parent 4.0.8 và nhập BOM spring-cloud-dependencies 2025.1.3 (Spring Cloud chỉ có hiệu lực khi phiên bản khớp với Boot, vì vậy bài dùng 4.0.8 chứ không dùng 4.1.1 như các chương trước). Phụ thuộc: spring-boot-starter-webmvc cho ba dịch vụ, spring-cloud-starter-netflix-eureka-server cho sổ đăng ký, spring-cloud-starter-netflix-eureka-client cho dịch vụ đăng ký và tra cứu, spring-cloud-starter-loadbalancer cho phần chia tải phía client, và spring-boot-starter-test. Plugin exec-maven-plugin chạy DiscoveryDemo bằng mvn -q compile exec:exec; DiscoveryDemo tự mở mỗi dịch vụ thành một tiến trình Java riêng.

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.0.8</version>
        <relativePath/>
    </parent>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>cloud-service-discovery</artifactId>
    <version>1.0.0</version>
    <name>Spring Cloud - Service Discovery voi Eureka</name>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2025.1.3</spring-cloud.version>
        <main.class>dev.canhdominich.cloud.DiscoveryDemo</main.class>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </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

Mỗi thành phần là một ứng dụng Spring Boot riêng và DiscoveryDemo khởi chạy chúng thành các tiến trình Java riêng, như khi triển khai thật. Sổ đăng ký chỉ cần một annotation:

src/main/java/dev/canhdominich/cloud/registry/RegistryApplication.java

java
package dev.canhdominich.cloud.registry;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class RegistryApplication {

    public static void main(String[] args) {
        SpringApplication.run(RegistryApplication.class, args);
    }
}

CatalogApplication là dịch vụ được tra cứu. Nó không có dòng nào về Eureka: chỉ cần starter eureka-client trên classpath là nó tự đăng ký, tên đăng ký lấy từ spring.application.name:

src/main/java/dev/canhdominich/cloud/catalog/CatalogApplication.java

java
package dev.canhdominich.cloud.catalog;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CatalogApplication {

    public static void main(String[] args) {
        SpringApplication.run(CatalogApplication.class, args);
    }
}

CatalogController có một đầu vào /whoami trả về instance-id của bản đang chạy, để bên gọi biết lời gọi đã vào bản nào:

src/main/java/dev/canhdominich/cloud/catalog/CatalogController.java

java
package dev.canhdominich.cloud.catalog;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CatalogController {

    private final String instance;

    public CatalogController(@Value("${eureka.instance.instance-id}") String instance) {
        this.instance = instance;
    }

    @GetMapping("/whoami")
    public String whoami() {
        return instance;
    }
}

ShopApplication có hai RestClient.Builder: một bản thường đánh dấu @Primary và một bản @LoadBalanced. Lý do hai bản là một bẫy được đo ở phần sau:

src/main/java/dev/canhdominich/cloud/shop/ShopApplication.java

java
package dev.canhdominich.cloud.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestClient;

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }

    @Bean
    @Primary
    public RestClient.Builder plainBuilder() {
        return RestClient.builder();
    }

    @Bean
    @LoadBalanced
    public RestClient.Builder loadBalancedBuilder() {
        return RestClient.builder();
    }
}

ShopController gọi http://catalog/whoami bằng bản @LoadBalanced. Chú ý URL chỉ có tên catalog, không có địa chỉ hay cổng. /instances hỏi DiscoveryClient xem shop đang thấy bao nhiêu bản:

src/main/java/dev/canhdominich/cloud/shop/ShopController.java

java
package dev.canhdominich.cloud.shop;

import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;

@RestController
public class ShopController {

    private final RestClient catalog;
    private final DiscoveryClient discovery;

    public ShopController(@LoadBalanced RestClient.Builder builder, DiscoveryClient discovery) {
        this.catalog = builder.baseUrl("http://catalog").build();
        this.discovery = discovery;
    }

    @GetMapping("/call")
    public String call() {
        return catalog.get().uri("/whoami").retrieve().body(String.class);
    }

    @GetMapping("/instances")
    public String instances() {
        return String.valueOf(discovery.getInstances("catalog").size());
    }
}

DiscoveryDemo làm toàn bộ thí nghiệm: mở từng tiến trình trên cổng ngẫu nhiên, đo chia tải, rồi tắt một bản theo hai cách (destroy() gửi SIGTERM, destroyForcibly() gửi SIGKILL) và đo số lời gọi lỗi:

src/main/java/dev/canhdominich/cloud/DiscoveryDemo.java

java
package dev.canhdominich.cloud;

import dev.canhdominich.cloud.catalog.CatalogApplication;
import dev.canhdominich.cloud.registry.RegistryApplication;
import dev.canhdominich.cloud.shop.ShopApplication;
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.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.BooleanSupplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class DiscoveryDemo {

    private static final HttpClient HTTP = HttpClient.newHttpClient();

    public static void main(String[] args) throws Exception {
        run().forEach(System.out::println);
    }

    public static List<String> run() throws Exception {
        List<String> lines = new ArrayList<>();
        List<Node> nodes = new ArrayList<>();
        try {
            Node registry = launch(nodes, RegistryApplication.class, "registry",
                    "--eureka.client.register-with-eureka=false",
                    "--eureka.client.fetch-registry=false",
                    "--eureka.server.enable-self-preservation=false",
                    "--eureka.server.eviction-interval-timer-in-ms=1000",
                    "--eureka.server.response-cache-update-interval-ms=1000");
            String eureka = "http://localhost:" + registry.port + "/eureka/";
            lines.add("-- a registry with nothing registered");
            lines.add("catalog instances in the registry: " + instanceIds(registry.port));
            Node catalogA = launch(nodes, CatalogApplication.class, "catalog", catalogArgs(eureka, "catalog-A"));
            Node catalogB = launch(nodes, CatalogApplication.class, "catalog", catalogArgs(eureka, "catalog-B"));
            Node shop = launch(nodes, ShopApplication.class, "shop",
                    "--eureka.client.service-url.defaultZone=" + eureka,
                    "--eureka.client.register-with-eureka=false",
                    "--eureka.client.jersey.enabled=false",
                    "--eureka.client.registry-fetch-interval-seconds=1",
                    "--spring.cloud.loadbalancer.cache.ttl=1s");
            lines.add("-- two instances of one service register themselves");
            lines.add("shop sees 2 instances of catalog within 40 s: " + waitFor(() -> text(shop.port, "/instances").equals("2"), 40_000));
            lines.add("instances listed by the registry: " + instanceIds(registry.port));
            lines.add("-- the load balancer spreads calls");
            lines.add("20 calls to http://catalog/whoami: " + calls(shop.port, 20));
            lines.add("-- an instance leaves cleanly (SIGTERM)");
            catalogA.process.destroy();
            catalogA.process.waitFor();
            long start = System.currentTimeMillis();
            boolean gone = waitFor(() -> instanceIds(registry.port).equals("[catalog-B]"), 15_000);
            lines.add("registry lists only catalog-B within 15 s: " + gone);
            lines.add("shop sees 1 instance within 15 s: " + waitFor(() -> text(shop.port, "/instances").equals("1"), 15_000));
            lines.add("10 calls afterwards: " + calls(shop.port, 10));
            lines.add("-- an instance that crashes (SIGKILL)");
            Node catalogC = launch(nodes, CatalogApplication.class, "catalog", catalogArgs(eureka, "catalog-C"));
            waitFor(() -> text(shop.port, "/instances").equals("2"), 40_000);
            catalogC.process.destroyForcibly();
            catalogC.process.waitFor();
            long killed = System.currentTimeMillis();
            int failed = 0;
            int total = 0;
            long firstGood = -1;
            long lastFail = -1;
            while (System.currentTimeMillis() - killed < 15_000) {
                total++;
                if (status(shop.port, "/call") == 200) {
                    if (firstGood < 0) {
                        firstGood = System.currentTimeMillis();
                    }
                } else {
                    failed++;
                    lastFail = System.currentTimeMillis();
                }
                Thread.sleep(100);
            }
            lines.add("some calls failed while the registry still listed the dead instance: " + (failed > 0));
            lines.add("failed calls: " + failed + " of " + total + ", last failure " + (lastFail < 0 ? 0 : (lastFail - killed) / 1000) + " s after the crash");
            lines.add("failures stopped by themselves within 10 s: " + (lastFail < 0 || lastFail - killed < 10_000));
            lines.add("registry lists only catalog-B at the end: " + instanceIds(registry.port));
        } finally {
            for (Node node : nodes) {
                node.process.destroyForcibly();
            }
        }
        return lines;
    }

    static String[] catalogArgs(String eureka, String id) {
        return new String[] {
            "--eureka.client.service-url.defaultZone=" + eureka,
            "--eureka.instance.instance-id=" + id,
            "--eureka.instance.lease-renewal-interval-in-seconds=1",
            "--eureka.instance.lease-expiration-duration-in-seconds=3",
            "--eureka.client.fetch-registry=false",
            "--eureka.client.jersey.enabled=false"
        };
    }

    static Map<String, Integer> calls(int shopPort, int count) {
        Map<String, Integer> counts = new TreeMap<>();
        for (int i = 0; i < count; i++) {
            counts.merge(text(shopPort, "/call"), 1, Integer::sum);
        }
        return counts;
    }

    static Node launch(List<Node> nodes, Class<?> main, String name, String... extra) throws Exception {
        int port = freePort();
        List<String> command = new ArrayList<>(List.of(
                System.getProperty("java.home") + "/bin/java",
                "-Xmx160m",
                "-classpath", System.getProperty("java.class.path"),
                main.getName(),
                "--server.port=" + port,
                "--spring.application.name=" + name,
                "--spring.main.banner-mode=off",
                "--logging.level.root=WARN"));
        if (name.equals("registry")) {
            command.add("--eureka.client.service-url.defaultZone=http://localhost:" + port + "/eureka/");
        }
        command.addAll(List.of(extra));
        File logs = new File("target/logs");
        logs.mkdirs();
        File log = new File(logs, name + "-" + port + ".log");
        Process process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log).start();
        Node node = new Node(process, port);
        nodes.add(node);
        if (!waitFor(() -> status(port, "/") > 0, 60_000)) {
            throw new IllegalStateException(name + " did not start, see " + log);
        }
        return node;
    }

    static int freePort() throws Exception {
        try (ServerSocket socket = new ServerSocket(0)) {
            return socket.getLocalPort();
        }
    }

    static boolean waitFor(BooleanSupplier condition, long millis) throws InterruptedException {
        long deadline = System.currentTimeMillis() + millis;
        while (System.currentTimeMillis() < deadline) {
            try {
                if (condition.getAsBoolean()) {
                    return true;
                }
            } catch (RuntimeException ignored) {
            }
            Thread.sleep(200);
        }
        return false;
    }

    static String instanceIds(int registryPort) {
        String body = get(registryPort, "/eureka/apps/CATALOG", "application/json");
        List<String> ids = new ArrayList<>();
        Matcher matcher = Pattern.compile("\"instanceId\"\\s*:\\s*\"([^\"]+)\"").matcher(body);
        while (matcher.find()) {
            ids.add(matcher.group(1));
        }
        Collections.sort(ids);
        return ids.toString();
    }

    static String get(int port, String path, String accept) {
        try {
            HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).header("Accept", accept).GET().build(), HttpResponse.BodyHandlers.ofString());
            return response.body();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    static String text(int port, String path) {
        return get(port, path, "text/plain");
    }

    static int status(int port, String path) {
        try {
            return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    record Node(Process process, int port) {
    }
}

SelfPreservationDemo dùng lại các hàm trên để đo thời gian sổ đăng ký loại một bản đã sập khi bật và tắt cơ chế self-preservation:

src/main/java/dev/canhdominich/cloud/SelfPreservationDemo.java

java
package dev.canhdominich.cloud;

import dev.canhdominich.cloud.DiscoveryDemo.Node;
import dev.canhdominich.cloud.catalog.CatalogApplication;
import dev.canhdominich.cloud.registry.RegistryApplication;
import java.util.ArrayList;
import java.util.List;

public class SelfPreservationDemo {

    public static void main(String[] args) throws Exception {
        System.out.println("self preservation false, crash 10 s after start: evicted after " + secondsUntilEvicted("false", 10) + " s");
        System.out.println("self preservation true, crash 10 s after start: evicted after " + secondsUntilEvicted("true", 10) + " s");
        System.out.println("self preservation true, crash 90 s after start: evicted after " + secondsUntilEvicted("true", 90) + " s");
    }

    static long secondsUntilEvicted(String selfPreservation, int crashAfterSeconds) throws Exception {
        List<Node> nodes = new ArrayList<>();
        try {
            Node registry = DiscoveryDemo.launch(nodes, RegistryApplication.class, "registry",
                    "--eureka.client.register-with-eureka=false",
                    "--eureka.client.fetch-registry=false",
                    "--eureka.server.enable-self-preservation=" + selfPreservation,
                    "--eureka.server.eviction-interval-timer-in-ms=1000",
                    "--eureka.server.response-cache-update-interval-ms=1000");
            long registryStarted = System.currentTimeMillis();
            String eureka = "http://localhost:" + registry.port() + "/eureka/";
            DiscoveryDemo.launch(nodes, CatalogApplication.class, "catalog", DiscoveryDemo.catalogArgs(eureka, "catalog-A"));
            Node b = DiscoveryDemo.launch(nodes, CatalogApplication.class, "catalog", DiscoveryDemo.catalogArgs(eureka, "catalog-B"));
            DiscoveryDemo.waitFor(() -> DiscoveryDemo.instanceIds(registry.port()).equals("[catalog-A, catalog-B]"), 30_000);
            Thread.sleep(Math.max(0, crashAfterSeconds * 1000L - (System.currentTimeMillis() - registryStarted)));
            b.process().destroyForcibly();
            long start = System.currentTimeMillis();
            while (System.currentTimeMillis() - start < 180_000) {
                if (DiscoveryDemo.instanceIds(registry.port()).equals("[catalog-A]")) {
                    return (System.currentTimeMillis() - start) / 1000;
                }
                Thread.sleep(1000);
            }
            return -1;
        } finally {
            for (Node node : nodes) {
                node.process().destroyForcibly();
            }
        }
    }
}

Chạy bằng mvn -q compile exec:exec (mất khoảng một phút). Kết quả:

text
-- a registry with nothing registered
catalog instances in the registry: []
-- two instances of one service register themselves
shop sees 2 instances of catalog within 40 s: true
instances listed by the registry: [catalog-A, catalog-B]
-- the load balancer spreads calls
20 calls to http://catalog/whoami: {catalog-A=10, catalog-B=10}
-- an instance leaves cleanly (SIGTERM)
registry lists only catalog-B within 15 s: true
shop sees 1 instance within 15 s: true
10 calls afterwards: {catalog-B=10}
-- an instance that crashes (SIGKILL)
some calls failed while the registry still listed the dead instance: true
failed calls: 31 of 129, last failure 7 s after the crash
failures stopped by themselves within 10 s: true
registry lists only catalog-B at the end: [catalog-B]

Hai dòng failed calls và last failure thay đổi giữa các lần chạy (khoảng 30 lời gọi lỗi, lỗi cuối cùng vào giây thứ 7 hoặc 8); mọi dòng khác phải y hệt.

Gọi bằng tên thay vì bằng địa chỉ

Cách đọc hình: hàng trên là địa chỉ viết cứng: khi bản chuyển chỗ hoặc thêm bản mới, cấu hình sai cho đến khi có người sửa. Hàng dưới là tên logic catalog: sổ đăng ký trả danh sách hiện có và bộ chia tải phía client chia lời gọi, đo được 10 và 10 trong 20 lời gọi.

Có hai việc khác nhau mà người mới hay gộp làm một:

  • Discovery trả lời "tên này hiện có những bản nào". Ở bài này đó là Eureka và DiscoveryClient.
  • Chia tải trả lời "trong các bản đó chọn bản nào cho lời gọi này". Ở bài này đó là Spring Cloud LoadBalancer, mặc định chọn luân phiên (round robin), đúng với kết quả 10 và 10.
Chia tải phía clientChia tải phía server
Ai quyết địnhChính bên gọi, từ danh sách đã tải vềMột bộ cân bằng ở giữa
Ví dụSpring Cloud LoadBalancerNginx, bộ cân bằng của nhà cung cấp đám mây
Điểm yếuMỗi client tự giữ một danh sách có thể cũThêm một bước nhảy và một điểm có thể hỏng

Eureka không đứng giữa lời gọi: nó chỉ trả danh sách. Lời gọi đi thẳng từ shop đến catalog, nên sổ đăng ký chết tạm thời không làm các lời gọi đang chạy hỏng (bên gọi dùng danh sách đã lưu).

Hợp đồng thuê: một bản sống trong sổ đăng ký thế nào

Cách đọc hình: hàng trên là đường bình thường: đăng ký, gia hạn bằng nhịp tim đều đặn, hủy đăng ký khi tắt êm. Hàng dưới là đường lỗi: nhịp tim dừng vì sập, mất mạng hay tạm dừng quá lâu, hợp đồng hết hạn rồi một bộ định thời loại bản đó.

Sổ đăng ký không "kiểm tra" xem dịch vụ còn sống hay không. Nó chỉ tin vào nhịp tim: mỗi bản gia hạn hợp đồng thuê định kỳ, và bản nào ngừng gia hạn quá hạn mức thì bị loại. Bạn có thể xem bản ghi bằng chính REST của sổ đăng ký (GET /eureka/apps/CATALOG với Accept: application/json); DiscoveryDemo đọc instanceId từ đó để in danh sách.

Năm tham số quyết định mọi thứ. Cột giữa là mặc định đã đọc từ siêu dữ liệu cấu hình của thư viện, cột phải là giá trị lab dùng để thấy thay đổi trong vài giây:

Tham sốMặc địnhLab
eureka.instance.lease-renewal-interval-in-seconds (chu kỳ nhịp tim)301
eureka.instance.lease-expiration-duration-in-seconds (hạn hợp đồng)903
eureka.client.registry-fetch-interval-seconds (client làm mới danh sách)301
eureka.server.response-cache-update-interval-ms (bộ nhớ đệm của sổ đăng ký)300001000
spring.cloud.loadbalancer.cache.ttl (bộ nhớ đệm của bộ chia tải)35s1s
eureka.server.eviction-interval-timer-in-ms (bộ định thời loại bản)siêu dữ liệu cấu hình không ghi mặc định1000

Cộng các độ trễ này lại là lý do con số mặc định không dùng được cho mọi hệ thống: một bản sập đột ngột có thể vẫn được liệt kê trong khoảng thời gian tính theo phút với cấu hình mặc định. Bài chỉ đo với cấu hình rút ngắn ở cột phải; hãy tự đo với giá trị mặc định ở bài thực hành.

Hai cách một bản biến mất

Cách đọc hình: hàng trên là SIGTERM: bản tự hủy đăng ký nên sổ đăng ký đổi ngay và chỉ còn chờ client làm mới. Hàng dưới là SIGKILL: không có thông báo nào, sổ đăng ký giữ bản đã chết cho đến khi hợp đồng hết hạn.

Kết quả đo bằng DiscoveryDemo:

  • Tắt êm (SIGTERM). Sổ đăng ký chỉ còn [catalog-B] trong vòng 15 giây, shop thấy 1 bản trong vòng 15 giây, và 10 lời gọi sau đó đều vào catalog-B. Tắt êm hoạt động vì client Eureka gửi yêu cầu hủy đăng ký khi nhận tín hiệu thoát (eureka.client.should-unregister-on-shutdown mặc định true).
  • Sập đột ngột (SIGKILL). Trong 15 giây sau khi sập, shop gọi liên tục: khoảng 31 trên 129 lời gọi lỗi (một nửa, vì chia tải vẫn luân phiên vào bản đã chết), và lời gọi lỗi cuối cùng đến sau 7 hoặc 8 giây. Lỗi là Connection refused tới địa chỉ của bản đã chết. Cuối cùng sổ đăng ký tự loại bản đó, không cần can thiệp.

Con số 7 giây không phải một hằng số: nó là tổng của hợp đồng 3 giây, bộ định thời loại 1 giây, bộ nhớ đệm của sổ đăng ký 1 giây, chu kỳ làm mới của client 1 giây và bộ nhớ đệm của bộ chia tải 1 giây, cộng độ trễ lẻ giữa các chu kỳ. Với giá trị mặc định mỗi thành phần dài hơn khoảng 30 lần, nên cửa sổ lỗi dài hơn tương ứng.

Đang tải Animation…

Điều này dẫn đến hai quy tắc thiết kế:

  • Tắt êm là mặc định đúng cho triển khai. Khi thay bản cũ bằng bản mới, hãy gửi SIGTERM và cho tiến trình thời gian thoát, đừng tắt cứng. Trong container, đảm bảo tiến trình chính nhận được tín hiệu.
  • Discovery không thay thế khả năng chịu lỗi của bên gọi. Luôn có khoảng thời gian bên gọi cầm một danh sách có bản đã chết; thử lại sang bản khác và ngắt mạch (bài sau) là phần việc của bên gọi.

Cơ chế self-preservation

Sổ đăng ký có một cơ chế bảo vệ: khi số nhịp tim nhận được tụt mạnh so với kỳ vọng (ví dụ mạng giữa nó và nhiều dịch vụ đứt), nó ngừng loại bản để khỏi xóa sạch danh sách vì lỗi của chính nó. Cơ chế này được điều khiển bởi eureka.server.enable-self-preservation (mặc định true) và ngưỡng eureka.server.renewal-percent-threshold (mặc định 0.85).

SelfPreservationDemo đo một hệ quả dễ bất ngờ: thời gian để sổ đăng ký loại một bản đã sập, khi bản đó sập 10 giây sau khi sổ đăng ký khởi động.

Cấu hìnhSập lúcBị loại sau
self-preservation tắt10 giây sau khi sổ đăng ký chạy4 giây
self-preservation bật (mặc định)10 giây sau khi sổ đăng ký chạy44 giây
self-preservation bật (mặc định)90 giây sau khi sổ đăng ký chạy7 giây

Tức là với cơ chế bật, sổ đăng ký chưa loại bản nào trong khoảng một phút đầu sau khi khởi động, rồi sau đó hoạt động bình thường. Hệ quả thực tế: một sổ đăng ký vừa được khởi động lại (hoặc môi trường kiểm thử vừa dựng) có thể giữ bản chết lâu hơn dự kiến; đừng viết bài kiểm thử mong sổ đăng ký loại bản ngay trong phút đầu. Bài không khẳng định cơ chế bên trong gây ra độ trễ này; chỉ khẳng định kết quả đo.

Hai bẫy cấu hình khi dựng lab

Cả hai đều là lỗi thật gặp khi dựng bài này, nên bạn nên biết trước:

Cách đọc hình: hàng trên là cấu hình ngây thơ với một builder @LoadBalanced duy nhất. Hàng dưới là cách sửa trong ShopApplication: hai builder, bản thường là @Primary cho client Eureka và bản chia tải chỉ được tiêm vào nơi cần gọi bằng tên.

  • Một RestClient.Builder @LoadBalanced duy nhất. Client Eureka (trong Spring Cloud 2025.1) cũng dùng RestClient.Builder có trong container để gọi sổ đăng ký. Nếu bean duy nhất là bản chia tải, nó cố tra cứu host của sổ đăng ký (localhost) bằng chính cơ chế chia tải, trong khi chưa tải được danh sách nào. Dấu hiệu trong log của shop: Exception occurred while retrieving instances for service localhost rồi No servers available for service: localhost, và /instances trả 0. Cách sửa đã dùng: thêm bean RestClient.Builder thường @Primary và tiêm bản chia tải bằng @LoadBalanced ở ShopController.
  • eureka-server và eureka-client trên cùng một classpath. Lab đặt cả hai trong một dự án để đơn giản. Khi đó tiến trình client báo lỗi thiếu bean TransportClientFactories. Đặt --eureka.client.jersey.enabled=false cho các tiến trình client (như DiscoveryDemo làm) thì hết lỗi. Trong dự án thật, sổ đăng ký và dịch vụ là các dự án tách biệt nên bẫy này không xuất hiện.

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Cách gọi dịch vụTên logic qua discoveryThêm một hệ thống phải vận hành, và cửa sổ danh sách cũ
Thời gian hợp đồngNgắn (vài giây)Phát hiện nhanh nhưng nhiều nhịp tim hơn và dễ loại nhầm khi tạm dừng
Thời gian hợp đồngDài (mặc định 90 giây)Ít tải nhưng cửa sổ lỗi dài khi sập
Self-preservationBậtKhông xóa nhầm khi mạng sổ đăng ký hỏng, nhưng giữ bản chết lâu hơn
Bộ nhớ đệmNgắn ở sổ đăng ký, client và bộ chia tảiCập nhật nhanh, nhiều yêu cầu hơn tới sổ đăng ký
Tắt dịch vụSIGTERM và chờ thoátTriển khai chậm hơn một chút

Playbook: gặp sự cố discovery thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
No servers available for service: X/eureka/apps/X của sổ đăng ký có bản nào khôngTên đăng ký khác tên gọi, hoặc bản chưa đăng ký xongSo spring.application.name với tên trong URL; chờ đăng ký
No servers available for service: localhostCó bean @LoadBalanced RestClient.Builder duy nhất khôngClient Eureka dùng nhầm builder chia tảiThêm builder thường @Primary
Một phần lời gọi Connection refused sau khi một bản chếtBản đó còn trong /eureka/apps khôngHợp đồng và bộ nhớ đệm chưa hết hạnRút ngắn tham số, thêm thử lại và ngắt mạch
Bản đã chết vẫn nằm trong sổ đăng ký rất lâuSổ đăng ký vừa khởi động lại không, self-preservation bật khôngCơ chế self-preservationChờ qua phút đầu, hoặc xem xét tắt ở môi trường nhỏ
Một bản nhận hết lưu lượngShop thấy mấy bản (/instances)Danh sách chưa làm mới hoặc bản kia chưa đăng kýKiểm tra chu kỳ làm mới và log đăng ký
Lỗi thiếu bean TransportClientFactoriesCó eureka-server trong cùng classpath khôngTrộn server và clientTách dự án hoặc tắt Jersey ở client

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Dòng shop sees 2 instances ra falseMáy chậm, ba tiến trình chưa kịp đăng ký trong 40 giâyChạy lại; xem target/logs/*.log của từng tiến trình để tìm lỗi khởi động
Ở shop có log No servers available for service: localhostBean RestClient.Builder được gắn @LoadBalanced là bean duy nhất nên cả client Eureka cũng dùng nó và cố tìm sổ đăng ký qua chính cơ chế chia tảiKhai báo thêm một RestClient.Builder thường có @Primary như trong ShopApplication, và tiêm bản chia tải bằng @LoadBalanced
Dịch vụ báo lỗi thiếu bean TransportClientFactoriesCó cả eureka-server (kéo theo Jersey) và eureka-client trên cùng classpathĐặt eureka.client.jersey.enabled=false cho tiến trình client, như DiscoveryDemo làm

Bài thực hành

Làm trong dự án của bài (thư mục 13-spring-cloud/01-service-discovery). 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í: {catalog-A=10, catalog-B=10}, rồi [catalog-B] sau khi tắt êm, rồi cửa sổ lỗi vài giây khi sập.
  2. Đo với giá trị mặc định. Trong catalogArgs và các tham số của sổ đăng ký, xóa các tham số rút ngắn (lease, fetch, cache, eviction). Tăng thời gian chờ trong DiscoveryDemo lên vài phút. Tiêu chí: cửa sổ lỗi kéo dài tính bằng phút; ghi lại con số của bạn và giải thích bằng tổng các độ trễ.
  3. Ba bản. Chạy thêm catalog-C. Tiêu chí: 30 lời gọi chia 10, 10, 10 (đã đo); sau khi tắt êm một bản, 20 lời gọi chia 10 và 10 (đã đo).
  4. Làm hỏng cố ý. Đổi ShopApplication chỉ còn bean @LoadBalanced. Tiêu chí: /instances trả 0 và log có No servers available for service: localhost; giải thích vì sao.
  5. Tắt cứng nhưng báo trước. Sau khi gửi SIGKILL, gọi DELETE /eureka/apps/CATALOG/catalog-C trên sổ đăng ký. Tiêu chí: lệnh trả 200 và sổ đăng ký chỉ còn catalog-A ngay lập tức; số lời gọi lỗi giảm (đo được khoảng 18 trên 101, so với khoảng 31 trên 129) nhưng không về 0 vì bộ nhớ đệm của client và bộ chia tải còn giữ danh sách cũ vài giây; giải thích đây là điều tắt êm làm giúp bạn.
  6. Chạy bộ test (tùy chọn). Sau khi clone kho mã, mvn test cho Tests run: 5, Failures: 0, Errors: 0 (mất khoảng một phút). Dọn dẹp bằng mvn clean.

Hiểu lầm và giới hạn

  • "Eureka kiểm tra sức khỏe dịch vụ." Nó chỉ nhận nhịp tim và không gọi vào dịch vụ để kiểm tra; trạng thái mà bản tự báo là thứ nó tin.
  • "Có Eureka thì không còn lỗi kết nối." Luôn có cửa sổ danh sách cũ, nên bên gọi vẫn phải thử lại.
  • "Eureka đứng giữa các lời gọi." Nó chỉ trả danh sách; lời gọi đi thẳng.
  • "Giá trị mặc định đủ dùng." Chúng ưu tiên ít tải hơn là phát hiện nhanh.
  • Số đo thuộc về Spring Boot 4.0.8, Spring Cloud 2025.1.3, JDK 17, mọi dịch vụ chạy trên một máy và cấu hình rút ngắn; con số giây cụ thể phụ thuộc cấu hình đó. Bài chưa nói về nhiều sổ đăng ký chạy song song (peer replication), vùng (zone), bảo mật sổ đăng ký, Consul hay Kubernetes service discovery, và cấu hình @LoadBalanced cho WebClient.

Tự kiểm tra

  1. Sổ đăng ký biết một bản còn sống bằng cách nào, và vì sao nó không tự kiểm tra?
  2. Vì sao tắt êm gần như không tạo lỗi còn sập đột ngột thì có?
  3. Hai việc discovery và chia tải khác nhau ở điểm nào?
  4. Vì sao một RestClient.Builder @LoadBalanced duy nhất làm hỏng client Eureka?
  5. Nêu ba bộ nhớ đệm hoặc độ trễ cộng dồn thành cửa sổ lỗi.

Gợi ý đáp án. (1) Qua nhịp tim gia hạn hợp đồng; nó chỉ tin các thông điệp nhận được. (2) Tắt êm gửi hủy đăng ký nên sổ đăng ký đổi ngay; sập thì không có thông báo nào. (3) Discovery trả danh sách bản, chia tải chọn một bản trong danh sách. (4) Client Eureka dùng builder đó để gọi sổ đăng ký và bị chia tải cố tra host của sổ đăng ký khi chưa có danh sách. (5) Hợp đồng thuê, bộ nhớ đệm của sổ đăng ký, chu kỳ làm mới của client, bộ nhớ đệm của bộ chia tải.

Điều cần nhớ

  • Gọi bằng tên: discovery trả danh sách, bộ chia tải chọn bản, cả hai chạy phía client.
  • Nhịp tim và hợp đồng thuê quyết định sổ đăng ký biết gì; tắt êm báo trước còn sập thì phải chờ hết hạn.
  • Cửa sổ lỗi là tổng của nhiều bộ nhớ đệm; đo, đừng đoán, và để bên gọi tự thử lại.
  • Self-preservation bật thì sổ đăng ký mới khởi động chưa loại bản nào trong khoảng một phút đầu (đo được 44 giây so với 4 giây).

Đọc thêm

Bài tiếp theo giải quyết điều bài này vừa chỉ ra: khi gọi dịch vụ khác, bạn cần một cách gọi gọn (OpenFeign) và cách tự bảo vệ khi bản đích chậm hoặc hỏng (thời gian chờ, fallback, circuit breaker).