给博客加相册功能时,我遇到的第一个问题不是"怎么上传",而是"上传之后怎么办"。

手机拍的照片动辄三五兆。我的服务器带宽只有 1Mbps,一张 4MB 的原图传完要半分钟。一个相册列表页如果直接加载十几张原图,页面会卡到没法用。

所以缩略图不是"锦上添花"的功能,是必须做的基础设施。这篇文章讲怎么用 PHP 的 GD 库实现它。

整体流程

一次上传要经过这些步骤:

用户选文件
   ↓
前端串行上传(一张一张传)
   ↓
服务端校验(扩展名 → 真实 MIME → 图片有效性)
   ↓
保存原图
   ↓
生成 400px 缩略图
   ↓
写入数据库
   ↓
返回 JSON

先看校验这一段:

$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp'];
if (!in_array($ext, $allowed)) {
    http_response_code(400);
    echo json_encode(['error' => '仅支持 jpg/png/webp 格式']);
    exit;
}

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowedMimes)) {
    http_response_code(400);
    echo json_encode(['error' => '文件类型不合法']);
    exit;
}

$imgInfo = @getimagesize($file['tmp_name']);
if (!$imgInfo) {
    http_response_code(400);
    echo json_encode(['error' => '不是有效的图片文件']);
    exit;
}

这里做了三重校验,每一重挡的东西不一样:

第一重看扩展名,只挡手误。

第二重看真实 MIME。这是关键的一步。文件扩展名是用户随便起的——把 shell.php 改名成 shell.jpg,扩展名校验就直接通过了。finfo_file() 读的是文件头部字节来判断真实类型,改扩展名骗不过它。

有人可能觉得"我自己的相册,干嘛防自己"。但上传接口是暴露在公网的——任何人构造一个 POST 请求都能调用它。如果不校验,别人就能往你的服务器上传一个 PHP 文件,再访问它执行任意代码。

第三重 getimagesize() 验证这确实是一张能解析的图片,而不只是"头几个字节像图片"。有些畸形文件能骗过 MIME 检测但没法被真正解码。

生成缩略图

核心是 resizeImage(),用 GD 库实现:

function resizeImage($srcPath, $dstPath, $maxWidth = 400) {
    $info = @getimagesize($srcPath);
    if (!$info) return false;

    $srcW = $info[0];
    $srcH = $info[1];
    $type = $info[2];

    // 大图临时提内存限制(解压后约 w*h*4 bytes)
    $estimated = $srcW * $srcH * 4 * 2 / 1024 / 1024;
    if ($estimated > 64) {
        ini_set('memory_limit', max(intval(ini_get('memory_limit')), ceil($estimated + 64)) . 'M');
    }

    // 已经比目标宽度小,直接复制,不放大会糊
    if ($srcW <= $maxWidth) {
        return copy($srcPath, $dstPath);
    }

    $ratio = $maxWidth / $srcW;
    $dstW = $maxWidth;
    $dstH = (int)($srcH * $ratio);

    $srcImg = null;
    switch ($type) {
        case IMAGETYPE_JPEG: $srcImg = imagecreatefromjpeg($srcPath); break;
        case IMAGETYPE_PNG:  $srcImg = imagecreatefrompng($srcPath);  break;
        case IMAGETYPE_WEBP: $srcImg = imagecreatefromwebp($srcPath); break;
        case IMAGETYPE_GIF:  $srcImg = imagecreatefromgif($srcPath);  break;
        default: return false;
    }
    if (!$srcImg) return false;

    $dstImg = imagecreatetruecolor($dstW, $dstH);

    if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
        imagealphablending($dstImg, false);
        imagesavealpha($dstImg, true);
    }

    imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);

    $result = false;
    switch ($type) {
        case IMAGETYPE_JPEG: $result = imagejpeg($dstImg, $dstPath, 85); break;
        case IMAGETYPE_PNG:  $result = imagepng($dstImg, $dstPath, 8);  break;
        case IMAGETYPE_WEBP: $result = imagewebp($dstImg, $dstPath, 85); break;
        case IMAGETYPE_GIF:  $result = imagegif($dstImg, $dstPath);      break;
    }

    imagedestroy($srcImg);
    imagedestroy($dstImg);
    return $result;
}

GD 库的操作模式在所有语言里都差不多,分四步:

1. 读进来——imagecreatefrom*() 把文件解码成内存里的图像资源。不同格式用不同函数,所以要先判断格式。

2. 建画布——imagecreatetruecolor() 创建目标尺寸的空图像。

3. 缩放绘制——imagecopyresampled() 一次完成"从源图采样 + 缩放 + 画到目标画布"。

