Mysql 인덱스 사용법 및 실행 계획 정리

데이터베이스/mysql|2020. 6. 10. 19:18

mysql 인덱스에 대한 정확한 이해도 없이 사용을 하다보니 조금 개념적으로 헷갈리는게 많이 있었다. 이 부분에 대해 한번 정리하고 넘어가고자 기록해본다.

 

인덱스


인덱스는 빠르게 특별한 컬럼과 함께 값을 찾는데 사용된다. 인덱스가 없으면 Mysql은 처음 행부터 전체 테이블을 읽어 들여서 데이터를 찾는다. 거대한 테이블에서 이런 행동은 비용이 상당히 많이 들어가게 된다. 만약에 테이블이 인덱스를 가지고 있으면 빠르게 접근할 수 있게 된다.

대부분의 Mysql 인덱스 (PRIMARY KEY, UNIQUE, INDEX, and FULLTEXT)는 B-tree안에 저장된다. 예외적으로 spatial 데이터 타입은 R-tree를 사용, 메모리 테이블은 또한 hash index를 지원, InnoDB는 FULLTEXT 인덱스를 위해 inverted list를 사용한다.

 

 

인덱스 동작 방식


- 행을 찾기 위해서 매칭되는 WHERE 구문을 빠르게 찾는다.

 

- 조건으로 부터 불필요 행을 제거한다. 만약에 여러 인덱스가 있는 경우 Mysql은 가장 적은 수의 행을 사용하는 인덱스를 선택한다. (Mysql은 한번에 하나의 인덱스만 사용할 수 있다.)

 

- 만약 테이블이 multiple column 인덱스를 가지고 있으면 인덱스의 가장 왼쪽에 컬럼을 사용하여 옵티마이저를 통해 행을 찾는다. 예를 들어 만약에 (col1, col2, col3)을 사용하는 인덱스가 있는 경우 인덱스는 이 순서로 검색을 진행한다. (col1), (col1, col2), (col1, col2, col3)

 

- 조인이 있는 경우 다른 테이블에서 행을 찾는다. Mysql은 동일한 유형과 사이즈로 되어 있는 열을 index로 사용할 때 더욱 효과적으로 행을 찾는다. VARCHAR, CHAR는 두개를 같은 사이즈로 명시 하였을 경우에 같은 타입으로 고려되어 사용된다. 예를 들어 VARCHAR(10) = CHAR(10)이지만 VARCHAR(10) ≠ CHAR(15)는 같지 않다.

 

- binary가 아닌 문자열 사이를 비교하기 위해서는 동일한 문자열 집합을 사용해야한다. utf8과 latin1열을 비교할 경우 인덱스를 사용할 수 없다.

 

- 타입이 다른 문자열과 숫자 등을 비교하려고 할때도 마찬가지로 인덱스를 탈 수 없다. (묵시적 형변환)

 

- index로 사용되는 key_col에서 min(), max()의 값을 찾기 위해 인덱스에서 key_col 이전에 발생하는 모든 키 파트에서 WHERE key_part_n = const를 사용하는지 여부를 확인하기 위해서 전처리기를 통해서 최적화가 진행된다. 이 경우에서 Mysql은 각 min() 또는 max() 표현식에 대해 단일키 조회를 수행하여 상수를 대체한다. 모든 표현식이 상수로 바뀌고 나면 쿼리가 한번에 반환된다.

SELECT MIN(key_part2),MAX(key_part2)
  FROM tbl_name WHERE key_part1=10;

 

 

실행계획


- 실행계획 필드 정리

  • Id
    • Select 구문 구분 ID
  • select_type
    • SIMPLE : 단순 SELECT
    • DERIVED : 서브 쿼리 중 가장 안쪽에 있는 쿼리 
    • PRIMARY : 서브 쿼리 바깥쪽에 있는 쿼리
    • DEPENDENT SUBQUERY  : 조건절 내부에서 외부 쿼리와 연결된 SELECT 
// PRIMARY
SELECT * FROM ( 
    // DERIVED : 서브 쿼리 중 가장 안쪽에 있는 쿼리
    SELECT * FROM timeline_item ti
) tt;


