서브쿼리의 결과를 조인시 인덱스를 활용하지 못하는 성능 저하문제 0 21 3,273

by daviraba [MySQL] [2022.11.09 12:48:33]


안녕하세요 오랜만에 인사드립니다.

오랜만에 남기는 글이 질문이어서 민망하네요. 날씨가 변덕이 심하니 건강 유의하세요

 

지금 진행중인 프로젝트에서 일어난 일인데요.

기본 테이블이 있고 해당 테이블을 기준으로 서브쿼리의 결과를 아우터 조인을 하여 한번에 관련 데이터를 뽑아오는 쿼리를 작성하고 있습니다.

개발 당시에는 데이터가 적어 전혀 문제가 되지 않다가 본격적으로 테스트가 진행되기 시작하면서 기본테이블에 쌓이는 데이터가

일일 40만건에서 80만건까지 늘어남에따라 성능저하가 눈에띄게 발생했습니다. 

(쿼리를 프로파일링시 Sending Data에 90%이상 집중됩니다.)

 

사실 주말에 비슷한 글을 올렸다가 스스로 방법을 먼저 찾고자 하는 마음에 튜닝관련서적을 보고 문제점을 찾았는데요

아우터 조인시 GROUP BY가 적용된 서브쿼리를 대상으로 조인을 실행하다보니 서브쿼리의 결과가 임시테이블에 저장이되고

임시테이블의 내용을 바탕으로 조인을 할때는 인덱스를 활용하지 못한다는 사실을 알게되었습니다.

 

여러 결과를 하나의 행으로 만들어서 목록을 출력해야 하므로 조인시 서브쿼리의 결과로 조인을 하긴 해야하는데

이런경우 어떠한 점을 유념해서 쿼리작성을 해야하고, 개선하기위해 어떠한식으로 접근을 해야할까요?

 

작성한 쿼리를 보여드리지 못해 죄송합니다

by 마농 [2022.11.09 13:04:45]

테이블 정보 및 인덱스 정보, 건수 정보 등이 필요합니다.
PK정보, 인덱스 정보, 전체 건수, 조건에 맞는 건수 , 집계시 건수, 기타 특이사항 등등
실 쿼리는 아니더라도 간략 쿼리 올려주시면 됩니다.


by daviraba [2022.11.09 13:34:36]

안녕하세요 마농님

게시글을 올리고 다시 한번 프로파일링을 해보는데

지연되는 항목이 Executing, converting HEAP to ondisk 두가지로 보입니다.

Executing이 MySQL 8.0 이상에서는 Sending Data 항목도 포함이 되어 표기가 된다고 책에서 봤습니다.

 

테이블 정보는 다음과 같습니다. 엔진은 모두 InnoDB 사용중입니다.

** 기본테이블 (유저정보 테이블, PK는 Unsigned,BIGINT,Auto Increment 로 되어있습니다.)

** 서브쿼리로 조인되는 두개의 테이블은 각각 해당 유저의 현재까지의 총 수입금액의 합과 지출금액의 합입니다. 

** transactions 테이블은 거래 종류를 구분하는 용도로 PK는 (Unsigned, BIGINT, Auto Increment), 인덱스는 type(INT) 컬럼에 걸려있습니다.

** transaction_details 테이블은 거래 상세내역을 입력하는 용도로 PK는 (trans_id 컬럼과, user_id 컬럼입니다. Unsigned, BIGINT,)

 

각 테이블의 전체 행 갯수는 다음과 같습니다.

users = 434

transactions = 77755

transaction_detail = 3053

 

작성한 쿼리를 대략적으로 설명하자면 다음과 같습니다.



SELECT
    users.id as uid,
    users.name as name,
    income.amount as income_amount,
    outcome.amount as outcome_amount
FROM  users
LEFT JOIN (
    SELECT
         t.user_id as user_id,
         SUM(ut.amount) as amount
    FROM
        transactions as t
    LEFT JOIN
        transaction_detail as ut ON ut.trans_id = t.id
    WHERE
        t.type = 1 AND ut.is_confirm = 1
    GROUP BY
        t.user_id
) income ON income.user_id = users.id
LEFT JOIN (
    SELECT
         t.user_id as user_id,
         SUM(ut.amount) as amount
    FROM
        transactions as t
    LEFT JOIN
        transaction_detail as ut ON ut.trans_id = t.id
    WHERE
        t.type = 2 AND ut.is_confirm = 1
    GROUP BY
        t.user_id
) outcome ON outcome.user_id = users.id

 

