qinghuan
← Spring Cloud / 全部笔记

单元 85 / Spring Cloud

P1 · 重点提升L2 · 进阶

Spring Cloud 起点:服务发现与配置中心各解决什么

分清查找服务地址和读取运行配置,再沿实例发现、选择、连接和配置实际生效逐步验证。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能说明服务名解析与配置加载链路,并列出注册中心或配置中心不可用时的行为。

学习理由、准备要求与知识点

为什么学它

多实例调用与集中配置是服务拆分的基础能力,但都会引入刷新延迟和可用性选择。

先读这些内容

知识点清单

  • 注册与发现
  • DiscoveryClient
  • 负载均衡
  • 实例健康
  • 配置导入
  • 配置优先级
  • 启动失败策略
  • 版本兼容矩阵

运行前先确认示例学习基线为 Spring Boot 3.5 与 Spring Cloud 2025.0 对应的 4.3.x 组件;源码固定 Commons v4.3.0。实际项目应按官方兼容矩阵选择整套版本。算法使用 Java 17,不替代完整 Spring 属性绑定与刷新实现。

01先弄懂它是什么

服务有多个实例时,调用者需要知道现在能找谁;同一程序部署到不同环境时,还需要知道使用什么地址和超时。服务发现回答当前有哪些候选实例,配置管理回答这次运行用哪些参数。它们可能依赖同一套基础设施,但解决的问题和失败表现不同,要分别检查。

查到服务地址,还没完成请求。DiscoveryClient 提供服务实例,负载均衡器从中选目标,网络客户端再连接调用。注册列表里有实例,不代表调用者能连到它,也不代表它的数据库正常。实例下线、缓存更新和请求失败之间可能有时间差,所以服务发现之后仍要设计超时和失败恢复。

应用最后用哪个配置值,要把多个来源放在一起看。默认值、配置文件、环境变量、命令行和远程配置可能写了同一个设置,框架会按优先级决定采用哪个,再把它填入程序使用的对象。配置中心写着 500,实际请求却等了两秒,可能是另一个来源覆盖了它、程序对时间单位的理解不同,或旧客户端对象还没更新。排查时同时记录值来自哪里、是哪一版配置,以及哪一个运行实例真正用上了它。

文中这几个词,先认一认

DiscoveryClient
获取服务名与实例信息的统一接口,注册方式和缓存细节由具体实现负责。
Config Data
Spring Boot 加载配置的一套机制,可以通过 spring.config.import 引入外部配置。
属性来源优先级
同一个参数出现在多份配置中时,按什么顺序决定最终采用谁的值。
配置快照
一组经过共同校验的配置值,作为一个版本一起切换,避免只更新了一部分。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

应用启动时先加载配置,再把配置值填入相关 Bean,也就是由 Spring 管理的对象。Config Client 可用 spring.config.import=configserver:http://localhost:8888 读取本地学习用的配置服务器;这里的 localhost 不是生产地址。

加上 optional: 后,连接不上配置服务器也可继续启动,但关键地址和凭据能否缺省,要由业务决定。还要一起检查应用名、profile(选用的配置分组)和 label(配置版本标识);使用 Git 保存配置时,label 可以指向分支、标签或提交,不能只核对应用名。

运行时发现链路通常包含注册、续约或健康检查、客户端缓存与负载均衡器缓存。不同实现的一致性与失效时限不同,旧地址可能短暂残留。多个发现客户端组合时也未必合并所有实例,例如本篇固定源码中的 getInstances 取第一个非空结果。配置了两个注册中心,不代表请求会自动在两个中心的全部实例中均匀分配。

配置中心改了值,程序不一定马上用上。应用还要收到变更、读入新配置,并让正在使用的对象采用它;有的对象支持更新,有的必须重新创建,还有些修改需要重启。比如超时和重试次数要配套使用,先改一个再改另一个,可能短暂出现“新超时配旧重试数”。可以先准备一份完整的新配置,检查这些值放在一起是否合理,通过后一次切换到这份配置,并保留旧版本,出问题时切回。

正在加载流程图…

