Pratik kodlama

Rate limiter sınıfı: token bucket'tan thread-safe çoklu kullanıcıya ve sliding window log'a

Orta~75 dk

Görev

Bir API'nin önünde duracak, bellek içi bir rate limiter sınıfı yazacaksın. Görev dört seviyede büyüyor: tek bir token bucket, kullanıcı başına ve plana göre limit, çok thread'li kullanım ve sonunda farklı bir algoritma (sliding window log) ile karşılaştırma. Algoritma tablosu ve dağıtık (Redis) sürüm Bölüm 19'da; burada odak, aynı kodu seviye seviye büyütürken tasarımı bozmamak: test edilebilir saat, güvenli eşzamanlılık, bellek temizliği ve ikinci algoritmayı eklerken yapılan refactor.

Rate limiter klasik bir "küçük ama derin" sorudur: ilk seviye 10 dakikada biter, ama "iki thread aynı anda gelirse", "milyon kullanıcıda bellek ne olur" ve "pencere sınırında ne olur" soruları seni gerçekten production kodu gibi düşünmeye zorlar. Pratik formatlı mülakatlarda (aday raporlarına göre OpenAI ve Anthropic'in çok aşamalı görevleri gibi) bu tür bir sınıfın seviye seviye genişletilmesi sık görülen bir kalıp.

Netleştirme soruları

  • Limit tanımı: "dakikada 5" kesin bir pencere mi, yoksa ortalama hız ve kısa patlama (burst) izni mi? (Seviye 1-3: token bucket; Seviye 4: kesin pencere.)
  • Reddedilen istek ne olur: hemen false mı (HTTP 429), bekletme mi? (Hemen false.)
  • Limit herkes için aynı mı? (Hayır, plana göre: Seviye 2.)
  • Zaman kaynağı? (Enjekte edilen bir saat; testler sleep kullanmaz.)
  • Tek process mi, çok instance mı? (Tek process; dağıtık sürüm takip sorusu, bkz. Bölüm 19.)

Seviye 1

Tek bir token bucket: kapasite capacity, her refillInterval'da bir token eklenir, her istek bir token harcar. Yeni kova dolu başlar.

java
TokenBucket bucket = new TokenBucket(new TokenBucket.Limit(3, 1_000), clock.now());
bucket.tryConsume(0)      -> true, true, true   // 3'lük patlama
bucket.tryConsume(0)      -> false
bucket.tryConsume(999)    -> false              // henüz yeni token yok
bucket.tryConsume(1_000)  -> true

İki önemli karar:

  • Lazy refill: Arka planda token ekleyen bir zamanlayıcı yok; istek geldiğinde "son dolumdan beri kaç token birikti" hesaplanır. Hiç istek atmayan kullanıcı sıfır CPU harcar.
  • Tam sayı aritmetiği ve kesir kaybetmemek: 1500 ms sonra 1 token eklenir ama lastRefill 1500'e değil 1000'e ilerler; kalan 500 ms bir sonraki token'a sayılır. Kova dolunca artık süre atılır, yoksa uzun bir sessizlikten sonra kova kapasitesinin üstünde "kredi" birikir. double ile token tutmanın sınırda verdiği hatalar Bölüm 19'da ölçülü olarak anlatıldı.

Seviye 2

Kullanıcı başına ayrı kova ve kullanıcının planına göre limit. Ayrıca bellek: milyonlarca kullanıcının kovası sonsuza kadar map'te kalmamalı.

java
var limiter = new TokenBucketRateLimiter(
        user -> user.startsWith("premium:") ? new TokenBucket.Limit(5, 100) : new TokenBucket.Limit(2, 100),
        clock::now);
limiter.tryAcquire("free:1")     // 2 kez true
limiter.tryAcquire("premium:1")  // 5 kez true
limiter.evictIdle()              // dolu kovaları siler, silinen sayısını döner

Temizlik için kilit soru: hangi durumu silmek bilgi kaybettirmez? Dolu bir kova, yeni oluşturulmuş bir kovayla davranış olarak aynıdır (yeni kova da dolu başlar). Dolayısıyla dolu kovaları silmek tamamen kayıpsızdır; kısmen boş bir kovayı silmek ise kullanıcıya bedava token verir. "Son erişimden beri 10 dakika geçti" gibi zaman tabanlı TTL de çalışır, ama doğruluğunu ayrıca kanıtlaman gerekir; "dolu = silinebilir" kuralı kendini kanıtlar.

Seviye 3

tryAcquire aynı anda birçok thread'den çağrılacak. Gereksinim: frozen bir saatte kapasitesi 100 olan bir kullanıcıya 8 thread'den toplam 16.000 istek gelirse tam olarak 100 tanesi kabul edilmeli; temizlik (evictIdle) trafik sürerken çalışabilmeli.

Üç ayrı problem var:

  1. Map'e ekleme yarışı: İki thread aynı yeni kullanıcı için iki kova yaratmasın. ConcurrentHashMap.computeIfAbsent atomiktir.
  2. Kova güncelleme yarışı: "Oku, hesapla, yaz" üç adımdır; map'in thread-safe olması bunu atomik yapmaz. Kova başına synchronized yeterli ve kilit kullanıcı başına olduğu için farklı kullanıcılar birbirini beklemez.
  3. Temizlik yarışı: Thread A kovayı map'ten okudu, kilidi almadan önce temizlikçi kovayı sildi; A artık map'te olmayan bir kovadan token harcar, bu arada başka bir thread yeni bir dolu kova yaratır. Sonuç: limit aşılır. Çözüm, kovayı bir Slot içine koyup silerken retired = true işaretlemek; kilidi alan thread slot'un emekli olduğunu görürse döngüye dönüp güncel slot'u alır.
java
while (true) {
    Slot<S> slot = slots.computeIfAbsent(userId, id -> new Slot<>(newState(id, now())));
    synchronized (slot) {
        if (slot.retired) continue;          // silinmiş: yenisini al
        return tryAcquire(slot.state, now());
    }
}

Alternatif: Tüm güncellemeyi slots.compute(userId, (id, s) -> ...) içinde yapmak. compute anahtar başına atomiktir, temizlik de computeIfPresent(..., -> null) ile yapılır; retired bayrağına gerek kalmaz. Bedeli: fonksiyon map'in iç bin kilidini tutarken çalışır (aynı bin'e düşen başka kullanıcılar da bekler) ve sonucu lambda dışına taşımak için bir tutucu gerekir. İki çözüm de doğru; mülakatta birini seçip diğerinin trade-off'unu söylemek yeterli. Tamamen kilitsiz bir sürüm (GCRA, tek AtomicLong + CAS) Bölüm 19'da.

