最近给一个 ThinkPHP 5.1 的企业官网做了 Cloudflare R2 对象存储集成,把所有上传的图片和文件都迁移到了 R2 上。整个过程踩了不少坑,写下来给需要的人参考。
为什么选 R2
之前文件都存在服务器本地的 uploads 目录,几个问题:
- 服务器磁盘空间越来越紧张,4000 多个文件占了不少地方
- 没有 CDN 加速,海外用户访问图片慢
- 服务器挂了文件也没了,没有独立备份
Cloudflare R2 兼容 S3 协议,免出站流量费,每月 10GB 免费存储,对小站来说基本等于白嫖。
技术方案
整体思路:
- 写一个 R2Storage 工具类,封装上传、删除、列表、校验等操作
- Hook 进 ThinkPHP 的上传流程,新文件自动传到 R2
- 批量迁移旧文件
- 替换数据库中的本地路径为 R2 CDN 链接
- 删除本地已迁移的文件释放空间
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 回去。
删除本地文件
删除前会逐个校验:
- 计算本地文件大小 + MD5
- HEAD 请求 R2 上的对应文件
- 两个都一致才删,不一致跳过
同样用分批处理,每次 10 个,防止超时。
后台管理页面
最后做了一个独立的 R2 存储管理页面,集成了:
- R2 配置(启用/密钥/Bucket/域名)
- 连接测试
- 批量迁移(带进度条)
- 替换数据库路径
- 删除本地文件(带进度条)
- 本地文件统计
效果
- 4267 个文件全部迁移到 R2,0 失败
- 服务器磁盘释放了几百 MB
- 图片走 Cloudflare CDN,海外访问速度明显提升
- 新上传的文件自动存到 R2,本地只是中转
注意事项
- R2 的 listObjectsV2 单次最多返回 1000 个文件,超过要用 ContinuationToken 分页
- 下载功能要兼容 R2 远程文件,不能再用
download('./uploads/xxx'),要判断是否 http 开头 - 用单例模式管理 R2 连接,但配置变更后要记得 resetInstance
- JSON 字段里的路径替换要特殊处理
评论
暂无评论