本文来自一次多服务连接数与容器内存的治理实践。地址、账号、命名空间和业务名称均已泛化。配置值是保守起点,必须结合数据库、Redis、RabbitMQ 容量和业务压测调整。
当几十个 Spring Boot 服务共同读取一个 global.properties 时,任何“顺手优化”都会被副本数放大:
每 Pod 多 8 个 Redis 空闲连接
× 60 个 Pod
= 480 个额外连接
RabbitMQ 也一样。几十个 @RabbitListener、多个消费者线程和较大的 prefetch 相乘后,既会增加 Channel 和线程,也会让大量未确认消息留在 JVM Heap。
因此,一份“完整 Nacos 配置”不能只是把可用参数全部填上。它应该明确:
- 哪些是所有 Spring Boot 3 服务共享的安全基线。
- 哪些只能放在应用级 dataId。
- 哪些敏感值必须来自 Kubernetes Secret。
- 哪些旧 Boot 服务需要临时兼容层。
- 每个连接池上限如何与 Pod 数量一起核算。
推荐的配置分层
flowchart TD
A["Kubernetes Secret / 环境变量"] --> D["最终 Environment"]
B["global.properties<br/>Boot 3 保守基线"] --> D
C["application.properties<br/>应用例外"] --> D
E["Deployment 环境变量<br/>强制运行基线"] --> D
D --> F["Spring Boot 属性绑定"]
建议边界:
| 层 | 存放内容 | 示例 |
|---|---|---|
| Secret | 密码、Token、证书 | Redis、RabbitMQ、数据库密码 |
| global dataId | 同版本、同语义的保守参数 | timeout、探针、默认并发 |
| app dataId | 业务特有容量和兼容项 | pool、批任务、Boot 2 别名 |
| Deployment | 与容器直接相关且必须强制的项 | JVM、HOME、诊断开关 |
同一个 key 不应在四层中重复维护。Spring 属性源具有优先级,一旦 Deployment 环境变量覆盖了 Nacos,控制台显示发布成功也不代表运行值已经改变。
一份可审阅的 Spring Boot 3 全局模板
以下内容使用占位符,不包含生产密码。先把它当作候选基线,而不是直接全量发布。
# ------------------------------------------------------------
# Web 与反向代理
# ------------------------------------------------------------
server.forward-headers-strategy=framework
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
# 仅适用于 Servlet / Tomcat 服务;WebFlux 服务会忽略这些项
server.tomcat.threads.max=100
server.tomcat.threads.min-spare=10
server.tomcat.accept-count=100
server.tomcat.max-connections=2000
server.tomcat.connection-timeout=10s
# ------------------------------------------------------------
# Actuator 与 Kubernetes 探针
# ------------------------------------------------------------
management.endpoint.health.probes.enabled=true
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true
management.endpoint.health.show-details=never
management.endpoints.web.exposure.include=health,info,prometheus,serviceregistry
# ------------------------------------------------------------
# HikariCP:只对使用 Spring 默认 Hikari DataSource 的服务生效
# ------------------------------------------------------------
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.validation-timeout=1000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=120000
# ------------------------------------------------------------
# Spring Data Redis / Lettuce
# 地址不是 Secret,密码从环境变量读取
# ------------------------------------------------------------
spring.data.redis.host=redis-service.app.svc.cluster.local
spring.data.redis.port=6379
spring.data.redis.database=0
spring.data.redis.password=${REDIS_PASSWORD:}
spring.data.redis.connect-timeout=3s
spring.data.redis.timeout=3s
spring.data.redis.client-type=lettuce
spring.data.redis.lettuce.shutdown-timeout=100ms
# 默认不启用连接池,避免 commons-pool2 出现在 classpath 后
# 每个 Pod 自动建立一批空闲连接
spring.data.redis.lettuce.pool.enabled=false
# ------------------------------------------------------------
# RabbitMQ
# ------------------------------------------------------------
spring.rabbitmq.addresses=rabbitmq.app.svc.cluster.local:5672
spring.rabbitmq.username=${RABBITMQ_USERNAME}
spring.rabbitmq.password=${RABBITMQ_PASSWORD}
spring.rabbitmq.virtual-host=/
spring.rabbitmq.connection-timeout=5s
spring.rabbitmq.requested-heartbeat=30s
spring.rabbitmq.cache.connection.mode=channel
spring.rabbitmq.cache.channel.size=25
spring.rabbitmq.cache.channel.checkout-timeout=2s
spring.rabbitmq.listener.type=simple
spring.rabbitmq.listener.simple.concurrency=1
spring.rabbitmq.listener.simple.max-concurrency=3
spring.rabbitmq.listener.simple.prefetch=10
spring.rabbitmq.listener.simple.acknowledge-mode=auto
spring.rabbitmq.listener.simple.auto-startup=true
# ------------------------------------------------------------
# 文件上传:限制请求在内存和临时盘中的放大
# ------------------------------------------------------------
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=110MB
spring.servlet.multipart.file-size-threshold=2MB
先修正废弃属性
模板已使用 server.forward-headers-strategy,不再保留 server.use-forward-headers。NATIVE、FRAMEWORK、NONE 的选择取决于实际代理链,不能无条件信任公网传入的 X-Forwarded-*。
旧 key 的迁移报告、spring-boot-properties-migrator 的退出条件统一维护在 Spring Boot 3 容器启动异常复盘 中,本篇只维护最终生产配置。
Redis:连接少不等于连接池越小越好
Spring Data Redis 使用 Lettuce 时,普通命令可以复用共享的 native connection。连接池并非所有服务的默认必需品。
Spring Boot 3.1 属性说明指出,当 classpath 中存在 commons-pool2 时,Lettuce pool 可以自动启用。因此只要某个 starter 后续引入该依赖,连接行为就可能发生变化。
本次治理将全局默认设为:
spring.data.redis.lettuce.pool.enabled=false
只有满足以下条件的应用,才在自己的 dataId 中启用 pool:
- 使用阻塞 Redis 命令。
- 使用事务或需要独占连接的调用。
- 已经通过指标证明共享连接存在争用。
- classpath 中明确包含兼容版本的
commons-pool2。
应用级候选:
spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-idle=4
spring.data.redis.lettuce.pool.min-idle=0
spring.data.redis.lettuce.pool.max-wait=2s
spring.data.redis.lettuce.pool.time-between-eviction-runs=60s
min-idle=0 可避免每个 Pod 在没有业务时也强制保留一批空闲连接。是否需要提高,由 Redis connected_clients、命令延迟和 pool 等待指标决定。
别忘了 Redisson 是另一套客户端
spring.data.redis.* 不会自动限制代码中单独创建的 RedissonClient。如果服务同时使用 Spring Data Redis 和 Redisson,连接总数就是两套客户端的连接数之和。
需要额外盘点:
- Redisson 采用的是单节点、主从、哨兵还是集群模式。
connectionPoolSize和connectionMinimumIdleSize。- subscription connection pool。
threads与nettyThreads。- 每个 Pod 实际创建了几个 RedissonClient。
连接预算应按客户端分别计算:
总连接上界
≈ Pod 数
× 每 Pod 的 Lettuce 连接
+ Pod 数
× 每 Pod 的 Redisson 普通连接
+ subscription 连接
+ Redis 运维与监控连接
多个业务模块各自 new RedissonClient,比 pool 参数设置偏大更危险。优先保证一个应用上下文只创建一组受管理客户端。
RabbitMQ:全局限制的是每个 Listener 容器
concurrency是最小 listener invoker 线程数。max-concurrency是自动扩展上限。prefetch是每个消费者允许的最大未确认消息数。
因此:
concurrency=1
max-concurrency=3
prefetch=10
不是“整个服务最多三条消息”,而是每个 Listener 容器最多三个消费者,每个消费者最多十条未确认消息。
若一个应用有 40 个 Listener,理论上限可能达到:
40 × 3 × 10 = 1200 条未确认消息
真实数量还受队列流量和容器扩容策略影响,但这个乘法足以解释为什么 Listener 较多的服务 Heap 会突然升高。
不建议在全局基线中直接修改:
spring.rabbitmq.listener.simple.default-requeue-rejected
spring.rabbitmq.listener.simple.retry.enabled
spring.rabbitmq.publisher-confirm-type
它们会改变消息可靠性语义,必须由业务结合 DLQ、幂等和重试策略来决定,不能当作纯性能优化处理。
Hikari:先算数据库能承受多少连接
maximum-pool-size=10 只有放在总连接量上核算才合理:
数据库业务连接上界
= 所有服务的 Pod 数 × 各自 maximumPoolSize
+ 任务、管理、迁移和运维连接
例如 50 个服务、每个服务 2 个 Pod、每池 20 个连接,总连接数已经达到 2000 个。数据库不一定因为每个池都“只有 20”就能承受。
推荐步骤:
- 按数据库实例汇总所有调用方和副本数。
- 记录高峰 active、idle、pending 和 acquire time。
- 普通服务先使用较小的连接池。
- 为高并发或长事务服务做应用级例外。
- 保留数据库运维和故障切换的余量。
如果项目使用 dynamic-datasource、Druid 或手动创建多个 DataSource,spring.datasource.hikari.* 可能只作用于默认数据源。必须检查最终的 Bean,而不能只看 Nacos key。
Tomcat 线程与数据库连接不是一一对应
server.tomcat.threads.max=100 并不意味着 Hikari 也应该配置 100 个连接。大量请求可能命中缓存、校验失败或执行非数据库逻辑;反过来,一个请求也可能并行访问多个数据源。
容量关系应通过指标验证:
HTTP active threads
→ 数据库 active / pending
→ 下游 P95/P99
→ CPU 与 GC
WebFlux/Netty 服务不会使用 Tomcat 线程配置,应从应用级 dataId 中删除这些无效项,并单独治理 event loop、连接池和 Direct Buffer。
Actuator 暴露范围必须配合网络边界
全局配置中暴露 health、prometheus 和 serviceregistry 是为了探针、监控与优雅摘流量。但 serviceregistry 能改变注册状态,不应直接暴露到公网。
至少要做到:
- Management 端口只允许集群内部访问。
- Ingress 不转发管理路径,或通过认证与 NetworkPolicy 限制。
- health 详情不泄露地址、账号或异常栈。
- Prometheus 通过明确的 ServiceMonitor 或受控抓取访问。
如果无法保证边界,就不要为了 preStop 的方便而全局暴露管理端点。
Boot 2 服务不要直接继承这份全局 Redis 配置
Spring Boot 2 使用 spring.redis.*,Spring Boot 3 使用 spring.data.redis.*。仍未升级的服务只在自己的 dataId 中添加兼容别名,不应把旧前缀重新塞回 Boot 3 全局基线。
兼容配置、localhost:6379 的证据链与 commons-pool2 陷阱统一维护在 Boot 2/3 Redis 配置回退 localhost 中,本篇不再重复。
Nacos 发布顺序
全局 dataId 的变更会影响大量客户端,推荐:
- 导出并保存原始配置、namespace、group、dataId 和校验值。
- 为所有 key 建立 Boot 版本与组件适用矩阵。
- 从 global 中删除明显属于单应用的参数。
- 选一个普通服务,用应用级临时覆盖来试验候选值。
- 滚动一个 Pod,验证真实的 Redis、RabbitMQ 和数据库路径。
- 观察连接数、线程、Heap、消息积压和延迟。
- 再发布 global,并分批滚动服务。
- 回读 Nacos,检查新 Pod 的有效配置和近期日志。
不要同时修改:
- Redis pool。
- RabbitMQ 并发。
- Hikari 上限。
- JVM Heap。
- 探针阈值。
否则一旦出现内存下降或吞吐退化,就无法判断是由哪个参数引起的。
验收指标
| 组件 | 必看指标 | 风险信号 |
|---|---|---|
| Redis | connected_clients、命令延迟、超时 | 连接下降但 pool 等待上升 |
| Redisson | 每 Pod 客户端和连接数 | 多实例重复创建 |
| RabbitMQ | ready、unacked、consumer、channel | 积压持续增长 |
| Hikari | active、idle、pending、acquire | pending 非零且延迟升高 |
| Tomcat | active、queue、reject | 线程长期打满 |
| JVM | Heap used/committed、GC、RSS | Full GC 或 OOM 增加 |
| Kubernetes | Ready、Restart、rollout | 旧 ReplicaSet 无法退出 |
至少经历一个真实业务高峰,再决定是否继续收缩。
完成定义
global.properties只包含适用于 Boot 3 且语义通用的参数。- Secret 不以明文存放在 Nacos。
- Redis 连接池默认不会因 classpath 变化而自动扩大。
- Redisson 与 Spring Data Redis 分别计入连接预算。
- RabbitMQ 并发与 prefetch 已按 Listener 数量核算。
- 数据库的总连接上界低于实例容量,并保留故障余量。
- Servlet 与 WebFlux 配置不再混为一谈。
- Deployment 环境变量与 Nacos 之间不存在相互覆盖的双重事实来源。
- Boot 2 兼容项只存在于应用级 dataId,且有删除期限。
最终结论
全局配置的目标不是让所有服务参数完全相同,而是提供最小化、安全且可预测的默认行为。
Redis 连接数、RabbitMQ 未确认消息和数据库连接池都会随着 Pod 数和业务组件数的增加而放大。把它们放进 Nacos 之前,必须先做乘法;发布到 Nacos 之后,还要确认没有被 Deployment 环境变量覆盖。
真正完整的配置不是 key 数量最多,而是每个 key 都有适用版本、容量依据、覆盖关系、监控指标和回滚边界。
DISCUSSION
讨论与反馈