Files
document-haness/.run/redis/redis-rate-limit-code-walkthrough.md
T

17 KiB

세 가지 Redis Rate Limit Lua를 코드로 추적하기

Redis 코드 상세 시리즈 14/20 · 전체 지도 · 이전: Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL · 다음: Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기

이 글이 답하는 코드 질문

HTTP 요청 하나가 어떤 식별자를 남기고 Redis의 fixed-window, sliding-counter, token-bucket 중 하나를 실행합니까? evaluationIdmaximumClockRegression은 실제 Lua에 전달됩니까? timeout 뒤 결과는 어떻게 표현합니까?

현행 production 경로는 HTTP transport bridge부터 Redis Lua까지 조립됩니다. 그러나 계약에 있는 evaluation deduplication과 clock-regression 설정은 이 adapter가 소비하지 않습니다. 이 차이를 먼저 고정해야 코드를 과대평가하지 않습니다.

먼저 보는 클래스·리소스 지도

코드 입력 출력 다음 호출
RateLimitInterceptor.preHandle HTTP request 통과 또는 typed outcome의 HTTP 응답 EdgeRateLimitTransportBridge.evaluate
EdgeRateLimitTransportBridge.evaluate raw HTTP subject pseudonymous RateLimitRequest EdgeRateLimitPort.evaluate
RedisEdgeRateLimitAdapter.evaluate policy, subject digest, cost, evaluation ID, deadline Evaluated, Unavailable, Incompatible SCRIPT lane과 RateLimitScripts
RateLimitKeys.counterKey policy ID/revision, subject digest physical key Lua KEYS[1]
RateLimitScripts.evaluate policy parameters, cost, caller time {allowed, remaining, resetAfterMillis} SCRIPT LOAD, EVALSHA
RateLimitOutcome evaluation/failure provider-neutral discriminated result web response mapping

객체 조립과 transport pseudonym

ca-skeleton.capabilities.rate-limit.provider=redis이고 Redis 전역 switch가 켜져 있으면 RedisCapabilityConfig.redisEdgeRateLimitPort가 bean을 만듭니다. 설정의 policy map을 RateLimitPolicy로 바꾸고, RateLimitKeys, 세 Lua를 가진 RateLimitScripts, Clock, command timeout, failure retry-after를 주입합니다. redisEdgeRateLimitPort

policy map이 비어 있거나 default policy ID가 map에 없으면 startup이 실패합니다. failure policy는 fail-closed만 허용됩니다. algorithm 문자열은 fixed-window, sliding-counter, token-bucket만 받습니다. policiesOf

HTTP 경계는 principal/API key/client IP와 route operation을 EdgeRateLimitSubject로 만든 뒤 VersionedEdgeSubjectPseudonymizer로 보냅니다. pseudonymizer는 subject kind, canonical identity, operation ID를 UTF-8 byte length로 framing해 HMAC delegate에 전달하고 v<version>:<digest>를 만듭니다. VersionedEdgeSubjectPseudonymizer.pseudonymize

bridge는 server-owned evaluation ID를 새로 만들고 caller deadline을 clock.instant() + budget으로 계산합니다. client가 보낸 Idempotency-Key나 rate-limit evaluation header는 사용하지 않습니다. cost는 HTTP bridge에서 1로 고정됩니다. EdgeRateLimitTransportBridge.evaluate

요청 시 호출 순서

sequenceDiagram
    participant H as HTTP interceptor
    participant B as Transport bridge
    participant A as RedisEdgeRateLimitAdapter
    participant L as RateLimitScripts
    participant R as Redis
    H->>B: evaluate(request)
    B->>B: subject resolve + pseudonym + evaluationId
    B->>A: RateLimitRequest(cost=1, deadline)
    A->>A: policy/cost/deadline 검사
    A->>L: evaluate(key, policy, cost, now)
    L->>R: SCRIPT LOAD (digest miss)
    L->>R: EVALSHA key args
    alt NOSCRIPT
        L->>R: SCRIPT LOAD
        L->>R: EVALSHA 한 번 재시도
    end
    R-->>L: allowed, remaining, resetAfter
    L-->>A: Evaluation
    A-->>B: Evaluated / Unavailable / Incompatible

RedisEdgeRateLimitAdapter는 먼저 policy 존재 여부와 cost <= maximumCost를 검사합니다. 실패하면 Redis를 호출하지 않고 Incompatible(STATE_INCOMPATIBLE)을 반환합니다. caller deadline이 이미 지났으면 Unavailable(ADMISSION_REJECTED)입니다. 이후 SCRIPT lane을 빌리고 policy revision과 subject digest가 포함된 단일 counter key를 Lua에 넘깁니다. policy revision이 바뀌면 이전 counter와 새 counter가 섞이지 않습니다. RateLimitKeys

세 Lua가 읽고 쓰는 상태

fixed-window

FIXED_WINDOWwindowStart를 계산하고 hash field 이름으로 씁니다.

  • HGET key <windowStart>로 현재 소비량을 읽습니다.
  • current + cost > limit이면 mutation 없이 deny합니다.
  • 허용이면 HSET으로 소비량을 쓰고 PEXPIRE key windowMillis*2를 설정합니다.
  • 반환값은 allow flag, 남은 budget, 현재 window 끝까지의 milliseconds입니다.

고정 window 경계가 바뀌면 새 field를 사용하므로 budget이 복구됩니다. key TTL은 매 hit마다 다시 설정되지만 두 window 길이로 제한됩니다.

sliding-counter

