2450 字
约 8 分钟
17
S17-A24 · 服务启动的依赖顺序:谁必须等谁

一次发布后服务全部"健康",但前十分钟的请求大量失败。原因不是服务没起来,而是它起来得太早:连接池已建立,缓存客户端已连上,但缓存里的基础数据(字典、配置、路由表)要等一个初始化任务跑完才加载。健康检查只看端口,于是流量在数据就绪前就打进来。

这个问题的特殊之处是它只在新实例出现时暴露:全量重启、扩容、机器故障后的重建。所以它可能在半年里只出现几次,每次都被当成"发布的偶发失败",而不会被归到一个固定的原因上。

启动顺序常常只在"第一次部署"时被认真对待,之后被"反正能起来"取代。但能起来与能服务是两件事,而健康检查通常只测前者。


1. 四类依赖

类型 例子
硬依赖(不可用则无法服务) 数据库、配置中心
软依赖(不可用则降级) 缓存、推荐服务
数据依赖(需要预热) 字典表、路由表、限流规则
顺序依赖(A 起了 B 才能起) 迁移任务、注册中心

第二类与第三类最容易被"混为一谈":缓存不可用可以降级到数据库,但字典表没加载就等于不知道业务规则。前者应当容忍,后者必须等待。

第四类是部署层面的:数据库迁移没跑完,新版本代码查询新列就会 500。这类问题在滚动发布里非常常见。

2. 三种就绪信号

信号 含义
进程存活 端口可连
依赖可连 下游握手成功
业务就绪 数据加载完、预热完

三种信号最好落成三个不同的端点:/livez 只回答"进程要不要被杀",/readyz 回答"能不能接流量",业务侧再有一个 /healthz 汇总关键依赖。混成一个端点,就会导致"依赖抖一下就重启"。

第三项才是流量可以进来的条件,而很多系统只实现前两项。只测前两项的健康检查,在"数据依赖"这类场景里等于没有检查。

// 就绪探针:把"业务就绪"写成显式状态
@Component
public class ReadinessGate {
    private final AtomicBoolean dataReady = new AtomicBoolean(false);
    @EventListener(ApplicationReadyEvent.class)
    public void load() {
        dictCache.loadAll();          // 字典
        routeTable.reload();          // 路由
        rateRules.refresh();          // 限流规则
        dataReady.set(true);
    }
    public boolean isReady() { return dataReady.get(); }
}
// /readyz 返回 200 仅当 isReady();否则 503,负载均衡不导流

3. 硬依赖的处理:等待还是快速失败

策略 适用
无限等待重试 依赖可能长时间不可用
有限重试后退出 编排会重启,形成收敛
快速失败 + 就绪探针 依赖恢复后自然就绪

第二项与第三项的选择取决于"谁来做重试":如果编排会重启,就别在进程内无限重试;如果进程退出后编排不重启(如某些托管环境),进程内重试才有意义。这个判断要写在部署文档里。

第一项最危险:进程卡在启动阶段,编排以为它在运行,于是既不重启也不告警,故障静默延长。

第二项与第三项是主流:失败后退出,让编排用重启做重试;或活着但"未就绪",让流量进不来。两者都必须有上限,否则会变成无限循环的重启风暴。

// 启动重试:有限次数 + 退避,超过就退出交给编排
startup:
  retry:
    max_attempts: 30
    backoff: "1s,2s,4s,8s,16s,30s"   # 之后固定 30s
  on_exhausted: exit                 # 而不是继续等待

4. 数据预热的顺序问题

顺序 问题
先接流量后预热 前几分钟错误率高
先预热后接流量 启动变慢,但错误少
增量预热(分批可用) 复杂,但最快可用

第一种在滚动发布里最危险:旧实例已下线,新实例还没预热完,中间几分钟全量请求打到未就绪实例。

第三种是高级做法:把"可用"定义成部分可用,例如先加载热点字典再加载全量。它需要明确的"降级可用"语义,通常只在超大系统里值得做。

