API 性能优化实战:7 个让接口更快的实用技巧
原文:https://dev.to/lovestaco/7-ways-to-make-your-api-faster-4020(作者 @lovestaco)
你的 API 很慢。
你知道它慢,是因为产品群里有人发了一张转圈加载动画的截图,配文"这正常吗”。
于是你打开代码库,九十秒之内就有了三种猜想、两个重构计划,还有一股想换掉 JSON 库的强烈冲动。
停下。
把手从键盘上拿开。
一切规则之前的那条规则
优化不是第一步。优化是第四步——排在测量之后、确认之后、找到真正慢的东西之后。
本文中的每一项优化,都是拿复杂度换速度。
缓存失效、连接池调优、分页游标、异步日志缓冲,这些没有一样是免费的。你要在未来每一次调试中持续为此买单。
所以,入场的门票是一次性能分析(profile)。
对接口做压测,看看时间究竟花在了哪里,然后再从下面的清单里挑一个技巧。

我见过有人花整整一周去优化某个接口的序列化,而它真正的问题只是一条没建索引的查询——这种事我见过的次数可不算少。
测量。确认。然后再优化。每次都是这个顺序。

好。假设你已经测量过了。下面就是这七件值得动手的事。
1. 缓存:如何不把同样的活干两遍
缓存是这份清单里杠杆率最高的技巧,因为最快的数据库查询,是你压根不发出去的那一条。
套路很简单:一次昂贵的计算跑完后,结果写进 Redis 或 Memcached,接下来 N 个提出同一问题的调用方直接拿存储好的答案。
坑在于,人们总觉得上缓存是个很重的架构承诺。它通常不是。
def get_top_products(category: str):
key = f"top_products:{category}"
if hit := redis.get(key):
return json.loads(hit)
result = db.query_expensive_top_products(category)
redis.setex(key, 30, json.dumps(result)) # 三十秒。就这样。
return result
看看这个 TTL。三十秒。
这个时长短得几乎有点冒犯人,而这恰恰就是重点。
如果一个接口耗时 400ms、每分钟被打 200 次,一个三十秒的缓存就能干掉大约 99% 的数据库查询,而且下游没有任何人会察觉数据有半分钟是旧的。
短 TTL 被严重低估,因为它能带来大部分收益,却几乎不带缓存失效的痛苦。
你不是在维护一个缓存,你只是拒绝把同一个问题连着回答 200 遍。
真正难的地方在于数据必须新鲜——那时你就进入了缓存失效的领域,也就是著名的计算机科学两大难题之一。
先从朴素的 TTL 版本开始。只有当 TTL 版本被证明不适合你的场景时,再升级到主动失效方案。
2. 连接池:别再反复自我介绍
建立数据库连接不是免费的。
先来一次 TCP 握手,通常还有一次 TLS 握手,然后是认证,然后是会话建立。
你跟 Postgres 打招呼花的时间,很容易比真正查询它的时间还多。
连接池维护一组常开的连接,按需发放。你的请求借走一条,跑完查询,再还回去。
大多数框架默认就这么干,你从来不用想它。这本来挺好——直到你转向 Serverless 的那天。
Serverless 打破了连接池底层的假设。每个函数实例都是独立的小进程,带着自己独立的小连接池,而流量高峰时平台会毫不在意地拉起 500 个这样的实例。
现在,你的数据库——大概只配置了 100 个连接——要一次性认识 500 个陌生人。
Postgres 在这一点上尤其不会优雅降级。它为每条连接 fork 一个进程,所以连接耗尽不是变慢,而是一堵墙。
这正是 AWS RDS Proxy 连同 pgbouncer 一类工具存在的全部原因。
它们夹在你转瞬即逝的函数和你绝不变幻的数据库之间,把一小池真实连接复用给大量调用方。
flowchart LR
subgraph Serverless
F1[Fn instance 1]
F2[Fn instance 2]
F3[Fn instance ...500]
end
F1 --> P[Connection Proxy]
F2 --> P
F3 --> P
P -->|small, reused pool| DB[(Postgres)]
F1 -.->|without a proxy| DB
F2 -.->|500 handshakes| DB
F3 -.->|database says no| DB
classDef fn fill:#6ea8ff,stroke:#2f5fb8,color:#1a1a1a
classDef proxy fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
classDef db fill:#ff9a5c,stroke:#c0632c,color:#1a1a1a
class F1,F2,F3 fn
class P proxy
class DB db
3. N+1 查询:伪装成整洁代码的 bug
这是我最喜欢的一条,因为 N+1 查询几乎总是把代码写得更漂亮的副产品。
你有一个 posts 表,每篇文章都有评论,于是你顺手写下了最直观的代码:
posts = db.query("SELECT * FROM posts LIMIT 20")
for post in posts:
# 每次循环都多一次数据库往返,无一例外
post.comments = db.query("SELECT * FROM comments WHERE post_id = %s", post.id)
渲染一个页面要跑 21 条查询,其中 20 条结构完全相同,只差一个整数。
在你自己的笔记本上,数据库就在 localhost,每条查询 0.2ms,整个循环眨眼间就跑完了。
到了生产环境,数据库隔着一张网络,每次往返 3ms,你刚刚花掉的 60ms 里什么都没干,纯粹在等。
把这个页面放大到 200 篇文章,你就会得到一个不知为何就是很慢、日志里却一条慢查询都找不到的接口。
这正是 N+1 恶心的地方:单看每条查询都没问题,慢查询日志无话可说,唯独查询条数不对。
修法就是别再一条一条地问:
posts = db.query("SELECT * FROM posts LIMIT 20")
ids = [p.id for p in posts]
rows = db.query("SELECT * FROM comments WHERE post_id = ANY(%s)", ids)
by_post = defaultdict(list)
for row in rows:
by_post[row.post_id].append(row)
for post in posts:
post.comments = by_post[post.id]
两条查询,恒定不变,不管取多少篇文章都是这两条。
如果你用的是 ORM,这正是 Django 的 select_related 和 prefetch_related、SQLAlchemy 的 joinedload、Prisma 的 include 存在的意义。
工具一直都在,只是默认关闭,因为 ORM 无从得知你到底要不要那些关联行。
这里最有用的一个习惯,是在开发环境记录每个请求发出的查询条数。一个要发 47 条查询的接口会立刻原形毕露。