配置加载决定运行参数,服务发现提供实例候选;最后仍要用实际请求验证可达性与业务结果。
查看流程图源码(Mermaid)
flowchart TD
 C["本地与远程配置源"] --> E["Config Data 与 Environment"]
 E --> V["绑定并校验配置"]
 V --> B["创建运行对象"]
 B --> R["实例注册与健康信息"]
 R --> D["发现客户端与缓存"]
 D --> L["负载均衡选择实例"]
 L --> H["发起真实业务请求"]
 C --> U["配置变更通知"]
 U --> S["候选快照校验与生效验证"]

按顺序看,每一步做了什么

  1. 按兼容矩阵固定 Boot、Cloud 发布列车与组件版本。
  2. 核对应用名、profile、label 及导入是否必须成功。
  3. 确认最终属性值、来源与绑定对象实际使用值。
  4. 检查实例注册地址、健康和从调用者出发的网络可达性。
  5. 刷新后用新请求核验行为,并记录配置版本及回滚结果。

03对照实现代码

CompositeDiscoveryClient 取实例为何不是简单求并集 (新标签页)↗

Spring Cloud Commons v4.3.0 · CompositeDiscoveryClient#getInstances 与 getServices

原创局部模型,保留按既定顺序取首个非空实例列表的规则。省略真实 ServiceInstance、Ordered 排序和健康探测;不模拟每种注册中心的缓存或错误恢复。

核心机制代码 · java
import java.util.*;
import java.util.function.Function;
final class DiscoveryCompositionModel {
    static List<String> instances(String service,
            List<Function<String, List<String>>> orderedClients) {
        for (var client : orderedClients) {
            List<String> found = client.apply(service); // 输入顺序代表已配置优先级。
            if (found != null && !found.isEmpty())
                return List.copyOf(found); // 首个非空即返回,不继续合并后续来源。
        }
        return List.of(); // 全部无结果时明确返回空集合。
    }
}

固定源码在构造时按 Ordered 规则排序发现客户端,然后 getInstances 顺序查询并返回第一个非空列表。这不是把各来源全部实例加入 Set。优先级靠前的客户端若返回旧但非空的地址,后续客户端的较新结果不会在这个方法里自动补充。排障时应打印启用的客户端与顺序,而不是只看有哪些依赖包。

同一个类里,getServices 会合并各客户端给出的服务名并去重,getInstances 却只返回第一个非空的实例列表。方法看起来都在“查服务”,实际组合结果的方式不同,不能只凭类名推断。服务名出现在目录里,也不代表 getInstances 会从所有注册中心找齐它的节点。

方法的“空结果继续尝试”也不等于“任意异常自动降级”。真实实现中某个客户端抛异常时如何传播,需要沿具体调用链确认;教学模型没有 catch 后假装成功。把连接异常吞成空列表可能提高表面可用性,却丢失注册基础设施正在故障的证据,必须按约定选择并保留诊断信息。

发现结果通常还会被负载均衡层使用,那里可能有健康筛选和独立缓存。本方法既不执行真实 HTTP 请求,也不验证每个下游业务依赖。源码研读的结果应是清晰划分“组合候选、选择候选、实际调用”的职责,并为每层找相应观测信号,避免让一个注册成功日志承担它无法证明的结论。

代码先调用谁,再调用谁

  1. 自动配置创建具体 DiscoveryClient 实现
  2. CompositeDiscoveryClient 按顺序组织多个发现客户端
  3. getInstances(serviceId) 逐个查询并选首个非空列表
  4. LoadBalancer 从实例候选中选择目标
  5. 客户端连接并执行真实请求,报告请求级结果

04动手写一个例子

配置覆盖解析与来源追踪

输入按优先级从高到低排列的配置来源,每个来源有名称与键值映射。每个键采用最先出现的值,同时保留来源名称。空字符串是显式值,不等于缺失;null 值拒绝。此规则是通用教学模型,具体 Spring 来源顺序由框架配置决定。

输入 1
cli:timeout=700;remote:timeout=500,region=jp;default:timeout=1000
输出 1
timeout=700来自cli;region=jp来自remote
输入 2
高优先级title为空串,低优先级title=default
输出 2
title保留空串

想解释某个值为什么生效,单独返回合并后的 Map 不够,还需要保存胜出的来源。把每项结果定义为 value 与 source 的不可变组合。高优先级来源先遍历,第一次见到键就写入,之后遇到相同键不覆盖;这样来源追踪与取值遵守同一条决策路径,不会出现值来自 A、标签却显示 B 的错配。