// DEPENDENT SUBQUERY
SELECT * FROM timeline_item t1 WHERE EXISTS ( SELECT * FROM timeline_site t2 WHERE t1.id = t2.id)
  • table

    • 참조하는 테이블 이름
  • type

    • 조인 혹은 조회 타입 (아래로 갈수록 성능 하락)
      1. System : 테이블에 데이터가 하나만 있는 경우
      2. const : SELECT에서 Primary Key 혹은 Unique Key를 상수로 조회하는 경우
      3. eq_ref : 조인할 때 Primary, Unique Key로 매칭하는 경우
      4. ref : 조인할 때 Primary, Unique Key로 매칭하지 않은 경우
      5. ref_or_null : ref와 같지만 NULL이 추가되어 검색된 경우
      6. index_merge : 두개의 인덱스가 병합되어 검색이 된경우
      7. unique_subquery : In절 내부 서브쿼리에서 Primary Key가 있는 경우
      8. index_subquery : In절 내부 서브쿼리에서 Primary Key가 아닌 인덱스가 있는 경우
      9. range : 특정 범위 내에서 인덱스를 사용하여 데이터 추출 하는 경우
      10. index : 인덱스를 처음부터 끝까지 찾아서 검색하는 경우로 일반적인 인덱스 풀스캔
      11. all : 테이블 풀스캔
  • possible_keys

    • 데이터 조회 시 DB에서 사용할 수 있는 인덱스 리스트
  • key

    • 실제로 사용할 인덱스
  • key_len

    • 실제로 사용할 인덱스 길이
  • ref

    • key 안의 인덱스와 비교하는 컬럼(상수)
  • rows

    • 쿼리 실행 시 조사하는 행수
  • extra

    • 추가 정보 (데이터가 많고 Using filesort, Using temporary 상태가 나온다면 무조건 최적화 필요)

      1. Using Index

        커버링 인덱스라고 하며 인덱스 자료구조를 이용하여 데이터를 추출

      2. Using where

        where 조건으로 데이터를 추출 (Type이 All 또는 Index와 같이 표현될 시 성능이 안좋다는 뜻)

      3. Using filesort

        데이터 정렬이 필요한 경우로, 메모리 혹은 디스크 상에서의 정렬을 모두 포함 (데이터 많을 시 성능 하락)

      4. Using Temporary

        쿼리 처리 시 내부적으로 Temporary 테이블이 사용됨

 

 

WHERE 조건문 주의사항


  • 묵시적 형변환에 조심하라
  • 무턱되고 함수를 사용하면 옵티마이저가 데이터 분포도 체크를 하지 못하기에 사용하지 말 것
// 함수 사용
SELECT * FROM timeline_item ti WHERE DATE_FORMAT(modified_at, '%Y%m%d') <= '20200402'

// 대안방안
SELECT * FROM timeline_item ti WHERE modified_at <= '2020-04-02'
  • Like 검색은 % 위치에 따라 다르게 수행 된다. %123, %125%의 경우에는 데이터 풀 스캔이 발생된다. 하지만 12312%와 같은 경우에는 인덱스를 사용해서 진행됨. 하지만 1%등과 같이 데이터 분포도에 비해 너무 추상적으로 %를 사용하게 될 경우에는 옵티마이저가 인덱스 사용과 데이터 풀스캔의 효율성 판단 했을 때 풀스캔이 효율적이다고 생각하고 데이터 풀스캔이 실행된다. 

댓글()

Elasticsearch 7.7 feature와 heap 메모리 사용량의 두드러진 감소량

 


줄어든 heap 사용량

Elasticsearch 사용자들은 Elasticsearch 노드에 저장이 가능한 만큼 데이터를 집어 넣지만, 가끔 disk에 저장되기 전에 heap memory 사용량이 초과되는 것을 경험한다. 이는 비용을 줄이기 위해 가능한 노드당 많은 양의 데이터를 넣고 싶은 사용자들에게 문제를 일으킨다. (실제로 현재 운영중인 es에서도 대량의 데이터 삽입 시 가끔 발생함)

 

왜 Elasticsearch에는 데이터를 저장하기 위해 heap memory 영역이 필요한걸까? 왜 디스크 공간만으로 충분하지 않은걸까?? 거기에는 여러 이유가 존재하지만 가장 중요한 이유는 루씬은 디스크 상에 데이터를 찾을 수 있는 위치를 찾아내기 위해서 일부 정보를 메모리에 저장해야 한다.

 

예를 들어 루씬의 inverted index는 terms 사전(디스크 상에 순서대로 블록 형태로 되어있는 terms group)과 terms index(terms 사전에서 빠르게 조회하기 위해 구성된)로 구성되어 있다. 이 terms index는 디스크상의 블록에 prefix starts 위치를 포함하고 있는 terms를 offset과 함께 terms의 prefix 정보로 도식화 하고 있다. 그런데 이 terms 사전은 disk 상에 존재하지만 terms index는 heap 위에서 존재한다.

 

