Skip to content

Conversation

@ekgns33
Copy link
Collaborator

@ekgns33 ekgns33 commented Feb 5, 2025

연관된 이슈를 적어주세요 📌

#77

작업한 내용을 설명해주세요 ✔️

  • 서버에서 NOAUTH 문제로 레디스 연결이 안되는 이슈가 발생했습니다.
  • pub-sub으로 모든 메세지를 바로 릴레이하던 로직에서 중앙 스케줄러가 push하는 방식으로 구현 변경
  • 위치 업데이트의 원자성 + 레디스 요청 횟수를 줄이기 위해, 2번의 request로 처리하던 로직을 lua스크립트로 변경했습니다.

트러블 슈팅

  • 부하테스트 시, 과도한 네트워크 IO로 lettuce가 죽어버리는 문제가 발생했습니다.
    Screenshot 2025-01-24 at 12 14 27 AM

  • 레디스 직렬화에러가 발생하여 새로운 RedisTemplate 객체를 빈으로 등록했습니다. 다만 RedisTemplate을 런타임에 2개 가지고있는 것이 염려되어 <String,String> 도 일괄적으로 처리할 수 있는지 확인해보고 개선하겠습니다.

리뷰어에게 하고 싶은 말을 적어주세요

확인하기

  • : 코드에 에러가 없는지 확인했나요?
  • : PR에 설명을 기재했나요?
  • : PR 태그를 붙였나요?
  • : 불필요한 로그나 System.out을 제거했나요?

@ekgns33 ekgns33 added 🐛 bug 🐛 Something isn't working 🚨 L3 급함 labels Feb 5, 2025
@ekgns33 ekgns33 self-assigned this Feb 5, 2025
@ekgns33
Copy link
Collaborator Author

ekgns33 commented Feb 5, 2025

[Socket Exception]
네트워크 소켓의 버퍼가 가득차서 발생한 예외라고 설명되어있습니다. 참고할 자료는 다음과 같습니다!
https://stackoverflow.com/questions/6363253/java-socketexception-no-buffer-space-available

  • lettuce에서 발생했기 때문에 레디스에 문제가 있는지 중점으로 확인해봤는데 레디스 커넥션이 K번 죽었다가 다시 커넥션을 시도하는 현상을 발견했어요.
  • 또한 웹소켓 연결을 맺고있던 클라이언트에는 Broken Pipe 에러가 발생했습니다.
    Screenshot 2025-01-24 at 12 36 58 AM
  1. lettuce에서 소켓버퍼가 가득차 커넥션이 죽는다.
  2. lettuce-client가 다시 커넥션을 얻기위해 요청한다.
  3. 재요청할때마다 소켓버퍼가 가득찼다는 에러로인해 커넥션이 죽는다.

이 과정을 반복하다가 웹소켓 연결까지 모두 죽어버렸습니다.
Screenshot 2025-01-25 at 11 18 11 PM

네트워크 패킷 흐름을 확인해봤을때도 레디스 에러가 발생한 후 커넥션이 죽으며 IO가 줄어드는 모습을 확인했습니다.

  • 소켓의 버퍼를 확인했을때는 8192 바이트의 기본 소켓버퍼 크기였는데 이게 가득찰 수 있나 고민하다가. 우선 레디스로 향하는 네트워크 횟수를 줄여보기로 로직을 변경했습니다.

기존의 로직에서는 레디스에 2번 요청을 주고 count가 최신이라면 위치를 업데이트했는데요.

 LocationData.RunnerPos lastLocation =
        runningRedisRepository.getParticipantLocation(runningId, userId);
    LocationData.RunnerPos newLocation = new LocationData.RunnerPos(latitude, longitude);
    locationRepository.saveLocation(createLiveKey(runningId, userId, RUNNING_PREFIX), newLocation);

    if (lastLocation != null && isSignificantMove(lastLocation, newLocation)) {
      runningRedisRepository.publishLocationUpdateSingle(runningId, userId, latitude, longitude);
    }

두 가지 문제점이 있습니다.

  1. 실제로 atomic한가. >> 사용자가 화면을 계속 확인하면서 뛰지는 않을거다라는 가정이 있지만...
  • 사용자마다 자신의 위치만을 업데이트하기때문에 지금 상황에서는 문제가 없지만 atomic하다고는 못할 것 같습니다.
  1. 요청을 최대 2회 전송
  • 최대 2회 네트워크 전송이 일어나는데, 소켓버퍼가 가득찬 에러 해결을 위해 IO횟수를 줄여야 합니다.
local current = redis.call('GET', KEYS[1])

if current then
    local currentData = cjson.decode(current)
    if tonumber(ARGV[4]) <= currentData.count then
        return false -- Do not update if the current count is greater or equal
    end
end

local newData = cjson.encode({
    latitude = tonumber(ARGV[1]),
    longitude = tonumber(ARGV[2]),
    count = tonumber(ARGV[4])
})

