---
title: "Statement Digest 분석: 쿼리 패턴별 latency와 rows examined 추적"
description: "MySQL statement digest를 기준으로 쿼리 패턴별 지연 시간과 조사 행 수를 구간별로 추적하고 튜닝 우선순위를 정하는 방법을 설명한다."
tags: [ MySQL, 성능최적화, 운영, DBA ]
image: "mysql-report-bg.png"
published: "2026-08-17"
updated: "2026-08-17"
author: "MySQL 기술 노트"
source_url: ""
---

MySQL 성능 문제를 조사할 때 개별 SQL 원문만 나열하면 같은 구조의 문장이 서로 다른 literal 때문에 수천 개 항목으로 흩어진다. 반대로 평균 응답 시간 하나만 보면 호출량이 많은 짧은 쿼리와 드물게 발생하는 긴 지연을 구분하기 어렵다. 운영자가 관리해야 할 단위는 SQL 문자열 한 줄이 아니라, 구조가 같은 문장을 정규화한 **statement digest**와 일정 시간 구간에서 그 digest가 소비한 작업량이다.

`performance_schema.events_statements_summary_by_digest`는 digest별 실행 횟수, 누적·평균·최대 latency, 조사 행 수, 반환 행 수, 임시 테이블과 인덱스 미사용 신호를 누적한다. 이 정보를 올바르게 사용하면 다음 질문에 답할 수 있다.

- 호출 횟수는 많지만 한 번의 비용은 작은 쿼리 패턴은 무엇인가?
- 호출 수는 적어도 평균·최대 latency가 큰 패턴은 무엇인가?
- 반환 행에 비해 `ROWS_EXAMINED`가 지나치게 큰 패턴은 무엇인가?
- 배포 전후 또는 장애 전후에 호출량과 작업량이 실제로 얼마나 변했는가?
- 긴 지연이 실행 계획 문제인지, 잠금·I/O·동시성 문제인지 추가 조사해야 하는가?

이 글은 MySQL 8.0 이상을 기준으로 digest가 만들어지는 과정, 핵심 지표의 의미, 재현 가능한 비교 실험, 두 시점 snapshot의 delta 계산, 운영 한계와 Aurora MySQL 해석까지 설명한다.

## 1. Statement digest는 무엇을 묶는가

MySQL은 계측된 statement의 SQL text를 토큰화하고 literal을 placeholder로 정규화하여 `DIGEST_TEXT`와 hash 식별자인 `DIGEST`를 만든다. 예를 들어 다음 두 문장은 서로 다른 고객 번호를 사용하지만 같은 구조다.

```text
SELECT order_id, total_amount FROM orders WHERE customer_id = 101;
SELECT order_id, total_amount FROM orders WHERE customer_id = 9072;
```

정규화 결과는 대략 다음과 같은 형태가 된다.

```text
SELECT `order_id` , `total_amount` FROM `orders` WHERE `customer_id` = ?
```

같은 digest로 분류된 실행은 summary 한 행에 누적된다. 이 압축 덕분에 운영자는 수백만 번의 호출을 query pattern 단위로 분석할 수 있다. 그러나 digest가 같다고 해서 실행 비용까지 같다는 뜻은 아니다. parameter 값에 따라 선택도, range 크기, 반환 행 수, lock 경합, cache hit 여부가 달라질 수 있다.

```mermaid
flowchart LR
    A[애플리케이션 SQL 실행] --> B[Statement instrument]
    B --> C[SQL token 정규화]
    C --> D[DIGEST와 DIGEST_TEXT]
    D --> E[Digest summary 누적]
    E --> F1[COUNT_STAR 호출량]
    E --> F2[SUM AVG MAX latency]
    E --> F3[ROWS_EXAMINED와 ROWS_SENT]
    E --> F4[임시 테이블 정렬 인덱스 신호]
    F1 --> G[두 시점 snapshot]
    F2 --> G
    F3 --> G
    F4 --> G
    G --> H[구간 delta와 튜닝 우선순위]
```

### Digest가 보존하지 않는 정보

Digest는 운영 집계를 위해 세부 정보를 의도적으로 버린다.

- literal 원값과 bind parameter 분포
- 개별 실행의 정확한 시작·종료 시각
- 모든 실행의 개별 latency 분포
- 애플리케이션 route와 최종 사용자 요청 문맥
- 특정 실행이 기다린 lock 또는 I/O의 전체 인과관계

`QUERY_SAMPLE_TEXT`가 실제 문장 표본을 제공할 수 있지만, 모든 실행을 보존하는 로그가 아니며 가장 느린 실행이라고 보장되지 않는다. 표본에는 개인정보와 업무 식별자가 포함될 수 있으므로 접근 권한과 보존 정책도 필요하다.

## 2. 수집 경로가 활성화되어 있는지 먼저 확인한다

Digest table이 비어 있을 때 바로 “부하가 없다”고 판단해서는 안 된다. Performance Schema 자체, statement instrument, `statements_digest` consumer, digest table 용량을 차례로 확인해야 한다.