循环不变量是:处理完前 i 个来源后,结果包含这些来源出现过的全部键,每项来自其中优先级最高的那个。进入第 i+1 个来源时,已有键已经有更高优先级结果,保留即可;新键则只能从当前来源得到首次候选。由归纳可知处理完所有来源得到符合定义的唯一结果。

空字符串必须保留,因为它可能表示主动清空显示文案;null 在本题表示非法配置,不能偷偷当成回退默认值。来源名称也要求唯一,避免审计结果无法定位具体输入。完成合并后再执行数值类型、必填字段和跨字段约束校验,这是两个不同阶段:字符串解析成功不代表它是合法超时或安全地址。

为什么这样写是对的处理完任意前缀来源后,每个已出现键都保留该前缀内优先级最高的非 null 显式值及其真实来源,后续低优先级项无法覆盖它。

时间 通常散列假设下 O(S+E)额外空间 O(U+S)

E 为所有来源键值项总数,U 为唯一键数,S 为来源数。即使所有来源都是空 Map,也要遍历并去重 S 个来源;每个键值项只检查一次。结果保存 U 项,来源去重集合保存 S 项。若字符串散列与比较不是缓存常数,还须加入键和值处理的字符成本。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 44 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    record Source(String name, Map<String, String> values) {}
    record Resolved(String value, String source) {}
    static Map<String, Resolved> resolve(List<Source> ordered) {
        Objects.requireNonNull(ordered);
        Map<String, Resolved> result = new LinkedHashMap<>();
        Set<String> names = new HashSet<>();
        for (Source source : ordered) {
            if (source == null || source.name == null || source.name.isBlank()
                    || !names.add(source.name)) throw new IllegalArgumentException("source");
            Objects.requireNonNull(source.values);
            for (var entry : source.values.entrySet()) {
                if (entry.getKey() == null || entry.getKey().isBlank()
                        || entry.getValue() == null)
                    throw new IllegalArgumentException("entry");
                result.putIfAbsent(entry.getKey(),
                    new Resolved(entry.getValue(), source.name)); // 首次值胜出。
            }
        }
        return Collections.unmodifiableMap(result); // 不让读者修改有效配置。
    }
    static void eq(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        var values = resolve(List.of(
            new Source("cli", Map.of("timeout","700")),
            new Source("remote", Map.of("timeout","500","region","jp")),
            new Source("default", Map.of("timeout","1000"))));
        eq(new Resolved("700","cli"), values.get("timeout"));
        eq(new Resolved("jp","remote"), values.get("region"));
        eq(Map.of(), resolve(List.of())); // 空来源得到空配置。
        eq(new Resolved("","high"), resolve(List.of(
            new Source("high",Map.of("title","")),
            new Source("low",Map.of("title","default")))).get("title"));
        boolean bad = false;
        try { resolve(List.of(new Source("same",Map.of()),new Source("same",Map.of()))); }
        catch (IllegalArgumentException e) { bad = true; }
        if (!bad) throw new AssertionError("ambiguous source accepted");
        System.out.println("PASS 5");
    }
}

这些特殊情况也要试一试

  • 空字符串是显式覆盖,不能误当缺失。
  • 同名来源拒绝,保证来源追踪可解释。
  • null 配置与空来源是不同输入语义。
  • 合并规则不决定具体框架的全部来源排序,调用者必须传正确优先级。
进阶选读 · 配置依赖图、完整校验与原子发布

场景重试总时长依赖单次超时和次数,连接池容量又受实例规模约束,逐个键刷新可能形成不合法组合。

做法把可派生配置之间的依赖构成有向图,拓扑排序计算候选值,发现环则拒绝。对候选快照执行类型、范围和跨字段不变量校验,通过后用 AtomicReference 一次替换不可变对象;读取者在一次请求中持有同一版本快照。

复杂度图构建与拓扑处理 O(V+E),快照存储 O(V);切换引用为常数,但验证、资源重建与旧对象关闭各有成本。

好处与代价原子快照消除混合字段组合,但数据库连接池等有生命周期的资源不能仅替换配置值就生效。需要渐进切换、旧请求排空和资源回收协议。

05项目里怎么用

