Статьи

Базовое ограничение скорости API

Вполне вероятно, что вы разрабатываете какую-либо форму (web / RESTful) API, и в случае, если она является общедоступной (или даже внутренней), вы обычно хотите каким-то образом ограничить ее. То есть ограничить количество запросов, выполненных за определенный период времени, чтобы сэкономить ресурсы и защитить от злоупотреблений.

Вероятно, это может быть достигнуто на уровне веб-сервера / балансировщика нагрузки с некоторыми умными конфигурациями, но обычно вы хотите, чтобы ограничитель скорости был специфичным для клиента (т. Е. Каждый клиент вашего API должен иметь отдельное ограничение скорости), и способ, которым клиент Идентифицируется варьируется. Вероятно, все еще возможно сделать это на балансировщике нагрузки, но я думаю, что имеет смысл иметь это на уровне приложений.

Я буду использовать spring-mvc для примера, но любой веб-фреймворк имеет хороший способ подключить перехватчик.

Итак, вот пример перехватчика Spring-MVC:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Component
public class RateLimitingInterceptor extends HandlerInterceptorAdapter {
 
    private static final Logger logger = LoggerFactory.getLogger(RateLimitingInterceptor.class);
     
    @Value("${rate.limit.enabled}")
    private boolean enabled;
     
    @Value("${rate.limit.hourly.limit}")
    private int hourlyLimit;
 
    private Map<String, Optional<SimpleRateLimiter>> limiters = new ConcurrentHashMap<>();
     
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (!enabled) {
            return true;
        }
        String clientId = request.getHeader("Client-Id");
        // let non-API requests pass
        if (clientId == null) {
            return true;
        }
        SimpleRateLimiter rateLimiter = getRateLimiter(clientId);
        boolean allowRequest = limiter.tryAcquire();
     
        if (!allowRequest) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        }
        response.addHeader("X-RateLimit-Limit", String.valueOf(hourlyLimit));
        return allowRequest;
    }
     
    private SimpleRateLimiter getRateLimiter(String clientId) {
        if (limiters.containsKey(clientId)) {
            return limiters.get(clientId);
        } else {
            synchronized(clientId.intern()) {
                // double-checked locking to avoid multiple-reinitializations
                if (limiters.containsKey(clientId)) {
                    return limiters.get(clientId);
                }
                 
                SimpleRateLimiter rateLimiter = createRateLimiter(clientId);
                 
                limiters.put(clientId, rateLimiter);
                return rateLimiter;
            }
        }
    }
     
    @PreDestroy
    public void destroy() {
        // loop and finalize all limiters
    }
}

Это инициализирует ограничения скорости на клиента по требованию. Кроме того, при запуске вы можете просто просмотреть все зарегистрированные клиенты API и создать ограничение скорости для каждого. В случае, если ограничитель скорости не разрешает больше запросов (tryAcquire () возвращает false), затем возвращает «Слишком много запросов» и прерывает выполнение запроса (возвращает «false» от перехватчика).

Это звучит просто. Но есть несколько уловов. Вы можете спросить, где определен SimpleRateLimiter выше. Мы доберемся до этого, но сначала давайте посмотрим, какие у нас есть варианты для реализации ограничителя скорости.

Наиболее рекомендуемым является guava RateLimiter . Он имеет простой фабричный метод, который дает вам ограничитель скорости для указанной скорости (разрешений в секунду). Однако он не очень хорошо подходит для веб-API, поскольку вы не можете инициализировать RateLimiter с уже существующим количеством разрешений. Это означает, что должен пройти период времени, прежде чем лимитер разрешит запросы. Есть еще одна проблема — если у вас меньше одного разрешения в секунду (например, если желаемое ограничение скорости составляет «200 запросов в час»), вы можете передать дробь (hourlyLimit / secondsInHour), но она все равно не будет работать так, как вы ожидайте этого, поскольку внутри есть поле «maxPermits», которое будет ограничивать количество разрешений намного меньше, чем вы хотите. Кроме того, ограничитель скорости не допускает посылки — у вас есть ровно X разрешений в секунду, но вы не можете распространять их в течение длительного периода времени, например, имеете 5 запросов в секунду, а затем нет запросов в течение следующих нескольких секунд. На самом деле все вышеперечисленное можно решить, но, к сожалению, через скрытые поля, к которым у вас нет доступа. В течение многих лет существует множество запросов к функциям, но Guava просто не обновляет ограничение скорости, что делает его гораздо менее применимым к ограничению скорости API.

