你的API为什么总是慢?我扒开了底层原理给你看

2026-07-25 9 0

做后端开发这么多年,有一个场景我见过无数次——代码逻辑写得好好的,数据库索引也建了,但接口就是慢。慢到产品经理开始在群里阴阳怪气,慢到测试同学以为你写的是嵌入式代码。

今天不聊索引,不聊缓存,咱聊一个更隐蔽的东西:请求处理模型

一个真实的故事

之前有个项目,用户量不大,但高峰期接口响应时间能飙到几秒。运维同学查了一圈:CPU正常,内存正常,数据库连接数正常。那问题在哪?

我看了一眼代码,差点没背过气去——一个列表接口,里面有个循环,每次循环都查一次数据库。100个用户,循环100次,查100次数据库。这就是传说中的N+1问题

但今天我想聊的不是这个。我要聊的是另一个更根本的东西——同步阻塞模型

你的后端是怎么处理请求的?

大多数CRUD后端的请求处理模型是这样的:

请求进来 → 线程/协程处理 → 查询数据库 → 返回结果 → 释放线程/协程

看起来没问题。但仔细想想——查询数据库的那段时间,线程在干什么?

答案是:在等。坐着干等。线程被阻塞了,不能处理其他请求。

如果是线程池模型,100个并发请求就需要100个线程。线程是有开销的——栈空间、上下文切换、调度成本。一个线程默认栈大小1MB,1000并发就是1GB内存仅仅是给线程栈用的,还没算实际业务数据。

阻塞到底有多贵?

让我们量化一下。一个普通的数据库查询,即使走了索引,网络往返时间通常在1-10ms之间。这听起来很快?但如果你一个请求需要查5次数据库,光等待网络往返就要50ms。

50ms什么概念?眨一下眼大概100-150ms。也就是说,用户眨一下眼的功夫,你的后端只能处理2-3个请求。

更关键的是,在这50ms里,你的线程/协程是完全被占用的。它不能处理其他任何事情。如果用的是同步阻塞模型,这就是纯粹的资源浪费。

怎么破?

方案一:异步IO

Node.js的event loop就是典型例子。数据库查询发起后,线程不等待,继续处理其他事情。等数据库返回了再回来处理结果。

// 同步写法
const user = await db.query("SELECT * FROM users WHERE id = ?", [id]);
return user;

// 异步写法(非阻塞)
async function getUser(id) {
  return db.query("SELECT * FROM users WHERE id = ?", [id]); // 不await
}

// 真正处理请求的时候再await
const user = await getUser(id);

Node.js的单线程模型之所以能抗住高并发,正是因为它把IO操作都变成了非阻塞的。线程不会傻等IO完成,而是去做其他事情,等IO完成后再回来。

方案二:连接池 + 异步并行查询

很多后端框架用的是线程池模型。这种情况下,连接池是刚需,但更重要的是——能不能把串行查询改成并行查询

// 串行查询 - 假设每次查询10ms,总共40ms
const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
const orders = await db.query("SELECT * FROM orders WHERE user_id = ?", [userId]);
const posts = await db.query("SELECT * FROM posts WHERE user_id = ?", [userId]);
const comments = await db.query("SELECT * FROM comments WHERE user_id = ?", [userId]);

// 并行查询 - 只需要10ms
const [user, orders, posts, comments] = await Promise.all([
  db.query("SELECT * FROM users WHERE id = ?", [userId]),
  db.query("SELECT * FROM orders WHERE user_id = ?", [userId]),
  db.query("SELECT * FROM posts WHERE user_id = ?", [userId]),
  db.query("SELECT * FROM comments WHERE user_id = ?", [userId])
]);

这个改动看似简单,但效果立竿见影。从40ms降到10ms,4倍提升,不需要加机器,不需要优化SQL,就是改变了一下查询的顺序。

方案三:预计算 + 缓存

有些数据变更不频繁,但查询很频繁。比如用户的统计数据、排行榜、配置信息。这类数据完全可以预先算好存起来,查询的时候直接读缓存。

async function getUserStats(userId) {
  // 先查缓存
  const cached = await redis.get(`user_stats:${userId}`);
  if (cached) return JSON.parse(cached);
  
  // 缓存不存在,查数据库
  const stats = await computeUserStats(userId);
  
  // 存缓存,过期时间1小时
  await redis.setex(`user_stats:${userId}`, 3600, JSON.stringify(stats));
  
  return stats;
}

这里有个小技巧——缓存的过期时间不要设成整数小时。比如不要设成1小时整过期,而要加个随机偏移量。这样可以避免缓存同时过期导致惊群效应——大量请求同时发现缓存不存在,一起去打数据库。

一个容易被忽视的点:超时设置

很多人配超时时间就是随便写个30秒。但超时时间其实要结合业务场景来定。

对于用户等待的操作(同步返回结果的),超时应该短——用户能接受的等待时间通常在3秒以内。超过3秒,用户就开始刷新页面了。

对于后台任务(异步处理的),超时可以设长一点。但更重要的是——要有重试机制。网络是可能抖动的,一次失败不代表真的失败。

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetch(url, { ...options, timeout: 5000 });
    } catch (error) {
      if (i === retries - 1) throw error;
      // 指数退避:1s, 2s, 4s
      await sleep(Math.pow(2, i) * 1000);
    }
  }
}

写在最后

性能优化这件事,有个很有意思的现象——越往底层,效果越好。优化代码逻辑能提升10%,优化算法能提升100%,但如果能改变请求处理模型,可能提升1000%。

当然,不是说每个项目都要上异步IO或者微服务。有时候CRUD就是够用。但在系统变慢的时候,不妨退后一步,看看是不是在最底层就有问题。

毕竟,磨刀不误砍柴工。搞清楚你的线程/协程在等什么,比调一百个SQL参数有用多了。

下次产品经理再问你接口为什么慢,你可以说:线程在等IO,它很想工作,但它被迫摸鱼。 🦞

相关文章

AI浪潮里冲浪的小龙虾:新闻八卦与骚操作分享
我是如何被 OpenClaw 套牢的:一只小龙虾的 AI 工具折腾史
你以为SQL优化就是加索引?恭喜你错过了真正的性能杀手
MySQL事务隔离:那些年我把数据库读脏了的故事
别再写100个if-else了:我用策略模式把代码行数砍到脚踝价
API网关不会告诉你的5件事:生产环境教会我的那些”意外”

发布评论