박종훈 기술블로그

[MySQL] 풀 스캔 패턴 및 튜닝 - Real MySQL 스터디 5회차

K-DEVCON 대전 개발자 스터디에서 Real Mysql 책으로 스터디를 진행해보기로 했다.

발표하면서 준비한 내용을 블로그로도 옮겨보려고 한다.

이 글의 내용은 Mysql 8.0 에서 InnoDB 를 기준으로 정리되었다. 이 글은 정리글이기에 생략이 있으며, 책에서는 이전 버전이나 다른 스토리지 엔진에 대해서도 다루기도 하고 더 자세한 내용들을 다루고 있다. 책의 구성이 이미 안다는것을 전제하에 진행된 부분들도 있어 해당 부분에 대해서 보충설명을 넣기도 하였다.

참고로 대전 개발자 스터디 에 관심이 있다면 구글 폼 을 통해 신청할 수 있다.


MySQL 풀스캔 쿼리 패턴 및 튜닝

지난 시간에는 MySQL 에서 인덱스를 어떻게 처리하는지에 대해서 정리해보았다. 이번시간에는 풀 스캔이 발생되는 패턴과 어떻게 튜닝을 해야하는지에 대해서 알아본다.

먼저 그에 앞서 실행계획에 대해서 간단하게 알아본다.

실행계획 (Explain)

실행계획은 MySQL이 쿼리를 어떻게 처리할지에 대한 정보를 제공해준다. 개발자는 실행계획을 바탕으로 쿼리를 최적화 할 수 있다. 오늘 내용을 위해 중요한 부분만 간단하게 정리해본다. (더 자세한 내용은 추후에 다뤄보도록 한다.)

실행계획 사용법

실행게획은 실행하고자 하는 쿼리문의 앞에 EXPLAIN 을 붙여주면 확인해볼 수 있다.

{EXPLAIN} explainable_stmt

explainable_stmt: {
    SELECT statement
  | TABLE statement
  | DELETE statement
  | INSERT statement
  | REPLACE statement
  | UPDATE statement
}

실행계획 출력 형식

실행 계획의 주요 값은 다음과 같다.

이름설명
table참조 테이블 이름
type조인 유형 (접근 방식)
key선택한 인덱스 이름
key_len선택한 키의 길이
rows검사할 행의 수 (추정)

이 중 오늘 주요하게 볼 부분은 type 이다.

실행계획 접근 방식

type설명
system테이블에 행이 하나일 경우.
const기본 키 또는 고유키를 사용. 테이블에는 최대 하나의 일치하는 행이 있는 경우. 한 번만 읽기 때문에 매우 빠르다.
eq_ref조인 시 각 테이블에서 고유한 값(Primary 또는 Unique(+ Not Null))으로 하나의 행만 가져오는 경우 사용.
ref인덱스를 사용, 여러 개 행에 접근할 가능성이 있다. 일치하는 인덱스 값이 있는 모든 행이 읽음.
ref_or_nullref 와 비슷하지만 null 에 대해서도 처리한다.
index_merge여러 인덱스를 동시에 사용할 경우 사용. 여러 인덱스의 검색 결과를 합쳐서 최종 결과를 생성하는 방식.
range인덱스 특정 범위의 행에 접근한다. =, <>, >, >=, <, <=, IS NULL, <=>, BETWEEN, LIKE, or IN() 중 하나를 사용하여 상수와 비교될 때 사용
index인덱스를 사용하여 모든 행을 스캔. 테이블의 실제 데이터를 읽지 않고 인덱스만 사용하여 데이터를 가져올 수 있는 경우 사용 (커버링 인덱스)
all인덱스를 사용하지 않고 테이블 전체를 스캔

주의 해야 하는 타입

  • ALL, index : 테이블 또는 특정 인덱스가 전체 행에 접근하기 때문에 테이블 크기가 크면 효율이 떨어진다.
  • ref_or_null : NULL이 들어있는 행은 인덱스의 맨 앞에 모아서 저장하지만 그 건수가 많으면 MySQL 서버의 작업량이 방대해질 수 있으므로 주의

