22 KiB
Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기
Redis 코드 상세 시리즈 15/20 · 전체 지도 · 이전: 세 가지 Redis Rate Limit Lua를 코드로 추적하기 · 다음: Redis Idempotency V2 상태 머신: Claim에서 Replay까지
이 글이 답하는 코드 질문
Redis lease가 같은 resource의 중복 작업을 어떻게 줄이며, 왜 domain invariant를 보호하는 lock으로 사용할 수 없습니까? acquire reply가 사라지거나 renew가 timeout일 때 handle state는 어떻게 바뀝니까? LeaseRequest.waitTimeout은 실제로 기다리는 데 쓰입니까?
현행 구현의 이름 그대로 이 capability는 EFFICIENCY_ONLY입니다. owner 확인은 제공하지만 fencing token이 없습니다. tryAcquire는 한 번만 Redis에 보내며 wait loop도 없습니다. 더 직접적인 현재 위험도 있습니다. same-attempt replay가 받은 Redis PTTL을 버리고 요청 TTL 전체로 local validity를 다시 만들기 때문에, replay handle은 실제 lease보다 오래 ACTIVE라고 판단할 수 있습니다.
먼저 보는 클래스 지도
| 코드 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
DistributedLeasePort |
operation ID, lease request, inspection request | attempt, acquire/inspect outcome | Redis adapter |
LeaseRequest |
purpose, resource digest, wait timeout, TTL, attempt | bounded request | tryAcquire |
RedisDistributedLeaseAdapter.tryAcquire |
request | acquired/replayed/contended/conflict/indeterminate | acquire Lua |
LeaseScripts |
key, ownerToken:operationId, TTL |
status, PTTL, holder | SCRIPT LOAD, EVALSHA |
RedisLeaseHandle |
confirmed ownership | local validity와 ACTIVE/LOST/RELEASED/UNKNOWN | renew/release Lua |
LeaseWatchdog |
handle, TTL, cadence, deadline, callbacks | bounded renewal registration | handle.renew |
production 조립
ca-skeleton.capabilities.lease.provider=redis이고 app.redis.enabled=true일 때 RedisCapabilityConfig.redisDistributedLeasePort가 DistributedLeasePort bean을 만듭니다. 공통 namespace와 key version, LeaseScripts, wall clock, System::nanoTime, command timeout, contention retry-after, drift budget을 adapter에 전달합니다. redisDistributedLeasePort
기본값은 command timeout 200ms, contention retry-after 50ms, drift budget 10ms입니다. RedisCapabilitySettings.Lease
resource의 raw ID는 port contract가 허용하지 않습니다. resourceDigest는 versioned lowercase SHA-256 형태로 validation되고 Redis key는 namespace/capability lease/key version/purpose/digest 아래에 생깁니다. LeaseKeys.leaseKey
attempt를 send 전에 만드는 이유
caller는 첫 provider call 전에 newAttempt(operationId)를 호출합니다. adapter는 SecureRandom 24바이트를 Base64URL without padding으로 바꿔 owner token을 만들고 caller operation ID와 묶습니다. newAttempt
Redis value는 ownerToken:operationId입니다. 같은 attempt를 유지하면 reply-loss 뒤 재호출을 새 acquisition과 구분할 수 있습니다. 같은 owner라도 operation ID가 다르면 이전 작업의 lease를 새 작업이 상속하지 못합니다.
acquire 호출 순서와 상태
sequenceDiagram
participant C as Caller
participant A as RedisDistributedLeaseAdapter
participant L as LeaseScripts
participant R as Redis
C->>A: newAttempt(operationId)
A-->>C: ownerToken + operationId
C->>A: tryAcquire(request)
A->>A: startedAt = nanoTime
A->>L: acquire(key, ownership, ttl)
L->>R: SCRIPT LOAD / EVALSHA
R->>R: GET; SET PX if absent; PTTL
alt status 1
A-->>C: Acquired(handle)
else status 2
R-->>A: current PTTL
A-->>C: ReplayedSameOperation(handle=request TTL - drift)
Note over A,C: reply PTTL은 handle 생성에 쓰이지 않음
else same owner, other operation
A-->>C: OwnerOperationConflict
else other holder
A-->>C: Contended(retryAfter)
else reply uncertain
A-->>C: Indeterminate(operationId)
end
acquire Lua는 GET 후 값이 없으면 SET key ownership PX ttl을 같은 server execution에서 실행하고 status 1을 반환합니다. 같은 ownership이면 TTL을 연장하지 않고 status 2와 현재 PTTL을 반환합니다. 다른 holder면 status 0입니다. ACQUIRE
status 2에서 server가 반환한 남은 시간과 adapter가 만든 handle의 시간이 다릅니다. tryAcquire는 status 1과 2에 모두 같은 handle(request, ownership, startedAt)을 호출하고, 이 helper는 reply의 remainingMillis를 받지 않습니다. replay Lua는 TTL을 갱신하지 않았는데 새 handle은 다시 request.leaseTtl() - driftBudget을 부여받습니다. tryAcquire의 replay mapping, handle
가령 30초 lease를 얻고 29초 뒤 같은 attempt로 다시 호출하면 Redis에는 약 1초가 남아 있어도 replay handle은 약 30초 - drift를 유효하다고 봅니다. 그 사이 key가 만료되어 새 owner가 획득해도 이전 replay handle은 local budget만으로 ACTIVE를 반환할 수 있습니다. 이는 fencing 부재를 논하기 전부터 handle의 local-validity 판단이 server lease와 어긋나는 경로입니다.
tryAcquire는 SCRIPT lane에서 이를 한 번 호출합니다. status 1은 Acquired, 2는 ReplayedSameOperation입니다. 다른 holder value가 같은 owner token prefix를 가지면 OwnerOperationConflict, 아니면 Contended입니다. Redis PTTL이 양수면 그대로 retry-after를 쓰고 아니면 configured 50ms fallback을 씁니다. contendedOrConflicting
interruption을 포함한 모든 exception은 Indeterminate(operationId)입니다. request가 Redis에 도달했는지 adapter가 구분하지 않기 때문에 definite unavailable을 만들지 않습니다. caller는 같은 attempt로 inspect해야 합니다.
waitTimeout은 소비되지 않습니다
LeaseRequest는 0 이상 bounded waitTimeout을 받습니다. 그러나 RedisDistributedLeaseAdapter.tryAcquire는 request.waitTimeout()을 읽지 않습니다. sleep, poll, retry loop도 없습니다. 따라서 현재 의미는 “try once”이며 Contended.retryAfter는 caller가 바깥에서 재시도 정책을 만들 때 쓸 정보입니다.
waitTimeout 필드가 존재한다고 해서 adapter가 그 시간 동안 기다린다고 설명하면 잘못입니다. 테스트도 모두 Duration.ZERO로 adapter를 호출합니다. RedisDistributedLeaseAdapterTest.request
local validity는 server PTTL이 아닙니다
status 1로 새 lease를 만든 acquisition handle의 grantedValidity는 leaseTtl - driftBudget입니다. 이 계산은 status 2 replay에도 그대로 재사용되지만, replay에는 새 TTL이 부여되지 않았으므로 안전한 근거가 아닙니다. localValidityOf
TTL이 drift budget 이하이면 localValidityOf가 IllegalArgumentException을 던집니다. 이는 Redis 호출 전 validation이 아닙니다. acquire 또는 renew script가 성공한 뒤 local budget을 만들 때 발생하고 enclosing catch가 Indeterminate로 바꾸므로, server mutation은 이미 적용됐을 수 있습니다.
budget 기준점은 reply 수신 시각이 아니라 send 직전 startedAt = nanoTime입니다. round trip에 걸린 시간까지 차감하는 보수적 계산입니다. remainingValidity는 monotonic elapsed를 빼고 0 아래로 내리지 않습니다. ACTIVE handle의 remaining이 0이면 state()는 서버 조회 없이 LOST를 반환합니다. remainingValidity와 state
observedServerExpiry라는 이름과 달리 adapter는 acquire reply의 PTTL을 handle에 넣지 않습니다. acquiredAt + grantedValidity를 반환합니다. status 1에서는 요청 TTL과 drift budget으로 계산한 local 진단값이고, status 2에서는 오래된 lease의 현재 PTTL과 무관한 값입니다.
renew와 release의 owner check
renew Lua는 GET한 값이 없으면 0, ownership이 다르면 -1, 같으면 PEXPIRE 후 1을 반환합니다. release Lua도 같은 비교를 거쳐 owner일 때만 DEL합니다. check와 mutation은 각 Lua 안에서 원자적으로 실행됩니다. RENEW와 RELEASE
stateDiagram-v2
state "ACTIVE field" as ACTIVE
state "state() returns LOST<br/>field remains ACTIVE" as LOCAL_EXPIRED
state "LOST field" as LOST
[*] --> ACTIVE: acquire/replay handle
ACTIVE --> ACTIVE: renew status 1
ACTIVE --> LOCAL_EXPIRED: local budget 0
LOCAL_EXPIRED --> ACTIVE: renew status 1, budget reset
ACTIVE --> LOST: renew absent/not owner
ACTIVE --> RELEASED: release/release already absent
ACTIVE --> UNKNOWN: renew/release indeterminate
UNKNOWN --> UNKNOWN: renew status 1, field는 복구되지 않음
LOST --> LOST: renew status 1, field는 복구되지 않음
RELEASED --> RELEASED: renew status 1, field는 복구되지 않음
이 그림에서 LOCAL_EXPIRED는 LeaseState field가 아니라 state()의 계산 결과입니다. state()는 ACTIVE field와 0인 budget을 보고 LOST를 반환할 뿐 field를 바꾸지 않습니다. renew에는 현재 state나 remaining-validity precondition이 없어 local expiry 뒤에도 script를 보냅니다. server key가 아직 같은 ownership이면 성공해 budget을 교체하고 다시 ACTIVE로 보일 수 있습니다. state, renew
renew 성공은 local budget을 새 TTL minus drift로 교체하지만 state = ACTIVE를 쓰지 않습니다. 따라서 field가 이미 UNKNOWN, LOST, RELEASED인 handle도 renew 호출 자체는 가능하고, Redis가 status 1을 반환하면 outcome은 Renewed이면서 state()는 기존 field를 계속 반환할 수 있습니다. absent/not owner는 field를 LOST로, exception은 UNKNOWN으로 바꾸며 ambiguous renew에서는 budget을 연장하지 않습니다. LOST/UNKNOWN/RELEASED를 terminal state로 막는 precondition이나 일관된 복구 transition은 현행 method에 없습니다.
release 성공과 already absent는 RELEASED, not owner는 LOST, exception은 UNKNOWN입니다. close()는 release() 결과를 버리므로 release certainty가 필요한 caller는 먼저 명시적으로 호출하고 typed outcome을 검사해야 합니다. LeaseHandle.close
LeaseWatchdog는 별도 application-core utility입니다. bounded registration과 scheduled renew를 제공하고 renew가 unknown/lost가 되면 cancellation callback을 한 번 호출합니다. Redis lease bean과 watchdog을 자동으로 묶는 production bean은 확인되지 않습니다.
왜 lock이 아닌가
owner check는 다른 caller가 현재 Redis value를 renew/delete하지 못하게 합니다. 하지만 expiry 뒤 새 owner가 획득한 다음, 오래 멈췄던 이전 process가 외부 DB나 API에 effect를 쓰는 것을 Redis lease가 막지는 못합니다. effect target에 제시할 monotonically increasing fencing token이 없기 때문입니다.
LeaseHandle.guarantee()와 adapter의 static guarantee()는 모두 LeaseGuarantee.EFFICIENCY_ONLY를 반환합니다. 이 계약은 correctness-sensitive write를 보호하지 않습니다. DB revision, conditional update 같은 effect-point guard가 따로 필요합니다.
또한 single Redis/Sentinel/Cluster deployment 하나에 Lua를 실행할 뿐 quorum lock이나 Redlock 구현이 아닙니다. 이 글은 Redis topology 자체의 availability를 mutual exclusion 증명으로 바꾸지 않습니다.
NOSCRIPT와 ambiguous 분기
네 script는 digest를 cache하고 EVALSHA를 사용합니다. NOSCRIPT일 때만 SCRIPT LOAD 후 한 번 다시 시도합니다. LeaseScripts.run
NOSCRIPT 이외의 exception은 adapter로 올라가 typed Indeterminate가 됩니다. acquire/renew/release는 mutation 가능성이 있으므로 clean failure로 바꾸지 않는 선택입니다. inspect는 read-only이지만 exception 역시 Indeterminate입니다.
테스트가 고정하는 계약
DistributedLeaseV2ContractTest는 bounded/redacted attempt, digest-only request, response-loss outcome,EFFICIENCY_ONLY, usable budget을 provider-neutral type 수준에서 검사합니다.RedisDistributedLeaseAdapterTest는 uncontended acquire, contention, same-operation replay, operation conflict, renew, local expiry, release, inspection과 unreachable indeterminate를 in-memory gateway로 고정합니다.theSameClaimReplays는 outcome type만 검사합니다. replay handle의 remaining validity가 reply PTTL 이하인지 확인하지 않습니다.- 같은 테스트의
anExpiredBudgetIsLost는 server call 없이 monotonic budget만으로 LOST가 반환됨을 검사합니다. 그 뒤 renew하거나 UNKNOWN 뒤 renew하는 경로는 없습니다. LeaseWatchdogTest는 registration bound와 indeterminate renew 시 cancel/lost callback을 고정합니다.LiveRedisSemanticPortsTest.theLeaseIsExclusiveUnderTheAdvancedAccount는 standalone/cluster real-server lane에서 한 holder만 acquire하고 두 번째는 contended이며 release가 성공하는 흐름을 검사하도록 태그되어 있습니다.RedisCapabilityCompositionTest.leaseProviderComposesThePort는 selector가 port bean을 만드는지만 확인하며 서버에는 연결하지 않습니다.
현재 한계와 다음 source 순서
- fencing token이 없으므로 domain correctness lock이 아닙니다.
waitTimeout은 request validation에는 있지만 Redis adapter가 소비하지 않습니다. wait loop가 없습니다.- same-attempt replay는 reply PTTL을 버리고 요청 TTL로 local budget을 다시 만듭니다. replay handle이 실제 Redis lease보다 오래 ACTIVE라고 판단할 수 있으며 이를 막는 regression test가 없습니다.
state()의 local-expiry LOST는 field에 저장되지 않고,renew는 state precondition 없이 실행됩니다. 성공해도 field를 ACTIVE로 복구하지 않아Renewedoutcome과 UNKNOWN/LOST/RELEASED state가 함께 남을 수 있습니다.- adapter는 before-send unavailable과 after-send ambiguous를 구분하지 않고 대부분
Indeterminate로 보냅니다. port에 있는Unavailable·Overloadedvariant는 이 adapter에서 생성되지 않습니다. observedServerExpiry는 acquire reply의 PTTL을 반영하지 않습니다. replay에서는 진단값도 server expiry보다 길 수 있습니다.- watchdog은 구현·unit test되어 있지만 production bean 조립은 확인되지 않습니다.
- 이번 작성에서는 real-server lane을 재실행하지 않았습니다.
DistributedLeasePort → adapter tryAcquire → 네 Lua → inner handle → adapter test 순으로 읽으면 owner identity와 certainty 경계를 놓치지 않습니다.