정리를 하다보니까 느린 이유를 도저히 모르겠네요. 

현재 사용중인 데이터베이스 서버의 사양은 4CORE, RAM 8GB 사양 사용중이며

쿼리 실행시 대략 10초정도 걸립니다


by daviraba [2022.11.09 13:47:26]

문제되는 쿼리가 따로 있었습니다. 

각 회원의 최종 잔액과 포인트도 각 행에 표기가 되는데 그부분이 문제였습니다.

문제되는 쿼리 지금 바로 남겨보겠습니다.


by 마농 [2022.11.09 13:55:26]
SELECT u.id uid
     , u.name
     , IFNULL(SUM(CASE t.type WHEN 1 THEN ut.amount END), 0)  income_amount
     , IFNULL(SUM(CASE t.type WHEN 2 THEN ut.amount END), 0) outcome_amount
  FROM users u
  LEFT JOIN transactions t
    ON t.user_id = u.user_id
  LEFT JOIN transaction_detail ut
    ON ut.trans_id = t.id
   AND ut.is_confirm = 1
 GROUP BY u.id, u.name
;

 


by daviraba [2022.11.09 14:28:25]

이런방법도 있군요. 쿼리가 많이 단순해졌어요. 많이 배웁니다 마농님 감사합니다.


by daviraba [2022.11.09 14:04:24]

문제되는 쿼리는 다음과 같습니다.

각 조인되는 테이블의 각 마지막 행을 가지고 오는데 시간이 오래 걸렸습니다

 

문제가 되는 테이블의 정보는 다음과 같습니다.

** user_balance (회원 잔액 정보 테이블) PK(Unsigned,BIGINT,Auto Increment 로 transactions 테이블에 외래키로 설정 되어있습니다.) 인덱스는 (user_id 컬럼에 걸려있습니다)

** user_point (회원 구매 포인트 테이블) PK(Unsigned,BIGINT,Auto Increment 로 transactions 테이블에 외래키로 설정 되어있습니다.), 인덱스는 (user_id 컬럼에 걸려있습니다)

 

각 테이블의 전체 행개수는 다음과 같습니다.

user_balance = 1,241,234

user_point = 1,494,156

 

작성한 쿼리는 다음과 같습니다.

SELECT
    users.id as uid,
    users.name as name,
    ub.balance as user_balance,
    up.point as user_point
FROM  users
LEFT JOIN (
    SELECT
         ub.user_id as user_id,
         ub.balance as balance,
         ROW_NUMBER() OVER(PARTITION BY ub.user_id ORDER BY ub.created_at DESC) rn
    FROM
        user_balance as ub
) ub ON ub.user_id = users.id AND ub.rn = 1
LEFT JOIN (
    SELECT
         up.user_id as user_id,
         up.point as point,
         ROW_NUMBER() OVER(PARTITION BY up.user_id ORDER BY up.created_at DESC) rn
    FROM
        user_point as up
) ub ON up.user_id = users.id AND up.rn = 1

 

책에서 보니 WHERE 절에 따라서 인덱스를 타지 못하는경우도 있다고 하던데 그부분 문제는 아닌것 같고

최종 잔액을 윈도우함수로 불러오는게 문제인것 같다는 생각이 방금 들었습니다.

각 멤버별 최종잔액을 구하기 위해 저렇게 쿼리를 작성했는데 문제가 발생한게 맞는것인지 아니면 쿼리자체가 헤비해서 

집계용 테이블을 만들어서 관리를 해야하는지 궁금합니다.


by 마농 [2022.11.09 14:12:38]

rn = 1 조건을 아우터 조인 조건으로 주지 말고 조인 전에 미리 걸러내 보세요.
인덱스 정보는 어떻게 되나요?
(user_id + created_at) 으로 이루어진 인덱스는 없나요?


by 마농 [2022.11.09 14:18:04]
-- (user_id + created_at) 으로 이루어진 인덱스가 있다면?
-- 다음 스칼라서브쿼리 방법을 시도해 볼 수 있습니다.
SELECT u.id uid
     , u.name
     , (SELECT balance
          FROM user_balance
         WHERE user_id = u.id
         ORDER BY created_at DESC
         LIMIT 1
        ) user_balance
     , (SELECT point
          FROM user_point
         WHERE user_id = u.id
         ORDER BY created_at DESC
         LIMIT 1
        ) user_point
  FROM users u
;

 


by daviraba [2022.11.09 14:27:15]

아.. 마농님 늘 많이 배웁니다.

(user_id + created_at) 로 인덱스가 구성되어있지 않습니다.