```sql
SELECT VERSION() AS mysql_version,
       @@performance_schema AS performance_schema_enabled;

SELECT NAME, ENABLED
FROM performance_schema.setup_consumers
WHERE NAME = 'statements_digest';

SELECT COUNT(*) AS statement_instruments,
       SUM(ENABLED = 'YES') AS enabled_instruments,
       SUM(TIMED = 'YES') AS timed_instruments
FROM performance_schema.setup_instruments
WHERE NAME LIKE 'statement/%';

SELECT @@performance_schema_digests_size AS digest_capacity,
       COUNT(*) AS current_digest_rows
FROM performance_schema.events_statements_summary_by_digest;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT VERSION() AS mysql_version,
    ->        @@performance_schema AS performance_schema_enabled;

+---------------+----------------------------+
| mysql_version | performance_schema_enabled |
+---------------+----------------------------+
| 8.0.46        |                          1 |
+---------------+----------------------------+
1 row in set (0.00 sec)

mysql> SELECT NAME, ENABLED
    -> FROM performance_schema.setup_consumers
    -> WHERE NAME = 'statements_digest';

+-------------------+---------+
| NAME              | ENABLED |
+-------------------+---------+
| statements_digest | YES     |
+-------------------+---------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) AS statement_instruments,
    ->        SUM(ENABLED = 'YES') AS enabled_instruments,
    ->        SUM(TIMED = 'YES') AS timed_instruments
    -> FROM performance_schema.setup_instruments
    -> WHERE NAME LIKE 'statement/%';

+-----------------------+---------------------+-------------------+
| statement_instruments | enabled_instruments | timed_instruments |
+-----------------------+---------------------+-------------------+
|                   213 |                 198 |               198 |
+-----------------------+---------------------+-------------------+
1 row in set (0.00 sec)

mysql> SELECT @@performance_schema_digests_size AS digest_capacity,
    ->        COUNT(*) AS current_digest_rows
    -> FROM performance_schema.events_statements_summary_by_digest;

+-----------------+---------------------+
| digest_capacity | current_digest_rows |
+-----------------+---------------------+
|           10000 |                   7 |
+-----------------+---------------------+
1 row in set (0.00 sec)
```

`statements_digest='YES'`여도 모든 문장이 반드시 원하는 형태로 보이는 것은 아니다. 특정 statement instrument가 비활성화되어 있거나, digest table이 가득 찼거나, SQL text 길이 관련 시작 옵션 때문에 표시 정보가 제한될 수 있다. `performance_schema_digests_size`는 서버 시작 시 메모리 구조에 영향을 주는 설정이므로 증설 전에 실제 digest 개수와 메모리 예산을 확인한다.

## 3. 핵심 지표를 함께 읽는 방법

Digest 한 행을 평가할 때는 latency와 row 작업량을 분리하지 않는다.

| 지표 | 의미 | 운영 해석 |
|---|---|---|
| `COUNT_STAR` | 완료된 실행 횟수 | 호출 폭증과 누적 비용의 배경을 확인한다. |
| `SUM_TIMER_WAIT` | 누적 statement 시간 | 전체 DB 시간을 많이 소비한 패턴을 찾는다. |
| `AVG_TIMER_WAIT` | 호출당 평균 시간 | 전형적인 한 번의 비용을 가늠한다. |
| `MAX_TIMER_WAIT` | 관측 구간의 최대 시간 | tail latency와 간헐적 대기를 조사하는 단서다. |
| `QUANTILE_95`, `QUANTILE_99` | histogram 기반 latency 상한 추정 | 정확한 요청 추적 percentile과 동일하다고 가정하지 않는다. |
| `SUM_ROWS_EXAMINED` | 서버가 조건 평가 과정에서 조사한 행 수 | 비효율적인 scan과 filtering 후보를 찾는다. |
| `SUM_ROWS_SENT` | 클라이언트에 보낸 행 수 | 조사 행 대비 결과 행의 차이를 해석한다. |
| `SUM_NO_INDEX_USED` | 인덱스를 사용하지 않은 실행 횟수 | 작은 테이블의 합리적 full scan인지 별도 확인한다. |
| `FIRST_SEEN`, `LAST_SEEN` | digest 관측 범위 | 신규 패턴과 최근 활동 여부를 판단한다. |

Performance Schema timer는 일반적으로 picosecond 단위 정수다. millisecond로 표시할 때는 `1,000,000,000`으로 나누고, second로 표시할 때는 `1,000,000,000,000`으로 나눈다. 저장·delta 계산에는 원본 정수를 유지하고 표시 단계에서 변환하는 편이 반올림 오류를 줄인다.

### 3.1 Rows examined per call

다음 값은 한 번 호출할 때 평균적으로 몇 행을 조사했는지 나타낸다.

```text
SUM_ROWS_EXAMINED / COUNT_STAR
```

호출 수가 증가해 누적 조사 행이 커진 경우와, 실행 계획이 나빠져 호출당 조사 행이 커진 경우를 구분하는 데 유용하다. 전자는 호출 경로·cache·batching을, 후자는 index·predicate·통계를 우선 조사할 수 있다.

### 3.2 Rows examined per row sent

다음 비율은 결과 한 행을 보내기 위해 얼마나 많은 행을 조사했는지 보여 주는 탐색 효율 신호다.

```text
SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0)
```

