最近给一个 ThinkPHP 5.1 的企业官网做了 Cloudflare R2 对象存储集成,把所有上传的图片和文件都迁移到了 R2 上。整个过程踩了不少坑,写下来给需要的人参考。

为什么选 R2

之前文件都存在服务器本地的 uploads 目录,几个问题:

  • 服务器磁盘空间越来越紧张,4000 多个文件占了不少地方
  • 没有 CDN 加速,海外用户访问图片慢
  • 服务器挂了文件也没了,没有独立备份

Cloudflare R2 兼容 S3 协议,免出站流量费,每月 10GB 免费存储,对小站来说基本等于白嫖。

技术方案

整体思路:

  1. 写一个 R2Storage 工具类,封装上传、删除、列表、校验等操作
  2. Hook 进 ThinkPHP 的上传流程,新文件自动传到 R2
  3. 批量迁移旧文件
  4. 替换数据库中的本地路径为 R2 CDN 链接
  5. 删除本地已迁移的文件释放空间

R2Storage 核心实现

用 AWS SDK 的 S3Client 连接 R2(R2 兼容 S3 协议):

$this->client = new S3Client([
    'region'      => 'auto',
    'version'     => 'latest',
    'endpoint'    => "https://{$accountId}.r2.cloudflarestorage.com",
    'credentials' => [
        'key'    => $accessKey,
        'secret' => $secretKey,
    ],
    'use_path_style_endpoint' => true,
]);

上传后自动校验,用 HEAD 请求对比文件大小和 MD5:

public function verifyUpload($objectKey, $localSize, $localMd5) {
    $result = $this->client->headObject([
        'Bucket' => $this->bucket,
        'Key'    => $objectKey,
    ]);
    // 对比 ContentLength 和 ETag
}

让新上传自动走 R2

ThinkPHP 项目里,所有上传都走 Base 控制器的 uploadsImg() 方法。我在这个方法里加了 R2 自动上传的逻辑:

// 文件先存本地,然后自动上传到 R2
$r2 = R2Storage::getInstance();
if ($r2->isEnabled()) {
    $r2Url = $r2->upload($localPath, $objectKey);
    if ($r2Url !== false) {
        $returnPath = $r2Url; // 返回 R2 链接
    }
}

关键点:R2 上传失败时回退到本地路径,不影响正常使用。

批量迁移 4267 个文件

旧文件用 uploadDirectory 方法批量上传。一开始是一次性跑完,结果 4000 多个文件跑了快一个小时,中间还超时了。

后来改成分批处理,每次 10 个文件,前端自动轮询下一批:

$result = $r2->uploadDirectory($uploadsDir, 'uploads', null, $batch, $offset);
// 返回 next_offset,前端拿到后继续请求下一批

前端用递归 AJAX 实现进度条,体验好多了。

替换数据库路径

文件迁移完还不够,数据库里存的还是本地路径 /uploads/xxx。需要批量替换成 R2 CDN 链接。

这里有个坑:JSON 字段里的路径不会被普通 SQL REPLACE 匹配到。因为 JSON 里路径是 \/uploads\/ 带转义斜杠的,要用 PHP 脚本先 json_decode 再替换再 json_encode 回去。

删除本地文件

删除前会逐个校验:

  1. 计算本地文件大小 + MD5
  2. HEAD 请求 R2 上的对应文件
  3. 两个都一致才删,不一致跳过

同样用分批处理,每次 10 个,防止超时。

后台管理页面

最后做了一个独立的 R2 存储管理页面,集成了:

  • R2 配置(启用/密钥/Bucket/域名)
  • 连接测试
  • 批量迁移(带进度条)
  • 替换数据库路径
  • 删除本地文件(带进度条)
  • 本地文件统计

效果

  • 4267 个文件全部迁移到 R2,0 失败
  • 服务器磁盘释放了几百 MB
  • 图片走 Cloudflare CDN,海外访问速度明显提升
  • 新上传的文件自动存到 R2,本地只是中转

注意事项

  • R2 的 listObjectsV2 单次最多返回 1000 个文件,超过要用 ContinuationToken 分页
  • 下载功能要兼容 R2 远程文件,不能再用 download('./uploads/xxx'),要判断是否 http 开头
  • 用单例模式管理 R2 连接,但配置变更后要记得 resetInstance
  • JSON 字段里的路径替换要特殊处理