阿里云 OSS 生产环境上传图片频繁超时排查与优化实践
一、问题背景
最近线上生产环境出现了一个比较棘手的问题:用户上传图片到阿里云 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 连接刚建立起来传完就关了,没法有效利用长连接,握手开销占比很高。
四、根因定位
综合以上排查,最终定位到三个核心问题:
- 并发量过高 :默认 parallel=5,叠加浏览器同域名 6 连接限制,容易导致连接排队超时
- 分片过小 :512KB 的分片产生过多分片请求,放大了并发问题,且握手开销占比高
- 公网链路不稳定 :全国用户跨地域访问深圳 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.8s | 6.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