이 비율은 절대적인 불량 판정 기준이 아니다. `COUNT(*)`, 집계, 존재 여부 검사, batch scan은 의도적으로 많은 행을 읽고 적은 행을 반환한다. 반대로 비율이 낮아도 wide row 전송, filesort, lock wait, 함수 계산 때문에 느릴 수 있다. SQL의 업무 목적과 실행 계획을 함께 봐야 한다.

### 3.3 평균과 최대의 간격

`MAX_TIMER_WAIT`가 `AVG_TIMER_WAIT`보다 매우 크면 parameter skew, cold cache, lock wait, metadata lock, storage stall, plan 변화 가능성을 조사한다. 그러나 누적 summary만으로 어떤 실행이 원인이었는지 복원할 수는 없다. 해당 시간대의 slow query log, `events_statements_history_long`, lock 진단, 애플리케이션 trace를 함께 사용해야 한다.

## 4. Indexed lookup과 full scan을 digest로 비교한다

다음 재현은 1,000행 테이블에 `tenant_id` index만 만들고 두 query pattern을 각각 두 번 실행한다. 첫 패턴은 index로 일부 행을 찾고, 두 번째 패턴은 index가 없는 `status`를 조건으로 전체 테이블을 조사한다. `TRUNCATE`는 digest summary의 기존 관측값을 지우므로 **폐기 가능한 검증 환경에서만** 실행한다. 운영 서버에서는 절대로 예제 그대로 초기화하지 말고 snapshot delta를 사용한다.

```sql
TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;
DROP TABLE IF EXISTS statement_digest_demo;
DROP TABLE IF EXISTS digest_digits;

CREATE TABLE digest_digits (
    n TINYINT UNSIGNED PRIMARY KEY
) ENGINE = InnoDB;

INSERT INTO digest_digits VALUES
    (0), (1), (2), (3), (4), (5), (6), (7), (8), (9);

CREATE TABLE statement_digest_demo (
    id BIGINT UNSIGNED NOT NULL,
    tenant_id INT UNSIGNED NOT NULL,
    status VARCHAR(16) NOT NULL,
    payload VARCHAR(80) NOT NULL,
    PRIMARY KEY (id),
    KEY ix_tenant (tenant_id)
) ENGINE = InnoDB;

INSERT INTO statement_digest_demo (id, tenant_id, status, payload)
SELECT seq_id,
       MOD(seq_id - 1, 50) + 1,
       CASE WHEN MOD(seq_id, 10) = 0 THEN 'ARCHIVED' ELSE 'ACTIVE' END,
       RPAD('x', 40, 'x')
FROM (
    SELECT 1 + ones.n + tens.n * 10 + hundreds.n * 100 AS seq_id
    FROM digest_digits AS ones
    CROSS JOIN digest_digits AS tens
    CROSS JOIN digest_digits AS hundreds
) AS generated_rows;

ANALYZE TABLE statement_digest_demo;

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE tenant_id = 7;

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE tenant_id = 13;

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE status = 'ARCHIVED';

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE status = 'ACTIVE';

SELECT LEFT(DIGEST_TEXT, 120) AS digest_text,
       COUNT_STAR AS calls,
       ROUND(SUM_TIMER_WAIT / 1000000000, 3) AS total_ms,
       ROUND(AVG_TIMER_WAIT / 1000000000, 3) AS avg_ms,
       ROUND(MAX_TIMER_WAIT / 1000000000, 3) AS max_ms,
       SUM_ROWS_EXAMINED AS rows_examined,
       SUM_ROWS_SENT AS rows_sent,
       ROUND(SUM_ROWS_EXAMINED / NULLIF(COUNT_STAR, 0), 1) AS examined_per_call,
       SUM_NO_INDEX_USED AS no_index_calls
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE()
  AND DIGEST_TEXT LIKE 'SELECT COUNT%'
  AND DIGEST_TEXT LIKE '%`statement_digest_demo`%'
ORDER BY SUM_ROWS_EXAMINED DESC;
```

실행 결과(MySQL 8.0.x):

아래 출력은 준비 DDL/DML의 성공 여부와 최종 digest 집계 결과를 발췌한 것이다. 시간값은 같은 SQL을 임시 검증 환경에서 재실행한 한 번의 관측값이다.

```text
mysql> CREATE TABLE digest_digits (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO digest_digits VALUES (0), ..., (9);
Query OK, 10 rows affected (0.01 sec)
Records: 10  Duplicates: 0  Warnings: 0

mysql> CREATE TABLE statement_digest_demo (..., KEY ix_tenant (tenant_id));
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO statement_digest_demo (id, tenant_id, status, payload)
    -> SELECT ...;
Query OK, 1000 rows affected (0.01 sec)
Records: 1000  Duplicates: 0  Warnings: 0

mysql> SELECT LEFT(DIGEST_TEXT, 120) AS digest_text, ...
    -> FROM performance_schema.events_statements_summary_by_digest
    -> WHERE SCHEMA_NAME = DATABASE()
    ->   AND DIGEST_TEXT LIKE 'SELECT COUNT%'
    ->   AND DIGEST_TEXT LIKE '%`statement_digest_demo`%'
    -> ORDER BY SUM_ROWS_EXAMINED DESC;
+-----------------------------------------------------------------------------------------+-------+----------+--------+--------+---------------+-----------+-------------------+----------------+
| digest_text                                                                             | calls | total_ms | avg_ms | max_ms | rows_examined | rows_sent | examined_per_call | no_index_calls |
+-----------------------------------------------------------------------------------------+-------+----------+--------+--------+---------------+-----------+-------------------+----------------+
| SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE STATUS = ?      |     2 |    0.437 |  0.218 |  0.221 |          2000 |         2 |            1000.0 |              2 |
| SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE `tenant_id` = ? |     2 |    1.116 |  0.558 |  0.991 |            40 |         2 |              20.0 |              0 |
+-----------------------------------------------------------------------------------------+-------+----------+--------+--------+---------------+-----------+-------------------+----------------+
2 rows in set (0.00 sec)
```

