Redis 8.4's digest-based SET option cuts a 1MB compare-and-set from 4.3ms to 1.6ms
Redis 8.4의 digest 기반 SET, 1MB 비교 후 쓰기 지연을 4.4ms에서 1.8ms로 줄여
Redis 8.4의 SET 조건부 옵션 IFEQ와 IFDEQ는 기존 값이나 digest가 일치할 때만 쓰기를 수행합니다. 작성자의 localhost 측정에서 1MB 값에 IFDEQ를 쓰면 기존 GET-후-SET보다 지연이 줄었고, 동시 갱신 실험에서는 재시도 루프로 lost update를 막았습니다. 다만 값의 동일성만 비교하므로 WATCH와 의미가 다를 수 있습니다.
- 주제
에디터 노트
IFDEQ의 장점은 비교하려는 큰 값을 다시 주고받지 않는 데 있고, 측정에서도 1MB 조건에서 전송량과 지연이 함께 줄었습니다. 다만 매번 DIGEST를 계산하면 1MB에서 1.03ms가 들기 때문에 이 이점은 앞서 읽은 시점의 digest를 재사용할 때 가장 분명합니다. 작은 값에서는 측정 차이가 실행 간 흔들림 범위였고, 한 키에 경합이 집중되면 정확성을 얻는 대신 약 5,000회의 재시도가 생겼습니다. 더 큰 적용 조건은 비교의 의미입니다. IFEQ와 IFDEQ는 값의 현재 내용만 확인하므로 A에서 B를 거쳐 A로 돌아온 상황을 구분하지 못합니다. 댓글에서 제기된 이 지점은 작성자도 인정했으며, 변경 이력 자체가 중요하면 매 쓰기마다 달라지는 버전 필드를 값에 넣어야 합니다. 따라서 WATCH의 대체 여부는 경합 처리 방식뿐 아니라 변경 여부를 내용으로 판단해도 되는지에 달려 있습니다. redis-py 저수준 경로에서 응답 값이 잘못 처리된 사례도 있어 새 옵션을 붙일 때는 서버 조건의 성공 여부와 클라이언트가 실제 반환한 응답을 따로 확인해야 합니다.
AI 요약
Redis 8.4는 SET 명령에 IFEQ, IFNE, IFDEQ, IFDNE 조건을 추가했습니다. IFEQ와 IFNE는 현재 값을 지정한 문자열과 비교하고, IFDEQ와 IFDNE는 DIGEST가 계산한 16자리 16진수 digest와 비교합니다. 작성자는 여러 worker가 공유하는 캐시 객체를 갱신할 때 이전에 읽은 값이 그대로인지 확인하는 용도로 새 옵션을 시험했습니다.
대용량 값에서 줄어든 전송량과 지연
작성자는 redis:8.4 컨테이너를 localhost에서 실행하고, 크기별로 50회씩 세 차례 측정해 중앙값을 비교했습니다. 1MB 값을 대상으로 한 결과는 GET 후 SET이 4.44ms, Lua 스크립트 CAS가 7.37ms, SET ... IFDEQ가 1.77ms였습니다. 비교에 필요한 데이터 전송량은 기존 방식의 1,000,000바이트에서 digest 방식의 16바이트로 줄었습니다. 100B에서는 각각 0.29ms, 0.17ms, 0.21ms였고 10KB에서는 0.32ms, 0.25ms, 0.17ms였습니다. 작성자는 작은 값에서의 차이는 실행 간 흔들림 범위라고 설명합니다. 100KB에서는 0.55ms, 1.10ms, 0.48ms였습니다.
GET 후 SET은 기존 값을 클라이언트로 가져온 뒤 조건을 확인해 새 값을 보냅니다. Lua 방식은 비교할 기존 값을 인자로 서버에 보내고 스크립트에서 비교합니다. IFDEQ는 클라이언트가 이전 읽기 때 보관한 digest를 보내므로 비교 대상 원문을 다시 전송하지 않습니다. 다만 DIGEST도 값을 모두 읽어야 합니다. 측정된 DIGEST 중앙 지연은 100B에서 0.17ms, 10KB에서 0.15ms, 1MB에서 1.03ms, 10MB에서 6.20ms였습니다. 매번 새로 digest를 계산하면 네트워크 전송량을 줄이는 대신 서버 CPU 비용이 듭니다.
조건부 갱신과 동시성
20개 thread가 약 950바이트 JSON 값의 카운터를 각각 50회 올리는 실험에서, GET 후 무조건 SET을 쓰자 세 번의 실행 결과가 109, 105, 105에 그쳤습니다. 목표값 1,000 가운데 각각 891, 895, 895회가 유실됐습니다. 기존 값을 읽은 뒤 SET ... IFEQ로 조건부 갱신하고 성공할 때까지 재시도하자 세 번 모두 1,000회 갱신에 성공했습니다. 대신 총 재시도는 4,892회, 5,111회, 5,111회였습니다. 작성자는 조건부 쓰기가 정확성을 보장해도 같은 키에 경합이 몰리면 재시도 비용이 생긴다고 설명합니다.
동작상 제약과 클라이언트 응답
NX와 IFEQ는 함께 쓸 수 없습니다. 리스트 같은 다른 자료형을 대상으로 하면 WRONGTYPE 오류가 나고, digest 형식이 16자리 16진수가 아니면 오류가 발생합니다. 키가 존재하지 않을 때 IFEQ나 IFDEQ는 조건 실패로 처리되어 아무것도 쓰지 않습니다. 키가 없을 수도 있는 경우에는 NX 또는 별도의 존재 확인이 필요합니다.
작성자는 redis-py 8.1.0의 저수준 execute_command 경로에서 실패한 조건과 GET을 함께 보냈을 때 서버가 반환한 이전 값 대신 False를 받았습니다. raw socket으로 확인하니 서버 응답은 정상이었고, 저수준 경로의 응답 유형 처리에서 값이 사라졌습니다. redis-py의 형식 지정 래퍼인 r.set(..., ifeq=..., get=True)는 실제 값을 반환하지만 실험적 기능으로 매 호출마다 UserWarning을 출력한다고 적었습니다. 또한 INFO commandstats의 failed_calls가 조건 불일치로 실패한 SET 호출을 포함하므로 경합을 살피는 지표로 쓸 수 있다고 설명합니다.
dev.to 반응
- @arhancanli — WATCH/MULTI/EXEC와 의미가 다른 점이 있습니다. digest 비교는 "내용이 같은가"를 묻지만 WATCH는 "누군가 이 키에 썼는가"를 묻습니다. 다른 worker가 A를 B로 바꾼 뒤 다시 A로 되돌리면 A의 digest를 비교하는 IFDEQ는 성공하지만 WATCH는 트랜잭션을 중단합니다. 캐시 객체라면 대체로 원하는 동작이겠지만, 이전 값으로 돌아갈 수 있는 카운터나 상태 머신처럼 변경 순서가 중요하다면 매 쓰기마다 바뀌는 버전 필드가 필요합니다. 캐시가 제거된 뒤 같은 내용으로 다시 만들어져도 오래된 digest와 일치할 텐데, 그 경우도 확인했나요?
- @alexgeorgiev17 — 맞는 지적이며 일반 IFEQ에도 적용됩니다. 둘 다 내용을 비교하므로 A에서 B로 갔다가 A로 돌아오면 "아무도 건드리지 않았다"고 처리합니다. 글에서 IFEQ가 일반적인 WATCH 사용 사례를 대체한다고 쓴 부분은 내용을 기준으로만 판단할 때에 한정됩니다. 값이 이전 상태로 돌아갈 수 있거나 순환하는 상태 머신에서는 문제가 됩니다. 매 쓰기마다 값을 올리는 버전 필드를 넣으면 나머지 내용이 같아도 digest가 달라집니다. eviction 사례는 시험하지 않았지만, 같은 내용으로 다시 생성되면 오래된 digest와 일치할 것이라는 데 동의하며 캐시라면 같은 데이터이므로 괜찮다고 답했습니다.