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 중 하나를 실행합니까? evaluationId와 maximumClockRegression은 실제 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_WINDOW는 windowStart를 계산하고 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*3TTL을 둡니다. - 이 방식은 exact sliding log가 아니므로 decision certainty가
APPROXIMATE_ALGORITHM입니다.
token-bucket
TOKEN_BUCKET는 hash의 tokens, updatedAt을 HMGET합니다.
- 상태가 없으면 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를 먼저 호출하고, 이후 evaluateRegisteredForList로 EVALSHA를 보냅니다. 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
그러나 RedisEdgeRateLimitAdapter와 RateLimitScripts는 request.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에서 생성되지 않습니다.
테스트가 고정하는 계약
RedisEdgeRateLimitAdapterTest는 fixed limit, 새 window, sliding approximate 표시, token refill, unreachable fail-closed, unknown policy, oversized cost, deadline과 subject isolation을 in-memory gateway에서 검사합니다.EdgeRateLimitProviderNeutralContractTest는 세 portable algorithm과 bounded pseudonymous request를 고정합니다. dedupe 실행을 검증하지는 않습니다.EdgeRateLimitTransportBridgeTest는 raw subject가 port를 넘지 않고 server-generated evaluation ID와 750ms deadline이 전달됨을 확인합니다.LiveRedisSemanticPortsTest.theRateLimiterEnforcesUnderTheAdvancedAccount는 standalone/cluster lane에서 advanced account로 세 번 허용 후 deny되는 fixed window를 검증하도록 태그되어 있습니다.RedisTopologyContractTest.scriptPathIsAdvancedOnly는 advanced account만EVALSHA를 실행하고EVAL은 누구에게도 열지 않는 ACL 계약을 real server에 묻습니다.
현재 한계와 다음 source 순서
- evaluation ID 생성과 bounded dedupe policy type은 있지만 Redis state/Lua가 이를 소비하지 않습니다.
maximumClockRegression과CLOCK_UNSAFE도 설정·type만 있고 실행 경로가 소비하지 않습니다.- Lua의 TTL 식은 policy의
cleanupGrace를 사용하지 않습니다. validation에는 포함되지만 script ARGV에는 전달되지 않습니다. - 실패는 fail-closed이지만 ambiguous mutation을
Indeterminate로 분리하지 않습니다. - 이번 작성에서는 real-server topology lane을 재실행하지 않았습니다.
source는 transport bridge → adapter → scripts → adapter test → live semantic test 순으로 읽는 편이 호출 경계를 가장 빨리 드러냅니다.