그럼 얼마나 많은 양의 메모리가 필요로 할까? 전형적으로 인덱스 GB당 작은 MB 만큼이 필요로 한다. 이것은 많지는 않지만 사용자가 노드에 terabyte 상당의 데이터를 디스크에 사용한다면 indicies는 indices에 terabyte만큼의 데이터를 저장하기 위해서 10~20GB상당의 heap memory가 필요로 하게 된다.

 

Elasticsearch에서는 30GB이상의 힙메모리를 올리지 말라고는 하지만 종종 집계와 같은 쿼리 시 다른 consumer를 위한 공간을 남기지 않기 때문에 JVM에서 클러스터 관리 작업을 위한 공간이 충분치 않는 경우가 많아 운영에 어려움을 주는 경우가 있다.

 

실제로 기존에 6.x 버전과 7.x 초기버전의 경우에는 10TB 데이터 저장 시 17기가의 힙 메모리가 필요로 했다. 하지만 7.7버전에서는 2.5기가만 필요로 하도록 개선되었다고 한다.

 

어떻게 이게 가능해진걸까? Jvm에서 디스크로 데이터를 옮기는 구조와 메모리에서 hot bits를 유지하기 위해서 파일시스템을 사용하는 등의 기술들이 루씬 indices의 여러 컴포넌트들에게 시간이 흐름에 따라 동일하게 적용되고 있다. 그리고 이 메모리는 여전히 할당된 곳에서 내용을 읽을 수는 있지만 이 메모리에 상당한 부분은 사용사례에 따라 사용 되지 않는 경우가 많았다.

 

예를 들어 디스크상의 _id field의 terms index의 이동으로 삭제된 terms는 오직 GET API와 정확한 IDS로 document들을 인덱싱 했을 때만 사용된다. 하지만 elasticsearch로 메트릭과 로그를 인덱스하는 사용자의 대부분은 해당 기능을 사용하지 않는다. 이렇게 사용되지 않고 있는 자원들을 활용해서 heap의 사용률을 7.7버전 부터는 더 적게 heap 크기를 사용 할 수 있게 되었다.

 

그 밖에 새로운 feature

이 밖에도 검색 결과를 동기로 기다리지 않고 검색결과를 검색 시 사용한 ID를 이용해서 추후해 결과를 얻을 수 있는 async search와 aggregation시 많은 bucket을 할 당할 경우 발생할 수 있는 OOM을 피하기 위해서 주기적으로 memory circuit breaker를 bucket을 추가 할당 하기 전에 체크하는 기능 등이 추가되었다.

 

 

 

 

출처 및 읽어보면 좋은 링크

 

인덱스와 샤드의 관계

https://www.elastic.co/kr/blog/how-many-shards-should-i-have-in-my-elasticsearch-cluster

How many shards should I have in my Elasticsearch cluster?

이 블로그는 여러분의 클러스터에 적합한 인덱스와 샤드의 개수와 크기를 어떻게 가져가야 하는지에 대한 실질적인 가이드라인을 제공합니다.

www.elastic.co

https://www.elastic.co/kr/blog/significantly-decrease-your-elasticsearch-heap-memory-usage

Significantly decrease your Elasticsearch heap memory usage

Fitting as much data per Elasticsearch node as possible is often important to reduce costs. Learn more about the improvements coming in Elasticsearch 7.7 to dramatically reduce the amount of heap memory needed per GB of data.

www.elastic.co

 

댓글()

모던 자바 인 액션 내용 정리

JAVA/고급 자바|2020. 4. 12. 16:46

 

포킹 

자바 8에서 추가된 스트림 api에서 데이터를 필터링, 추출, 그룹화 등의 기능을 진행할 수 있다. 이러한 동작들을 병렬화 할 수 있어 여러 cpu에서 작업을 분산해서 처리할 수 있다. 이런 작업을 포킹 단계라고 한다.

 

 

함수형 인터페이스

- 하나의 추상메서드를 가지고 있는 함수형 인터페이스지만 상속을 받은 인터페이스는 추상메서드를 하나만 가지고 있다고 하여도 함수형 인터페이스가 아니다.

- 디폴트 메소드가 아무리 많아도 추상 메소드가 하나이면 함수형 인터페이스이다.

- @FunctionalInterfeace 애노테이션을 붙이면 함수형 인터페이스가 아닌 경우 컴파일 에러를 발생 시킬 수 있다.

 

 

람다에서 지역변수를 final로 제약하는 이유 

람다에서 지역변수가 final로 사용되는지 궁금한데 이는 인터페이스 변수는 힙에 저장되고 지역변수는 스택에 저장되는 이유가 대표적이다. 람다에서 지역변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수가 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 그렇기 때문에 람다 내부에서 사용하는 값은 복사본으로써 그 값은 변경되면 안된다는 제약이 존재한다. (그래서  무조건 final로 제약하는 것)

 

 

