2026-08-24 16:05:52.845346+08 / restful-api
Performance
介绍 RESTful API 中压缩、部分响应、ETag、分页、Prefer 和 HTTP 缓存等性能优化实践。
Performance
性能是 RESTful API 设计的重要方面,尤其是对于返回大载荷或运行在高流量环境中的 API。
API 设计应尽量减少:
- 网络带宽使用
- 响应延迟
- 不必要的数据传输
- 对未变化资源的重复获取
- 不必要的服务端处理
常见技术包括:
- 响应压缩
- 部分响应和字段过滤
- 使用 ETag 的条件请求
- 合理使用 HTTP 缓存
- 分页
- 最小化响应
- 在适当场景进行异步处理
减少带宽使用和延迟
当预期载荷大小或请求量使优化有明显收益时,API 应提供减少带宽使用和延迟的机制。
这对以下场景尤其重要:
- 大型资源集合
- 大型资源表示
- 高流量 API
- 移动客户端
- 运行在慢速或高延迟网络中的客户端
常见优化方式包括:
- 在适当情况下压缩请求和响应载荷。
- 允许客户端只请求需要的资源字段。
- 使用 ETag 和条件请求,避免传输未变化的资源。
- 当客户端可以接受精简响应或异步处理时使用 Prefer Header。
- 对大型集合使用分页。
- 缓存稳定或很少修改的资源。
示例:
GET /users/123 HTTP/1.1
Accept: application/json
Accept-Encoding: gzip
服务端可以返回:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
性能优化应根据 API 的实际特征选择,不能盲目套用。例如,非常小的响应从压缩中获得的收益可能很小,但仍会消耗 CPU 资源。
使用响应压缩
当响应足够大、压缩可以显著减少传输数据时,API 应支持载荷压缩。
gzip 是常见且广泛支持的 HTTP 压缩格式。客户端通过 Accept-Encoding 请求 Header 声明支持的压缩算法:
GET /users HTTP/1.1
Host: api.example.org
Accept-Encoding: gzip
如果服务端使用 gzip,应通过 Content-Encoding 响应 Header 声明:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
服务端仍应支持未压缩响应。对于没有发送 Accept-Encoding: gzip 的客户端,应能够返回普通表示。
以下情况可能不适合压缩:
- 响应非常小
- 载荷已经压缩
- 压缩的 CPU 成本过高
- 压缩引入的延迟超过节省的网络传输时间
因此,压缩行为应基于载荷特征和预期流量决定。
适当支持部分响应
对于字段很多或结构深度嵌套的资源,API 应考虑允许客户端只请求实际需要的字段。
这样可以显著减少:
- 响应大小
- 网络带宽
- JSON 序列化成本
- 客户端解析成本
可以使用 fields 查询参数实现这一点。
完整响应示例:
{
"id": "123",
"name": "John Doe",
"address": "1600 Pennsylvania Avenue Northwest, Washington, DC, United States",
"birthday": "1984-09-13",
"friends": [
{
"id": "1",
"name": "Jane Doe"
}
]
}
如果客户端只需要用户姓名和朋友姓名,可以请求:
GET /users/123?fields=(name,friends(name)) HTTP/1.1
返回:
{
"name": "John Doe",
"friends": [
{
"name": "Jane Doe"
}
]
}
fields 参数决定响应中返回哪些属性。例如,fields=(name) 只返回 name;fields=(name,friends(name)) 返回顶层 name 和 friends 集合中的 name。
字段选择语法
一种可行的 fields 语法如下:
<fields> ::= [ <negation> ] <fields_struct>
<fields_struct> ::= "(" <field_items> ")"
<field_items> ::= <field> [ "," <field_items> ]
<field> ::= <field_name> | <fields_substruct>
<fields_substruct> ::= <field_name> <fields_struct>
<field_name> ::= <dash_letter_digit> [ <field_name> ]
<negation> ::= "!"
示例:
fields=(name)
fields=(name,email)
fields=(name,friends(name))
fields=(id,name,address)
如果支持排除语义,必须清晰记录其规则。
不要定义令人意外的默认字段过滤
fields 查询参数不应有隐藏的或令人意外的默认值。没有显式提供 fields 参数时,客户端通常应收到文档中定义的标准资源表示,而不是被静默省略部分属性。
这符合最小意外原则:客户端不应意外收到不完整的资源表示。
记录部分响应行为
OpenAPI 无法完整表达由动态字段选择产生的所有响应结构。因此,支持字段过滤的 API 应清晰记录 fields 参数的行为,包括嵌套字段表达式。
完整响应 Schema 仍应描述完整的资源表示。
使用条件请求避免不必要的传输
API 可以使用实体标签标识资源的特定版本。
服务端可以返回 ETag:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "7e2f31a"
客户端可以保存该 ETag,并在后续请求中使用:
GET /users/123 HTTP/1.1
If-None-Match: "7e2f31a"
如果资源没有变化,服务端可以返回:
HTTP/1.1 304 Not Modified
ETag: "7e2f31a"
此时不需要再次传输资源表示。这对大型资源、频繁请求的资源、参考数据、主数据以及很少变化的资源尤其有用。
ETag 与 If-Match
ETag 也可以与 If-Match 一起用于条件更新。
如果客户端之前获取到 ETag: "version-42",后续可以发送:
PUT /users/123 HTTP/1.1
Content-Type: application/json
If-Match: "version-42"
只有当当前资源仍然匹配所提供的实体标签时,服务端才应执行操作。这可以防止客户端意外覆盖其他客户端已经做出的修改。
缓存校验通常使用 If-None-Match;条件修改通常使用 If-Match。
对大型集合使用分页
大型集合通常不应作为单个无界响应返回,而应支持分页。
例如:
GET /users?limit=100
GET /users?page=2&page-size=100
GET /users?limit=100&cursor=eyJpZCI6IjEyMyJ9
分页可以限制:
- 响应大小
- 内存使用
- 序列化成本
- 网络带宽
- 客户端处理成本
同一个 API 中应保持分页机制的一致性。
适当使用 Prefer Header
HTTP Prefer Header 可以用来表达客户端的处理偏好。
两个常见示例是:
Prefer: return=minimal
Prefer: respond-async
当客户端不需要操作完成后的完整资源表示时,可以使用 Prefer: return=minimal,让服务端避免生成和传输不必要的响应表示。
对于可能需要较长处理时间的操作,可以使用 Prefer: respond-async,让服务端在支持该行为时异步处理请求。
API 必须记录所支持偏好的确切行为。
HTTP 缓存
HTTP 缓存可以显著提升性能,但会带来与以下因素相关的复杂性:
- 身份认证
- 数据一致性
- 缓存失效
- 资源更新
- 多用户场景
- 共享缓存与私有缓存
- 缓存生命周期
因此,应有意识地引入缓存。对于没有明确缓存需求的 API,建议采用保守默认值:
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
特别是 Cache-Control: no-store 会阻止客户端和中间代理存储响应,适合作为默认策略。
记录可缓存端点
如果 API 明确支持缓存,必须记录其缓存行为。这适用于有意启用缓存的 GET、HEAD 和 POST 操作。
文档应说明 Cache-Control、Vary 和 ETag 等相关 Header。当 Cache-Control 已经提供所需的缓存生命周期策略时,应避免同时使用 Expires 和 Cache-Control 定义重叠语义。
Cache-Control
Cache-Control 定义客户端和中间缓存如何存储及复用响应。
Cache-Control: private, must-revalidate, max-age=300
含义是:
- private:响应只面向单个用户,共享缓存不得存储。
- must-revalidate:响应过期后,必须重新校验才能复用。
- max-age=300:响应在 300 秒内可以被视为新鲜。
对于认证用户专属 API,在明确启用缓存时,private 通常比 public 更安全。
Vary
Vary 响应 Header 用于标识会影响响应表示选择的请求 Header。
例如:
Vary: Accept, Accept-Encoding
这表示缓存必须考虑 Accept 和 Accept-Encoding,因为同一个 URI 可能根据客户端是否支持 gzip 返回不同表示。
如果语言也会影响响应,还可以使用:
Vary: Accept, Accept-Encoding, Accept-Language
具体取值取决于端点的实际特征。
OpenAPI 中记录缓存 Header
缓存相关响应 Header 可以作为可复用的 OpenAPI 组件进行记录,包括 Cache-Control、Vary 和 ETag,然后从各个操作中引用这些组件。
缓存校验
客户端应能够高效判断缓存的资源表示是否仍然有效。
GET /users/123 HTTP/1.1
If-None-Match: "7e2f31a"
如果资源未变化,返回 304 Not Modified,并且响应不得包含完整资源表示。如果资源已变化,则返回 200 OK 和新的 ETag,让客户端更新本地缓存。
使用 HEAD 进行缓存校验
对于大型资源,API 可以支持 HEAD 请求,让客户端在不下载完整表示的情况下检查元数据。
HEAD /large-resources/123 HTTP/1.1
客户端可以将返回的实体标签与本地缓存版本比较。使用 If-None-Match 的条件 GET 也是常用机制。
根据数据大小选择缓存策略
小型数据集
小型数据集可以提供完整集合端点,例如 GET /countries,并为完整集合关联 ETag。客户端之后可以使用 If-None-Match 检查是否发生变化。
中型数据集
中型集合可以将缓存与分页结合,例如:
GET /products?limit=100&cursor=abc123
API 可以为分页或集合版本提供实体标签,避免客户端重复下载未变化集合,同时避免响应过大。
大型资源
对于单个大型资源,可以考虑支持 HEAD、ETag、If-None-Match 和条件 GET,让客户端判断是否需要下载完整表示。
适当优先使用服务端或网关缓存
通用 HTTP 客户端和代理缓存可能较为复杂,因为缓存必须正确理解 Cache-Control、Vary、ETag、身份认证、用户专属表示和内容协商。
对于很多系统,在应用、服务或 API Gateway 层直接缓存更容易控制。具体实现取决于基础设施,但对客户端暴露的 HTTP 语义仍应清晰定义。
缓存失效
缓存设计不能脱离资源更新单独考虑。启用缓存前应明确:
- 缓存什么?
- 可以缓存多长时间?
- 底层资源变化时会发生什么?
- 如何检测过期数据?
- 如何使过期数据失效?
- 缓存表示是否与用户相关?
- 是否可以安全地在多个客户端之间共享?
例如,GET /countries 适合较长时间缓存,因为国家参考数据很少变化;而 GET /accounts/123/balance 可能需要很短的缓存时间甚至完全不缓存,因为余额变化频繁且可能是用户专属数据。
性能优化总结
REST API 性能优化首先应避免不必要的工作和数据传输。
| 技术 | 主要收益 |
|---|---|
| 压缩 | 减少传输字节数 |
| 部分响应 | 避免返回未使用字段 |
| 分页 | 限制集合响应大小 |
| ETag | 标识资源版本 |
| If-None-Match | 避免重新下载未变化资源 |
| If-Match | 支持安全的条件修改 |
| Cache-Control | 定义缓存策略 |
| Vary | 防止错误复用缓存变体 |
| HEAD | 在没有响应体的情况下获取元数据 |
| Prefer: return=minimal | 避免不必要的响应载荷 |
| Prefer: respond-async | 支持异步处理 |
良好的 API 应在不削弱 HTTP 语义清晰性和可预测性的前提下优化性能。
核心原则是:
当客户端不需要数据时,不要传输、计算或重新生成这些数据。