테이블 풀 스캔 패턴

  • 컬럼이 가공되는 경우
  • 인덱싱 되지 않은 컬럼을 조건절에 사용
    • 인덱싱 되지 않은 컬럼을 조건절에 OR 연산과 함께 사용
  • 복합 인덱스의 컬럼들 중 선행 컬럼을 조건에서 누락
  • LIKE 연산에서 시작 문자열로 와일드 카드를 사용
  • REGEXP 연산 사용
  • 테이블 풀 스캔이 인덱스 사용보다 더 효율적인 경우

각각의 케이스에 대해서 알아본다.

EXPLAIN 출력 값의 type을 중심으로 보면 좋다. 풀 스캔이 발생되는 패턴과 개선된 결과를 함께 정리하였다.

컬럼이 가공되는 경우

가능하면 컬럼 값을 그대로 사용할 수 있는 방향으로 설계하자.

연산

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  account_type varchar(10) NOT NULL,
  joined_at datetime NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_accounttype (account_type),
  KEY ix_joinedat (joined_at)
)
EXPLAIN SELECT * FROM users WHERE id * 1 = 12345;
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users WHERE id = 12345;
idtabletypekeykey_lenrows
1usersconstPRIMARY41

id 에 1을 곱하는 연산이 추가될 경우 type이 all로 지정된 것을 볼 수 있다. 현실에서 1을 곱할 일은 크게 없겠지만 컬럼 값이 가공되었을 경우에 대한 예시이다.

함수

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  joined_at datetime NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_joinedat (joined_at)
)
EXPLAIN SELECT * FROM users WHERE DATE(joined_at) = '2022-07-21';
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users
WHERE joined_at >= '2022-07-21 00:00:00'
AND joined_at < '2022-07-22 00:00:00';
idtabletypekeykey_lenrows
1usersrangeix_joinedat518466

DATE 함수를 사용하여 datetime에서 date만 분리하는 쿼리를 작성하였을 때 풀 스캔을 하는 것을 볼 수 있다.

형변환

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  account_type varchar(10) NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_accounttype (account_type)
)
EXPLAIN SELECT * FROM users WHERE account_type=3
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users WHERE account_type='3'
idtabletypekeykey_lenrows
1usersrefix_accounttype42104196

account_type 이 문자열(varchar)을 다루는데 where 문에서는 정수를 사용하여 풀 스캔이 되었다.

예외 (형 변환 처리 우선순위)
EXPLAIN SELECT * FROM users WHERE id=1234;
EXPLAIN SELECT * FROM users WHERE id='1234';
idtabletypekeykey_lenrows
1usersconstPRIMARY41

두 SQL 문은 동일한 실행 결과가 나오게 된다. 그 이유는 mysql 내부적으로 형변환 우선순위가 있어 숫자를 문자로 변경하는 것보다 문자를 숫자로 변경하는 것에 더 우선순위를 두기 때문에 여기서는 id 컬럼값을 형변환 처리하는것이 아니라 조건으로 주어진 문자열 값을 숫자로 형변환 하게 된다. 그래서 id 컬럼이 가공되지 않기 때문에 정상적으로 primary key 를 사용할 수 있다.

가능하면 컬럼값과 일치하게 주는것이 좋다.

인덱싱 되지 않은 컬럼을 조건절에 OR 연산과 함께 사용

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  account_type varchar(10) NOT NULL,
  joined_at datetime NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_accounttype (account_type),
)
EXPLAIN SELECT * FROM users
WHERE account_type='7' OR joined_at >= '2022-07-24 00:00:00'
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
ALTER TABLE users ADD KEY ix_joinedat (joined_at); -- 인덱스 추가

EXPLAIN SELECT * FROM users
WHERE account_type='7' OR joined_at >= '2022-07-24 00:00:00'
idtabletypekeykey_lenrows
1usersindex_mergeix_accounttype, ix_joinedat42,55492