predicate에는 여러 and, or, negete등이 사용이 가능하다.

and와 or로 predicate를 연결 할 수 있고 negete로 반대의 값을 가져올수도 있다.

 

List<String> dish = Arrays.asList("banana", "pizza", "chicken");

Predicate<String> isBanana = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("banana");
	}
};

Predicate<String> isChicken = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("chicken");
	}
};

Predicate<String> isNotPizza = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return !s.equals("pizza");
	}
};

// predicate or
System.out.println(dish.stream().filter(isBanana.or(isChicken)).collect(Collectors.joining(", ")));

// predicate and
System.out.println(dish.stream().filter(isBanana.and(isChicken)).collect(Collectors.joining(", ")));

// predicate negate
System.out.println(dish.stream().filter(isBanana.negate()).collect(Collectors.joining(", ")));

 

 

Map의 문제 FlatMap으로 해결

- 문제상황

["hello", "world"] 두개의 단어에서 알파벳 중복된 걸 빼고 하나의 알파벳 배열로 합쳐 보자. 구하고자 하는 결과물은 다음과 같다. ["H", "e", "l", "o", "W", "r", "d"]

 

- Map으로 진행

List<String> str = Arrays.asList("hello", "world");

str.stream()
    .map(data -> data.split(""))
    .distinct()
    .collect(Collectors.toList());

이렇게 하면 각 문자열에서 문자로 쪼개고 거기서 distinct작업을 한뒤 합쳐서 성공적으로 하나의 list안에 중복된 문자가 없을 것 같지만 실제로는 ["hello"], ["world"]가 노출 된다. 

 

왜냐하면 실제로 동작하는은 다음과 같이 진행되기 때문이다.

hello → ["h","e","l","l","o"] (split하면 문자열 배열로 반환) 
world → ["w", "o", "r", "l", "d"] (split하면 문자열 배열로 반환) 

최종
→ distinct 대상이 string[] 결국 두개가 합쳐져 Stream<String[]>이 된다.

그럼 이를 해결 하기 위해서 map에서 생성된 배열이 dinstinct로 넘어갈 때 각 값을 다른 스트림으로 만든 다음 모든 스트림을 하나의 스트림으로 연결해야 하는데 이를 flatMap으로 해결이 가능하다.

 

List<String> str = Arrays.asList("hello", "world");
        System.out.println(str.stream()
            .map(data -> data.split(""))
            .flatMap(Arrays::stream)
            .distinct()
            .collect(Collectors.toList()));

map으로 반환된 Stream<String[]>에서 각 String[]을 Stream으로 변형한 후 하나의 stream으로 합쳐서 distinct를 진행하면 정상적인 결과가 도출된다.

 

flatmap은 각 요소를 별도의 스트림으로 만든 후 다시 합쳐준다. 또한 flatMap은 스트림을 받아 다른 스트림으로 변경해주는 역할을 한다.

예를 들어 (1, 2, 3) 배열과 (4, 5) 배열이 있을 때 이 두개를 합쳐서 (1, 4), (1, 5), (2, 4), (2, 5), (3, 4), (3,5) 이렇게 묶고 싶으면 다음과 같이 flatMap을 사용하여 두개를 합칠 수 있다.

List<Integer> data1 = Arrays.asList(1, 2, 3);
List<Integer> data2 = Arrays.asList(4, 5);
data1.stream()
	.flatMap(
		data -> data2.stream().map(j -> new int[] {data, j})
	)
	.collect(Collectors.toList());

