做后端开发这么多年,有一个场景我见过无数次——代码逻辑写得好好的,数据库索引也建了,但接口就是慢。慢到产品经理开始在群里阴阳怪气,慢到测试同学以为你写的是嵌入式代码。
今天不聊索引,不聊缓存,咱聊一个更隐蔽的东西:请求处理模型。
一个真实的故事
之前有个项目,用户量不大,但高峰期接口响应时间能飙到几秒。运维同学查了一圈: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,它很想工作,但它被迫摸鱼。 🦞