首页 / 资讯中心 / 文章详情

大促秒杀防抖与降频:前端基于滑动时间窗口与令牌桶算法的智能限频器

大促秒杀防抖与降频:前端基于滑动时间窗口与令牌桶算法的智能限频器 ★ FEATURED ARTICLE
大促秒杀防抖与降频前端基于滑动时间窗口与令牌桶算法的智能限频器双 11 零点那一秒前端提单按钮面临的往往不是优雅的人类手指而是两种极端力量的绞杀一方面是上千万焦虑的用户开启“筋膜枪”式连击另一方面是黑产抢购脚本以每秒数十次的高频并发发起网络探测。如果前端只做最原始的lodash.debounce那么用户的每一次狂点都会导致最后一次请求被不断后推直到用户停下手来请求才发得出去——在按毫秒决定生死的秒杀库存争夺中这无异于直接宣布抢购失败。而如果只用throttle又无法允许短时间内的合理突发点击Burst且极易被按规律发包的自动化脚本穿透。后端的网关限流固然是最后一道防线但如果前端放任每一次点击都向服务端发出 TCP 连接与握手网关会在几毫秒内被海量非正常无效请求打至连接数耗尽。把防线前移到客户端在前端按钮与网络请求拦截器之间建立一套兼具“突发容忍”与“严格均值约束”的滑动时间窗口与令牌桶智能限频器是保卫秒杀系统的第一道盾牌。为什么传统防抖/节流在秒杀中全面溃败Debounce防抖的致命缺陷防抖本质是“等待静止”。用户疯狂连续点击 10 次期间间隔 100ms设置了 300ms 防抖的按钮在 1000ms 结束后才发出请求。此时秒杀早已售罄。固定窗口 Throttle节流的毛刺穿透假设设置 1 秒内只发 1 次请求。在第 0.9 秒点击一次第 1.1 秒点击一次。虽然两个请求分别落在两个相邻的秒级窗口内但在 200ms 的极短时间内实际上发出了 2 次请求。黑产脚本利用这个时间跨窗边界可以轻松制造数倍于阈值的突发洪峰。缺乏信誉度与行为感知无论是防抖还是节流对待所有行为都是冷冰冰的定时器。但正常用户的双击手抖应当被快速兜底而连续以 50ms 绝对均等间隔发起的 20 次点击必然是脚本作弊。我们需要的是一套自适应限频器平时像令牌桶一样允许适度的短时突发当监测到高频违规脉冲时立刻切换为滑动时间窗口进行严格惩罚性降频。客户端智能令牌桶限频器实现该算法维护一个容量为 $C$ 的令牌桶以固定速率 $r$个/秒向桶中注入令牌。每次用户点击必须向桶申请一个令牌若有则立刻放行若无则进入冷却并拒绝请求。同时记录最近 $N$ 次请求的时间戳形成环形滑动窗口实时监测点击频率标准差用于识别外挂作弊。export interface RateLimiterOptions { capacity: number; // 桶容量最大允许突发量建议 2~3 refillRate: number; // 令牌恢复速率个/秒如 0.5 表示 2 秒恢复 1 个 windowSizeMs: number; // 滑动窗口探测时长如 2000ms maxRequestsPerWindow: number; // 窗口内绝对最大请求上限 } export interface AcquireResult { allowed: boolean; remainingTokens: number; retryAfterMs: number; suspectedBot: boolean; } export class ClientTokenBucketLimiter { private capacity: number; private refillRate: number; private windowSizeMs: number; private maxRequestsPerWindow: number; private tokens: number; private lastRefillTimestamp: number; private slidingWindowTimestamps: number[] []; constructor(options: RateLimiterOptions) { this.capacity options.capacity; this.refillRate options.refillRate; this.windowSizeMs options.windowSizeMs; this.maxRequestsPerWindow options.maxRequestsPerWindow; this.tokens options.capacity; this.lastRefillTimestamp performance.now(); } // 尝试获取请求令牌 public acquire(): AcquireResult { const now performance.now(); this.refill(now); this.pruneSlidingWindow(now); // 1. 滑动窗口安全兜底防止任何绕过令牌桶的边界穿透 if (this.slidingWindowTimestamps.length this.maxRequestsPerWindow) { const oldestInWindow this.slidingWindowTimestamps[0]; const waitTime Math.ceil(this.windowSizeMs - (now - oldestInWindow)); return { allowed: false, remainingTokens: 0, retryAfterMs: Math.max(waitTime, 500), suspectedBot: this.detectMachinePattern(), }; } // 2. 令牌桶逻辑判定 if (this.tokens 1.0) { this.tokens - 1.0; this.slidingWindowTimestamps.push(now); return { allowed: true, remainingTokens: Math.floor(this.tokens), retryAfterMs: 0, suspectedBot: false, }; } // 3. 令牌枯竭计算恢复 1 个令牌所需的精确等待毫秒数 const waitTimeMs Math.ceil(((1.0 - this.tokens) / this.refillRate) * 1000); return { allowed: false, remainingTokens: 0, retryAfterMs: waitTimeMs, suspectedBot: this.detectMachinePattern(), }; } // 惰性补充令牌根据流逝时间推算生成的令牌量避免后台定时器开销 private refill(now: number): void { const elapsedSeconds (now - this.lastRefillTimestamp) / 1000; this.lastRefillTimestamp now; this.tokens Math.min( this.capacity, this.tokens elapsedSeconds * this.refillRate ); } // 清除滑动时间窗口外的过期时间戳 private pruneSlidingWindow(now: number): void { const boundary now - this.windowSizeMs; while ( this.slidingWindowTimestamps.length 0 this.slidingWindowTimestamps[0] boundary ) { this.slidingWindowTimestamps.shift(); } } // 简易黑产机器人特征识别检测连续点击间隔的标准差 // 机器脚本通常使用固定的 setInterval间隔极度均等正常人类手指点击方差较大 private detectMachinePattern(): boolean { if (this.slidingWindowTimestamps.length 4) return false; const intervals: number[] []; for (let i 1; i this.slidingWindowTimestamps.length; i) { intervals.push( this.slidingWindowTimestamps[i] - this.slidingWindowTimestamps[i - 1] ); } const mean intervals.reduce((a, b) a b, 0) / intervals.length; const variance intervals.reduce((a, b) a Math.pow(b - mean, 2), 0) / intervals.length; const standardDeviation Math.sqrt(variance); // 标准差小于 5 毫秒且平均点击频率极快极大概率为软件脚本 return standardDeviation 5 mean 100; } }与 UI 按钮与 Fetch 拦截器的集成智能限频器不能只在前端默默报错它必须无缝接管按钮的交互反馈const buyButton document.getElementById(seckill-submit-btn) as HTMLButtonElement; const limiter new ClientTokenBucketLimiter({ capacity: 2, // 允许前两次轻微连击 refillRate: 0.33, // 每 3 秒恢复 1 个请求额度 windowSizeMs: 3000, // 3 秒滑动窗口 maxRequestsPerWindow: 3, }); buyButton.addEventListener(click, async () { const check limiter.acquire(); if (!check.allowed) { // 命中限频按钮置灰并进入倒计时动画 buyButton.disabled true; const originalText buyButton.innerText; buyButton.innerText 排队中 (${Math.ceil(check.retryAfterMs / 1000)}s); if (check.suspectedBot) { console.warn(Risk engine triggered: Suspicious automation activity.); // 上报风控打点或悄然调起无感滑块验证码 } setTimeout(() { buyButton.disabled false; buyButton.innerText originalText; }, check.retryAfterMs); return; } // 放行执行秒杀提单网络请求 try { await executeSeckillOrder(); } catch (e) { // 异常处理 } });关键考量与安全边界绝不能将客户端限频视为绝对安全前端的一切代码都是运行在不受信任的用户环境中的。专业的黑客可以直接反编译 JS 甚至脱机通过 cURL 发起请求。前端限频的战略价值是过滤 95% 以上的盲目重试流量与业余脚本削平网关的脉冲毛刺绝对安全依然需要依赖服务端的网关令牌桶与风控验证码。惰性计算Lazy Refill消除 CPU 唤醒上面的实现中没有使用任何setInterval去周期性生产令牌而是借由每次acquire()触发时计算时间差elapsedSeconds。在用户不操作时限频器完全零开销不会抢占浏览器主线程哪怕一毫秒的资源。跨 Tab 页的额度共享在大促秒杀中如果用户同时打开 10 个浏览器标签页点击同一个商品各自独立的内存限频器会失效。对于有严格单设备频次限制的场景可以将滑动窗口的时间戳数组同步至localStorage或通过BroadcastChannel在 Tab 间做跨页同步。把网关的压力化解在手指落下的第一个屏幕像素上用扎实的算法代替粗暴的延时这才是应对大促洪峰时前端工程师该有的底气。
阅读完成 · 觉得有帮助?
咨询建站