Ngừng sử dụng Fixed Window: Xây dựng Sliding Window Rate Limiter mạnh mẽ với Redis và Node.js

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Cái bẫy tiềm ẩn của Fixed Window

Hầu hết các nhà phát triển triển khai rate limiting bằng thuật toán Fixed Window. Logic rất đơn giản: bạn cho phép 100 yêu cầu mỗi phút và vào đầu mỗi phút mới, bộ đếm sẽ reset về không. Dù dễ lập trình, cách tiếp cận này có một lỗ hổng nguy hiểm được gọi là ‘vấn đề ranh giới’ (boundary problem).

Hãy tưởng tượng một người dùng gửi 100 yêu cầu vào lúc 10:00:59 và 100 yêu cầu khác vào lúc 10:01:01. Hệ thống của bạn coi đó là hai phút riêng biệt và cho phép tất cả 200 yêu cầu. Trên thực tế, máy chủ của bạn vừa phải chịu đựng một đợt bùng nổ 200 lượt truy cập chỉ trong hai giây. Tôi đã từng thấy các cơ sở dữ liệu production bị treo vì giới hạn ‘100 yêu cầu mỗi phút’ cho phép một đợt tăng đột biến đáng lẽ phải bị chặn. Làm chủ rolling window là cách giúp hạ tầng của bạn sống sót qua những đợt lưu lượng truy cập đột ngột này.

Tại sao Sliding Window Log lại ưu việt hơn

Thuật toán Sliding Window Log loại bỏ vấn đề ranh giới bằng cách theo dõi dấu thời gian (timestamp) của từng yêu cầu riêng lẻ. Thay vì một bộ đếm toàn cục, chúng ta duy trì một lịch sử (hoặc log) cho mỗi người dùng. Khi một yêu cầu mới gửi đến API, hệ thống thực hiện ba bước:

  1. Nó xóa tất cả các dấu thời gian cũ hơn cửa sổ hiện tại (ví dụ: bất kỳ thứ gì cũ hơn 60 giây).
  2. Nó thêm dấu thời gian của yêu cầu hiện tại vào log.
  3. Nó đếm các mục còn lại. Nếu số lượng nằm trong giới hạn, yêu cầu sẽ được thực hiện.

Vì cửa sổ được tính tương ứng với chính xác mili giây của yêu cầu, nên không có các điểm reset cố định. Với giới hạn 100 yêu cầu, người dùng bị giới hạn nghiêm ngặt trong bất kỳ khoảng thời gian 60 giây nào, bất kể họ bắt đầu gửi yêu cầu từ lúc nào.

Điều kiện tiên quyết và Thiết lập môi trường

Trong một môi trường phân tán, bạn cần một kho lưu trữ dữ liệu chung để tất cả các instance API luôn đồng bộ. Redis là tiêu chuẩn công nghiệp ở đây. Sorted Sets (ZSET) của nó rất lý tưởng để quản lý log dấu thời gian với hiệu suất cao. Chúng ta sẽ sử dụng Node.js và thư viện ioredis để triển khai.

Bắt đầu bằng cách thiết lập thư mục dự án của bạn:

mkdir redis-rate-limiter
cd redis-rate-limiter
npm init -y
npm install ioredis

Nếu bạn chưa cài đặt Redis cục bộ, bạn có thể khởi chạy một container chỉ trong vài giây bằng Docker:

docker run -d --name redis-limiter -p 6379:6379 redis

Triển khai Logic Rate Limiter

Chúng ta sẽ tận dụng Redis Sorted Sets, nơi mỗi phần tử đều có một điểm số (score). Bằng cách sử dụng Unix timestamp làm cả giá trị và điểm số, chúng ta có thể truy vấn và cắt bỏ các điểm dữ liệu cũ với độ chính xác đến từng micro giây. Cách tiếp cận dựa trên class này giúp logic dễ dàng áp dụng vào bất kỳ route Express hoặc Fastify nào.

const Redis = require('ioredis');

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
});

class RateLimiter {
  constructor(limit, windowInSeconds) {
    this.limit = limit;
    this.windowInMs = windowInSeconds * 1000;
  }

