07d82d046b
- Move standalone AI-related files into 8-artificial-intelligence directory - Move development tools content into 2-development-tools directory - Move server/backend content into 4-server-and-backend directory - Create new index files for each section - Update .gitignore to exclude old backup directories - Update theme imports for new component locations
1823 lines
61 KiB
Markdown
1823 lines
61 KiB
Markdown
# 对象存储与 CDN
|
||
> 💡 **学习指南**:本文会带你走完一条完整的链路——从文件上传到用户下载。你会看到对象存储如何像"智能仓库"一样管理海量文件,CDN 如何像"快递网点"一样把内容送到用户家门口,以及这中间有哪些"坑"等着你跳进去。建议先了解基础的 HTTP 请求和 DNS 解析原理。
|
||
|
||
在开始之前,建议你先补几块"基础砖":
|
||
|
||
- **HTTP 请求流程**:可以先阅读 [浏览器输入 URL 后发生了什么](./web-basics/url-to-browser.md) 了解完整的请求链路。
|
||
- **DNS 解析原理**:如果你对域名解析还不太熟悉,可以先看 [DNS 查询流程](./deployment/dns-flow.md) 的图解部分。
|
||
|
||
---
|
||
|
||
## 0. 引言:为什么文件上传下载这么"慢"?
|
||
|
||
想象一下这个场景:你在一个图片社区上传了一张 10MB 的高清照片,结果等了半分钟才传完;而你的朋友在北京,点击下载却只要 2 秒。为什么同一张文件,上传和下载的体验天差地别?
|
||
|
||
或者再想想:你的电商网站双十一搞活动,商品详情页突然涌入百万流量,服务器直接"躺平"。是带宽不够?还是架构设计有问题?
|
||
|
||
这些问题的答案,都藏在**对象存储**和 **CDN** 这对"黄金搭档"里。
|
||
|
||
---
|
||
|
||
## 1. 对象存储:你的"智能云仓库"
|
||
|
||
### 1.1 什么是对象存储?
|
||
|
||
传统文件系统就像你家衣柜:衣服按"上衣/裤子/裙子"分层放,你要找一件衬衫,得先打开衣柜→上衣区→衬衫格。这种"层级嵌套"的模式,在文件数量爆炸时会变得极其笨重。
|
||
|
||
对象存储则像现代仓储物流:每个包裹都有一个唯一的"快递单号"(对象键),你只需报单号,仓库机器人就能从海量包裹中精准取出。
|
||
|
||
<ObjectStorageDemo />
|
||
|
||
**核心区别一览**:
|
||
|
||
| 维度 | 传统文件系统 | 对象存储 |
|
||
| :----------- | :--------------------- | :---------------------- |
|
||
| **组织方式** | 层级目录树 | 扁平键值对 |
|
||
| **访问协议** | POSIX(本地文件操作) | HTTP/REST API |
|
||
| **扩展性** | 单机容量有限 | 近乎无限水平扩展 |
|
||
| **元数据** | 基础属性(大小、时间) | 丰富的自定义元数据 |
|
||
| **典型场景** | 本地办公文档 | 图片/视频/备份/静态资源 |
|
||
|
||
### 1.2 对象存储的核心概念
|
||
|
||
#### 桶(Bucket):你的"仓库分区"
|
||
|
||
桶是对象存储的顶级容器,相当于一个独立的命名空间。所有对象都必须存放在某个桶中。
|
||
|
||
**命名规则**(以阿里云 OSS 为例):
|
||
|
||
- 全局唯一:在整个云厂商的所有用户中不能重复
|
||
- 只能包含小写字母、数字和短横线
|
||
- 必须以小写字母或数字开头和结尾
|
||
- 长度在 3-63 个字符之间
|
||
|
||
**实战踩坑**:曾经有个团队按业务线创建了几十个桶,结果月底账单出来傻眼了——每个桶都有最低存储费用和请求费用。建议:按"环境+用途"组合规划桶,比如 `prod-static-assets`、`dev-backup-archive`。
|
||
|
||
#### 对象(Object):你的"数据包裹"
|
||
|
||
对象是存储的基本单元,由三部分组成:
|
||
|
||
1. **键(Key)**:对象的唯一标识,相当于"快递单号"
|
||
- 示例:`images/avatar/2024/user123.jpg`
|
||
- 虽然看起来像路径,但本质只是字符串
|
||
|
||
2. **数据(Data)**:对象的内容本身
|
||
- 可以是任意二进制数据
|
||
- 大小限制取决于云厂商(通常单个对象 5TB 以内)
|
||
|
||
3. **元数据(Metadata)**:描述对象的附加信息
|
||
- 系统元数据:Content-Type、ETag、Last-Modified 等
|
||
- 自定义元数据:如 `x-oss-meta-owner`、`x-oss-meta-project`
|
||
|
||
#### 访问控制:谁能动我的"仓库"?
|
||
|
||
对象存储提供多层权限控制:
|
||
|
||
| 层级 | 控制方式 | 典型场景 |
|
||
| :----------- | :------------------------ | :------------------------------ |
|
||
| **桶级别** | Bucket Policy(资源策略) | 禁止所有外网访问、只允许特定 IP |
|
||
| **对象级别** | ACL(访问控制列表) | 公开图片、私有文档 |
|
||
| **临时授权** | STS(安全令牌服务) | 前端直传、移动端上传 |
|
||
|
||
**安全红线**:永远不要把 AccessKey ID 和 AccessKey Secret 写在前端代码里!正确做法是:前端向你的后端申请临时 STS 凭证,后端验证身份后返回带过期时间的临时凭证。
|
||
|
||
---
|
||
|
||
## 2. CDN:你的"全球快递网络"
|
||
|
||
### 2.1 为什么需要 CDN?
|
||
|
||
想象你开了一家网店,服务器放在深圳。现在有个用户在北京访问你的图片:
|
||
|
||
- **没有 CDN**:请求从北京→河北→河南→湖北→湖南→广东→深圳,跨越 2000 多公里,来回就是 4000 多公里。光网络传输就要几十毫秒,遇到网络拥堵更惨。
|
||
|
||
- **有了 CDN**:请求从北京直接到北京的 CDN 节点(可能就在北京联通机房),距离从 2000 公里变成 20 公里,延迟从 50ms 变成 5ms。
|
||
|
||
这就是 CDN 的核心价值:**让内容离用户更近**。
|
||
|
||
<CdnAccelerationDemo />
|
||
|
||
### 2.2 CDN 的核心架构
|
||
|
||
#### 边缘节点:离用户最近的"快递站"
|
||
|
||
边缘节点是 CDN 网络中最接近用户的层级,通常部署在:
|
||
|
||
- 运营商机房(联通/电信/移动)
|
||
- 大城市互联网交换中心
|
||
- 重要交通枢纽
|
||
|
||
**中国主要 CDN 节点分布**:
|
||
|
||
- 一线城市:北京、上海、广州、深圳
|
||
- 二线城市:杭州、南京、成都、武汉、西安
|
||
- 海外:香港、新加坡、东京、硅谷、法兰克福
|
||
|
||
<EdgeNodeDistributionDemo />
|
||
|
||
#### 源站:内容的"总仓库"
|
||
|
||
源站是 CDN 回源获取内容的地方,可以是:
|
||
|
||
- 对象存储(OSS/COS/S3)
|
||
- 自建服务器(ECS/物理机)
|
||
- 负载均衡(SLB/CLB)
|
||
|
||
**关键配置**:
|
||
|
||
- **回源 HOST**:CDN 节点访问源站时使用的域名/IP
|
||
- **回源协议**:HTTP 还是 HTTPS
|
||
- **回源端口**:80、443 还是自定义端口
|
||
|
||
#### 中间层节点:"区域分拨中心"
|
||
|
||
在边缘节点和源站之间,CDN 通常还有一层或多层中间节点:
|
||
|
||
- **汇聚节点**:聚合多个边缘节点的回源请求,减少源站压力
|
||
- **区域中心**:负责一个大区的内容分发和调度
|
||
|
||
这种分层架构的好处:
|
||
|
||
1. **降低源站压力**:1000 个边缘节点的请求,可能只需要向源站发起 10 次
|
||
2. **提高命中率**:热门内容在中间层就被拦截,不需要回源
|
||
3. **故障隔离**:某条链路出问题,可以自动切换到其他路径
|
||
|
||
### 2.3 CDN 加速的完整流程
|
||
|
||
让我们跟踪一次真实的用户请求:
|
||
|
||
<CachePolicyDemo />
|
||
|
||
**Step 1:DNS 解析**(智能调度)
|
||
|
||
```
|
||
用户输入:cdn.example.com/image.jpg
|
||
↓
|
||
DNS 服务器返回:北京联通 CDN 节点 IP(1.2.3.4)
|
||
```
|
||
|
||
这里的关键是**智能 DNS**:根据用户的运营商、地理位置、节点负载,返回最优的 CDN 节点 IP。
|
||
|
||
**Step 2:边缘节点查找**(缓存命中?)
|
||
|
||
```
|
||
请求到达北京联通 CDN 节点(1.2.3.4)
|
||
↓
|
||
节点检查本地缓存:
|
||
├─ 命中?直接返回内容 ✓
|
||
└─ 未命中?继续下一步
|
||
```
|
||
|
||
**Step 3:回源获取**(层层向上)
|
||
|
||
```
|
||
边缘节点未命中
|
||
↓
|
||
向父节点(如:华北区域中心)请求
|
||
├─ 父节点命中?返回内容
|
||
└─ 父节点未命中?继续向上
|
||
↓
|
||
向源站请求
|
||
↓
|
||
源站返回内容
|
||
```
|
||
|
||
**Step 4:缓存并返回**(下次更快)
|
||
|
||
```
|
||
内容沿链路返回
|
||
↓
|
||
每层节点都缓存一份
|
||
↓
|
||
最终到达用户
|
||
```
|
||
|
||
这样,下次有用户请求同一个文件时,就能直接从边缘节点返回,实现"秒开"。
|
||
|
||
---
|
||
|
||
## 3. 从上传到访问:完整链路解析
|
||
|
||
### 3.1 文件上传的三种方式
|
||
|
||
<UploadProcessDemo />
|
||
|
||
#### 方式一:客户端 → 服务端 → 对象存储(传统模式)
|
||
|
||
```
|
||
浏览器 → 你的后端服务器 → 对象存储
|
||
```
|
||
|
||
**流程**:
|
||
|
||
1. 用户选择文件,点击上传
|
||
2. 文件先上传到你的后端服务器
|
||
3. 后端接收完整文件后,再转上传到对象存储
|
||
4. 返回上传结果给用户
|
||
|
||
**优点**:
|
||
|
||
- 实现简单,前后端都好控制
|
||
- 可以在后端做文件校验、格式转换
|
||
- 敏感操作可以记录日志、做权限校验
|
||
|
||
**缺点**:
|
||
|
||
- **带宽双吃**:用户上传占用一次带宽,服务器转传又占用一次
|
||
- **服务器压力大**:大文件会占用大量内存和 CPU
|
||
- **上传慢**:相当于多了一道中转,用户感知到的上传时间更长
|
||
|
||
**适用场景**:小文件(<10MB)、需要后端处理(如图片压缩、加水印)、内部管理系统。
|
||
|
||
#### 方式二:客户端直传对象存储(现代推荐)
|
||
|
||
```
|
||
浏览器 ──────→ 对象存储
|
||
↑
|
||
后端只签发临时凭证
|
||
```
|
||
|
||
**流程**:
|
||
|
||
1. 用户选择文件,前端先向后端申请"上传凭证"
|
||
2. 后端验证用户身份,向对象存储服务申请**临时 STS 凭证**(带过期时间)
|
||
3. 后端把临时凭证返回给前端
|
||
4. 前端拿着凭证,**直接上传文件到对象存储**
|
||
5. 对象存储返回上传结果,前端通知后端"上传完成"
|
||
|
||
**优点**:
|
||
|
||
- **上传快**:少了中转环节,用户感知速度最快
|
||
- **服务器压力小**:只处理凭证签发,不处理文件流
|
||
- **带宽省**:只走一次上传流量
|
||
- **安全性高**:临时凭证有过期时间,泄露也危害有限
|
||
|
||
**缺点**:
|
||
|
||
- 实现稍复杂,需要理解 STS、签名机制
|
||
- 前端需要处理分片上传、断点续传等逻辑
|
||
- 跨域(CORS)需要配置
|
||
|
||
**适用场景**:大文件上传、用户生成内容(UGC)、需要高并发上传的业务。
|
||
|
||
#### 方式三:分片上传 + 断点续传(大文件必备)
|
||
|
||
```
|
||
10GB 视频文件
|
||
↓
|
||
切分成 1000 个 10MB 的分片
|
||
↓
|
||
并行上传(同时传 5 个分片)
|
||
↓
|
||
断网了!已传 600 个
|
||
↓
|
||
恢复网络,从第 601 个继续传
|
||
↓
|
||
所有分片传完,发起"合并"请求
|
||
```
|
||
|
||
**为什么需要分片?**
|
||
|
||
| 场景 | 不分片 | 分片 |
|
||
| :----------- | :---------------------- | :------------------- |
|
||
| **网络波动** | 传了 99% 断网,全部重传 | 只重传失败的分片 |
|
||
| **上传速度** | 单线程,速度慢 | 多线程并行,速度快 |
|
||
| **内存占用** | 需要缓存整个文件 | 只需缓存当前分片 |
|
||
| **进度显示** | 只有 0% 和 100% | 精确到每个分片的进度 |
|
||
|
||
**主流云厂商的分片规格**:
|
||
|
||
| 厂商 | 分片大小限制 | 最大分片数 | 最小分片大小 |
|
||
| :------------- | :----------- | :--------- | :----------- |
|
||
| **阿里云 OSS** | 100MB | 10000 | 100KB |
|
||
| **腾讯云 COS** | 5GB | 10000 | 1MB |
|
||
| **AWS S3** | 5GB | 10000 | 5MB(推荐) |
|
||
| **七牛云** | 100MB | 10000 | 4MB |
|
||
|
||
### 3.2 CDN 回源策略详解
|
||
|
||
<CachePolicyDemo />
|
||
|
||
#### 什么是"回源"?
|
||
|
||
CDN 边缘节点缓存了源站的内容,但当:
|
||
|
||
- 用户请求的内容**第一次被访问**
|
||
- 缓存的内容**已过期(TTL 到期)**
|
||
- 缓存被**手动刷新/预热**
|
||
|
||
CDN 节点就需要向**源站**请求最新内容,这个过程就叫"回源"。
|
||
|
||
#### 回源的三种模式
|
||
|
||
| 模式 | 原理 | 适用场景 | 优缺点 |
|
||
| :-------------------- | :----------------------- | :------------------------ | :----------------------- |
|
||
| **直接回源** | CDN 节点 → 源站 | 源站有公网 IP,且流量不大 | 简单直接,但源站压力大 |
|
||
| **中间源回源** | CDN 节点 → 中间层 → 源站 | 大型网站,多层缓存架构 | 分担源站压力,架构复杂 |
|
||
| ** OSS/COS 作为源站** | CDN 节点 → 对象存储 | 静态资源、图片、视频 | 最佳实践,成本低、性能好 |
|
||
|
||
#### 回源配置实战
|
||
|
||
**场景 1:对象存储作为源站(推荐)**
|
||
|
||
```
|
||
用户访问:cdn.example.com/images/photo.jpg
|
||
↓
|
||
CDN 边缘节点(北京)
|
||
↓
|
||
未命中,回源到源站
|
||
↓
|
||
源站:bucket-name.oss-cn-beijing.aliyuncs.com
|
||
↓
|
||
返回图片,CDN 缓存并响应用户
|
||
```
|
||
|
||
关键配置项:
|
||
|
||
- **源站类型**:OSS/COS 域名 或 自定义源站
|
||
- **回源协议**:HTTP 还是 HTTPS(建议 HTTPS)
|
||
- **回源 HOST**:访问源站时使用的 Host 头
|
||
- **回源 SNI**:HTTPS 回源时的服务器名称指示
|
||
|
||
**场景 2:多源站负载均衡**
|
||
|
||
当单个源站扛不住回源压力时,可以配置多个源站:
|
||
|
||
```
|
||
CDN 边缘节点
|
||
├─ 源站 A (权重 50%)
|
||
├─ 源站 B (权重 30%)
|
||
└─ 源站 C (权重 20%)
|
||
```
|
||
|
||
主备模式:
|
||
|
||
```
|
||
CDN 边缘节点
|
||
├─ 主源站 A (健康时全部流量)
|
||
└─ 备源站 B (主源故障时切换)
|
||
```
|
||
|
||
#### 回源带宽 vs CDN 带宽
|
||
|
||
这里有个容易混淆的概念:
|
||
|
||
| 指标 | 定义 | 计费关系 |
|
||
| :--------------- | :---------------------- | :--------------------------- |
|
||
| **CDN 下行带宽** | 从 CDN 节点到用户的流量 | 通常按流量计费的 CDN 费用 |
|
||
| **回源带宽** | 从源站到 CDN 节点的流量 | 通常对象存储或源站出流量费用 |
|
||
|
||
**省钱技巧**:
|
||
|
||
- 提高 CDN 命中率(让更多请求命中缓存,减少回源)
|
||
- 设置合理的缓存时间(TTL)
|
||
- 使用预热功能,在用户访问前就缓存热点内容
|
||
- 开启"跟随 301/302",避免不必要的回源跳转
|
||
|
||
### 3.3 缓存策略配置
|
||
|
||
<CachePolicyDemo />
|
||
|
||
#### 缓存键(Cache Key):决定什么算"同一个文件"
|
||
|
||
CDN 如何判断两次请求是否应该返回同一个缓存副本?靠的就是**缓存键**。
|
||
|
||
**默认缓存键通常包括**:
|
||
|
||
- URL 路径(不含查询参数)
|
||
- 例如:`/images/photo.jpg`
|
||
|
||
**问题场景**:
|
||
|
||
```
|
||
用户 A 请求:/images/photo.jpg?w=100&h=100 (100x100 缩略图)
|
||
用户 B 请求:/images/photo.jpg?w=800&h=600 (800x600 大图)
|
||
```
|
||
|
||
如果缓存键只包含路径,两张不同尺寸的图片会被认为是同一个文件,导致混乱。
|
||
|
||
**解决方案:自定义缓存键规则**
|
||
|
||
| 规则 | 示例 | 效果 |
|
||
| :------------------- | :------------------------ | :------------------------ |
|
||
| **保留指定查询参数** | 保留 `w`、`h` | 不同尺寸分别缓存 |
|
||
| **保留所有查询参数** | 保留全部 | 完全精确匹配 |
|
||
| **忽略特定查询参数** | 忽略 `token`、`timestamp` | 带时间戳的 URL 能命中缓存 |
|
||
| **包含请求头** | 包含 `Accept-Language` | 不同语言返回不同内容 |
|
||
|
||
**实战配置示例**(阿里云 CDN):
|
||
|
||
```
|
||
缓存键规则:
|
||
- URL 路径:/images/*
|
||
- 保留查询参数:w, h, format
|
||
- 忽略查询参数:token, timestamp, utm_source
|
||
```
|
||
|
||
#### 缓存时间(TTL):内容"新鲜度"的平衡
|
||
|
||
TTL(Time To Live)决定了内容在 CDN 节点上缓存多久。设置太短,回源多、成本高;设置太长,内容更新后用户看到旧内容。
|
||
|
||
**按文件类型设置 TTL 的建议**:
|
||
|
||
| 文件类型 | 建议 TTL | 原因 |
|
||
| :---------- | :---------------------- | :----------------------------- |
|
||
| HTML 页面 | 0-5 分钟 | 内容频繁更新,需要实时 |
|
||
| JS/CSS 文件 | 1 年(配合文件名 hash) | 内容不变,文件名变化即缓存失效 |
|
||
| 图片/视频 | 7-30 天 | 更新频率低,可长期缓存 |
|
||
| 字体文件 | 1 年 | 几乎不变 |
|
||
| API 响应 | 0-5 分钟(视业务) | 数据实时性要求高 |
|
||
|
||
**前端工程化配合 CDN 的最佳实践**:
|
||
|
||
```javascript
|
||
// webpack/vite 配置
|
||
output: {
|
||
filename: 'js/[name]-[contenthash:8].js',
|
||
chunkFilename: 'js/[name]-[contenthash:8].chunk.js',
|
||
}
|
||
```
|
||
|
||
生成的文件名:`app-a3f2b1c9.js`
|
||
|
||
- 文件内容变化 → hash 变化 → 新 URL → 自然缓存失效
|
||
- 文件内容不变 → hash 不变 → URL 不变 → 长期缓存命中
|
||
|
||
#### 缓存刷新与预热
|
||
|
||
**手动刷新(应急场景)**:
|
||
|
||
当你更新了源站内容,但 CDN 缓存还没过期,用户看到的还是旧内容:
|
||
|
||
| 刷新类型 | 效果 | 耗时 | 适用场景 |
|
||
| :----------- | :--------------------- | :---------- | :----------- |
|
||
| **URL 刷新** | 指定 URL 的缓存失效 | 5-10 分钟 | 单个文件更新 |
|
||
| **目录刷新** | 指定目录下所有内容失效 | 10-30 分钟 | 批量更新 |
|
||
| **全站刷新** | 整个域名的缓存全部失效 | 30 分钟以上 | 紧急回滚 |
|
||
|
||
**重要提醒**:刷新只是让缓存失效,下次请求会回源拉取新内容。不要在高峰期大批量刷新,否则可能导致源站被打爆。
|
||
|
||
**预热( proactive 优化)**:
|
||
|
||
刷新是被动的(内容已更新),预热是主动的(提前缓存)。
|
||
|
||
```
|
||
场景:明天上午 10 点要发一篇爆款文章
|
||
|
||
今晚就提交预热请求:
|
||
- URL: https://cdn.example.com/articles/爆款文章.html
|
||
- 预热范围:全国所有边缘节点
|
||
|
||
效果:
|
||
明天 10 点用户访问时,内容已经在边缘节点等着了
|
||
→ 零回源延迟,秒开体验
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 流量调度:让用户访问"最近"的节点
|
||
|
||
<TrafficSchedulingDemo />
|
||
|
||
### 4.1 智能 DNS 调度
|
||
|
||
传统 DNS 解析:
|
||
|
||
```
|
||
用户问:cdn.example.com 的 IP 是什么?
|
||
DNS 答:1.2.3.4(固定的)
|
||
```
|
||
|
||
智能 DNS 解析:
|
||
|
||
```
|
||
用户(北京联通)问:cdn.example.com 的 IP 是什么?
|
||
智能 DNS:让我查查... 北京联通的 CDN 节点是 1.2.3.4
|
||
|
||
用户(上海电信)问:cdn.example.com 的 IP 是什么?
|
||
智能 DNS:上海电信的 CDN 节点是 5.6.7.8
|
||
```
|
||
|
||
**调度维度**:
|
||
| 维度 | 说明 | 效果 |
|
||
| :--- | :--- | :--- |
|
||
| **地理位置** | 按省/市/国家分配 | 就近访问,降低延迟 |
|
||
| **运营商** | 联通/电信/移动/BGP | 同运营商传输,避免跨网 |
|
||
| **节点负载** | 实时 CPU/带宽/QPS | 避开过载节点 |
|
||
| **节点健康** | 探测可用性 | 自动剔除故障节点 |
|
||
| **成本因素** | 带宽单价差异 | 平衡性能与成本 |
|
||
|
||
### 4.2 HTTP DNS 与 IP 直连
|
||
|
||
传统 DNS 有个问题:**DNS 劫持和解析延迟**。
|
||
|
||
**HTTP DNS 方案**:
|
||
|
||
```
|
||
客户端 → 绕过系统 DNS → 直接问 HTTP DNS 服务(如 223.5.5.5:80)
|
||
↓
|
||
返回最优 IP 列表(带权重)
|
||
↓
|
||
客户端根据网络质量探测,选择最优 IP
|
||
```
|
||
|
||
优势:
|
||
|
||
- 防劫持:不走运营商 DNS
|
||
- 更精准:可以按客户端网络质量选择 IP
|
||
- 实时性:故障切换更快
|
||
|
||
**实战建议**:
|
||
|
||
- 移动端 APP 强烈建议接入 HTTP DNS
|
||
- Web 端可以使用 CDN 提供的 CNAME 调度
|
||
- 关键业务可以做多 IP 容灾(一个域名返回多个 IP)
|
||
|
||
---
|
||
|
||
## 5. HTTPS 优化:安全与性能的平衡
|
||
|
||
<HttpsOptimizationDemo />
|
||
|
||
### 5.1 为什么 CDN 上 HTTPS 很重要?
|
||
|
||
**场景对比**:
|
||
|
||
```
|
||
无 HTTPS:
|
||
用户访问 http://cdn.example.com/image.jpg
|
||
↓
|
||
浏览器地址栏显示"不安全"
|
||
↓
|
||
某些浏览器/APP 直接拦截访问
|
||
↓
|
||
SEO 排名降低
|
||
```
|
||
|
||
```
|
||
有 HTTPS:
|
||
用户访问 https://cdn.example.com/image.jpg
|
||
↓
|
||
浏览器显示绿色锁标志
|
||
↓
|
||
HTTP/2 多路复用生效
|
||
↓
|
||
性能 + 安全双提升
|
||
```
|
||
|
||
### 5.2 CDN HTTPS 配置要点
|
||
|
||
#### 证书管理
|
||
|
||
| 方案 | 说明 | 成本 | 适用场景 |
|
||
| :--------------------- | :-------------------- | :------------- | :--------------- |
|
||
| **云厂商免费证书** | 阿里云/腾讯云等提供 | 免费 | 单域名,快速上手 |
|
||
| **Let's Encrypt** | 社区免费证书 | 免费 | 自动化部署 |
|
||
| **商业 DV/OV/EV 证书** | 赛门铁克、GeoTrust 等 | ¥几百-几万/年 | 企业级、需要绿条 |
|
||
| **泛域名证书** | \*.example.com | ¥几千/年 | 多子域名 |
|
||
|
||
**实战建议**:
|
||
|
||
- 测试环境:Let's Encrypt 或云厂商免费证书
|
||
- 生产环境:泛域名证书(省事)或单域名 OV 证书(省钱)
|
||
- 注意证书过期时间,设置自动续期提醒
|
||
|
||
#### HTTPS 优化配置
|
||
|
||
**TLS 版本选择**:
|
||
|
||
```
|
||
推荐配置:仅 TLS 1.2 和 TLS 1.3
|
||
兼容配置:TLS 1.1 + TLS 1.2 + TLS 1.3(兼容老旧浏览器)
|
||
```
|
||
|
||
**密码套件**:
|
||
|
||
```
|
||
推荐:ECDHE 密钥交换 + AES-GCM 加密
|
||
禁用:DES、RC4、MD5、SHA1
|
||
```
|
||
|
||
**OCSP Stapling**:
|
||
|
||
```
|
||
功能:CDN 节点预获取证书吊销状态
|
||
效果:减少客户端验证时间 200-500ms
|
||
建议:务必开启
|
||
```
|
||
|
||
**TLS 会话复用**:
|
||
|
||
```
|
||
Session ID 复用:客户端带着上次 Session ID,服务端恢复会话
|
||
Session Ticket 复用:服务端把会话状态加密发给客户端,下次带来
|
||
效果:避免完整 TLS 握手,减少 1-RTT
|
||
```
|
||
|
||
### 5.3 HTTP/2 与 HTTP/3 在 CDN 上的应用
|
||
|
||
**HTTP/2 多路复用**:
|
||
|
||
```
|
||
HTTP/1.1:
|
||
请求 1 (index.html) ────────────────→
|
||
响应 1 ←──────────────────────────────
|
||
请求 2 (style.css) ─────────────────→
|
||
响应 2 ←──────────────────────────────
|
||
请求 3 (script.js) ─────────────────→
|
||
响应 3 ←──────────────────────────────
|
||
(串行,一个完了下一个)
|
||
|
||
HTTP/2:
|
||
请求 1 ──→
|
||
请求 2 ──→ 合并在一个 TCP 连接上,帧交错传输
|
||
请求 3 ──→
|
||
响应 1 ←── 按优先级流式返回
|
||
响应 2 ←──
|
||
响应 3 ←──
|
||
(并行,一个连接多路复用)
|
||
```
|
||
|
||
**HTTP/2 服务端推送**:
|
||
|
||
```
|
||
场景:用户请求 index.html,里面引用了 style.css 和 script.js
|
||
|
||
传统方式:
|
||
1. 用户下载 index.html
|
||
2. 解析发现需要 style.css 和 script.js
|
||
3. 再发两个请求获取
|
||
|
||
HTTP/2 推送:
|
||
1. 用户请求 index.html
|
||
2. CDN 节点返回 index.html 的同时,主动推送 style.css 和 script.js
|
||
3. 用户解析 html 时,资源已经在缓存里了
|
||
|
||
注意:推送要谨慎,推多了浪费带宽,推少了没效果
|
||
```
|
||
|
||
**HTTP/3 (QUIC)**:
|
||
|
||
```
|
||
HTTP/2 的问题:基于 TCP,队头阻塞
|
||
→ 一个 TCP 包丢失,整个连接等待重传
|
||
|
||
HTTP/3 的解决:基于 QUIC(UDP 之上实现可靠传输)
|
||
→ 每个流独立,一个流阻塞不影响其他流
|
||
→ 连接迁移:WiFi 切 4G,连接不中断
|
||
→ 0-RTT 握手:第一次访问也能快速建立连接
|
||
|
||
现状:2024 年主流 CDN 已支持 HTTP/3,建议开启
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 访问分析:看懂你的 CDN 报表
|
||
|
||
<AccessAnalyticsDemo />
|
||
|
||
### 6.1 核心指标解读
|
||
|
||
#### 带宽(Bandwidth)
|
||
|
||
```
|
||
定义:单位时间内传输的数据量
|
||
单位:bps(比特每秒)、Mbps、Gbps
|
||
|
||
CDN 带宽 = 所有边缘节点的出流量总和
|
||
|
||
注意区分:
|
||
- 计费带宽:通常按 95 峰值或日峰值计费
|
||
- 实际带宽:实时传输速率
|
||
```
|
||
|
||
**带宽与流量的关系**:
|
||
|
||
```
|
||
1 Mbps 带宽持续跑 1 小时 = 450 MB 流量
|
||
(计算:1,000,000 bps × 3600s ÷ 8 ÷ 1024 ÷ 1024 ≈ 429 MB)
|
||
```
|
||
|
||
#### QPS(Queries Per Second)
|
||
|
||
```
|
||
定义:每秒查询/请求数
|
||
|
||
CDN QPS = 所有边缘节点每秒处理的 HTTP 请求总数
|
||
|
||
注意:QPS 高不代表带宽高
|
||
- 小文件场景:QPS 很高,带宽不高
|
||
- 大文件场景:QPS 不高,带宽很高
|
||
```
|
||
|
||
#### 命中率(Hit Ratio)
|
||
|
||
```
|
||
定义:在 CDN 边缘节点命中的请求占总请求的比例
|
||
|
||
计算公式:
|
||
命中率 = (命中数 / 总请求数) × 100%
|
||
或
|
||
命中率 = (1 - 回源流量 / 总出流量) × 100%
|
||
|
||
行业标准:
|
||
- 图片/视频/JS/CSS:> 95%
|
||
- HTML 页面:50-80%(视更新频率)
|
||
- API 接口:通常不缓存或极低
|
||
```
|
||
|
||
**命中率低的常见原因**:
|
||
|
||
| 原因 | 现象 | 解决方案 |
|
||
| :------------- | :----------------- | :----------------------- |
|
||
| 缓存时间太短 | TTL 只有几分钟 | 根据文件类型调整 TTL |
|
||
| 查询参数变化 | URL 带随机数 | 配置忽略特定参数 |
|
||
| 缓存键设置不当 | 不该区分的被区分了 | 优化缓存键规则 |
|
||
| 内容更新频繁 | 文件经常被覆盖 | 使用版本号或 hash 文件名 |
|
||
| 首次访问多 | 新内容或新节点 | 提前预热 |
|
||
|
||
### 6.2 日志分析与问题排查
|
||
|
||
#### CDN 日志字段解析
|
||
|
||
典型 CDN 访问日志包含以下字段:
|
||
|
||
```
|
||
时间 | 客户端 IP | 请求方法 | URL | HTTP 状态码 | 响应大小 | 缓存状态 | 响应时间 | Referer | User-Agent
|
||
|
||
示例:
|
||
2024-01-15 14:32:01 | 114.114.114.114 | GET | https://cdn.example.com/images/photo.jpg | 200 | 153600 | HIT | 23 | https://example.com/ | Mozilla/5.0...
|
||
```
|
||
|
||
关键字段解释:
|
||
|
||
| 字段 | 说明 | 分析价值 |
|
||
| :-------------- | :------------- | :------------------------------------------- |
|
||
| `cache_status` | 缓存状态 | HIT(命中)、MISS(未命中)、EXPIRED(过期) |
|
||
| `response_time` | 响应时间(ms) | 判断用户体验,>500ms 需优化 |
|
||
| `http_status` | HTTP 状态码 | 404/500 错误排查 |
|
||
| `bytes_sent` | 发送字节数 | 带宽统计 |
|
||
|
||
#### 常见问题排查
|
||
|
||
**问题 1:用户反映访问慢**
|
||
|
||
排查步骤:
|
||
|
||
```
|
||
1. 看日志 response_time
|
||
- 如果很大(>500ms):检查是缓存 MISS 还是源站慢
|
||
|
||
2. 检查 cache_status
|
||
- HIT:缓存命中,慢可能是文件太大或节点问题
|
||
- MISS:未命中,需优化缓存策略或命中率
|
||
|
||
3. 检查客户端 IP 分布
|
||
- 某些地区慢:可能是该节点负载高或覆盖不足
|
||
```
|
||
|
||
**问题 2:缓存不生效,每次都回源**
|
||
|
||
排查清单:
|
||
|
||
```
|
||
□ 源站响应头是否有 Cache-Control: no-cache / private?
|
||
□ URL 是否带随机参数(如 ?_=123456)?
|
||
□ 缓存键配置是否正确?
|
||
□ TTL 设置是否过短?
|
||
□ 是否命中浏览器本地缓存而非 CDN?
|
||
```
|
||
|
||
**问题 3:费用暴涨**
|
||
|
||
排查方向:
|
||
|
||
```
|
||
1. 看账单明细
|
||
- CDN 流量费高:检查是否有大文件被频繁访问,或被盗链
|
||
- 回源流量费高:检查命中率是否骤降
|
||
- 请求数费用高:检查是否有 CC 攻击或爬虫
|
||
|
||
2. 看访问日志
|
||
- 是否有大量 404 请求(可能是扫描或配置错误)
|
||
- Referer 是否异常(判断是否被盗链)
|
||
|
||
3. 安全设置
|
||
- 开启防盗链(Referer 白名单)
|
||
- 开启 IP 黑名单/白名单
|
||
- 配置 CC 防护
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 实战案例:从 0 搭建图片加速方案
|
||
|
||
### 7.1 业务场景
|
||
|
||
假设你是一个图片社区的技术负责人,面临以下挑战:
|
||
|
||
- **用户上传**:用户每天上传 100 万张图片(平均 2MB/张)
|
||
- **用户访问**:每天 5000 万次图片查看请求
|
||
- **访问分布**:用户遍布全国,海外也有少量访问
|
||
- **性能要求**:图片加载时间 < 500ms
|
||
- **成本预算**:尽量控制在每月 5 万以内
|
||
|
||
### 7.2 架构设计
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ 用户上传流程 │
|
||
└──────────────────────────────────────┘
|
||
|
||
用户浏览器 后端服务 对象存储
|
||
│ │ │
|
||
│ 1. 申请上传凭证 │ │
|
||
│───────────────────────────────────────────>│ │
|
||
│ │ │
|
||
│ │ 2. 申请 STS 临时凭证 │
|
||
│ │───────────────────────────>│
|
||
│ │ │
|
||
│ │ 3. 返回 STS 凭证 │
|
||
│ │<───────────────────────────│
|
||
│ │ │
|
||
│ 4. 返回上传凭证(含 STS) │
|
||
│<───────────────────────────────────────────│ │
|
||
│ │ │
|
||
│ 5. 直接上传文件(使用 STS 签名) │
|
||
│──────────────────────────────────────────────────────────────────────>│
|
||
│ │ │
|
||
│ 6. 返回上传结果(URL、ETag 等) │
|
||
│<──────────────────────────────────────────────────────────────────────│
|
||
│ │ │
|
||
│ 7. 通知后端上传完成(保存到数据库) │
|
||
│───────────────────────────────────────────>│ │
|
||
|
||
|
||
┌──────────────────────────────────────┐
|
||
│ 用户访问流程 │
|
||
└──────────────────────────────────────┘
|
||
|
||
用户浏览器 DNS 解析 CDN 节点 对象存储(源站)
|
||
│ │ │ │
|
||
│ 1. 请求图片 URL │ │ │
|
||
│────────────────────────────────────────>│ │
|
||
│ │ │ │
|
||
│ │ 2. DNS 查询 │ │
|
||
│ │────────────────────>│ │
|
||
│ │ │ │
|
||
│ │ 3. 返回最优节点 IP │ │
|
||
│ │<────────────────────│ │
|
||
│ │ │ │
|
||
│ 4. 连接 CDN 节点 │ │ │
|
||
│────────────────────────────────────────>│ │
|
||
│ │ │ │
|
||
│ │ 5. 检查缓存 │ │
|
||
│ │ ├─ 命中?直接返回 │
|
||
│ │ └─ 未命中?继续 │
|
||
│ │ │ │
|
||
│ │ │ 6. 回源获取 │
|
||
│ │ │──────────────────>│
|
||
│ │ │ │
|
||
│ │ │ 7. 返回文件 │
|
||
│ │ │<──────────────────│
|
||
│ │ │ │
|
||
│ │ 8. 缓存并响应 │ │
|
||
│<────────────────────────────────────────│ │
|
||
```
|
||
|
||
### 7.3 关键配置详解
|
||
|
||
#### 对象存储配置
|
||
|
||
**存储桶规划**:
|
||
|
||
```
|
||
Bucket: myapp-images-prod
|
||
├─ 目录结构:
|
||
│ ├─ uploads/ # 用户上传的原图
|
||
│ │ ├─ 2024/01/15/user123-abc.jpg
|
||
│ │ └─ 2024/01/15/user456-def.png
|
||
│ ├─ thumbnails/ # 缩略图
|
||
│ │ ├─ small/ # 100x100
|
||
│ │ ├─ medium/ # 400x300
|
||
│ │ └─ large/ # 800x600
|
||
│ └─ processed/ # 处理后的图片(加水印等)
|
||
│
|
||
├─ 访问权限:
|
||
│ ├─ 原图目录:私有(需签名访问)
|
||
│ ├─ 缩略图目录:公共读
|
||
│ └─ 跨域 CORS:允许 *.myapp.com 访问
|
||
│
|
||
└─ 生命周期策略:
|
||
├─ 上传 7 天后:低频存储(省 40% 费用)
|
||
├─ 上传 90 天后:归档存储(省 70% 费用)
|
||
└─ 上传 3 年后:自动删除(或转存到更便宜的冷存储)
|
||
```
|
||
|
||
**CORS 跨域配置**:
|
||
|
||
```xml
|
||
<CORSConfiguration>
|
||
<CORSRule>
|
||
<AllowedOrigin>https://myapp.com</AllowedOrigin>
|
||
<AllowedOrigin>https://www.myapp.com</AllowedOrigin>
|
||
<AllowedMethod>GET</AllowedMethod>
|
||
<AllowedMethod>HEAD</AllowedMethod>
|
||
<AllowedHeader>*</AllowedHeader>
|
||
<ExposeHeader>ETag</ExposeHeader>
|
||
<ExposeHeader>x-oss-request-id</ExposeHeader>
|
||
<MaxAgeSeconds>3600</MaxAgeSeconds>
|
||
</CORSRule>
|
||
</CORSConfiguration>
|
||
```
|
||
|
||
#### CDN 加速配置
|
||
|
||
**缓存策略配置**:
|
||
|
||
```
|
||
全局默认规则:
|
||
├─ 缓存键:URL 路径 + 保留 w、h、format 查询参数
|
||
├─ 默认 TTL:7 天
|
||
└─ 回源 HOST:自动跟随
|
||
|
||
按文件类型细分:
|
||
├─ *.html:
|
||
│ ├─ TTL:5 分钟
|
||
│ └─ 优先从内存缓存读取
|
||
│
|
||
├─ *.js, *.css:
|
||
│ ├─ TTL:1 年
|
||
│ └─ 忽略查询参数(因为文件名有 hash)
|
||
│
|
||
├─ *.jpg, *.png, *.gif, *.webp:
|
||
│ ├─ TTL:30 天
|
||
│ ├─ 保留查询参数(w、h、format 用于动态裁剪)
|
||
│ └─ 启用图片自动压缩优化
|
||
│
|
||
└─ /api/*:
|
||
├─ TTL:0(不缓存)
|
||
└─ 直接回源
|
||
```
|
||
|
||
**HTTPS 优化配置**:
|
||
|
||
```
|
||
证书配置:
|
||
├─ 证书类型:泛域名证书 *.myapp.com
|
||
├─ 部署方式:CDN 控制台上传,自动续期
|
||
└─ 备用证书:EV 证书用于主域名(显示绿色地址栏)
|
||
|
||
TLS 配置:
|
||
├─ 最低 TLS 版本:1.2(兼容性与安全平衡)
|
||
├─ 最高 TLS 版本:1.3
|
||
├─ 密码套件:仅启用强加密套件
|
||
├─ OCSP Stapling:开启
|
||
├─ TLS 会话复用:开启 Session Ticket
|
||
└─ HSTS:开启(max-age=31536000)
|
||
|
||
HTTP/2 与 HTTP/3:
|
||
├─ HTTP/2:开启(多路复用、头部压缩)
|
||
├─ HTTP/2 Server Push:按需开启(推荐用 Preload 替代)
|
||
└─ HTTP/3 (QUIC):开启(实验性功能,逐步放量)
|
||
```
|
||
|
||
### 7.4 成本控制策略
|
||
|
||
#### 费用构成分析
|
||
|
||
```
|
||
月度 CDN + 对象存储费用构成:
|
||
|
||
CDN 部分:
|
||
├─ 下行流量费(大头,约 60%)
|
||
│ ├─ 中国大陆:0.15-0.30 元/GB
|
||
│ ├─ 亚太地区:0.40-0.80 元/GB
|
||
│ └─ 欧美:0.30-0.60 元/GB
|
||
│
|
||
├─ 请求数费用(小头,约 5%)
|
||
│ ├─ HTTP:0.01-0.05 元/万次
|
||
│ └─ HTTPS:0.05-0.15 元/万次(因为 TLS 握手消耗资源)
|
||
│
|
||
├─ 带宽峰值费用(可选计费方式)
|
||
│ └─ 95 峰值计费:适合流量波动大的场景
|
||
│
|
||
└─ 增值功能费(约 5%)
|
||
├─ HTTPS 证书管理
|
||
├─ WAF 防护
|
||
├─ 实时日志推送
|
||
└─ 边缘脚本/函数
|
||
|
||
对象存储部分:
|
||
├─ 存储容量费(约 15%)
|
||
│ ├─ 标准存储:0.12-0.15 元/GB/月
|
||
│ ├─ 低频存储:0.08-0.10 元/GB/月
|
||
│ └─ 归档存储:0.03-0.05 元/GB/月
|
||
│
|
||
├─ 请求费用(约 5%)
|
||
│ ├─ PUT:0.01-0.05 元/万次
|
||
│ └─ GET:0.005-0.01 元/万次
|
||
│
|
||
├─ 数据取回费用(低频/归档)
|
||
│ └─ 提前删除或取回收额外费用
|
||
│
|
||
└─ 回源出流量费(约 10%)
|
||
└─ CDN 回源到对象存储的流量费
|
||
```
|
||
|
||
#### 省钱技巧实战
|
||
|
||
**技巧 1:存储分级,自动生命周期管理**
|
||
|
||
```yaml
|
||
# 生命周期规则示例
|
||
rules:
|
||
- id: image-lifecycle
|
||
prefix: uploads/
|
||
transitions:
|
||
# 7 天后转低频存储,省 30% 费用
|
||
- days: 7
|
||
storageClass: IA
|
||
# 90 天后转归档存储,省 70% 费用
|
||
- days: 90
|
||
storageClass: Archive
|
||
# 3 年后自动删除
|
||
expiration:
|
||
days: 1095
|
||
```
|
||
|
||
**技巧 2:提高 CDN 命中率,减少回源**
|
||
|
||
```
|
||
命中率从 90% 提升到 95% 意味着什么?
|
||
|
||
假设:
|
||
- 日流量:10 TB
|
||
- 命中率 90%:回源 1 TB
|
||
- 命中率 95%:回源 0.5 TB
|
||
|
||
节省回源流量:0.5 TB/天 × 0.15 元/GB × 30 天 = 2250 元/月
|
||
```
|
||
|
||
**技巧 3:压缩与格式优化**
|
||
|
||
```
|
||
图片优化方案:
|
||
├─ 原图存储在对象存储(不直接对外)
|
||
├─ CDN 开启图片处理功能:
|
||
│ ├─ 格式自动转换:JPEG → WebP/AVIF(省 30-50%)
|
||
│ ├─ 质量自动压缩:视觉无损压缩(省 20-40%)
|
||
│ ├─ 尺寸自适应:根据设备返回合适尺寸
|
||
│ └─ 渐进式加载:先模糊后清晰
|
||
└─ 效果:带宽成本降低 50-70%
|
||
```
|
||
|
||
**技巧 4:带宽峰值封顶与告警**
|
||
|
||
```yaml
|
||
# 带宽封顶配置
|
||
bandwidth_cap:
|
||
daily_limit: 500 # Mbps,日峰值超过则自动停用 CDN
|
||
monthly_limit: 10000 # GB,月流量超过则停用
|
||
|
||
# 告警阈值
|
||
alerts:
|
||
- threshold: 70% # 达到 70% 发告警
|
||
channels: [sms, email]
|
||
- threshold: 90% # 达到 90% 打电话
|
||
channels: [phone]
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 总结:对象存储 + CDN 的黄金法则
|
||
|
||
### 8.1 架构设计原则
|
||
|
||
**原则 1:动静分离**
|
||
|
||
```
|
||
动态内容(API、HTML)→ 走源站或边缘函数
|
||
静态内容(图片、JS、CSS、视频)→ 走 CDN + 对象存储
|
||
```
|
||
|
||
**原则 2:就近服务**
|
||
|
||
```
|
||
用户在哪里,内容就缓存到哪里
|
||
→ 选择覆盖广的 CDN 服务商
|
||
→ 启用智能 DNS 调度
|
||
→ 重要内容提前预热
|
||
```
|
||
|
||
**原则 3:分层缓存**
|
||
|
||
```
|
||
浏览器本地缓存(最强)
|
||
↓
|
||
CDN 边缘节点缓存(次强)
|
||
↓
|
||
CDN 中间层/区域节点(兜底)
|
||
↓
|
||
对象存储/源站(最后防线)
|
||
```
|
||
|
||
**原则 4:成本与体验的平衡**
|
||
|
||
```
|
||
存储分级:热数据标准存储,冷数据归档存储
|
||
缓存策略:高频内容长 TTL,低频内容短 TTL
|
||
压缩优化:WebP/AVIF 格式,智能质量压缩
|
||
监控告警:设置带宽封顶,防止异常流量
|
||
```
|
||
|
||
### 8.2 避坑清单
|
||
|
||
**存储桶命名与权限**
|
||
|
||
- [ ] 桶名全局唯一,避免被占用
|
||
- [ ] 私有文件不要设置为公共读
|
||
- [ ] AccessKey 不要写在前端代码里,用 STS 临时凭证
|
||
- [ ] 启用服务端加密(SSE)保护敏感数据
|
||
|
||
**CDN 缓存配置**
|
||
|
||
- [ ] HTML 文件 TTL 不要太长(建议 < 5 分钟)
|
||
- [ ] JS/CSS 建议用带 hash 的文件名,TTL 设为 1 年
|
||
- [ ] 缓存键要合理,不要把用户信息等变量放进去
|
||
- [ ] 重要更新后记得刷新缓存或预热
|
||
|
||
**HTTPS 安全**
|
||
|
||
- [ ] 证书不要过期,设置自动续期
|
||
- [ ] 最低 TLS 版本建议 1.2
|
||
- [ ] 开启 HSTS 防止降级攻击
|
||
- [ ] 敏感 Cookie 设置 Secure 和 HttpOnly
|
||
|
||
**成本控制**
|
||
|
||
- [ ] 开启带宽封顶告警,防止异常流量
|
||
- [ ] 低频/归档存储有最小存储时间和提前删除费,注意规则
|
||
- [ ] 回源流量费也很贵,努力提高 CDN 命中率
|
||
- [ ] 定期分析访问日志,清理僵尸资源
|
||
|
||
---
|
||
|
||
## 9. 实战代码模板
|
||
|
||
### 9.1 前端直传对象存储(JavaScript)
|
||
|
||
```javascript
|
||
/**
|
||
* 对象存储直传工具类
|
||
* 支持:阿里云 OSS、腾讯云 COS、AWS S3
|
||
*/
|
||
class DirectUploader {
|
||
constructor(config) {
|
||
this.provider = config.provider // 'oss' | 'cos' | 's3'
|
||
this.region = config.region
|
||
this.bucket = config.bucket
|
||
this.getCredentials = config.getCredentials // 获取临时凭证的函数
|
||
}
|
||
|
||
/**
|
||
* 获取 STS 临时凭证
|
||
*/
|
||
async fetchCredentials() {
|
||
// 向后端申请临时凭证
|
||
const credentials = await this.getCredentials()
|
||
return {
|
||
accessKeyId: credentials.accessKeyId,
|
||
accessKeySecret: credentials.accessKeySecret,
|
||
sessionToken: credentials.securityToken || credentials.sessionToken,
|
||
expiration: credentials.expiration
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成上传签名(适用于前端计算签名)
|
||
*/
|
||
generateSignature(credentials, fileKey, fileType, options = {}) {
|
||
const timestamp = new Date().toISOString()
|
||
const date = timestamp.slice(0, 10).replace(/-/g, '')
|
||
|
||
// 不同厂商的签名算法略有差异
|
||
switch (this.provider) {
|
||
case 'oss':
|
||
return this._ossSignature(credentials, fileKey, date, options)
|
||
case 'cos':
|
||
return this._cosSignature(credentials, fileKey, date, options)
|
||
case 's3':
|
||
return this._s3Signature(credentials, fileKey, date, options)
|
||
default:
|
||
throw new Error('Unknown provider')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单文件上传(小文件 < 100MB)
|
||
*/
|
||
async upload(file, options = {}) {
|
||
const credentials = await this.fetchCredentials()
|
||
const fileKey = this._generateFileKey(file, options.directory)
|
||
|
||
const formData = new FormData()
|
||
|
||
// 构建表单字段(不同厂商字段名不同)
|
||
const formFields = this._buildFormFields(
|
||
credentials,
|
||
fileKey,
|
||
file.type,
|
||
options
|
||
)
|
||
Object.entries(formFields).forEach(([key, value]) => {
|
||
formData.append(key, value)
|
||
})
|
||
|
||
formData.append('file', file)
|
||
|
||
// 发送上传请求
|
||
const uploadUrl = this._getUploadUrl()
|
||
const response = await fetch(uploadUrl, {
|
||
method: 'POST',
|
||
body: formData,
|
||
// 如果上传大文件,可能需要设置更长的超时
|
||
signal: options.signal // 支持 AbortController 取消上传
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
throw new Error(`Upload failed: ${response.status} ${errorText}`)
|
||
}
|
||
|
||
return {
|
||
url: this._getFileUrl(fileKey),
|
||
key: fileKey,
|
||
etag: response.headers.get('ETag'),
|
||
size: file.size
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 分片上传(大文件 > 100MB)
|
||
*/
|
||
async multipartUpload(file, options = {}) {
|
||
const partSize = options.partSize || 10 * 1024 * 1024 // 默认 10MB/片
|
||
const parallel = options.parallel || 3 // 默认 3 个并发
|
||
|
||
const credentials = await this.fetchCredentials()
|
||
const fileKey = this._generateFileKey(file, options.directory)
|
||
|
||
// 1. 初始化分片上传
|
||
const uploadId = await this._initMultipartUpload(
|
||
credentials,
|
||
fileKey,
|
||
file.type
|
||
)
|
||
|
||
// 2. 计算分片
|
||
const parts = []
|
||
const totalParts = Math.ceil(file.size / partSize)
|
||
for (let i = 0; i < totalParts; i++) {
|
||
const start = i * partSize
|
||
const end = Math.min(start + partSize, file.size)
|
||
parts.push({
|
||
number: i + 1,
|
||
start,
|
||
end,
|
||
blob: file.slice(start, end)
|
||
})
|
||
}
|
||
|
||
// 3. 上传分片(带并发控制和断点续传)
|
||
const uploadedParts = []
|
||
const failedParts = []
|
||
|
||
// 支持断点续传:检查哪些分片已上传
|
||
if (options.resume) {
|
||
const existingParts = await this._listParts(
|
||
credentials,
|
||
fileKey,
|
||
uploadId
|
||
)
|
||
for (const part of existingParts) {
|
||
uploadedParts.push(part)
|
||
}
|
||
}
|
||
|
||
// 过滤出未上传的分片
|
||
const pendingParts = parts.filter(
|
||
(p) => !uploadedParts.some((up) => up.partNumber === p.number)
|
||
)
|
||
|
||
// 并发上传
|
||
const uploadPart = async (part) => {
|
||
try {
|
||
const etag = await this._uploadPart(
|
||
credentials,
|
||
fileKey,
|
||
uploadId,
|
||
part
|
||
)
|
||
return { partNumber: part.number, etag }
|
||
} catch (error) {
|
||
failedParts.push({ part, error })
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 使用 Promise.all 控制并发
|
||
const chunks = []
|
||
for (let i = 0; i < pendingParts.length; i += parallel) {
|
||
chunks.push(pendingParts.slice(i, i + parallel))
|
||
}
|
||
|
||
for (const chunk of chunks) {
|
||
const results = await Promise.allSettled(chunk.map(uploadPart))
|
||
for (const result of results) {
|
||
if (result.status === 'fulfilled') {
|
||
uploadedParts.push(result.value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否所有分片都上传成功
|
||
if (uploadedParts.length !== totalParts) {
|
||
throw new Error(
|
||
`Upload incomplete: ${uploadedParts.length}/${totalParts} parts uploaded`
|
||
)
|
||
}
|
||
|
||
// 4. 完成分片上传(合并分片)
|
||
await this._completeMultipartUpload(
|
||
credentials,
|
||
fileKey,
|
||
uploadId,
|
||
uploadedParts
|
||
)
|
||
|
||
return {
|
||
url: this._getFileUrl(fileKey),
|
||
key: fileKey,
|
||
size: file.size,
|
||
parts: totalParts
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成文件存储路径
|
||
*/
|
||
_generateFileKey(file, directory = '') {
|
||
const date = new Date()
|
||
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
|
||
const random = Math.random().toString(36).substring(2, 10)
|
||
const ext = file.name.split('.').pop() || 'bin'
|
||
const key = directory
|
||
? `${directory}/${datePath}/${random}.${ext}`
|
||
: `${datePath}/${random}.${ext}`
|
||
return key
|
||
}
|
||
|
||
// ============ 各厂商特定方法 ============
|
||
|
||
_getUploadUrl() {
|
||
switch (this.provider) {
|
||
case 'oss':
|
||
return `https://${this.bucket}.oss-${this.region}.aliyuncs.com`
|
||
case 'cos':
|
||
return `https://${this.bucket}.cos.${this.region}.myqcloud.com`
|
||
case 's3':
|
||
return `https://${this.bucket}.s3.${this.region}.amazonaws.com`
|
||
default:
|
||
throw new Error('Unknown provider')
|
||
}
|
||
}
|
||
|
||
_getFileUrl(key) {
|
||
return `https://${this.bucket}.${this.provider === 'oss' ? 'oss' : 'cos'}-${this.region}.${
|
||
this.provider === 'oss'
|
||
? 'aliyuncs.com'
|
||
: this.provider === 'cos'
|
||
? 'myqcloud.com'
|
||
: 'amazonaws.com'
|
||
}/${key}`
|
||
}
|
||
|
||
// 各厂商的签名、分片上传等方法...(根据实际需求实现)
|
||
_buildFormFields(credentials, fileKey, fileType, options) {
|
||
// 各厂商表单字段构建逻辑
|
||
// 这里需要根据具体厂商的文档实现
|
||
return {}
|
||
}
|
||
|
||
async _initMultipartUpload(credentials, fileKey, fileType) {
|
||
// 各厂商初始化分片上传逻辑
|
||
return 'upload-id'
|
||
}
|
||
|
||
async _uploadPart(credentials, fileKey, uploadId, part) {
|
||
// 各厂商分片上传逻辑
|
||
return 'etag'
|
||
}
|
||
|
||
async _completeMultipartUpload(credentials, fileKey, uploadId, parts) {
|
||
// 各厂商完成分片上传逻辑
|
||
}
|
||
|
||
async _listParts(credentials, fileKey, uploadId) {
|
||
// 各厂商列出已上传分片逻辑
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const uploader = new DirectUploader({
|
||
provider: 'oss',
|
||
region: 'cn-beijing',
|
||
bucket: 'myapp-images-prod',
|
||
getCredentials: async () => {
|
||
// 向后端申请临时凭证
|
||
const res = await fetch('/api/upload/credentials')
|
||
return res.json()
|
||
}
|
||
})
|
||
|
||
// 小文件上传
|
||
async function uploadAvatar(file) {
|
||
try {
|
||
const result = await uploader.upload(file, {
|
||
directory: 'avatars',
|
||
onProgress: (progress) => {
|
||
console.log(`上传进度: ${progress.percent}%`)
|
||
}
|
||
})
|
||
console.log('上传成功:', result.url)
|
||
return result
|
||
} catch (error) {
|
||
console.error('上传失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 大文件分片上传
|
||
async function uploadVideo(file) {
|
||
try {
|
||
const result = await uploader.multipartUpload(file, {
|
||
directory: 'videos',
|
||
partSize: 10 * 1024 * 1024, // 10MB 每片
|
||
parallel: 3, // 3 个并发
|
||
resume: true, // 支持断点续传
|
||
onProgress: (progress) => {
|
||
console.log(
|
||
`上传进度: ${progress.percent}%, 已传 ${progress.loaded}/${progress.total}`
|
||
)
|
||
},
|
||
onPartComplete: (part) => {
|
||
console.log(`分片 ${part.number} 上传完成`)
|
||
}
|
||
})
|
||
console.log('上传成功:', result.url)
|
||
return result
|
||
} catch (error) {
|
||
console.error('上传失败:', error)
|
||
// 可以在这里实现重试逻辑或保存断点信息
|
||
throw error
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.2 后端临时凭证服务(Node.js/Express)
|
||
|
||
```javascript
|
||
/**
|
||
* 对象存储 STS 临时凭证服务
|
||
* 支持:阿里云 OSS、腾讯云 COS、AWS S3
|
||
*/
|
||
const express = require('express')
|
||
const STS = require('ali-oss').STS // 阿里云
|
||
// const COS = require('cos-nodejs-sdk-v5') // 腾讯云
|
||
const router = express.Router()
|
||
|
||
// 配置
|
||
const config = {
|
||
// 阿里云 OSS 配置
|
||
oss: {
|
||
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
|
||
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
|
||
region: 'oss-cn-beijing',
|
||
bucket: 'myapp-images-prod',
|
||
// STS 角色 ARN(需要在 RAM 控制台创建)
|
||
roleArn: process.env.OSS_STS_ROLE_ARN
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 STS 临时凭证(阿里云 OSS)
|
||
* POST /api/upload/credentials
|
||
*/
|
||
router.post('/credentials', async (req, res) => {
|
||
try {
|
||
// 1. 验证用户身份(根据实际情况实现)
|
||
const userId = req.user?.id
|
||
if (!userId) {
|
||
return res.status(401).json({ error: 'Unauthorized' })
|
||
}
|
||
|
||
// 2. 生成唯一的文件路径前缀(用于权限隔离)
|
||
const date = new Date()
|
||
const prefix = `uploads/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${userId}/`
|
||
|
||
// 3. 创建 STS 客户端
|
||
const sts = new STS({
|
||
accessKeyId: config.oss.accessKeyId,
|
||
accessKeySecret: config.oss.accessKeySecret
|
||
})
|
||
|
||
// 4. 申请临时凭证
|
||
const result = await sts.assumeRole(
|
||
config.oss.roleArn,
|
||
{
|
||
// Policy 限制权限范围(最小权限原则)
|
||
Statement: [
|
||
{
|
||
Effect: 'Allow',
|
||
Action: [
|
||
'oss:PutObject',
|
||
'oss:InitiateMultipartUpload',
|
||
'oss:UploadPart',
|
||
'oss:CompleteMultipartUpload',
|
||
'oss:AbortMultipartUpload',
|
||
'oss:ListParts'
|
||
],
|
||
Resource: [`acs:oss:*:*:${config.oss.bucket}/${prefix}*`]
|
||
}
|
||
],
|
||
Version: '1'
|
||
},
|
||
3600, // 凭证有效期 1 小时
|
||
'web-upload-session-' + Date.now()
|
||
)
|
||
|
||
// 5. 返回凭证和配置
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
// STS 临时凭证
|
||
credentials: {
|
||
accessKeyId: result.credentials.AccessKeyId,
|
||
accessKeySecret: result.credentials.AccessKeySecret,
|
||
sessionToken: result.credentials.SecurityToken,
|
||
expiration: result.credentials.Expiration
|
||
},
|
||
// 上传配置
|
||
config: {
|
||
provider: 'oss',
|
||
region: config.oss.region,
|
||
bucket: config.oss.bucket,
|
||
endpoint: `https://${config.oss.bucket}.${config.oss.region}.aliyuncs.com`,
|
||
prefix: prefix, // 文件路径前缀
|
||
// 安全限制
|
||
maxSize: 100 * 1024 * 1024, // 最大 100MB
|
||
allowedTypes: [
|
||
'image/jpeg',
|
||
'image/png',
|
||
'image/gif',
|
||
'image/webp',
|
||
'video/mp4'
|
||
]
|
||
}
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('Get credentials failed:', error)
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to get upload credentials',
|
||
message: error.message
|
||
})
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 回调通知:前端上传完成后通知后端
|
||
* POST /api/upload/callback
|
||
*/
|
||
router.post('/callback', async (req, res) => {
|
||
try {
|
||
const { key, etag, size, mimeType, originalName } = req.body
|
||
const userId = req.user?.id
|
||
|
||
// 1. 验证文件是否存在
|
||
// 2. 保存文件信息到数据库
|
||
const fileRecord = await db.files.create({
|
||
userId,
|
||
key,
|
||
etag,
|
||
size,
|
||
mimeType,
|
||
originalName,
|
||
url: `https://cdn.example.com/${key}`,
|
||
createdAt: new Date()
|
||
})
|
||
|
||
// 3. 异步处理:生成缩略图、提取元数据、内容审核等
|
||
await processFileAsync(fileRecord)
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
fileId: fileRecord.id,
|
||
url: fileRecord.url,
|
||
size: fileRecord.size
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('Upload callback failed:', error)
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Failed to process uploaded file'
|
||
})
|
||
}
|
||
})
|
||
|
||
module.exports = router
|
||
```
|
||
|
||
### 9.3 防盗链与安全配置
|
||
|
||
```javascript
|
||
/**
|
||
* CDN 防盗链与安全配置示例
|
||
*/
|
||
|
||
// 1. Referer 防盗链(防止其他网站直接引用你的资源)
|
||
const refererConfig = {
|
||
// 白名单模式:只允许以下 Referer 访问
|
||
allowList: [
|
||
'*.myapp.com', // 主站
|
||
'*.myapp.cn', // 国内站
|
||
'localhost:*', // 本地开发
|
||
'127.0.0.1:*'
|
||
],
|
||
|
||
// 黑名单模式(可选):禁止以下 Referer
|
||
blockList: [
|
||
'*. competitor.com', // 竞争对手
|
||
'spam-site.com'
|
||
],
|
||
|
||
// 空 Referer 处理:是否允许直接访问(浏览器地址栏输入 URL)
|
||
allowEmptyReferer: false // 生产环境建议 false,测试环境可 true
|
||
}
|
||
|
||
// 2. URL 鉴权(更安全的防盗链,带时间戳和签名)
|
||
class URLAuth {
|
||
constructor(config) {
|
||
this.key = config.key // 鉴权密钥,只在服务端保存
|
||
this.expireTime = config.expireTime || 3600 // 默认 1 小时有效期
|
||
}
|
||
|
||
/**
|
||
* 生成带鉴权的 URL
|
||
* @param {string} url - 原始 URL,如 https://cdn.example.com/images/photo.jpg
|
||
* @param {number} expireIn - 有效期(秒)
|
||
* @returns {string} 带鉴权参数的 URL
|
||
*/
|
||
sign(url, expireIn = this.expireTime) {
|
||
const urlObj = new URL(url)
|
||
const pathname = urlObj.pathname
|
||
const timestamp = Math.floor(Date.now() / 1000) + expireIn
|
||
|
||
// 构造签名字符串(不同厂商格式不同,这里是通用示例)
|
||
const signStr = `${pathname}-${timestamp}-${this.key}`
|
||
const signature = this._md5(signStr)
|
||
|
||
// 添加鉴权参数
|
||
urlObj.searchParams.set('sign', signature)
|
||
urlObj.searchParams.set('t', timestamp.toString())
|
||
|
||
return urlObj.toString()
|
||
}
|
||
|
||
/**
|
||
* 验证 URL 签名(在 CDN 边缘或源站使用)
|
||
*/
|
||
verify(url) {
|
||
const urlObj = new URL(url)
|
||
const signature = urlObj.searchParams.get('sign')
|
||
const timestamp = parseInt(urlObj.searchParams.get('t'))
|
||
const pathname = urlObj.pathname
|
||
|
||
// 检查是否过期
|
||
if (timestamp < Math.floor(Date.now() / 1000)) {
|
||
return { valid: false, error: 'URL expired' }
|
||
}
|
||
|
||
// 验证签名
|
||
const signStr = `${pathname}-${timestamp}-${this.key}`
|
||
const expectedSign = this._md5(signStr)
|
||
|
||
if (signature !== expectedSign) {
|
||
return { valid: false, error: 'Invalid signature' }
|
||
}
|
||
|
||
return { valid: true }
|
||
}
|
||
|
||
_md5(str) {
|
||
// 实际项目中使用 crypto-js 或其他 MD5 库
|
||
// 这里仅作示例
|
||
return require('crypto').createHash('md5').update(str).digest('hex')
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const auth = new URLAuth({
|
||
key: 'your-secret-key-only-known-by-server',
|
||
expireTime: 3600 // 1 小时有效期
|
||
})
|
||
|
||
// 服务端生成带签名的 URL
|
||
const signedUrl = auth.sign(
|
||
'https://cdn.example.com/private/document.pdf',
|
||
7200
|
||
)
|
||
// 结果:https://cdn.example.com/private/document.pdf?sign=xxxxx&t=1699123456
|
||
|
||
// CDN 边缘或源站验证
|
||
const result = auth.verify(signedUrl)
|
||
if (!result.valid) {
|
||
// 返回 403 Forbidden
|
||
}
|
||
|
||
// 3. IP 黑白名单
|
||
const ipConfig = {
|
||
// 只允许特定 IP 访问(适合内部系统)
|
||
whiteList: [
|
||
'192.168.1.0/24', // 内网网段
|
||
'10.0.0.0/8'
|
||
],
|
||
|
||
// 禁止特定 IP 访问(封禁攻击者)
|
||
blackList: ['1.2.3.4', '5.6.7.8']
|
||
}
|
||
|
||
// 4. UA(User-Agent)黑白名单
|
||
const uaConfig = {
|
||
// 禁止爬虫/下载工具
|
||
blackList: [
|
||
'Wget',
|
||
'curl',
|
||
'python-requests',
|
||
'Scrapy',
|
||
'AhrefsBot',
|
||
'SemrushBot'
|
||
],
|
||
|
||
// 只允许浏览器访问(严格模式)
|
||
whiteList: [
|
||
'Mozilla/*', // 现代浏览器
|
||
'AppleWebKit/*'
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 名词对照表
|
||
|
||
| 英文术语 | 中文对照 | 解释 |
|
||
| :------------------------- | :---------------- | :--------------------------------------------------------------------------------------------------- |
|
||
| **Object Storage** | 对象存储 | 一种数据存储架构,将数据作为对象管理,而非文件系统层级结构。适合存储图片、视频、备份等非结构化数据。 |
|
||
| **Bucket** | 存储桶 | 对象存储中的顶级容器,用于组织和隔离数据。每个桶有独立的权限控制和配置。 |
|
||
| **Object** | 对象/文件对象 | 对象存储的基本单元,包含数据本身、元数据(Metadata)和全局唯一键(Key)。 |
|
||
| **CDN** | 内容分发网络 | Content Delivery Network,通过在全球部署边缘节点,将网站内容缓存到离用户最近的位置,加速访问速度。 |
|
||
| **Edge Node** | 边缘节点 | CDN 网络中部署在各地的缓存服务器,直接为用户提供内容访问服务。 |
|
||
| **Origin** | 源站 | CDN 回源获取内容的服务器,可以是对象存储、ECS 或自建服务器。 |
|
||
| **Cache Hit** | 缓存命中 | 用户请求的内容在 CDN 边缘节点已存在,直接返回,无需回源。 |
|
||
| **Cache Miss** | 缓存未命中 | 边缘节点没有请求的内容,需要回源获取。 |
|
||
| **Hit Ratio** | 命中率 | 缓存命中次数占总请求次数的比例。命中率越高,回源越少,成本越低。 |
|
||
| **TTL** | 生存时间/缓存时间 | Time To Live,内容在 CDN 节点上缓存的有效期。过期后需要重新回源。 |
|
||
| **Back to Source** | 回源 | CDN 边缘节点向源站请求内容的过程。 |
|
||
| **Purge/Refresh** | 刷新缓存 | 强制使 CDN 缓存失效,下次请求回源获取最新内容。 |
|
||
| **Preheat** | 预热 | 在正式发布前,主动将内容推送到 CDN 节点,让用户第一次访问就能命中缓存。 |
|
||
| **CORS** | 跨域资源共享 | Cross-Origin Resource Sharing,浏览器的安全机制,控制不同域之间的资源访问。 |
|
||
| **Referer** | 来源页面 | HTTP 请求头字段,指示请求是从哪个页面链接过来的。用于防盗链。 |
|
||
| **STS** | 安全令牌服务 | Security Token Service,颁发临时访问凭证的服务,用于前端直传等场景。 |
|
||
| **Multipart Upload** | 分片上传 | 将大文件切分成多个小分片并行上传,支持断点续传,提高上传效率和可靠性。 |
|
||
| **ETag** | 实体标签 | HTTP 响应头,用于标识资源的特定版本,常用于缓存验证。 |
|
||
| **S3 API** | S3 兼容接口 | AWS S3 的对象存储 API 规范,多数云厂商的对象存储都兼容此接口。 |
|
||
| **Canonical Query String** | 规范查询字符串 | 签名字符串的一部分,用于计算请求签名,确保请求不被篡改。 |
|
||
|
||
---
|
||
|
||
## 总结:对象存储 + CDN 的黄金法则
|
||
|
||
1. **上传走直传**:大文件用分片,安全用 STS
|
||
2. **缓存分层次**:浏览器 -> CDN -> 源站,层层缓存
|
||
3. **就近服务用户**:智能 DNS + 全球节点覆盖
|
||
4. **安全不松懈**:HTTPS + 防盗链 + 访问控制
|
||
5. **成本要监控**:命中率、带宽、存储分级,持续优化
|
||
|
||
这套架构撑起了互联网绝大部分的静态资源访问,理解它,你就理解了现代 Web 性能优化的基石。
|