节流解释:管理 API 请求限制的指南

何时应在代码中实现节流?

对于大型项目,最好使用 Cloudflare Rate Limiting 或 HAProxy 等工具。这些工具功能强大、可靠,可以为您处理繁重的工作。

但对于较小的项目,或者如果你想了解它的工作原理,你可以在代码中创建自己的速率限制器。为什么?

  • 它很简单:您将构建一些简单易懂的东西。
  • 经济实惠:除了托管服务器之外无需额外费用。
  • 它适用于小型项目:只要流量较低,它就能保持快速和高效。
  • 它是可重复使用的:您可以将其复制到其他项目中,而无需设置新的工具或服务。
  • 您将学到什么

    在本指南结束时,您将了解如何在 TypeScript 中构建基本的节流器,以防止您的 API 不堪重负。以下是我们将介绍的内容:

  • 可配置的时间限制:每次阻止尝试都会增加锁定时间以防止滥用。
  • 请求上限:设置允许的最大请求数。这对于涉及付费服务的 API(如 OpenAI)尤其有用。
  • 内存存储:一种无需 Redis 等外部工具即可运行的简单解决方案 - 非常适合小型项目或原型。
  • 每个用户的限制:使用每个用户的 IPv4 地址跟踪请求。我们将利用 SvelteKit 的内置方法轻松检索客户端 IP。
  • 本指南旨在成为一个实用的起点,非常适合那些想要学习基础知识而又不想经历不必要复杂性的开发人员。**但它还未准备好投入生产**。

    在开始之前,我想对 Lucia 的 Rate Limiting 部分给予应有的赞扬。

    节流阀实施

    让我们定义“Throttler”类:

    export class Throttler {
        private storage = new Map();
    
        constructor(private timeoutSeconds: number[]) {}
    }

    `Throttler` 构造函数接受一个超时时长列表(`timeoutSeconds`)。每次阻止用户时,时长都会根据此列表逐渐增加。最终,当达到最终超时时,您甚至可以触发回调以永久禁止用户的 IP — — 尽管这超出了本指南的范围。

    以下是创建用于阻止用户增加时间间隔的节流阀实例的示例:

    const throttler = new Throttler([1, 2, 4, 8, 16]);

    此实例第一次将阻止用户一秒钟。第二次将阻止用户两秒钟,依此类推。

    我们使用 Map 来存储 IP 地址及其对应的数据。`Map` 是理想的选择,因为它可以高效地处理频繁的添加和删除。

    专业提示:对于频繁变化的动态数据,请使用 Map。对于静态、不变的数据,最好使用对象。(兔子洞)

    当您的端点收到请求时,它会提取用户的 IP 地址并咨询“Throttler”以确定是否应允许该请求。

    工作原理

  • 情况 A:新用户或不活跃用户 如果在 Throttler 中找不到 IP,则可能是用户首次请求,或者用户已经长时间不活跃。在这种情况下:允许操作。通过存储 IP 和初始超时来跟踪用户。
  • 案例 B:活跃用户 如果找到 IP,则意味着用户之前已发出请求。此处:检查自上次阻止以来是否已过了所需的等待时间(基于 timeoutSeconds 数组)。如果已过了足够的时间:更新时间戳。增加超时索引(上限为最后一个索引以防止溢出)。如果没有,则拒绝请求。
  • 在后一种情况下,我们需要检查自上一个区块以来是否已经过了足够的时间。由于“索引”,我们知道应该参考哪个“timeoutSeconds”。如果没有,只需反弹。否则更新时间戳。

    export class Throttler {
        // ...
    
        public consume(key: string): boolean {
            const counter = this.storage.get(key) ?? null;
            const now = Date.now();
    
            // Case A
            if (counter === null) {
                // At next request, will be found.
                // The index 0 of [1, 2, 4, 8, 16] returns 1.
                // That's the amount of seconds it will have to wait.
                this.storage.set(key, {
                    index: 0,
                    updatedAt: now
                });
                return true; // allowed
            }
    
            // Case B
            const timeoutMs = this.timeoutSeconds[counter.index] * 1000;
            const allowed = now - counter.updatedAt >= timeoutMs;
            if (!allowed) {
                return false; // denied
            }
    
            // Allow the call, but increment timeout for following requests.
            counter.updatedAt = now;
            counter.index = Math.min(counter.index + 1, this.timeoutSeconds.length - 1);
            this.storage.set(key, counter);
    
            return true; // allowed
        }
    }

    更新索引时,它会限制到 `timeoutSeconds` 的最后一个索引。如果没有它,`counter.index + 1` 将会溢出,并且下一个 `this.timeoutSeconds[counter.index]` 访问将导致运行时错误。

    端点示例

    此示例展示了如何使用“Throttler”来限制用户调用 API 的频率。如果用户发出的请求过多,他们将收到错误,而不是运行主逻辑。

    const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 300]);
    
    export async function GET({ getClientAddress }) {
        const IP = getClientAddress();
    
        if (!throttler.consume(IP)) {
            throw error(429, { message: 'Too Many Requests' });
        }
    
        // Read from DB, call OpenAI - do the thing.
    
        return new Response(null, { status: 200 });
    }
    Throttling GIF example

    认证须知

    当使用登录系统的速率限制时,您可能会遇到以下问题:

  • 用户登录,触发节流阀将超时与其 IP 关联。
  • 用户注销或其会话结束(例如,立即注销、cookie 随着会话过期以及浏览器崩溃等)。
  • 当他们不久后再次尝试登录时,Throttler 可能仍会阻止他们,并返回 429 请求过多错误。
  • 为了防止这种情况,请使用用户的唯一“userID”而不是其 IP 来限制速率。此外,您必须在成功登录后重置节流器状态,以避免不必要的阻止。

    向 `Throttler` 类添加 `reset` 方法:

    export class Throttler {
        // ...
    
        public reset(key: string): void {
            this.storage.delete(key);
        }
    }

    并在登录成功后使用:

    const user = db.get(email);
    
    if (!throttler.consume(user.ID)) {
        throw error(429);
    }
    
    const validPassword = verifyPassword(user.password, providedPassword);
    if (!validPassword) {
        throw error(401);
    }
    
    throttler.reset(user.id); // Clear throttling for the user

    通过定期清理来管理陈旧的 IP 记录

    当您的节流器跟踪 IP 和速率限制时,重要的是要考虑如何以及何时删除不再需要的 IP 记录。如果没有清理机制,您的节流器将继续将记录存储在内存中,随着数据的增长,可能会导致性能问题。

    为了防止这种情况,您可以实现一个清理函数,在一段时间不活动后定期删除旧记录。下面是一个示例,说明如何添加一个简单的清理方法,以从节流器中删除过时的条目。

    export class Throttler {
        // ...
    
        public cleanup(): void {
            const now = Date.now()
    
            // Capture the keys first to avoid issues during iteration (we use .delete)
            const keys = Array.from(this.storage.keys())
    
            for (const key of keys) {
                const counter = this.storage.get(key)
                if (!counter) {
                    // Skip if the counter is already deleted (handles concurrency issues)
                    return
                }
    
                // If the IP is at the first timeout, remove it from storage
                if (counter.index == 0) {
                    this.storage.delete(key)
                    continue
                }
    
                // Otherwise, reduce the timeout index and update the timestamp
                counter.index -= 1
                counter.updatedAt = now
                this.storage.set(key, counter)
            }
        }
    }

    安排清理的一个非常简单的方法(但可能不是最好的)是使用“setInterval”:

    const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 300])
    const oneMinute = 60_000
    setInterval(() => throttler.cleanup(), oneMinute)

    这种清理机制有助于确保您的节流阀不会无限期地保留旧记录,从而保持应用程序的高效性。虽然这种方法简单易行,但对于更复杂的用例(例如,使用更高级的调度或处理高并发性),可能需要进一步改进。

    通过定期清理,您可以防止内存膨胀,并确保不再跟踪一段时间内未尝试发出请求的用户 - 这是使您的速率限制系统可扩展且资源高效的第一步。

  • 如果你喜欢冒险,你可能会对属性的分配方式和变化方式感兴趣。此外,为什么不了解虚拟机优化,例如内联缓存,这尤其受到单态性的青睐。尽情享受吧。↩