SLIDING_COUNTER는 current window와 previous window를 HGET으로 읽습니다. 이전 window 사용량에 남은 비율을 곱하고 math.floor한 뒤 current를 더합니다.

  • estimated consumption에 cost를 더해 limit을 넘으면 deny합니다.
  • 허용이면 current field만 HSET합니다.
  • 두 window 전 field를 HDEL하고 key에 windowMillis*3 TTL을 둡니다.
  • 이 방식은 exact sliding log가 아니므로 decision certainty가 APPROXIMATE_ALGORITHM입니다.

token-bucket

TOKEN_BUCKET는 hash의 tokens, updatedAtHMGET합니다.

  • 상태가 없으면 full capacity와 현재 시각으로 시작합니다.
  • 지난 whole refill period 수만큼 token을 보충합니다.
  • 부족해도 상태와 TTL을 HSET/PEXPIRE한 뒤 deny합니다.
  • 충분하면 cost를 빼고 같은 방식으로 저장합니다.
  • stored timestamp는 whole period만 전진하므로 partial period를 버리지 않습니다.

세 script 모두 caller Clock의 epoch milliseconds를 ARGV로 받으며 Redis TIME은 호출하지 않습니다. 다만 이 사실만으로 clock regression bound가 적용되는 것은 아닙니다.

script 등록과 NOSCRIPT 복구

각 algorithm은 process-local AtomicReference<String>에 SHA digest를 cache합니다. digest가 없으면 SCRIPT LOAD에 해당하는 gateway.loadScript를 먼저 호출하고, 이후 evaluateRegisteredForListEVALSHA를 보냅니다. run

failure message가 NOSCRIPT로 시작할 때만 digest cache를 비우고 load 후 EVALSHA를 한 번 더 보냅니다. NOSCRIPT는 script가 실행되지 않았다는 서버 응답이므로 이 재시도는 ambiguous mutation 재시도와 다릅니다. 그 외 exception은 그대로 올립니다.

정상·거절·ambiguous 분기

정상 reply는 세 값 이상이어야 합니다. 부족하거나 예상하지 못한 type이면 decoder가 IllegalStateException을 던지고 adapter catch-all에서 Unavailable(NO_MUTATION_CONFIRMED)가 됩니다. evaluationOf

정상 evaluation은 RateLimitDecision으로 변환됩니다. allowed이면 retry-after는 0, denied이면 최소 1ms입니다. sliding counter만 approximate이고 나머지는 certain입니다. decisionOf

실패는 모두 fail-closed typed outcome입니다.

  • unknown policy/oversized cost: Incompatible(STATE_INCOMPATIBLE)
  • expired caller deadline: Unavailable(ADMISSION_REJECTED)
  • non-ambiguous RedisOperationException: Unavailable(UNAVAILABLE_BEFORE_SEND)
  • ambiguous metadata, interruption, timeout, 알 수 없는 exception: Unavailable(NO_MUTATION_CONFIRMED)

이 adapter는 RateLimitOutcome.Indeterminate를 반환하지 않습니다. mutation 여부가 불확실해도 UnavailableCategory.NO_MUTATION_CONFIRMED라는 이름을 사용합니다. 따라서 이 category 이름을 “mutation이 없다고 확인됨”으로 해석하면 안 됩니다. 구현 주석은 ambiguous call이 budget을 소비했을 수 있다고 설명합니다. RedisOperationException catch

설정·계약이 있지만 소비되지 않는 두 항목

RateLimitPolicy는 기본적으로 RateLimitEvaluationDedupPolicy.enabledDefaults()를 넣습니다. 기본은 TTL 5초, 최대 256 entries, 논리 stored bytes 65,536입니다. RateLimitEvaluationDedupPolicy.enabledDefaults

그러나 RedisEdgeRateLimitAdapterRateLimitScriptsrequest.evaluationId()policy.evaluationDedupPolicy()를 읽지 않습니다. Lua key와 ARGV에도 evaluation ID가 없습니다. response-loss retry dedupe는 현재 구현되지 않았습니다. live test의 “port de-duplicates repeats” 주석도 현행 production body와 맞지 않는 historical/drift 문구입니다. LiveRedisSemanticPortsTest.request

RateLimitPolicy.maximumClockRegression도 validation되며 bootstrap 설정에서 채워집니다. 하지만 scripts에 전달되지 않습니다. token bucket은 updatedAt > now이면 stored timestamp를 지금으로 낮출 뿐 bound를 비교하거나 CLOCK_UNSAFE를 반환하지 않습니다. RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE는 type에 있으나 adapter에서 생성되지 않습니다.

테스트가 고정하는 계약

현재 한계와 다음 source 순서

  1. evaluation ID 생성과 bounded dedupe policy type은 있지만 Redis state/Lua가 이를 소비하지 않습니다.
  2. maximumClockRegressionCLOCK_UNSAFE도 설정·type만 있고 실행 경로가 소비하지 않습니다.
  3. Lua의 TTL 식은 policy의 cleanupGrace를 사용하지 않습니다. validation에는 포함되지만 script ARGV에는 전달되지 않습니다.
  4. 실패는 fail-closed이지만 ambiguous mutation을 Indeterminate로 분리하지 않습니다.
  5. 이번 작성에서는 real-server topology lane을 재실행하지 않았습니다.

source는 transport bridge → adapter → scripts → adapter test → live semantic test 순으로 읽는 편이 호출 경계를 가장 빨리 드러냅니다.

시리즈에서 이어 읽기