만약 map(data → data.2stream().map(j → new int[] {data, })로 사용했으면 결국 최종적으로 Stream<Stream<int[]>>가 만들어지는 아쉬운 결과가 나올 수 있다.

 

 

findFirst와 findAny 차이

findFirst와 findAny는 같은 결과를 가져오지만 병렬 스트림에서는 findFirst로 첫 번째 요소를 가져오는게 어렵기 때문에 그런 경우에는 findAny를 사용한다.

 

 

parallel stream과 fork/join

parallel을 사용하여 fork/join 병렬을 진행 할 수 있다. 병렬 스트림은 기본적으로 ForkJoinPool을 사용하는데 이는 프로세스 수 즉 Runtime.getRuntime().availableProcessor()가 반환하는 값에 상응하는 스레드를 갖는다. 이 fork/join의 스레드 수는 조절이 가능하다.

 

 

Optional Serialize

Optional의 경우에 serialize를 구현하지 않았기 때문에 이는 직렬화 될 수 없다.

 

 

옳지 못한 상속 차단

코드 양이 많고 동작이 변경 되기를 원치 않는 클래스의 경우 final로 선언 또는 private 생성자를 지정한다. 이렇게 되어 있는 클래스의 기능을 이용하기 위해서는 델리게이션 즉 멤버 변수로 이용해서 클래스에서 필요한 메서드를 직접 호출하는 메서드를 작성하는 것이 좋다.

 

 

다중 상속 상황에서 동일한 이름의 메소드 우선순위

1. 클래스가 무조건 이긴다.

2. 서브 인터페이스가 이긴다. 예를 들어 B 클래스가 A 클래스를 상속받는다면 B 클래스가 무조건 A 클래스를 이긴다.

3. 이래도 불명확한 경우에는 여러 인터페이스를 상속받은 클래스에서 명시적으로 받고자 하는 곳을 지정할 수 있다.

 

 

CompletableFuture와 리액티브 프로그래밍 컨셉

값이 있을 때는 onNext, 도중에 에러가 발생했을 때는 onError, 값을 다 소진했거나 에러가 발생해서 더 이상 처리할 데이터가 없을 때 onComplete 등 각각의 콜백이 호출 된다. 이런 이벤트는 보통 순서의 개의치 않는다.

 

CompletableFuture 클래스의 join는 Future 인터페이스의 get 메소드와 동일한 의미로써 작업이 끝날 때 까지 기다린다. 다만 join은 어떠한 예외도 발생시키지 않는다는 차이점이 존재한다.

 

publisher는 subscriber가 자신을 구독하는 경우 최초로 onSubscribe를 호출하여 SubScription 객체를 전달한다. 이 Subscription 인터페이스는 publisher에게 이벤트 요청을 알리는 request 메소드가 있고 더 이상 받지 않겠다고 하는 cancel이 존대한다. 

 

publisher는 반드시 Subscription의 request 메소드에 정의된 개수 이하의 요소만 Subscriber에게 전달 할 수 있다. 해당 요청을 받은 publisher는 subscriber에게 onNext를 통해 여러번에 통해 데이터를 전달 할 수 있고 전달된 값에 따라 onComplete, onError를 통해 publisher에게 데이터 전달 성공 유무를 전달 할 수 있다. 이 대화는 publisher가 subscriber에게 전달한 subscription을 통해서 이루어진다.

 

 

 

 

 

책 전체가 조금 재미없게 구성되어 있다. 그래도 정리할 수 있어 좋았다.

댓글()

Too many dynamic script compilations 에러

elasticsearch를 사용하여 개발을 하다보면 스크립트를 사용하는 경우가 굉장히 많이 발생한다. 나는 아무생각없이 스크립트를 만들어서 사용했는데 어느날 운영에 반영을 하는데 본적이 없던 에러를 발견했다.

 

java.lang.AssertionError: 
Expecting code not to raise a throwable but caught
  <"ElasticsearchStatusException[Elasticsearch exception [type=search_phase_execution_exception, reason=all shards failed]]; nested: ElasticsearchException[Elasticsearch exception [type=circuit_breaking_exception, reason=[script] Too many dynamic script compilations within, max: [75/5m]; please use indexed, or scripts with parameters instead; this limit can be changed by the [script.max_compilations_rate] setting]];

스크립트 컴파일 에러가 발생했다고 하는데 왜 발생한 것인지... 몰라 검색해봤다.

 

 

에러 원인

elasticsearch는 기본적으로 컴파일하여 사용할 수 있는 스크립트 수를 제한을 한다고 한다. 그 제한의 기본값은 75/5m rate 즉 5분동안 75개의 스크립트만 컴파일하여 사용이 가능하다고 한다.

 

그래서 그 이상의 스크립트를 컴파일 하려고 할 시 Elasticsearch에서 out of meory 방지를 위해 circuit을 열어버린다. 

https://www.elastic.co/guide/en/elasticsearch/reference/current/circuit-breaker.html

 

근데 나는 스크립트를 그 정도로 많이 만들어서 사용하지는 않았는데 어떻게 그럴수가 있을까 하고 있던 찰나에 같이 일하는 동료분께서 내가 짠 스크립트에서 문제가 있다고 확인해주셨다.

 

바로 데이터를 스크립트에 사용되는 유동적인 데이터 중 일부가 param의 형태로 들어가지 않고 스크립트를 만들 때 동적으로 들어가게 해놨던 것이었다....

 

예를 들면 다음과 같이 스크립트를 매번 만들었던 것이었다. ㅜㅜ 그래서 당연히 초당 요청이 엄청 많은 우리 서비스에서 75요청은 그냥 넘어갔고 그 결과 서킷이 열려버려서 elasticsearch에서 400에러를 내뱉었다.

for (int i = 0; i <= 76; i++) {
	testRepository.search(QueryBuilders.scriptQuery(new Script(ScriptType.INLINE, "painless", "return 0 <= " + i + ";", Collections.emptyMap())));
}

아뿔싸 나 때문에 오전부터 고생했던 배포를 못하게 되었다 ㅜㅜ

 

 

해결방법

이 문제를 해결하기 위한 방식은 2가지가 있다.

 

 

먼저 유동적으로 들어오는 데이터로인해 스크립트가 계속 새로 컴파일 되지 못하도록 유동성 데이터는 param으로 넘겨서 사용하는 방식이다. 나 또한 이 방식으로 데이터를 바꿨다. 위의 예시 기준으로 다음과 같이 변경하였다.

for (int i = 0; i <= 76; i++) {
	testRepository.search(QueryBuilders.scriptQuery(new Script(ScriptType.INLINE, "painless", "return 0 <= params[i];", Collections.emptyMap("i", i))));
}

 

 

그리고 또다른 방식으로는 실제로 스크립트가 많이 컴파일 되어야 할 때는 상황에 맞게 그 rate를 조절해야 할 수도 있다. 이때는 다음과 같이 변경해주면된다.

// dsl
PUT http://localhost:9200/_cluster/settings
{
  "transient": {
    "script.max_compilations_rate": "150/1m"
}

// java (rest high level client)
public void changeScriptMaxCompileRate(String rate) {

	ClusterUpdateSettingsRequest request = new ClusterUpdateSettingsRequest();
	request.transientSettings(ImmutableMap.of(
		"script.max_compilations_rate", rate
	));

	restHighLevelClient.cluster().putSettings(
		request, RequestOptions.DEFAULT
	);
}

 

하지만 이와 관련된 이슈에서 다음과 같은 문구를 찾았다. 

The best solution is actually to not to increase the limit. If a test suite breaks the amount of compilations allowed, it will absolutely blow up in any serious environments.

The best solution is to figure out which painless script(s) are always recompiling, and parameterize them instead. I've had that happen on a few occasions, and I just needed to move some literal values out of the script and into params. Check out this PR: https://github.com/elastic/beats/pull/9308/files#diff-759f580883147ab049f76cd3501ec965R32

 

무조건 limit를 늘리는건 방식이 아니고 재 컴파일 되지 않도록 수정하는게 옳은 방식이라고 한다.

물론 나 때문에 문제가 발생하였지만 몰랐고 놓쳤던 부분을 알게 되어서 값비싼 수업을 들은 기분이 들었다.

댓글()

Spring boot2 resilience4j를 이용한 circuit breaker 사용

web/Spring|2020. 2. 23. 21:37

fault tolerance library (장애 허용 시스템)


fault tolerance library는 무엇인가? 간단하게 이야기해보자. MSA 환경에서 한 개의 서비스에서 다른 api를 호출 할 때 일시적으로 에러가 발생하고 있다고 가정해보자. 만약 이 시기에 요청이 계속 들어오면 계속 500에러를 내보내게 된다. 그럼 사용자들은 이 서비스에 대해서 신뢰를 잃어 버리게 되고 안좋은 인식을 만들 수 있다.

그래서 특정 api 호출과 같은 작업에 에러가 발생했을 때, 그 횟수를 정해놓고 그 횟수 이상 에러를 초과하면 기존에 설정해 놓은 fallback에 맞게 동작하게 하고 일정 시간 후에 다시 시도하여 진행하는 등에 작업이 필요하다. 이게 바로 fault tolerance library (장애 허용 시스템) 이다.

요새 같이 msa로 동작하는 환경이 많아지면서 이에 대한 작업이 많이 필요해졌다.

 

 

circuit breaker


circuit breaker는 fault tolerance library 시스템에서 사용되는 대표적인 패턴으로써 서비스에서 타 서비스 호출 시 에러가 계속 발생하게 되면 circuit를 열어서 메시지가 다른 서비슬 전파되지 못하도록 막고 미리 정의해 놓은 fallback response를 보내어 서비스 장애가 전파되지 않도록 하는 패턴이다.

 

 

resilience4j 


그럼 스프링 부트에서 어떻게 사용하면 될까? 그래서 라이브러리를 알아보다가 Hystrix가 유명하다는 걸 알게 되었다. 이 라이브러리는 netflix에서 만들어서 spring에 기본 라이브러리로 사용되었으나, 넷플릭스에서 더 이상 추가 개발 하지 않고 유지보수만 하겠다고 발표하였으며 resilience4j를 사용하기를 권고했다.

기본적으로 Resilience4j는 Ring Bit Buffer라는 곳에 결과를 저장하게 되는데 성공 여부에 따라 0(실패) 또는 1(성공)로 저장한다. 해당 buffer에 크기는 조정이 가능하다.

그래서 resilience4j를 사용해 보기로 했다.

spring boot2, webflux에서 필요한 라이브러리는 다음과 같다.

dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
    annotationProcessor group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    implementation 'org.springframework.boot:spring-boot-starter-webflux'

    //Resilience4J
    compile("io.github.resilience4j:resilience4j-spring-boot2:1.3.0")
    compile("io.github.resilience4j:resilience4j-reactor:1.3.0")
    compile("io.github.resilience4j:resilience4j-timelimiter:1.3.0")

    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'io.projectreactor:reactor-test'
}

 

resilience4j에서 사용할 수 있는 여러 옵션이 있는데 대표적인 옵션은 다음과 같다.

resilience4j:
  circuitbreaker:
    backends:
      wedul:
        ringBufferSizeInClosedState: 30
        ringBufferSizeInHalfOpenState: 30
        waitDurationInOpenState: 5000ms
        failureRateThreshold: 20
        registerHealthIndicator: false
옵션명 설명
ringBufferSizeInClosedState Returns the ring buffer size for the circuit breaker while in closed state.
Circuit이 닫혀있을 때(정상) Ring Buffer 사이즈, 기본값은 100
ringBufferSizedHalfOpenState Returns the ring buffer size for the circuit breaker while in half open state.
half-open 상태일 때 RingBuffer 사이즈 기본값은 10
waitDurationInOpenState Returns the wait duration the CircuitBreaker will stay open, before it switches to half closed
half closed전에 circuitBreaker가 open 되기 전에 기다리는 기간
failureRateThreshold Returns the failure rate threshold for the circuit breaker as percentage.
Circuit 열지 말지 결정하는 실패 threshold 퍼센테이지

 

 

Circuit Open Test


그럼 실제로 실패가 발생하였을 때 circuit이 열리고 fallback이 정상적으로 전달되고 정해진 시간내에 다시 시도하여 정상을 돌아오는지 테스트 해보자.

우선 간단하게 테스트하기 위해서 Ring Buffer 사이즈와 failureRateThreshold 수를 줄여보자.

resilience4j:
  circuitbreaker:
    backends:
      wedul:
        ringBufferSizeInClosedState: 10
        ringBufferSizeInHalfOpenState: 30
        waitDurationInOpenState: 10000ms
        failureRateThreshold: 20
        registerHealthIndicator: false

위에 설정대로라면 실패가 10개의 ringBuffer 20Percent 이상 발생하였을 때  10초동안 fallback 메시지를 보내고 api가 정상적을 돌아오면 정상적으로 돌아오는지 테스트 해보자.

우선 CircuitBreaker를 생성한다. circuitName은 yml에서 설정했던 이름과 동일하게 하면 기본 설정이 Override되어서 지정된다.

@Configuration
public class WedulConfig {

    private static final String CIRCUIT_NAME = "wedul";

    @Bean
    public io.github.resilience4j.circuitbreaker.CircuitBreaker circuitBreaker(CircuitBreakerRegistry registry) {
        return registry.circuitBreaker(CIRCUIT_NAME);
    }

}

 

그다음 webClient에 해당 Circuit Breaker를 사용하도록 지정하고 특정 api를 찌르도록 한다.

return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(HttpClient.create()
                .tcpConfiguration(tcpClient ->
                        tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, properties.getConnetTimeout())
                    )
            ))
            .uriBuilderFactory(new DefaultUriBuilderFactory(
                UriComponentsBuilder
                    .newInstance()
                    .scheme("http")
                    .host(properties.getUrl())
                    .port(properties.getPort())))
            .build()
            .get()
            .uri(uriBuilder -> uriBuilder.path("/result")
                .queryParam("name", request.getName())
                .queryParam("price", request.getPrice())
                .build()
            )
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .bodyToMono(WedulResponse.class)
            .doOnError(error -> log.error("에러 발생!!!"))
            .transform(CircuitBreakerOperator.of(circuitBreaker))
            .timeout(Duration.ofMillis(properties.getTimeout()));

 