  async isAllowed(userId) {
    const key = `rate_limit:${userId}`;
    const now = Date.now();
    const windowStart = now - this.windowInMs;

    // Sử dụng một pipeline đảm bảo tính nguyên tử và giảm độ trễ mạng
    const multi = redis.multi();

    // 1. Dọn dẹp: Xóa các dấu thời gian cũ hơn cửa sổ trượt của chúng ta
    multi.zremrangebyscore(key, 0, windowStart);

    // 2. Ghi lại lượt thử hiện tại
    multi.zadd(key, now, now);

    // 3. Lấy tổng số lượng hiện tại cho người dùng này
    multi.zcard(key);

    // 4. Đặt TTL để người dùng không hoạt động không làm lãng phí bộ nhớ Redis
    multi.expire(key, Math.ceil(this.windowInMs / 1000) + 1);

    const results = await multi.exec();

    // ioredis trả về kết quả theo định dạng: [[err, result], ...]
    const requestCount = results[2][1];

    return {
      allowed: requestCount <= this.limit,
      count: requestCount
    };
  }
}

module.exports = RateLimiter;

Giải thích các lệnh Redis

  • zremrangebyscore: Đây là động cơ của cửa sổ trượt. Nó xóa các yêu cầu “hết hạn” đã xảy ra trước thời điểm hiện tại trừ đi thời lượng của cửa sổ.
  • zadd: Lệnh này ghi lại lượt truy cập hiện tại. Ngay cả khi yêu cầu cuối cùng bị chặn, việc ghi lại lượt thử sẽ ngăn người dùng spam vào ranh giới.
  • zcard: Lệnh này trả về tổng số dấu thời gian hợp lệ còn lại trong set.
  • multi.exec(): Lệnh này bao bọc mọi thứ trong một transaction. Nó ngăn chặn tình trạng race condition khi hai yêu cầu đồng thời có thể tính toán sai số lượng.

Xác minh và Giám sát

Để thấy limiter hoạt động, hãy kết nối nó với một server Express đơn giản. Cài đặt framework bằng npm install express và tạo tệp server.js.

const express = require('express');
const RateLimiter = require('./limiter');

const app = express();
const limiter = new RateLimiter(5, 10); // Cho phép 5 yêu cầu mỗi 10 giây

app.get('/api/resource', async (req, res) => {
  const userId = req.query.user || 'anonymous';
  const { allowed, count } = await limiter.isAllowed(userId);

  if (!allowed) {
    return res.status(429).json({
      error: 'Quá nhiều yêu cầu',
      currentCount: count,
      limit: 5
    });
  }

  res.json({ message: 'Thành công!', currentCount: count });
});

app.listen(3000, () => console.log('Server đang chạy trên cổng 3000'));

Kiểm tra Logic Sliding Window

Gửi bảy yêu cầu liên tiếp bằng vòng lặp bash này. Bạn sẽ thấy năm yêu cầu đầu tiên thành công, trong khi hai yêu cầu cuối cùng bị chặn ngay lập tức.

for i in {1..7}; do curl "http://localhost:3000/api/resource?user=dev_user"; echo ""; done

Chi phí vận hành

Sliding Window Log mang lại độ chính xác vượt trội, nhưng nó đánh đổi bộ nhớ để lấy độ chính xác đó. Một mục ZSET duy nhất trong Redis tiêu tốn khoảng 60 đến 100 byte. Nếu bạn theo dõi 1.000 yêu cầu cho 10.000 người dùng đang hoạt động, bạn có thể tốn tới 1GB RAM chỉ cho việc rate limiting. Luôn giám sát mức sử dụng của bạn bằng lệnh INFO memory.

Nếu bộ nhớ trở thành điểm nghẽn, bạn có thể tìm hiểu về Sliding Window Counter. Đó là một cách tiếp cận lai (hybrid) sử dụng ít RAM hơn nhưng chấp nhận một biên độ sai số nhỏ. Tuy nhiên, đối với hầu hết các API yêu cầu bảo mật cao, phương pháp ZSET vẫn là tiêu chuẩn vàng.

Tổng kết

Chuyển từ Fixed Window sang Sliding Window Log giúp loại bỏ rủi ro người dùng tăng gấp đôi thông lượng tại thời điểm chuyển giao phút. Bằng cách sử dụng Redis, giới hạn tốc độ của bạn vẫn nhất quán ngay cả khi bạn mở rộng lên 50 instance Node.js phía sau load balancer. Thiết lập này cung cấp một nền tảng chuyên nghiệp để bảo vệ backend của bạn khỏi cả các đợt tăng đột biến vô tình và sự lạm dụng có ý đồ xấu.

Share: