레디스에서 장애가 발생하면 이는 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상태로 변경된다.
그런데 문제가 있다.
저 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()));
}
};
실제 운영 서버에서는 상태율변경과 초과율만 로그를 찍도록 해싸.