특정 api는 name을 파라미터로 받는데 이름이 wedul이 아니면 RuntimeException을 발생시키도록 하고 wedul이면 isExist를 true로 반환하도록 한다.

// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {

    private final WedulService wedulService;

    @GetMapping("/test")
    public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
        return wedulService.circuitTest(wedulRequest)
            .map(ResponseEntity::ok);
    }

}


// 서비스
@Slf4j
@Service
public class ResultService {

    public Mono<WedulResponse> result(WedulRequest request) {
        if (!request.getName().equals("wedul")) {
            throw new RuntimeException("error");
        }

        return Mono.just(WedulResponse.builder().isExist(true).build());
    }

}

 

그리고 해당 기능을 호출할 간단한 api를 만든다,

// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {

    private final WedulService wedulService;

    @GetMapping("/test")
    public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
        return wedulService.circuitTest(wedulRequest)
            .map(ResponseEntity::ok);
    }

}

// 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class WedulService {

    private final WedulClient wedulClient;

    public Mono<WedulResponse> circuitTest(WedulRequest request) {
        return wedulClient.isWedulExist(request);
    }

}

 

도식을 간단하게 그리면 다음과 같이 circuit/test 엔드포인트로 들어온 요청을 Circuit Breaker가 설정되어있는 webClient를 사용해서 /result api를 찔러 결과를 리턴 받는다. 이때 에러가 지정한 percent이상 ring buffer에 발생하였을 때 circuit 스위치가 열리는지 확인해보면 된다.

 

