
前两天,一个朋友在群里问:"我的 BFF 服务(Backend For Frontend)老是被打挂,Redis 明明好好的,接口偶尔就开始 504,是不是 teledb 不稳?"
我笑了下,说:
"不,这不是 teledb 的锅。是你自己的服务太'实诚'了。"
什么意思? 太多 Node.js 初级后端都在一个误区:他们的服务只会"硬抗"。来多少请求都照单全收,下游挂了就等,直到超时、线程堆积、内存飙升、服务崩掉。 然后再开始复盘、扩容、加机器。
这篇文章我们就聊两个词------限流 和 熔断。 这俩是服务"自我保护"的基本功。 我尽量用接地气的方式说,代码也能直接跑。
一、限流:先学会说"不"
你想想,一个人一天最多能喝 10 杯咖啡。 第 11 杯来了,不拒绝,你就猝死。
限流,就是系统的"自控力"。
Node.js 里我们最常用的库叫 express-rate-limit
。如果你项目里是 Express 框架,那几行代码就能起飞。
但很多人装完库,直接:
js
app.use(rateLimit({ windowMs: 60*1000, max: 100 }))
就完事了。
问题来了------你有好几个实例跑在 Docker 集群里。每个容器都自己算次数,那 100 次变成了 100 × N。你以为你限的是 100 QPS,其实打在后端的已经上千了。
解决办法其实很简单:用 Redis。 所有实例共用一个计数器,才叫真正的限流。
看个最小代码例子:
js
const express = require('express');
const { createClient } = require('redis');
const RedisStore = require('rate-limit-redis');
const rateLimit = require('express-rate-limit');
const app = express();
const redisClient = createClient({ url: 'redis://redis-server:6379' });
redisClient.connect();
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
message: { status: 429, message: '请求太频繁,请稍后再试。' }
});
app.use(limiter);
app.get('/api/projects', (req, res) => res.json({ ok: true }));
app.listen(3000);
跑起来,这就是真·限流。
如果有人用爬虫猛刷接口,达到阈值,就会被 429
打回来。 不会影响其他人,更不会把服务打挂。
我第一次加限流的时候,其实挺抵触的,总觉得"挡用户"不太好。后来上线后,我发现夜里系统终于不崩了,Redis 也稳如老狗。那一刻我才明白,限流不是冷漠,而是成熟。
二、熔断:有时候"断掉"才是聪明
限流解决了外部的"洪水",熔断解决的是内部的"内伤"。
你想象下这个场景: BFF 服务每次都要去查一个 teledb 数据库。 平时挺快,几十毫秒就回来。 突然某天 teledb 卡住了,所有请求都卡在等待。
此时 BFF 就成了"堵车现场"------所有请求在等那个慢数据库。 最终你整条服务链路都挂了。
这就是熔断该出手的时候。
熔断的逻辑特别简单:
"连续失败太多次,就先别再请求下游了。先歇会儿。"
Node 里有个库叫 opossum
,非常好用。
比如你有个函数:
js
async function queryDatabase(id) {
const result = await db.query('SELECT * FROM projects WHERE id=?', [id]);
return result;
}
现在我们用熔断器包一下它:
js
const CircuitBreaker = require('opossum');
const breaker = new CircuitBreaker(queryDatabase, {
timeout: 3000, // 超过3秒算失败
errorThresholdPercentage: 50, // 失败率超过50%就熔断
resetTimeout: 30000 // 30秒后再试一次
});
breaker.fallback(() => ({
message: '服务临时不可用,已返回缓存结果。',
data: { id: 'cached-001', name: 'Cached Project' }
}));
这样,当 teledb 卡成狗时,breaker 会自动"跳闸"。 此时再有请求进来,它不会再去访问 teledb,而是立刻走 fallback 返回缓存。 30 秒后再尝试放行一个请求,如果成功,再恢复正常。
我第一次用熔断的时候,简直像发现了"防自杀神器"。 以前出问题都是整条链崩,现在服务自己会"拉闸保护",稳得离谱。
三、什么才算"失败"?
熔断器并不懂什么是失败。它只认 Promise 是"resolved"还是"rejected"。 你得自己定义。
常见几种情况:
- 超时 :函数执行太久没结果。
opossum
会自动处理; - 数据库连接失败:Driver 会抛错;
- 业务失败 :请求虽然成功返回,但数据不对,这种你得自己
throw new Error()
; - 连接池满了:拿连接失败;
- HTTP 5xx:axios 会 reject。
所以逻辑上,你只要觉得"这次不行",就 throw
。 比如:
js
async function queryDatabase(id) {
const result = await db.query(...);
if (!result.rows.length) {
throw new Error(`项目 ${id} 不存在`);
}
return result.rows[0];
}
这样熔断器才知道要记一次失败。
四、限流 + 熔断的组合拳
很多初学者以为:加了限流,就不用熔断。 其实这俩是两道不同的防线。
场景 | 机制 | 目的 |
---|---|---|
外部用户疯狂请求 | 限流 | 保护自己不被打爆 |
下游服务出故障 | 熔断 | 保护自己不被拖死 |
临时抖动 | 重试 | 给对方一个机会 |
持续故障 | 降级 | 保核心功能不崩 |
真正的弹性系统,是这几层叠加在一起的。
五、一些经验碎片
写到这我忽然想到几个小经验,不系统,但挺有用的:
-
别怕限流,怕的是不控制。 就像高速入口限流,不是让车不跑,而是让车安全跑。
-
熔断器别只加在外部调用上。 内部数据库、Redis、消息队列都可以加。
-
要监控熔断状态。 熔断打开的那一刻,一定要有告警。否则你以为系统稳了,其实早在"假稳"。
-
fallback 不是摆设。 最好准备一份缓存或者默认值,让前端感知最小化。
结尾
Node.js 生态这些年成熟了太多,我们不缺库、不缺方案,缺的是那种"工程上的敬畏"。 服务要学会拒绝,要学会保护自己。
我特别喜欢一句话:
"好的架构不是让系统更强,而是让系统能承认自己的弱。"
限流和熔断,就是 Node.js 服务"成熟"的第一步。 当你写完那几行代码,再看一次日志,你会发现: 以前让你心惊胆战的 504,终于安静下来了。