选择 Spring Cloud 组件时,先按 Spring Boot 版本找到兼容的发布系列,再用该系列的 BOM 管理依赖。BOM 可以理解为一份集中约定组件版本的清单,避免自己拼出一组“每个都很新、合起来却不兼容”的组件。先确认单体业务和基础网络正常,再逐步加入注册中心与远程配置,方便定位新增问题。敏感配置按凭据管理方式保存;诊断页面可以显示配置来源和版本,日志里不要打印秘密值。

实例健康应分清进程存活、是否愿意接收新流量及真实关键依赖状态。注册中心缓存失效期间仍可能发往旧地址,调用端需要有界超时和安全重试。关机时先停止接收新流量并注销或标记不可用,再等待在途请求排空;仅杀掉进程会把故障检测延迟变成用户错误窗口。

修改配置也要像发布代码一样:先检查改动,记录检查结果和恢复旧版本的方法,再让少量运行实例先使用。用真实请求确认超时、访问地址或限流行为符合预期后,再逐步扩大范围。配置平台显示保存成功,只能说明新版本已写入;有的实例可能还没收到通知,或还在使用旧对象。要确认“全部更新了”,应逐一记录实例标识、实际采用的配置版本和业务验证结果。

容易踩的坑

  • 服务发现与配置中心混作同一条启动链。
  • 注册成功就认为业务请求和数据库均健康。
  • 加 optional 后关键配置缺失也启动,却没有安全默认值。
  • 多个发现客户端并存就假定实例列表自动求并集。
  • 只更新配置中心值,没有验证绑定对象是否刷新。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
静态地址与本地配置最容易理解和复现,依赖少。实例变化与多环境管理需要人工或部署系统配合。学习起步、规模小且发布流程清晰。
注册发现加集中配置运行实例和环境参数可集中管理。增加控制平面依赖、缓存一致性和权限管理。实例动态变化且团队有配套运维能力。
不可变制品加部署注入配置配置随发布版本可追踪,回滚明确。部分参数变化需要重新部署。优先可重复交付、动态刷新需求有限。
动态配置快照适合需要快速调整的策略。必须校验、灰度及处理资源生命周期。限流、阈值等可明确验证的运行参数。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01发现客户端返回非空列表能证明实例可用吗?

展开参考答案与推导

不能。它是候选地址证据,可能缓存过期或网络不可达,最终要验证实际连接与业务结果。

02同一组合类的 getServices 与 getInstances 是否必然同样合并?

展开参考答案与推导

不必然。本篇固定源码中服务名合并去重,实例取第一个非空来源,必须阅读具体方法。

03配置中心写入成功为何不代表全部实例生效?

展开参考答案与推导

还需通知、加载、绑定和对象生命周期更新,每个实例可能处于不同版本。

换个情况,还会处理吗

01配置中心显示 timeout=500,应用仍等待两秒,先查什么?

展开参考答案与推导

查最终属性来源和单位、活动 profile、配置版本以及客户端对象实际值,再看整体调用是否还包含连接池等待或重试。

02注册列表有地址但调用连接拒绝,如何定位?

展开参考答案与推导

从调用者环境验证地址与端口,核对服务绑定网卡、注册地址、进程监听和注销缓存;不要先改业务序列化代码。

03变更重试次数后请求总时长突然暴涨,应如何恢复?

展开参考答案与推导

核对次数与超时的组合以及多层重试,回滚到已验证快照;后续增加跨字段预算校验与灰度请求验证。

写代码试一试

01给配置解析器增加必填键检查。

输入 / 条件
有效结果缺少 endpoint
输出 / 预期
拒绝发布候选配置

提示 合并后再验证,不能依赖任一单独来源。

展开参考答案与推导

读取最终结果检查非空合法地址,失败时保留旧快照,测试低优先级补齐与高优先级显式空覆盖。

02实现多个发现列表的首个非空策略。

输入 / 条件
来源一=[],二=[a],三=[b]
输出 / 预期
[a]

提示 不要在命中后继续求并集。

展开参考答案与推导

顺序扫描并立即返回首个非空列表,测试全部为空和首个来源抛异常时的明确策略。

03检测派生配置依赖环。

输入 / 条件
timeout依赖budget,budget依赖retries,retries依赖timeout
输出 / 预期
报告依赖环

提示 可用入度拓扑排序。

展开参考答案与推导