Seviye 4

İkinci algoritma: sliding window log. Her kullanıcı için kabul edilen isteklerin zaman damgaları tutulur; pencerenin dışına düşenler atılır; pencerede maxRequests'ten az istek varsa kabul edilir. Sonra iki algoritmayı aynı trafikle karşılaştır.

java
var log = new SlidingWindowLogRateLimiter(3, 10_000, clock::now);   // herhangi 10 sn'de en fazla 3
// t=0: 1 istek, t=4000: 2 istek -> doldu
// t=9999: reddedilir (t=0'daki istek hâlâ (−1, 9999] penceresinde)
// t=10000: kabul (t=0'daki istek pencereden çıktı)

İkinci algoritmayı eklerken kopyala-yapıştır yerine refactor: kullanıcı başına durum, kilitleme ve temizlik mantığı KeyedRateLimiter<S> soyut sınıfına taşınır (Template Method); alt sınıflar sadece üç şeyi söyler: yeni durum nasıl oluşur, istek nasıl değerlendirilir, durum ne zaman "boş" sayılır. Seviye 3'te zorlukla doğru yapılan eşzamanlılık kodu böylece tek yerde kalır.

Karşılaştırma: İkisi de "10 saniyede 5 istek" olarak ayarlandı (token bucket: kapasite 5, 2 saniyede bir token). 10 saniye boyunca her 100 ms'de bir istek:

Token bucketSliding window log
Bu trafikte kabul edilen9 (5 patlama + 2, 4, 6, 8. saniyelerde birer)5
Herhangi bir pencerede üst sınırYaklaşık kapasite + pencere/aralık (burada ~10)Kesin maxRequests
Kullanıcı başına bellekO(1): iki longO(maxRequests): zaman damgaları
İstek başına işO(1)Amortize O(1), en kötü O(maxRequests) temizlik
Ne zaman seçilirOrtalama hızı koruyup kısa patlamaya izin vermek (API kotaları)Kesin sınırın sözleşme olduğu yerler (SMS/OTP gönderimi, şifre deneme)

