RealMySQL 책을 읽고 노션에 정리한 글을 옮긴 것입니다.
mysql> SELECT COUNT (*) FROM employees
mysql> SELECT * FROM employees
장점 | 단점 | |
---|---|---|
인덱스 이용 | INSERT, UPDATE, DELETE 쿼리가 실행될 때 이미 인덱스가 정렬돼 있어서 순서대로 읽기만 하면 되므로 매우 빠르다. | INSERT, UPDATE, DELETE 작업 시 부가적인 인덱스 추가/삭제 작업이 필요하므로 느리다. 인덱스 때문에 디스크 공간이 더 많이 필요하다. 인덱스의 개수가 늘어날수록 InnoDB의 버퍼 풀을 위한 메모리가 많이 필요하다. |
Filesort 이용 | 인덱스를 생성하지 않아도 되므로 인덱스를 이용할 때의 단점이 장점으로 바뀐다. 정렬해야 할 레코드가 많지 않으면 메모리에서 Filesort 가 처리되므로 충분히 빠르다 | 정렬 작업이 쿼리 실행시 처리되므로 레코드 대상 건수가 많아질수록 쿼리의 응답 속도가 느리다. |
정렬 기준이 너무 많아서 요건별로 모두 인덱스를 생성하는 것이 불가능한 경우
GROUP BY의 결과 또는 DISTINCT 같은 처리의 결과를 정렬해야 하는 경우
UNION의 결과와 같이 임시 테이블의 결과를 다시 정렬해야 하는 경우
랜덤하게 결과 레코드를 가져와야 하는 경우
정렬을 수행하기 위한 별도의 메모리 공간, 가변적이며 정렬이 필요한 경우에만 할당된다.
소트 버퍼 공간보다 정렬해야할 레코드가 많다면 레코드를 여러 조각으로 분할 한 후, 정렬 한 후 그 결과를 디스크에 저장하고 정렬하기를 반복한다. 임시로 디스크에 저장한 레코드를 병합하는데 이를 멀티 머지(Multi-merge)라고 표현한다.
소트 버퍼 크기가 클수록 성능이 좋아지는 것이 아니다(56kb ~ 1MB) 가 적당하다.
소트버퍼는 글로벌 메모리 영역이 아닌 세션 메모리 영역이다. 고로 클라이언트가 많아지면 많아질 수록 소비되는 메모리 공간이 커진다.
싱글 패스(Single-pass) 알고리즘
투 패스(Two-pass) 알고리즘
싱글 패스 방식은 정렬 대상 레코드의 크기나 건수가 작은 경우 빠른 성능을 보이며, 투 패스 방식은 정렬 대상 레코드의 크기나 건수가 상당히 많은 경우 효율적이라 볼 수 있다.
ORDER BY 가 쿼리에 사용되면 반드시 다음 3가지 처리 방법 중 하나로 정렬이 처리된다.
정렬 처리 방법 | 실행 계획의 Extra 칼럼 내용 |
---|---|
인덱스를 사용한 정렬 | 별도 표기 없음 |
조인에서 드라이빙 테이블만 정렬 | "Using filesort” 메시지가 표시됨 |
조인에서 조인 결과를 임시 테이블로 저장 후 정렬 | “Using temporary; Using filesort” 메시지가 표시됨 |
인덱스를 이용한 정렬
인덱스가 정렬을 활용할 수 있는 경우
CREATE INDEX idx_status_created_at ON payments (status, created_at);
SELECT * FROM payments WHERE status = 'completed' ORDER BY created_at;
WHERE status = 'completed'
로 인해 status
를 먼저 검색
이후 created_at
기준으로 이미 인덱스에서 정렬된 상태이므로 추가적인 정렬 과정(Filesort)이 필요 없음.
인덱스가 정렬을 활용할 수 없는 경우
SELECT * FROM payments ORDER BY created_at, status;
(created_at, status) 인덱스가 없고, (status, created_at)만 있는 경우 인덱스를 사용할 수 없다.
ORDER BY
대상 컬럼의 순서가 인덱스 순서와 일치해야 함.
⇒ 해결 방법
CREATE INDEX idx_created_at_status ON payments(created_at, status);
인덱스를 활용한 정렬이 가능한 경우
CREATE INDEX idx_status_created_at ON payment(status, created_at);
SELECT * FROM payments WHERE status = 'completed' ORDER BY created_at;
WHERE status = 'completed'
을 만족하는 데이터만 조회.
ORDER BY created_at
이 인덱스 순서를 그대로 따르므로 추가적인 정렬이 필요 없음.
인덱스를 활용한 정렬이 불가능한 경우
SELECT * FROM payments WHERE amount > 100 ORDER BY created_at;
WHERE amount > 100
에 대한 인덱스가 없거나.
ORDER BY created_at
과 같은 인덱스를 사용할 수 없는 경우 추가적인 정렬 과정(Filesort)이 발생
⇒ 해결 방법
CREATE INDEX idx_amount_created_at ON payments(amount, created_at);
⇒ WHERE 와 ORDER BY 가 같은 인덱스를 사용하도록 최적화 가능
예제 테이블
CREATE TABLE customers (
customer_id BIGINT PRIMARY KEY,
name VARCHAR(255)
);
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT,
total_price DECIMAL(10,2),
INDEX idx_customer_id (customer_id) -- 인덱스 생성
);
조인 쿼리
SELECT c.customer_id, c.name, o.order_id, o.total_price
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
Nested Loop Join 동작 과정
드라이빙 테이블(Outer Table, customers)을 하나씩 읽음
각 행에 대해, 드리븐 테이블(Inner Table, orders)에서 매칭되는 행을 찾음.
매칭된 행이 있으면 결과로 반환
반복적으로 모든 customers 테이블의 행을 확인하면서 orders 테이블과 비교
⇒ 즉, 바깥 루프(드라이빙 테이블)에서 행을 하나 가져오고, 안쪽 루프 (드리븐 테이블)에서 해당 행과 매칭되는 데이터를 찾는 구조
조인의 드라이빙 테이블만 정렬
mysql> SELECT * FROM employees e, salaries s WHERE s.emp_no=e.emp_no AND e.emp_no
BETWEEN 100002 AND 100010 ORDER BY e.last_name;
드라이빙 테이블 정렬 후 조인하는 과정
인덱스를 이용해 “emp_no BETWEEN 100001 AND 100010” 조건을 만족하는 9건을 검색
검색 결과를 last_name 칼럼으로 정렬을 수행 (Filesort)
정렬된 결과를 순서대로 읽으면서 salaries 테이블과 조인을 수행해 86건의 최종 결과를 가져옴
임시 테이블을 이용한 정렬
조인의 드라이빙 테이블만 정렬
외 의 상황에서는 임시 테이블을 사용한다.mysql> SELECT * FROM employees e, salaries s WHERE s.emp_no=e.emp_no AND
e.emp_no BETWEEN 100002 AND 100010 ORDER BY s.salary;
정렬 처리 방법의 성능 비교
- LIMIT 를 ORDER BY, GROUP BY 랑 같이 사용하면 ORDER BY, GROUP BY 가 된 상태에서 LIMIT 해서 값을 가져온다.
1. 스트리밍 방식
- 서버 쪽에서 처리할 데이터가 얼마인지 관계없이 조건에 일치하는 레코드가 검색될때마다 바로바로 클라이언트로 전송해주는 방식을 의미한다.
- 쿼리가 얼마나 많은 레코드를 조회하느냐에 상관없이 빠른 응답 시간을 보장해준다.
- 스트리밍 방식으로 처리되는 쿼리에서 LIMIT 처럼 결과 건수를 제한하는 조건들은 쿼리의 전체 실행 시간을 상당히 줄여준다. 하지만 LIMIT, OFFSET 일 경우는 다르다.
2. 버퍼링 방식
- ORDER BY나 GROUP BY 같은 처리는 쿼리의 결과가 스트리밍되는 것을 불가능하게 한다.
- MySQL 서버에서는 모든 레코드를 검색하고 정렬 작업을 하는 동안 클라이언트는 아무것도 하지 못한다.
- 결과를 모은다음 MySQL 서버에서 일괄 가공해야한다.
- 버퍼링 방식으로 처리되는 쿼리는 LIMIT 처럼 결과 건수를 제한하는 조건이 있어도 성능 향상에는 별로 도움이 되지 않는다.
> JDBC 라이브러리는 MySQL 서버에서 스트리밍 처리해서 보내는 데이터를 버퍼에 담아뒀다가 한번에 클라이언트로 반환한다. 그 이유는 전체 처리 시간이 짧고 MySQL 서버와의 통신 횟수가 적어 자원 소모가 줄어들기 때문이다.
>
- 인덱스 정렬만 스트리밍 형태이며 나머지 두가지는 버퍼링된 후에 정렬된다.
조인의 드라이빙 테이블에 속한 칼럼만 이용해 그루핑 할때 GROUP BY 칼럼으로 인덱스가 있다면 인덱스를 이용해서 결과를 처리한다.
GROUP BY가 인덱스를 사용해서 결과를 처리한다고 해도 그룹함수(Aggregation function) 등 의 그룹값을 처리해야 해서 임시 테이블이 필요할 때도 있다.
그루핑 방식을 사용하는 쿼리의 실행 계획에서는 Extra 칼럼에 별도로 GROUP BY 관련 코멘트 (”Using index for group-by”)나 임시 테이블 사용 또는 정렬 관련 코멘트(”Using temporary, Using filesort”)가 표시되지 않는다.
옵티마이저가 루스 인덱스 스캔을 사용할 때는 실행 계획의 Extra 칼럼에 “Using index for group-by” 코멘트가 표시된다.
mysql> EXPLAIN SELECT emp_no FROM salaries WHERE from_date='1985-03-01' GROUP BY emp_no;
WHERE 조건이 범위가 아니기 때문에 레인지 스캔 접근 방식으로 사용 할 수 없는 쿼리지만 이 쿼리는 인덱스 레인지 스캔 (Range 타입) 을 이용했다.
+---+---------------+-------------+----------+----------------------------------------+
| id| table | type | key | Extra |
+---+---------------+-------------+----------+----------------------------------------+
| 1 | salaries | range | PRIMARY | Using where; Using index for group - by|
+---+---------------+-------------+----------+----------------------------------------+
단일 테이블에 대해 수행되는 GROUP BY 처리에만 사용할 수 있다. JOIN TABLE 에서는 사용 할 수 없다.
프리픽스 인덱스는 루스 인덱스를 사용 할 수 없다.
루스 인덱스 스캔은 인덱스 레인지 스캔과는 반대로 유니크한 값이 적을수록 성능이 향상된다.
루스 인덱스 스캔을 사용할 수 있는 쿼리
SELECT col1, col2 FROM tb_test GROUP BY col1, col2;
SELECT DISTINCT col1, col2 FROM tb_test;
SELECT col1, MIN(col2) FROM tb_test GROUP BY col1;
SELECT col1, col2 FROM tb_test WHERE col1 < const GROUP BY col1, col2;
SELECT MAX(col3), MIN(col3), col1, col2 FROM tb_test WHERE col2 > const GROUP BY col1, col2;
SELECT col2 FROM tb_test WHERE col1 < const GROUP BY col1, col2;
SELECT col1, col2 FROM tb_test WHERE col3 = const GROUP BY col1, col2;
루스 인덱스 스캔을 사용할 수 없는 쿼리
--// MIN() 과 MAX() 이외의 집합 함수가 사용됐기 때문에 루스 인덱스 스캔은 사용 불가
SELECT col1, SUM(col2) FROM tb_test GROUP BY col1;
--// GROUP BY 에 사용된 칼럼이 인덱스 구성 칼럼의 왼쪽부터 일치하지 않기 때문에 사용 불가
SELECT col1, col2 FROM tb_test GROUP BY col2, col3;
--// SELECT 절의 칼럼이 GROUP BY와 일치하지 않기 때문에 사용 불가
SELECT col1, col3 FROM tb_test GROUP BY col1, col2;
인덱스를 사용할 수 없는 GROUP BY 일 경우는 임시 테이블을 사용해서 쿼리 결과를 도출한다.
GROUP BY 만 있을 시에는 “Using temporary” 메시지만 표시되고 ORDER BY 가 있어야 “Using Filesort”를 사용한다.
MySQL 8.0 부터는 GROUP BY 에 ORDER BY를 같이 사용하지 않으면 따로 정렬을 하지 않는다.
ORDER BY 를 사용하지 않는 GROUP BY 랑 내부적으로 같은 작업을 수행한다.
mysql> SELECT DISTINCT emp_no FROM salaries;
mysql> SELECT emp_no FROM salaries GROUP BY emp_no;
DISTINCT는 SELECT 하는 레코드 자체를 유니크하게 해준다.
mysql> SELECT DISTINCT first_name, last_name FROM employees;
first_name 만 유니크하게 하는 것이 아닌 (first_name, last_name) 조합 전체가 유니크한 레코드가 된다.
COUNT(), MAX(), MIN() 같은 집합 함수와 같이 사용 하는 경우는 일반 SELECT DISTINCT 와는 다르게 작동한다.
mysql> EXPLAIN SELECT COUNT(DISTINCT s.salary) FROM employees e, salaries s WHERE
e.emp_no=s.emp_no AND e.emp_no BETWEEN 100001 AND 100100;
+-----+-----------+---------+-----------+-------------------------------------------+
| id | table | type | key | rows Extra |
+-----+-----------+---------+-----------+-------------------------------------------+
| 1 | e | range | PRIMARY | 100 Using where; Using Index |
| 2 | s | ref | PRIMARY | 10 NULL |
+-----+-----------+---------+-----------+-------------------------------------------+
이 쿼리는 COUNT(DISTINCT s.salary)
를 처리하기 위해 임시 테이블을 사용한다.
하지만 MySQL 의 실행계획 에서는 “Using Temporary”를 표시하지 않는다.
mysql> SELECT COUNT(DISTINCT s.salary), COUNT(DISTINCT e.last_name) FROM employees e,
salaries s WHERE e.emp_no=s.emp_no AND e.emp_no BETWEEN 100001 AND 100100;
위의 쿼리 처럼 COUNT(DISTINCT e.last_name)
을 추가 한 경우 임시 테이블이 하나 더 생성된다.
mysql> SELECT COUNT(DISTINCT emp_no) FROM employees;
mysql> SELECT COUNT(DISTINCT emp_no) FROM dept_no GROUP BY dept_no;
집합 함수를 사용하지만 임시 테이블 없이 처리되는 경우는 DISTINCT 를 처리할 때 사용하는 것이 인덱스로 되어있으면 풀 스캔이나 레인지 스캔을 통해 임시테이블 없이 최적화로 처리 될 수 있다.
CREATE TEMPORARY TABLE
명령으로 만들어진 임시 테이블과는 다르다. 쿼리가 종료되면 자동으로 삭제된다.기존에는 임시 테이블이 메모리를 사용할때는 MEMORY 스토리지 엔진을 사용하고, 디스크에 저장될 때는 MyISAM 스토리지 엔진을 사용한다.
8.0 버전 부터는 메모리는 TempTable 이라는 스토리지 엔진을 사용하고, 디스크에 저장되는 임시 테이블은 InnoDB 스토리지 엔진을 사용한다.
temptable_max_ram
최대 사용 가능한 공간의 메모리 크기를 제어할 수 있는데 기본값이 1GB 로 되어있다. 만약 1GB 를 넘어가게 되면 디스크에 임시 테이블이 저장되고 이는 InnoDB 스토리지 엔진을 사용한다.
디스크 저장방법은 MMAP 파일로 디스크에 기록, InnoDB 테이블로 기록 두가지 방법이 있다.
MMAP 파일로 전환하는 것이 InnoDB 테이블로 전환하는 것보다 오버헤드가 적다.
임시 테이블이 메모리로 처음부터 생성되는게 아니라 바로 디스크로 생성되는 경우도 있다.
ORDER BY와 GROUP BY에 명시된 칼럼이 다른 쿼리
ORDER BY 나 GROUP BY에 명시된 칼럼이 조인의 순서상 첫 번째 테이블이 아닌 쿼리
DISTINCT와 ORDER BY가 동시에 쿼리에 존재하는 경우 또는 DISTINCT가 인덱스로 처리되지 못하는 쿼리
UNION이나 UNION DISTINCT가 사용된 쿼리(select_type 칼럼이 UNION RESULT인 경우)
쿼리의 실행 계획에서 select_type이 DERIVED 인 쿼리
UNION 이나 UNION ALL 에서 SELECT되는 칼럼 중에서 길이가 512바이트 이상인 크기의 칼럼이 있는 경우
GROUP BY나 DISTINCT 칼럼에서 512바이트 이상인 크기의 칼럼이 있는 경우
메모리 임시 테이블의 크기가 (MEMORY 스토리지 엔진에서) tmp_table_size 또는 max_heap_table_size 시스템 변수보다 크거나 (TempTable 스토리지 엔진에서) temptable_max_ram 시스템 변수 값보다 큰 경우
Using temporary
표시가 되어있다고 임시 테이블이 메모리에서 처리됐는지, 디스크에서 처리 됐는지, 한번만 사용됐는지 여러번 사용 됐는지 알 수 없다.
임시 테이블이 디스크에 생성됐는지 메모리에 생성됐는지 확인하기 위해서 SHOW SESSION STATUS LIKE 'Created_tmp%';
를 사용해보면 된다.
옵티마이저 스위치 이름 | 기본값 | 설명 |
---|---|---|
batched_key_access | off | BKA 조인 알고리즘을 사용할지 여부 설정 |
block_nested_loop | on | BLOCK Nested Loop 조인 알고리즘을 사용할지 여부 설정 |
engine_condition_pushdown | on | Engine Condition Pushdown 기능을 사용할지 여부 설정 |
index_condition_pushdown | on | Index Condition Pushdown 기능을 사용할지 여부 설정 |
use_index_extensions | on | Index Extension 최적화를 사용할지 여부 설정 |
index_merge | on | Index Merge 최적화를 사용할지 여부 설정 |
index_merge_intersection | on | Index Merge Intersection 최적화를 사용할지 여부 설정 |
index_merge_sort_union | on | Index Merge Sort Union 최적화를 사용할지 여부 설정 |
index_merge_union | on | Index Merge Union 최적화를 사용할지 여부 설정 |
mrr | on | MRR 최적화를 사용할지 여부 설정 |
mrr_cost_based | on | 비용 기반의 MRR 최적화를 사용할지 여부 설정 |
semijoin | on | 세미 조인 최적화를 사용할지 여부 설정 |
firstmatch | on | FirstMatch 세미 조인 최적화를 사용할지 여부 설정 |
loosescan | on | LooseScan 세미 조인 최적화를 사용할지 여부 설정 |
materialization | on | Materialization 최적화를 사용할지 여부 설정(Materialization 세미 조인 최적화 포함) |
subquery_materialization_cost_based | on | 비용 기반의 Materialization 최적화를 사용할지 여부 설정 |
조인 버퍼
공간에 드라이빙 레코드를 일정 넣어놓는다.조인 버퍼
에 있는 레코드가 일정 이상 차면 드리븐 레코드와 기존에 버퍼에 저장된 레코드와 한번에 JOIN 이 된다. 예를 들어 기존 방식은 1000개의 드라이빙 레코드가 있어서 JOIN 할때 I/O가 1000번 일어났다면 조인버퍼에 1000개를 한번에 넣고 I/O 를 발생시키면 JOIN 이 1번 발생하는거다. SELECT * FROM employees e JOIN salaries s ON e.emp_no = s.emp_no
조인 버퍼
라고 한다.mysql> SELECT * FROM dept_emp de, employees e WHERE de.from_date > '1995-01-01'
AND e.emp_no < 109004;
mysql> ALTER TABLE employees ADD INDEX ix_lastname_firstname (last_name, first_name);
mysql> SET optimizer_switch='index_condition_pushdown=off';
mysql> SHOW VARIABLES LIKE 'optimizer_switch'
Variable_name : optimizer_switch
VALUE : ..., index_condition_pushdown=off,...
mysql> SELECT * FROM employees WHERE last_name='ACTION' AND first_name LIKE '%sal'
mysql> CREATE TABLE dept_emp (
emp_no INT NOT NULL,
dept_no CHAR(4) NOT NULL,
from_date DATE NOT NULL,
to_date DATE NOT NULL,
PRIMARY KEY (dept_no, emp_no),
KEY ix_fromdate (from_date)
) ENGINE=InnoDB;
mysql> EXPLAIN SELECT COUNT(*) FROM dept_emp WHERE from_date='1987-07-25'
AND dept_no='d001'
+----+--------------+---------+----------+------------+-----------+------------+
| id | select_type | table | type | key | key_len | ref |
+----+--------------+---------+----------+------------+-----------+------------+
| 1 | SIMPLE |dept_mep | ref | ix_fromdate| 19 | const,const|
+----+--------------+---------+----------+------------+-----------+------------+
mysql> EXPLAIN SELECT COUNT(*) FROM dept_emp WHERE from_date='1987-07-25';
+----+--------------+---------+----------+------------+-----------+------------+
| id | select_type | table | type | key | key_len | ref |
+----+--------------+---------+----------+------------+-----------+------------+
| 1 | SIMPLE |dept_mep | ref | ix_fromdate| 3 | const |
+----+--------------+---------+----------+------------+-----------+------------+
mysql> EXPLAIN SELECT COUNT(*) FROM dept_emp WHERE from_date='1987-07-25'
ORDER BY dept_no;
+----+--------------+---------+----------+------------+-----------+------------+
| id | select_type | table | type | key | key_len | EXTRA |
+----+--------------+---------+----------+------------+-----------+------------+
| 1 | SIMPLE |dept_mep | ref | ix_fromdate| 3 | NULL |
+----+--------------+---------+----------+------------+-----------+------------+
mysql> EXPLAIN SELECT * FROM employees WHERE first_name = 'Georgi'
AND emp_no BETWEEN 10000 AND 20000;
+-------------+--------------------+---------+---------------------------------------------------+
| type | key | key_len | Extra |
+-------------+--------------------+-------------------------------------------------------------+
| index_merge |ix_firstname,PRIMARY| 62, 4 |Using intersect(ix_firstname,PRIMARY); Using where |
+-------------+--------------------+---------+---------------------------------------------------+
각 인덱스에서 조건을 만족하는 ROW ID(레코드) 검색
ix_first_name
인덱스에서 first_name = 'Georgi'
조건을 만족하는 ROW ID 목록 조회
PRIMARY
(emp_no) 인덱스에서 emp_no BETWEEN 10000 AND 20000
조건을 만족하는 ROW ID 목록 조회
ix_first_name 인덱스: (ROW ID) {10005, 10010, 10015, 15000, 18000, 20005}
PRIMARY (emp_no) 인덱스: (ROW ID) {10010, 10015, 12000, 15000, 18000}
두 인덱스의 ROW ID 리스트 교집합 연산
ix_first_name
에서 찾은 ROW ID : {10005, 10010, 10015, 15000, 18000, 20005}
PRIMARY
에서 찾은 ROW ID : {10010, 10015, 12000, 15000, 18000}
{10010, 10015, 15000, 18000}
인덱스 머지 실행 계획이 아닌 방식으로 처리 했을 경우
mysql> SELECT COUNT(*) FROM employees WHERE first_name='Georgi';
+-----------+
| COUNT(*) |
+-----------+
| 253 |
+-----------+
mysql> SELECT COUNT(*) FROM employees WHERE emp_no BETWEEN 10000 AND 20000;
+-----------+
| COUNT(*) |
+-----------+
| 10000 |
+-----------+
“first_name=’Georgi’” 조건만 인덱스를 사용했다면 일치하는 레코드 253건을 검색한 다음 데이터 페이지에서 레코드를 찾고 emp_no 칼럼의 조건에 일치하는 레코드들만 반환하는 형태로 처리돼야 한다.
“emp_no BETWEEN 10000 AND 20000” 조건만 인덱스를 사용했다면 프라이머리 키를 이용해 10,000 건을 읽어와서 “first_name=’Georgi’” 조건에 일치하는 레코드만 반환하는 형태로 처리돼야 한다.
mysql> EXPLAIN SELECT * FROM employees WHERE first_name = 'Georgi'
AND emp_no BETWEEN 10000 AND 20000;
+-----------+
| COUNT(*) |
+-----------+
| 14 |
+-----------+
ix_firstname 인덱스 만 사용했으면 253번의 데이터 페이지 읽기를 하지만 실제로는 14번만 의미있는 작업이였고, PRIMARY 키만 사용했다면 10,000건을 읽어서 9,986건은 버리는 작업을 했다.
Using union
은 WHERE 절에 사용된 2개 이상의 조건이 각각의 인덱스를 사용하되 OR 연산자로 연결된 경우에 사용되는 최적화다.
mysql> SELECT * FROM employees WHERE first_nmae='Matt' OR hire_date='1987-03-31';
+-------------+------------------------+---------+---------------------------------------+
| type | key | key_len | Extra |
+-------------+------------------------+-------------------------------------------------+
| index_merge |ix_firstname,ix_hiredate| 58, 3 |Using union(ix_firstname,ix_hiredate); |
+-------------+------------------------+---------+---------------------------------------+
ix_firstname 인덱스 에서 가져온 레코드랑 ix_hiredate 인덱스에서 가져온 레코드랑 중복된 값이 있으면 어떻게 될까?
두 인덱스는 세컨더리 인덱스 이기 때문에 프라이머리 키를 가지고 있다. 두 집합에서 하나씩 가져와서 서로 비교하면서 프라이머리 키인 emp_no 칼럼의 값이 중복된 레코드들을 제거한다. 이 제거를 우선순위 큐(Priority Queue) 에서 진행한다.
💡AND 조건일 경우 2 개의 조건중 한 조건이 인덱스를 사용하지 못하면 인덱스 레인지 스캔
OR 조건일 경우 2개의 조건중 한 조건이 인덱스를 사용하지 못하면 풀 테이블 스캔
인덱스 머지시 정렬되어 있는 PRIMARY KEY 를 이용하기 때문에 따로 정렬하지 않고 머지를 진행했었다. 하지만 정렬이 필요한 경우도 있다.
mysql> EXPLAIN SELECT * FROM employees WHERE first_name='Matt'
OR hire_date BETWEEN '1987-03-01' AND '1987-03-31'
mysql> SELECT * FROM employees WHERE first_name='Matt';
mysql> SELECT * FROM employees WHERE hire_date BETWEEN '1987-03-01' AND '1987-03-31'
첫번째 쿼리는 결과가 emp_no로 정렬되지만 두번째 쿼리는 emp_no로 정렬되지 않는다.
두 집합의 결과에 중복을 제거하기 위해 각 집합을 emp_no 칼럼으로 정렬한 다음 중복 제거를 수행한다.
인덱스 머지 최적화에서 중복 제거를 위해 강제로 정렬을 수행해야 하는 경우에는 실행 계획의 Extra 칼럼에 “Using sort_union”문구가 표시된다.
mysql> SELECT * FROM employees e WHERE e.emp_no IN
(SELECT de.emp_no FROM dept_emp de WHERE de.from_date='1995-01-01');
+------+------------------+---------+-------+-------------+----------------------+
| id | select_type | table | type | key | rows |
+------+------------------+---------+-------+-------------+----------------------+
| 1 | PRIMARY | e | ALL | NULL | 300363 |
| 2 | SUBQUERY | de | ref | ix_fromdate | 57 |
+------+------------------+---------+-------+-------------+----------------------+
Table Pull-out
Douplicate Weed-out
First Match
Loose Scan
Materialization
세미 조인(EXISTS, IN)의 서브쿼리에 사용된 테이블을 아우터 쿼리로 끄집어 낸 후에 쿼리를 조인 쿼리로 재작성하는 방식
mysql> EXPLAIN SELECT * FROM employees e WHERE e.emp_no IN
(SELECT de.emp_no FROM dept_emp de WHERE de.dept_no='d009');
+------+---------------+-------+---------+--------+--------+-----------------+
| id | select_type | table | type | key | rows | Extra |
+------+---------------+-------+---------+--------+--------+-----------------+
| 1 | SIMPLE | de | ref | PRIMARY| 46012 | Using index |
| 1 | SIMPLE | e | eq_ref | PRIMARY| 1 | NULL |
+------+---------------+-------+---------+--------+--------+-----------------+
Table pull out 은 실행계획에 따로 Using Table pullout 이 작성되지 않는다.
그래서 Table pullout 최적화가 됐는지 확인해보려면 id 칼럼이 같은지를 비교하면 된다.
더 확실하게 알기 위해서는 EXPLAIN 로 실행계획을 실행한 직후 SHOW WARNINGS 명령어로 옵티마이저가 재작성한 쿼리를 확인하면 된다.
Table pullout 은 모든 형태의 서브쿼리에서 사용되는게 아니라 특정한 사항에서만 작용한다
- Table pullout 최적화는 세미 조인 서브쿼리에서만 사용 가능하다.
- Table pullout 최적화는 서브쿼리 부분이 UNIQUE 인덱스나 프라이머리 키 룩업으로 결과가 1건인 경우에만 사용가능하다.
- Table pullout이 적용된다고 하더라도 기존 쿼리에서 가능했던 최적화 방법이 불가능한 것은 아니므로 MySQL에서는 가능하다면 Table pullout 최적화를 최대한 적용한다.
(Table pullout + 기존에 사용했던 최적화 방법) 이렇게 사용할 수 있게 된것
- Table pullout 최적화는 서브쿼리의 테이블을 아우터 쿼리로 가져와서 조인으로 풀어쓰는 최적화를 수행하는데, 만약 서브쿼리의 모든 테이블이 아우터 쿼리로 끄집어 낼 수 있다면 서브쿼리 자체는 없어진다.
- MySQL 에서는 “최대한 서브쿼리를 조인으로 풀어서 사용해라”라는 튜닝 가이드가 많은데, Table pullout 최적화는 사실 이 가이드를 그대로 실행한 것이다. 이제부터는 서브쿼리를 조인으로 풀어서 사용할 필요가 없다.
IN (subquery) 형태의 세미 조인을 EXISTS(subquery) 형태로 튜닝한 것과 비슷한 방법으로 실행된다.
mysql> EXPLAIN SELECT * FROM employees e WHERE e.first_name='Matt' AND
e.emp_no IN ( SELECT t.emp_no FROM titles t WHERE t.from_date
BETWEEN '1995-01-01' AND '1995-01-30');
+------+-------+---------+--------------+--------+-----------------------------------------+
| id | table | type | key | rows | Extra |
+------+-------+---------+--------------+--------+-----------------------------------------+
| 1 | e | ref | ix_firstname | 233 | NULL |
| 1 | t | ref | PRIMARY | 1 | Using where; Using index; FirstMatch(e) | |
+------+-------+---------+--------------+--------+-----------------------------------------+
실행 계획의 id 칼럼의 값이 모두 1로 표시된 것은 titles 테이블이 서브쿼리 패턴으로 실행되지 않고, 조인으로 처리됐다는것을 알 수 있다.
“FirstMatch(e)” 문구는 employees 테이블의 레코드에 대해서 titles 테이블에 일치하는 레코드 1건만 찾으면 더이상의 titles 테이블 검색을 하지 않는다는 것을 의미한다. 의미적으로는 EXISTS(subquery)와 동일하지만 서브쿼리가 아니라 조인으로 처리된다.
ix_firstname 인덱스를 통해서 ‘Matt’ 를 인덱스 레인지 스캔을 통해서 읽고 titles 테이블 과 Join 을 한다. 이때 일치하는 레코드가 있으면 다음을 읽지 않고 값을 반환한다.
First Match 최적화는 SQL 5.5 에서 사용하던 최적화 방법인 IN-to-EXISTS 변환과 거의 비슷한 처리방식이다. 하지만 IN-to-EXISTS 보다 더 장점이 잇는데 이는 아래와 같다
FirstMatch 최적화의 몇가지 제한 사항과 특성
- FirstMatch 가 진행되면 결과 값을 바로 찾는 상황이기 때문에 서브쿼리가 참조하고 있는 모든 아우터 테이블이 모두 조회 된 후 마지막에 진행을 한다.
- FirstMatch 최적화가 실행되면 실행 계획의 Extra 칼럼에는 “FirstMatch(table-N)” 문구가 표시된다.
- Firstmatch 최적화는 상관 서브쿼리(Correlated suquery) 에서도 사용될 수 있다
- FirstMatch 최적화는 GROUP BY 나 집합 함수가 사용된 서브쿼리의 최적화에는 사용될 수 없다.
GROUP BY 최적화에서 사용하는 “Using index for group-by” 의 루스 인덱스 스캔과 읽기 방식이 비슷하다.
mysql> EXPLAIN SELECT * FROM departments d WHERE d.dept_no IN (
SELECT de.dept_no FROM dept_emp de);
+--------+---------+--------------+------------+--------+-----------------------+
| id | table | type | key | rows | Extra |
+--------+---------+--------------+------------+--------+-----------------------+
| 1 | de | index | PRIMARY | 331143 | Using idnex; LooseScan|
| 1 | d | eq_ref | PRIMARY | 1 | NULL |
+--------+---------+--------------+------------+--------+-----------------------+
departments 테이블의 레코드 건수는 9건 밖에 되지 않지만 dept_emp 테이블의 레코드 건수는 무려 33만건 가까이 저장돼 있다. (IN 안에 있는 테이블 > 일반 테이블)
dept_emp TABLE : PRIMARY KEY = (dept_no + emp_no)
dept_emp table 의 프라이머리 키를 루스 인덱스 스캔으로 유니크한 dept_no 만 읽어주면 아주 효율적으로 서브쿼리 부분을 실행할 수 있다.
서브쿼리에 사용된 dept_no 테이블이 드라이빙 테이블로 실행되며, dept_emp 테이블의 프라이머리 키를 dept_no 부분에서 유니크하게 한 건씩만 읽고 있다는 것을 보여준다.
LooseScan 최적화는 루스 인덱스 스캔으로 서브쿼리 테이블을 읽고, 가드음으로 아우터 테이블을드리븐으로 사용해서 조인을 수행한다.
서브쿼리 부분이 루스 인덱스 스캔을 사용할 수 있는 조건이 갖춰져야 사용할 수 있는데 그 조건은 아래와 같다
SELECT ... FROM .. WHERE expr IN (SELECT keypart1 FROM tab WHERE ...)
SELECT ... FROM .. WHERE expr IN (SELECT keypart2 FROM tab
WHERE keypart1='상수'...)
세미조인에 사용된 서브쿼리를 통째로 내부 임시테이블을 생성해서 쿼리를 최적화 한다는 의미
1995년 1월 1일 조직이 변경된 사원들의 목록을 조회하는 쿼리
mysql> EXPALIN SELECT * FROM employees e WHERE e.emp_no IN
(SELECT de.emp_no FROM dept_emp de WHERE de.from_date='1995-01-01');
+--------+--------------+-------------+--------+-------------+--------------------+
| id | select_type | table | type | key | ref |
+--------+--------------+-------------+--------+-------------+--------------------+
| 1 | SIMPLE | <subquery2> | ALL | NULL | NULL |
| 1 | SIMPLE | e | eq_ref | PRIMARY | <subquery2>.emp_no |
| 2 | MATERIALIZED | de | ref | ix_fromdate | const |
+--------+--------------+-------------+--------+-------------+--------------------+
실행 계획 마지막 라인의 select_type 칼럼에 간단하게 “MATERIALIZED” 라고 표시 됐다.
이 쿼리에서 사용하는 테이블은 2개인데 실행 계획은 3개 라인이 출력되어 있으므로 임시테이블이 생성됐다는것을 추론할 수 있다.
dept_emp 테이블을 읽는 서브쿼리가 먼저 실행되어 그 결과로 임시 테이블 ()이 만들어졌다.
그리고 최종적으로 서브쿼리가 구체화된 임시 테이블()과 employees 테이블을 조인해서 결과를 반환한다.
Materialization 최적화는 다른 서브쿼리 최적화와는 달리, 다음 쿼리와 같이 서브쿼리 내에 GROUP BY 절이 있어도 이 최적화 전략을 사용할 수 있다.
mysql > EXPLAIN SELECT * FROM employees e WHERE e.emp_no IN
(SELECT de.emp_no FROM dept_emp de WHERE de.from_date='1995-01-01'
GROUP BY de.dept_no);
Materialization 최적화가 사용될 수 있는 형태의 쿼리에서도 몇가지 제한 사항이 있다
- IN(subquery) 에서 서브쿼리는 상관 서브쿼리(Correlated subquery)가 아니어야 한다.
- 서브쿼리는 GROUP BY 나 집합 함수들이 사용돼도 구체화를 사용할 수 있다.
- 구체화가 사용된 경우에는 내부 임시테이블이 사용된다.
세미 조인 서브쿼리를 일반적인 INNER JOIN 쿼리로 바꿔서 실행하고 마지막에 중복된 레코드를 제가하는 방법으로 처리되는 최적화 알고리즘이다.
mysql > EXPLAIN SELECT * FROM employees e WHERE e.emp_no
IN (SELECT s.emp_no FROM salaries s WHERE s.salary>150000);
+------+-------------+-------+-------+-----------+-------------------------------------------+
| id | select_type | table | type | key | Extra |
+------+-------------+-------+-------+-----------+-------------------------------------------+
| 1 | SIMPLE | s | range | ix_salary | Using where; Using index; Start temporary |
| 1 | SIMPLE | e | eq_ref| PRIMARY | End temporary |
+------+-------------+-------+-------+-----------+-------------------------------------------+
salaries table : PRIMARY KEY = (emp_no + from_date) 이므로 salaries 테이블에서 조회하면 결과가 중복된 emp_no 가 발생할 수 있다. 이 쿼리를 다음과 같이 재작성해서 GROUP BY 절을 넣어주면 위의 세미 조인 서브쿼리와 동일한 결과를 얻을 수 있다.
mysql> SELECT e.* FROM employees e, salaries s WHERE e.emp_no=s.emp_no
AND s.salary>150000 GROUP BY e.emp_no
Duplicate Weedout 최적화 알고리즘은 원본 쿼리를 위와 같이 INNER JOIN + GROUP BY 절로 바꿔서 실행하는 것과 동일한 작업으로 쿼리를 처리한다.
B 테이블에서 10건을 가져온다.
각 B의 레코드에 대해 A 테이블을 검색한다 (A를 10번만 읽음)
즉, A 테이블이 인덱스가 잘 설계되어 있다면, 각 B의 레코드에 대해 A에 필요한 데이터만 빠르게 찾을 수 있다.
👉 A 테이블 전체(1만 건)를 탐색하는 것이 아니라, B의 10건에 대해 필요한 만큼만 조회하면 된다.
Case 2 : A가 드라이빙 테이블 (A → B)
A 테이블에서 10,000 건을 가져온다.
각 A의 레코드에 대해 B 테이블을 검색한다 (B를 10,000번 읽음).
👉 B 테이블이 인덱스가 없거나 비효율적인 구조라면, B 테이블을 최대 10,000번 반복해서 읽어야 한다.
👉 특히 B 테이블의 데이터가 적다고 해도,
반복 접근 횟수가 많아지므로 성능이 급격히 떨어질 수 있다.
mysql> SELECT * FROM employees e INNER JOIN salaries s ON s.emp_no=e.emp_no
WHERE e.first_name='Matt' AND e.hire_date BETWEEN '1985-11-21' AND '1986-11-21';
+------+---------+------+--------------+-------+------------+----------------+
| id | table | type | key | rows | filtered | Extra |
+------+---------+------+--------------+-------+------------+----------------+
| 1 | e | ref | ix_firstname | 233 | 100.00 | Using where |
| 1 | s | ref | PRIMARY | 10 | 100.00 | NULL |
+------+---------+------+--------------+-------+------------+----------------+
+------+---------+------+--------------+-------+------------+----------------+
| id | table | type | key | rows | filtered | Extra |
+------+---------+------+--------------+-------+------------+----------------+
| 1 | e | ref | ix_firstname | 233 | 23.20 | Using where |
| 1 | s | ref | PRIMARY | 10 | 100.00 | NULL |
+------+---------+------+--------------+-------+------------+----------------+
MySQL 옵티마이저가 실행 계획을 수립할 때 테이블이나 인덱스의 통계 정보만 사용하는 것이 아니라 다음 순서대로 사용 가능한 방법을 선택한다.
레인지 옵티마이저는 실제 인덱스의 데이터를 소량으로 빠르게 읽어보고 예측한다.
레인지 옵티마이저에 의한 예측은 인덱스를 이용하는 쿼리일때만 사용할 수 있다.
레인지 옵티마이저를 이용한 예측이 히스토그램이나 인덱스 통계 정보보다 우선순위가 높기 때문에 실행 계획에 표시되는 레코드 건수가 테이블이나 인덱스의 통계, 히스토그램 정보와 다른 값이 표시될 수 있다.
mysql> EXPLAIN SELECT * FROM ( SELECT * FROM employees WHERE first_name='Matt')
derived_table WHERE derived_table.hire_date='1986-04-03';
+----+-------------+------------+------+--------------------+
| id | select_type | table | type | key |
+----+-------------+------------+------+--------------------+
| 1 | PRIMARY | <derived2> | ref | <auto_key0> |
| 2 | DERIVED | employees | ref | ix_firstname |
+----+-------------+------------+------+--------------------+
+----+-------------+------------+-------------+--------------------------+
| id | select_type | table | type | key |
+----+-------------+------------+-------------+--------------------------+
| 1 | PRIMARY | employees | index_merge | ix_hiredate,ix_firstname |
+----+-------------+------------+-------------+--------------------------+
SELECT employees.employees.emp_no AS emp_no,
employees.employees.birth_date AS birth_date,
employees.employees.first_name AS first_name,
employees.employees.last_name AS last_name,
employees.employees.gender AS gender,
employees.employees.hire_date AS hire_date
FROM employees.employees
WHERE ((employees.employees.hire_date = DATE'1986-04-03')
AND (employees.employees.first_name = 'Matt'))
-- // 옵티마이저가 ix_hiredate 인덱스를 사용하지 못하게 변경
mysql> ALTER TABLE employees ALTER INDEX ix_hiredate INVISIBLE;
-- // 옵티마이저가 ix_hiredate 인덱스를 사용할 수 있게 변경
mysql> ALTER TABLE employees ALTER INDEX ix_hiredate VISIBLE;
mysql> ALTER TABLE employees ADD INDEX ix_gender_birthdate (gender, birth_date);
mysql> SELECT * FROM employees WHERE birth_date >= '1965-02-01';
mysql> EXPLAIN SELECT * FROM employees e IGNORE INDEX(PRIMARY, ix_hiredate)
INNER JOIN dept_emp de IGNORE(ix_empno_fromdate, ix_fromdate) ON de.emp_no=e.emp_no
AND de.from_date=e.hire_date;
+----+-------------+--------+---------+--------------------------------------------+
| id | select_type | table | type | Extra |
+----+-------------+--------+---------+--------------------------------------------+
| 1 | SIMPLE | de | ALL | NULL |
| 1 | SIMPLE | e | ALL | Using where; Using join buffer (hash join) |
+----+-------------+--------+---------+--------------------------------------------+
mysql> EXPLAIN SELECT * FROM employees WHERE hire_date BETWEEN '1985-01-01'
AND '1985-02-01'; ORDER BY emp_no;
+----+-----------+-------+---------+--------+-------------+
| id | table | type | key | rows | Extra |
+----+-----------+-------+---------+--------+-------------+
| 1 | employees | index | PRIMARY | 300252 | Using where |
+----+-----------+-------+---------+--------+-------------+
-- // 현재 커넥션에만 prefer_ordering_index 옵션을 비활성화
mysql> SET SESSION optimizer_switch='prefer_ordering_index=OFF';
-- // 현재 쿼리에 대해서만 prefer_ordering_index 옵션을 비활성화
mysql> SELECT /*+ SET_VAR (optimizer_switch='prefer_ordering_index=off') */
...
FROM
...
mysql> SELECT * FROM t1, t2, t3, t4 WHERE ...
SELECT, UPDATE, DELETE 쿼리에서 여러 개의 테이블을 조인할때 순서를 고정시켜준다.
조인이 여러게 발생하거나 할때 제대로 INDEX 처리가 안될 수 있기 때문에 강제 하게 하는것
COUNT(*) 로 읽어 오는 것이 더 효율적
LIMIT 시에 사용하는데 LIMIT 로 가져오는것 제외 하고 전체 값을 읽어온 다음 그중에 LIMIT 값을 사용자에게 반환하는 방식
mysql> EXPLAIN SELECT /*+ SET_VAR (optimizer_switch='index_merge_intersection=off') */*
FROM employees
WHERE first_name='Georgi' AND emp_no BETWEEN 10000 AND 20000;
mysql EXPLAIN SELECT * FROM departments d WHERE d.dept_no IN
(SELECT /*+ SEMIJOIN(MATERIALIZATION) */ de.dept_no FROM dept_emp de);
최적화 방법 | 힌트 |
---|---|
IN-to-EXISTS | SUBQUERY(INTOEXISTS) |
Materialization | SUBQUERY(MATERIALIZATION) |
인덱스 힌트 | 옵티마이저 힌트 |
---|---|
USE INDEX | INDEX |
USE INDEX FOR GROUP BY | GROUP_INDEX |
USE INDEX FOR ORDER BY | ORDER_INDEX |
IGNORE INDEX | NO_INDEX |
IGNORE INDEX FOR GROUP BY | NO_GROUP_INDEX |
IGNORE INDEX FOR ORDER BY | NO_ORDER_INDEX |