Đặt vấn đề: Thách thức bảo vệ tài nguyên API trong hệ thống phân tán

Trong các hệ thống Enterprise quy mô lớn, API không chỉ đơn thuần là cổng giao tiếp giữa client và server, mà còn là tài nguyên cốt lõi cần được bảo vệ nghiêm ngặt. Việc không kiểm soát tần suất gửi yêu cầu (Rate Limiting) có thể dẫn đến nhiều hệ lụy nghiêm trọng: từ các cuộc tấn công từ chối dịch vụ (DDoS), brute-force mật khẩu, quét dữ liệu (scraping) trái phép, cho đến việc một vài client chiếm dụng toàn bộ băng thông hệ thống, gây ảnh hưởng đến trải nghiệm của người dùng khác.

Đối với các ứng dụng chạy trên một instance duy nhất, việc triển khai rate limiting khá đơn giản bằng cách lưu trữ trạng thái trong bộ nhớ (In-memory). Tuy nhiên, trong kiến trúc Microservices hoặc khi ứng dụng được scale out ra nhiều instance (Multi-instance) phía sau một Load Balancer, giải pháp In-memory hoàn toàn thất bại vì trạng thái không được đồng bộ. Lúc này, chúng ta cần một giải pháp lưu trữ tập trung (Centralized Storage) có tốc độ truy xuất cực nhanh và hỗ trợ các phép toán nguyên tử (Atomic Operations). Redis chính là ứng cử viên hàng đầu.

Bài viết này sẽ đi sâu vào kỹ thuật thiết kế và xây dựng hệ thống Rate Limiting Đa Tầng (Multi-tier Rate Limiting) sử dụng kết hợp hai thuật toán mạnh mẽ: Token Bucket (để xử lý burst traffic) và Sliding Window Log (để đảm bảo độ chính xác tuyệt đối theo thời gian thực), triển khai trực tiếp trên framework NestJS kết hợp với Redis Lua Scripting.

Phân tích thuật toán: Token Bucket vs Sliding Window Log

Để xây dựng một hệ thống rate limiting hiệu quả, việc hiểu rõ ưu và nhược điểm của từng thuật toán là điều bắt buộc. Thay vì chỉ áp dụng một thuật toán duy nhất cho toàn bộ hệ thống, việc kết hợp chúng ở các tầng khác nhau sẽ mang lại sự linh hoạt tối đa.

1. Thuật toán Token Bucket

Thuật toán Token Bucket hoạt động dựa trên nguyên lý một chiếc xô chứa tối đa C token. Cứ sau mỗi khoảng thời gian nhất định, một lượng token R (refill rate) sẽ được tự động thêm vào xô. Khi có một request gửi đến, hệ thống sẽ kiểm tra xem trong xô còn token hay không. Nếu còn, request được thông qua và 1 token bị trừ đi. Nếu xô rỗng, request sẽ bị từ chối ngay lập tức.

  • Ưu điểm: Cho phép xử lý các đợt lưu lượng truy cập tăng đột biến (burst traffic) trong thời gian ngắn, miễn là số lượng request không vượt quá dung lượng tối đa của xô (capacity).
  • Nhược điểm: Khó đảm bảo tính phân phối đều đặn của request theo thời gian. Nếu một client dùng hết sạch token trong 1 giây đầu tiên, họ sẽ phải đợi đến chu kỳ refill tiếp theo.

2. Thuật toán Sliding Window Log

Sliding Window Log ghi lại dấu thời gian (timestamp) của mỗi request thành một danh sách (Sorted Set trong Redis). Khi một request mới đến, hệ thống sẽ xóa bỏ tất cả các timestamp cũ nằm ngoài cửa sổ thời gian hiện tại (ví dụ: ngoài khoảng 1 phút gần nhất), sau đó đếm số lượng timestamp còn lại trong danh sách để quyết định có cho phép request tiếp theo hay không.

  • Ưu điểm: Độ chính xác cực kỳ cao, loại bỏ hoàn toàn hiện tượng "double limit" ở ranh giới các cửa sổ cố định (Fixed Window).
  • Nhược điểm: Tốn kém tài nguyên bộ nhớ vì phải lưu trữ timestamp của từng request. Nếu một client gửi hàng triệu request, kích thước của Sorted Set sẽ phình to rất nhanh.

3. Chiến lược Đa Tầng (Multi-tier Rate Limiting)

Để tối ưu hóa hiệu năng và chi phí, chúng ta sẽ thiết kế hệ thống theo mô hình đa tầng:

  • Tầng 1 (Tầng IP - Sliding Window Log): Áp dụng ở mức global để ngăn chặn các cuộc tấn công thô bạo (DDoS, brute-force). Tầng này giới hạn số lượng request tối đa từ một IP trong một khoảng thời gian ngắn (ví dụ: tối đa 100 requests/phút).
  • Tầng 2 (Tầng User/API Key - Token Bucket): Áp dụng cho từng tài khoản người dùng đã xác thực. Tầng này linh hoạt hơn, cho phép người dùng Premium có dung lượng xô lớn hơn người dùng Free, đồng thời cho phép burst traffic khi họ tải trang hoặc thực hiện các thao tác đồng thời.