Bu tablo testlerle doğrulandı (level4_tokenBucketAllowsMoreInSameWindow). Mülakatta "hangisi daha iyi?" sorusunun cevabı "neyi koruduğuna bağlı" olmalı: token bucket downstream'i ortalama hızda tutar ama bir pencerede limitin neredeyse iki katına izin verebilir; log kesindir ama limit 10.000 olunca kullanıcı başına 10.000 zaman damgası tutar. Arada sliding window counter (iki sayaçla yaklaşık pencere) var; bkz. Bölüm 19.

İpuçları

İpucu 1

Saati LongSupplier olarak enjekte et ve testlerde elle ilerlet. System.currentTimeMillis() doğrudan çağrılırsa testler ya sleep ister ya da kararsız (flaky) olur.

İpucu 2

Bellek temizliği için "hangi durum yenisiyle aynıdır?" diye sor: dolu bir token bucket ve boş bir zaman damgası listesi, yeni oluşturulanla davranış olarak aynıdır; onları silmek kayıpsızdır.

İpucu 3

Eşzamanlı temizlikte, silinen durumu tutan thread'in onu kullanmaya devam etmesini engelle: durumu bir slot içine koy, silerken aynı kilit altında retired işaretle, tryAcquire emekli slot görürse yeniden dene. Seviye 4'te bu mantığı ortak bir soyut sınıfa taşı.

Java çözümü

Beş dosya: ortak arayüz, tek kova (Seviye 1), kullanıcı başına durum ve eşzamanlılık iskeleti (Seviye 2-3), token bucket limiter ve sliding window log limiter (Seviye 4). Hepsi Java 21 ile derlenip testlerle (eşzamanlılık testleri birden çok kez) çalıştırıldı.

Dosya: RateLimiter.java

java
/** Common contract for every limiter in this task. */
public interface RateLimiter {
    boolean tryAcquire(String userId);
}

Dosya: TokenBucket.java

java
import java.util.Objects;

/** Level 1: a single token bucket. Not thread-safe; callers synchronize. */
public final class TokenBucket {

    /** {@code capacity} tokens at most, one new token every {@code refillIntervalMillis}. */
    public record Limit(long capacity, long refillIntervalMillis) {
        public Limit {
            if (capacity <= 0 || refillIntervalMillis <= 0) {
                throw new IllegalArgumentException("capacity and refill interval must be positive");
            }
        }
    }

    private final Limit limit;
    private long tokens;
    private long lastRefillMillis;

    public TokenBucket(Limit limit, long nowMillis) {
        this.limit = Objects.requireNonNull(limit);
        this.tokens = limit.capacity();          // a new bucket starts full
        this.lastRefillMillis = nowMillis;
    }

    public boolean tryConsume(long nowMillis) {
        refill(nowMillis);
        if (tokens == 0) {
            return false;
        }
        tokens--;
        return true;
    }

    public boolean isFullAt(long nowMillis) {
        refill(nowMillis);
        return tokens == limit.capacity();
    }

    /** Lazy refill: compute what accumulated since the last refill instead of running a timer. */
    private void refill(long nowMillis) {
        long elapsed = nowMillis - lastRefillMillis;
        if (elapsed <= 0) {
            return;                               // same instant or clock moved back
        }
        long newTokens = elapsed / limit.refillIntervalMillis();
        if (newTokens == 0) {
            return;
        }
        if (newTokens >= limit.capacity() - tokens) {
            tokens = limit.capacity();            // full: drop the leftover time
            lastRefillMillis = nowMillis;
        } else {
            tokens += newTokens;                  // keep the fractional part of the interval
            lastRefillMillis += newTokens * limit.refillIntervalMillis();
        }
    }
}

Dosya: KeyedRateLimiter.java

java
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongSupplier;

/**
 * Levels 2-3: per-user state in a ConcurrentHashMap, one lock per user, safe eviction of idle state.
 * Subclasses only describe the algorithm (Template Method), which is what Level 4 reuses.
 */
public abstract class KeyedRateLimiter<S> implements RateLimiter {

    /** Holder that can be retired by eviction; a thread holding a stale holder must retry. */
    private static final class Slot<S> {
        final S state;
        boolean retired;                          // guarded by the slot's monitor

