一次发布后服务全部"健康",但前十分钟的请求大量失败。原因不是服务没起来,而是它起来得太早:连接池已建立,缓存客户端已连上,但缓存里的基础数据(字典、配置、路由表)要等一个初始化任务跑完才加载。健康检查只看端口,于是流量在数据就绪前就打进来。
这个问题的特殊之处是它只在新实例出现时暴露:全量重启、扩容、机器故障后的重建。所以它可能在半年里只出现几次,每次都被当成"发布的偶发失败",而不会被归到一个固定的原因上。
启动顺序常常只在"第一次部署"时被认真对待,之后被"反正能起来"取代。但能起来与能服务是两件事,而健康检查通常只测前者。
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. 自测
- 四类依赖里,哪两类最容易被混为一谈?为什么它们的处理方式必须不同?
- 三种就绪信号中,哪一类才是"流量可以进来"的条件?只测前两类会漏掉什么?
- "无限等待重试"为什么比"快速失败"更危险?它会造成什么静默故障?
- 滚动发布里"先接流量后预热"为什么最危险?预热耗时超过一分钟时该怎么做?
- 扩展-收缩三步分别做什么?跳过第二步会造成什么?
- 判断"是否需要严格排序"的那个问题是什么?