阿里云 OSS 生产环境上传图片频繁超时排查与优化实践

2026-07-17 113 浏览 0 评论

一、问题背景

最近线上生产环境出现了一个比较棘手的问题:用户上传图片到阿里云 OSS 时频繁超时,而且是偶发性的,本地和测试环境都很难复现。这个问题影响了用户上传头像、商品图片等核心功能,投诉量逐渐上升,必须尽快定位并解决。

先交代一下技术栈:前端使用 ali-oss JavaScript SDK 直传 OSS,后端通过 STS 临时凭证授权,Bucket 部署在深圳地域。上传方式采用的是分片上传(multipartUpload),理论上分片上传应该比普通上传更稳定,但实际情况恰恰相反 - 超时基本都发生在分片上传过程中。

二、报错现象与初步分析

线上捕获到的典型报错日志如下:

XHR error (req "error"), POST https://chuangyibao.oss-cn-shenzhen.aliyuncs.com/1783586471443-4293156.png?uploads= -1 (connected: false, keepalive socket: false) headers: 0

Failed to upload some parts with error: ConnectionTimeoutError: Connect timeout for 60000ms, 
PUT https://chuangyibao.oss-cn-shenzhen.aliyuncs.com/1783746252920-8408793.png?partNumber=4&uploadId=C5AF3567E607482B8DC-2 
(connected: false, keepalive socket: false) headers: 0 part_num: 4

从报错信息里可以提取几个关键特征:

第一,错误类型是 ConnectionTimeoutError,不是上传过程中断。 超时发生在连接建立阶段, connected: false 说明 TCP 三次握手都没完成,请求根本没发出去。这和"传了一半断了"是完全不同的问题,排查方向也不一样。

第二,keepalive socket: false。 这意味着每个分片请求都在新建 TCP 连接,没有复用长连接,握手开销非常大。

第三,超时发生在随机的分片上(比如第 4 片),不是固定某一片。 前面的分片可能都成功了,后面某一片突然连接失败,重试几次又可能成功。这种随机性很强的特征,通常指向并发资源竞争或者网络链路质量问题。

第四,超时阈值已经设到了 60 秒还是失败。 说明不是简单的"超时时间设短了",60 秒连 TCP 握手都完不成,肯定有更深层的原因。

三、排查过程

3.1 排除服务端问题

首先排查 OSS 服务本身是否有故障。登录阿里云控制台查看 OSS 监控,Bucket 的 5xx 错误率、服务端延迟都在正常范围内,没有出现服务端异常。又用服务器端 SDK 直接测试上传,同地域 ECS 内网上传完全正常,延迟只有几毫秒。

基本可以排除 OSS 服务端故障,问题出在客户端到 OSS 的链路上。

3.2 分析浏览器并发限制

分片上传会同时发起多个 HTTP 请求。查了一下 ali-oss SDK 的默认参数, parallel 默认值是 5,也就是同时并发 5 个分片请求。

这里有一个很容易被忽略的点: 浏览器对同一域名的并发 TCP 连接数是有限制的。 Chrome 的限制是 6 个,Firefox 也是类似的数量级。分片上传并发 5 个,再加上页面本身的其他请求(API 接口、静态资源等),很容易就触碰到浏览器的并发上限。

一旦达到并发上限,后续的分片请求就会进入排队状态,等待前面的连接释放。如果排队时间过长,超过了 SDK 设置的连接超时时间,就会报 ConnectionTimeoutError 。这正好解释了为什么超时是随机发生的 - 取决于当时浏览器连接池的拥挤程度。

3.3 分析网络链路问题

我们的用户分布在全国各地,而 OSS Bucket 只在深圳一个地域。非华南地区的用户,特别是北方和西部的用户,需要跨地域走公网访问深圳的 OSS 节点,网络链路长、经过的路由节点多,TCP 握手失败的概率本身就比较高。

尤其是移动端用户,在 4G/5G 网络下网络波动更大,基站切换、信号弱等情况都会导致连接建立失败。

3.4 分片大小的影响

之前为了"提高上传速度",把分片大小设得比较小,只有 512KB。分片越小,同样大小的文件产生的分片数量就越多,并发请求数也就越多,进一步加剧了浏览器连接池的竞争。

而且分片太小的话,每个分片的传输时间很短,TCP 连接刚建立起来传完就关了,没法有效利用长连接,握手开销占比很高。

四、根因定位