4. 分页:没有人真的在读 40000 行数据
每个代码库里都藏着这么一个接口:刚写好时返回 12 条记录,如今返回 40000 条——表长大了,却没人回头看过这个 handler。
数据库得把它取出来,你的应用得把它序列化,网络得把它运过去。
客户端得把它解析出来,然后精确地只渲染前二十条。
分页是解药,人人都知道。但并非人人知道的是:LIMIT 20 OFFSET 100000 其实并不快。
OFFSET 并不会跳过任何工作。数据库仍然要遍历全部 100000 行,把它们统统扔掉,然后才把二十行交到你手上。
页码越深越慢,而且是线性变慢,你的“优化”悄悄变成了新的瓶颈。
游标分页(cursor based pagination)换了个问法来绕开这个问题。你不再问“给我第 5000 页”,而是问“给我这条记录之后的二十行”:
-- offset:翻得越深越慢 SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 100000; -- cursor:走索引,第 1 页和第 5000 页代价相同 SELECT * FROM events WHERE id > 100000 ORDER BY id LIMIT 20;
第二种写法是一次索引查找(index seek),无论翻到多深,代价都一样。
代价是你失去了按页码随机跳转的能力。这也是为什么 offset 分页至今活在管理后台里,而 Stripe 的 API 和你用过的每一个无限滚动信息流用的都是游标分页。
5. 序列化:每个响应都要缴的税
数据进了内存之后,总得有什么东西把它变成 JSON,而这件事在每个响应上都在消耗你的 CPU。
负载小时这只是噪音;但对一个返回几千个对象的接口来说,序列化真的可能成为主要开销,profiler 会直直地指向它。
好消息是,这是整张清单里最便宜的修法,因为通常换个库就完事。在 Python 里,orjson 明显快于标准库 json。
在 Node 里,JSON 序列化器是原生的,但像 fast-json-stringify 这类基于 schema 的方案凭借提前知道数据形状而胜过它。提前拿到 schema 的序列化器可以省掉所有运行时类型探测。
但拜托,一定要先做 profile。给一个 95% 时间都耗在数据库上的接口换序列化器,是优雅地消磨一个下午、然后一无所获的好办法。