같은 column을 비교하는 두 실행은 literal이 달라도 하나의 digest에 합쳐진다. 검증 결과에서 `status = ?` 패턴은 호출당 1,000행을 조사하고, `tenant_id = ?` 패턴은 호출당 20행을 조사해야 한다. latency 절대값은 CPU, cache, timer 해상도와 실행 시점에 따라 달라지므로 작은 임시 테이블의 시간값을 운영 임계값으로 사용하지 않는다. 이 예제에서 안정적으로 비교할 신호는 digest 통합 여부, `COUNT_STAR`, `SUM_ROWS_EXAMINED`, `SUM_NO_INDEX_USED`의 방향이다.

### Full scan 신호를 즉시 index 생성 명령으로 바꾸지 않는다

`SUM_NO_INDEX_USED`가 높아도 다음 조건에서는 full scan이 합리적일 수 있다.

- 테이블이 매우 작아 index lookup보다 연속 scan이 저렴하다.
- 대부분의 행을 반환하는 낮은 선택도 조건이다.
- 집계·ETL·점검 작업이 의도적으로 전체 데이터를 읽는다.
- 사용 가능한 index가 있어도 통계와 비용 계산상 scan이 더 싸다.
- index를 추가하면 쓰기 증폭과 Buffer Pool 사용량이 더 크게 증가한다.

따라서 digest는 튜닝 후보를 찾는 도구이고, 최종 변경은 대표 parameter와 실제 데이터 분포에서 `EXPLAIN ANALYZE`로 검증해야 한다.

## 5. 현재 누적 상위 패턴을 조회하는 운영 쿼리

다음 쿼리는 업무 schema의 `SELECT` digest를 누적 latency 순으로 조회한다. `DIGEST`를 함께 보존해야 `DIGEST_TEXT`가 잘리거나 비슷하게 보이는 패턴을 구분할 수 있다.

```sql
SELECT COALESCE(SCHEMA_NAME, '<no schema>') AS schema_name,
       DIGEST,
       LEFT(DIGEST_TEXT, 120) AS digest_text,
       COUNT_STAR AS calls,
       ROUND(SUM_TIMER_WAIT / 1000000000000, 6) AS total_seconds,
       ROUND(AVG_TIMER_WAIT / 1000000000, 3) AS avg_ms,
       ROUND(MAX_TIMER_WAIT / 1000000000, 3) AS max_ms,
       ROUND(QUANTILE_95 / 1000000000, 3) AS p95_upper_ms,
       SUM_ROWS_EXAMINED AS rows_examined,
       SUM_ROWS_SENT AS rows_sent,
       ROUND(SUM_ROWS_EXAMINED / NULLIF(COUNT_STAR, 0), 1) AS examined_per_call,
       ROUND(SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0), 1) AS examined_per_row_sent,
       FIRST_SEEN,
       LAST_SEEN
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST IS NOT NULL
  AND SCHEMA_NAME NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
  AND DIGEST_TEXT LIKE 'SELECT%'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
```

실행 결과(MySQL 8.0.x):

다음은 검증 출력에서 설명 대상 두 digest의 핵심 열만 발췌한 것이다. digest hash, 시각 열과 검증 SQL 자체의 digest 행은 생략했다.

```text
mysql> SELECT COALESCE(SCHEMA_NAME, '<no schema>') AS schema_name,
    ->        DIGEST, LEFT(DIGEST_TEXT, 120) AS digest_text, ...
    -> FROM performance_schema.events_statements_summary_by_digest
    -> WHERE DIGEST IS NOT NULL
    ->   AND SCHEMA_NAME NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
    ->   AND DIGEST_TEXT LIKE 'SELECT%'
    -> ORDER BY SUM_TIMER_WAIT DESC
    -> LIMIT 10;
+-----------------+-----------------------------------------------------------------------------------------+-------+---------------+--------+--------+--------------+---------------+-----------+-------------------+-----------------------+
| schema_name     | digest_text                                                                             | calls | total_seconds | avg_ms | max_ms | p95_upper_ms | rows_examined | rows_sent | examined_per_call | examined_per_row_sent |
+-----------------+-----------------------------------------------------------------------------------------+-------+---------------+--------+--------+--------------+---------------+-----------+-------------------+-----------------------+
| mysql_tech_note | SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE STATUS = ?      |     2 |        0.0004 |  0.215 |  0.234 |        0.240 |          2000 |         2 |            1000.0 |                1000.0 |
| mysql_tech_note | SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE `tenant_id` = ? |     2 |        0.0004 |  0.191 |  0.297 |        0.302 |            40 |         2 |              20.0 |                  20.0 |
+-----------------+-----------------------------------------------------------------------------------------+-------+---------------+--------+--------+--------------+---------------+-----------+-------------------+-----------------------+
```