        Slot(S state) {
            this.state = state;
        }
    }

    private final ConcurrentHashMap<String, Slot<S>> slots = new ConcurrentHashMap<>();
    private final LongSupplier clockMillis;

    protected KeyedRateLimiter(LongSupplier clockMillis) {
        this.clockMillis = Objects.requireNonNull(clockMillis);
    }

    protected abstract S newState(String userId, long nowMillis);

    protected abstract boolean tryAcquire(S state, long nowMillis);

    /** True if dropping the state is indistinguishable from keeping it. */
    protected abstract boolean isIdle(S state, long nowMillis);

    @Override
    public final boolean tryAcquire(String userId) {
        Objects.requireNonNull(userId, "userId");
        while (true) {
            Slot<S> slot = slots.computeIfAbsent(userId, id -> new Slot<>(newState(id, clockMillis.getAsLong())));
            synchronized (slot) {                 // only this user's requests contend here
                if (slot.retired) {
                    continue;                     // evicted between lookup and lock: take the new slot
                }
                return tryAcquire(slot.state, clockMillis.getAsLong());
            }
        }
    }

    /** Removes idle per-user state; call periodically. Returns how many users were evicted. */
    public final int evictIdle() {
        int evicted = 0;
        for (var entry : slots.entrySet()) {
            Slot<S> slot = entry.getValue();
            synchronized (slot) {
                if (!slot.retired && isIdle(slot.state, clockMillis.getAsLong())) {
                    slot.retired = true;
                    slots.remove(entry.getKey(), slot);
                    evicted++;
                }
            }
        }
        return evicted;
    }

    public final int trackedUsers() {
        return slots.size();
    }
}

Dosya: TokenBucketRateLimiter.java

java
import java.util.Objects;
import java.util.function.Function;
import java.util.function.LongSupplier;

/** Levels 2-3: token bucket per user, limits chosen per user (e.g. by plan). */
public final class TokenBucketRateLimiter extends KeyedRateLimiter<TokenBucket> {

    private final Function<String, TokenBucket.Limit> limitPolicy;

    public TokenBucketRateLimiter(Function<String, TokenBucket.Limit> limitPolicy, LongSupplier clockMillis) {
        super(clockMillis);
        this.limitPolicy = Objects.requireNonNull(limitPolicy);
    }

    @Override
    protected TokenBucket newState(String userId, long nowMillis) {
        return new TokenBucket(limitPolicy.apply(userId), nowMillis);
    }

    @Override
    protected boolean tryAcquire(TokenBucket bucket, long nowMillis) {
        return bucket.tryConsume(nowMillis);
    }

    @Override
    protected boolean isIdle(TokenBucket bucket, long nowMillis) {
        return bucket.isFullAt(nowMillis);        // a full bucket behaves exactly like a new one
    }
}

Dosya: SlidingWindowLogRateLimiter.java

java
import java.util.ArrayDeque;
import java.util.function.LongSupplier;

/** Level 4: exact limit of {@code maxRequests} in any sliding window of {@code windowMillis}. */
public final class SlidingWindowLogRateLimiter extends KeyedRateLimiter<ArrayDeque<Long>> {

    private final int maxRequests;
    private final long windowMillis;

    public SlidingWindowLogRateLimiter(int maxRequests, long windowMillis, LongSupplier clockMillis) {
        super(clockMillis);
        if (maxRequests <= 0 || windowMillis <= 0) {
            throw new IllegalArgumentException("maxRequests and windowMillis must be positive");
        }
        this.maxRequests = maxRequests;
        this.windowMillis = windowMillis;
    }

    @Override
    protected ArrayDeque<Long> newState(String userId, long nowMillis) {
        return new ArrayDeque<>(maxRequests);
    }

    @Override
    protected boolean tryAcquire(ArrayDeque<Long> log, long nowMillis) {
        pruneOlderThanWindow(log, nowMillis);
        if (log.size() >= maxRequests) {
            return false;
        }
        log.addLast(nowMillis);                   // only accepted requests are logged
        return true;
    }

    @Override
    protected boolean isIdle(ArrayDeque<Long> log, long nowMillis) {
        pruneOlderThanWindow(log, nowMillis);
        return log.isEmpty();
    }