// 预热耗时与发布窗口的关系
预热 10s、实例 10 个、滚动批次 2 个 → 约 50s 内完成,可接受
预热 3min、实例 10 个、滚动批次 2 个 → 6min 期间可用容量只有一半
// 结论:预热耗时超过 1 分钟时,必须配合"先扩容后缩容"或分批就绪

5. 部署层面的顺序依赖

依赖 必须的顺序
数据库迁移 先迁移(向后兼容)再发代码
配置变更 先发布配置(兼容旧代码)再发代码
消息格式变更 先兼容双读,再切写
接口下线 先确认无调用方

第四项(接口下线)也有顺序:先停止新调用方接入,再迁移存量,最后下线。反过来做的结果是"下线当天发现还有一个在用",而此时回滚成本最高。

第一项有个关键前提:迁移必须向后兼容(只加列、不改语义),否则旧版本实例会立刻出错。这也是"扩展-收缩"模式的核心:先扩展(新旧都能用),再收缩(删旧)。

第三项同理:消费者先支持新旧两种格式,生产者再切换——顺序反了就会出现"消息读到但解析失败"。

// 扩展-收缩:一次结构变更的三步
1. 扩展:加新列/新字段,代码双写,读旧
2. 迁移:后台回填历史数据,读新(带兜底)
3. 收缩:删旧列/旧字段,代码只读写新
// 任何一步都要能独立回滚;跳过第 2 步直接收缩 = 数据丢失

把顺序依赖画成一张表比写成文字可靠,因为它能暴露"环":

// 启动依赖表(含环检测)
配置中心 ← 无依赖
数据库   ← 配置中心
迁移任务 ← 数据库
应用实例 ← 迁移任务,数据库,配置中心
流量接入 ← 应用实例(就绪探针通过)
// 出现 A ← B 且 B ← A 时,必须有一方改成异步或容忍未就绪

6. 排错:五种"启动相关的故障"

现象 成因 处理方向
发布后前几分钟 5xx 未就绪就接流量 加业务就绪探针
服务反复重启 硬依赖无限重试 定上限并退出
新版本查不到新列 迁移未先执行 迁移与发布分离
部分实例行为不同 配置未全部下发 校验实例配置一致
扩容后新实例慢 预热未完成 分批就绪或先扩容

还有一个观测上的盲区:"启动耗时"很少被当成指标。它其实非常有用——启动时间突然从 20 秒变成 90 秒,通常意味着某个初始化逻辑在退化,而这个退化会在下次紧急扩容时变成"扩不出来"。

第五项在弹性扩容时很典型:新实例因缓存冷启动而变慢,负载均衡按"连接数"分配,于是新实例被压垮——它看起来健康,只是慢。

7. 边界:什么情况不必严格排序

场景 为什么 做法
无状态、无预热的服务 起来即就绪 只做存活探针
单实例本地开发 顺序无所谓 不折腾
依赖可降级的软依赖 不影响服务 容忍不可用
停机维护窗口内 无流量 直接起

最后一条与"测试"有关的边界:启动顺序的问题几乎无法被单元测试覆盖,只能靠"在预发做一次完整滚动发布"来验证。所以它属于"必须靠流程保证"的一类,而不是"靠代码写好"。

判断口径一句话:在这个服务"起来了但没就绪"的那段时间里,请求进来会发生什么。会出错,就必须有第三类就绪信号;只是慢一点,前两类够用。


8. 自测

  1. 四类依赖里,哪两类最容易被混为一谈?为什么它们的处理方式必须不同?
  2. 三种就绪信号中,哪一类才是"流量可以进来"的条件?只测前两类会漏掉什么?
  3. "无限等待重试"为什么比"快速失败"更危险?它会造成什么静默故障?
  4. 滚动发布里"先接流量后预热"为什么最危险?预热耗时超过一分钟时该怎么做?
  5. 扩展-收缩三步分别做什么?跳过第二步会造成什么?
  6. 判断"是否需要严格排序"的那个问题是什么?
S17-A24 · 服务启动的依赖顺序:谁必须等谁
https://www.clxhxhhr.top/posts/6463/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。