Fastify SSR 限流实战:远程热更新 + 自定义 Store + 友好 429 页
Fastify SSR 限流实战远程热更新 自定义 Store 友好 429 页本文介绍一套适用于Node.js Fastify SSR 电商站点的限流方案通过远程 JSON 配置动态开关与调参自定义 Store 支持timeWindow热更新并在触发限流时返回多语言友好页面而不是冷冰冰的 JSON 错误。关键词限流、Rate Limit、Fastify、Node.js、SSR、429、toad-cache、远程配置、热更新前言电商 SSR 服务面临两类流量压力恶意爬取 / CC 攻击短时间大量请求打满 Node 进程突发正常流量大促、广告投放带来瞬时高峰如果在代码里写死max: 100、timeWindow: 60000一旦要调整阈值或紧急关闭限流就得改代码、重新部署。攻击进行中时这个窗口往往太长。我们的做法是限流参数放远程 JSON服务每 10 秒拉取一次max和timeWindow均可热更新拉取失败自动关闭限流避免误伤正常用户。一、技术选型组件作用fastify/rate-limitFastify 官方限流插件支持全局/路由级限流toad-cache高性能 LRU 内存缓存插件默认 Store 也用它自定义HotReloadStore解决默认 Store 无法热更新timeWindow的问题remoteLimitConfig.js远程配置拉取、解析、轮询public/429.html多语言友好限流提示页依赖示例{fastify/rate-limit:9.1.0,fastify:^4.24.3}toad-cache作为fastify/rate-limit的传递依赖引入自定义 Store 中直接import { LruMap } from toad-cache即可。二、整体架构┌──────────────────────────────────────────────────────────────────┐ │ 远程配置 limit-config.jsonCDN / 对象存储 │ │ { e: true, m: 100, t: 60000 } │ └────────────────────────────┬─────────────────────────────────────┘ │ 每 10s 轮询 ▼ ┌──────────────────────────────────────────────────────────────────┐ │ remoteLimitConfig.js │ │ 解析配置 → 内存 activePolicy → getActivePolicy() │ │ 拉取失败 → 自动 fallbackToOpenTraffic() │ └────────────────────────────┬─────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ fastify/rate-limit HotReloadStore │ │ 每次 incr 读取最新 timeWindowmax 通过 async 函数动态获取 │ └────────────────────────────┬─────────────────────────────────────┘ │ 超限 ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 429 响应日志记录 IP/UA 多语言 HTML 页面 │ └──────────────────────────────────────────────────────────────────┘三个模块各司其职远程配置层运维可在不发版的情况下开关限流、调阈值计数存储层内存 LRU 计数支持timeWindow实时生效响应层返回用户可理解的 429 页面而非裸 JSON三、远程配置模块remoteLimitConfig.js建议独立一个配置文件模块负责拉取、解析、缓存远程限流参数。3.1 配置地址与默认值constCONFIG_URLprocess.env.RATE_LIMIT_CONFIG_URL||https://example.com/limit-config.json;constPOLL_INTERVAL10*1000;// 10 秒轮询constFETCH_TIMEOUT5000;// 5 秒超时letactivePolicy{enabled:false,// 默认关闭拉取成功后才可能开启max:100,timeWindow:60_000// 60 秒窗口};设计要点默认enabled: false避免服务启动后、配置未拉到前就误限流支持RATE_LIMIT_CONFIG_URL环境变量覆盖方便多环境部署3.2 远程 JSON 格式精简字段为减少 CDN 流量和解析成本字段做了缩写远程字段含义示例eenabled是否开启限流truemmax时间窗口内最大请求数100ttimeWindow窗口时长毫秒60000示例limit-config.json{e:true,m:200,t:60000}3.3 解析与容错functionnormalizeRemotePayload(data){constmaxNumber(data?.m);consttimeWindowNumber(data?.t);return{enabled:!!data?.e,max:Number.isFinite(max)max0?Math.trunc(max):100,timeWindow:Number.isFinite(timeWindow)timeWindow0?Math.trunc(timeWindow):60_000,};}非法值回退到安全默认避免max: 0或NaN导致全站不可用。3.4 失败降级自动关闭限流functionfallbackToOpenTraffic(reason){if(activePolicy.enabled){activePolicy{...activePolicy,enabled:false};console.warn([限流] 限流已关闭:${reason});return;}console.warn([限流] 配置拉取失败限流保持关闭:${reason});}思路配置源不可达时与其用过期参数误伤用户不如直接关闭限流。SSR 站点可用性优先。3.5 拉取与轮询asyncfunctionrequestRemotePolicy(){constresponseawaitfetch(CONFIG_URL,{signal:AbortSignal.timeout(FETCH_TIMEOUT),headers:{Accept:application/json},});if(!response.ok){thrownewError(HTTP${response.status});}returnresponse.json();}asyncfunctionrefreshRemotePolicy(){try{constdataawaitrequestRemotePolicy();activePolicynormalizeRemotePayload(data);}catch(err){fallbackToOpenTraffic(err.message);}}3.6 初始化与轮询exportasyncfunctionstartPolicySync(){try{awaitrefreshRemotePolicy();}catch(err){fallbackToOpenTraffic(err.message);}consttimersetInterval((){refreshRemotePolicy().catch((err){fallbackToOpenTraffic(err.message);});},POLL_INTERVAL);// 不阻止 Node 进程退出if(typeoftimer.unreffunction){timer.unref();}}exportfunctiongetActivePolicy(){returnactivePolicy;}四、核心难点为什么需要自定义 Storefastify/rate-limit支持动态maxmax:async()getActivePolicy().max,但插件注册时的timeWindow会传给默认 Store默认 Store 在创建时就把窗口固定了。远程把t从60000改成30000后计数器仍按旧窗口运行直到重启服务。4.1 HotReloadStore 的思路每次incr计数时重新读取getActivePolicy().timeWindow。这样max和timeWindow都能在 10 秒内随远程配置生效。4.2 完整实现import{LruMapasLru}fromtoad-cache;import{getActivePolicy}from./remoteLimitConfig.js;/** 每次计数时读取远程 timeWindow支持热更新 */functionHotReloadStore(options{}){this.lrunewLru(options.cache??5000);this.continueExceedingoptions.continueExceeding;}HotReloadStore.prototype.incrfunction(key,cb,max,ban){consttimeWindowgetActivePolicy().timeWindow;constnowInMsDate.now();letcurrentthis.lru.get(key);if(!current){// 新 key初始化计数current{current:1,ttl:timeWindow,ban:false,iterationStartMs:nowInMs,};}elseif(current.iterationStartMstimeWindownowInMs){// 窗口过期重置current.current1;current.ttltimeWindow;current.banfalse;current.iterationStartMsnowInMs;}else{// 窗口内累加current.current;if(this.continueExceedingcurrent.currentmax){current.ttltimeWindow;current.iterationStartMsnowInMs;}else{current.ttltimeWindow-(nowInMs-current.iterationStartMs);}}if(ban!-1!current.bancurrent.current-maxban){current.bantrue;}this.lru.set(key,current);cb(null,current);};HotReloadStore.prototype.childfunction(routeOptions){returnnewHotReloadStore(routeOptions);};exportdefaultHotReloadStore;4.3 toad-cache / LruMap 的作用用LRU Map存储每个 key 的计数状态默认最多缓存5000个 key超出后淘汰最久未访问的条目防止大量 IP / 路由 key 导致内存无限增长toad-cache是fastify/rate-limit内置 Store 同款依赖自定义 Store 复用它可以保持行为一致、减少额外依赖。4.4 incr 参数说明参数来源含义keykeyGenerator返回值限流桶标识见下文max插件传入当前允许的最大请求数ban插件配置超出 max 多少后标记 ban本项目未启用currentStore 维护当前窗口内请求计数ttlStore 计算距离窗口重置的剩余毫秒iterationStartMsStore 维护当前窗口起始时间戳五、Fastify 接入5.1 注册顺序在创建 Fastify 实例后、注册业务路由前importFastifyRateLimitfromfastify/rate-limit;import{startPolicySync,getActivePolicy}from./remoteLimitConfig.js;importHotReloadStorefrom./hotReloadRateLimitStore.js;// 1. 先初始化远程配置awaitstartPolicySync().catch((err){console.warn([限流] 初始化失败限流已关闭:,err.message);});// 2. 再注册限流插件awaitapp.register(FastifyRateLimit,{global:true,max:async()getActivePolicy().max,timeWindow:getActivePolicy().timeWindow,store:HotReloadStore,keyGenerator:()global,// ... allowList、onExceeded 等见下文});5.2 全局桶 vs 按 IP 限流当前实现keyGenerator:()global,即全站共用一个计数桶适合作为 SSR 服务的过载保护 / 紧急熔断——当总 QPS 超过阈值时对所有请求返回 429。如需改为按 IP 限流更适合防单点滥用可改为keyGenerator:(request){return(request.headers[cf-connecting-ip]||request.headers[x-forwarded-for]?.split(,)[0]?.trim()||request.ip);},同时确保trustProxy: true见下文。5.3 白名单路径静态资源、健康检查、本地开发代理路径不应计入限流allowList:(request){if(!getActivePolicy().enabled){returntrue;// 远程关闭时全部放行}constpathrequest.url.split(?)[0];return(path/health||path/service-worker.js||path.startsWith(/assets/)||isDevApiProxyPath(path)// 本地 dev 代理前缀);},enabled: false时allowList直接返回true相当于限流逻辑完全旁路。5.4 反向代理与真实 IP服务部署在 Cloudflare / Nginx 后面时constappFastify({trustProxy:true,// ...});超限日志中优先取真实客户端 IPonExceeded:(request){constiprequest.headers[cf-connecting-ip]||request.headers[x-forwarded-for]?.split(,)[0]?.trim()||request.ip;console.warn([限流] 429,url,request.url,ip,ip,ua,request.headers[user-agent],);},5.5 自定义 429 响应默认插件返回 JSON对 SSR 页面不友好。通过errorResponseBuilder 全局setErrorHandler返回 HTMLerrorResponseBuilder:(_request,context){consterrnewError(Too Many Requests);err.statusCode429;err.retryAftercontext.after;// 如 1 minutereturnerr;},app.setErrorHandler((error,request,reply){if(error.statusCode429){constretryAftererror.retryAfter||1 minute;consthtmllimitPageTemplate.replace(/\{\{RETRY_AFTER\}\}/g,retryAfter);returnreply.code(429).type(text/html; charsetutf-8).send(html);}reply.code(error.statusCode||500).send(error);});生产环境从构建产物读取模板constlimitPageTemplateisProd?fs.readFileSync(resolve(../dist/client/429.html),utf-8):;源文件放在public/429.html随前端构建进入dist/client/。六、429 友好页面设计要点public/429.html建议包含品牌一致性Logo、配色与主站统一多语言根据navigator.languages自动匹配如 en/fr/es/de/ar明确等待时间占位符{{RETRY_AFTER}}由服务端替换为context.after操作引导「重试」「返回首页」按钮SEOmeta namerobots contentnoindex, nofollow占位符替换示例consthtmllimitPageTemplate.replace(/\{\{RETRY_AFTER\}\}/g,retryAfter);七、如何验证限流已生效7.1 确认配置已加载启动日志应出现类似输出[限流] 配置已更新(remote): enabledtrue, max100, timeWindow60000ms [限流] 已注册: enabledtrue, max100, timeWindow60000ms7.2 压测触发 429# 快速发送 150 个请求假设 max100foriin$(seq1150);docurl-s-o/dev/null-w%{http_code}\nhttps://example.com/some-pagedone预期前 100 个返回200之后返回429且响应体为 HTML 而非 JSON。7.3 检查响应头curl-Ihttps://example.com/-HCookie: ...超限时应看到HTTP/1.1 429 Too Many Requests。7.4 验证远程热更新将远程limit-config.json的m从100改为50等待约 10 秒轮询间隔观察日志[限流] 配置已更新(remote): ...重新压测阈值应变为 507.5 验证失败降级将RATE_LIMIT_CONFIG_URL指向无效地址等待拉取失败日志应出现[限流] 限流已关闭: ...此时所有请求正常放行7.6 验证清单远程enabled: false时全站不限流远程enabled: true时超限返回 429 HTML/health、/assets/*在白名单内修改远程m/t后约 10 秒内生效无需重启配置源不可达时自动关闭限流429 页面多语言与{{RETRY_AFTER}}替换正常日志能记录真实 IP经 CDN/反代时八、运维建议8.1 推荐初始参数场景maxtimeWindow说明日常观察关闭—e: false先接入再开启过载熔断全局桶500~200060000按 SSR 机器总承载估算防单 IP 滥用60~12060000需配合 per-IPkeyGenerator8.2 与网关限流的关系层级位置适用场景CDN / WAF边缘DDoS、地理封禁、Bot 管理Nginxlimit_req接入层粗粒度 QPS 控制Fastify 限流本文应用层SSR 过载保护、细粒度开关、友好 429 页应用层限流不能替代 CDN/WAF但可以在应用侧秒级热更新且直接返回业务定制的 429 页面。8.3 多实例部署注意当前HotReloadStore是进程内内存计数多 Pod / 多机器各自独立计数全局桶模式下实际全站阈值 ≈max × 实例数如需精确全站限额应改用 Redis Storefastify/rate-limit支持外部存储九、常见问题Q1为什么max能热更新默认 Store 却不行max通过async () getActivePolicy().max在每次请求时动态读取默认 Store 的timeWindow在 Store 创建时绑定到计数逻辑不会随配置变化。自定义 Store 在incr里每次读timeWindow即可解决。Q2配置拉取失败为什么要关闭限流而不是用旧配置限流是有损策略。配置源故障时旧参数可能过严误伤或过松无效。对电商 SSR 而言可用性优先关闭比误拦 thousands of users 更安全。Q3keyGenerator: () global是不是限流粒度太粗是的这是刻意的全站熔断设计。防爬虫、防单 IP 刷接口应改为 per-IPkeyGenerator。两种模式可以按路由注册不同的FastifyRateLimit实例。Q4开发环境需要限流吗建议 dev 默认关闭远程e: false同时allowList排除本地 API 代理前缀如/st-prt、/v2等避免 HMR 和接口调试被误拦。Q5429 页面为什么不用 SPA 路由限流触发时 SSR 可能已过载返回一份静态自包含 HTML最稳妥——无 JS bundle 依赖、无 API 依赖确保用户一定能看到提示。十、总结模块文件作用远程配置remoteLimitConfig.js拉取/解析/轮询失败自动关闭自定义 StorehotReloadRateLimitStore.jsLRU 计数 timeWindow热更新插件接入server/index.js注册fastify/rate-limit、白名单、429 处理用户提示public/429.html多语言友好限流页这套方案的核心思路是限流是可运维的能力不是写死的常量。远程开关 热更新 失败降级让 SSR 服务在攻击或突发流量下既能自我保护又能在误配置时快速自愈。如果你也在做 Node.js 网关或 SSR 服务的限流欢迎评论区交流全局熔断 vs 按 IP 限流怎么选、Redis Store 怎么接、和 Cloudflare Rate Limiting 如何分工。