    /** The window is (now - windowMillis, now]. */
    private void pruneOlderThanWindow(ArrayDeque<Long> log, long nowMillis) {
        while (!log.isEmpty() && log.peekFirst() <= nowMillis - windowMillis) {
            log.pollFirst();
        }
    }
}

evictIdle bir ScheduledExecutorService ile periyodik çağrılır. Dakikada bir çalıştırmak çoğu zaman yeterlidir: bir kullanıcının durumu temizlenene kadar bellekte kalması doğruluğu değil sadece belleği etkiler. ConcurrentHashMap üzerinde iterasyon sırasında silme güvenlidir (iterator zayıf tutarlıdır, ConcurrentModificationException atmaz).

Testler

javac -d out *.java && java -cp out RateLimitersTest ile çalışır; çıktı 8/8 tests passed. Eşzamanlılık testleri bir CountDownLatch ile tüm thread'leri aynı anda başlatır ki yarış olasılığı artsın; saat donuk olduğu için beklenen sonuç kesindir.

Dosya: RateLimitersTest.java

java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

public final class RateLimitersTest {

    /** Controllable clock; tests never sleep. */
    static final class FakeClock {
        final AtomicLong millis = new AtomicLong();
        long now() { return millis.get(); }
        void set(long value) { millis.set(value); }
    }

    public static void main(String[] args) throws Exception {
        Map<String, Callable<Void>> tests = new LinkedHashMap<>();
        tests.put("level1_burstThenRefill", wrap(RateLimitersTest::level1_burstThenRefill));
        tests.put("level1_refillKeepsFractionAndCapsAtCapacity", wrap(RateLimitersTest::level1_refillKeepsFractionAndCapsAtCapacity));
        tests.put("level2_usersAreIsolatedAndPolicyPerUser", wrap(RateLimitersTest::level2_usersAreIsolatedAndPolicyPerUser));
        tests.put("level2_onlyFullBucketsAreEvicted", wrap(RateLimitersTest::level2_onlyFullBucketsAreEvicted));
        tests.put("level3_concurrentRequestsNeverExceedCapacity", RateLimitersTest::level3_concurrentRequestsNeverExceedCapacity);
        tests.put("level3_evictionDuringTrafficKeepsLimit", RateLimitersTest::level3_evictionDuringTrafficKeepsLimit);
        tests.put("level4_slidingLogIsExactAtWindowEdge", wrap(RateLimitersTest::level4_slidingLogIsExactAtWindowEdge));
        tests.put("level4_tokenBucketAllowsMoreInSameWindow", wrap(RateLimitersTest::level4_tokenBucketAllowsMoreInSameWindow));

        int passed = 0;
        for (var test : tests.entrySet()) {
            test.getValue().call();
            System.out.println("PASS " + test.getKey());
            passed++;
        }
        System.out.println(passed + "/" + tests.size() + " tests passed");
    }

    static void level1_burstThenRefill() {
        var clock = new FakeClock();
        var bucket = new TokenBucket(new TokenBucket.Limit(3, 1_000), clock.now());
        check(bucket.tryConsume(0) && bucket.tryConsume(0) && bucket.tryConsume(0), "burst of 3");
        check(!bucket.tryConsume(0), "4th rejected");
        check(!bucket.tryConsume(999), "no token before interval");
        check(bucket.tryConsume(1_000), "one token after interval");
        check(!bucket.tryConsume(1_000), "only one");
    }

    static void level1_refillKeepsFractionAndCapsAtCapacity() {
        var bucket = new TokenBucket(new TokenBucket.Limit(2, 1_000), 0);
        bucket.tryConsume(0);
        bucket.tryConsume(0);
        check(bucket.tryConsume(1_500), "1 token at 1500");
        // the 500 ms remainder is kept, so the next token arrives at 2000, not 2500
        check(bucket.tryConsume(2_000), "token at 2000");
        check(bucket.isFullAt(1_000_000), "long idle -> full, not overflowing");
        check(bucket.tryConsume(1_000_000) && bucket.tryConsume(1_000_000) && !bucket.tryConsume(1_000_000), "cap 2");
    }

    static void level2_usersAreIsolatedAndPolicyPerUser() {
        var clock = new FakeClock();
        var limiter = new TokenBucketRateLimiter(
                user -> user.startsWith("premium:") ? new TokenBucket.Limit(5, 100) : new TokenBucket.Limit(2, 100),
                clock::now);
        check(countAllowed(limiter, "free:1", 10) == 2, "free plan");
        check(countAllowed(limiter, "free:2", 10) == 2, "other user unaffected");
        check(countAllowed(limiter, "premium:1", 10) == 5, "premium plan");
    }

