19-[生产实践] 那些年,我们踩过的 Spring Boot 默认配置“大坑”


7. [生产实践] 那些年,我们踩过的 Spring Boot 默认配置“大坑”

摘要: Spring Boot 以“开箱即用”和“约定优于配置”闻名,极大地简化了开发。然而,这种便捷性背后隐藏着诸多为生产环境埋下的“地雷”。本章将逐一揭露那些在生产环境中可能引发性能瓶颈、内存溢出甚至安全漏洞的默认配置,并提供经过实战检验的最佳实践,帮助您构建一个真正健壮、稳定的线上应用。

7.1. Tomcat Web 容器:脆弱的默认并发能力

陷阱: Spring Boot 默认的内嵌 Tomcat 服务器,其最大连接数(max-connections)和最大工作线程数(max-threads)都默认为 200

后果:
在高并发场景下,这意味着服务器最多只能同时处理 200 个请求。一旦并发数超过 200,后续的请求就会进入等待队列,导致响应时间急剧增加,甚至出现请求超时。对于任何有一定流量的线上应用,这个默认值都远远不够,是首要的性能瓶颈。

解决方案:
根据服务器规格和预估流量,合理调高这些参数。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
# 最大连接数,建议设置得比最大线程数大,例如 10000
max-connections: 10000
# 等待队列长度,当所有线程都在工作时,新请求的排队长度
accept-count: 100
threads:
# 最大工作线程数,通常根据 CPU 核心数和 I/O 模型调整,例如 800
max: 800
# 最小备用线程数,服务器启动时创建,用于应对突发流量
min-spare: 100

7.2. HikariCP 数据库连接池:吝啬的默认连接数

陷阱: Spring Boot 默认的 HikariCP 数据库连接池,其最大连接数(maximum-pool-size)仅为 10

后果:
对于一个典型的 Web 应用,10 个数据库连接很快就会被耗尽,尤其是在有慢查询或长事务的情况下。当连接池被占满,新的数据库请求只能排队等待,导致整个应用响应缓慢,甚至完全卡死。

解决方案:
根据业务的数据库访问频次和并发量,调整连接池大小。同时,强烈建议开启连接泄漏检测。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
datasource:
hikari:
# 最大连接数,一个经验法则是 (CPU核心数 * 2) + 1,可根据压测调整
maximum-pool-size: 50
# 最小空闲连接数
minimum-idle: 10
# 连接超时时间 (30秒)
connection-timeout: 30000
# 【关键】连接泄漏检测阈值 (60秒)
# 开启后,如果一个连接被占用超过60秒,HikariCP会打印警告日志,
# 帮助我们快速定位未关闭连接的代码。默认关闭。
leak-detection-threshold: 60000

7.3. Jackson JSON 序列化:危险的时区处理

陷阱: Spring Boot 默认的 Jackson 序列化 LocalDateTime 等时间对象时,会使用服务器的系统默认时区,并且默认可能将日期序列化为时间戳 (Timestamps)

后果:
在分布式、跨国部署的系统中,服务器可能位于不同时区。同一个存储在数据库中的 UTC 时间,在东京的服务器上序列化返回,和在伦敦的服务器上序列化返回,可能会是两个不同的字符串值,引发客户端的逻辑混乱。

解决方案:
强制指定一个统一的时区(如 GMT+8),并统一日期格式化字符串。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
spring:
jackson:
# 强制所有日期时间序列化都使用 GMT+8 时区
time-zone: GMT+8
# 统一定义日期时间格式
date-format: yyyy-MM-dd HH:mm:ss
serialization:
# 禁止将日期类型序列化为时间戳,确保返回格式化的字符串
write-dates-as-timestamps: false

7.4. 日志配置:被“撑爆”的磁盘

陷阱: Spring Boot 默认的 Logback 日志配置,只会将日志输出到控制台,如果配置了 logging.file.name,日志会写入文件,但没有滚动和归档策略

后果:
一个长时间运行的线上应用,其日志文件会无限增长,直到有一天它会默默地占满服务器的整个磁盘空间,导致应用崩溃甚至服务器宕机。

解决方案:
通过 logback-spring.xml 配置文件,定义精细的日志滚动和归档策略。

文件路径: demo-admin/src/main/resources/logback-spring.xml (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
</encoder>
</appender>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

7.5. Spring Cache:会无限增长的内存缓存

陷阱: 如果不引入任何第三方缓存依赖,Spring Cache 默认使用 ConcurrentHashMap 作为缓存实现。这是一个没有大小限制、没有过期策略的纯内存 Map。

后果:
在一个多实例部署的生产环境中,这种默认缓存是完全不可用的,因为它无法在多个实例间共享。即便是在单体应用中,如果被缓存的数据不断增多,最终也会因为缓存无限增长而导致内存溢出(OOM)

解决方案:
明确指定一个专业的缓存提供商。在我们的项目中,我们已经配置了 Redis 作为分布式缓存,这已经是一个最佳实践。这里再次强调其重要性。

文件回顾: demo-framework/src/main/java/com/example/demoframework/config/CacheConfig.java

1
2
3
4
5
6
7
8
9
10
11
// 我们已经通过这个配置类,将 Spring Cache 的后端实现指向了 Redis
// 并且配置了 JSON 序列化和默认1小时的过期时间,这避免了上述陷阱。
@Configuration
public class CacheConfig {
// ...
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// ... (配置了过期时间、序列化方式)
}
// ...
}

7.6. Spring Actuator:默认的“信息裸奔”