6. 压缩:可以交给 CDN 代劳的那一项
JSON 的压缩效果非常好,因为 JSON 里大部分内容是重复的键名和空白字符。API 响应压缩到原来的五分之一到十分之一,完全是正常水平。
这意味着跨网络传输的数据少了 5 到 10 倍,对移动端用户来说至关重要;而一旦载荷变大,这对所有人都是大事。
gzip 是安全的默认选择,地球上所有客户端都支持。Brotli 在速度相近的情况下通常能压得更小,且所有现代浏览器都支持,所以在条件允许的地方值得开启。
有两点需要注意。
压缩是要消耗 CPU 的。你是在用处理器时间换网络时间,这几乎总是一笔划算的交易,但它终究是一次交换。
小响应加上压缩开销后确实可能变得更慢,所以大多数服务器都设有一个最小体积阈值,而这个阈值你应该保留。
而且你很可能根本不该自己动手做这件事。Cloudflare、Fastly 这类服务会在边缘节点替你压缩,把 CPU 开销完全从你的服务器上挪走,并统一作用于你提供的所有内容。
如果你已经用了 CDN,这项优化不过是勾选一个复选框的事。
7. 异步日志:当微秒也要计较时
把它放在最后是有原因的。大多数服务根本不必关心这个。
但在高吞吐路径上,写一行日志就是一次系统调用。如果这次写入是同步阻塞的,你的请求线程就只能干坐在那里等磁盘或网络 socket,什么有用的事都做不了。
异步日志把请求线程的工作变得微不足道,从而解决这个问题:它把日志条目丢进一个内存环形缓冲区后立刻继续执行,由另一个线程负责清空缓冲区并执行实际写入。
请求路径从“等待写入完成”变成了“往队列里追加一条”。
flowchart LR
R[Request thread] -->|append, microseconds| B[In-memory buffer]
B --> W[Logger thread]
W --> D[(Disk / log service)]
R --> RESP[Response sent]
C{App crashes<br/>before flush?}
B -.-> C
C -->|yes| L[Buffered logs lost]
C -->|no| D
classDef thread fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
classDef buf fill:#6ea8ff,stroke:#2f5fb8,color:#1a1a1a
classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
classDef bad fill:#ff9a5c,stroke:#c0632c,color:#1a1a1a
class R,W thread
class B,RESP buf
class C decision
class L,D bad
那条虚线分支就是全部的取舍所在,开启这个功能之前你应该盯着它好好看一会儿。
进程死掉时还留在缓冲区里的东西就没了。也就是说,描述这次崩溃的日志,恰恰是最可能丢失的那些日志。
对审计日志、支付记录,或任何你在事故复盘时需要的东西来说,这是一笔实实在在的坏交易。
而对高流量的访问日志来说,这是一笔完全没问题的交易——丢掉最后几百行不会让你损失什么。
按日志流逐个决定,而不是按整个应用一刀切。
真正的要点
再看一遍这七条,注意它们是如何归类的。
缓存、连接池和 N+1 全都是关于不和数据库打交道——无论是跳过提问、跳过握手,还是把二十次询问合并成一次。
分页、序列化和压缩全都是关于搬运更少的数据——分别在查询层、CPU 层和网络传输层。
异步日志则是关于逃离请求路径,这和后台任务是同一个思路,只是应用在更小的东西上。
它们没有一个算得上新奇。全都乏味、被充分理解,而且离你只有一次库调用的距离。
难的从来不是了解这些技术。难的是有纪律地去弄清你的接口到底需要哪一个,而不是七条全上然后管它叫架构。
先做性能分析。修掉分析结果指向的那个问题。然后去做点更有意思的事。
原文:https://dev.to/lovestaco/7-ways-to-make-your-api-faster-4020(作者 @lovestaco)