이 결과는 서버 시작 또는 summary 초기화 이후의 **누적값**이다. 어제 조회한 누적값과 오늘 조회한 누적값을 그대로 순위 비교하면 서로 다른 길이의 관측 구간을 비교하게 된다. 트래픽이 많은 오래된 패턴이 계속 상위에 남아 최근 회귀를 가릴 수도 있다. 운영 경보와 배포 전후 비교에는 반드시 동일한 길이의 구간 delta를 사용한다.

## 6. 두 시점 snapshot으로 구간 delta를 계산한다

누적 counter를 구간 지표로 바꾸는 기본 절차는 다음과 같다.

1. 시점 A에서 `(server epoch, schema, digest, counter)`를 저장한다.
2. 일정 시간이 지난 시점 B에서 같은 값을 저장한다.
3. 같은 `(schema, digest)`의 B-A를 계산한다.
4. `COUNT_STAR`, `SUM_TIMER_WAIT`, `SUM_ROWS_EXAMINED`, `SUM_ROWS_SENT` delta를 함께 해석한다.
5. restart, failover, `TRUNCATE`, digest eviction이 발생하면 새 epoch로 시작한다.

다음 예제는 별도 snapshot batch와 value table을 만들고, 앞에서 생성한 두 query pattern을 추가 실행한 뒤 delta를 계산한다. 실제 운영에서는 이 저장소를 관측 대상 primary가 아닌 별도 관리 DB나 외부 시계열 저장소에 두는 편이 안전하다.

```sql
DROP TABLE IF EXISTS ops_digest_snapshot_value;
DROP TABLE IF EXISTS ops_digest_snapshot_batch;

CREATE TABLE ops_digest_snapshot_batch (
    batch_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    captured_at DATETIME(6) NOT NULL,
    server_uuid VARCHAR(64) NOT NULL,
    uptime_seconds BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (batch_id)
) ENGINE = InnoDB;

CREATE TABLE ops_digest_snapshot_value (
    batch_id BIGINT UNSIGNED NOT NULL,
    schema_name VARCHAR(64) NOT NULL,
    digest VARCHAR(64) NOT NULL,
    digest_text LONGTEXT NOT NULL,
    count_star BIGINT UNSIGNED NOT NULL,
    sum_timer_wait BIGINT UNSIGNED NOT NULL,
    sum_rows_examined BIGINT UNSIGNED NOT NULL,
    sum_rows_sent BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (batch_id, schema_name, digest),
    CONSTRAINT fk_digest_snapshot_batch
        FOREIGN KEY (batch_id) REFERENCES ops_digest_snapshot_batch (batch_id)
        ON DELETE CASCADE
) ENGINE = InnoDB;

INSERT INTO ops_digest_snapshot_batch (captured_at, server_uuid, uptime_seconds)
SELECT NOW(6), @@server_uuid, CAST(VARIABLE_VALUE AS UNSIGNED)
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Uptime';
SET @batch_a = LAST_INSERT_ID();

INSERT INTO ops_digest_snapshot_value
SELECT @batch_a,
       SCHEMA_NAME,
       DIGEST,
       DIGEST_TEXT,
       COUNT_STAR,
       SUM_TIMER_WAIT,
       SUM_ROWS_EXAMINED,
       SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE()
  AND DIGEST IS NOT NULL
  AND DIGEST_TEXT LIKE 'SELECT COUNT%'
  AND DIGEST_TEXT LIKE '%`statement_digest_demo`%';

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE tenant_id = 21;

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE tenant_id = 34;

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE status = 'ARCHIVED';

SELECT COUNT(*) AS matched_rows
FROM statement_digest_demo
WHERE status = 'ACTIVE';

INSERT INTO ops_digest_snapshot_batch (captured_at, server_uuid, uptime_seconds)
SELECT NOW(6), @@server_uuid, CAST(VARIABLE_VALUE AS UNSIGNED)
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Uptime';
SET @batch_b = LAST_INSERT_ID();

INSERT INTO ops_digest_snapshot_value
SELECT @batch_b,
       SCHEMA_NAME,
       DIGEST,
       DIGEST_TEXT,
       COUNT_STAR,
       SUM_TIMER_WAIT,
       SUM_ROWS_EXAMINED,
       SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE()
  AND DIGEST IS NOT NULL
  AND DIGEST_TEXT LIKE 'SELECT COUNT%'
  AND DIGEST_TEXT LIKE '%`statement_digest_demo`%';

SELECT LEFT(newer.digest_text, 100) AS digest_text,
       newer.count_star - older.count_star AS calls_delta,
       ROUND((newer.sum_timer_wait - older.sum_timer_wait) / 1000000000, 3) AS total_ms_delta,
       newer.sum_rows_examined - older.sum_rows_examined AS rows_examined_delta,
       newer.sum_rows_sent - older.sum_rows_sent AS rows_sent_delta,
       ROUND(
           (newer.sum_rows_examined - older.sum_rows_examined)
           / NULLIF(newer.count_star - older.count_star, 0),
           1
       ) AS examined_per_call
FROM ops_digest_snapshot_value AS older
JOIN ops_digest_snapshot_value AS newer
  ON newer.schema_name = older.schema_name
 AND newer.digest = older.digest
WHERE older.batch_id = @batch_a
  AND newer.batch_id = @batch_b
ORDER BY rows_examined_delta DESC;

DROP TABLE ops_digest_snapshot_value;
DROP TABLE ops_digest_snapshot_batch;
DROP TABLE statement_digest_demo;
DROP TABLE digest_digits;
```

