Chia sẻ kiến thức lập trình, kĩ năng mềm từ góc nhìn của một Engineer

How to build Rate Limit with Hazelcast and Spring Boot

Đây là một thiết kế nâng cao và rất thường xuyên gặp trong những bài toán thực tế, mục đích chính là để giới hạn truy cập dữ liệu tới một nguồn tài nguyên cụ thể trong một khoảng thời gian nhất định, sẵn tiện đang đi series Hazelcast thì mình sẽ hướng dẫn mọi người cách dựng một khung Rate Limit sử dụng Hazelcast luôn.

Một số usecase cơ bản thường sử dụng

Có một vài bài toán thực tế hay gặp như sau:

  • Giới hạn lượt request OTP để xác minh số điện thoại/email: giả sử chỉ được request 5 mã otp trong vòng 24h và mỗi lượt lấy mã OTP cách nhau tối thiểu 5 phút chẳng hạn
  • Hạn chế lượt truy cập theo địa chỉ IP, theo số điện thoại, theo tài khoản nhằm mục mục đích hạn chế người dùng truy cập quá nhanh, quá nhiều, có thể ngoài mục đích truy cập thông thường thì người dùng còn có thể làm các mục đích khác như: crawl dữ liệu, Ddos.
  • Tạo các chương trình hạn ngạch truy cập giống kiểu các API của Google, Youtube: dùng free thì chỉ sử dụng được một ngưỡng nhất định, sau khi qua ngưỡng thì không truy cập được và bị tính phí.

Bài toán thực tế trong việc giới hạn ngưỡng

Chẳng hạn như dự án ngày trước ở công ty cũ mình làm thì bài toán của nó như thế này:

Công ty mình cung cấp dịch vụ gửi tin nhắn sms otp/sms marketing thông qua đầu số truyền thống, nó là kiểu bạn sẽ nhận được các mã otp hoặc những tin quảng cáo nhưng thay vì nhận từ một brand name của nhãn hàng thì các bạn sẽ nhận qua một số điện thoại lạ 10 số.

Có rất nhiều đối tác cùng tích hợp vào hệ thống bên mình để gửi tin nhắn đến các khách hàng của họ. Tuy nhiên hệ thống Backend gửi tin nhắn ở dưới để có thể đáp ứng được tất cả đối tác cùng truy cập thì sẽ còn phải phụ thuộc vào sản lượng tin nhắn từ các đối tác trong từng thời điểm cũng như đội vận hành chuẩn bị sẵn sàng khâu sim số (do gửi từ sim rác).

Vậy nên việc giới hạn truy cập của các đối tác là điều thực sự cần thiết, hệ thống có thể đáp ứng được TPS là bao nhiêu thì cấu hình riêng cho từng đối tác một tham số TPS riêng, vượt quá ngưỡng thì ignore request, tin nhắn nào đến consume luôn tin đó, để luôn luôn đảm bảo hệ thống ở dưới đáp ứng được việc gửi tin nhắn một cách mượt mà nhất.

Thường thì những tin nhắn dạng OTP thì thời gian countdown khá là nhanh, chỉ nằm trong khoảng dưới 1 phút hoặc 2 phút là khách hàng phải nhận được để xác thực.

Để xử lý được bài toán khách hàng luôn luôn nhận được tin nhắn với thời gian nhanh nhất cũng như chi phí gửi tin là thấp nhất thì nó sẽ còn liên quan đến nhiều thứ khác như là : thiết lập cụm máy backup, cụm máy resend, rồi thiết lập chi phí sim sủng của các nhà mạng (Viettel, Mobile, Vina, Vietnammobile,..) để đạt được hiệu quả kinh doanh tốt cũng như đạt được cam kết SLA tốt với các đối tác.

Ở đây mình không bàn sâu như vậy mà chỉ tập trung vào việc hạn chế truy cập.

Mô phỏng bài toán

Trước khi đi vào tìm hiểu chi tiết thì mình sẽ nói qua về bài toán và cách xử lý, chúng ta sẽ mô phỏng lại việc Server A gửi request tới Server B, Server B chỉ cho phép Server A gửi 5 lượt request trong vòng 1 phút, hết 1 phút thì bên A được request tiếp sang bên B.

Dựng cấu trúc dự án, giải thích luồng xử lý

Nếu các bạn đã từng theo dõi bài trước Hazelcast Distributed Cache with Spring Boot thì có thể làm tương tự cấu trúc đó, mình có dùng lại nhiều phần.

Cấu trúc dự án
package com.cafeincode.hazelcast.config;

import com.cafeincode.hazelcast.model.ConfigRateLimit;
import com.hazelcast.client.HazelcastClient;
import com.hazelcast.client.config.ClientConfig;
import com.hazelcast.core.HazelcastInstance;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author hungtv27
 */
