本节目标:把「能跑的服务」变成「敢上线的服务」——掌握上线检查清单、灰度与分批发布策略、上线后必看的指标、基于 SLO 的告警阈值,以及代码 / 配置 / 特性开关 / 数据四类回滚的取舍与复盘方法。
适用版本:Spring Boot 4.1.x(Java 21)
18.3 上线、观测与回滚
18.2 结束时服务能在本地跑通、集成测试全绿。但「本地能跑」和「敢上线」是两件事。上线真正难的不是发布动作本身,而是发布之后你能不能立刻知道它好不好,出问题时能不能退回去。
本节按「发之前 → 发的时候 → 发之后 → 出问题」四段来讲,最后收束全书。
18.3.1 上线检查清单
发布前逐项核对,任何一项没确认就不发。这份清单是把 15.2 Kubernetes 部署 与 15.3 健康探针与优雅停机 的结论固化成动作:
| 类别 | 检查项 | 依据 |
|---|---|---|
| 配置 | 环境 profile 与外部配置源正确 | 3.1 多环境配置 |
| 密钥 | 数据库口令、JWT 签名密钥来自密钥管理,不在镜像里 | 3.2 密钥管理 |
| 迁移 | Flyway 脚本已在预发执行且可回滚评估 | 12.1 事务边界 |
| 探针 | liveness / readiness 已配置且语义正确 | 15.3 探针 |
| 资源 | CPU / 内存 limit 与 request 合理 | 17.3 JVM 调优 |
| 日志 | 生产日志级别为 INFO,敏感字段已脱敏 | 16.3 日志聚合 |
| 镜像 | 分层镜像、基础镜像已扫描 | 15.1 镜像分层 |
探针语义必须分清:readiness 失败会摘流量,liveness 失败会重启容器。数据库连不上时应该只让 readiness 失败(摘流量、等恢复),而不是让 liveness 失败(无限重启)。4.0 起 liveness/readiness 探针默认启用,如需关闭用 management.endpoint.health.probes.enabled。
优雅停机同样要在清单里确认。应用要能在收到终止信号后先停止接收新请求、再等待在途请求完成:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
少了这段,滚动发布时正在处理的借还请求会被直接切断,用户看到 502。
探针要让依赖健康与自身存活分开。业务接口的 readiness 应包含数据库与关键依赖;而 liveness 只反映进程是否卡死,不该包含外部依赖。4.0 起这两组探针默认启用,需要显式控制时用已确认的属性:
management:
endpoint:
health:
probes:
enabled: true
判据很简单:重启这个实例能不能修好? 能(进程卡死、死锁)就归 liveness;不能(数据库宕机、MQ 不可达)就归 readiness。把外部故障归到 liveness,重启风暴只会让故障扩散。
18.3.2 灰度与分批发布
三种策略没有绝对优劣,看你能承受多长的发布窗口、有没有可靠的回退路径:
| 策略 | 做法 | 适用条件 | 代价 |
|---|---|---|---|
| 滚动 | 逐批替换实例 | 无状态、变更小 | 新旧版本短暂共存 |
| 蓝绿 | 起全量新版本,切流量 | 需要秒级回退 | 双倍资源 |
| 金丝雀 | 先放少量流量到新版本 | 变更风险高、有指标可判 | 需要流量切分与观测 |
借还服务属于有状态(数据库共享)的核心链路,推荐金丝雀:先放一小部分流量到新版本,观察一段时间(借出成功率、错误率、P99)无异常再逐步放大。
金丝雀能成立的前提是新旧版本能共存——即数据库与接口都是向后兼容的。如果新版本改了表结构让旧版本读不了,金丝雀就退化成「一起崩」。这就是下一节要讲的向前兼容。
18.3.3 上线后看什么
发布完成不等于结束,接下来要盯指标。指标清单见 16.1 Actuator 与指标 。分四类:
| 类别 | 指标 | 为什么看 |
|---|---|---|
| RED | 请求速率、错误率、P99 延迟 | 最直接的健康信号 |
| 资源 | HikariCP 活跃连接、JVM 堆、GC 暂停 | 定位瓶颈 |
| 依赖 | 数据库、MQ 的调用延迟与错误 | 外部故障的第一现场 |
| 业务 | 借出成功率、还书成功率、逾期任务完成数 | 技术指标正常但业务出错 |
业务指标最容易被忽略,却最关键。 一个接口可能 200 正常、P99 正常,但「借出成功率」掉了——因为业务规则判断把大量正常请求拒了。技术指标和业务指标必须一起看。
看 GC 与连接池时注意区分现象与原因。下面是一段示例输出(非本机实测),说明该看哪些字段:
# HikariCP 活跃连接(示例输出)
hikaricp_connections_active{pool="HikariPool-1"} 47
hikaricp_connections_pending{pool="HikariPool-1"} 3
# JVM GC 暂停(示例输出)
jvm_gc_pause_seconds_max{action="end of major GC"} 0.21
活跃连接长期贴住上限、pending 大于 0,说明连接不够用;此时先看慢查询,而不是盲目调大池——调大只会把压力转嫁给数据库,见 11.1 HikariCP 调优
。
18.3.4 告警阈值基于 SLO,不是拍数字
「错误率超过 1% 就告警」这种拍出来的阈值,要么天天误报(团队麻木),要么出事不报。正确做法是先定 SLO,再由 SLO 推导阈值。
| 概念 | 含义 | 本例 |
|---|---|---|
| SLI | 可测量的服务水平指标 | 借还接口成功请求占比 |
| SLO | SLI 的目标值 | 月度 99.9% |
| Error Budget | 允许的失败额度 | 月度 0.1% 的时间/请求 |
| 告警 | 预算消耗过快时触发 | 快速燃烧率触发 |
告警不直接盯着「错误率 > X%」,而是盯错误预算的燃烧速率:短时间内烧得太快才告警。这样慢性的轻微劣化不会半夜吵醒人,而急性故障会立刻触发。
燃烧率的算法:把 SLO 的失败额度平摊到窗口上,算当前消耗是它的多少倍。例如月度 SLO 99.9%,额度是 0.1%;若某 1 小时窗口内失败率 2%,则消耗速率约为 2% / 0.1% = 20 倍。常见的双窗口配置:
| 窗口 | 燃烧率阈值 | 含义 | 处理 |
|---|---|---|---|
| 1 小时 | 14 倍 | 约 2 天烧完一个月额度 | 立即告警 |
| 6 小时 | 6 倍 | 约 5 天烧完 | 工单跟进 |
| 3 天 | 1 倍 | 正常消耗 | 不告警 |
阈值不是拍出来的,是从「多久烧完额度」反推出来的。这样调参有依据,团队也信任告警。
业务 SLO 同样要定。例如「借出成功率」,它不等于 HTTP 成功率——被业务规则正确拒绝的请求是成功的(用户看到明确提示),但因 bug 误拒的是失败的。这两者要靠错误码区分,见 18.2 的错误码设计。
18.3.5 出问题时的回滚决策
回滚有四类,代价与速度差别很大,先选代价最小的:
| 类型 | 手段 | 速度 | 适用 |
|---|---|---|---|
| 配置回滚 | 改回配置项、重启或热加载 | 快 | 阈值、开关、限流参数调错 |
| 特性开关 | 关掉新功能开关 | 快 | 新功能有问题、老功能正常 |
| 代码回滚 | 回退到上一个镜像 | 中 | 代码缺陷 |
| 数据回滚 | 恢复数据或反向迁移 | 慢、危险 | 数据被写坏 |
特性开关应该是新功能默认带的。把高风险新逻辑(比如新的逾期罚金算法)包在开关后面,出问题直接关,比回滚代码快得多,也不用等镜像重新部署。用配置属性承载开关,配合 3.1 多环境配置 的配置源:
@Component
class FineCalculator {
private final boolean newAlgorithm;
private final FineRule newRule;
private final FineRule legacyRule;
FineCalculator(@Value("${app.fine.new-algorithm:false}") boolean newAlgorithm,
FineRule newRule, FineRule legacyRule) {
this.newAlgorithm = newAlgorithm;
this.newRule = newRule;
this.legacyRule = legacyRule;
}
BigDecimal calculate(Loan loan, LocalDate today) {
return (newAlgorithm ? newRule : legacyRule).fine(loan, today);
}
}
开关默认 false,新算法先在小流量租户上打开验证,稳定后再全局放开。开关要成对清理——功能稳定后把旧分支删掉,否则开关会越积越多,代码里全是 if。
代码回滚要能「一键回退到上一个版本」,前提是镜像打了不可变标签(用 commit 或版本号,不用 latest)。用 latest 的团队,出事时根本不知道上一个版本是哪个。
数据回滚最难,因为数据库迁移往往不可逆。 DROP COLUMN 之后就回不来了。所以迁移必须写成向前兼容的,让新旧版本能同时跑:
-- 第 1 步:加列,允许为空(旧版本不写这列,也不受影响)
ALTER TABLE loan ADD COLUMN fine_amount NUMERIC(10,2);
-- 第 2 步:回填历史数据(分批,避免长事务)
UPDATE loan SET fine_amount = 0 WHERE fine_amount IS NULL;
-- 第 3 步:新版本开始写这列;观察稳定后,才加 NOT NULL 约束
ALTER TABLE loan ALTER COLUMN fine_amount SET NOT NULL;
-- 第 4 步:确认旧版本彻底下线后,才考虑删旧列(通常放到下一个版本)
删列、改名、改类型这类破坏性操作永远分两次发布:先兼容,再清理。这样任何一步出问题,旧版本都还能正常读写,回滚不会遇到「表结构已经变了」的死局。
18.3.6 一次事故的复盘模板
事故复盘的目标是改流程,不是追责。用固定模板,保证每次都不漏项:
| 栏目 | 内容 |
|---|---|
| 标题与等级 | 例如「借还接口 P99 超 2s,P2」 |
| 时间线 | 发现、定位、缓解、恢复各时间点 |
| 影响面 | 受影响租户、请求量、持续时间、资损 |
| 根因 | 直接原因 + 深层原因(为什么没被更早发现) |
| 改进项 | 每条有负责人与截止日期 |
| 复盘结论 | 是流程问题、技术问题,还是两者 |
时间线必须用监控数据说话,不靠回忆。这也是 18.1 里把埋点列为「第一天决策」的原因——没有历史数据,复盘只能靠猜。改进项要能验证,例如「增加借出成功率告警」比「加强监控意识」有用得多。
18.3.7 发布演练与预发验证
上线前最后一次「预演」,能在预发环境暴露的问题,绝不带到生产。演练清单:
- 迁移预演:在预发用生产规模的近似数据量跑一次 Flyway 脚本,确认加索引 / 加列的耗时与锁表风险,见 17.2 压测 的思路。
- 回滚预演:真的回退到上一个镜像,确认服务能正常启动、能读新表结构(向前兼容的验证)。
- 探针预演:手动断开数据库,确认 readiness 变红、实例被摘流量,而 liveness 保持绿、不触发重启。
- 容量预演:用压测工具打到预期峰值的若干倍,观察 HikariCP 与 GC 是否撑得住,必要时用 17.1 Profiling 定位热点。
预发环境最大的价值是能安全地演练回滚——生产上没人愿意为了测试回滚能力而真的回退一次。把回滚演练固化到每次发布的流程里,出事时才不会手忙脚乱。
18.3.8 常见坑
- 检查清单靠脑子记。 每次上线都漏不同的项,把清单写成脚本或 PR 模板强制过。
- 探针语义写反。 数据库故障让 liveness 失败,容器无限重启,雪上加霜。
- 用
latest标签。 出事时找不到上一个可回滚的版本。 - 迁移写成破坏性操作。 加非空列、删列、改类型一次性做,回滚无路。
- 告警阈值拍数字。 误报让团队麻木,真故障反而被淹没。
- 只看技术指标。 HTTP 全 200,但业务成功率掉了,一样是事故。
- 从不演练回滚。 回滚路径没测过,出事时才发现回不去,比不回滚更糟。
- 上线后立刻走人。 发布完就散场,没留观察窗口,故障往往在无人看护时爆发。
小结
到这里,全书走完了一个完整闭环:从模块拆分、配置与测试,到数据访问、安全、异步、可观测,最后在 18.3 收束成一次可交付、可观测、可回滚的生产上线。
- 上线前用检查清单核对配置、密钥、迁移、探针、资源与日志,不靠记忆。
- 探针语义要分清:readiness 摘流量、liveness 重启;优雅停机保证在途请求不被切断。
- 灰度策略看变更风险与回退能力:滚动最简、蓝绿回退最快、金丝雀最稳。
- 上线后四类指标一起看:RED、资源、依赖、业务,业务指标最容易被漏。
- 告警阈值由 SLO 推导,盯错误预算燃烧率,而不是拍一个百分比。
- 回滚优先选代价小的:配置与开关最快,代码次之,数据最难;迁移永远向前兼容、分两次发布。
真正的生产级不是「用了多少组件」,而是每一次变更都有据可查、每一种故障都有退路。检查清单让变更可控,指标与 SLO 让故障可见,回滚与向前兼容让退路存在。把这三节当成一套模板,换一个业务领域,流程依然成立。
发布窗口也值得挑:避开业务高峰与节假日前夕,给观察与回滚留出足够的人手和时间。一次在深夜无人值守时上线的变更,即使问题本身很小,也可能因为没人及时处置而放大成事故。
至此,《Spring Boot 实战》从需求到上线走完了完整的一遍。愿你交付的每一个服务,都既有速度,也有退路。
阅读导航:上一节:18.2 实现与联调 · 下一节:目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。