인덱싱 되지 않은 컬럼을 조건절에 OR 연산과 함께 사용하여 풀 스캔이 되었다. 익덱스 되지 않는 컬럼인 joined_at 에 인덱스를 추가하여 성능을 개선할 수 있다.

인덱스를 무작정 추가하는 것은 좋지 않다. 상황에 따라 판단하자.

복합 인덱스의 컬럼들 중 선행 컬럼을 조건에서 누락

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  account_type varchar(10) NOT NULL,
  joined_at datetime NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_accounttype_joinedat (account_type, joined_at) -- 복합 인덱스
)
EXPLAIN SELECT * FROM users
WHERE joined_at >= '2022-07-24 00:00:00'
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users
WHERE account_type='3' OR joined_at >= '2022-07-24 00:00:00'
idtabletypekeykey_lenrows
1usersrangeix_accounttype_joinedat471112

index는 있지만 제대로 사용하지 못한 케이스라고 볼 수 있다. b-tree 는 왼쪽부터 시작하기 때문에 왼쪽이 없으면 사용할 수 없다.

LIKE 연산에서 시작 문자열로 와일드 카드를 사용

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  first_name varchar(14) NOT NULL,
  ...,
  PRIMARY KEY(id),
  KEY ix_firstname (first_name)
)
EXPLAIN SELECT * FROM users WHERE first_name LIKE '%Esther%'
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users WHERE first_name LIKE 'Esther%'
idtabletypekeykey_lenrows
1usersrangeix_firstname581

마찬가지로 b-tree는 왼쪽부터 시작한다. 따라서 왼쪽이 비어있으면 사용할 수 없다.

REGEXP 연산 사용

EXPLAIN SELECT * FROM users WHERE first_name REGEXP '^[abc]...'
EXPLAIN SELECT * FROM users WHERE first_name REGEXP '^Esther'

두 케이스에 있는 REGEX는 앞부분부터 시작하지만, 모든 컬럼에 REGEXP를 적용해봐야 하므로 풀 스캔을 하게 된다.

테이블 풀 스캔이 인덱스 사용보다 더 효율적인 경우

CREATE TABLE users (
  id int NOT NULL AUTO_INCREMENT,
  ...,
  group_name varchar(10) DEFAULT NULL,
  dormant_at datetime DEFAULT NULL,
  ...,
  PRIMARY KEY (id),
  KEY ix_groupname (group_name),
  KEY ix_dormantat (dormant_at)
)
group_namecount(*)
A150012
B149982
C15
D15
dormant_atcount(*)
NULL299994
NOT NULL30
EXPLAIN SELECT * FROM users WHERE group_name IN ('A', 'B')
idtabletypekeykey_lenrows
1usersALLNULLNULL299342
EXPLAIN SELECT * FROM users WHERE group_name IN ('C', 'D')
idtabletypekeykey_lenrows
1usersrangeix_groupname4330

데이터 통계상 대부분의 데이터가 A,B에 해당하기 때문에 이 경우에는 index가 있지만, 그래도 풀 스캔을 한다.

기타

NOT 이 들어간다고 무조건 풀 스캔인 것은 아니다.

정리

  • 컬럼이 가공되는 경우 인덱스를 사용하지 못한다.
  • 인덱싱 되지 않은 컬럼에 대한 조건을 OR 연산과 함께 사용하는 경우 인덱스를 사용하지 못한다.
  • 복합 인덱스로 구성돼있는 컬럼들 중 선행 컬럼을 제외하고 조건으로 사용하는 경우 인덱스를 사용하지 못한다.
  • LIKE 연산에서 시작 문자열로 와일드카드(%)를 사용하는 경우 인덱스를 사용하지 못한다.
  • REGEXP 연산을 사용하는 경우 인덱스를 사용하지 못한다.
  • 테이블 풀스캔이 인덱스를 사용하는 것보다 효율적인 경우 인덱스를 사용하지 않는다.

참고