@Configuration
public class HazelcastConfig {

    @Value("#{'${hazelcast.cafeincode.address}'.split(',')}")
    protected String[] address;

    @Value("${hazelcast.cafeincode.cluster_name}")
    private String clusterName;

    @Bean(name = "hazelcastClient")
    public HazelcastInstance hazelcastInstance() {
        ClientConfig clientConfig = new ClientConfig();
        clientConfig.setClusterName(clusterName);
        clientConfig.getNetworkConfig().addAddress(address);
        clientConfig.getNetworkConfig().setConnectionTimeout(50000);
        return HazelcastClient.newHazelcastClient(clientConfig);
    }

    @Bean(name = "configRateLimit")
    public ConfigRateLimit configRateLimit() {
        ConfigRateLimit configDefault = new ConfigRateLimit();
        configDefault.setLimit(5); // limit request 5
        configDefault.setSecond(60); //second
        return configDefault;
    }
}

Ở đây chúng ta khởi tạo HazelcastClient, và tạo thêm một Bean configRateLimit để lưu trữ thông tin cấu hình (max request là 5, và khoảng thời gian hiệu lực là 60s, mình để 60s cho dễ test).

package com.cafeincode.hazelcast.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
public class CardDto implements Serializable {
    private String cardNumber;
    private String serial;
    private String expireDate;
    private String cardTelco;
}
package com.cafeincode.hazelcast.model;

import lombok.Data;

@Data
public class ConfigRateLimit {

    private long limit;
    private long second;
}
package com.cafeincode.hazelcast.service;

import com.cafeincode.hazelcast.model.CardDto;
import net.bytebuddy.utility.RandomString;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.List;
import java.util.Random;

@Service(value = "mockService")
public class MockService {

    public List<CardDto> mockCards() {
        CardDto cardOne = generateCardInfo(new Random().nextInt(6));
        CardDto cardTwo = generateCardInfo(new Random().nextInt(7));
        CardDto cardThree = generateCardInfo(new Random().nextInt(8));
        CardDto cardFour = generateCardInfo(new Random().nextInt(9));
        CardDto cardFive = generateCardInfo(new Random().nextInt(10));
        return Arrays.asList(cardOne, cardTwo, cardThree, cardFour, cardFive);
    }

    private CardDto generateCardInfo(int size) {
        return CardDto.builder().cardNumber(RandomString.make(size + 5)).serial(RandomString.make(size + 5)).expireDate("2705-05-27").cardTelco("VTE").build();
    }
}

MockService dùng để tạo ra dữ liệu mock, trả về cho client thông tin thẻ cào random, đoạn này các bạn tự biên tự diễn kiểu gì cũng được.

hazelcast.cafeincode.address=localhost:5701
hazelcast.cafeincode.cluster_name=dev
server.port=8888

Trong file application.properties khai báo kết nối tới hazelcast và cluster.

version: "3.8"
services:
  hazelcast:
    container_name: cafeincode-hazelcast
    image: hazelcast/hazelcast:5.2.1
    ports:
    - "5701:5701"
  management-center:
    container_name: cafeincode-hazelcast-management
    image: hazelcast/management-center:5.2.1
    ports:
    - "8080:8080"
    environment:
    - MC_DEFAULT_CLUSTER=dev
    - MC_DEFAULT_CLUSTER_MEMBERS=hazelcast

Trong file docker-compose các bạn khai báo hazelcast server và hazelcast management để vào xem trang quản trị.

Cuối cùng đến phần xử lý logic chính ở trong HzRestController , bình thường thì chúng ta không nên viết xử lý logic ở phần Controller mà chỉ dùng Controller để điều hướng thôi, nhưng mà mình viết ở đây cho nhanh :D

package com.cafeincode.hazelcast.controller;

import com.cafeincode.hazelcast.model.CardDto;
import com.cafeincode.hazelcast.model.ConfigRateLimit;
import com.cafeincode.hazelcast.service.MockService;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.cp.IAtomicLong;
import com.hazelcast.map.IMap;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * @author hungtv27
 * @date 28/02/2023
 * @source cafeincode.com
 */

@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("/cards")
public class HzRestController {

    private final HazelcastInstance hazelcastClient;
    private final ConfigRateLimit configRateLimit;
    private final MockService mockService;

    private static final String MAPS_USER = "USERS";
    private static final String AUTHENTICATED_USER = "cafeincode-user";


