2.4 图片上传:COS 直传与回调
场景:发帖接口被图片拖垮
迁到自建后端的第一版发帖,图片走的是最朴素的方案:小程序把图片上传到我的 Node 接口,接口转存到 COS。结果晚高峰一个带九张图的帖子,接口耗时两秒起步,CVM 带宽被图片占满,连带着信息流接口变慢。本质上我把一台两核四G的 CVM 当成了图片中转站,干的却是 CDN 的活。监控图上最明显的是网卡出站流量,晚高峰尖峰能到八十多 Mbps,而 我们的固定带宽才五 Mbps,队列里全是等上传完才释放的连接。
更难受的是这种中转还会放大故障面:COS 抖动一下,我的 CVM 上堆积一堆未写完的文件,磁盘和内存都被占住。当天晚上我们就决定改成 COS 直传:小程序拿临时密钥直接往 COS 传,传完把对象 Key 通过一个轻量回调接口告诉后端,全程不经 CVM。改完之后发帖接口耗时降到一百二十毫秒以内,CVM 带宽占用掉了百分之八十。机器负载曲线那天之后肉眼可见地平了,晚高峰网卡出站流量再没超过三 Mbps。
决定直传那天我们还顺带算过另一笔账:图片中转方案下每张图要经过用户上传到 CVM、CVM 再上传到 COS 两次网络传输,等于同一份流量交了两遍带宽费。直传之后这部分钱直接消失,CVM 只剩下发帖接口本身那一百多毫秒的逻辑。小团队做架构决策,有时候不是技术先进性问题,而是谁也不想给流量费买单。
团队里最早反对直传的声音来自我自己:总觉得把上传逻辑交出去就不踏实,出问题没法排查。后来真出过一次小程序 SDK 的兼容问题,靠回调日志十分钟就定位到,比当年排查中转故障还快,疑虑当场消散。
还有一层考虑是稳定性。中转方案下,CVM 的并发连接数被上传请求长时间占住,两核四G的机器在晚高峰撑不住三十个并发上传,接口排队直接拖垮整个服务。直传之后这些连接全部转移到 COS,CVM 只剩下毫秒级的逻辑请求,机器负载曲线肉眼可见地平了。
方案:临时密钥加回调确认
直传方案的核心是密钥分发。小程序绝不能拿到永久密钥,否则等于把存储账户交给客户端。流程分四步:小程序请求接口拿临时密钥,临时密钥只授予 PutObject 到固定目录的权限;小程序用临时密钥直传 COS;COS 传完后小程序把对象 Key 回调给后端;后端校验对象存在再落库。权限最小化是关键,目录按日期切分,违规内容只影响单天目录。这套四步流程上线三个月后,我们统计过回调成功率长期稳定在百分之九十九点六以上,剩下的零星失败几乎全是用户弱网中断。
| 环节 | 参与方 | 权限或动作 | 耗时 |
|---|---|---|---|
| 申请密钥 | 小程序调接口 | 仅 PutObject | 十毫秒 |
| 直传 | 小程序到 COS | 上传文件 | 看图大小 |
| 回调 | 小程序调接口 | 校验并落库 | 二十毫秒 |
| 审核 | 后端异步 | 图片审核 | 秒级 |
| 访问 | CDN 回源 COS | 只读 | 缓存命中极快 |
配合图片处理,我们还立了一张图规格表,贴在小程序端和后端各一份,任何端要改图都先看这张表,避免再出现列表页拉原图的事故:
| 用途 | 规格 | 生成方式 | 访问路径 |
|---|---|---|---|
| 列表缩略图 | 最长边三百 | 数据万象 | 原图加处理参数 |
| 详情大图 | 原图压缩 | 数据万象 | 原图加压缩参数 |
| 头像 | 一比一裁剪 | 客户端裁后直传 | 独立目录 |
配置与代码
临时密钥由后端用固定密钥签名生成,作用域限定在指定前缀目录,有效期十分钟。有效期为什么定十分钟:实测一张五兆的图在弱网下直传约四十秒,留十分钟足够覆盖重试和用户犹豫,又不至于让泄露的密钥有太长的作恶时间窗口。小程序拿到后用对象存储的小程序 SDK 直传,回调只在文件真实存在时才写库:
const { SecretId, SecretKey } = require('./cos_secret');
async function getUploadToken(uid) {
const key = 'upload/' + today() + '/' + uid + '/' + Date.now() + '.jpg';
const expire = Math.floor(Date.now() / 1000) + 600;
const policy = {
version: '2.0', expiry: expire,
statement: [{ action: 'name/cos:PutObject',
effect: 'allow',
resource: 'qcs::cos:ap-guangzhou:uid/' + APPID
+ ':' + Bucket + '/' + key }]
};
return { tmp_key: sign(policy, SecretId, SecretKey),
key, bucket: Bucket, region: 'ap-guangzhou',
expire: 600 };
}
回调接口轻到极致,只做对象存在性校验和落库,不碰任何文件流。这个设计反过来看也是为了防止接口超时把用户卡在发帖最后一步,所有重活都安排在异步审核里:
router.post('/api/upload/callback', async ctx => {
const { key } = ctx.request.body;
if (!/^upload\/\d{8}\/\d+\//.test(key)) ctx.throw(4001, '非法路径');
const exist = await cosClient.headObject(key);
if (!exist) ctx.throw(4002, '文件不存在');
ctx.state.data = { url: cosUrl(key) };
});
清理脚本也贴一段,跑在凌晨低峰期,无引用的对象先转低频存储再删除,存储费从每月两百多降到四十出头:
// 每天凌晨清理无引用对象
async function sweep() {
const keys = await cosClient.list('upload/');
const stale = keys.filter(k => !await postModel.isImgUsed(k));
for (const k of stale.slice(0, 500)) {
await cosClient.transitionToStandardIA(k);
}
}
踩坑
第一个坑是临时密钥的权限粒度。最早图省事给了整个存储桶的写权限,安全评审时被指出可以覆盖任意文件,一旦临时密钥泄露攻击者能把首页图全换掉,想想都后怕。收紧到当天目录之后,又发现小程序并发传多张图时 key 相同会互相覆盖,把 key 改成时间戳加随机串才解决。第二个坑是 CDN 回源配置,图片首次访问每次都回 COS,高峰期回源流量费用很高,设置七天缓存、打开参数忽略后命中率到百分之八十五,又省了一笔。第三个坑是方向反了的一次故障:小程序传完图回调失败,用户看到上传成功但帖子里图丢了,加了一个补偿任务,五分钟内帖子关联但图状态为待确认的自动重新校验。第四个坑是大小图,原始图三兆一张,列表页直接拉原图首屏慢到四秒,规范了处理规则:上传时 COS 数据万象自动压缩出缩略图,列表用缩略图、详情用原图。第五个坑是删除同步,用户删帖后 COS 里的对象没人清,存储费每月悄悄上涨,账单出来那天我们盯着数字看了好久,加了定期清理脚本,把超过三十天无引用的对象转入低频存储再删除。第六个坑是上传进度体验,直传后小程序拿不到后端的中转进度,弱网下用户干等,前端改成监听上传任务的进度回调并显示百分比,投诉立刻消失。第七个坑是防盗链,图片域名被别的站直接引用,CDN 流费一夜之间翻了三倍,配上 referer 白名单和签名访问后回落。第八个坑是跨域配置,管理后台是浏览器域名调 COS 接口被拦,配好 CORS 规则才通过,小程序本身不受影响但调试工具会。第九个坑是上传并发数,小程序端一次发九张图如果不限制并发,弱网下全部挤在一起互相抢带宽,改成最多三张并行后总耗时不升反降。
小结
直传让 CVM 彻底从图片链路里脱身,值得每一个图片密集型小程序采用。要点三个:临时密钥最小权限、回调只校验不中转、大小图分离。再补一条选型层面的感悟:云产品的优势就在这种地方,COS 把存储、压缩、分发、清理的能力打包好了,自建这些能力至少多花两个人月,小团队真的耗不起。存储这一块的实践,可以和 网盘系统实战 的存储设计互相印证,思路都是元数据与文件流分离。图片审核链路的串联做法,还能参考 AI 工具实战 里的异步处理章节。最后一个提醒:图片链路一旦上量,CDN 回源策略比实例规格更影响成本,我们在这个认知上交过一整个季度的学费。
