公众号旧文

一次图床故障背后的系统设计思考

作者:胖子阅读 —

刚试图上传一张 6MB 左右的图片,前端提示:

上传失败:服务器返回的内容不是有效 JSON

第一反应不是图片大小问题,而是怀疑:

  • 后端接口是不是挂了?
  • 返回格式是不是变了?
  • Nginx 反代是不是配置错了?
  • 程序是不是异常退出了?

但是经过排查,最终发现:

服务没有挂,程序也没有问题,只是图片大小超过了服务器限制。

这个问题背后,刚好体现了 Web 系统中非常重要的一个概念:

同一个请求,在不同层被拦截,会产生完全不同的错误。

一次上传请求,实际上经过很多层

很多人认为上传图片就是:

浏览器 → 后端

实际上生产环境通常是:

浏览器
  |
  ↓
Nginx
  |
  ↓
应用服务
  |
  ↓
文件存储

每一层都可能限制请求大小。

例如 Nginx:

client_max_body_size 5M;

Go 后端:

http.MaxBytesReader(w, r.Body, MaxUploadSize)

这两个限制看起来都是限制上传大小,但作用完全不同。

第一次失败:为什么不是提示文件太大?

当时 Nginx 设置:

client_max_body_size = 5M

但是上传的是 6MB 图片

请求流程:

浏览器
 |
 | 上传6MB图片
 ↓
Nginx
 |
 | 检查大小超过5MB
 ↓
直接拒绝

请求没有到后端程序。

Nginx 返回:

413 Request Entity Too Large

但是问题来了,Nginx 默认返回的是 HTML:

<html>
<body>
413 Request Entity Too Large</body>
</html>

而前端代码期待的是:

{
  "success": true,
  "url": "/uploads/a.png"
}

于是前端尝试解析:

JSON.parse(html)

结果失败。

所以用户看到:服务器返回的内容不是有效 JSON

看起来像 JSON 问题。

实际上真正的问题是图片大小超过限制。

只是错误经过了一层包装。

修改 Nginx 后,为什么错误又变了?

后来把 Nginx 改成:

client_max_body_size 10M;

再次上传。

这次请求通过 Nginx:

浏览器
 |
 ↓
Nginx 10M
 |
 ↓
Go 后端

但是 Go 程序里面还有限制:

MaxUploadSize = 5 << 20

也就是:

5 × 1024 × 1024
= 5MB

于是 Nginx 通过 Go 拒绝,这次返回:

http: request body too large

因为请求已经进入应用层。

错误来源已经变了。

两层限制为什么都需要?

有人可能会想:既然 Go 有限制,那为什么还需要 Nginx?

或者:既然 Nginx 有限制,那为什么应用还要限制?

答案是:两个都需要。

Nginx 限制:保护应用

Nginx 是入口。

它负责:

  • 拦截超大请求
  • 节省后端资源
  • 防止大量垃圾请求进入应用

例如有人上传:

5GB 文件

如果没有 Nginx 限制,请求可能进入:

Go程序
 |
读取请求
 |
占用内存
 |
消耗CPU
 |
影响正常用户

应用限制:保护自己

应用不能完全相信前面的代理。

原因是未来可能出现:

  • 绕过 Nginx
  • 内网访问
  • 新增接口
  • 配置错误

所以应用自己也必须限制。

例如:

http.MaxBytesReader(...)

就是最后一道保险。

但是,还有一个问题:限制有了,用户体验呢?

这次故障暴露了另一个问题:

很多系统只做了:拒绝错误请求

但是没有做好:告诉用户为什么错误

例如现在:

上传失败:服务器返回内容不是有效 JSON

用户完全不知道发生了什么。

更好的设计应该是:

上传失败:图片大小超过10MB限制

一个成熟的上传系统应该这样设计

第一层:前端提前检查

上传前:

if(file.size > 10 * 1024 * 1024){
    alert("图片不能超过10MB");
    return;
}

优点:

  • 不浪费流量
  • 用户立即知道原因

第二层:Nginx 返回统一格式

不要直接返回 HTML。

例如:

{
    "error":"文件超过10MB限制"
}

第三层:后端统一错误结构

不要直接返回:

http: request body too large

而应该:

{
    "success":false,
    "message":"文件大小超过限制"
}

最终用户体验应该是:

用户上传20MB图片
↓
前端检查
↓
提示:
文件不能超过10MB

如果绕过前端:

↓
Nginx拦截
↓
JSON错误
↓
前端展示

如果绕过Nginx:

↓
Go后端拦截
↓
JSON错误
↓
前端展示

这次故障最大的收获

这个问题看似只是图片太大上传失败

实际上涉及:

  • HTTP 状态码
  • 反向代理
  • 应用层保护
  • 错误格式设计
  • 前后端协议约定

很多线上故障都是类似的问题:

用户看到的是:失败

但是工程师需要继续向下追:

浏览器?
 ↓
代理?
 ↓
网关?
 ↓
应用?
 ↓
数据库?
 ↓
存储?

真正优秀的系统,不只是能够拒绝错误请求。

更重要的是当系统拒绝请求时,要告诉用户和开发者,到底为什么拒绝。

这也是生产环境和个人项目之间最大的区别之一。