    @GetMapping
    public ResponseEntity<List<CardDto>> getListCards(@RequestHeader Map<String, String> headers) {

        String userRequest = getUserInfoFromHeader(headers);   //get user info from header -> for test
        IMap<String, Long> hzMapUser = hazelcastClient.getMap(MAPS_USER);   //get map config from hazelcast
        if (isAccessResource(userRequest, hzMapUser)) {   //check access resource
            savedCounterRequestOfUser(userRequest, incrementAndGetCounterOfUser(userRequest), configRateLimit.getSecond(), hzMapUser);
            log.info("User:[{}] has access resource, currentCounter={}", userRequest, getCurrentCounter(userRequest, hzMapUser));
            return ResponseEntity.ok(mockService.mockCards());
        }
        log.info("User:[{}] was rejected because too many request in [{}] s", userRequest, configRateLimit.getSecond());
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
    }

    private boolean isAccessResource(String userRequest, IMap<String, Long> mapHz) {
        long limit = configRateLimit.getLimit();
        long currentCounter = getCurrentCounter(userRequest, mapHz);
        return currentCounter < limit;
    }

    private long getCurrentCounter(String userRequest, IMap<String, Long> mapHz) {
        if (Objects.isNull(mapHz.get(userRequest))) {
            resetCounter(userRequest);
            return 0L;
        }
        return mapHz.get(userRequest);
    }

    private long incrementAndGetCounterOfUser(String userRequest) {
        IAtomicLong counter = hazelcastClient.getCPSubsystem().getAtomicLong(userRequest);
        return counter.incrementAndGet();
    }

    private void savedCounterRequestOfUser(String userRequest, long counter, long secondExpired, IMap<String, Long> mapHz) {
        mapHz.put(userRequest, counter, secondExpired, TimeUnit.SECONDS);
    }

    private void resetCounter(String userRequest) {
        IAtomicLong counter = hazelcastClient.getCPSubsystem().getAtomicLong(userRequest);
        counter.set(0);
        log.info("Reset counter of user: [{}] successfully counter:[{}]", userRequest, counter.get());
    }

    private String getUserInfoFromHeader(Map<String, String> headers) {
        return headers.get(AUTHENTICATED_USER);
    }
}

Bản chất về mặt logic xử lý sẽ như thế này:

  • Lấy thông tin người dùng từ header, ở đây mình chỉ mô phỏng thôi chứ bình thường thì người ta không để thông tin người dùng ở đó mà để trong Token.
  • Khi người dùng gửi request tới server thì kiểm tra trong Imap Hazelcast số lượng request người dùng đã truy cập tới hệ thống
  • Kiểm tra logic xem người dùng đã request quá hạn (5 lần) trong khoảng thời gian cấu hình (60 giây) hay chưa
  • Nếu chưa vượt quá ngưỡng và trong ngưỡng thời gian 60s/5 request thì cho phép truy cập tài nguyên, tăng counter
  • Nếu vượt quá ngưỡng giới hạn trên 5 lần và trong ngưỡng thời gian 60s thì Reject truy cập
  • Nếu qua ngưỡng thời gian 60s thì reset lại biến counter của người dùng về 0, quá trình sẽ được lặp đi lặp lại

Chạy Demo

Để chạy được dự án thì các bạn làm lần lượt theo các bước sau:

  1. Mở terminal lên và chạy lệnh docker-compose up để chạy file docker-compose.yml, mục đích để khởi tạo hazelcast server thông qua Docker
  2. Sau đó start ứng dụng lên
Running Hazelcast

Truy cập http://localhost:8080 để vào trang quản trị Hazelcast Management

Hazelcast Management
Start Application

Mở Postman lên và test, run 6 request cùng lúc và xem console log:

Lượt 1
Rejected request

Ở đây ta thấy, khi request tới lần thứ 6 thì đã bị Reject do vượt quá ngưỡng 5 lần request và đang ở trong thời gian là 60s

Sau đây, khi hết thời gian 60s, chúng ta chạy tiếp 6 lượt nữa và xem kết quả

Lượt 2

Ở lượt thứ 2 này chúng ta thấy, sau khi hết thời gian 60s của khóa đối với tài khoản hungtv27 thì lúc này biến counter được reset về 0 để tiếp tục một chu trình mới.

Phần cấu hình Rate Limit thì tùy vào nhu cầu mà các bạn muốn tùy biến như nào cũng được, ở đây mình demo nên dùng giá trị nhỏ cho dễ làm, các bạn có thể truy cập ở đây để xem source code chi tiết.

Chúc các bạn nghiên cứu, học tập và làm việc với Hazelcast hiệu quả.

Xem thêm các bài hướng dẫn liên quan dưới đây:

1 Comment

  1. Destiny

    Hello, thanks for the information

© 2024 Cafeincode

Theme by Anders NorenUp ↑