먼저 10번 실행 중 3개 에러 발생 시켜서 에러 퍼센트를 만들어주면 다음 요청에 switch가 열리는지 확인해보자. 우선 10개 요청을 실패 7개 성공 3개를 나눠서 실행시켜보자.

curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:20.235+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"fdc3768a"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%                                                                                                                                                                                                                              ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:22.995+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"284c6869"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.105+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"b890c0c6"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.720+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"5bae49c7"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:25.408+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"e7121964"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:26.125+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"c500e446"}%     ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.052+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"a5f1f79c"}%

아직 까지는 circuit이 열리지 않았다. 그럼 11번째 요청부터는 circuit이 열리는지 확인해보자. 

➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.987+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"0a553bba"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:29.524+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"6fb4e23a"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:30.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"36235d1f"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:31.392+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"41a434df"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:32.371+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51661969"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:33.443+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"55be1c74"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:34.416+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"34150e67"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:35.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51a9b6a6"}%                         ➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:36.523+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"8ac98f8d"}%

정상적으로 지정해놓은 10초동안 circuit이 열려있는 걸 확인 할 수있다.

그리고 10초 뒤에 다시 실행시켜보면 정상적으로 circuit이 다시 닫혀서 요청을 실행하는걸 볼 수 있다.

➜  ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%

 

 