해당 (user_id + created_at)으로 인덱스를 구성하면 스칼라서브쿼리에서도 성능저하가 미비한가요?

(질문전 미리 시도해보면 되지만 테스트가 실시간으로 계속 진행중이고 사실 어제 새벽에 테스트중인 데이터베이스에 인덱스를 추가하다가 쓰기 락이걸려서 결국 데이터베이스 서버를 재시작후 해결해서 바로 시도하기가 겁이나서 여쭤봅니다)

사실 몇달전 작성했었던 쿼리가 마농님이 작성해주신 형태의 스칼라서브쿼리형태로 작성을 했었는데 너무 헤비해서 다른 방법으로 쿼리를 작성했었거든요..

결국은 인덱스 문제로 귀결이 되는것 같네요..

데이터베이스가 하나씩 알아가다보면 프로그래밍보다 재밋는데, 실수하면 댓가가 너무 커서 아직 미숙한 저로서는 너무 무섭네요...


by 마농 [2022.11.09 14:41:18]

스칼라서브쿼리는 유저 수만큼 반복 수행됩니다. 434회
해당 인덱스가 있다면? 인덱스 스캔 1행만 읽고 바로 끝납니다.
400번 반복하지만 1행만 읽고 끝나므로 큰 부담이 없습니다.
해당 인덱스가 없거나? 유저ID 인덱스만 있다면?
스칼라서브쿼리는 오히려 더 큰 부담이 될 수 있습니다.

rn = 1 조건을 인라인뷰 안으로 넣는 방법은 시도해 보셨나요?
인덱스가 없는 상황에서는 이 방법이 좋을 것 같습니다.


by daviraba [2022.11.09 14:51:50]

네 마농님 인덱스가 걸려있지 않은 상황에서 스칼라 서브쿼리를 하니 전체 소요시간이 10초 => 3초로 줄었습니다.

괜찮으시다면 질문 하나 더 드리고 싶습니다.

 

1. SQL Anti Pattern이라는 책에서 인덱스를 남발하면 오히려 성능저하가 있을 수 있다는 내용을 봤습니다. 구글에서 검색하거나 최근 본 튜닝책에서는 검색 조건에 따라 인덱스를 주는게 성능 향상에 좋다고 하는데, 인덱스를 남발하지 않고 검색조건에 따라 인덱스를 묶어서 주는 두가지를 만족하기 위해서는 서비스 전반적으로 일관된 형태의 쿼리를 작성해야한다고 이해가 되는데 마농님 의견은 어떠신가요? (최종 잔액을 구하는 경우 [user_id, created_at] 으로만 검색) 

2. 윈도우 함수를 최근 관심가지고 사용하게 되면서 조금 햇갈립니다. rn = 1 조건을 인라인뷰 안으로 넣는 예시 하나 보여주실 수 있으시면 감사드리겠습니다.


by 마농 [2022.11.09 15:21:08]

소요시간이 줄었다면?                                            
- 그나마 유저ID 인덱스가 있어서 그렇지 않을까? 생각되네요.      
- (유저ID, 일자) 결합인덱스가 있으면 획기적인 개선이 예상됩니다.
 


by 마농 [2022.11.09 15:00:59]

두가지 목적이 상충되어서 그렇습니다.
검색속도를 향상시킬 것인가?
입력속도를 향상시킬 것인가?
인덱스와 같은 추가적인 관리 요소는 입력속도를 저하시키게 됩니다.
너무 많으면 안되고, 그렇다고 아예 만들지 말아야 하는 것도 아니고
적당히 만들어야 합니다.
자주 사용되는 쿼리에서 속도가 안나온다면 만들어야 합니다.
가끔 사용하는 쿼리에서 속도가 안나온다면 인내해야 하겠죠.

정규화 만으로 속도를 만족하지 못할 경우 반정규화를 고려할 수 있습니다.
- 사용자 테이블에 최종 잔액과 포인트를 두는 방안
- 잔액 및 포인트 테이블에 최종 여부 항목을 관리하는 방안, 인덱스 필요(유저ID + 최종여부)


by daviraba [2022.11.09 15:08:42]

의견 감사합니다 이해가 확실히 되었습니다.

책만보고 이해하며 실무에 바로 작업하다보니 이해가 안되는 부분이 많았어요.

말씀과 같이 인덱스는 목적에 따라서 사용해야겠네요.  실행계획의 중요성을 오늘 확실히 이해했습니다.

구루비에서 정말 많은것을 배워갑니다.


