Skip to content

一次文件上传超时引发的"血案":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=50

4.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 Timeout3~5s建立 TCP 连接
Socket/Read Timeout按业务(10~60s)数据传输
Connection Request Timeout1~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