← blog · 30 Eylül 2026

API'de Hız Sınırlama: Jeton Kovası, Doğru Anahtar ve Dağıtık Sayaç Tuzakları

Sabit pencere neden sınırın iki katını geçirir, IP neden kötü bir anahtardır, Redis'te INCR ve EXPIRE neden bir istemciyi sonsuza dek engelleyebilir ve sınır aşıldığında 503 yerine neden 429 ile Retry-After dönülmelidir.

Bir API'yi hız sınırlaması olmadan yayına almak, kapıyı açık bırakıp kimsenin girmeyeceğini ummaya benzer. Kötü niyetli biri gerekmez: sonsuz döngüye giren bir istemci, yanlış yazılmış bir yeniden deneme mantığı ya da tek bir büyük müşterinin toplu içe aktarımı, diğer herkesin isteklerini yavaşlatmaya yeter. Hız sınırlama bu yüzden bir güvenlik özelliğinden çok bir adalet ve dayanıklılık mekanizmasıdır. Sorun şu ki "dakikada 100 istek" cümlesi, uygulamaya geçtiği anda hangi algoritma, hangi anahtar, hangi depolama ve sınır aşıldığında ne döneceği gibi dört ayrı karara bölünür. Bu kararların her birinde yaygın bir hata var.

Algoritma seçimi

Sabit pencere en basitidir: her dakika için bir sayaç tutulur, sayaç sınırı geçince istek reddedilir. Zayıflığı pencere sınırındadır. Sınır dakikada 100 ise bir istemci 00:59'da 100, 01:00'da yine 100 istek atabilir; iki saniye içinde sınırın iki katı geçer. Günlük kota gibi kaba sınırlar için yeterlidir, anlık yükü korumak için değil.

Kayan log her isteğin zaman damgasını saklar ve son 60 saniyedekileri sayar. Kesin sonuç verir ama istemci başına sınır kadar kayıt tutar; yüksek sınırlarda bellek maliyeti hızla büyür.

Kayan pencere sayacı ikisinin arasında bir yaklaşımdır: mevcut ve önceki pencerenin sayaçlarını tutar, önceki pencereyi geçen süre oranında ağırlıklandırır. İki sayıyla neredeyse kayan log kadar düzgün davranır.

Jeton kovası (token bucket) farklı bir soru sorar: istemcinin ne kadar biriktirme hakkı var? Kova belli bir kapasiteye kadar sabit hızla dolar, her istek bir jeton harcar. Böylece iki parametre elde edersiniz: sürekli hız ve izin verilen patlama boyutu. Gerçek istemciler düzgün akmaz; bir sayfa açılışında on istek birden gelir, sonra sessizlik olur. Jeton kovası bu davranışı cezalandırmadan uzun süreli aşırı kullanımı keser. GCRA (Generic Cell Rate Algorithm) aynı davranışı istemci başına tek bir zaman damgasıyla elde eden, depolaması daha hafif bir eşdeğerdir.

Taraf tutmak gerekirse: kullanıcıya dönük API'lerde jeton kovası ya da GCRA ile başlayın. Sabit pencereyi yalnızca faturalamayla bağlantılı günlük ya da aylık kotalarda kullanın; orada "bu ay 10.000 istek" gibi insanın anladığı bir takvim sınırı zaten istenen şeydir.

Neyi sayıyorsunuz?

Algoritmadan daha sık yanlış yapılan karar anahtardır.

IP adresi en kolay anahtardır ve en çok yanıltanıdır. Bir şirket ofisi, bir mobil operatörün taşıyıcı sınıfı NAT'ı ya da bir üniversite yurdu yüzlerce kullanıcıyı tek bir IPv4 adresinin arkasında toplar; IP bazlı sıkı bir sınır bu kullanıcıların hepsini birlikte engeller. IPv6'da ise tersi olur: bir istemci genellikle en az bir /64 önek alır ve her istekte farklı bir adres kullanabilir. IPv6 için tam adresi değil öneki anahtar yapın.

