主题
一次文件上传超时引发的"血案":COS 上传卡死 + 数据库连接泄漏排查实录
一、事故背景
今天接到客户反馈:文件上传功能频繁超时,部分请求直接无响应。
初步现象:
- 用户上传文件时,页面长时间转圈,最终报
504 Gateway Timeout - 并非所有请求都失败,但失败率随时间推移逐渐升高
- 重启服务后短暂恢复,运行一段时间后问题复现
典型的"慢性病"——不是代码逻辑写错了,而是资源在慢慢被耗尽。
二、排查过程
2.1 第一现场:线程堆栈
通过 jstack 抓取线程快照,发现大量线程阻塞在同一位置:
"exec-nio-8080-exec-32" #152 daemon prio=5 os_prio=0 tid=0x... nid=0x... WAITING
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at com.qcloud.cos.http.HttpClientConnectionManager.leaseConnection(...)
at com.qcloud.cos.COSClient.putObject(...)
at com.xxx.service.FileUploadService.upload(FileUploadService.java:87)
...关键信息: 线程全部卡在 COS SDK 的 putObject 调用上,且状态为 WAITING——不是 TIMED_WAITING,意味着没有生效的超时机制。
2.2 第二现场:数据库连接池
查看 Druid 监控面板:
| 指标 | 值 |
|---|---|
| 最大连接数 | 20 |
| 当前活跃连接 | 20 |
| 等待获取连接的线程数 | 47 |
| 连接最长持有时间 | > 300秒 |
数据库连接池已完全耗尽,新请求全部在排队等待连接。
2.3 关键疑问:为什么上传文件会占用数据库连接?
查看代码,发现了问题根源👇
三、根因分析
3.1 问题代码(还原)
java
@Service
public class FileUploadService {
@Autowired
private FileRecordMapper fileRecordMapper;
@Transactional // ⚠️ 问题1:事务范围过大
public FileUploadResult upload(MultipartFile file) {
// Step 1: 插入文件记录(获取数据库连接)
FileRecord record = new FileRecord();
record.setFileName(file.getOriginalFilename());
record.setStatus("UPLOADING");
fileRecordMapper.insert(record);
// Step 2: 上传到 COS(⚠️ 问题2:在事务内做远程IO)
// 此时数据库连接一直被持有!
PutObjectRequest putRequest = new PutObjectRequest(
bucketName, objectKey, file.getInputStream());
cosClient.putObject(putRequest); // 💥 卡在这里
// Step 3: 更新状态
record.setStatus("SUCCESS");
fileRecordMapper.updateById(record);
return buildResult(record);
}
}3.2 COS 客户端配置(缺失超时)
java
@Bean
public COSClient cosClient() {
ClientConfig config = new ClientConfig(new Region("ap-guangzhou"));
// ⚠️ 问题3:未配置超时!使用SDK默认值(无限等待)
// config.setConnectionTimeout(5000);
// config.setSocketTimeout(30000);
// config.setConnectionRequestTimeout(1000);
return new COSClient(credentials, config);
}3.3 致命组合拳
┌─────────────────────────────────────────────────────────┐
│ 故障链条 │
├─────────────────────────────────────────────────────────┤
│ │
│ ① COS服务端偶发抖动/网络异常 │
│ ↓ │
│ ② COS SDK 无超时配置 → 线程无限阻塞 │
│ ↓ │
│ ③ @Transactional 包裹整个方法 → 数据库连接不释放 │
│ ↓ │
│ ④ 连接池 20 个连接逐个被"钉死" │
│ ↓ │
│ ⑤ 新请求获取不到连接 → 全部超时 │
│ ↓ │
│ ⑥ 服务整体不可用(不仅是文件上传) │
│ │
└─────────────────────────────────────────────────────────┘一句话总结:一个没有超时的远程调用,被一个范围过大的事务"锁"住了数据库连接,最终拖垮整个连接池。
四、解决方案
4.1 紧急止血
bash
# 1. 重启服务恢复
# 2. 临时调大连接池(治标)
spring.datasource.druid.max-active=504.2 COS 客户端:必须配置超时
java
@Bean
public COSClient cosClient() {
ClientConfig config = new ClientConfig(new Region("ap-guangzhou"));
// ✅ 连接建立超时:5秒
config.setConnectionTimeout(5 * 1000);
// ✅ 数据传输超时:60秒(大文件可适当放宽)
config.setSocketTimeout(60 * 1000);
// ✅ 从连接池获取连接的等待超时:3秒
config.setConnectionRequestTimeout(3 * 1000);
// ✅ 最大连接数
config.setMaxConnectionsCount(200);
// ✅ 空闲连接存活时间
config.setIdleConnectionAlive(60 * 1000);
return new COSClient(credentials, config);
}4.3 缩小事务范围:数据库操作与远程调用分离
java
@Service
public class FileUploadService {
@Autowired
private FileRecordMapper fileRecordMapper;
@Autowired
private COSClient cosClient;
public FileUploadResult upload(MultipartFile file) {
// Step 1: 短事务 - 插入记录
FileRecord record = insertRecord(file);
try {
// Step 2: 事务外 - 上传COS(不占用数据库连接)
doUploadToCOS(file, record);
// Step 3: 短事务 - 更新状态
updateRecordSuccess(record);
} catch (Exception e) {
// 失败处理:更新状态为 FAILED
updateRecordFailed(record, e.getMessage());
throw new BusinessException("文件上传失败", e);
}
return buildResult(record);
}
@Transactional(rollbackFor = Exception.class)
public FileRecord insertRecord(MultipartFile file) {
FileRecord record = new FileRecord();
record.setFileName(file.getOriginalFilename());
record.setStatus("UPLOADING");
record.setCreateTime(LocalDateTime.now());
fileRecordMapper.insert(record);
return record;
}
// ⚠️ 无 @Transactional —— 不持有数据库连接
private void doUploadToCOS(MultipartFile file, FileRecord record) throws Exception {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
PutObjectRequest putRequest = new PutObjectRequest(
bucketName, record.getObjectKey(), file.getInputStream(), metadata);
cosClient.putObject(putRequest);
}
@Transactional(rollbackFor = Exception.class)
public void updateRecordSuccess(FileRecord record) {
record.setStatus("SUCCESS");
record.setUpdateTime(LocalDateTime.now());
fileRecordMapper.updateById(record);
}
}4.4 增加上传异步化(进阶优化)
对于大文件场景,进一步将上传改为异步:
java
@Async("uploadExecutor")
public CompletableFuture<FileUploadResult> uploadAsync(MultipartFile file) {
// 独立线程池执行,不占用 Tomcat 工作线程
// ...
}
// 线程池配置
@Bean("uploadExecutor")
public ThreadPoolTaskExecutor uploadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("cos-upload-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
return executor;
}4.5 连接池防御性配置
yaml
spring:
datasource:
druid:
max-active: 30
# ✅ 获取连接最大等待时间(毫秒),超时直接抛异常而非无限等待
max-wait: 5000
# ✅ 开启连接泄漏检测
remove-abandoned: true
remove-abandoned-timeout: 120 # 秒
log-abandoned: true # 打印泄漏堆栈
# ✅ 空闲连接回收
min-evictable-idle-time-millis: 300000
time-between-eviction-runs-millis: 60000五、核心教训
⚠️ 铁律一:远程调用必须设置超时
任何 HTTP/RPC/SDK 调用,没有超时 = 没有上线资格。
| 超时类型 | 建议值 | 说明 |
|---|---|---|
| Connect Timeout | 3~5s | 建立 TCP 连接 |
| Socket/Read Timeout | 按业务(10~60s) | 数据传输 |
| Connection Request Timeout | 1~3s | 从池中取连接 |
⚠️ 铁律二:事务内禁止远程调用
❌ @Transactional { DB操作 → HTTP/COS/MQ → DB操作 }
✅ 短事务(DB) → 远程调用(无事务) → 短事务(DB)事务的本质是持有数据库连接。事务范围越大,连接占用时间越长。远程调用的延迟是不可控的,绝不能让它"绑架"数据库连接。
⚠️ 铁律三:连接池必须配置泄漏检测
yaml
remove-abandoned: true
log-abandoned: true这不是"可选优化",而是生产环境必配项。它能在连接被异常持有时打印完整堆栈,将排查时间从"小时级"缩短到"分钟级"。
⚠️ 铁律四:关注 open-in-view
yaml
spring:
jpa:
open-in-view: false # 必须关闭如果项目使用 JPA,OSIV 会让 EntityManager 在整个 HTTP 请求周期内持有,进一步放大连接泄漏风险。
六、排查工具箱(备忘)
| 场景 | 命令/工具 |
|---|---|
| 线程卡死 | jstack <pid> | grep -A 20 "WAITING" |
| 连接池状态 | Druid 监控 /druid/datasource.html |
| 网络连接 | netstat -anp | grep :443 | grep CLOSE_WAIT |
| GC 情况 | jstat -gcutil <pid> 1000 |
| 实时日志 | tail -f app.log | grep -i "timeout|refused" |
七、总结
一个没有超时的COS调用
+ 一个范围过大的 @Transactional
+ 一个没有泄漏检测的连接池
─────────────────────────────
= 整个服务雪崩这次故障的本质不是某个单点 Bug,而是多个"小疏忽"叠加后的系统性风险。每一个环节单独看似乎都"能跑",但组合在一起就成了一颗定时炸弹。
防御性编程不是过度设计,而是对生产环境最基本的尊重。
作者:yanshaodong