本文来自一次 Spring Boot 2 与 3 混部集群的真实排障。服务名、集群、地址和业务数据均已泛化。系列导航:Kubernetes 重建迁移教程系列。
一次全局 Redis 连接池优化发布后,大部分 Spring Boot 3 服务运行正常,但少量网关和 WebSocket 服务开始反复报告:
Unable to connect to localhost:6379
Redis 明明部署在 Kubernetes Service 后面,Nacos 中也存有正确地址。更有误导性的是:部分旧服务启动成功,过一段时间才出现异常;另一些服务在补充连接池参数后,直接因为缺少 commons-pool2 而无法启动。
最终确认,这不是 Redis、Service DNS 或网络故障,而是三件事叠加:
- Spring Boot 2 与 3 使用不同的 Redis 属性前缀。
- Redis 客户端可能延迟到第一次业务访问时才真正建连。
- 配置连接池参数会改变自动配置分支,缺少依赖时反而让应用启动失败。
先看事故链路
flowchart LR
A["全局配置改为 spring.data.redis"] --> B["Boot 3 正常绑定"]
A --> C["Boot 2 忽略新前缀"]
C --> D["保留默认 localhost:6379"]
D --> E["首次访问 Redis 时才连接"]
E --> F["业务运行后暴露异常"]
C --> G["盲目补充 Lettuce pool 参数"]
G --> H["缺少 commons-pool2"]
H --> I["新 Pod 启动失败"]
这个链路解释了为什么“配置已经在 Nacos”“Pod 已经 Ready”和“Redis 实际可达”可以同时成立,业务却仍然访问 localhost:6379。
根因一:Boot 2 与 Boot 3 的前缀不是同一个
Spring Boot 2 的常见写法:
spring.redis.host=redis-service.app.svc.cluster.local
spring.redis.port=6379
spring.redis.database=0
spring.redis.timeout=3s
Spring Boot 3 则使用:
spring.data.redis.host=redis-service.app.svc.cluster.local
spring.data.redis.port=6379
spring.data.redis.database=0
spring.data.redis.timeout=3s
Spring Boot 的官方属性清单也能直接验证这个版本边界:Boot 2.7 使用 spring.redis.*,Boot 3.5 使用 spring.data.redis.*;两者的默认 host 都是 localhost,默认端口均为 6379。
spring.redis.host 与 spring.data.redis.host 并不是 relaxed binding 能自动互换的同一个属性。旧应用读到后者时,不一定会报“未知配置”,而是可能继续使用客户端默认值:
host = localhost
port = 6379
因此,日志里的 localhost:6379 是非常有价值的证据:它通常说明应用没有绑定到预期地址,而非集群把 Redis 域名解析到了本机。
根因二:启动成功不代表 Redis 配置正确
不少服务不会在 Spring Context 初始化阶段立即访问 Redis。例如:
- 只有用户登录后才读取 Session。
- 只有 WebSocket 建连后才写在线状态。
- 定时任务到点后才获取分布式锁。
- 某个低频接口才会访问缓存。
这类服务可能先通过 startup、readiness 甚至 liveness 探针,等真实请求触发连接时才出现异常:
RedisConnectionFailureException
Unable to connect to localhost:6379
所以“新 Pod 已经 Ready”只能说明当前探针检查通过,不能证明所有延迟初始化的外部依赖都可用。
第 1 步:先建立版本与配置矩阵
不要根据仓库名称猜 Spring Boot 版本。可以从构建文件、启动日志或可执行 JAR 中确认:
unzip -l app.jar | grep 'BOOT-INF/lib/spring-boot-[0-9]'
然后建立最小矩阵:
| 服务 | Boot 版本 | Redis 前缀 | 是否启动建连 | 是否有 commons-pool2 |
|---|---|---|---|---|
| service-a | 3.x | spring.data.redis | 否 | 是 |
| service-b | 2.3.x | spring.redis | 否 | 否 |
| service-c | 2.7.x | spring.redis | 是 | 是 |
这张表比“一次性修改全局配置,然后观察谁报错”可靠得多。尤其要把暂未访问 Redis 的服务列为潜在风险,而不是标记为正常。
第 2 步:排除 DNS、Service 和 Redis 自身
从出问题的真实业务 Pod 内验证:
getent hosts redis-service.app.svc.cluster.local
nc -vz redis-service.app.svc.cluster.local 6379
再从集群侧确认 Service 和 Endpoint:
kubectl -n app get svc redis-service
kubectl -n app get endpointslice \
-l kubernetes.io/service-name=redis-service
如果域名能解析、端口能连接、Endpoint 非空,而异常地址明确是 localhost:6379,排查重点就应该转向属性绑定和配置优先级。
第 3 步:确认 Nacos 的实际属性来源
Nacos 中“能搜索到某个 key”不等于应用最终使用了它。至少要确认:
- namespace 是否正确。
- group 是否正确。
- dataId 是否被当前应用加载。
- shared、extension 和 application dataId 的优先级。
- 环境变量、命令行参数是否覆盖 Nacos。
- 发布后客户端是否真正刷新了配置。
建议在变更单中记录:
application -> namespace -> group -> dataId -> key -> expected value
同时应回读刚发布的配置并比对内容或校验值,不能只依赖控制台上的“发布成功”提示。
最小风险修复:在旧应用 dataId 中添加兼容别名
如果全局配置已经按 Spring Boot 3 统一,可以只在仍使用 Boot 2 的应用级 dataId 中加入:
spring.redis.host=${spring.data.redis.host}
spring.redis.port=${spring.data.redis.port}
spring.redis.database=${spring.data.redis.database}
spring.redis.timeout=${spring.data.redis.timeout}
这样做有三个好处:
- Redis 地址仍由一组 Boot 3 全局属性维护。
- 兼容范围只覆盖旧应用,不会让所有 Boot 3 服务长期携带废弃 key。
- 应用升级后,可以按 dataId 精确删除临时别名。
不要为了“兼容所有版本”把两套前缀永久放进全局配置。这样会扩大配置面,也会让 Boot 3 应用持续触发属性迁移或废弃警告。
如果密码来自 Secret 或独立 dataId,也应引用已有值,不要在兼容配置中复制明文。
一个容易踩中的二次事故:连接池参数会激活新代码路径
排障时很容易顺手补上:
spring.redis.lettuce.pool.max-active=16
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=2
spring.redis.lettuce.pool.max-wait=2s
但连接池配置不是“无依赖的性能参数”。对于缺少 Apache Commons Pool 2 的旧应用,它可能让自动配置进入池化分支,并在启动时失败:
NoClassDefFoundError:
org/apache/commons/pool2/impl/GenericObjectPoolConfig
Spring Boot 的属性说明也明确将连接池的可用性与 commons-pool2 关联在一起;因此不能只把 pool key 当成 Nacos 侧的数值优化。
如果业务确实需要 Lettuce 连接池,应先在代码中显式加入依赖并完成构建验证:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
事故止血阶段更稳妥的做法是:
- 只补齐
host、port、database和timeout。 - 不启用尚未验证依赖的 pool 参数。
- 把连接池优化作为独立的代码变更和容量测试。
配置变更不仅会改变数值,也可能改变 Spring Boot 的条件装配结果。这一点必须纳入评审清单。
为什么滚动发布没有把旧服务一起打挂
在 Deployment 默认的滚动更新策略下,新 Pod 启动失败时,旧 Pod 通常会保留下来并继续提供服务。这是好事,但也容易制造假象:
Deployment 有可用副本 ≠ 新版本发布成功
发布后必须同时检查:
kubectl -n app rollout status deployment/<deployment> --timeout=10m
kubectl -n app get pod -l app=<application> -o wide
kubectl -n app get rs -l app=<application>
kubectl -n app logs <new-pod> --since=20m
如果旧 ReplicaSet 仍有副本、新 ReplicaSet 的 Pod 在重启,业务可能暂时可用,但发布已经失败。
推荐的滚动修复顺序
- 备份目标 dataId 的原始内容和校验值。
- 先选一个低风险的 Boot 2 服务,加入四个兼容 key。
- 回读 Nacos,确认 namespace、group、dataId 和内容。
- 滚动该 Deployment,并等待新 ReplicaSet 全部 Ready。
- 触发一条真实的 Redis 读写路径,而不是只检查健康接口。
- 搜索新 Pod 日志中的
localhost:6379、连接异常和类缺失。 - 验证通过后,再按版本矩阵逐个处理其余旧服务。
建议的日志检查:
kubectl -n app logs deployment/<deployment> --since=30m \
| grep -Ei 'localhost:6379|RedisConnection|commons.pool2|GenericObjectPool'
需要同时检查之前看似正常的 Boot 2 服务,因为它们可能只是尚未真正访问 Redis。
完成定义
这次修复不能只以“Pod 不重启”作为完成标志。完成标准至少包括:
- 所有使用 Redis 的服务都已确认 Spring Boot 版本。
- Boot 2 服务均存在可解释的应用级兼容配置。
- 新 Pod 全部来自目标 ReplicaSet,且 Ready。
- 真实 Redis 业务路径读写成功。
- 当前与近期日志不再出现
localhost:6379。 - 未配置连接池的服务不会意外加载 pool 分支。
- 每个兼容别名都有升级和删除条件。
最终结论
这次事故最关键的教训不是“记住两个 Redis 前缀”,而是配置中心治理必须带着客户端版本一起做。
全局配置可以统一连接地址,但不能假设不同 Spring Boot 大版本拥有完全相同的绑定规则;Pod Ready 可以证明进程暂时可服务,但不能覆盖延迟建连;一个看似普通的连接池参数,也可能改变自动配置路径并引入新的运行时依赖。
更稳妥的治理方法是:先建立版本矩阵,以新属性作为唯一事实来源,在旧应用 dataId 中做最小兼容,通过真实业务路径验证,最后随着升级逐项删除兼容层。
DISCUSSION
讨论与反馈