실행 결과(MySQL 8.0.x):

아래 출력은 준비·정리 DDL을 줄이고 snapshot 적재 성공 신호와 핵심 delta 결과를 보존한 발췌다. 시간값은 한 번의 검증 실행 결과이며 고정 기준값이 아니다.

```text
mysql> CREATE TABLE ops_digest_snapshot_batch (...);
Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE ops_digest_snapshot_value (...);
Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO ops_digest_snapshot_value SELECT @batch_a, ...;
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> INSERT INTO ops_digest_snapshot_value SELECT @batch_b, ...;
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> SELECT LEFT(newer.digest_text, 100) AS digest_text, ...
    -> FROM ops_digest_snapshot_value AS older
    -> JOIN ops_digest_snapshot_value AS newer
    ->   ON newer.schema_name = older.schema_name
    ->  AND newer.digest = older.digest
    -> WHERE older.batch_id = @batch_a
    ->   AND newer.batch_id = @batch_b
    -> ORDER BY rows_examined_delta DESC;
+-----------------------------------------------------------------------------------------+-------------+----------------+---------------------+-----------------+-------------------+
| digest_text                                                                             | calls_delta | total_ms_delta | rows_examined_delta | rows_sent_delta | examined_per_call |
+-----------------------------------------------------------------------------------------+-------------+----------------+---------------------+-----------------+-------------------+
| SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE STATUS = ?      |           2 |          0.384 |                2000 |               2 |            1000.0 |
| SELECT COUNT ( * ) AS `matched_rows` FROM `statement_digest_demo` WHERE `tenant_id` = ? |           2 |          0.203 |                  40 |               2 |              20.0 |
+-----------------------------------------------------------------------------------------+-------------+----------------+---------------------+-----------------+-------------------+
2 rows in set (0.00 sec)
```

검증 결과에서 두 패턴 모두 `calls_delta=2`다. 그러나 `status = ?` 패턴의 `rows_examined_delta`는 2,000이고, `tenant_id = ?` 패턴은 40이다. 이렇게 delta를 사용하면 과거 누적 부하가 아니라 해당 관측 구간에 실제로 발생한 작업량을 비교할 수 있다.

### 6.1 Snapshot 수집기가 반드시 보존할 문맥

단순 counter만 저장하면 reset을 정상 감소로 오인할 수 있다. 최소한 다음 값을 함께 보존한다.

- 수집 시각과 시간대
- `@@server_uuid` 또는 관리형 서비스의 instance identifier
- `Uptime`과 수집 epoch
- server role과 writer/reader 구분
- MySQL version
- `SCHEMA_NAME`, `DIGEST`, `DIGEST_TEXT`
- 수집 성공 여부와 누락 digest 수
- 배포·failover·restart·summary reset 이벤트

`DIGEST_TEXT`는 표시와 조사에 사용하고, 시계열 key는 `DIGEST`와 schema를 사용한다. MySQL upgrade로 digest 계산 방식이나 정규화 표현이 달라질 수 있으므로 version 경계도 별도 epoch로 취급하는 편이 안전하다.

### 6.2 새 digest와 사라진 digest 처리

두 snapshot을 inner join하면 A에는 없고 B에 새로 생긴 digest를 놓친다. 실제 수집기는 다음 정책을 구현해야 한다.

- B에만 있는 digest: B의 counter를 새 epoch의 첫 delta로 보거나, 첫 구간은 `new_digest=true`로 별도 표시한다.
- A에만 있는 digest: 호출이 없었는지 eviction되었는지 capacity 상태와 함께 판정한다.
- B counter가 A보다 작음: restart, reset, failover, row replacement를 의심하고 음수 delta를 버린다.
- `DIGEST=NULL`: 분류되지 않은 statement 비율로 별도 집계한다.

## 7. Digest capacity와 분류 누락을 감시한다

Digest table의 용량이 부족하면 새로운 digest는 `DIGEST=NULL` catch-all 행에 합쳐질 수 있다. 이 비율이 커지면 상위 패턴 보고서가 workload를 제대로 대표하지 못한다.

```sql
SELECT @@performance_schema_digests_size AS digest_capacity,
       COUNT(*) AS occupied_rows,
       SUM(DIGEST IS NULL) AS catch_all_rows,
       SUM(COUNT_STAR) AS total_statements,
       SUM(CASE WHEN DIGEST IS NULL THEN COUNT_STAR ELSE 0 END) AS unclassified_statements,
       ROUND(
           100 * SUM(CASE WHEN DIGEST IS NULL THEN COUNT_STAR ELSE 0 END)
           / NULLIF(SUM(COUNT_STAR), 0),
           2
       ) AS unclassified_pct
FROM performance_schema.events_statements_summary_by_digest;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT @@performance_schema_digests_size AS digest_capacity,
    ->        COUNT(*) AS occupied_rows,
    ->        SUM(DIGEST IS NULL) AS catch_all_rows,
    ->        SUM(COUNT_STAR) AS total_statements,
    ->        SUM(CASE WHEN DIGEST IS NULL THEN COUNT_STAR ELSE 0 END) AS unclassified_statements,
    ->        ROUND(
    ->            100 * SUM(CASE WHEN DIGEST IS NULL THEN COUNT_STAR ELSE 0 END)
    ->            / NULLIF(SUM(COUNT_STAR), 0),
    ->            2
    ->        ) AS unclassified_pct
    -> FROM performance_schema.events_statements_summary_by_digest;

+-----------------+---------------+----------------+------------------+-------------------------+------------------+
| digest_capacity | occupied_rows | catch_all_rows | total_statements | unclassified_statements | unclassified_pct |
+-----------------+---------------+----------------+------------------+-------------------------+------------------+
|           10000 |            26 |              0 |               39 |                       0 |             0.00 |
+-----------------+---------------+----------------+------------------+-------------------------+------------------+
1 row in set (0.00 sec)
```

`occupied_rows`가 capacity에 근접하거나 `unclassified_pct`가 증가하면 다음 순서로 조사한다.

1. tenant별 동적 table 이름처럼 identifier가 무한히 늘어나는지 확인한다.
2. 애플리케이션이 literal을 identifier 또는 SQL 구조에 삽입하는지 확인한다.
3. restart·초기화 이후 digest 증가 속도를 측정한다.
4. 메모리 예산과 시작 옵션 변경 영향을 검토한다.
5. 용량 증설 전에 SQL 생성 패턴을 안정화할 수 있는지 확인한다.

용량을 늘리는 것만으로 동적 SQL 폭증을 해결할 수는 없다. 지나치게 많은 digest는 분석 cardinality를 키우고 중요한 패턴을 찾기 어렵게 만든다.

## 8. 튜닝 우선순위를 정하는 네 가지 축

하나의 종합 점수만 만들면 서로 다른 실패 모드가 섞인다. 다음 네 목록을 따로 만든 뒤 교집합을 보는 편이 안전하다.

### 8.1 누적 영향도

- `SUM_TIMER_WAIT delta`가 큰 패턴
- `COUNT_STAR delta`가 큰 패턴
- 서비스 핵심 route 또는 batch 마감에 연결된 패턴

호출당 2ms여도 초당 수천 번 실행되면 전체 CPU와 connection 점유에 큰 영향을 줄 수 있다.

### 8.2 호출당 비용

- `AVG_TIMER_WAIT` 또는 `SUM_TIMER_WAIT delta / COUNT_STAR delta`
- `MAX_TIMER_WAIT`와 p95/p99 추정값
- 같은 digest 안에서 평균과 최대의 격차

호출 수가 적지만 한 번의 지연이 사용자 timeout이나 transaction 장기화로 이어지는 패턴은 별도 우선순위가 필요하다.

### 8.3 Row 효율

- `SUM_ROWS_EXAMINED delta / COUNT_STAR delta`
- `SUM_ROWS_EXAMINED delta / SUM_ROWS_SENT delta`
- `SUM_NO_INDEX_USED`, sort, temporary table 관련 delta

Rows examined가 크면 index와 predicate를 우선 의심할 수 있지만, 집계·분석 workload의 업무 목적을 먼저 확인한다.

### 8.4 개선 위험과 검증 가능성

- index 추가가 DML, redo, backup, replica apply에 미치는 영향
- SQL rewrite가 결과 의미와 lock 범위를 바꾸는지 여부
- 대표 parameter와 실제 데이터 분포를 확보할 수 있는지 여부
- 배포 전후 같은 digest와 같은 시간대의 delta를 비교할 수 있는지 여부
- rollback 방법과 관측 기간이 정해져 있는지 여부

일반적으로 구간 총시간과 조사 행이 모두 크고, 호출당 작업량이 안정적으로 높으며, 낮은 위험으로 검증 가능한 패턴부터 처리한다.

## 9. 흔한 오해와 실패 모드

### 9.1 누적 상위 10개를 현재 장애 원인으로 본다

누적값에는 장애 이전의 정상 트래픽이 포함된다. 현재 문제는 짧은 구간 delta, 현재 실행 thread, 애플리케이션 오류율과 함께 판단한다.

### 9.2 Rows examined가 크면 무조건 index를 추가한다

집계나 낮은 선택도 조회는 많은 행을 읽는 것이 정상일 수 있다. 새 index의 쓰기 비용과 실제 `EXPLAIN ANALYZE` 결과를 확인한다.

### 9.3 Digest가 같으면 parameter에 관계없이 같은 계획과 비용이라고 본다

같은 query pattern 안에서도 값 분포와 range 크기에 따라 비용이 달라진다. sample 하나가 전체 호출을 대표한다고 가정하지 않는다.

### 9.4 평균 latency가 낮으면 안전하다고 본다

평균은 lock wait와 storage stall 같은 tail을 숨길 수 있다. 최대, quantile, slow log, 요청 trace를 함께 본다.

### 9.5 Snapshot 사이의 음수 delta를 그대로 저장한다

음수는 성능 향상이 아니라 restart, reset, failover, eviction을 의미할 가능성이 높다. server identity와 uptime으로 epoch를 분리한다.

### 9.6 Summary table을 주기적으로 비워 구간을 만든다

