explain select
distinct question.question_id as c1,
question.question_title as c2,
categorys.category_title as c3,
case
when memberquestion.member_id=2 then case
when memberquestion.question_success<>0 then 1
when memberquestion.question_fail<>0 then 2
else 0
end
else 0
end as col_3_0_
from
question question
left outer join
category categorys
on question.category_id=categorys.category_id
left outer join
member_question memberquestion
on question.question_id=memberquestion.question_id
left outer join
member members
on memberquestion.member_id=members.member_id
where
question.question_title='Sample Question 66'
and question.category_id = (
select
categorysub.category_id
from
category categorysub
where
categorysub.category_title='네트워크'
)
and members.member_id=2
and memberquestion.question_success<>1
and limit 100;
기존의 조건 검색을 실행계획을 살펴보니 다음과 같다. 현재 이 부분에 Type이 ALL로 Full Scan을 하고 있다고 생각을 했습니다.
Explain Analyze
를 통하여 실행을 순서대로 살펴보겠습니다.
SIMPLE
: UNION이나 서브쿼리를 사용하지 않는 가장 단순한 SELECT 쿼리인 경우 해당 쿼리 문장의 타입이 SIMPLE로 표시가 됩니다. union이란
하나의 테이블에 다른 테이블의 정보를 담기 위해서는 서브쿼리와 조인의 방식으로 데이터를 표현을 합니다. 하지만 다른 테이블이지만 각각의 테이블에 동일한 컬럼에 담아서 표현하고 싶은 경우에 union을 사용할 수 있습니다.
PRIMARY
: UNION이나 서브쿼리를 가지는 SELECT 쿼리의 실행 계획에서 가장 OUTER에 있는 단위 쿼리를 의미합니다.
UNION
: UNION 결합하는 단위 SELECT 쿼리 가운데 첫 번째를 제외한 두번째 이후 단위 SELECT 쿼리의 타입을 UNION으로 표시합니다.
SUBQUERY
: FROM절 이외에서 사용되는 서브쿼리만 의미를 합니다.
EXPLAIN SELECT NOW();
EXPLAIN SELECT NOW() FROM DUAL;
저느 쿼리의 실행 계획에서 가장 중요하게 보는 부분입니다. MySQL서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 나타냅니다. 여기서 방식이란 인덱스를 사용을 하였는지아니면 풀 테이블 스캔으로 레코드를 읽었는지 등 다양한 방식을 의미를 합니다.
타입에는 많은 방식이 있지만 가장 많이 살펴본 방식 몇가지를 소개를 하겠습니다.
CONST
: 테이블의 레코드 건수와 관계없이 쿼리가 pk, 유니크 키 컬럼을 사용하여 where 절을 가지고 있으며 반드시 1개만 반환이 되는 처리 방식을 의미를 합니다. 보통 이것을 유니크 인덱스 스캔이라고 말합니다.eq_ref
: 여러 테이블이 조인되는 쿼리의 실행 계획에서 표시를 합니다. 조인에서 처음 읽은 테이블의 컬럼값을 그다음 읽어야 할 테이블의 pk나 유니크 키 컬럼의 검색 조건에 사용할 때 eq_ref로 표현을 합니다.ref
: 조인의 순서와 관계없이 사용을 합니다. 보통 pk, 유니크 키등 제약 조건이 없습니다. 인덱스의 종류와 상관없이 EQUAL조건으로 검색을 할때 사용을 합니다.ALL
: 가장 문제가 되는 부분 입니다. 풀 테이블 스캔을 의미를 합니다. 테이블을 처음부터 끝까지 전부 읽어서 불필요한 레코드를 제거하고 반환을 합니다. 가장 비효율적인 방법입니다.index
: INDEX는 보통 READ의 효율을 높인다고 학습을 하였지만 실행 계획에서 INDEX는 인덱스 풀 스캔을 의미를 합니다. 범위 접근 방법과 효율적인 인덱스의 필요한 부분만 읽는 것과 다릅니다.Using filesort
: Order by를 처리하기 위해 Index를 사용할 수 있지만 인덱스가 적절하지 않는 경우에 MySQL이 조회된 레코드를 다시 정렬을 합니다. Order By 처리가 인덱스를 사용하지 못할 때만 실행 계획의 Extra 컬럼에 Using filesort가 표시가 된다.Using temporary
: MySQL 서버에서 쿼리를 처리하는 동안 중간 결과를 담아 두기 위한 임시 테이블을 사용을 합니다. 임시 테이블을 사용하기 때문에 디스크상에 생성될 수 있어 성능적인 저하가 발생을 합니다.Using Index
: 데이터 파일을 전혀 읽지 않고 인덱스만 읽어서 모두 처리할 수 있을 때 Using Index로 표시가 된다. 이것을 커버링 인덱스로 표현을 합니다. 커버링 인덱스
쿼리를 충족시키는데 필요한 모든 데이터를 갖고 있는 인덱스를 의미를 합니다.
https://frogand.tistory.com/195
Using Join buffer(Hash join)
: 일반적으로 MySQL의 옵티마이저는 조인되는 두 테이블에 있는 각 컬럼에서 인덱스를 조사하고 인덱스가 없는 테이블이 있으면 그 테이블을 먼저 읽어서 조인을 합니다.
public class QuestionRepositoryCustomImpl implements QuestionRepositoryCustom {
private final JPAQueryFactory queryFactory;
public QuestionRepositoryCustomImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
public Page<QuestionPageWithCategoryAndTitle> findQuestionPageWithCategory(Pageable pageable, QuestionSearchCondition questionSearchCondition, LoginUserDto loginUserDto) {
List<QuestionPageWithCategoryAndTitle> content = queryFactory.select(
new QQuestionPageWithCategoryAndTitle(
question.id.as("questionId"),
question.title.as("questionTitle"),
category.categoryTitle.as("categoryTitle"),
divisionStatusAboutMemberId(loginUserDto)
)).from(question)
.distinct()
.leftJoin(question.category, category)
.leftJoin(question.questions, memberQuestion)
.leftJoin(memberQuestion.member, member)
.where(
questionTitleEq(questionSearchCondition.getQuestionTitle()),
memberIdEq(questionSearchCondition.getMemberId()),
statusEq(questionSearchCondition.getStatus()),
question.category.id.eq(category.id)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
return new PageImpl<>(content, pageable, content.size());
}
private static NumberExpression<Integer> divisionStatusAboutMemberId(LoginUserDto loginUserDto) {
return Expressions.cases()
.when(memberQuestion.member.id.eq(loginUserDto.getMemberId())).then(
Expressions.cases()
.when(memberQuestion.success.ne(0)).then(1)
.when(memberQuestion.fail.ne(0)).then(2)
.otherwise(Expressions.constant(0))
)
.otherwise(Expressions.constant(0))
.as("status");
}
private BooleanExpression categoryTitleEqSubquery(String categoryTitle) {
if (StringUtils.hasText(categoryTitle)) {
JPAQuery<Long> subquery = queryFactory.select(category.id)
.from(category)
.where(category.categoryTitle.eq(categoryTitle));
return question.category.id.eq(subquery);
} else {
return null;
}
}
private BooleanExpression questionTitleEq(String questionTitle) {
return StringUtils.hasText(questionTitle) ? question.title.eq(questionTitle) : null;
}
private BooleanExpression memberIdEq(Long memberId) {
return memberId != null ? member.id.eq(memberId) : null;
}
private BooleanExpression statusEq(Integer status) {
if (status != null) {
if (status.equals(1)) {
return memberQuestion.success.ne(0);
} else if (status.equals(2)) {
return memberQuestion.fail.ne(0);
}
}
return null;
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "Category", indexes = {
@Index(name = "idx_category_title", columnList = "category_title")
})
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private Long id;
... 생략
}
@Entity
@Getter
@NoArgsConstructor
@Table(name = "question", indexes = {
@Index(name = "idx_question_title", columnList = "question_title")
})
public class Question {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "question_id")
private Long id;
...생략
}
private BooleanExpression categoryTitleEq(String categoryTitle) {
return StringUtils.hasText(categoryTitle) ? category.categoryTitle.eq(categoryTitle) : null;
}
카테고리 타이틀로 동등 조인
한 쿼리와 실행 계획을 살펴보겠습니다....생략
where
question.question_title='Sample Question 66'
and categorys.category_title='네트워크'
and members.member_id=2
and memberquestion.question_success<>1
위에 사진을 보면 category를 먼저 조인하고 데이터를 처리를 하는 모습을 살필 수 있습니다.
일단 이때 동등 비교를 하기 때문에 카테고리에 인덱스를 추가를 하였습니다.
이렇게 하였을 때 기존의 3개의 filter를 통해 full scan을 하는 쿼리에서 비교적 최적화를 시킨 모습을 살필 수 있습니다.
이 부분에서 인덱스를 추가적으로 학습을 하면서 클러스터, 논클러스터 인덱스에 대해서 학습을 하였습니다.
학습을 하면서 지금의 and categorys.category_title='네트워크'
의 비교 where절을 바꿀 수 있다고 생각을 했습니다.
where
question.question_title='Sample Question 66'
and members.member_id=2
and memberquestion.question_success<>1
and question.category_id=categorys.category_id
limit 100;
이렇게 리펙토링을 하여 다음과 같은 효과를 얻을 수 있었습니다.
1. 불필요한 Category_title 인덱스 제거
2. 논클러스터 인덱스를 타지 않고 바로 클러스터 인덱스로 더욱 빠르게 조회
- 어처피 논클러스터 인덱스도 결국 PK를 타고 조회를 해야되는데 바로 pk를 타고 조회가 가능하여 좋음
3. 실행계획의 순서가 바뀌어 추가적인 using where가 필요없음
Oracle과 다르게 MySQL은 Index를 다르게 PK를 기반해서 데이터를 찾습니다.
이때 category_title을 설정해서 비교를 하게 되면 넌 클러스터 인덱스를 찾고 이후 클러스터 인덱스를 찾아서 데이터베이스에 조회를 하게 됩니다.
이걸 클러스터 인덱스로만 가능하다면 1단계를 생략할 수 있기 때문에 좋다고 생각한다.
@Override
public Page<QuestionPageWithCategoryAndTitle> findQuestionPageWithCategory(Pageable pageable, QuestionSearchCondition questionSearchCondition, LoginUserDto loginUserDto) {
List<QuestionPageWithCategoryAndTitle> content = queryFactory.select(
new QQuestionPageWithCategoryAndTitle(
question.id.as("questionId"),
question.title.as("questionTitle"),
category.categoryTitle.as("categoryTitle"),
divisionStatusAboutMemberId(loginUserDto)
)).from(question)
.distinct()
.leftJoin(question.category, category)
.leftJoin(question.questions, memberQuestion)
.on(memberQuestion.member.id.eq(loginUserDto.getMemberId()))
.leftJoin(memberQuestion.member, member)
.where(
questionTitleEq(questionSearchCondition.getQuestionTitle()),
categoryTitleEq(questionSearchCondition.getCategoryTitle()),
memberIdEq(questionSearchCondition.getMemberId()),
statusEq(questionSearchCondition.getStatus())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(question.count())
.from(question)
.leftJoin(question.category, category)
.leftJoin(question.questions, memberQuestion)
.where(memberQuestion.member.id.eq(loginUserDto.getMemberId()))
.leftJoin(memberQuestion.member, member)
.where(
questionTitleEq(questionSearchCondition.getQuestionTitle()),
categoryTitleEq(questionSearchCondition.getCategoryTitle()),
memberIdEq(questionSearchCondition.getMemberId()),
statusEq(questionSearchCondition.getStatus())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchCount);
}
private static NumberExpression<Integer> divisionStatusAboutMemberId(LoginUserDto loginUserDto) {
return Expressions.cases()
.when(memberQuestion.member.id.eq(loginUserDto.getMemberId())).then(
Expressions.cases()
.when(memberQuestion.success.ne(0)).then(1)
.when(memberQuestion.fail.ne(0)).then(2)
.otherwise(Expressions.constant(0))
)
.otherwise(Expressions.constant(0))
.as("status");
}
private BooleanExpression categoryTitleEq(String categoryTitle) {
return StringUtils.hasText(categoryTitle) ? category.categoryTitle.eq(categoryTitle) : null;
}
private BooleanExpression questionTitleEq(String questionTitle) {
return StringUtils.hasText(questionTitle) ? question.title.contains(questionTitle) : null;
}
private BooleanExpression memberIdEq(Long memberId) {
return memberId != null ? member.id.eq(memberId) : null;
}
private BooleanExpression statusEq(Integer status) {
if (status != null) {
if (status.equals(1)) {
return memberQuestion.success.ne(0);
} else if (status.equals(2)) {
return memberQuestion.fail.ne(0);
}
}
return null;
}
explain SELECT DISTINCT
question.question_id AS questions,
question.question_title AS question_title,
categorys.category_title AS category_title,
CASE
WHEN memberquestion.member_id = 4 THEN CASE
WHEN memberquestion.question_success <> 0 THEN 1
WHEN memberquestion.question_fail <> 0 THEN 2
ELSE 0
END
else 0
END AS member_status
FROM
question question
LEFT OUTER JOIN
category categorys
ON question.category_id = categorys.category_id
LEFT OUTER JOIN
member_question memberquestion
on (question.question_id=memberquestion.question_id and memberquestion.member_id=4)
AND memberquestion.member_id = 4
LEFT OUTER JOIN
member members
ON memberquestion.member_id = members.member_id
where
question.question_title='question_title'
and memberquestion.question_success<>1
and question.category_id=categorys.category_id limit 100;
기존에 카테고리는 Full Scan에서 const로 pk, fk로 반드시 1개만 반환을 하게 설정 + 서브쿼리를 이용하여 조회에서 동등 조인으로 코드 가독성 및 서브쿼리를 제거를 하였습니다.
추가적으로 인덱스를 사용하여 Full Scan을 하는 category와 memberquestion이 const, ref로 변경을 했습니다.
테이블 풀 스캔
리펙토링
초기 성능: 366 ms
개선된 성능: 153 ms
성능 개선 차이 = 초기 성능 - 개선된 성능
성능 개선 차이 = 366 - 153 = 213
성능 개선 백분율 = (성능 개선 차이 / 초기 성능) * 100
성능 개선 백분율 = (213 / 366) * 100 = 58.20
https://velog.io/@boo105/%EC%BB%A4%EB%B2%84%EB%A7%81-%EC%9D%B8%EB%8D%B1%EC%8A%A4
https://jojoldu.tistory.com/476