    static void level2_onlyFullBucketsAreEvicted() {
        var clock = new FakeClock();
        var limiter = new TokenBucketRateLimiter(user -> new TokenBucket.Limit(2, 1_000), clock::now);
        limiter.tryAcquire("a");
        limiter.tryAcquire("b");
        limiter.tryAcquire("b");
        check(limiter.evictIdle() == 0, "nobody is full yet");
        clock.set(1_000);   // a: 1+1 = 2 (full), b: 0+1 = 1
        check(limiter.evictIdle() == 1 && limiter.trackedUsers() == 1, "only a evicted");
        check(countAllowed(limiter, "a", 5) == 2, "evicted user starts with a full bucket, same as before");
    }

    static Void level3_concurrentRequestsNeverExceedCapacity() throws Exception {
        var clock = new FakeClock();                // frozen: no refill during the test
        var limiter = new TokenBucketRateLimiter(user -> new TokenBucket.Limit(100, 1_000), clock::now);
        int allowed = hammer(limiter, "hot-user", 8, 2_000, () -> {});
        check(allowed == 100, "exactly capacity, got " + allowed);
        return null;
    }

    static Void level3_evictionDuringTrafficKeepsLimit() throws Exception {
        var clock = new FakeClock();
        var limiter = new TokenBucketRateLimiter(user -> new TokenBucket.Limit(50, 1_000), clock::now);
        // Evicting a full bucket is lossless, so even with constant eviction the total stays 50.
        int allowed = hammer(limiter, "user", 8, 1_000, limiter::evictIdle);
        check(allowed == 50, "limit kept under eviction, got " + allowed);
        return null;
    }

    static void level4_slidingLogIsExactAtWindowEdge() {
        var clock = new FakeClock();
        var limiter = new SlidingWindowLogRateLimiter(3, 10_000, clock::now);
        clock.set(0);
        limiter.tryAcquire("u");
        clock.set(4_000);
        limiter.tryAcquire("u");
        limiter.tryAcquire("u");
        check(!limiter.tryAcquire("u"), "3 in window");
        clock.set(9_999);
        check(!limiter.tryAcquire("u"), "first request still inside (0, 9999]");
        clock.set(10_000);
        check(limiter.tryAcquire("u"), "request at 0 left the window at 10000");
        check(!limiter.tryAcquire("u"), "full again");
    }

    static void level4_tokenBucketAllowsMoreInSameWindow() {
        // Both are configured as "5 requests per 10 seconds"; one request every 100 ms for 10 s.
        var clock = new FakeClock();
        var bucket = new TokenBucketRateLimiter(user -> new TokenBucket.Limit(5, 2_000), clock::now);
        var log = new SlidingWindowLogRateLimiter(5, 10_000, clock::now);
        int bucketAllowed = 0;
        int logAllowed = 0;
        for (long t = 0; t < 10_000; t += 100) {
            clock.set(t);
            if (bucket.tryAcquire("u")) bucketAllowed++;
            if (log.tryAcquire("u")) logAllowed++;
        }
        check(logAllowed == 5, "log is exact: " + logAllowed);
        check(bucketAllowed == 9, "bucket = burst 5 + refills at 2s,4s,6s,8s: " + bucketAllowed);
    }

    // ---------- helpers ----------

    static int countAllowed(RateLimiter limiter, String user, int attempts) {
        int allowed = 0;
        for (int i = 0; i < attempts; i++) {
            if (limiter.tryAcquire(user)) allowed++;
        }
        return allowed;
    }

    /** Runs threads * callsPerThread requests at once while {@code background} runs in a loop. */
    static int hammer(RateLimiter limiter, String user, int threads, int callsPerThread, Runnable background)
            throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(threads + 1);
        try {
            var start = new CountDownLatch(1);
            var done = new AtomicBoolean(false);
            Future<?> backgroundTask = pool.submit(() -> {
                while (!done.get()) background.run();
            });
            List<Future<Integer>> results = new ArrayList<>();
            for (int i = 0; i < threads; i++) {
                results.add(pool.submit(() -> {
                    start.await();
                    return countAllowed(limiter, user, callsPerThread);
                }));
            }
            start.countDown();
            int total = 0;
            for (Future<Integer> f : results) total += f.get();
            done.set(true);
            backgroundTask.get();
            return total;
        } finally {
            pool.shutdownNow();
        }
    }

    static Callable<Void> wrap(Runnable test) {
        return () -> { test.run(); return null; };
    }

    static void check(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError(message);
        }
    }
}