这里用的是 resampled 而不是 imagecopyresized()。区别在于采样算法:resized 是简单的最近邻取样,缩小时会直接丢像素,产生明显的锯齿;resampled 会做插值平均,边缘平滑得多。图片缩略图必须用后者。

4. 写出去——imagejpeg() 等函数编码保存,最后 imagedestroy() 释放内存。

imagedestroy() 这步很容易忘。GD 的图像资源占用的是 PHP 进程外的内存,不手动释放的话,批量处理时内存会持续上涨直到进程被杀。PHP 8 虽然会在请求结束时自动回收,但一个请求里处理多张图时,及时释放仍然必要。

三个容易被忽略的细节

细节一:内存估算

$estimated = $srcW * $srcH * 4 * 2 / 1024 / 1024;
if ($estimated > 64) {
    ini_set('memory_limit', max(intval(ini_get('memory_limit')), ceil($estimated + 64)) . 'M');
}

这是整个函数里最实用的一段。它解决一个具体问题:解码大图时内存不够。

GD 解码后,图片在内存里是未压缩的。一张 4000×3000 的照片,文件可能只有 3MB,但解码后是 4000 × 3000 × 4 字节 = 48MB——因为每个像素要存 RGBA 四个通道。

PHP 默认的 memory_limit 常见是 128M。看着够,但同时解码原图和创建目标画布,再加上 PHP 本身的开销,处理手机拍的大图很容易超。

代码先按 宽 × 高 × 4 字节 估算解码后的体积,再乘 2 留出余量(原图 + 目标画布),超过 64MB 就临时提高限制。

max(intval(ini_get('memory_limit')), ...) 是为了不降级——如果服务器本来就配了更大的值,不该被这个函数改小。

细节二:透明通道

if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
    imagealphablending($dstImg, false);
    imagesavealpha($dstImg, true);
}

PNG 和 WebP 支持透明背景,JPEG 不支持。创建的新画布默认是黑色不透明的,如果什么都不做,缩略图里的透明区域会变成黑色。

imagesavealpha($dstImg, true) 告诉 GD 保存时保留 alpha 通道。

imagealphablending($dstImg, false) 关闭混色。默认开启时,绘制操作会把新像素和画布上已有的像素混合——在透明画布上这会导致颜色被黑色污染。缩放场景下必须关掉。

这两行只对 PNG 和 WebP 执行,对 JPEG 反而是多余的。

细节三:只缩不放

if ($srcW <= $maxWidth) {
    return copy($srcPath, $dstPath);
}

如果原图本来就比 400px 窄,直接复制一份,不走缩放流程。

放大图片没有任何意义——不会增加任何细节,只会让文件变大、画面变糊。这是处理图片时的通用原则:只缩不放

前端:为什么一张一张传

上传多张照片时,前端没有并发发送,而是写成递归串行:

function uploadOne(i) {
  if (i >= files.length) { input.value = ''; loadPhotos(); return; }
  showStatus('上传中 ' + (i + 1) + ' / ' + total + '...');

  var fd = new FormData();
  fd.append('photo', files[i]);
  if (currentCatId) fd.append('category_id', currentCatId);

  fetch(baseUrl + '?album_action=upload', { method: 'POST', body: fd })
    .then(function(r) { return r.json(); })
    .then(function(data) {
      if (data.error) { failed.push(files[i].name); }
      uploadOne(i + 1);          // 无论成功失败都继续下一张
    })
    .catch(function() { failed.push(files[i].name); uploadOne(i + 1); });
}
uploadOne(0);

串行比并发慢,但当时是故意的:

一是内存。每张图在服务端都要经历"解码 → 缩放 → 编码",瞬间占用几十兆内存。我的服务器只有 2GB,同时处理五张图很可能触发 OOM,PHP 进程会被系统杀掉。

二是进度反馈。串行能准确显示"上传中 3 / 8"。

三是失败可控。某张失败了记进 failed 数组继续下一张,不会因为一张坏图导致整批中断。

这个设计在小服务器上是合理的。如果哪天换了配置更高的机器,改成并发 2~3 张会更快。

踩过的坑

1. 缩略图失败不应该阻断上传

调用缩略图生成时用了错误抑制符:

@resizeImage($destPath, $thumbPath, 400);

原图已经保存成功了,缩略图生成失败(内存不足、格式损坏等)不该让整个上传失败。数据库里照常写入记录,前端列表里用原图兜底显示。

算是一种降级策略:能用的部分先用起来,别让一个次要环节拖垮主流程。

2. "上传失败"却没有任何报错

现象。电脑上截的图能传,手机拍的照片一律失败,而且前端连个错误提示都没有——只显示"上传失败",后端日志里也什么都没有。