处理完零入度队列后若未访问节点数大于零,存在环;输出剩余节点供诊断,再考虑 DFS 提取具体环。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能说明服务名解析与配置加载链路,并列出注册中心或配置中心不可用时的行为。

本篇重点加练

实例健康与配置来源都具有时效性,必须证明当前调用采用了哪份状态。

  • 实例健康
  • 配置优先级

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    客户端发现同名服务有三个实例,其中一个返回的地址只能在另一网络访问。画出注册信息、发现查询与真实调用的边界。

    提交内容
    调用链图与配置清单
    怎样算完成
    • 说明 DiscoveryClient 返回对象的含义
    • 区分服务名和可达地址

    练到的知识点注册与发现 · DiscoveryClient

  2. 作业 02代码推演

    三实例权重相同,一实例正在停止接收新请求,但发现结果暂未更新。推演十次选择可能产生的行为并定义健康信息的新鲜度需求。

    提交内容
    选择轨迹与健康判断规则
    怎样算完成
    • 区别负载分配和健康判定
    • 包含列表为空时的行为

    练到的知识点负载均衡 · 实例健康

综合应用

  1. 作业 03排错实验

    建立本地最小配置样例,让同一 timeout 键出现在默认文件、环境变量与导入配置中。记录实际有效值及来源,再改变其中一项重复观察。

    提交内容
    配置样例与来源记录
    怎样算完成
    • 使用虚构配置且无凭据
    • 记录启动参数及优先级证据

    练到的知识点配置导入 · 配置优先级

  2. 作业 04设计评审

    应用依赖必要路由配置和可选欢迎语,配置服务启动时不可用。设计启动策略,并用项目依赖清单审查 Spring Boot 与 Spring Cloud 组合。

    提交内容
    启动决策表与版本审查
    怎样算完成
    • 分别处理必要和可选配置
    • 附版本来源及核查日期

    练到的知识点启动失败策略 · 版本兼容矩阵

  3. 作业 05编程实践

    实现本地服务发现与配置的可替换适配器测试,输入重复实例、失效实例、空列表及缺失配置,验证业务模块的可观察行为。

    提交内容
    测试工程与执行记录
    怎样算完成
    • 适配器替身局限明确
    • 每例记录有效配置和选中实例

    练到的知识点注册与发现 · DiscoveryClient · 负载均衡 · 实例健康 · 配置导入 · 配置优先级 · 启动失败策略

  4. 作业 06代码推演

    实例在健康检查通过后立刻停止接收新连接,发现缓存尚未过期。设计一次调用的观察点,分析哪些“健康”结论只在某个时刻成立。

    提交内容
    健康证据时间线
    怎样算完成
    • 区分主动检查和实际请求
    • 记录缓存年龄

    练到的知识点实例健康

  5. 作业 07设计评审

    同一配置键既控制客户端重试,又被管理页面显示,二者采用不同配置对象。评审配置绑定时点和刷新后的“一致生效”声明。

    提交内容
    配置依赖与版本表
    怎样算完成
    • 不只比较文本配置值
    • 包含派生对象的旧值

    练到的知识点配置优先级

  6. 作业 08编程实践

    在本地桩中建立带版本的实例列表和客户端配置,记录每次调用使用的两种版本;注入健康更新滞后与配置刷新失败。

    提交内容
    演示程序及逐调用记录
    怎样算完成
    • 能区分变更发布与客户端采用
    • 不模拟真实注册中心一致性协议

    练到的知识点实例健康 · 配置优先级

进阶挑战 · 掌握前置后选做

  1. 作业 09面试场景

    一次灰度发布只有一半实例使用新限流阈值。设计从依赖版本、配置来源到实例状态的排查顺序,并定义安全结束灰度的条件。

    提交内容
    排查树与验收清单
    怎样算完成
    • 按实例核对而非只看配置中心
    • 包含更新失败和回退

    练到的知识点实例健康 · 配置导入 · 配置优先级 · 启动失败策略 · 版本兼容矩阵

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    如果同一服务的部分实例只支持新协议,而客户端配置又允许热切换协议,怎样定义“可用实例”?设计能暴露错误混用的实验。

    提交内容
    兼容性定义与反例样本
    怎样算完成
    • 健康不只依赖进程存活
    • 覆盖过渡期双版本配置

    练到的知识点实例健康 · 配置优先级

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