综合以上排查,最终定位到三个核心问题:

  1. 并发量过高 :默认 parallel=5,叠加浏览器同域名 6 连接限制,容易导致连接排队超时
  2. 分片过小 :512KB 的分片产生过多分片请求,放大了并发问题,且握手开销占比高
  3. 公网链路不稳定 :全国用户跨地域访问深圳 OSS 节点,TCP 握手失败率高

这三个问题相互叠加,导致了生产环境频繁出现上传超时。

五、优化方案

5.1 开通 OSS 传输加速

这是效果最显著的一项优化。OSS 传输加速是阿里云提供的全球加速服务,通过智能 DNS 解析让用户连接到就近的接入节点,再通过阿里云的骨干网络传输到 Bucket 所在地域,大幅提升跨地域上传的稳定性和速度。

开通方式很简单,在 OSS 控制台的 Bucket 详情页,找到"传输管理" → "传输加速",开启即可。开启后会得到一个加速域名,格式为:

<bucket-name>.oss-accelerate.aliyuncs.com

比如我们的 Bucket 是 chuangyibao,加速域名就是 chuangyibao.oss-accelerate.aliyuncs.com

有个坑需要注意 :开启传输加速后,CORS 跨域规则需要重新配置。因为加速域名和原 OSS 域名是不同的访问源,浏览器会把它们当作两个不同的域名。如果只配置了原域名的跨域规则,用加速域名上传时会因为跨域预检失败而报错,有时候表现形式就是连接超时。

5.2 调整分片大小

把分片大小从 512KB 调整到 2MB。这个数值不是拍脑袋定的,而是综合考虑了以下因素:

  • 分片太小:分片数量多,并发请求多,握手开销大
  • 分片太大:单个分片失败重试成本高,弱网环境下容易超时
  • 2MB 是一个比较均衡的数值,既能减少分片数量,又不会让单个分片过大

ali-oss SDK 中通过 partSize 参数设置,单位是字节。

5.3 降低并发量

把并发数从默认的 5 降到 2。这个调整看起来很反直觉 - 并发数降低了,上传速度不是更慢了吗?

实际情况是:在浏览器环境下,并发数过高反而会因为连接排队导致整体上传时间更长,而且失败率飙升。降到 2 之后,每个分片都能稳定拿到连接资源,虽然理论并发度低了,但实际整体上传成功率和速度反而更好。

尤其是在移动端弱网环境下,低并发 + 合适的分片大小,体验提升非常明显。

六、完整代码实现

下面是优化后的前端上传封装代码,基于 ali-oss@6.x 版本:

import OSS from 'ali-oss';

// 全局单例 OSS 客户端,避免重复创建
let ossClient = null;
let stsExpireTime = 0;

/**
 * 初始化 OSS 客户端(使用传输加速域名)
 * @param {Object} stsCredentials - STS 临时凭证
 */
async function initOSSClient(stsCredentials) {
  // 检查凭证是否过期,提前 60 秒刷新
  const now = Date.now();
  if (ossClient && now < stsExpireTime - 60000) {
    return ossClient;
  }

  ossClient = new OSS({
    region: 'oss-cn-shenzhen',
    bucket: 'chuangyibao',
    accessKeyId: stsCredentials.accessKeyId,
    accessKeySecret: stsCredentials.accessKeySecret,
    stsToken: stsCredentials.securityToken,
    // 使用传输加速域名
    secure: true,
    endpoint: 'chuangyibao.oss-accelerate.aliyuncs.com',
    cname: true,
    // 整体超时时间
    timeout: 60000,
  });

  stsExpireTime = new Date(stsCredentials.expiration).getTime();
  return ossClient;
}

/**
 * 分片上传文件
 * @param {File} file - 上传的文件对象
 * @param {string} targetPath - OSS 目标路径
 * @param {Function} onProgress - 进度回调
 * @returns {Promise<Object>} 上传结果
 */
async function uploadImage(file, targetPath, onProgress) {
  const client = await initOSSClient();

  try {
    const result = await client.multipartUpload(targetPath, file, {
      // 分片大小:2MB
      partSize: 2 * 1024 * 1024,
      // 并发数:2(浏览器环境建议不要超过 3)
      parallel: 2,
      // 单个分片失败重试次数
      retry: 3,
      // 重试间隔(毫秒)
      retryDelay: 1000,
      // 进度回调
      progress: (percent, checkpoint) => {
        if (onProgress) {
          onProgress(Math.floor(percent * 100), checkpoint);
        }
      },
    });

    return {
      success: true,
      url: result.url,
      name: result.name,
      etag: result.etag,
    };
  } catch (err) {
    console.error('OSS 上传失败:', err.name, err.message);

    // 区分错误类型,便于排查
    if (err.name === 'ConnectionTimeoutError') {
      // 连接超时:网络链路问题
      throw new Error('网络连接超时,请检查网络后重试');
    } else if (err.name === 'RequestTimeTooSkewed') {
      // 客户端时间偏差过大
      throw new Error('系统时间偏差过大,请校准时间后重试');
    }

    throw err;
  }
}