by 마농 [2022.11.09 15:07:36]
-- 1. rn = 1 조건을 안으로 넣는 예제
SELECT u.id uid
     , u.name
     , ub.user_balance
     , up.user_point
  FROM users u
  LEFT OUTER JOIN
       (SELECT *
          FROM (SELECT user_id
                     , balance user_balance
                     , ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) rn
                  FROM user_balance
                ) a
         WHERE rn = 1
        ) ub
    ON u.id = ub.user_id
  LEFT OUTER JOIN
       (SELECT *
          FROM (SELECT user_id
                     , point user_point
                     , ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) rn
                  FROM user_point
                ) a
         WHERE rn = 1
        ) up
    ON u.id = up.user_id
;

-- 2. MAX 쿼리를 이용하는 예제
SELECT u.id uid
     , u.name
     , ub.user_balance
     , up.user_point
  FROM users u
  LEFT OUTER JOIN
       (SELECT user_id
             , SUBSTR(
               MAX(CONCAT(DATE_FORMAT(created_at, '%Y%m%d'), balance))
               , 9, 20) user_balance
          FROM user_balance
         GROUP BY user_id
        ) ub
    ON u.id = ub.user_id
  LEFT OUTER JOIN
       (SELECT user_id
             , SUBSTR(
               MAX(CONCAT(DATE_FORMAT(created_at, '%Y%m%d'), point))
               , 9, 20) user_point
          FROM user_point
         GROUP BY user_id
        ) up
    ON u.id = up.user_id
;

 


by daviraba [2022.11.09 15:23:45]

감사합니다 마농님

MAX 쿼리로 해보니 눈에 띌 정도로 개선이되었습니다. 10초 => 4초

현재 테스트중인 서버에 적용후 향후 인덱스를 추가해서 개선해 보겠습니다.

오늘 많은 질문 답변해주셔서 너무 감사드립니다.

책에서도 구할수 없는 것들을 정말 많이 배웠습니다.

감사드립니다


by 마농 [2022.11.09 15:48:59]

rn = 1 쿼리와 MAX 쿼리는 각각 수행시간이 어떻게 될까요?
- 조인 하기 전 각 쿼리의 수행시간


by daviraba [2022.11.09 16:19:35]

조인하기전 MAX 쿼리를 단독으로 실행하면 잔액테이블, 포인트 테이블 각각 1.02~3초까지 나옵니다.

조인하기전 rn = 1 쿼리를 단독으로 실행하면 잔액테이블, 포인트 테이블 각각 0.8~2초까지 나옵니다

(이는 계속 새로운 데이터가 입력되고 있어서인가요? 실행시마다 편차가 크네요. 서버성능탓인것 같기도 하구요)

 


by 마농 [2022.11.09 16:42:02]

각각의 쿼리가 평균 1~2 초 걸린다고 가정하면
조인 쿼리 3~4 초는 적당한 것 같습니다.


by daviraba [2022.11.09 16:55:27]

의견 감사합니다 마농님.

지식으로 채우다가 그 지식으로 실전을 해보니까 많이 다르네요.

실제 실무를 해보니 변수도 많고 몰랏던 사실도 알게 되구요.

오늘 많이 알려주셔서 너무 감사드립니다

곧 저녁시간인데 식사 맛있게 하세요 감사합니다.


by daviraba [2022.11.16 14:46:51]

조언대로 처리 해본 후기를 남깁니다.

마농님 말씀대로 결합인덱스로 획기적인 개선을 경험했습니다 (총 20초 이상 걸리는 쿼리 => 1초)

획기적인 개선에 대한 경험이후 욕심이 생겨 지연이 되던 다양한 쿼리에 대해 조건에 따른 결합인덱스를 걸어봤습니다.

이후 다시 쿼리가 지연되는 현상이 발생했습니다.

해당 증상에 대해 찾아보니 테이블에 다양한 인덱스가 여러개가 걸려있으면 지연되는 현상이 발생할 수 있다고 하더군요.

(정확한 표현인진 모르겠습니다만, 옵티마이저가 쿼리 실행시 의도하지 않은 인덱스를 선택한다는 의미로 봤습니다)

그래서 이건 제 지식부족에 따른 문제라 생각하고 공부중입니다.

다시한번 조언해주신 마농님께 감사드립니다.

댓글등록
SQL문을 포맷에 맞게(깔끔하게) 등록하려면 code() 버튼을 클릭하여 작성 하시면 됩니다.
로그인 사용자만 댓글을 작성 할 수 있습니다. 로그인, 회원가입