redis.call('SET', KEYS[1], newData, 'EX', ARGV[3])
return true -- Successfully updated

레디스 내에서 동작하는 루아스크립트를 실행하도록 변경하여 compare and swap의 원자성을 보장하도록 했습니다.
또한 1회 요청만 하도록 변경되었습니다.

하지만 해결되지 않음...

  • 스택오버플로우와 여러 글에서 해당 에러는 장비의 과도한 IO로 인해 발생한다는 것을 확인함.

이게 프로세스 자체가 IO를 많이하면 다른 소켓에도 영향을 준다는 것인지는 아직도 이해가 잘 되지 않습니다. 그런데 우선 프로세스 전체의 IO를 줄여보는 방향으로 삽질을 했습니다..

-기존의 로직에서는 사용자의 위치 업데이트를 수신하면 바로 모든 사용자에게 broadcast하는 구조였습니다.

이러면 N명의 사용자가 동시에 웹소켓 연결을 맺고 달리기를 진행할때 $O(n^2)$로 횟수가 증가합니다. 따라서 위치 업데이트만 진행후 서버에서 푸시하는 방식으로 변경했습니다.

private class BroadcastPositionsTask implements Runnable {

    private String runningId;

    public BroadcastPositionsTask(String runningId) {
      this.runningId = runningId;
    }

    @Override
    public void run() {
      Map<String, LocationData.RunnerPos> participantsLocations = runningRedisRepository.getAllParticipantsLocations(runningId);
      try {
        String message = objectMapper.writeValueAsString(participantsLocations);
        simpMessagingTemplate.convertAndSend(RunningConst.RUNNING_WS_SEND_PREFIX + runningId, message);
      } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
      }
    }
  }
  • 해당 로직을 적용하니 신기하게도 문제가 사라졌습니다. 멘붕.

문제는 해결했지만.

  • Task를 만들어서 세션이 시작될때, 스케줄러를 등록하게됩니다. 현재는 러닝을 실시할때마다 스레드풀에 작업이 등록되고 수거되고있지 않습니다. 🤯🤯

따라서 러닝이 종료되도 스레드가 쌓이고 있습니다. 러닝이 종료되면 스케줄러를 제거하는 방향으로 개발할 수 있지만, 클라이언트의 문제로 러닝 종료 이벤트를 수신하지
못하면 별도의 서버로직없이는 누수가 발생합니다.
Screenshot 2025-01-24 at 12 14 35 AM

  • 따라서 위치를 전송하는 스케줄러는 서버에 고정적인 숫자로 유지하려합니다. 각 스케줄러들이 현재 라이브세션이 있다면 발송하고 일정 시간동안 아무 업데이트가 없는 세션은 삭제하도록 구성하는게 어떨까요?
loop:
  remove sesssions from cancel list
 
  for session in live-sessions:
    if 실시간달리기 일정시간 업데이트x
       취소할 task에 등록 (레디스 내의 값들은 ttl 짧게 걸어서 알아서 사라지도록 or 즉시 삭제)
    else 
       레디스에서 위치 조회
       topic에 send

리액터 패턴 처럼, 만약 time-out인 세션이라면 다음 루프에 미리 삭제될 리스트에 넣어놓고 패스하고 나머지는 브로드캐스트하는 방식으로 짜보는게 어떨까요? 그냥 의사코드입니다 ㅎㅎ

@ekgns33 ekgns33 requested review from hughesgoon and jeeheaG February 5, 2025 10:21
Copy link
Contributor

@jeeheaG jeeheaG left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니 많은 일들이 있으셨군요,, 고생하셨습니다.
저도 redis NOAUTH 에러나고 보니까 패스워드 안쓰고 있길래 뭐지 했었는데 누락된 거였군여

위치 broadcast 로직은 이렇게 수정하셨다는 것으로 이해했습니다.

  • 기존 로직 : 현재 위치 redis 에 업데이트 + ws broadcast 까지 했었는데
  • 수정 로직 :
    1. 현재 위치 redis 에 업데이트
    2. 스케줄러로 등록되어있는 ws broadcast 로직 (러닝 시작 시 등록, 종료 시 제거)

이걸로 커넥션 에러가 개선된 것도 신기한데
이걸 분리해볼 생각은 어떤 시점에 하신건지 궁금하네요..

말씀해주신 것처럼 스레드 누수에 대한 방안은
TTL 설정과 마지막 업데이트 시각을 기준으로 하거나
또는 Run 엔티티에 현재 상태 컬럼을 추가한 후 종료상태인 Run의 스케쥴러를 제거하여 리소스 회수하도록 하면

누수에 어느정도 방어가 될 것 같습니다.

고민도 삽질도 많이 하셨던 게 느껴지는 내용 공유해주셔서 감사합니다.
고생많으셨습니다!!

@ekgns33 ekgns33 merged commit f8a819f into main Feb 8, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug 🐛 Something isn't working 🚨 L3 급함

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants