Skip to content

Split View: MySQL 쿼리 최적화 실전 가이드: EXPLAIN 분석부터 인덱스 설계·슬로우 쿼리 튜닝까지

✨ Learn with Quiz
|

MySQL 쿼리 최적화 실전 가이드: EXPLAIN 분석부터 인덱스 설계·슬로우 쿼리 튜닝까지

MySQL Query Optimization

들어가며

MySQL은 전 세계에서 가장 널리 사용되는 관계형 데이터베이스 중 하나다. 하지만 데이터가 수천만 건을 넘어가고 동시 접속이 증가하면, 한 줄의 비효율적인 쿼리가 서비스 전체를 마비시킬 수 있다. 이 글에서는 EXPLAIN 명령어를 사용하여 쿼리 실행 계획을 분석하는 방법부터, 인덱스 설계 전략, 쿼리 재작성 패턴, 옵티마이저 힌트 활용, 슬로우 쿼리 로그 분석, 그리고 InnoDB 버퍼 풀 튜닝까지 MySQL 쿼리 최적화에 필요한 모든 내용을 실전 예제와 함께 다룬다.

이 가이드는 이미 MySQL 기본 문법에 익숙한 개발자와 DBA를 대상으로 하며, MySQL 8.0 이상(특히 8.4의 개선사항)을 기준으로 작성되었다.

EXPLAIN 분석 완전 가이드

EXPLAIN 기본 사용법

EXPLAIN은 MySQL 옵티마이저가 쿼리를 어떤 방식으로 실행할지 미리 확인하는 필수 도구다. 기본 사용법은 간단하다.

EXPLAIN SELECT u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
  AND o.created_at >= '2026-01-01';

출력 결과는 쿼리의 각 테이블 접근 방식에 대한 행(row)으로 나타난다.

type 컬럼 해석

type 컬럼은 테이블 접근 방식을 나타내며, 성능에 가장 직접적인 영향을 미친다. 좋은 순서(빠른 순서)부터 나열하면 아래와 같다.

type 값설명성능 수준
const기본 키 또는 유니크 인덱스로 단일 행 조회최고
eq_refJOIN에서 기본 키/유니크 인덱스 매칭매우 좋음
ref비유니크 인덱스로 동일 값 조회좋음
range인덱스 범위 스캔 (BETWEEN, IN 등)보통
index전체 인덱스 스캔 (인덱스 트리 전체 읽기)나쁨
ALL전체 테이블 스캔 (풀 스캔)최악
-- const: 기본 키로 단일 행 조회
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const, rows: 1

-- ref: 비유니크 인덱스 조회
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ref (user_id에 인덱스가 있는 경우)

-- ALL: 풀 스캔 - 반드시 개선 필요
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL (함수 적용으로 인덱스 사용 불가)

Extra 컬럼 해석

Extra 컬럼에는 옵티마이저의 추가적인 실행 전략이 표시된다. 주의해야 할 값들은 다음과 같다.

Extra 값의미조치 필요 여부
Using index커버링 인덱스로 해결좋음 (유지)
Using whereWHERE 절 필터링 수행보통
Using filesort정렬을 위한 추가 작업 필요개선 필요
Using temporary임시 테이블 생성 필요개선 필요
Using index condition인덱스 컨디션 푸시다운 적용좋음

EXPLAIN ANALYZE로 실제 실행 시간 확인

MySQL 8.0.18부터 EXPLAIN ANALYZE를 사용할 수 있다. EXPLAIN과 달리 쿼리를 실제로 실행하고 실행 시간과 실제 행 수를 보여준다.

EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2026-01-01'
GROUP BY u.id
HAVING order_count > 5
ORDER BY order_count DESC
LIMIT 10;

출력에는 estimated rows, actual rows, actual time 등이 포함되어 옵티마이저 추정값과 실제값 간의 괴리를 확인할 수 있다. 추정 행수(estimated)와 실제 행수(actual) 차이가 10배 이상이면 통계 정보 업데이트가 필요하다.

-- 통계 정보 수동 갱신
ANALYZE TABLE users;
ANALYZE TABLE orders;

인덱스 유형과 특성

B-Tree 인덱스

MySQL InnoDB의 기본 인덱스 유형이다. 등호 비교, 범위 검색, 정렬, 그리고 최좌선 접두사(Leftmost Prefix) 매칭에 최적화되어 있다.

-- B-Tree 인덱스 생성
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- 이 인덱스가 효과적인 쿼리 예시
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01';
SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
SELECT * FROM orders WHERE user_id IN (100, 200, 300);

Hash 인덱스

MEMORY 엔진에서만 명시적으로 사용할 수 있으며, InnoDB에서는 Adaptive Hash Index 형태로 자동 관리된다. 등호(=) 검색에만 사용할 수 있고 범위 검색이나 정렬에는 사용할 수 없다.

-- MEMORY 엔진 테이블에서 Hash 인덱스 사용
CREATE TABLE session_cache (
    session_id VARCHAR(64) NOT NULL,
    user_id INT NOT NULL,
    data JSON,
    INDEX USING HASH (session_id)
) ENGINE = MEMORY;

-- InnoDB Adaptive Hash Index 상태 확인
SHOW ENGINE INNODB STATUS\G
-- Adaptive Hash Index 섹션에서 hit rate 확인

Full-text 인덱스

자연어 텍스트 검색을 위한 전문 인덱스다. InnoDB에서 MySQL 5.6부터 지원하며, MATCH ... AGAINST 구문과 함께 사용한다.

-- Full-text 인덱스 생성
ALTER TABLE articles ADD FULLTEXT INDEX ft_idx_content (title, body);

-- 자연어 모드 검색
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('MySQL 성능 최적화' IN NATURAL LANGUAGE MODE);

-- 불리언 모드 검색 (AND, OR, NOT 등)
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('+MySQL +성능 -PostgreSQL' IN BOOLEAN MODE);

Spatial 인덱스

공간 데이터(위치 정보 등)를 위한 R-Tree 기반 인덱스다. GEOMETRY, POINT 등의 컬럼에 사용한다.

-- Spatial 인덱스 생성
CREATE TABLE locations (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    position POINT NOT NULL SRID 4326,
    SPATIAL INDEX (position)
);

-- 반경 내 검색
SELECT name, ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) AS distance
FROM locations
WHERE ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) < 1000;

복합 인덱스 설계 전략

최좌선 접두사 규칙 (Leftmost Prefix Rule)

복합 인덱스는 정의된 컬럼 순서에서 왼쪽부터 연속된 접두사만 활용할 수 있다. 이것은 MySQL 인덱스 설계에서 가장 중요한 원칙이다.

-- 복합 인덱스: (a, b, c)
CREATE INDEX idx_abc ON orders (status, user_id, created_at);

-- 인덱스 활용 가능한 쿼리
SELECT * FROM orders WHERE status = 'completed';                          -- (a) 사용
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100;        -- (a, b) 사용
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100
  AND created_at >= '2026-01-01';                                         -- (a, b, c) 모두 사용

-- 인덱스 활용 불가능한 쿼리
SELECT * FROM orders WHERE user_id = 100;                                 -- (b)만 사용 - 인덱스 미활용
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01';  -- (b, c) - 인덱스 미활용
SELECT * FROM orders WHERE created_at >= '2026-01-01';                    -- (c)만 사용 - 인덱스 미활용

커버링 인덱스 (Covering Index)

쿼리에서 필요한 모든 컬럼이 인덱스에 포함되면, InnoDB는 테이블 데이터(클러스터드 인덱스)에 접근하지 않고 세컨더리 인덱스만으로 결과를 반환할 수 있다. EXPLAIN의 Extra에 "Using index"가 표시된다.

-- 커버링 인덱스 설계
CREATE INDEX idx_covering ON orders (user_id, status, total_amount);

-- 이 쿼리는 인덱스만으로 결과 반환 가능 (Using index)
EXPLAIN SELECT user_id, status, total_amount
FROM orders
WHERE user_id = 100 AND status = 'completed';

-- 아래 쿼리는 커버링 인덱스 미적용 (SELECT *로 인해 테이블 데이터 접근 필요)
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 'completed';

인덱스 설계 실전 패턴

인덱스 컬럼 순서를 결정할 때는 다음 원칙을 따른다.

  1. 등호(=) 조건 컬럼을 앞에 배치
  2. 범위 조건 컬럼을 뒤에 배치 (범위 조건 이후 컬럼은 인덱스 활용 불가)
  3. 카디널리티가 높은 컬럼을 우선 배치 (일반적 권장사항이지만 쿼리 패턴이 더 중요)
  4. ORDER BY / GROUP BY 컬럼 고려
-- 나쁜 설계: 낮은 카디널리티 컬럼만으로 인덱스
CREATE INDEX idx_bad ON orders (status);  -- status는 5~10가지 값뿐

-- 좋은 설계: 쿼리 패턴에 맞춘 복합 인덱스
CREATE INDEX idx_good ON orders (user_id, status, created_at);

-- 정렬까지 고려한 설계
-- WHERE user_id = ? ORDER BY created_at DESC 패턴이 빈번할 때
CREATE INDEX idx_sort ON orders (user_id, created_at DESC);

쿼리 재작성 패턴

서브쿼리를 JOIN으로 변환

MySQL 옵티마이저는 상관 서브쿼리(Correlated Subquery)를 비효율적으로 처리하는 경우가 많다. JOIN으로 재작성하면 성능이 크게 개선된다.

-- 나쁨: 상관 서브쿼리 (각 행마다 서브쿼리 실행)
SELECT u.name, u.email,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u
WHERE u.status = 'active';

-- 좋음: LEFT JOIN + GROUP BY
SELECT u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.name, u.email;

-- 또는: 파생 테이블(Derived Table) 사용
SELECT u.name, u.email, COALESCE(oc.cnt, 0) AS order_count
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS cnt
    FROM orders
    GROUP BY user_id
) oc ON u.id = oc.user_id
WHERE u.status = 'active';

OR 조건을 UNION으로 변환

OR 조건은 인덱스를 비효율적으로 사용하거나 아예 풀 스캔을 유발할 수 있다.

-- 나쁨: OR 조건으로 인한 인덱스 활용 어려움
SELECT * FROM products
WHERE category_id = 10 OR brand_id = 20;

-- 좋음: UNION ALL로 분리 (각 조건에 대해 인덱스 활용 가능)
SELECT * FROM products WHERE category_id = 10
UNION ALL
SELECT * FROM products WHERE brand_id = 20 AND category_id != 10;

인덱스 컬럼에 함수 사용 회피

인덱스 컬럼에 함수를 적용하면 인덱스를 사용할 수 없다. MySQL 8.0부터는 함수 기반 인덱스(Expression Index)를 생성할 수 있으나, 가능하면 쿼리를 재작성하는 것이 좋다.

-- 나쁨: 인덱스 컬럼에 함수 적용
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

-- 좋음: 범위 조건으로 재작성
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

-- MySQL 8.0+: 함수 기반 인덱스 (Expression Index)
CREATE INDEX idx_email_lower ON users ((LOWER(email)));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

암묵적 형변환 방지

컬럼 타입과 비교 값의 타입이 다르면 MySQL은 암묵적 형변환을 수행하며, 이때 인덱스가 무시된다.

-- 나쁨: phone_number가 VARCHAR인데 숫자로 비교
SELECT * FROM users WHERE phone_number = 01012345678;
-- MySQL이 phone_number를 숫자로 변환 -> 인덱스 미사용

-- 좋음: 타입 일치
SELECT * FROM users WHERE phone_number = '01012345678';

옵티마이저 힌트

인덱스 힌트

-- USE INDEX: 특정 인덱스 사용 권장 (옵티마이저가 무시할 수 있음)
SELECT * FROM orders USE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';

-- FORCE INDEX: 특정 인덱스 강제 사용 (풀 스캔 대신 반드시 인덱스 사용)
SELECT * FROM orders FORCE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';

-- IGNORE INDEX: 특정 인덱스 무시
SELECT * FROM orders IGNORE INDEX (idx_status)
WHERE status = 'completed' AND user_id = 100;

MySQL 8.0+ 옵티마이저 힌트 구문

MySQL 8.0부터 도입된 새로운 힌트 구문은 주석 형태로 작성한다.

-- JOIN 순서 고정
SELECT /*+ JOIN_ORDER(u, o) */
  u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active';

-- 특정 테이블 인덱스 지정
SELECT /*+ INDEX(o idx_user_created) */
  o.*
FROM orders o
WHERE o.user_id = 100;

-- Hash Join 강제
SELECT /*+ HASH_JOIN(u, o) */
  u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id;

-- 병렬 쿼리 실행 (MySQL 8.0.14+)
SELECT /*+ SET_VAR(innodb_parallel_read_threads=4) */
  COUNT(*) FROM large_table;

optimizer_switch 시스템 변수

-- 현재 설정 확인
SHOW VARIABLES LIKE 'optimizer_switch';

-- 세션 레벨에서 특정 최적화 비활성화
SET SESSION optimizer_switch = 'index_merge_intersection=off';
SET SESSION optimizer_switch = 'derived_merge=off';
SET SESSION optimizer_switch = 'batched_key_access=on';

-- MySQL 8.0.31+: 히스토그램 기반 최적화
ANALYZE TABLE orders UPDATE HISTOGRAM ON user_id, status WITH 100 BUCKETS;

슬로우 쿼리 로그 설정과 분석

슬로우 쿼리 로그 설정

-- 슬로우 쿼리 로그 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1초 이상 걸리는 쿼리 기록
SET GLOBAL log_queries_not_using_indexes = 'ON';  -- 인덱스 미사용 쿼리도 기록
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';

-- my.cnf에 영구 설정
-- [mysqld]
-- slow_query_log = 1
-- long_query_time = 1
-- log_queries_not_using_indexes = 1
-- slow_query_log_file = /var/log/mysql/slow-query.log
-- min_examined_row_limit = 1000

mysqldumpslow로 분석

# 가장 느린 쿼리 Top 10
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log

# 가장 빈번한 슬로우 쿼리 Top 10
mysqldumpslow -s c -t 10 /var/log/mysql/slow-query.log

# 특정 패턴 필터링
mysqldumpslow -s t -t 10 -g "orders" /var/log/mysql/slow-query.log

pt-query-digest로 상세 분석

Percona Toolkit의 pt-query-digest는 더 강력한 분석 도구다.

# 슬로우 로그 분석
pt-query-digest /var/log/mysql/slow-query.log

# 특정 시간 범위 분석
pt-query-digest --since="2026-03-10 00:00:00" --until="2026-03-11 00:00:00" \
  /var/log/mysql/slow-query.log

# 결과를 파일로 저장
pt-query-digest /var/log/mysql/slow-query.log > /tmp/slow-query-report.txt

출력에는 쿼리별 실행 횟수, 평균/최대 실행 시간, 검사 행 수, 반환 행 수 등이 포함된다. 검사 행 수(Rows examined)와 반환 행 수(Rows sent) 비율이 100:1 이상이면 인덱스 개선이 필요하다.

InnoDB 버퍼 풀 튜닝

버퍼 풀 크기 설정

InnoDB 버퍼 풀은 데이터와 인덱스를 메모리에 캐싱하는 핵심 구성 요소다. 일반적으로 전체 메모리의 70~80%를 할당한다.

-- 현재 버퍼 풀 상태 확인
SHOW VARIABLES LIKE 'innodb_buffer_pool%';
SHOW STATUS LIKE 'Innodb_buffer_pool%';

-- 버퍼 풀 적중률 계산
SELECT
  (1 - (
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') /
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
  )) * 100 AS buffer_pool_hit_rate;
-- 99% 이상이 바람직

버퍼 풀 인스턴스 분리

동시성이 높은 환경에서는 버퍼 풀을 여러 인스턴스로 분리하여 mutex 경합을 줄인다.

# my.cnf 설정 예시
[mysqld]
innodb_buffer_pool_size = 32G
innodb_buffer_pool_instances = 8    # 버퍼 풀 크기 / 인스턴스 = 4GB씩
innodb_buffer_pool_chunk_size = 1G  # 온라인 리사이징 단위
innodb_log_file_size = 4G
innodb_log_buffer_size = 64M
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000

워밍업 (Buffer Pool Dump/Load)

MySQL 재시작 시 버퍼 풀이 비어서 성능이 저하되는 콜드 스타트 문제를 방지한다.

-- 셧다운 시 버퍼 풀 덤프, 시작 시 로드
SET GLOBAL innodb_buffer_pool_dump_at_shutdown = ON;
SET GLOBAL innodb_buffer_pool_load_at_startup = ON;

-- 수동 덤프/로드
SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;

-- 로드 진행 상태 확인
SHOW STATUS LIKE 'Innodb_buffer_pool_load_status';

MySQL 8.0 vs 8.4 옵티마이저 개선 비교

기능MySQL 8.0MySQL 8.4
EXPLAIN FORMATTRADITIONAL, JSON, TREETRADITIONAL, JSON, TREE (개선)
Hash Join8.0.18부터 지원성능 개선 및 메모리 관리 향상
Window Functions기본 지원실행 계획 최적화 개선
Derived Table Merge일부 지원더 넓은 범위 지원
Invisible Index지원지원 (관리 기능 개선)
Functional Index지원지원 (Expression Index 개선)
Histogram Statistics지원자동 갱신 기능 강화
Parallel Query제한적InnoDB 병렬 읽기 확대
Cost Model기본 비용 모델비용 상수 재조정, SSD 반영
Subquery Optimization기본 세미조인안티조인 및 세미조인 전략 확대
EXPLAIN ANALYZE8.0.18 도입출력 형식 개선, 메모리 사용량 표시
-- MySQL 8.4: EXPLAIN 개선 예시
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id;

-- Invisible Index 활용 (인덱스 삭제 전 테스트)
ALTER TABLE orders ALTER INDEX idx_status INVISIBLE;
-- 성능 테스트 후 문제가 없으면 삭제
-- DROP INDEX idx_status ON orders;
-- 문제가 발생하면 다시 활성화
ALTER TABLE orders ALTER INDEX idx_status VISIBLE;

실패 사례와 복구 절차

사례 1: 암묵적 형변환으로 인덱스 미사용

-- 문제 상황: VARCHAR 컬럼에 숫자 비교
-- account_no 컬럼이 VARCHAR(20)인데 애플리케이션에서 숫자로 전달
EXPLAIN SELECT * FROM accounts WHERE account_no = 123456;
-- type: ALL (전체 테이블 스캔!)

-- 원인: MySQL이 account_no 컬럼을 숫자로 변환하므로 인덱스 사용 불가
-- 해결: 타입 일치시키기
EXPLAIN SELECT * FROM accounts WHERE account_no = '123456';
-- type: ref (인덱스 사용)

사례 2: 카디널리티 부족으로 인덱스 건너뜀

-- 문제 상황: 인덱스가 있지만 옵티마이저가 풀 스캔을 선택
-- status 컬럼에 인덱스가 있으나 'active'가 전체의 90%를 차지
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- type: ALL (옵티마이저가 풀 스캔이 더 효율적이라고 판단)

-- 원인: 카디널리티가 낮아 인덱스 효율이 떨어짐
-- 해결 1: 다른 조건과 복합 인덱스 구성
CREATE INDEX idx_status_created ON users (status, created_at);

-- 해결 2: 특정 값에 대해서만 인덱스가 유용한 경우
-- MySQL은 Partial Index를 직접 지원하지 않으므로, 쿼리 패턴 변경 고려

사례 3: 잘못된 복합 인덱스 순서

-- 문제 상황: 인덱스 (created_at, user_id)로 생성
-- 빈번한 쿼리: WHERE user_id = ? AND created_at >= ?
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND created_at >= '2026-01-01';
-- 인덱스를 비효율적으로 사용 (range scan on created_at first)

-- 해결: 등호 조건 컬럼을 앞에 배치
DROP INDEX idx_created_user ON orders;
CREATE INDEX idx_user_created ON orders (user_id, created_at);
-- type: range (효율적인 범위 스캔)

사례 4: 대량 DELETE 후 인덱스 단편화

-- 문제 상황: 500만 건 DELETE 후 쿼리 성능 저하
-- 원인: 인덱스 페이지에 빈 공간이 많아짐 (단편화)

-- 진단
SELECT
  TABLE_NAME,
  INDEX_LENGTH,
  DATA_LENGTH,
  DATA_FREE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'orders';

-- 해결: 인덱스 재구축
ALTER TABLE orders ENGINE=InnoDB;  -- 테이블 + 인덱스 재구축
-- 또는
OPTIMIZE TABLE orders;

프로덕션 최적화 체크리스트

인덱스 점검

  • 모든 외래 키(Foreign Key) 컬럼에 인덱스가 있는지 확인
  • 주요 쿼리에 대해 EXPLAIN 결과를 확인하고 type이 ALL인 것이 없는지 검증
  • 사용되지 않는 인덱스 식별 및 제거
  • 중복 인덱스(Duplicate Index) 제거
  • 복합 인덱스의 컬럼 순서가 쿼리 패턴에 맞는지 확인
-- 사용되지 않는 인덱스 찾기
SELECT
  s.TABLE_SCHEMA,
  s.TABLE_NAME,
  s.INDEX_NAME,
  s.COLUMN_NAME
FROM INFORMATION_SCHEMA.STATISTICS s
LEFT JOIN performance_schema.table_io_waits_summary_by_index_usage p
  ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA
  AND s.TABLE_NAME = p.OBJECT_NAME
  AND s.INDEX_NAME = p.INDEX_NAME
WHERE p.COUNT_STAR = 0
  AND s.TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
  AND s.INDEX_NAME != 'PRIMARY'
ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME;

-- 중복 인덱스 찾기 (sys 스키마 활용)
SELECT * FROM sys.schema_redundant_indexes;

쿼리 점검

  • 슬로우 쿼리 로그 활성화 및 주기적 분석
  • 인덱스 컬럼에 함수를 적용하는 쿼리가 없는지 확인
  • 암묵적 형변환이 발생하는 쿼리가 없는지 확인
  • SELECT * 대신 필요한 컬럼만 선택
  • N+1 쿼리 패턴 제거

서버 설정 점검

  • innodb_buffer_pool_size가 전체 메모리의 70~80% 수준인지 확인
  • innodb_buffer_pool_instances가 적절한지 확인 (최소 8, 버퍼 풀 1GB당 1인스턴스)
  • innodb_log_file_size가 충분한지 확인 (1~4GB 권장)
  • innodb_flush_log_at_trx_commit 값 확인 (1: 안전, 2: 타협)
  • innodb_buffer_pool_dump_at_shutdown/load_at_startup 활성화

모니터링

  • 버퍼 풀 적중률 99% 이상 유지
  • 슬로우 쿼리 발생 빈도 모니터링
  • 인덱스 사용률 주기적 점검
  • 테이블 통계 정보 자동 갱신 확인
  • 잠금 대기(Lock Wait) 및 데드락(Deadlock) 모니터링

운영 주의사항

대용량 테이블 인덱스 변경 시 주의점

MySQL 8.0에서는 대부분의 ALTER TABLE ... ADD INDEX가 온라인(Instant 또는 In-place)으로 처리되지만, 프로덕션 환경에서는 반드시 사전 테스트를 수행해야 한다.

-- 인덱스 추가 시 잠금 모드 확인
ALTER TABLE orders ADD INDEX idx_new (col1, col2), ALGORITHM=INPLACE, LOCK=NONE;

-- pt-online-schema-change 사용 (대용량 테이블에 권장)
-- 원본 테이블을 트리거로 동기화하며 스키마 변경
pt-online-schema-change \
  --alter "ADD INDEX idx_new (col1, col2)" \
  --execute \
  D=mydb,t=orders

쿼리 캐시 관련 참고

MySQL 8.0부터 쿼리 캐시(Query Cache)가 완전히 제거되었다. 애플리케이션 레벨 캐싱(Redis, Memcached 등)으로 대체해야 한다.

히스토그램 통계 활용

MySQL 8.0에서 도입된 히스토그램은 컬럼의 값 분포를 옵티마이저에게 제공하여 더 나은 실행 계획을 선택하도록 돕는다.

-- 히스토그램 생성
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;
ANALYZE TABLE orders UPDATE HISTOGRAM ON total_amount WITH 254 BUCKETS;

-- 히스토그램 확인
SELECT
  SCHEMA_NAME,
  TABLE_NAME,
  COLUMN_NAME,
  JSON_EXTRACT(HISTOGRAM, '$.histogram-type') AS histogram_type,
  JSON_EXTRACT(HISTOGRAM, '$.number-of-buckets-specified') AS buckets
FROM INFORMATION_SCHEMA.COLUMN_STATISTICS;

-- 히스토그램 삭제
ANALYZE TABLE orders DROP HISTOGRAM ON status;

참고자료

MySQL Query Optimization Practical Guide: From EXPLAIN Analysis to Index Design and Slow Query Tuning

MySQL Query Optimization

Introduction

MySQL is one of the most widely used relational databases in the world. However, once data grows beyond tens of millions of rows and concurrent connections increase, a single inefficient query can bring down an entire service. This guide covers everything you need for MySQL query optimization, from analyzing query execution plans with EXPLAIN, to index design strategies, query rewriting patterns, optimizer hints, slow query log analysis, and InnoDB buffer pool tuning -- all with practical examples.

This guide targets developers and DBAs already familiar with basic MySQL syntax, and is based on MySQL 8.0 and above (with a focus on 8.4 improvements).

Complete Guide to EXPLAIN Analysis

Basic EXPLAIN Usage

EXPLAIN is an essential tool for previewing how the MySQL optimizer will execute a query. Basic usage is straightforward.

EXPLAIN SELECT u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
  AND o.created_at >= '2026-01-01';

The output displays one row for each table access method in the query.

Interpreting the type Column

The type column indicates the table access method and has the most direct impact on performance. Listed from best (fastest) to worst:

type ValueDescriptionPerformance
constSingle row lookup via primary key or unique indexBest
eq_refPrimary key/unique index match in JOINVery Good
refNon-unique index lookup for equal valuesGood
rangeIndex range scan (BETWEEN, IN, etc.)Average
indexFull index scan (reads entire index tree)Poor
ALLFull table scanWorst
-- const: Single row lookup by primary key
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const, rows: 1

-- ref: Non-unique index lookup
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ref (when user_id has an index)

-- ALL: Full scan - must be optimized
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL (index unusable due to function application)

Interpreting the Extra Column

The Extra column displays additional execution strategies used by the optimizer. Key values to watch for:

Extra ValueMeaningAction Required
Using indexResolved via covering indexGood (maintain)
Using whereWHERE clause filtering performedNormal
Using filesortAdditional sort operation neededNeeds improvement
Using temporaryTemporary table creation neededNeeds improvement
Using index conditionIndex condition pushdown appliedGood

Verifying Actual Execution Time with EXPLAIN ANALYZE

Starting from MySQL 8.0.18, EXPLAIN ANALYZE is available. Unlike EXPLAIN, it actually executes the query and shows real execution times and actual row counts.

EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2026-01-01'
GROUP BY u.id
HAVING order_count > 5
ORDER BY order_count DESC
LIMIT 10;

The output includes estimated rows, actual rows, and actual time, allowing you to identify discrepancies between optimizer estimates and reality. If the difference between estimated and actual row counts is 10x or more, statistics need updating.

-- Manually refresh statistics
ANALYZE TABLE users;
ANALYZE TABLE orders;

Index Types and Characteristics

B-Tree Index

This is the default index type for MySQL InnoDB. It is optimized for equality comparisons, range searches, sorting, and leftmost prefix matching.

-- Creating a B-Tree index
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- Queries where this index is effective
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01';
SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
SELECT * FROM orders WHERE user_id IN (100, 200, 300);

Hash Index

Can only be used explicitly with the MEMORY engine. In InnoDB, it is automatically managed as an Adaptive Hash Index. It can only be used for equality (=) searches and cannot support range queries or sorting.

-- Using Hash index on a MEMORY engine table
CREATE TABLE session_cache (
    session_id VARCHAR(64) NOT NULL,
    user_id INT NOT NULL,
    data JSON,
    INDEX USING HASH (session_id)
) ENGINE = MEMORY;

-- Check InnoDB Adaptive Hash Index status
SHOW ENGINE INNODB STATUS\G
-- Check hit rate in the Adaptive Hash Index section

Full-text Index

A specialized index for natural language text search. Supported in InnoDB since MySQL 5.6, used with the MATCH ... AGAINST syntax.

-- Creating a Full-text index
ALTER TABLE articles ADD FULLTEXT INDEX ft_idx_content (title, body);

-- Natural language mode search
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('MySQL performance optimization' IN NATURAL LANGUAGE MODE);

-- Boolean mode search (AND, OR, NOT, etc.)
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('+MySQL +performance -PostgreSQL' IN BOOLEAN MODE);

Spatial Index

An R-Tree based index for spatial data (location information, etc.). Used with GEOMETRY, POINT, and similar column types.

-- Creating a Spatial index
CREATE TABLE locations (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    position POINT NOT NULL SRID 4326,
    SPATIAL INDEX (position)
);

-- Search within radius
SELECT name, ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) AS distance
FROM locations
WHERE ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) < 1000;

Composite Index Design Strategies

Leftmost Prefix Rule

A composite index can only be utilized from the leftmost columns in a continuous prefix order. This is the most important principle in MySQL index design.

-- Composite index: (a, b, c)
CREATE INDEX idx_abc ON orders (status, user_id, created_at);

-- Queries that CAN use the index
SELECT * FROM orders WHERE status = 'completed';                          -- (a) used
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100;        -- (a, b) used
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100
  AND created_at >= '2026-01-01';                                         -- (a, b, c) all used

-- Queries that CANNOT use the index
SELECT * FROM orders WHERE user_id = 100;                                 -- only (b) - index not used
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01';  -- (b, c) - index not used
SELECT * FROM orders WHERE created_at >= '2026-01-01';                    -- only (c) - index not used

Covering Index

When all columns needed by a query are included in the index, InnoDB can return results from the secondary index alone without accessing the table data (clustered index). EXPLAIN shows "Using index" in the Extra column.

-- Covering index design
CREATE INDEX idx_covering ON orders (user_id, status, total_amount);

-- This query can return results from index only (Using index)
EXPLAIN SELECT user_id, status, total_amount
FROM orders
WHERE user_id = 100 AND status = 'completed';

-- This query does NOT use covering index (SELECT * requires table data access)
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 'completed';

Practical Index Design Patterns

Follow these principles when determining composite index column order:

  1. Place equality (=) condition columns first
  2. Place range condition columns last (columns after a range condition cannot use the index)
  3. Prioritize high-cardinality columns (general recommendation, but query patterns matter more)
  4. Consider ORDER BY / GROUP BY columns
-- Bad design: Index on low-cardinality column only
CREATE INDEX idx_bad ON orders (status);  -- status has only 5-10 distinct values

-- Good design: Composite index matching query patterns
CREATE INDEX idx_good ON orders (user_id, status, created_at);

-- Design considering sorting
-- When WHERE user_id = ? ORDER BY created_at DESC is frequent
CREATE INDEX idx_sort ON orders (user_id, created_at DESC);

Query Rewriting Patterns

Converting Subqueries to JOINs

The MySQL optimizer often handles correlated subqueries inefficiently. Rewriting them as JOINs can significantly improve performance.

-- Bad: Correlated subquery (subquery executed for each row)
SELECT u.name, u.email,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u
WHERE u.status = 'active';

-- Good: LEFT JOIN + GROUP BY
SELECT u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.name, u.email;

-- Alternative: Using a Derived Table
SELECT u.name, u.email, COALESCE(oc.cnt, 0) AS order_count
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS cnt
    FROM orders
    GROUP BY user_id
) oc ON u.id = oc.user_id
WHERE u.status = 'active';

Converting OR to UNION

OR conditions can cause inefficient index usage or trigger full table scans.

-- Bad: Index underutilization due to OR condition
SELECT * FROM products
WHERE category_id = 10 OR brand_id = 20;

-- Good: Split using UNION ALL (each condition can use its own index)
SELECT * FROM products WHERE category_id = 10
UNION ALL
SELECT * FROM products WHERE brand_id = 20 AND category_id != 10;

Avoiding Functions on Indexed Columns

Applying functions to indexed columns prevents index usage. Since MySQL 8.0, you can create Expression Indexes, but rewriting the query is preferred when possible.

-- Bad: Function applied to indexed column
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

-- Good: Rewrite as range condition
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

-- MySQL 8.0+: Expression Index (Functional Index)
CREATE INDEX idx_email_lower ON users ((LOWER(email)));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

Preventing Implicit Type Conversion

When the column type and comparison value type differ, MySQL performs implicit type conversion, which causes indexes to be ignored.

-- Bad: phone_number is VARCHAR but compared with a number
SELECT * FROM users WHERE phone_number = 01012345678;
-- MySQL converts phone_number to numeric -> index not used

-- Good: Type matching
SELECT * FROM users WHERE phone_number = '01012345678';

Optimizer Hints

Index Hints

-- USE INDEX: Suggest a specific index (optimizer may ignore)
SELECT * FROM orders USE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';

-- FORCE INDEX: Force a specific index (must use index instead of full scan)
SELECT * FROM orders FORCE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';

-- IGNORE INDEX: Exclude a specific index
SELECT * FROM orders IGNORE INDEX (idx_status)
WHERE status = 'completed' AND user_id = 100;

MySQL 8.0+ Optimizer Hint Syntax

The new hint syntax introduced in MySQL 8.0 is written in comment-style format.

-- Fix JOIN order
SELECT /*+ JOIN_ORDER(u, o) */
  u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active';

-- Specify index for a specific table
SELECT /*+ INDEX(o idx_user_created) */
  o.*
FROM orders o
WHERE o.user_id = 100;

-- Force Hash Join
SELECT /*+ HASH_JOIN(u, o) */
  u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id;

-- Parallel query execution (MySQL 8.0.14+)
SELECT /*+ SET_VAR(innodb_parallel_read_threads=4) */
  COUNT(*) FROM large_table;

optimizer_switch System Variable

-- Check current settings
SHOW VARIABLES LIKE 'optimizer_switch';

-- Disable specific optimizations at session level
SET SESSION optimizer_switch = 'index_merge_intersection=off';
SET SESSION optimizer_switch = 'derived_merge=off';
SET SESSION optimizer_switch = 'batched_key_access=on';

-- MySQL 8.0.31+: Histogram-based optimization
ANALYZE TABLE orders UPDATE HISTOGRAM ON user_id, status WITH 100 BUCKETS;

Slow Query Log Configuration and Analysis

Slow Query Log Configuration

-- Enable slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- Record queries taking more than 1 second
SET GLOBAL log_queries_not_using_indexes = 'ON';  -- Also record queries not using indexes
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';

-- Permanent configuration in my.cnf
-- [mysqld]
-- slow_query_log = 1
-- long_query_time = 1
-- log_queries_not_using_indexes = 1
-- slow_query_log_file = /var/log/mysql/slow-query.log
-- min_examined_row_limit = 1000

Analysis with mysqldumpslow

# Top 10 slowest queries
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log

# Top 10 most frequent slow queries
mysqldumpslow -s c -t 10 /var/log/mysql/slow-query.log

# Filter by specific pattern
mysqldumpslow -s t -t 10 -g "orders" /var/log/mysql/slow-query.log

Detailed Analysis with pt-query-digest

Percona Toolkit's pt-query-digest is a more powerful analysis tool.

# Analyze slow log
pt-query-digest /var/log/mysql/slow-query.log

# Analyze specific time range
pt-query-digest --since="2026-03-10 00:00:00" --until="2026-03-11 00:00:00" \
  /var/log/mysql/slow-query.log

# Save results to file
pt-query-digest /var/log/mysql/slow-query.log > /tmp/slow-query-report.txt

The output includes per-query execution count, average/maximum execution time, rows examined, and rows sent. If the ratio of rows examined to rows sent exceeds 100:1, index improvements are needed.

InnoDB Buffer Pool Tuning

Buffer Pool Size Configuration

The InnoDB buffer pool is the core component that caches data and indexes in memory. Typically, 70-80% of total memory is allocated.

-- Check current buffer pool status
SHOW VARIABLES LIKE 'innodb_buffer_pool%';
SHOW STATUS LIKE 'Innodb_buffer_pool%';

-- Calculate buffer pool hit rate
SELECT
  (1 - (
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') /
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
  )) * 100 AS buffer_pool_hit_rate;
-- 99% or higher is desirable

Buffer Pool Instance Separation

In high-concurrency environments, splitting the buffer pool into multiple instances reduces mutex contention.

# my.cnf configuration example
[mysqld]
innodb_buffer_pool_size = 32G
innodb_buffer_pool_instances = 8    # Buffer pool size / instances = 4GB each
innodb_buffer_pool_chunk_size = 1G  # Online resizing unit
innodb_log_file_size = 4G
innodb_log_buffer_size = 64M
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000

Warm-up (Buffer Pool Dump/Load)

Prevent cold start performance degradation when the buffer pool is empty after MySQL restart.

-- Dump buffer pool at shutdown, load at startup
SET GLOBAL innodb_buffer_pool_dump_at_shutdown = ON;
SET GLOBAL innodb_buffer_pool_load_at_startup = ON;

-- Manual dump/load
SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;

-- Check load progress
SHOW STATUS LIKE 'Innodb_buffer_pool_load_status';

MySQL 8.0 vs 8.4 Optimizer Improvement Comparison

FeatureMySQL 8.0MySQL 8.4
EXPLAIN FORMATTRADITIONAL, JSON, TREETRADITIONAL, JSON, TREE (improved)
Hash JoinSupported from 8.0.18Performance and memory management improvements
Window FunctionsBasic supportExecution plan optimization improvements
Derived Table MergePartial supportBroader scope support
Invisible IndexSupportedSupported (management improvements)
Functional IndexSupportedSupported (Expression Index improvements)
Histogram StatisticsSupportedEnhanced auto-refresh capability
Parallel QueryLimitedExpanded InnoDB parallel reads
Cost ModelDefault cost modelCost constants recalibrated, SSD-aware
Subquery OptimizationBasic semi-joinExpanded anti-join and semi-join strategies
EXPLAIN ANALYZEIntroduced in 8.0.18Improved output format, memory usage display
-- MySQL 8.4: EXPLAIN improvement example
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id;

-- Using Invisible Index (test before dropping an index)
ALTER TABLE orders ALTER INDEX idx_status INVISIBLE;
-- After performance testing, if no issues, drop it
-- DROP INDEX idx_status ON orders;
-- If issues arise, reactivate
ALTER TABLE orders ALTER INDEX idx_status VISIBLE;

Failure Cases and Recovery Procedures

Case 1: Index Not Used Due to Implicit Type Conversion

-- Problem: Numeric comparison on a VARCHAR column
-- account_no column is VARCHAR(20) but application passes a number
EXPLAIN SELECT * FROM accounts WHERE account_no = 123456;
-- type: ALL (full table scan!)

-- Cause: MySQL converts account_no column to numeric, making index unusable
-- Fix: Match the types
EXPLAIN SELECT * FROM accounts WHERE account_no = '123456';
-- type: ref (index used)

Case 2: Index Skipped Due to Low Cardinality

-- Problem: Index exists but optimizer chooses full scan
-- status column has an index but 'active' accounts for 90% of all rows
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- type: ALL (optimizer determines full scan is more efficient)

-- Cause: Low cardinality makes the index inefficient
-- Fix 1: Create a composite index with other conditions
CREATE INDEX idx_status_created ON users (status, created_at);

-- Fix 2: When the index is only useful for specific values
-- MySQL does not directly support Partial Indexes, so consider changing query patterns

Case 3: Wrong Composite Index Column Order

-- Problem: Index created as (created_at, user_id)
-- Frequent query: WHERE user_id = ? AND created_at >= ?
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND created_at >= '2026-01-01';
-- Inefficient index usage (range scan on created_at first)

-- Fix: Place equality condition columns first
DROP INDEX idx_created_user ON orders;
CREATE INDEX idx_user_created ON orders (user_id, created_at);
-- type: range (efficient range scan)

Case 4: Index Fragmentation After Mass DELETE

-- Problem: Query performance degradation after deleting 5 million rows
-- Cause: Index pages have many empty spaces (fragmentation)

-- Diagnosis
SELECT
  TABLE_NAME,
  INDEX_LENGTH,
  DATA_LENGTH,
  DATA_FREE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'orders';

-- Fix: Rebuild indexes
ALTER TABLE orders ENGINE=InnoDB;  -- Rebuild table + indexes
-- Or
OPTIMIZE TABLE orders;

Production Optimization Checklist

Index Review

  • Verify all foreign key columns have indexes
  • Confirm EXPLAIN results for major queries show no type ALL
  • Identify and remove unused indexes
  • Remove duplicate indexes
  • Verify composite index column order matches query patterns
-- Find unused indexes
SELECT
  s.TABLE_SCHEMA,
  s.TABLE_NAME,
  s.INDEX_NAME,
  s.COLUMN_NAME
FROM INFORMATION_SCHEMA.STATISTICS s
LEFT JOIN performance_schema.table_io_waits_summary_by_index_usage p
  ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA
  AND s.TABLE_NAME = p.OBJECT_NAME
  AND s.INDEX_NAME = p.INDEX_NAME
WHERE p.COUNT_STAR = 0
  AND s.TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
  AND s.INDEX_NAME != 'PRIMARY'
ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME;

-- Find duplicate indexes (using sys schema)
SELECT * FROM sys.schema_redundant_indexes;

Query Review

  • Enable slow query log and analyze periodically
  • Verify no queries apply functions to indexed columns
  • Verify no queries cause implicit type conversion
  • Use specific column names instead of SELECT *
  • Eliminate N+1 query patterns

Server Configuration Review

  • Confirm innodb_buffer_pool_size is 70-80% of total memory
  • Confirm innodb_buffer_pool_instances is appropriate (minimum 8, 1 instance per 1GB buffer pool)
  • Confirm innodb_log_file_size is sufficient (1-4GB recommended)
  • Check innodb_flush_log_at_trx_commit value (1: safe, 2: compromise)
  • Enable innodb_buffer_pool_dump_at_shutdown/load_at_startup

Monitoring

  • Maintain buffer pool hit rate above 99%
  • Monitor slow query occurrence frequency
  • Periodically check index usage rates
  • Verify automatic table statistics refresh
  • Monitor lock waits and deadlocks

Operational Notes

Cautions When Modifying Indexes on Large Tables

In MySQL 8.0, most ALTER TABLE ... ADD INDEX operations are processed online (Instant or In-place), but pre-testing is essential in production environments.

-- Check lock mode when adding an index
ALTER TABLE orders ADD INDEX idx_new (col1, col2), ALGORITHM=INPLACE, LOCK=NONE;

-- Use pt-online-schema-change (recommended for large tables)
-- Synchronizes the original table via triggers while making schema changes
pt-online-schema-change \
  --alter "ADD INDEX idx_new (col1, col2)" \
  --execute \
  D=mydb,t=orders

Note on Query Cache

Query Cache has been completely removed since MySQL 8.0. It should be replaced with application-level caching (Redis, Memcached, etc.).

Using Histogram Statistics

Histograms introduced in MySQL 8.0 provide column value distribution information to the optimizer, helping it choose better execution plans.

-- Create histogram
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;
ANALYZE TABLE orders UPDATE HISTOGRAM ON total_amount WITH 254 BUCKETS;

-- Check histogram
SELECT
  SCHEMA_NAME,
  TABLE_NAME,
  COLUMN_NAME,
  JSON_EXTRACT(HISTOGRAM, '$.histogram-type') AS histogram_type,
  JSON_EXTRACT(HISTOGRAM, '$.number-of-buckets-specified') AS buckets
FROM INFORMATION_SCHEMA.COLUMN_STATISTICS;

-- Drop histogram
ANALYZE TABLE orders DROP HISTOGRAM ON status;

References