Arkasında bir ters vekil ya da CDN varsa IP'yi doğru okuduğunuzdan emin olun. Bağlantının kaynak adresine bakarsanız tüm trafik vekilin adresinden geliyor görünür ve herkes tek bir kovayı paylaşır. İstemcinin gönderdiği X-Forwarded-For başlığına körü körüne güvenirseniz de saldırgan her istekte farklı bir değer yazarak sınırı tamamen atlar. Başlığı yalnızca güvendiğiniz vekillerden gelen bağlantılarda okuyun ve zincirde güvendiğiniz son atlamayı alın.

Kimliği doğrulanmış isteklerde anahtar kullanıcı ya da API anahtarı olmalıdır. Çok kiracılı bir sistemde bunun üstüne kiracı düzeyinde ikinci bir sınır koyun; aksi halde bir kiracı yüz kullanıcı açarak paylaşılan kaynağın tamamını tüketebilir.

Uç noktalar eşit maliyetli değildir. Bir arama ya da rapor isteği, basit bir okuma isteğinin yüz katı iş yaptırabilir. Jeton kovasının güzel yanı istek başına farklı sayıda jeton harcatabilmenizdir; pahalı uç noktalara daha yüksek maliyet verin.

Giriş denemeleri ayrı bir problemdir

Parola denemesini sınırlarken iki farklı saldırıya karşı iki ayrı sayaç gerekir: tek bir hesaba çok sayıda parola denenmesi (hesap başına sayaç) ve çok sayıda hesaba az sayıda yaygın parola denenmesi (IP ya da önek başına sayaç). Yalnızca birini koyarsanız diğer saldırı rahatça geçer.

Hesap başına sayacın bir yan etkisi var: saldırgan bilerek yanlış parola girip gerçek kullanıcıyı dışarıda bırakabilir. Hesabı kalıcı olarak kilitlemek yerine kısa süreli ve giderek uzayan bir bekleme uygulayın, başarılı girişte sayacı sıfırlayın. Hata mesajında hesabın var olup olmadığını ele vermeyin; sınır aşıldığında da aynı genel cevabı dönün.

Birden fazla sunucu olduğunda

Sayaç her uygulama örneğinin belleğinde durursa, dört kopya çalışan bir serviste gerçek sınır yazdığınızın dört katıdır ve yük dengeleyicinin dağılımına göre dalgalanır. Paylaşılan sayaç için en yaygın tercih Redis'tir ve burada iki klasik hata yapılır.

Birincisi oku ve yaz arasındaki yarıştır. Değeri okuyup uygulamada karar verip sonra yazarsanız, eşzamanlı iki istek aynı eski değeri görür ve ikisi de geçer. İkincisi sabit pencerede sık görülen INCR ardından EXPIRE kalıbıdır: süreç iki komut arasında ölürse anahtarın süresi hiç atanmaz ve o istemci kalıcı olarak engellenir. Her iki sorunun çözümü de mantığı tek bir atomik Lua betiğine taşımaktır.

Aşağıdaki betik bir jeton kovası uygular. Zamanı uygulama sunucusundan değil Redis'in kendi saatinden alır; böylece sunucular arasındaki saat kayması kovanın dolum hızını bozmaz. Betikte TIME ardından yazma komutu kullanmak, betiklerin etkileriyle çoğaltıldığı Redis 7 ve sonrasında sorunsuzdur.

-- KEYS[1]: kova anahtarı
-- ARGV[1]: kapasite, ARGV[2]: saniyede dolum, ARGV[3]: bu isteğin maliyeti
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])

local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000

local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now

tokens = math.min(capacity, tokens + (now - ts) * rate)

local allowed = 0
local wait = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  wait = (cost - tokens) / rate
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)
return {allowed, tostring(wait)}

