本文来自一次多服务连接数与容器内存的治理实践。地址、账号、命名空间和业务名称均已泛化。配置值是保守起点,必须结合数据库、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 容器

Spring Boot RabbitMQ 属性说明中:

  • 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”就能承受。

推荐步骤:

  1. 按数据库实例汇总所有调用方和副本数。
  2. 记录高峰 active、idle、pending 和 acquire time。
  3. 普通服务先使用较小的连接池。
  4. 为高并发或长事务服务做应用级例外。
  5. 保留数据库运维和故障切换的余量。

如果项目使用 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 的变更会影响大量客户端,推荐:

  1. 导出并保存原始配置、namespace、group、dataId 和校验值。
  2. 为所有 key 建立 Boot 版本与组件适用矩阵。
  3. 从 global 中删除明显属于单应用的参数。
  4. 选一个普通服务,用应用级临时覆盖来试验候选值。
  5. 滚动一个 Pod,验证真实的 Redis、RabbitMQ 和数据库路径。
  6. 观察连接数、线程、Heap、消息积压和延迟。
  7. 再发布 global,并分批滚动服务。
  8. 回读 Nacos,检查新 Pod 的有效配置和近期日志。

不要同时修改:

  • Redis pool。
  • RabbitMQ 并发。
  • Hikari 上限。
  • JVM Heap。
  • 探针阈值。

否则一旦出现内存下降或吞吐退化,就无法判断是由哪个参数引起的。

验收指标

组件必看指标风险信号
Redisconnected_clients、命令延迟、超时连接下降但 pool 等待上升
Redisson每 Pod 客户端和连接数多实例重复创建
RabbitMQready、unacked、consumer、channel积压持续增长
Hikariactive、idle、pending、acquirepending 非零且延迟升高
Tomcatactive、queue、reject线程长期打满
JVMHeap used/committed、GC、RSSFull GC 或 OOM 增加
KubernetesReady、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 都有适用版本、容量依据、覆盖关系、监控指标和回滚边界。