다른 옵션으로 retry, bulkhead등에 동작이 있는데 상황에 따라 지정해서 사용해보면 될 것 같다. 테스트에 사용했던 소스는 git에 있다.

https://github.com/weduls/circuit_breaker_test

 

weduls/circuit_breaker_test

circuit_break_test. Contribute to weduls/circuit_breaker_test development by creating an account on GitHub.

github.com

참고

https://resilience4j.readme.io/docs/circuitbreaker

https://dlsrb6342.github.io/2019/06/03/Resilience4j란/

 

댓글()

spring cloud resilience4j 사용시 CircuitBreakerConfiguration 에러

web/Spring|2020. 2. 23. 20:32

CircuitBreaker 테스트를 위해서 Resilience4j를 사용했다.

버전은 1.3.0을 사용하려고 했다.

//Resilience4J
compile("io.github.resilience4j:resilience4j-spring-boot2:1.3.0")
compile("io.github.resilience4j:resilience4j-reactor:1.3.0")
compile("io.github.resilience4j:resilience4j-timelimiter:1.3.0")

 

그런데 분명 1.3.0을 사용한다고 명시하였고 gradle도 clean하고 사용하는 denpendency도 확인하였는데 계속해서 다음과 같이 1.1.0 라이브러리를 사용하려고 해서 문제가 발행했다.

Cannot resolve method 'of(java.util.Map<java.lang.String,io.github.resilience4j.circuitbreaker.CircuitBreakerConfig>, io.github.resilience4j.core.registry.RegistryEventConsumer<io.github.resilience4j.circuitbreaker.CircuitBreaker>, io.vavr.collection.HashMap<K,V>)'

 

그래서 알아보던 중 반갑게도 git에 해당 내용에 대한 이슈가 있었다.

이유는 spring cloud를 dependencyManamement로 선언해서 사용해서였다. spring cloud에 경우 내부적으로 1.1.0 라이브러리를 사용하고 있기 때문에 버전 충돌이 나서 그런것이었다. ㅋㅋ

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

 

에휴 한시간을 방황한 것 같다.

https://github.com/resilience4j/resilience4j/issues/596#issuecomment-582697615

댓글()