Используя отражение, вы можете настроить параметры и заставить работать ограничитель. Тем не менее, это уродливо, и это не гарантирует, что он будет работать, как ожидалось. Я показал здесь, как инициализировать ограничитель скорости гуавы с X разрешениями в час, с возможностью восстановления и полными начальными разрешениями. Когда я подумал, что это tryAcquire() , я увидел, что tryAcquire() имеет synchronized(..) блок synchronized(..) . Означает ли это, что все запросы будут ждать друг друга при простой проверке, разрешено ли сделать запрос? Это было бы ужасно.

Так что на самом деле guava RateLimiter не предназначен для ограничения скорости (веб) API. Может быть, сохранение его в плохом состоянии — это способ Guava отговорить людей от злоупотребления им?

Вот почему я решил реализовать что-то простое сам на основе семафора Java. Вот наивная реализация :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SimpleRateLimiter {
    private Semaphore semaphore;
    private int maxPermits;
    private TimeUnit timePeriod;
    private ScheduledExecutorService scheduler;
 
    public static SimpleRateLimiter create(int permits, TimeUnit timePeriod) {
        SimpleRateLimiter limiter = new SimpleRateLimiter(permits, timePeriod);
        limiter.schedulePermitReplenishment();
        return limiter;
    }
 
    private SimpleRateLimiter(int permits, TimeUnit timePeriod) {
        this.semaphore = new Semaphore(permits);
        this.maxPermits = permits;
        this.timePeriod = timePeriod;
    }
 
    public boolean tryAcquire() {
        return semaphore.tryAcquire();
    }
 
    public void stop() {
        scheduler.shutdownNow();
    }
 
    public void schedulePermitReplenishment() {
        scheduler = Executors.newScheduledThreadPool(1);
        scheduler.schedule(() -> {
            semaphore.release(maxPermits - semaphore.availablePermits());
        }, 1, timePeriod);
 
    }
}

Требуется количество разрешений (разрешенное количество запросов) и период времени. Период времени составляет «1 X», где X может быть секунда / минута / час / день — в зависимости от того, как вы хотите настроить свой лимит — в секунду, в минуту, ежечасно, ежедневно. Каждый 1 X планировщик пополняет полученные разрешения. Нет контроля над пакетами (клиент может потратить все разрешения с быстрой последовательностью запросов), нет функции разогрева, нет постепенного пополнения. В зависимости от того, что вы хотите, это может быть не идеально, но это просто базовый ограничитель скорости, который является поточно-ориентированным и не имеет какой-либо блокировки. Я написал модульный тест, чтобы подтвердить, что ограничитель ведет себя правильно, а также провел тесты производительности для локального приложения, чтобы убедиться, что ограничение соблюдается. Пока, похоже, работает.

Есть ли альтернативы? Ну, да — есть библиотеки, такие как RateLimitJ, которые используют Redis для реализации ограничения скорости. Это будет означать, однако, что вам нужно настроить и запустить Redis. Что кажется чрезмерным для «просто» ограничения скорости.

С другой стороны, как правильно работает ограничение скорости в кластере узлов приложений? Узлам приложений, вероятно, нужна некоторая база данных или протокол сплетен, чтобы делиться данными об оставшихся клиентских разрешениях (запросах)? Не обязательно. Очень простой подход к этой проблеме — предположить, что балансировщик нагрузки распределяет нагрузку поровну между вашими узлами. Таким образом, вам просто нужно установить предел для каждого узла равным общему пределу, деленному на количество узлов. Это не будет точно, но вам это редко нужно — разрешение 5-10 запросов больше не убьет ваше приложение, а 5-10 запросов не будет значительным для пользователей.

Это, однако, будет означать, что вы должны знать количество узлов приложения. Если вы используете автоматическое масштабирование (например, в AWS), количество узлов может меняться в зависимости от нагрузки. В этом случае вместо настройки жестко запрограммированного количества разрешений запланированное задание на пополнение может вычислять «maxPermits» на лету, вызывая API-интерфейс AWS (или другого облачного провайдера) для получения количества узлов в текущая группа автоматического масштабирования. Это все равно будет проще, чем просто поддерживать развертывание Redis.

В целом, я удивлен, что не существует «канонического» способа реализации ограничения скорости (в Java). Возможно, необходимость ограничения скорости не так распространена, как может показаться. Или это реализовано вручную — путем временного запрета клиентов API, которые используют «слишком много ресурсов».