`TRUNCATE`는 다른 조사자의 증거와 histogram을 지울 수 있다. 운영에서는 비파괴 snapshot과 delta를 기본으로 한다.

### 9.7 QUERY_SAMPLE_TEXT를 일반 지표처럼 외부 전송한다

Sample에는 literal, comment, 개인정보, 업무 식별자가 포함될 수 있다. aggregate와 sample의 접근 권한·masking·보존 정책을 분리한다.

## 10. Aurora MySQL에서의 운영 해석

Aurora MySQL에서도 Performance Schema digest 분석 원리는 유효하지만 관측 경계가 **클러스터가 아니라 DB 인스턴스**라는 점이 중요하다.

- writer와 각 reader는 별도 프로세스이므로 digest counter를 인스턴스별로 수집한다.
- reader endpoint 뒤의 routing 불균형은 cluster 합계만 보면 가려질 수 있다.
- failover 후 새 writer의 메모리 내 summary가 이전 writer의 누적값을 승계한다고 가정하지 않는다.
- DB parameter group에서 Performance Schema 관련 설정의 적용 범위와 재부팅 필요 여부를 확인한다.
- Performance Insights 또는 Database Insights의 Top SQL 식별자를 Performance Schema `DIGEST`와 같은 값으로 직접 조인하지 않는다.
- Aurora의 분산 스토리지 wait와 Community MySQL의 로컬 file I/O 해석을 동일시하지 않는다.

Aurora 수집기는 `(cluster identifier, instance identifier, role, engine version, server uptime, digest)`를 key 문맥으로 보존하는 것이 좋다. failover는 새 epoch를 만들고, 이전 writer와 새 writer의 delta를 직접 뺄셈하지 않는다. cluster 합계와 인스턴스별 목록을 함께 유지해야 hot reader와 routing 문제를 발견할 수 있다.

## 11. 운영 체크리스트

### 계측과 용량

- [ ] `performance_schema`와 `statements_digest` consumer가 활성화되어 있는가?
- [ ] statement instrument의 `ENABLED`, `TIMED` 상태를 확인했는가?
- [ ] `performance_schema_digests_size`와 실제 occupied row 수를 감시하는가?
- [ ] `DIGEST=NULL`의 statement 비율을 별도 지표로 수집하는가?
- [ ] SQL text와 sample 길이 관련 시작 옵션의 한계를 문서화했는가?

### Snapshot과 delta

- [ ] 누적값 자체가 아니라 동일 길이 구간의 delta를 비교하는가?
- [ ] `server_uuid`, instance role, `Uptime`, version을 함께 저장하는가?
- [ ] restart, failover, reset, upgrade를 새 epoch로 처리하는가?
- [ ] 새 digest, 사라진 digest, 음수 delta 처리 정책이 있는가?
- [ ] 수집 실패와 일부 인스턴스 누락을 정상 0으로 저장하지 않는가?

### 분석과 개선

- [ ] `COUNT_STAR`, latency, rows examined, rows sent를 함께 해석하는가?
- [ ] 평균뿐 아니라 최대와 tail 신호를 확인하는가?
- [ ] full scan 신호를 즉시 index 생성 명령으로 바꾸지 않는가?
- [ ] 대표 parameter와 실제 데이터 분포에서 `EXPLAIN ANALYZE`를 확인하는가?
- [ ] 변경 후 같은 digest의 호출량과 호출당 작업량을 다시 측정하는가?
- [ ] workload 변화와 순수한 튜닝 효과를 분리하는가?

### 보안과 Aurora

- [ ] `QUERY_SAMPLE_TEXT`를 민감 데이터로 분류했는가?
- [ ] aggregate와 원문 sample의 접근 권한·보존 기간을 분리했는가?
- [ ] Aurora writer와 각 reader를 별도 관측하는가?
- [ ] failover 전후 counter를 직접 차감하지 않는가?
- [ ] CloudWatch와 Performance Insights의 시간대·인스턴스 문맥을 digest snapshot과 맞추는가?

## 12. 결론

Statement digest 분석의 핵심은 느린 SQL 한 줄을 찾는 것이 아니라, 구조가 같은 실행을 안정적인 query pattern으로 묶고 **호출량, latency, rows examined를 같은 시간 구간에서 함께 비교하는 것**이다. `SUM_TIMER_WAIT`는 누적 영향도를, `AVG_TIMER_WAIT`와 `MAX_TIMER_WAIT`는 호출당 비용과 tail 위험을, `SUM_ROWS_EXAMINED`와 `SUM_ROWS_SENT`는 데이터 접근 효율을 보여 준다.

그러나 summary는 누적 counter이며 개별 사건의 인과관계를 모두 보존하지 않는다. 운영에서는 두 시점 snapshot의 delta를 기본으로 하고, restart·failover·reset을 epoch 경계로 처리해야 한다. 상위 digest를 찾은 뒤에는 대표 parameter, 실행 계획, lock과 I/O, 애플리케이션 trace로 내려가 원인을 검증한다.

다음 기술노트에서는 statement event와 wait event, thread 정보를 연결하여 높은 digest latency가 CPU 작업, row lock, metadata lock, file I/O 가운데 어디에서 발생했는지 단계적으로 좁히는 방법을 다룬다.