Bekleme süresinin metin olarak dönmesi bilinçlidir: Redis, Lua'dan dönen ondalık sayıları tam sayıya keser. Süre sonu, kova tamamen dolacak kadar uzun tutulur; o noktada anahtarın yokluğu ile dolu kova aynı anlama gelir ve boşta kalan istemciler bellek tüketmez. Maliyeti kapasiteden büyük bir istek hiçbir zaman geçemez; bunu yapılandırma aşamasında doğrulayın.

Redis'e ulaşılamadığında ne olacağına önceden karar verin. Genel API trafiğinde sınırlayıcının açık bırakılması (isteği geçirmek) genellikle doğrudur; hız sınırlayıcının arızası tüm servisi düşürmemelidir. Giriş denemesi ve parola sıfırlama gibi uç noktalarda ise kapalı kalmak daha güvenlidir. İki davranışı da ayrı ayrı yapılandırılabilir tutun ve her iki durumda da metrik üretin.

Sınır aşıldığında ne dönülmeli

Doğru durum kodu 429'dur ve yanında Retry-After başlığı olmalıdır. Bu başlık HTTP standardında tanımlıdır ve istemcinin güvenebileceği tek kararlı sinyaldir. IETF'te kalan kotayı bildiren RateLimit başlıkları için bir taslak çalışması var, ancak başlık adları taslak sürümleri arasında değişti; bunları kullanacaksanız bilgilendirme amaçlı sunun, istemcinin mantığını onlara bağlamayın.

Tuzak: nginx'in limit_req modülü varsayılan olarak 503 döner. Bunu değiştirmezseniz istemciler sınır aşımını sunucu arızası sanar, izleme sisteminiz de sahte bir kesinti alarmı üretir.

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api burst=20 nodelay;
        limit_req_status 429;
    }
}

Buradaki burst jeton kovasındaki kapasitenin karşılığıdır. nodelay olmadan nginx patlama içindeki istekleri reddetmek yerine sıraya koyup geciktirir; bu da istemci tarafında açıklanamayan gecikmeler olarak görünür.

İstemci tarafı da işin yarısıdır. 429 alan istemci Retry-After süresine uymalı, başlık yoksa rastgele sapma eklenmiş üstel geri çekilme kullanmalıdır. Sapma eklenmezse sınıra aynı anda takılan binlerce istemci aynı anda yeniden dener ve sınırlayıcıya her seferinde aynı dalga çarpar.

Katmanlar ve ne zaman yapmamalı

Tek bir yerde sınır koymak yetmez. Uçta, vekil ya da CDN seviyesinde kaba ve IP bazlı bir sınır hacimli kötüye kullanımı uygulamaya ulaşmadan keser. Uygulama içinde kimliğe ve kiracıya bağlı, maliyet ağırlıklı sınır adaleti sağlar. İkisi farklı sorunları çözer.

Hız sınırlamanın yanlış araç olduğu durumlar da var. Arka uçtaki bir veritabanının kapasitesini korumak istiyorsanız istek hızını değil eşzamanlılığı sınırlayın; saniyede 10 hızlı istek sorun değildir, aynı anda açık 10 yavaş sorgu sorundur. Kendi servisleriniz arasındaki iç trafikte sert red vermek yerine kuyruk ve geri basınç kullanın; iç çağıranı 429 ile reddetmek genellikle sorunu bir üst katmana yeniden deneme fırtınası olarak taşır. Hız sınırı da bir kapasite planlaması yerine geçmez: normal trafiğiniz sınıra yaklaşıyorsa sınırı değil altyapıyı büyütmeniz gerekiyordur.

Son pratik öneri: sınırı devreye almadan önce bir süre yalnızca kayıt tutan modda çalıştırın. Hangi istemcilerin sınıra takılacağını gerçek trafikle görmeden eşik belirlemek, en iyi müşterinizi ilk gün engellemenin en kısa yoludur.