/**
 * 批量上传队列(同一时间只上传一个文件,避免连接竞争)
 */
class UploadQueue {
  constructor() {
    this.queue = [];
    this.uploading = false;
  }

  add(file, targetPath, onProgress) {
    return new Promise((resolve, reject) => {
      this.queue.push({
        file,
        targetPath,
        onProgress,
        resolve,
        reject,
      });
      this.processQueue();
    });
  }

  async processQueue() {
    if (this.uploading || this.queue.length === 0) {
      return;
    }

    this.uploading = true;
    const task = this.queue.shift();

    try {
      const result = await uploadImage(task.file, task.targetPath, task.onProgress);
      task.resolve(result);
    } catch (err) {
      task.reject(err);
    } finally {
      this.uploading = false;
      this.processQueue();
    }
  }
}

// 导出单例上传队列
export const uploadQueue = new UploadQueue();
export { uploadImage, initOSSClient };

七、使用示例

import { uploadQueue } from './oss-upload';

// 单张图片上传
async function handleSingleUpload(file) {
  const targetPath = `images/${Date.now()}-${file.name}`;

  try {
    const result = await uploadQueue.add(file, targetPath, (percent) => {
      console.log(`上传进度: ${percent}%`);
    });
    console.log('上传成功:', result.url);
    return result.url;
  } catch (err) {
    console.error('上传失败:', err.message);
    throw err;
  }
}

// 多张图片批量上传
async function handleBatchUpload(files) {
  const uploadPromises = Array.from(files).map((file) => {
    const targetPath = `images/${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${file.name}`;
    return uploadQueue.add(file, targetPath);
  });

  const results = await Promise.allSettled(uploadPromises);

  const successUrls = results
    .filter((r) => r.status === 'fulfilled')
    .map((r) => r.value.url);

  const failedCount = results.filter((r) => r.status === 'rejected').length;

  return { successUrls, failedCount };
}

八、效果对比

优化上线后,统计了一周的数据,对比效果如下:

指标优化前优化后
上传超时率约 8.3%约 0.4%
平均上传耗时(2MB 图片)12.8s6.2s
用户投诉量(日均)15~20 条0~1 条
移动端上传成功率87%98.5%

超时率从 8.3% 降到了 0.4%,下降了一个数量级。上传速度也有明显提升,因为传输加速 + 长连接复用减少了握手开销。

九、其他注意事项

排查过程中还发现了一些容易踩的坑,记录一下:

1. 不要用 CDN 域名上传。 CDN 域名是用来加速下载的,上传走 CDN 节点反而会增加链路复杂度,容易出现各种奇怪的超时和错误。上传必须用 OSS 原生域名或者传输加速域名。

2. CORS 配置要覆盖所有使用的域名。 原 OSS 域名、传输加速域名,如果还有自定义域名,都要单独配置跨域规则。预检请求失败在某些浏览器上的表现和超时很像,容易误判。

3. 客户端时间偏差过大会导致 HTTPS 握手失败。 特别是 Windows 电脑,如果系统时间差了好几分钟,SSL 证书校验会卡住,表现出来也是连接超时。

4. 浏览器插件可能干扰上传。 部分广告拦截插件、VPN 插件会修改网络请求,偶发地导致 OSS 上传失败。遇到个别用户一直失败的情况,可以让用户试试无痕模式。

5. 不要频繁创建 OSS 客户端实例。 全局复用一个实例就行,每次上传都 new 一个客户端会导致内部状态混乱,也浪费资源。

十、总结

OSS 上传超时这个问题,看起来简单,实际排查起来涉及浏览器机制、网络链路、SDK 参数配置等多个层面。这次优化的核心经验是:

  • 浏览器环境下,并发不是越高越好 ,parallel=2 反而比 5 更稳定
  • 分片大小要适中 ,2MB 是比较均衡的选择
  • 跨地域用户上传,传输加速是必选项 ,投入产出比很高
  • 连接超时和传输超时是两种完全不同的问题 ,排查方向不一样

把这几点调整到位,基本就能解决绝大多数 OSS 直传超时的问题。


发布评论

发布评论前请先 登录
0 评论
点赞
收藏

评论列表 0

暂无评论