Post

Redis 실습 장애 대응 1

Redis 실습 장애 대응 1

증상

응답이 쌓일 수록 응답시간이 느려지는 증상 확인

응답시간이 점점 느려지는 화면

요구사항 파악

  • Redis에서 point:* 키의 랜덤 100개를 받아서 렌더링한다.

클라이언트 쪽 렌더링 문제인가? 서버 쪽 응답 문제인가?

확인을 위해 개발자 도구로 확인했을 때,

개발자 도구 네트워크 탭 확인

→ 클라이언트는 요청을 보내는데, 서버 응답이 pending되어 늦어지는 것을 확인

이 후 서버 쪽 app.py 코드를 확인해봐야 겠다고 생각했다.

가설

결국, GET /points 로 불러올 때 문제였기 때문에, 코드를 자세히 보기로 판단

1
2
3
4
5
@app.get("/points")
def get_points():
    start = time.time()
    keys = r.keys("points:*")
    duration_ms = int((time.time() - start) * 1000)

keys = r.keys("points:*") 코드가 redis에서 key들을 가져오는 코드인 것 같은데 구린 냄새가 난다고 생각 후, KEYS에 대해 알아봤다.

KEYS가 하는 일

  1. DB의 해시테이블 전체를 처음부터 끝까지 한 버킷씩 순회한다.
  2. 각 키에 대해 패턴과 문자열 매칭을 수행한다.
  3. 매칭되는 키를 결과 리스트에 담는다.
  4. 순회가 끝나면 리스트 전체를 클라이언트로 반환한다.
  5. 핵심은 패턴에 맞는 키가 1개뿐이어도 전체 키를 다 훑는다는 점과 Redis는 싱글 스레드라는 점이다.
    • 이 순회를 하는 동안 다른 일을 못하기 때문에, redis에 저장된 크기에 따라 응답이 늦어진다고 판단.

valkey-cli에서 keys * 명령어 실행시,

valkey-cli keys * 실행 결과

→ 무려 2.88s가 걸리는 것을 확인

문제를 정확히 파악했으니, KEYS 대신에 어떻게 해결할지 생각했다.

  1. 결국 keys의 모든 값을 가져올려고 하는 것이 문제니까, key 전체를 반환하지 않는 방향으로 수정
  2. keys의 큰 문제인 병목 현상을 해결해주는 다른 메서드가 있는지 확인

검증

1. key 전체를 반환하지 않는 방향으로 수정

1
random.sample(keys, min(100, len(keys)))

를 총 개수를 DBSIZE로, 표본 100개를 RANDOMKEY를 여러 번 호출해서 뽑으면 어떨까?

1
2
3
4
5
6
7
8
total = r.dbsize()
want = min(100, total)

sampled_keys = set()
while len(sampled_keys) < want:
    key = r.randomkey()
    if key is not None:
        sampled_keys.add(key)

위와 같이 sampled_keys를 set 자료구조로 한 뒤, 중복되지 않은 100개가 되기 위해 while 루프를 통해, 100개가 되도록 했습니다.

실행 결과

RANDOMKEY 방식 실행 결과

2. 병목 현상을 해결해주는 다른 메서드 확인 후 적용

다른 메서드를 확인해보니까, KEYS의 단점을 없애면서도, 느리지 않은 SCAN 명령어를 쉽게 찾을 수 있었다.

SCAN

  • KEYS와 같은 결과를 내지만, 한 번에 다 훑지 않고 커서로 잘게 나눠 훑는 명령어이다.
  • SCAN 0(cursor)로 시작해서 응답은 항상 [다음 커서, 이번에 찾은 키 목록] 두 부분이다.
  • 받은 커서를 다음 SCAN에 넣어 이어서 훑는다.
  • 커서가 다시 0으로 돌아오면 한 바퀴 완료

KEYS와 다른 점은?

  • KEYS: 전체를 한 명령으로 훑음 → 그동안 싱글 스레드 완전 블로킹
  • SCAN: 전체를 여러 명령으로 쪼개 훑음 → 명령 사이사이 다른 요청이 처리될 틈이 생김
    • 둘 다 총 작업량이 O(N)으로 같지만, 한 명령이 붙잡는 시간이 짧아지는 게 핵심이다.

하지만, SCAN을 그대로 모든 키를 훑어본다면?

  • KEYS와 크게 차이가 나지 않을 것 같다고 판단함.
  • 대신, 다른 큰 이점은 중간중간에 요청을 처리할 수 있다는 것인데 어짜피 랜덤한 key 100개를 원한다면 cursor를 랜덤으로 해서 100개를 반환해주면 어떨까? 생각으로 아래 코드를 작성했다.
1
2
3
4
5
6
7
8
9
want = min(100, total)
sampled_keys = set()

while len(sampled_keys) < want:
    cursor = random.randint(0, 2**31 - 1)
    _, keys = r.scan(cursor=cursor, match="point:*", count=want * 2)
    sampled_keys.update(keys)

sampled_keys = list(sampled_keys)[:want]

위와 같이 cursor를 무작위 값으로 해서 100개의 랜덤한 값을 가져오는 식으로 사용했다.

SCAN 방식 실행 결과

결론

  • 무식하게 KEYS를 사용하게 되면 싱글 스레드인 Redis는 블로킹에 걸려 모든 요청이 무시될 수 있다.
    • 실제 프로덕션 레벨에서 사용하면 큰일날 수도 있을 것 같다고 생각
  • 중요한 것은 어떻게 빠르게 처리할지
  • 처리 중간에 다른 요청을 처리할 수 있을지

느낀점

  • 실제 문제를 직접 보고 문제 파악 → 원인 분석 → 가설 → 검증 → 결론을 처음으로 해결해서 재미있었다.
  • 현업에서의 문제는 힌트가 없겠지만, 문제를 바라보는 시각을 넓히고 이에 대해 원인을 분석하고 가설을 세우고 검증하고 결론을 자연스럽게 내릴 수 있는 문제 해결 능력을 키워야 겠다고 생각했다.

Redis 장애 대응 실습 2, 3, 4 후편 작성 예정

This post is licensed under CC BY 4.0 by the author.