Eşzamanlılık testi geçti diye kodun doğru olduğu kanıtlanmaz; sadece belirli bir hatanın bu koşularda görülmediği gösterilir. Bu yüzden mülakatta testin yanında invariant'ı da söyle: "kova durumu sadece slot kilidi altında okunur ve yazılır; silme de aynı kilit altında ve retired ile yapılır, dolayısıyla silinmiş bir kovadan token harcanamaz." Daha güçlü doğrulama için OpenJDK'nın jcstress aracı kullanılır.

Mülakatçı için

  • Gereksinimi netleştirme: "Dakikada 5" ifadesinin kesin pencere mi ortalama hız mı olduğunu, reddin 429 mu bekleme mi olduğunu ve limitin kullanıcıya göre değişip değişmediğini kod yazmadan soruyor.
  • Seviyeler arası refactor: Seviye 2'de tek kovayı değiştirmeden bir map'e sarıyor; Seviye 4'te ikinci algoritmayı kopyalamak yerine kullanıcı başına durum, kilit ve temizlik mantığını ortak bir soyutlamaya çekiyor ve eski testler değişmeden geçiyor.
  • Test yazma: Enjekte edilen saatle sınır anlarını (999 ve 1000 ms, pencere sınırı 9999 ve 10000) test ediyor; eşzamanlılık için latch ile aynı anda başlayan thread'ler ve kesin bir beklenen değer kullanıyor; Thread.sleep kullanmıyor.
  • Kod kalitesi: Tam sayı aritmetiği kullanıyor, kesirli süreyi kaybetmiyor, constructor'da geçersiz limitleri reddediyor; kilit kapsamını kullanıcı başına tutuyor ve neden map'in thread-safe olmasının yetmediğini açıklıyor.
  • Zaman yönetimi: Seviye 1'i 10-15 dakikada bitirip eşzamanlılığa en büyük zamanı ayırıyor; temizlik yarışını göremezse en azından temizliği kapatıp doğru bir limiter teslim ediyor ve riski söylüyor.

Takip soruları:

  1. Uygulama 10 instance'a ölçeklendi; limit kullanıcı başına global olmalı. Redis'te Lua script ile atomik token bucket mı, yoksa instance başına limit / 10 mu? Redis erişilemezse fail-open mı fail-closed mı (bkz. Bölüm 19)?
  2. Sliding window log'da limit 10.000 ve 1 milyon kullanıcı olursa bellek ne olur? Sliding window counter'a geçmenin doğruluk bedeli nedir?
  3. Saat geri giderse (NTP düzeltmesi) iki algoritma nasıl davranır? System.nanoTime ile currentTimeMillis farkı nedir?

Hızlı tekrar

S: Token bucket'ta lazy refill nedir ve neden kesirli süreyi saklarız?

C: Token'lar istek geldiğinde geçen süreden hesaplanır; zamanlayıcı yoktur. lastRefill sadece eklenen token'ların süresi kadar ilerletilir ki kalan süre bir sonraki token'a sayılsın; kova dolunca artık süre atılır.

S: Hangi rate limiter durumu bellekten kayıpsız silinebilir?

C: Yeni oluşturulanla aynı davranan durum: dolu token bucket ya da pencerede hiç kaydı kalmamış log.

S: ConcurrentHashMap kullanmak kova güncellemesini neden thread-safe yapmaz?

C: Map sadece kendi işlemlerini atomik yapar; kovanın "oku, hesapla, yaz" adımları ayrı işlemlerdir. Kova başına kilit ya da compute gerekir.

S: "10 saniyede 5" için token bucket ve sliding window log arasındaki temel fark?

C: Log herhangi bir 10 saniyede kesin 5'e izin verir, kullanıcı başına O(limit) bellek tutar; token bucket patlama artı yeniden dolumla bir pencerede yaklaşık iki katına izin verebilir ama O(1) bellek kullanır.