From 27a9816238cd8d39322f9d3c5b8dddf426af30b4 Mon Sep 17 00:00:00 2001 From: wonder Date: Thu, 7 May 2026 19:26:22 +0800 Subject: [PATCH] vault backup: 2026-05-07 19:26:22 --- hzh/DEV/OSS.md | 682 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 hzh/DEV/OSS.md diff --git a/hzh/DEV/OSS.md b/hzh/DEV/OSS.md new file mode 100644 index 0000000..da7ed4d --- /dev/null +++ b/hzh/DEV/OSS.md @@ -0,0 +1,682 @@ +--- +tags: [后端, Go, 对象存储, S3, 文件上传] +create time: 2026-05-07 19:12 +--- + +# OSS 对象存储 + +## 概述 + +OSS(Object Storage Service)即**对象存储**,是一种将非结构化数据作为对象存储在云端的方案。阿里云的 OSS、AWS 的 S3 以及兼容 S3 协议的 MinIO 本质上是同一种技术——通过 HTTP API 对海量二进制数据进行增删改查。理解其核心概念和工作模式,是构建现代 Web 应用基础设施的关键一环。 + +思考题:和传统的关系型数据库相比,为什么图片、视频、日志文件这类数据不适合存在 MySQL 里? + +## 正文 + +### 1. 核心概念 + +对象存储有三个基本概念: + +| 概念 | 说明 | 类比关系型数据库 | +|------|------|------------------| +| **Bucket(存储桶)** | 对象的容器,类似"数据库" | Database / Table | +| **Object(对象)** | 实际存储的数据,包含 Key + Body + Metadata,类似"行记录" | Row | +| **Key(键)** | 对象的全局唯一标识,类似文件系统路径 | File Path | + +> **关键点**:对象存储是一个**扁平结构**——没有真正的目录层级,所谓的 `folder/` 只是 Key 中的一部分字符(如 `photos/2026/vacation.jpg`)。但管理面板会按 `/` 做可视化分组。 + +```mermaid +flowchart LR + subgraph OSS_BUCKET["Bucket: my-app-data"] + direction TB + O1["Key: uploads/avatar/001.png
Body: (PNG binary data)"] + O2["Key: docs/report.pdf
Body: (PDF binary data)"] + O3["Key: backups/db-dump.sql.gz
Body: (SQL compressed)"] + end + + classDef bucketStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + class OSS_BUCKET bucketStyle +``` + +**Region 和 Endpoint:** + +每个 Bucket 必须属于一个地域(Region),Region 决定了数据的物理存储位置。访问时需要使用对应的 Endpoint: + +| 地域 | 内网 Endpoint | 外网 Endpoint | +|------|---------------|---------------| +| 杭州 (oss-cn-hangzhou) | `oss-cn-hangzhou-internal.aliyuncs.com` | `oss-cn-hangzhou.aliyuncs.com` | +| 上海 (oss-cn-shanghai) | `oss-cn-shanghai-internal.aliyuncs.com` | `oss-cn-shanghai.aliyuncs.com` | +| 北京 (oss-cn-beijing) | `oss-cn-beijing-internal.aliyuncs.com` | `oss-cn-beijing.aliyuncs.com` | + +> **最佳实践**:当你的 Go 服务和 OSS Bucket 在同一 Region 时,务必使用**内网 Endpoint**。内网流量免费且速度比外网快 5~10 倍。 + +### 2. Go SDK 选型 + +目前 Go 生态中有两套主流 OSS/S3 SDK: + +```mermaid +graph TD + A["Go 对象存储 SDK"] --> B["Aliyun OSS SDK
alibabacloud-oss-go-sdk-v2"] + A --> C["Minio Client SDK
minio/go-minio"] + A --> D["AWS SDK for Go v2
aws/aws-sdk-go-v2/s3"] + + B --> B1["适用于阿里云 OSS"] + B --> B2["API 完全覆盖 OSS 功能"] + B --> B3["绑定阿里云鉴权体系"] + + C --> C1["兼容 S3/OSS/COS/GCS 等"] + C --> C2["API 接近标准库 io.Reader"] + C --> C3["推荐用于多云/混合云场景"] + + D --> D1["AWS 官方 SDK"] + D --> D2["功能最全面"] + D --> D3["体积大, 编译慢"] + + style C1 fill:#e8f5e9,stroke:#2e7d32 + style B1 fill:#fff3e0,stroke:#f57c00 + style D1 fill:#e3f2fd,stroke:#1565c0 +``` + +| 对比项 | Aliyun OSS SDK | Minio Client | AWS S3 SDK v2 | +|--------|---------------|-------------|---------------| +| 适配服务 | 仅阿里云 OSS | S3/OSS/COS/GCS等 | AWS S3 / Cloudflare R2 | +| 安装大小 | ~5MB | ~2MB | ~150MB | +| API 风格 | RESTful 包装 | `io.Reader` 流式 | 深度异步链式 | +| 预签名 URL | ✅ 原生支持 | ✅ `PresignedGetObject` | ✅ `PresignObjectAPI` | +| 推荐度 | 只用阿里云 | **多云通用首选** | AWS 重度用户 | + +> **结论**:如果你只做阿里云,用官方 SDK;如果需要同时对接 S3、MinIO 或其他兼容 S3 的服务,选择 Minio Client SDK。 + +### 3. 初始化客户端 + +**方式一:阿里云官方 SDK(v2)** + +```go +import ( + oss "github.com/alibabacloud-go/oss-20190517/v2/client" +) + +// 从环境变量读取凭证,避免硬编码 +client := oss.NewClient(&oss.Config{ + Endpoint: oss.String("oss-cn-hangzhou.aliyuncs.com"), + AccessKeyId: oss.String(os.Getenv("OSS_ACCESS_KEY_ID")), + AccessKeySecret: oss.String(os.Getenv("OSS_ACCESS_KEY_SECRET")), +}) +``` + +**方式二:Minio Client(更简洁,跨云兼容)** + +```go +import "github.com/minio/minio-go/v7" + +client, err := minio.New("oss-cn-hangzhou.aliyuncs.com", &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: true, // HTTPS +}) +if err != nil { + log.Fatal(err) +} +``` + +> **安全提醒**:永远不要将 AccessKey 写在代码里。生产环境应使用环境变量、密钥管理服务(KMS / Vault / 云厂商 IAM Role)注入。 + +### 4. Bucket 操作 + +#### 创建与查询 + +```go +// 创建 Bucket(Minio 写法) +bucketName := "my-app-uploads" +location := "cn-hangzhou" + +err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{ + Region: location, +}) +// 如果已存在则 ErrBucketExists 忽略即可 + +// 检查 Bucket 是否存在 +exists, _ := client.BucketExists(ctx, bucketName) +``` + +#### 权限模型 + +| ACL | 说明 | 适用场景 | +|-----|------|---------| +| `Private`(默认) | 只有 Owner 可读写 | 用户上传的文件、备份 | +| `PublicRead` | Owner 读写 + 所有人可读 | CDN 加速的图片资源 | +| `PublicReadWrite` | 所有人可读写 | **极不推荐** — 可能被恶意刷费 | + +### 5. 文件上传:三种架构对比 + +前端要上传图片/文件到 OSS,主要有以下三种架构方案。选择合适的方案直接影响系统的**可扩展性**、**安全性**和**开发成本**。 + +#### 方案 A:服务端中转上传(Server Relay) + +```mermaid +sequenceDiagram + participant FE as 浏览器 / 客户端 + participant GO as Gin Server + participant OSS as OSS 服务 + + FE->>GO: POST 文件 (multipart/form-data) + Note over GO: 解析请求体 (io.Reader 流式) + GO->>OSS: PUT 上传 (Stream 转发) + OSS-->>GO: 200 OK (ETag, Location) + GO-->>FE: 200 OK (返回 OSS 访问地址) +``` + +**工作流程:** +1. 客户端将文件 POST 到 Go 服务端(multipart/form-data) +2. Go 服务端接收后,通过 SDK 将文件写入 OSS +3. 返回文件的 OSS 访问地址给客户端 + +```go +func uploadWithRelay(c *gin.Context) { + // 限制文件大小(防内存溢出 + 防恶意大文件) + c.Request.ParseMultipartForm(32 << 20) // 最大 32MB + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(400, gin.H{"error": "invalid file"}) + return + } + defer file.Close() + + // 校验文件类型 + buf := make([]byte, 512) + file.Read(buf) + contentType := http.DetectContentType(buf) + allowed := map[string]bool{ + "image/jpeg": true, "image/png": true, "image/webp": true, + } + if !allowed[contentType] { + c.JSON(400, gin.H{"error": "unsupported file type"}) + return + } + // rewind for reading again + file.Seek(0, io.SeekStart) + + // 重命名防止冲突(UUID_原文件名) + fileName := uuid.New().String() + "_" + header.Filename + + _, err = client.PutObject(c.Request.Context(), bucketName, fileName, file, -1, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + // 记录元信息到数据库 + db.Create(&FileRecord{Key: fileName, Size: header.Size, Type: contentType}) + + c.JSON(200, gin.H{ + "url": fmt.Sprintf("https://%s.%s/%s", bucketName, endpoint, fileName), + "size": header.Size, + }) +} +``` + +#### 方案 B:预签名 URL 直传(Pre-Signed URL) + +```mermaid +sequenceDiagram + participant FE as 浏览器 / 客户端 + participant GO as Gin Server + participant OSS as OSS 服务 + + FE->>GO: GET /upload/url?filename=photo.jpg + Note over GO: 验证登录态和权限 + GO->>OSS: PresignedPutObject(photo.jpg, 15min) + OSS-->>GO: (预签名URL)含签名和过期参数 + GO-->>FE: (返回URL和类型信息) + + FE->>OSS: PUT 直接上传
Content-Type: image/jpeg
文件二进制数据 + OSS-->>FE: 200 OK + + opt 通知服务端完成 + FE->>GO: POST /upload/callback
文件名和元信息 + Note over GO: 验证对象存在于 OSS 中 + GO-->>FE: 200 OK (记录元信息到 DB) + end +``` + +**工作流程:** +1. 客户端请求服务端获取预签名 URL +2. 服务端调用 OSS 的 `PresignedPutObject` 生成有时效性的上传链接 +3. 客户端拿到 URL 后**直接 PUT 到 OSS**,不经服务端 +4. 上传完成后回调服务端,记录元信息到数据库 + +```go +// --- 服务端:生成预签名 URL --- +func getPresignedUploadURL(c *gin.Context) { + filename := c.Query("filename") + contentType := c.DefaultQuery("contentType", "application/octet-stream") + + url, err := client.PresignedPutObject( + context.Background(), + bucketName, + filepath.Join("uploads", filename), + 15*time.Minute, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{ + "uploadURL": url.String(), + "fileName": filepath.Join("uploads", filename), + "contentType": contentType, + "expiresIn": 900, // 秒 + }) +} + +// --- 客户端:用返回的 URL 直接 PUT 到 OSS --- +/* +const resp = await fetch('/api/upload/url?filename=avatar.jpg'); +const { uploadURL, contentType } = await resp.json(); + +await fetch(uploadURL, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: formData.get('file'), +}); + +// 上传完成后通知服务端 +await fetch('/api/upload/callback', { + method: 'POST', + body: JSON.stringify({ fileName: 'avatar.jpg', fileSize: 102400 }), +}); +*/ +``` + +> **详细说明**:预签名 URL 的完整流程和安全要点请参考第 9 节。 + +#### 方案 C:STS 临时凭证直传(STS Credentials) + +```mermaid +sequenceDiagram + participant FE as 浏览器 / 客户端 + participant GO as Gin Server + participant STS as STS 服务 + participant OSS as OSS 服务 + + FE->>GO: 请求上传权限 + Note over GO: 验证用户身份 + GO->>STS: AssumeRole (角色Arn + SessionName) + STS-->>GO: AccessKeyId + AccessKeySecret + SecurityToken
有效期15分钟 + GO-->>FE: (返回临时凭证) + + FE->>OSS: 用临时凭证直接上传
PostObject API + OSS-->>FE: 200 OK +``` + +**工作流程:** +1. 客户端请求服务端获取上传权限 +2. 服务端调用 STS(Security Token Service)AssumeRole 获取临时凭据 +3. 将临时凭据(AccessKeyId + Secret + Token)发给客户端 +4. 客户端直接使用这些凭据通过 OSS PostObject API 上传 + +```go +import "github.com/aliyun/aliyun-sts-go-sdk" + +func getSTSCredentials(c *gin.Context) { + stsClient := stssdk.NewClientWithAccessKey("cn-hangzhou", masterAK, masterSK) + request := stssdk.CreateAssumeRoleRequest() + request.RoleArn = "acs:ram:::role/oss-upload-role" + request.RoleSessionName = "web-session-" + userID + request.DurationSeconds = 900 // 15 分钟 + request.Policy = `{ + "Statement": [{ + "Action": ["oss:PutObject", "oss:PostObject"], + "Effect": "Allow", + "Resource": ["acs:oss:*:*:my-bucket/uploads/*"] + }] + }` + + response, err := stsClient.AssumeRole(request) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{ + "accessKeyId": response.Credentials.AccessKeyId, + "accessKeySecret": response.Credentials.AccessKeySecret, + "securityToken": response.Credentials.SecurityToken, + "expiration": response.Credentials.Expiration, + }) +} +``` + +> **关键区别**:预签名 URL 是一次性的、针对特定文件的上传链接;STS 临时凭证是一组通用的上传凭据,可以在有效期内用于上传任意文件(受 Policy 约束)。 + +#### 三种方案对比 + +| 维度 | 方案 A:服务端中转 | 方案 B:预签名 URL 直传 | 方案 C:STS 临时凭证 | +|------|-------------------|------------------------|--------------------| +| **流量走向** | 客户端 → 服务端 → OSS | 客户端 → OSS | 客户端 → OSS | +| **服务端带宽消耗** | N × 文件大小 ⚠️ | 零带宽 ✅ | 零带宽 ✅ | +| **水平扩展性** | 差(服务端成为瓶颈) | 优秀 | 优秀 | +| **延迟** | 往返两次(双 RTT) | 一次直传(单 RTT) | 一次直传(单 RTT) | +| **控制粒度** | 粗(整个文件过服务端) | 中(URL 级控制) | 细(Policy 级可控) | +| **实现复杂度** | 低 ✅ | 中 ⚡ | 高 ❌ | +| **安全性** | 高(服务端全拦截) | 中高(签名+过期) | 中(需配置好 Policy) | +| **适合场景** | 小文件 (<1MB)、需要服务端二次处理 | 普通上传(头像、截图等) | 大文件、批量上传、企业级应用 | + +#### 如何选型? + +```mermaid +flowchart TD + A["用户上传文件"] --> B{"文件大小?"} + B -- "<= 1MB" --> C{"是否需要服务端
内容分析/转码?"} + B -- "> 1MB 或不确定" --> D{"预期并发量?"} + + C -- "否" --> E["✅ 方案 B: 预签名 URL
最简单+零带宽开销"] + C -- "是" --> F["✅ 方案 A: 服务端中转
可控但限并发"] + + D -- "QPS < 100" --> G["✅ 方案 B: 预签名 URL"] + D -- "QPS >= 100 或
大批量/大文件" --> H["✅ 方案 C: STS 临时凭证
灵活且可扩展"] +``` + +> **经验法则**:大多数 Web 应用选**方案 B(预签名 URL)**就足够了。只有在需要对文件内容进行服务端分析(如病毒扫描、图像压缩、OCR),或者并发量极大时才考虑方案 A 或 C。 + +--- + +*本节的代码示例即为各方案的完整实现;更多细节和上下文请参考:服务端中转 → 第 6 节(对象基本操作)、预签名 URL → 第 7 节、STS 临时凭证 → 第 9 节。* + +### 6. 对象基本操作 + +#### 上传(服务端直传示例) + +```go +// 小文件:< 100MB 时直接用 FPutObject +err := client.FPutObject(ctx, bucketName, "uploads/photo.jpg", "./photo.jpg", minio.PutObjectOptions{ + ContentType: "image/jpeg", +}) + +// 大文件:自动分片上传(Minio SDK 内部处理) +file, _ := os.Open("large-video.mp4") +_, err = client.PutObject(ctx, bucketName, "videos/large.mp4", file, -1, minio.PutObjectOptions{ + PartSize: 10 << 20, // 每片 10MB + ContentType: "video/mp4", +}) +``` + +> **分片阈值**:Minio SDK 默认当文件超过 **5MB** 时自动启动分片上传,最大支持单文件 **50TB**。无需手动实现。 + +#### 下载 + +```go +// 完整下载 → io.Reader +obj, err := client.GetObject(ctx, bucketName, "uploads/photo.jpg", minio.GetObjectOptions{}) +defer obj.Close() + +data, _ := io.ReadAll(obj) +_ = data // 写入数据库或直接返回给前端 + +// 范围下载(只读文件的某个区间) +opts := minio.GetObjectOptions{} +opts.Set("Range", "bytes=0-1023") // 只读前 1KB +``` + +#### 删除 + +```go +// 删除单个对象 +client.RemoveObject(ctx, bucketName, "uploads/old-photo.jpg", minio.RemoveObjectOptions{}) + +// 批量删除 +objsCh := make(chan minio.ObjectInfo, 100) +for info := range objsCh { + client.RemoveObject(ctx, bucketName, info.Key, minio.RemoveObjectOptions{}) +} +close(objsCh) +client.RemoveObjects(ctx, bucketName, objsCh, minio.RemoveObjectsOptions{}) +``` + +#### 列出对象 + +```go +// 列出以 "uploads/" 开头的对象(模拟目录效果) +objects := client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{ + Prefix: "uploads/", // 前缀过滤 + Recursive: true, // false 则只在虚拟"目录"层面 +}) + +for obj := range objects { + fmt.Printf("%-40s %8d bytes %s\n", obj.Key, obj.Size, obj.LastModified) +} +``` + +### 7. 预签名 URL — 免服务端中转 + +**这是对象存储最常用的高级模式之一。** + +> **核心问题**:用户上传图片时,如果不走对象存储直传,而是先 POST 到 Go 服务端再由服务端转发到 OSS——当 1000 个用户同时上传 5MB 图片时,Go 进程需要消耗多少带宽? + +答案很简单:**5MB × 1000 = 5GB 内存 + 带宽压力**。这就是为什么应该让客户端直接跟 OSS 对话。 + +**预签名 URL 流程:** + +```mermaid +sequenceDiagram + participant FE as 浏览器 / 客户端 + participant GO as Gin Server + participant OSS as OSS 服务 + + FE->>GO: GET /upload/url
?filename=photo.jpg + Note over GO: 验证用户登录态和权限 + GO->>OSS: PresignedGetObject(photo.jpg, 15min) + OSS-->>GO: (预签名URL)
含签名和过期参数 + GO-->>FE: (返回URL和类型信息) + + FE->>OSS: PUT直接上传
Content-Type: image/jpeg
文件二进制数据 + OSS-->>FE: 200 OK + + opt 通知服务端完成 + FE->>GO: POST /upload/callback
文件名和元信息 + GO-->>FE: 200 OK + end +``` + +> **安全要点**:预签名 URL 有时间限制(通常 5~15 分钟),过期后无效。即使 URL 泄露也无法长期滥用。 + +**代码示例:** + +```go +func getPresignedUploadURL(c *gin.Context) { + filename := c.Query("filename") + contentType := c.DefaultQuery("contentType", "application/octet-stream") + + // 生成一个有效期 15 分钟的 PUT 预签名 URL + // 注意:PresignedPutObject 不允许设置 Content-Type 约束 + // 如需严格校验,应在 Callback 中二次确认 + url, err := client.PresignedPutObject( + context.Background(), + bucketName, + filepath.Join("uploads", filename), + 15*time.Minute, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{ + "uploadURL": url.String(), + "fileName": filepath.Join("uploads", filename), + "contentType": contentType, + "expiresIn": 900, // 秒 + }) +} +``` + +**POST 端回调验证(防越权上传):** + +```go +func uploadCallback(c *gin.Context) { + var req struct { + FileName string `json:"fileName" binding:"required"` + FileSize int64 `json:"fileSize" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "invalid params"}) + return + } + + key := filepath.Join("uploads", req.FileName) + + // 检查对象是否真的存在于 OSS 中 + _, err := client.StatObject(context.Background(), bucketName, key, minio.StatObjectOptions{}) + if err != nil { + c.JSON(404, gin.H{"error": "file not found in OSS"}) + return + } + + // 保存元信息到数据库 + db.Create(&FileRecord{Key: key, Size: req.FileSize}) + c.JSON(200, gin.H{"status": "recorded"}) +} +``` + +### 8. 生命周期管理(Lifecycle) + +频繁删除和上传会产生大量碎片化的旧版本,合理配置 Lifecycle 可以节省成本并避免磁盘爆炸: + +```go +import "github.com/minio/minio-go/v7/pkg/lifecycle" + +err := client.SetBucketLifecycle(ctx, bucketName, &lifecycle.Configuration{ + Rules: []lifecycle.Rule{ + { + ID: "expire-old-uploads", + Status: lifecycle.RuleEnabled, + Prefix: "uploads/", + Expiration: lifecycle.Expiration{Days: 90}, // 90天后自动删除 + }, + { + ID: "transition-rare-access", + Status: lifecycle.RuleEnabled, + Prefix: "backups/", + Transition: lifecycle.Transition{ + Days: 30, + StorageClass: "GLACIER", // 转归档存储(S3 兼容写法) + }, + }, + }, +}) +``` + +**存储类型与成本对比(阿里云为例):** + +| 存储类型 | 单价 (元/GB/月) | 取回费用 | 最低存储时长 | 适用场景 | +|---------|---------------|---------|------------|---------| +| 标准存储(Standard) | ~0.12 | 无 | 无 | 活跃数据、频繁访问 | +| 低频访问(IA) | ~0.084 | 有 | 30天 | 很少读取但需即时访问 | +| 归档存储 | ~0.042 | 有 + 恢复时间 | 60天 | 合规存档、历史备份 | +| 冷归档 | ~0.021 | 有 + 数小时恢复 | 180天 | 极低频、合规留存 | + +> **策略建议**:新创建的 Bucket 使用标准存储即可。在业务逻辑中标记哪些文件属于"冷数据",然后通过 Lifecycle 规则自动降级。不要人为在每个上传接口中判断冷热。 + +### 9. 并发与安全注意事项 + +**常见陷阱和解决方案:** + +```mermaid +flowchart TD + subgraph TRAPS["常见陷阱"] + T1["AccessKey泄露
导致账单爆炸"] + T2["未限流
OSS请求被拒绝"] + T3["命名冲突
同名文件覆盖"] + T4["内存溢出
全量读入内存"] + T5["内网跨地域
用了公网Endpoint"] + end + + subgraph FIXES["解决方案"] + F1["使用RAM子账号+STS
临时凭证(15min过期)"] + F2["客户端侧限速+重试退避
指数退避算法"] + F3["使用UUID或NanoID重命名
fileUUID_扩展名"] + F4["始终用io.Reader流式处理
避免ReadAll读大文件"] + F5["确保ECS与Bucket同Region
使用内网internal endpoint"] + end + + T1 --> F1 + T2 --> F2 + T3 --> F3 + T4 --> F4 + T5 --> F5 + + style T1 fill:#ffebee,stroke:#c62828 + style T2 fill:#ffebee,stroke:#c62828 + style T3 fill:#fff3e0,stroke:#f57c00 + style T4 fill:#ffebee,stroke:#c62828 + style T5 fill:#fff3e0,stroke:#f57c00 + + style F1 fill:#e8f5e9,stroke:#2e7d32 + style F2 fill:#e8f5e9,stroke:#2e7d32 + style F3 fill:#e8f5e9,stroke:#2e7d32 + style F4 fill:#e8f5e9,stroke:#2e7d32 + style F5 fill:#e8f5e9,stroke:#2e7d32 +``` + +**正确使用流式下载避免 OOM:** + +```go +// ❌ 错误做法:大文件会把整个内容加载到内存 +obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{}) +data, _ := io.ReadAll(obj) // 10GB 文件 → 10GB 内存 = 💥 + +// ✅ 正确做法:边读边写 +obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{}) +defer obj.Close() + +dst, _ := os.Create("./backup-latest.sql.gz") +defer dst.Close() + +_, err := io.Copy(dst, obj) // 零拷贝,内存占用恒定 +``` + +**STS 临时凭证(RAM Role):** + +```go +import "github.com/aliyun/aliyun-sts-go-sdk" + +func getTemporaryCredentials() (*sts.Credentials, error) { + client := stssdk.NewClientWithAccessKey("cn-hangzhou", ak, sk) + request := stssdk.CreateAssumeRoleRequest() + request.RoleArn = "acs:ram:::role/oss-upload-role" + request.RoleSessionName = "web-session" + request.DurationSeconds = 900 // 15 分钟 + + response, err := client.AssumeRole(request) + if err != nil { + return nil, err + } + // 前端用这些临时凭据直传 OSS,服务端不暴露 AK/SK + return &response.Credentials, nil +} +``` + +> **教学提示**:STS 的核心思路是"最小权限原则"——前端只获得一个有时效性的短期令牌,即使被拦截也只能在 15 分钟内使用,且只能用于指定的 Bucket 和路径。 + +## 附录:快速上手清单 + +部署新的 OSS 集成时,逐项检查: + +- [ ] 创建独立 RAM 子账号(不要直接用主账号 AK) +- [ ] 最小权限授权:只允许目标 Bucket 的操作 +- [ ] 服务端和内网都使用 `-internal` Endpoint +- [ ] 文件大小限制前置到中间件层(参考 `MaxMultipartMemory`) +- [ ] 文件名使用 UUID 重命名,防止冲突和路径遍历 +- [ ] 生成预签名 URL 时设置合理的过期时间 +- [ ] 上传成功后通过 callback 或事件通知更新数据库元信息 +- [ ] 配置 Lifecycle 自动清理过期文件 +- [ ] 大文件传输加进度条和断点续传支持(客户端侧) + +## 关联笔记 + +- [[GIN/8-file-upload]] — 文件上传后如何存入 OSS,以及 MaxMultipartMemory 的内存优化 +- [[GIN/11-static-files]] — 静态文件服务架构中 OSS/S3 + CDN 的集成模式 +- [[部署与运维基础]] — 容器环境中磁盘、网络和密钥管理的注意事项