排查过程。先在浏览器里看请求,发现 $_FILES 是空的,所以走到了 if (empty($_FILES['photo'])) 这个分支,返回 {'error': '没有文件'}

但文件明明是选了的。于是去看 PHP 的配置:

php -i | grep -E "^upload_max_filesize|^post_max_size"
# upload_max_filesize => 2M => 2M
# post_max_size => 8M => 8M

问题清楚了:PHP 的 upload_max_filesize 默认只有 2M,而我手机拍的照片动辄三四兆。超过限制时 PHP 会直接丢弃文件,连错误都不报——$_FILES 就是个空数组。

这就是为什么日志里什么都没有:请求在进入我的代码之前就已经被 PHP 拦掉了。

怎么改。要改三个地方,缺一个都不行。

第一处,PHP 的上传限制。

; /etc/php/8.1/fpm/php.ini
upload_max_filesize = 20M
post_max_size = 24M
max_file_uploads = 20

post_max_size 必须大于 upload_max_filesize。因为整个 POST 请求体除了文件本身,还有表单字段和 multipart 边界的开销。两个值设成一样大,卡在临界大小的文件照样会被丢。

max_file_uploads 默认是 20,一次传更多张时会被截断。

第二处,Nginx 的请求体限制。

# /etc/nginx/sites-available/blog
client_max_body_size 24m;

Nginx 默认只接受 1MB 的请求体,超了直接返回 413 Request Entity Too Large这个错误发生在 Nginx 层,PHP 完全看不到——所以如果只改 PHP 不改这里,大文件依然传不上去,而且错误更难查。

三个限制的关系是层层递进的,实际可用的大小取决于最小的那个

Nginx client_max_body_size (24m)
    ↓ 通过后才交给 PHP
PHP post_max_size (24m)
    ↓
PHP upload_max_filesize (20m)   ← 实际单文件上限

第三处,前端也要拦一道。让用户选完文件立刻知道超限,而不是等上传了半天才失败:

const MAX_BYTES = 20 * 1024 * 1024;

for (const file of files) {
  if (file.size > MAX_BYTES) {
    alert(file.name + ' 超过 20MB,已跳过');
    continue;
  }
  // ... 加入上传队列
}

但真正的修复是在服务端。前端校验只是体验优化——绕过它太容易了,随便一个 HTTP 客户端都能直接调接口。服务端必须自己校验:

define('MAX_UPLOAD_BYTES', 20 * 1024 * 1024);

if ($file['size'] > MAX_UPLOAD_BYTES) {
    http_response_code(413);
    echo json_encode([
        'error' => '文件超过 20MB 限制',
        'size'  => round($file['size'] / 1024 / 1024, 1) . 'MB',
        'max'   => '20MB',
    ]);
    exit;
}

更要紧的是:判断 PHP 层有没有丢文件。当 POST 体超过 post_max_size 时,PHP 会静默清空 $_POST$_FILES。这时可以拿 CONTENT_LENGTH 和实际收到的数据量对比,识别出这种情况并给出明确提示:

$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
$postMax = self::parseSize(ini_get('post_max_size'));

if ($contentLength > 0 && empty($_FILES) && empty($_POST) && $contentLength > $postMax) {
    http_response_code(413);
    echo json_encode(['error' => '请求体超过服务器限制(' . $postMax . ')']);
    exit;
}

改完这三处,手机照片就能正常上传了。回头看,这个问题的真正教训不是"配置要调对",而是静默失败最难排查——如果 PHP 当时能报个错,我五分钟就能定位,而不是对着"上传失败"这四个字瞎猜了半天。

3. 缩略图的原图也一起存了

还有个小问题:相册目录里同时存着原图和缩略图,一张照片占两份空间。40GB 的系统盘看着够用,但照片攒多了也是负担。

如果重做,我会在生成缩略图之后把原图也压缩一版(限制长边 2000px 左右)再存——大多数情况下,文章里根本用不到原始分辨率。

小结

这段代码大约 100 行,但覆盖了处理用户上传时几乎所有的关键点:多重校验、资源管理、内存估算、格式差异、降级策略。

最深的体会是:"能跑通"和"能安全地跑通"之间的距离,比想象中大得多。

第一版我只写了"保存文件 + 生成缩略图",跑起来完全正常。但那个版本可以被上传 PHP 文件直接执行任意代码,遇到大图会内存溢出,处理透明 PNG 会把背景变成黑色。

这些问题在本地测试时一个都不会暴露——因为你测试时上传的都是正常的、不大的、不透明的 JPEG。

相册网格
相册列表加载的是 400px 缩略图,点击才看原图
灯箱查看
点开后的灯箱效果