很多人做后端开发的时候,最怕遇到的情况就是:明明功能逻辑没问题,一到抢购或者活动期间,服务器就扛不住了,页面一直转圈,接口一个接一个超时,严重的时候整个系统直接崩掉。今天咱们不聊那些教科书里才有的高大上概念,就从一个普通开发者的视角,聊一聊怎么给 RESTful API 做架构设计,让它在高并发的情况下还能稳稳地干活。
我们先搞清楚一个事情:什么叫高并发?不是说你用 Postman 多按几次请求就算高并发,而是指在极短的时间内,有大量请求同时打到你的服务上。比如双十一的零点,几十万人同时点下单按钮,这时候你的服务端可能一秒钟就要处理几万甚至几十万个请求。这些都是真实发生在生活中的场景。
一、先搞清楚高并发到底意味着什么
在动手设计之前,咱们得明白高并发会给系统带来哪些压力。最常见的就是三类:第一,带宽被占满,网络传输来不及;第二,CPU 和内存被打满,计算和存储跟不上;第三,数据库连接被耗尽,读写卡死。大多数情况下,最先出问题的往往是数据库,因为磁盘 IO 的速度比内存慢得多,而且数据库能同时处理的连接数量是有限的。
所以,我们的设计目标就很明确了:让尽可能多的请求不要直接打到数据库上,而是通过缓存、队列、限流这些手段,把压力分散掉,把系统整体的吞吐量提上去。
1.1 应用场景
高并发设计并不是所有项目都需要。如果你的项目只是内部管理后台,每天请求量几百次,那完全没必要做复杂架构。真正需要关注高并发的场景通常是:电商秒杀、限时抢购、演唱会抢票、微博热搜、春晚红包等。这些场景有一个共同特点:流量在某个瞬间达到峰值,而且是普通流量的几十倍甚至上百倍。
1.2 需要注意的地方
高并发不是一个孤立的技术问题,它需要从整体考虑。如果你只优化接口代码,但数据库没有做任何保护,那数据库依然会打爆。所以,本文后面讲到的每一层设计都值得你认真对待。
二、从最基础的层面开始优化
很多人一上来就想着上各种中间件,其实在加那些复杂组件之前,先得把自己代码里的坑填平。比如你的接口里有没有做不必要的重复查询?循环里面有没有发 SQL?这些看起来不起眼的小问题,在高并发下会被无限放大。
另外一个非常基础但特别重要的点,就是使用连接池。如果没有连接池,每次请求都新建一个数据库连接,那数据库肯定第一个挂掉。连接池可以帮你复用已有的连接,减少握手和销毁的开销。市面上主流的语言都有很成熟的连接池方案,比如 Java 的 HikariCP,Node.js 的 mysql2 也支持连接池。下面是一个 Node.js 使用连接池的简单示例。
// 技术栈:Node.js + mysql2
const mysql = require('mysql2');
// 创建连接池,设置最大连接数为 20
// 这里的配置可以根据实际服务能力调整
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'shop',
waitForConnections: true, // 当连接池没空闲连接时,请求会排队等待
connectionLimit: 20, // 最大连接数
queueLimit: 0 // 排队请求上限,0 表示不限制
});
// 使用 Promise 包装,方便 async/await 调用
function query(sql, params) {
return new Promise((resolve, reject) => {
pool.query(sql, params, (err, results) => {
if (err) reject(err);
else resolve(results);
});
});
}
// 某个接口里直接调用
// async function getUser(id) {
// const rows = await query('SELECT * FROM user WHERE id = ?', [id]);
// return rows[0];
// }
2.1 连接池的优点和缺点
连接池的优点很明显:减少频繁创建和销毁连接的开销,提高数据库连接的复用率。但同时它也有缺点,如果最大连接数设置得过大,反而会占用数据库过多的内存;设置得过小,又会导致请求排队。所以需要根据实际压测结果来调整。
2.2 注意事项
连接池本身不能解决数据库容量问题。如果你的数据库一台机器已经超负荷了,连接池再精细也白搭。另外,连接池的等待时间要设置合理,否则当连接池满了以后,请求可能会无限期等下去,最终造成前端久等。
三、缓存:让数据读取快起来
数据库再快,也快不过内存。所以,对付高并发,第一件要想到的事情就是加缓存。缓存可以把那些频繁读取、不经常变化的数据放到 Redis 或者本地内存里,让绝大部分请求在内存这一层就拿到结果,根本不会碰到数据库。
缓存的策略有很多种,最常用的是“旁路缓存”。读的时候先查缓存,没有就查数据库,然后回填缓存。写的时候先更新数据库,再删除缓存,保证下次读取会重新加载。下面是一个用 Redis 做缓存的 Node.js 接口示例。
// 技术栈:Node.js + Express + Redis
const express = require('express');
const Redis = require('ioredis');
const mysql = require('mysql2/promise');
const app = express();
const redis = new Redis({ port: 6379, host: '127.0.0.1' });
// 数据库连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'shop',
connectionLimit: 10
});
/**
* 获取商品详情
* 请求:GET /product/:id
*/
app.get('/product/:id', async (req, res) => {
const productId = req.params.id;
const cacheKey = `product:${productId}`;
// 1. 先从缓存里查
const cached = await redis.get(cacheKey);
if (cached) {
// 如果缓存命中,直接返回,省去一次数据库查询
return res.json(JSON.parse(cached));
}
// 2. 缓存没命中,查数据库
const [rows] = await pool.query('SELECT * FROM product WHERE id = ?', [productId]);
if (rows.length === 0) {
return res.status(404).json({ message: '商品不存在' });
}
// 3. 把结果写入缓存,设置 60 秒过期
await redis.set(cacheKey, JSON.stringify(rows[0]), 'EX', 60);
res.json(rows[0]);
});
app.listen(3000);
这个例子很直观。当同一个商品被很多人同时访问时,第一次请求会查数据库,之后的所有请求都会直接命中缓存,性能提升非常明显。
3.1 缓存的应用场景
缓存特别适合“读多写少”的数据,比如商品详情、用户资料、配置信息。这些数据被读的频率远远高于写的频率,缓存命中率非常高,收益自然就大。反过来说,如果数据每秒都变,比如实时库存,那缓存的意义就不大了。
3.2 缓存的优缺点
优点当然是快,快得让数据库几乎没压力。缺点有两个:一是额外引入了一个中间件,增加了运维复杂度;二是数据一致性问题。如果缓存更新不及时,用户会看到旧数据。解决办法通常是“先更新数据库,再删除缓存”,并且给缓存设置过期时间兜底。
3.3 注意事项
需要注意“缓存穿透”和“缓存雪崩”。穿透是指查询一个根本不存在的数据,缓存里没有,数据库里也没有,每次请求都会打到数据库。解决方法是缓存空结果,或者使用布隆过滤器。雪崩是指大量缓存同时过期,导致流量全部砸到数据库。解决方法是把过期时间错开,给每个 key 的过期时间加一个随机值。
四、消息队列:削峰填谷
有了缓存,读取问题解决了一大半,但写入呢?如果高并发场景下,用户都在下单,而订单数据必须写到数据库里,数据库照样扛不住。这时候就需要消息队列来“削峰填谷”。它的核心思想很简单:先把请求放到队列里,后端服务按照自己的处理能力慢慢消费,而不是让数据库在那一瞬间承受所有压力。
典型的场景是秒杀。用户点击“立即购买”后,接口先把订单消息丢进队列,然后立刻返回“排队中”。后面有一个消费者程序慢慢从队列里读消息,创建订单,发通知。用户看到的是成功下单,但数据库的写入压力被平摊到了几秒甚至几十秒内。
下面是一个使用 Bull(基于 Redis 的 Node.js 队列库)来处理订单的示例。
// 技术栈:Node.js + Bull (Redis)
const Queue = require('bull');
// 创建一个订单队列
const orderQueue = new Queue('orderQueue', {
redis: { port: 6379, host: '127.0.0.1' }
});
/**
* 下单接口:把订单数据放进队列
* POST /api/order
*/
app.post('/api/order', async (req, res) => {
const { userId, productId, quantity } = req.body;
// 立即返回排队成功,真正处理在消费者里
await orderQueue.add({
userId,
productId,
quantity,
createdAt: Date.now()
});
res.json({ code: 0, message: '订单已受理,正在处理中' });
});
/**
* 消费者:从队列里取消息,写数据库
* 这里可以开多个进程同时消费,只需要保证同一队列即可
*/
orderQueue.process(async (job) => {
const { userId, productId, quantity } = job.data;
// 这里省略了写数据库的细节,实际会使用事务
await createOrder(userId, productId, quantity);
console.log(`订单已创建:用户 ${userId},商品 ${productId}`);
});
4.1 消息队列的优缺点
优点很明显:把同步请求变成异步处理,极大缓解了数据库压力,同时还能提高接口响应速度。缺点就是系统变得复杂了,消息可能存在延迟,如果消费者挂了,消息会积压。另外,消息队列本身也有单点故障风险,需要用集群保证可用性。
4.2 注意事项
不要滥用消息队列。如果请求必须立刻知道处理结果,比如查询余额,那就不适合用队列。另外,要考虑消息投递的可靠性,生产环境一般会开启消息确认机制,防止消息丢失。
五、限流和熔断:保护系统不崩溃
高并发下系统最容易发生的不是“变慢”,而是“雪崩”。一个接口挂了,导致调用方也挂了,然后连锁反应。所以我们需要限流和熔断机制。
限流的作用是控制单位时间内的请求数量。比如某个接口规定每秒最多接受 100 个请求,超出的直接返回“系统繁忙,请稍后再试”。常见的限流算法有令牌桶和漏桶,实现上可以用 Redis 的计数器或者更简单的本地限流。下面是用 Node.js 实现的基于 Redis 的简单限流中间件。
// 技术栈:Node.js + Express + Redis (ioredis)
const redis = new Redis();
/**
* 限流中间件
* 每个用户每分钟最多允许 60 次请求
*/
async function rateLimit(req, res, next) {
const userId = req.query.userId || req.ip; // 简单用 IP 作为标识
const key = `rate:${userId}`;
const limit = 60;
const windowSeconds = 60;
// 使用 INCR + EXPIRE 实现窗口计数
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
if (count > limit) {
return res.status(429).json({ code: 429, message: '请求太频繁,请稍后再试' });
}
next();
}
// 在需要保护的接口上使用
app.get('/api/user/info', rateLimit, (req, res) => {
res.json({ name: '张三', age: 18 });
});
5.1 限流的应用场景
限流非常适合那些容易被刷的接口,比如短信验证码、登录接口、支付接口。这些接口如果不加限制,很容易被恶意请求打垮,或者被拿去薅羊毛。
5.2 熔断机制
熔断则是在依赖的下游服务出现故障时,主动打开“开关”,直接拒绝请求,而不是一直傻傻地等超时。比如某个第三方接口已经挂了,如果我们的服务继续调用它,就会把请求线程全部占满,最后自己也被拖垮。熔断器在 Node.js 里可以用 opossum 来实现,思路是维护一个状态,成功率达到阈值就打开熔断器,一段时间后允许少量请求试探恢复。
熔断器的几个关键参数:失败率阈值、最小请求数、打开超时时间。举个例子,如果最近 10 秒内请求量超过 20 次,并且失败率高于 50%,就熔断 5 秒。这 5 秒内所有请求直接返回降级数据,比如“功能升级中”。
六、负载均衡和多实例部署
单台服务器的性能再强,也是有上限的。想要大幅提升处理能力,最简单的方法就是“多买几台服务器”,然后用负载均衡把请求分散到不同的服务器上。这样即使一台机器挂了,其他机器也能继续工作。
最常见的负载均衡工具是 Nginx。我们可以把多个 Node.js 实例部署在不同的端口,然后用 Nginx 做反向代理,把流量平均分配。下面是一个 Nginx 配置示例。
# 技术栈:Nginx,用于 Node.js 应用的负载均衡
upstream node_app {
server 127.0.0.1:3000 weight=1;
server 127.0.0.1:3001 weight=1;
server 127.0.0.1:3002 weight=1;
}
server {
listen 80;
server_name api.example.com;
location /api/ {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
6.1 负载均衡的优缺点
优点显而易见:提升了系统整体的吞吐量,还带来了高可用性。缺点是需要额外管理多台服务器,部署和排查问题的难度也增加了。而且,如果每个实例都各自维护一些状态,比如用户登录 session,那就需要做会话共享。
6.2 注意事项
负载均衡层本身也要做高可用,否则它一挂,整个系统就完了。通常会用 Keepalived 做 Nginx 的主备,不过那是更进阶的内容了。另外,在使用轮询算法时,如果不同服务器的配置不一样,可以用加权轮询,给配置好的机器更高的权重。
七、数据库层面的拆分
当数据量也大了,单表超过几百万甚至几千万行的时候,查询性能会急剧下降。这时候就需要考虑数据库拆分。最基础的是读写分离:主库负责写,从库负责读,减轻单一数据库的压力。再进一步是分库分表,把一个大表拆成多个小表,分散到不同的数据库实例上。
下面以订单表为例,假设我们按用户 ID 进行分表,把订单表分成 16 张表。在代码里动态拼接表名。
// 技术栈:Node.js + mysql2
/**
* 根据用户 ID 计算分表序号
* @param {number} userId 用户 ID
* @returns {string} 表名
*/
function getOrderTable(userId) {
const tableIndex = userId % 16; // 取模分表
return `order_${tableIndex}`; // 例如 order_3
}
// 查询该用户最近订单
async function getOrdersByUser(userId) {
const tableName = getOrderTable(userId);
const sql = `SELECT * FROM ${tableName} WHERE user_id = ? ORDER BY create_time DESC LIMIT 20`;
return await query(sql, [userId]);
}
7.1 读写分离和分库分表的应用场景
读写分离适合那种读请求远多于写请求的系统,比如内容平台,用户看一篇文章会触发一次读,但作者可能很久才更新一次。分库分表则是在数据量增长到一定程度后不得不做的选择,一般单表超过 2000 万行,或者单库容量都快满了,才需要动刀。
7.2 优缺点和注意事项
读写分离的实现成本相对较低,但会带来数据延迟问题,主库写完从库还没同步,用户可能看到旧数据。分库分表虽然能解决单表数据量过大的问题,但让跨表查询、事务、分页查询变得非常复杂。所以,不到万不得已,我个人不建议一开始就上分库分表,而是先考虑归档历史数据、使用缓存等更简单的方式。
八、总结
高并发的架构设计说白了就是“拆”和“缓存加排队”。把请求从一条直通数据库的独木桥,改造成一条多车道的高速公路,每辆车都有序地通过。咱们今天聊到的这些手段:连接池、缓存、消息队列、限流熔断、负载均衡、数据库拆分,每一个都是在不同的层面减轻系统的压力。
没有一套方案能通吃所有场景。比如初创项目可能只需要加个 Redis 缓存就够了,到了中大型项目才需要考虑消息队列和分库分表。重要的是,你要能够理解每一个组件背后的取舍:缓存带来性能提升,但可能牺牲一致性;消息队列能削峰,但增加了系统复杂度;限流能保护系统,但也确实拒绝了部分用户。
在真实的生产环境里,我们往往需要组合多种技术来达到目标。而且,架构设计不是一劳永逸的事情,它需要根据线上监控数据不断调整和优化。这篇文章里所有示例用的都是 Node.js 技术栈,核心思想对其他语言也是通用的。希望这些内容能帮你打开思路,下次再遇到高并发问题,不再手足无措。
评论
围绕“应对RESTful API高并发场景的架构设计要点”参与讨论