Redis에서 발생하는 장애의 전파를 막아보자

Alex·2024년 11월 21일
0

Plaything

목록 보기
21/118

레디스에서 장애가 발생하면 이는 Single Point Failure가 될 수 있다.
단일 장애 지점. 즉, 한 곳에서의 장애가 전체 시스템 장애로 이어지는 사례다.

레디스 cli에서 shutdown을 해서 서버를 내려봤다.
이 레디스 서버를 사용하는 api 요청이 어떻게 처리되는지 보자.

요청 자체가 처리가 안돼서 500에러 코드를 반환한다.

(참고로 저렇게 shutdown을 시키고 cli에서 재시작을 눌러도 계속 연결이 거절된다고 떠서 보니까
redis-server.exe를 눌러서 다시 시작해줘야 했다.)

그렇기에, 레디스에서 장애가 발생하면
이때 대처할 만한 매커니즘이 필요하다.

그때 활용할 수 있는 게 서킷브레이커다.

레디스를 왜 쓰는데?

지금은 중복 요청을 걸러내기 위해서 클라이언트에서 TransactionId를 보낸다.
이걸 DB에 계속 저장하고 조회하는 대신 레디스를 쓰기로 했다.

그런데, 레디스는 외부 서버인만큼 장애가 발생할 때 본 서버로 전파가 될 수 있다.
트래픽이 몰리는 경우가 그렇다.

그렇다면, 이 경우에 서킷브레이커로 장애 전파를 막아야 한다.

@Slf4j
@RequiredArgsConstructor
@Component
public class DuplicateRequestChecker {

    private final RedisTemplate<String, String> redisTemplate;
    private final PointKeyRepository pointKeyRepository;

    private static final String SIMPLE_CIRCUIT_BREAKER_CONIFG = "simpleCircuitBreakerConfig";

    @CircuitBreaker(name = SIMPLE_CIRCUIT_BREAKER_CONIFG, fallbackMethod = "fallback")
    public void checkDuplicateRequest(String userId, String transactionId) {
        boolean isFirstRequest = Boolean.TRUE.equals(redisTemplate.opsForValue()
                .setIfAbsent(userId + ":" + transactionId, "success", 10, TimeUnit.SECONDS));
        if (!isFirstRequest) {
            throw new CustomException(ErrorCode.TRANSACTION_ALREADY_PROCESSED);
        }
    }

    public void fallback(String userId, String transactionId, Exception ex) {
        log.error("Redis check failed, using DB fallback: {}", ex.getMessage());

        if (pointKeyRepository.existsByTransactionId(transactionId)) {
            throw new CustomException(ErrorCode.TRANSACTION_ALREADY_PROCESSED);
        }
    }
}


@Slf4j
@SpringBootApplication
@EnableJpaAuditing
public class ApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(ApiApplication.class, args);
    }


    @Bean
    public RegistryEventConsumer<CircuitBreaker> myRegistryEventConsumer() {

        return new RegistryEventConsumer<CircuitBreaker>() {
            @Override
            public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> entryAddedEvent) {
                log.info("RegistryEventConsumer.onEntryAddedEvent");

                entryAddedEvent.getAddedEntry().getEventPublisher()
                        .onEvent(event -> log.info(event.toString()));
                entryAddedEvent.getAddedEntry()
                        .getEventPublisher()
                        .onFailureRateExceeded(event -> log.info("FailureRateExceeded: {}", event.getEventType()));
            }

            @Override
            public void onEntryRemovedEvent(EntryRemovedEvent<CircuitBreaker> entryRemoveEvent) {
                log.info("RegistryEventConsumer.onEntryRemovedEvent");
            }

            @Override
            public void onEntryReplacedEvent(EntryReplacedEvent<CircuitBreaker> entryReplacedEvent) {
                log.info("RegistryEventConsumer.onEntryReplacedEvent");
            }
        };
    }
}

어떻게 되는지 테스트를 해보자.

레디스 서버를 꺼두면 위 같은 로그가 찍히면서 fallback 메서드가 시작된다.

임계점을 넘으니 서킷브레이커가 open 상태로 변경됐다.

이 상태에서 다시 요청을 보내면 open상태라 거절된다.

레디스 서버를 킨 상태로
half_open인 서킷브레이커에 요청을 보낸다.


이때 요청들이 계속 성공하면 closed상태로 변경된다.

난감한 fallback

그런데 문제가 있다.
저 CustomException을 open 카운트에 반영하지 않도록 설정을 해도
저 예외가 발생하면 계속 fallback 메서드가 실행됐다.

레디스를 쓰는 의미가 없는 셈이다.
레디스에서 끝나야 할 API가 DB까지 타고 다시 올라가는 것이니..