Hiện thực hóa giải pháp: Redis Lua Scripting

Một trong những vấn đề lớn nhất khi làm việc với Redis trong môi trường phân tán là Race Condition. Nếu hai request từ cùng một client đến hai instance NestJS khác nhau cùng một lúc, cả hai instance đều đọc trạng thái từ Redis, thấy vẫn còn token, và cùng tiến hành trừ token. Kết quả là client vượt quá hạn mức cho phép.

Để giải quyết triệt để vấn đề này, chúng ta phải sử dụng Redis Lua Script. Redis đảm bảo rằng mỗi script Lua được thực thi một cách nguyên tử (atomic) và đơn luồng trên server Redis. Không có lệnh nào khác có thể xen vào giữa quá trình thực thi script.

Lua Script cho thuật toán Token Bucket

Dưới đây là đoạn mã Lua Script dùng để kiểm tra và cập nhật token theo thuật toán Token Bucket. Script này nhận vào các tham số: key của Redis, dung lượng xô (capacity), tốc độ hồi phục (refill rate), và timestamp hiện tại.

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4] or 1)

local state = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(state[1])
local last_refill = tonumber(state[2])

if not tokens then
    tokens = capacity
    last_refill = now
else
    local elapsed = now - last_refill
    if elapsed > 0 then
        local refill = elapsed * refill_rate
        tokens = math.min(capacity, tokens + refill)
        last_refill = now
    end
end

if tokens >= requested then
    tokens = tokens - requested
    redis.call("HMSET", key, "tokens", tokens, "last_refill", last_refill)
    redis.call("EXPIRE", key, math.ceil(capacity / refill_rate))
    return {1, tokens}
else
    return {0, tokens}
end

Triển khai trong NestJS Enterprise

Bây giờ, chúng ta sẽ tích hợp giải pháp này vào một ứng dụng NestJS bằng cách xây dựng một Custom Guard và các Decorator để cấu hình linh hoạt.

Bước 1: Cấu hình Redis Module

Trước tiên, chúng ta cần cài đặt thư viện ioredis để giao tiếp với Redis và cấu hình nó như một Dynamic Module trong NestJS.

npm install ioredis @nestjs/config

Tạo file redis.module.ts để quản lý kết nối:

import { Module, Global, Provider } from '@nestjs/common';
import Redis from 'ioredis';

const RedisProvider: Provider = {
  provide: 'REDIS_CLIENT',
  useFactory: () => {
    return new Redis({
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT, 10) || 6379,
    });
  },
};

@Global()
@Module({
  providers: [RedisProvider],
  exports: ['REDIS_CLIENT'],
})
export class RedisModule {}

Bước 2: Xây dựng Custom Decorators

Chúng ta cần các decorator để định nghĩa cấu hình rate limit trực tiếp trên các Controller hoặc Route Handler.

import { SetMetadata } from '@nestjs/common';

export interface RateLimitOptions {
  capacity: number;
  refillRate: number; // số token được hồi phục mỗi giây
}

export const RATE_LIMIT_KEY = 'rate_limit_options';
export const RateLimit = (options: RateLimitOptions) => SetMetadata(RATE_LIMIT_KEY, options);

Bước 3: Xây dựng Rate Limiter Guard

Đây là trái tim của hệ thống. Guard này sẽ intercept các request, xác định định danh của client (IP hoặc User ID), gọi Lua Script trên Redis và đưa ra quyết định cho phép đi tiếp hoặc trả về lỗi 429 Too Many Requests.

import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import Redis from 'ioredis';
import { RATE_LIMIT_KEY, RateLimitOptions } from './rate-limit.decorator';

@Injectable()
export class RateLimiterGuard implements CanActivate {
  private readonly luaScript = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local requested = tonumber(ARGV[4] or 1)

    local state = redis.call("HMGET", key, "tokens", "last_refill")
    local tokens = tonumber(state[1])
    local last_refill = tonumber(state[2])

    if not tokens then
        tokens = capacity
        last_refill = now
    else
        local elapsed = now - last_refill
        if elapsed > 0 then
            local refill = elapsed * refill_rate
            tokens = math.min(capacity, tokens + refill)
            last_refill = now
        end
    end

    if tokens >= requested then
        tokens = tokens - requested
        redis.call("HMSET", key, "tokens", tokens, "last_refill", last_refill)
        redis.call("EXPIRE", key, math.ceil(capacity / refill_rate))
        return {1, math.floor(tokens)}
    else
        return {0, math.floor(tokens)}
    end
  `;

  constructor(
    private reflector: Reflector,
    @Inject('REDIS_CLIENT') private readonly redis: Redis,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const options = this.reflector.getAllAndOverride<RateLimitOptions>(RATE_LIMIT_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!options) {
      return true; // Không cấu hình rate limit thì bỏ qua
    }

    const request = context.switchToHttp().getRequest();
    const ip = request.ip;
    const user = request.user; // Giả định đã qua tầng Authentication Guard
    
    // Xác định định danh duy nhất cho client
    const identifier = user ? `user:${user.id}` : `ip:${ip}`;
    const redisKey = `rate_limit:${identifier}:${context.getHandler().name}`;

    const now = Math.floor(Date.now() / 1000);
    const { capacity, refillRate } = options;

    try {
      // Thực thi Lua Script một cách Atomic
      const result = await this.redis.eval(
        this.luaScript,
        1,
        redisKey,
        capacity.toString(),
        refillRate.toString(),
        now.toString(),
        '1'
      ) as [number, number];

      const [allowed, remainingTokens] = result;

      const response = context.switchToHttp().getResponse();
      response.setHeader('X-RateLimit-Limit', capacity);
      response.setHeader('X-RateLimit-Remaining', remainingTokens);

      if (allowed === 0) {
        response.setHeader('Retry-After', Math.ceil(1 / refillRate));
        throw new HttpException(
          'Too Many Requests - Bạn đã vượt quá giới hạn lượt gọi API cho phép.',
          HttpStatus.TOO_MANY_REQUESTS,
        );
      }

      return true;
    } catch (error) {
      if (error instanceof HttpException) {
        throw error;
      }
      // Fail-open: Nếu Redis gặp sự cố, cho phép request đi qua để tránh sập toàn bộ hệ thống
      console.error('Redis Rate Limiter Error:', error);
      return true;
    }
  }
}

Bước 4: Áp dụng vào Controller

Bây giờ, bạn có thể dễ dàng áp dụng Guard này cho bất kỳ endpoint nào cần bảo vệ với các cấu hình hạn mức khác nhau.

import { Controller, Get, UseGuards } from '@nestjs/common';
import { RateLimiterGuard } from './rate-limiter.guard';
import { RateLimit } from './rate-limit.decorator';

@Controller('payments')
@UseGuards(RateLimiterGuard)
export class PaymentController {

  @Get('checkout')
  @RateLimit({ capacity: 5, refillRate: 0.2 }) // Tối đa 5 request burst, hồi phục 1 token mỗi 5 giây
  async checkout() {
    return { message: 'Thanh toán thành công!' };
  }

  @Get('status')
  @RateLimit({ capacity: 20, refillRate: 1 }) // Tối đa 20 request burst, hồi phục 1 token mỗi giây
  async getStatus() {
    return { status: 'Hệ thống hoạt động bình thường' };
  }
}

Những lưu ý quan trọng khi vận hành thực tế (Production Best Practices)

Khi đưa hệ thống Rate Limiting này vào môi trường Production thực tế, có một số yếu tố kỹ thuật quan trọng bạn cần lưu ý:

  1. Chiến lược Fail-Open vs Fail-Closed: Trong đoạn code trên, chúng ta sử dụng chiến lược Fail-Open (nếu Redis bị sập, hệ thống vẫn cho phép request đi qua). Điều này đảm bảo tính sẵn sàng (Availability) của hệ thống nhưng lại làm giảm tính bảo mật tạm thời. Nếu hệ thống của bạn yêu cầu bảo mật tuyệt đối (ví dụ: cổng thanh toán), hãy chuyển sang Fail-Closed bằng cách ném ra lỗi 500 khi Redis không phản hồi.
  2. Tối ưu hóa Redis Performance: Việc gửi Lua Script liên tục dưới dạng plain text có thể làm tăng băng thông mạng không cần thiết. Hãy sử dụng tính năng SCRIPT LOADEVALSHA của Redis để lưu cache script trên server Redis, sau đó chỉ gọi thực thi thông qua mã SHA1 hash của script đó.
  3. Đồng bộ hóa thời gian: Thuật toán Token Bucket dựa trên timestamp của hệ thống để tính toán lượng token hồi phục. Hãy đảm bảo rằng thời gian trên các instance NestJS và Redis Server được đồng bộ hóa chính xác bằng giao thức NTP (Network Time Protocol).

Kết luận

Xây dựng một hệ thống Rate Limiting đa tầng hiệu quả đòi hỏi sự kết hợp khéo léo giữa tư duy kiến trúc hệ thống phân tán và việc tối ưu hóa mã nguồn ở mức thấp. Bằng việc sử dụng Redis kết hợp với Lua Scripting, chúng ta đã giải quyết triệt để bài toán Race Condition, đồng thời mang lại khả năng mở rộng (scalability) vượt trội cho ứng dụng NestJS.

Để làm chủ hoàn toàn các kỹ thuật thiết kế hệ thống back-end hiện đại, tối ưu hóa cơ sở dữ liệu và xây dựng các API chuẩn Enterprise, việc sở hữu một nền tảng kiến thức bài bản là vô cùng quan trọng. Tham khảo khóa học "RESTful API với NestJS & TypeORM" tại đây để nâng tầm tư duy lập trình và sẵn sàng cho các dự án quy mô lớn.