陷阱: Spring Boot Actuator 为了方便,默认可能会通过 JMX 或 Web 暴露大量监控端点,其中部分端点(如 /env, /configprops)可能包含数据库密码、API密钥等敏感信息。

后果:
如果 Actuator 的端口被意外暴露到公网上,将造成严重的信息泄漏事故。

解决方案:
遵循“最小权限”原则,只暴露绝对必要的、且不含敏感信息的端点。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
management:
endpoints:
web:
exposure:
# 只暴露 health, info, metrics 这三个相对安全的端点
include: health,info,metrics
# 默认暴露所有,非常危险,建议总是使用 include
# exclude: env,beans
endpoint:
health:
# health 端点的详细信息(如数据库连接状态),只在授权用户访问时显示
show-details: when-authorized

7.7. 文件上传:过小的默认尺寸限制

陷阱: Spring Boot 默认的文件上传大小限制非常小:单个文件 1MB,单次请求总大小 10MB

后果:
这个配置在开发环境用小文件测试时通常不会暴露问题。一旦上线,用户上传一份稍大(例如 2MB)的合同 PDF 或高清图片时,就会在等待了漫长的上传过程后,收到一个 MaxUploadSizeExceededException 异常,用户体验极差。

解决方案:
根据业务需求,合理配置上传文件的大小限制。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
spring:
servlet:
multipart:
# 将单个文件大小限制提升到 10MB
max-file-size: 10MB
# 将单次请求总大小限制提升到 100MB (允许多文件上传)
max-request-size: 100MB

7.8. @Async 异步任务:失控的默认线程池

陷阱: 使用 @Async 注解时,若不自定义线程池,Spring Boot 默认使用 SimpleAsyncTaskExecutor。这个执行器不会复用线程,而是为每一个异步任务都创建一个全新的线程。

后果:
在有大量异步任务(如发送邮件、记录日志)的生产环境中,系统会不断创建新线程,每个线程都会消耗内存(默认1MB栈空间)。最终将耗尽系统线程资源,导致应用性能急剧下降甚至崩溃。

解决方案:
必须自定义一个生产级的、基于线程池的 TaskExecutor Bean。我们在第 5.1.3 节已经这样做了,这同样是必须遵守的最佳实践。

文件回顾: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
// 我们已经通过这个配置,提供了一个核心线程数与CPU核心数挂钩的、
// 有界队列的、生产级的线程池,避免了无限创建线程的陷阱。
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// ... (核心/最大线程数、队列容量等关键配置)
return executor;
}
}

7.9. 静态资源:被遗忘的浏览器缓存

陷阱: Spring Boot 默认不为 /static/ 目录下的静态资源(CSS, JS, 图片等)设置任何 HTTP 缓存头(Cache-Control)。

后果:
浏览器每次访问页面,都会向服务器重新请求所有静态资源,即使这些资源从未改变。这极大地增加了网络流量,并严重拖慢了页面加载速度,尤其是在网络不佳的环境下。

解决方案:
为静态资源配置一个长周期的缓存策略,并启用内容版本化(Content Versioning)。

文件路径: demo-admin/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
web:
resources:
cache:
# 指示浏览器和代理服务器可以将资源缓存长达 365 天
cachecontrol:
max-age: 365d
chain:
strategy:
# 启用内容版本化策略
content:
enabled: true
# 应用于所有静态资源
paths: /**

开启内容版本化后,Spring Boot 会根据文件内容生成一个哈希值,并将其嵌入到 URL 中(例如 style-a1b2c3d4.css)。只有当文件内容改变时,URL 才会变化,浏览器才会下载新文件,否则将一直使用本地缓存。


7.10. @Transactional 事务:无尽等待的默认超时

陷阱: @Transactional 注解默认的事务超时时间(timeout)依赖于底层事务系统的默认值,通常是没有超时限制

后果:
一个长时间运行的事务(例如,一个需要处理几万条数据的大批量导入任务)会一直持有数据库连接和相关表的锁,这会阻塞其他所有需要访问这些表的操作,导致整个系统响应缓慢甚至假死。

解决方案:

  1. 为所有可能耗时较长的事务方法设置一个合理的超时时间。
  2. 对于大批量数据处理,应在代码层面进行分批处理,将一个大事务拆分为多个小事务。
  3. 总是明确指定 rollbackFor = Exception.class,确保任何异常都能触发回滚。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
// 设置事务超时时间为 30 秒,并对所有异常回滚
@Transactional(timeout = 30, rollbackFor = Exception.class)
public void batchProcess(List<Data> dataList) {
// 在业务逻辑中进行分批处理
int batchSize = 500;
for (int i = 0; i < dataList.size(); i += batchSize) {
List<Data> batch = dataList.subList(i, Math.min(i + batchSize, dataList.size()));
// 每一个小批量的处理都在一个独立的事务中(如果 processBatch 是 @Transactional(propagation=REQUIRES_NEW))
// 或者共享同一个有超时的事务,但能更快地完成并释放锁。
processSingleBatch(batch);
}
}

写在最后:
Spring Boot 的“约定优于配置”确实为开发省去了很多麻烦,但这份“省心”绝不应该延伸到生产环境。每一项默认配置背后,都藏着框架设计者的权衡与假设,而这些假设,在我们的真实业务场景中未必成立。作为专业的开发者,我们的职责就是洞悉这些默认值,并根据我们的业务需求和性能目标,做出明确的、显式的配置。提前优化配置,是对系统负责,也是对自己负责。