이건 fallback 매커니즘의 특성이라서 어찌해야할지 모르겠다.

그래서, 예외를 던지지 말고 boolean값을 반환하기로 했다.

예외를 던지는 건 이 메서드를 호출하는 곳에서 하는 걸로 변경했다.

이제는 비즈니스 로직으로 만든 예외가 던져져도 폴백 메서드가 실행되지 않는다.

그런데 갑자기 레디스가 작동을 안 하네?...

2024-11-21T13:53:38.389+09:00  WARN 19616 --- [api] [ioEventLoop-6-3] i.l.core.protocol.ConnectionWatchdog     : Cannot reconnect to [localhost/<unresolved>:6379]: Connection refused: no further information: localhost/127.0.0.1:6379
2024-11-21T13:53:45.415+09:00  INFO 19616 --- [api] [io-7002-exec-10] com.plaything.api.ApiApplication         : 2024-11-21T13:53:45.415141600+09:00[Asia/Seoul]: CircuitBreaker 'simpleCircuitBreakerConfig' recorded a successful call. Elapsed time: 60010 ms
2024-11-21T13:53:45.415+09:00 ERROR 19616 --- [api] [io-7002-exec-10] c.p.a.c.v.DuplicateRequestChecker        : Redis check failed, using DB fallback: Redis command timed out

로그를 보니 레디스 타임아웃이 60초 정도가 걸렸다.
타임아웃 설정을 해주어야 한다.

spring.data.redis.timeout=3000

resilience4j.circuitbreaker.configs.default.slow-call-duration-threshold=3000  # 3초 넘으면 slow call로 간주
resilience4j.circuitbreaker.configs.default.slow-call-rate-threshold=60  # slow call 60% 넘으면 circuit open

레디스 타임아웃과 슬로우 콜과 관련된 설정을 주었다.

스레드를 4초 정도 재운다음에 작업을 해서 슬로우 콜도 처리가 되는지 보자.

슬로우 콜도 임계치를 넘으면 Open 상태로 변경됐다.


resilience4j.circuitbreaker.configs.default.sliding-window-type= COUNT_BASED
resilience4j.circuitbreaker.configs.default.minimum-number-of-calls= 7
resilience4j.circuitbreaker.configs.default.sliding-window-size= 30
resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state= 30s
resilience4j.circuitbreaker.configs.default.failure-rate-threshold= 40
resilience4j.circuitbreaker.configs.default.slow-call-duration-threshold = 5000
resilience4j.circuitbreaker.configs.default.slow-call-rate-threshold= 60
resilience4j.circuitbreaker.configs.default.permitted-number-of-calls-in-half-open-state=5
resilience4j.circuitbreaker.configs.default.automatic-transition-from-open-to-half-open-enabled=true

resilience4j.circuitbreaker.configs.default.event-consumer-buffer-size= 100

resilience4j.circuitbreaker.configs.default.record-exceptions[0]=org.springframework.data.redis.RedisConnectionFailureException
resilience4j.circuitbreaker.configs.default.record-exceptions[1]=io.lettuce.core.RedisConnectionException
resilience4j.circuitbreaker.configs.default.record-exceptions[2]=io.lettuce.core.RedisCommandTimeoutException
resilience4j.circuitbreaker.configs.default.record-exceptions[3]=java.net.SocketException
resilience4j.circuitbreaker.configs.default.record-exceptions[4]=java.net.ConnectException
resilience4j.circuitbreaker.configs.default.record-exceptions[5]=io.lettuce.core.RedisCommandTimeoutException

resilience4j.circuitbreaker.instances.simpleCircuitBreakerConfig.base-config=default

클로드의 도움으로 최종적으로 만든 설정값들이다.

로그도 수정해야지

@Bean
public RegistryEventConsumer<CircuitBreaker> myRegistryEventConsumer() {
    return new RegistryEventConsumer<CircuitBreaker>() {
        @Override
        public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> entryAddedEvent) {
            entryAddedEvent.getAddedEntry().getEventPublisher()
                .onStateTransition(event -> log.warn("CircuitBreaker {} state changed from {} to {}",
                    event.getCircuitBreakerName(),
                    event.getStateTransition().getFromState(),
                    event.getStateTransition().getToState()))
                .onFailureRateExceeded(event -> log.error("CircuitBreaker {} failure rate exceeded: {}",
                    event.getCircuitBreakerName(),
                    event.getFailureRate()));
        }

    };
    

실제 운영 서버에서는 상태율변경과 초과율만 로그를 찍도록 해싸.

profile
답을 찾기 위해서 노력하는 사람

0개의 댓글