운영 서버에서 DB index 적용 작업 진행
- 01~07 작업 문서를 운영 측정값 기준으로 정리 (문서의 DB 비밀번호 삭제) - app_highest_record 중복 8,795행 점수 기준 정리, 인덱스 5개 + UNIQUE 키 2개 추가 - 02·04 공통 측정 스크립트와 측정 결과, comparison.txt 추가 - 서버 처리 시간 18~292배 단축, 결과 행 수 동일 - 운영 데이터 백업과 권한(비밀번호 해시) 파일은 커밋에서 제외 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
# 운영 서버 DB 성능 개선 (260915-improve-production)
|
||||||
|
|
||||||
|
> 스테이징에서 성공한 인덱스 추가를 운영 서버에 적용합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **목표** | 복합 인덱스 추가로 랭킹·기록 조회 쿼리의 검사 행 수와 실행 시간 감소 |
|
||||||
|
| **대상 DB** | chocomae.jinaju.com (AWS EC2 Docker, MariaDB 10.11.13) |
|
||||||
|
| **작업 방식** | 온라인 DDL (ALGORITHM=INPLACE, LOCK=NONE) |
|
||||||
|
| **예상 시간** | 약 1시간 (백업·측정·검증 포함, 인덱스 생성은 여유 있게 30분) |
|
||||||
|
| **위험도** | 인덱스 추가: 낮음 (스테이징 검증 완료) / 중복 정리: 중간 (운영 데이터 삭제, 백업 후 진행) |
|
||||||
|
| **영향 범위** | 인덱스: 쿼리 응답 시간만 개선 (서비스 무중단) / 중복 정리: `app_highest_record`의 중복 행 삭제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📏 운영 사전 측정 결과 (01단계, 2026-09-15)
|
||||||
|
|
||||||
|
| 항목 | 값 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| best_record | 약 115만 행, data 72.5MB / index 72.1MB | 월별 합계 1,210,482행 |
|
||||||
|
| typing_exam_record | 약 13만 행, data 10.3MB / index 10.9MB | |
|
||||||
|
| 월 적재량 | 최근 3개월(6~8월) 평균 57,968행/월 | 전년 같은 기간(27,049행)의 약 2.1배 |
|
||||||
|
| 일간 랭킹 EXPLAIN | `index_merge` (MaestroID ∩ AppID), rows **10,100** | 전체 스캔(ALL)이 아님 |
|
||||||
|
| app_highest_record 중복 | **4,823조합, 삭제 대상 8,795행** (약 3.8%) | 03-0에서 점수 기준으로 정리 |
|
||||||
|
| typing_exam_highest_record 중복 | 0건 | |
|
||||||
|
| jisangs 계정 권한 | `SELECT, ALTER` + 임시 부여(SUPER, 최고기록 테이블 2개 INSERT·DELETE, `mysql.slow_log` SELECT) | 05단계에서 회수 |
|
||||||
|
| Slow query log | ON, long_query_time=3, log_output=FILE | 05단계 복원 기준값 |
|
||||||
|
|
||||||
|
> 운영 DB에는 `MaestroID`, `AppID`, `PlayerID` 단일 인덱스가 이미 있어 스테이징(type=ALL)과 기준값이 다릅니다. 개선율은 스테이징 수치가 아니라 **운영에서 측정한 02(베이스라인)와 04(검증) 결과**로 계산합니다.
|
||||||
|
|
||||||
|
상세: [result/premeasure_summary.txt](result/premeasure_summary.txt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 기대 효과
|
||||||
|
|
||||||
|
| 항목 | 스테이징 결과 | 운영 예상 |
|
||||||
|
|---|---|---|
|
||||||
|
| **EXPLAIN cost** | 8.63 → 0.004 (**2,158배 ⬇️**) | MariaDB 10.11 EXPLAIN JSON에는 cost가 없어 rows·실행 시간으로 비교 |
|
||||||
|
| **EXPLAIN rows** | 5,804 → 1 (**5,800배 ⬇️**) | 10,100 → 해당 시간·날짜의 실제 기록 수 수준 (범위 조건 쿼리) |
|
||||||
|
| **접근 방식** | ALL → range | index_merge → range |
|
||||||
|
| **실행 시간** | ~2초 (충분) | 02·04에서 측정 |
|
||||||
|
| **느린 쿼리 제거** | 0.5초 이상 → 없음 | 04-5에서 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 스테이징 결과 요약
|
||||||
|
|
||||||
|
### ✅ 완료한 작업
|
||||||
|
1. 베이스라인 측정: 전체 스캔 (type=ALL)
|
||||||
|
2. 중복 데이터 정리: app_highest_record, typing_exam_highest_record
|
||||||
|
3. 인덱스 5개 + UNIQUE 키 2개 추가 (총 7개)
|
||||||
|
4. **쿼리 조건 수정**: 7개 파일, 8+ 함수 (DATE()/HOUR() → 범위 조건)
|
||||||
|
5. 개선 효과 검증: 2,000배 이상 향상 확인 (인덱스 + 쿼리 수정)
|
||||||
|
6. Slow query log: 개선 쿼리 0.5초 이하
|
||||||
|
|
||||||
|
### 📈 성능 개선 상세 (Stage 검증)
|
||||||
|
- **인덱스만 적용:** cost 8.63 → 1.0 (8배), rows 5,804 → 200 (29배)
|
||||||
|
- **인덱스 + 쿼리 수정:** cost 8.63 → 0.004 (2,158배), rows 5,804 → 1 (5,800배)
|
||||||
|
|
||||||
|
### 📁 저장된 파일
|
||||||
|
```
|
||||||
|
260915-improve-stage/result/
|
||||||
|
├── baseline_query1_explain.json
|
||||||
|
├── baseline_query2_explain.json
|
||||||
|
├── baseline_results.txt
|
||||||
|
├── after_query1_explain.json
|
||||||
|
├── after_query2_improved_explain.json
|
||||||
|
├── verify_results.txt
|
||||||
|
└── add_indexes.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 운영 DB 접속 정보
|
||||||
|
|
||||||
|
```
|
||||||
|
# 운영 DB (AWS EC2 Docker)
|
||||||
|
Host: chocomae.jinaju.com
|
||||||
|
Port: 3306
|
||||||
|
Database: chocomae
|
||||||
|
User: jisangs (임시 계정 — 2026-09-15 작업 후 삭제)
|
||||||
|
Password: 문서에 적지 않음 (별도로 전달받은 값 사용)
|
||||||
|
출발지 IP: 182.217.174.221
|
||||||
|
|
||||||
|
# 부여된 권한 (2026-09-15 SHOW GRANTS로 확인)
|
||||||
|
GRANT SELECT, ALTER ON chocomae.* TO jisangs@'182.217.174.221'
|
||||||
|
|
||||||
|
# 작업 기간 임시 권한 (2026-09-15 부여 → 같은 날 회수 완료, result/grants_after_revoke.txt)
|
||||||
|
GRANT SUPER ON *.* TO jisangs@'182.217.174.221'
|
||||||
|
GRANT INSERT, DELETE ON chocomae.app_highest_record TO jisangs@'182.217.174.221'
|
||||||
|
GRANT INSERT, DELETE ON chocomae.typing_exam_highest_record TO jisangs@'182.217.174.221'
|
||||||
|
GRANT SELECT ON mysql.slow_log TO jisangs@'182.217.174.221'
|
||||||
|
```
|
||||||
|
|
||||||
|
> ✅ 임시 계정 `jisangs`는 작업 후 삭제했습니다 (2026-09-15).
|
||||||
|
>
|
||||||
|
> ⚠️ 같은 비밀번호가 예전 커밋 기록과 메일(Gmail SMTP)·Python 배치(DB root) 소스 코드에도 평문으로 남아 있습니다. 계정 삭제와 별개로 그 비밀번호들은 바꿔야 합니다.
|
||||||
|
|
||||||
|
### 단계별 필요 권한
|
||||||
|
|
||||||
|
| 단계 | 작업 | 필요 권한 | jisangs 계정으로 가능? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 01, 02, 04 | 조회, EXPLAIN | SELECT | ✅ |
|
||||||
|
| 03-0 | 삭제 대상 행 백업 (`mariadb-dump`) | SELECT | ✅ |
|
||||||
|
| 03-0 | 중복 행 삭제 | `app_highest_record` DELETE | ✅ 임시 부여 |
|
||||||
|
| 03-2 | 인덱스·UNIQUE 키 추가 | ALTER | ✅ |
|
||||||
|
| 02-1, 05 | Slow query log 켜기·복원 (`SET GLOBAL`) | SUPER | ✅ 임시 부여 |
|
||||||
|
| 04-5, 07 | `mysql.slow_log` 조회 | `mysql.slow_log` SELECT | ✅ 임시 부여 |
|
||||||
|
| 06 | 삭제 행 복원 | 최고기록 테이블 INSERT | ✅ 임시 부여 |
|
||||||
|
| 06 | `ANALYZE TABLE best_record`, 복제 상태 확인 | `best_record` INSERT, REPLICA MONITOR 등 | ❌ 관리자 계정 |
|
||||||
|
|
||||||
|
2026-09-15에 관리자 계정으로 아래 권한을 임시 부여했습니다 (확인: `result/premeasure_grants_after.txt`). ❌ 작업은 문제가 생겼을 때만 필요하며 관리자 계정으로 실행합니다. 임시 권한은 05단계 사후 작업에서 회수합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 관리자 계정으로 실행함 (2026-09-15)
|
||||||
|
GRANT DELETE, INSERT ON chocomae.app_highest_record TO 'jisangs'@'182.217.174.221';
|
||||||
|
GRANT DELETE, INSERT ON chocomae.typing_exam_highest_record TO 'jisangs'@'182.217.174.221';
|
||||||
|
GRANT SELECT ON mysql.slow_log TO 'jisangs'@'182.217.174.221';
|
||||||
|
GRANT SUPER ON *.* TO 'jisangs'@'182.217.174.221';
|
||||||
|
```
|
||||||
|
|
||||||
|
> `SUPER`는 서버 전체 권한(다른 연결의 쿼리 중단, 모든 전역 설정 변경, read_only 우회)이므로 작업이 끝나면 바로 회수하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
1. **운영 DB 백업 필수**
|
||||||
|
- EC2 EBS 스냅샷 생성 (MariaDB 데이터 볼륨 포함)
|
||||||
|
- 또는 서버에서 `mariadb-dump`로 전체 DB 백업
|
||||||
|
|
||||||
|
2. **온라인 DDL 사용**
|
||||||
|
- ALGORITHM=INPLACE (테이블 잠금 최소화)
|
||||||
|
- LOCK=NONE (동시 읽기/쓰기 가능)
|
||||||
|
|
||||||
|
3. **중복 정리는 데이터 삭제**
|
||||||
|
- 03-0에서 삭제 대상 행을 먼저 파일로 백업한 뒤 삭제
|
||||||
|
- 인덱스와 달리 DROP INDEX로 되돌릴 수 없음 → 06-rollback.md의 "중복 정리 되돌리기" 참고
|
||||||
|
|
||||||
|
4. **모니터링 필수**
|
||||||
|
- 인덱스 추가 중 CPU/메모리 모니터링
|
||||||
|
- SHOW PROCESSLIST로 진행 상황 확인
|
||||||
|
|
||||||
|
5. **Slow query log 부하 주의**
|
||||||
|
- `log_output`에 `TABLE`이 있으면 `mysql.slow_log`에 계속 쌓이고 자동으로 비워지지 않음
|
||||||
|
- `log_queries_not_using_indexes`는 켜지 않음 (인덱스를 안 쓰는 모든 쿼리가 기록되어 로그가 급증)
|
||||||
|
- 작업 후 01단계 1-4에서 저장한 원래 값으로 복원
|
||||||
|
|
||||||
|
6. **롤백 계획**
|
||||||
|
- DROP INDEX 명령어 준비 (06-rollback.md)
|
||||||
|
- 문제 발생 시 즉시 실행 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 작업 일정
|
||||||
|
|
||||||
|
01단계 사전 측정은 2026-09-15에 완료했습니다.
|
||||||
|
|
||||||
|
### 권장: 새벽 2시~3시 (트래픽 최소 시간)
|
||||||
|
|
||||||
|
| 시간 | 작업 | 예상 소요 |
|
||||||
|
|---|---|---|
|
||||||
|
| 02:00 | DB 백업 (EBS 스냅샷 또는 전체 덤프) | 5분 |
|
||||||
|
| 02:05 | 성능 기준선 측정 (02-baseline.md) | 10분 |
|
||||||
|
| 02:15 | 중복 정리 + 인덱스 추가 (03-add-indexes.md) | 30분 |
|
||||||
|
| 02:45 | 인덱스 생성 확인 | 5분 |
|
||||||
|
| 02:50 | 성능 개선 검증 (04-verify.md) | 10분 |
|
||||||
|
| 03:00 | 최종 체크리스트, slow log 복원, 임시 권한 회수 (05-checklist.md) | 10분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
260915-improve-production/
|
||||||
|
├── 00-overview.md ← 이 파일
|
||||||
|
├── 01-premeasure.md ← 운영 DB 현황 측정
|
||||||
|
├── 02-baseline.md ← 성능 기준선 측정
|
||||||
|
├── 03-add-indexes.md ← 중복 정리 + 인덱스 추가
|
||||||
|
├── 04-verify.md ← 개선 효과 검증
|
||||||
|
├── 05-checklist.md ← 최종 체크리스트
|
||||||
|
├── 06-rollback.md ← 롤백 계획
|
||||||
|
├── 07-post-analysis.md ← 사후 분석
|
||||||
|
└── result/ ← 결과 파일 저장
|
||||||
|
├── premeasure_*.txt, premeasure_*.json ← 01 사전 측정
|
||||||
|
├── premeasure_summary.txt ← 01 요약
|
||||||
|
├── params.sql, queries/*.sql ← 02·04 공통 측정 쿼리
|
||||||
|
├── run_measure.sh, summarize_explain.py ← 02·04 공통 측정 도구
|
||||||
|
├── baseline_explain_*.json, baseline_perf.txt ← 02
|
||||||
|
├── backup_app_highest_dup_rows.sql ← 03-0 삭제 행 백업
|
||||||
|
├── add_indexes.sql, add_indexes_result.txt ← 03
|
||||||
|
├── after_explain_*.json, after_perf.txt ← 04
|
||||||
|
└── comparison.txt ← 04 비교
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 다음 단계
|
||||||
|
|
||||||
|
**01-premeasure.md** — 모든 측정 완료 (2026-09-15).
|
||||||
|
|
||||||
|
필요한 권한은 jisangs 계정에 임시 부여했습니다 (2026-09-15). **02-baseline.md**로 이동합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 문제 발생 시
|
||||||
|
|
||||||
|
| 상황 | 대응 |
|
||||||
|
|---|---|
|
||||||
|
| **인덱스 추가 실패 (`Duplicate entry`)** | 3-0 다시 실행 후 `add_indexes.sql` 재실행 (이미 만든 인덱스는 건너뜀) |
|
||||||
|
| **인덱스 추가 실패 (기타)** | 에러 확인 후 필요하면 DROP INDEX (06-rollback.md) |
|
||||||
|
| **서비스 영향** | KILL QUERY + 인덱스 삭제 (06-rollback.md) |
|
||||||
|
| **최고기록 값 이상** | 삭제 행 백업으로 되돌리기 (06-rollback.md) |
|
||||||
|
| **성능 악화** | 인덱스 삭제 → 그래도 안 되면 백업에서 복원 |
|
||||||
|
| **기타 문제** | 07-post-analysis.md 참고 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**01단계 완료, 작업 권한 준비 완료. 02단계부터 진행합니다.**
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# 01단계: 운영 DB 현황 측정 (사전준비)
|
||||||
|
|
||||||
|
> 운영 서버에 인덱스를 추가하기 전 현재 상태를 정확히 파악합니다.
|
||||||
|
>
|
||||||
|
> **진행 상태 (2026-09-15):** 모든 항목 측정 완료. 결과 요약은 [result/premeasure_summary.txt](result/premeasure_summary.txt)에 있습니다.
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doc/plan/db/260915-improve-production/result
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-0. 계정 권한 확인
|
||||||
|
|
||||||
|
03단계 중복 삭제(DELETE), 02단계 slow query log 켜기(`SET GLOBAL`), 04단계 `mysql.slow_log` 조회에는 `SELECT, ALTER` 외의 권한이 필요합니다. 실제 권한을 먼저 확인하세요.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "SHOW GRANTS;" > premeasure_grants.txt
|
||||||
|
|
||||||
|
cat premeasure_grants.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
- [00-overview.md의 단계별 필요 권한](00-overview.md#단계별-필요-권한) 표와 비교합니다.
|
||||||
|
- 권한이 없는 작업은 관리자 계정으로 실행할지, 작업 시간 동안만 권한을 받을지 정합니다.
|
||||||
|
|
||||||
|
**결과 (2026-09-15):** `GRANT SELECT, ALTER ON chocomae.*`만 있습니다. 중복 삭제(03-0-3), `SET GLOBAL`(02-1, 05), `mysql.slow_log` 조회(04-5, 07)는 관리자 계정으로 실행해야 합니다.
|
||||||
|
|
||||||
|
**임시 권한 부여 후 (2026-09-15, `premeasure_grants_after.txt`):** SUPER, 최고기록 테이블 2개 INSERT·DELETE, `mysql.slow_log` SELECT 추가. 05단계에서 회수합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-1. 테이블·인덱스 크기
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT TABLE_NAME,
|
||||||
|
TABLE_ROWS AS approx_rows,
|
||||||
|
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_mb,
|
||||||
|
ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_mb
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = 'chocomae'
|
||||||
|
ORDER BY DATA_LENGTH DESC;" > premeasure_table_sizes.txt
|
||||||
|
|
||||||
|
cat premeasure_table_sizes.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**저장할 수치:**
|
||||||
|
- `best_record` data_mb, index_mb
|
||||||
|
- `typing_exam_record` data_mb, index_mb
|
||||||
|
|
||||||
|
> `TABLE_ROWS`는 InnoDB 추정치라 실제 행 수와 몇 % 차이가 날 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-2. 월별 적재량 (증가 속도)
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT DATE_FORMAT(RecordDateTime, '%Y-%m') AS ym, COUNT(*) AS cnt
|
||||||
|
FROM best_record
|
||||||
|
GROUP BY ym
|
||||||
|
ORDER BY ym;" > premeasure_monthly_load.txt
|
||||||
|
|
||||||
|
cat premeasure_monthly_load.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
- 최근 3개월 증가 추이
|
||||||
|
- 월 평균 증가량
|
||||||
|
- 마지막 달은 측정일까지의 값이므로 평균에서 제외
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-3. 대표 쿼리 기준값 (EXPLAIN)
|
||||||
|
|
||||||
|
### 1-3-1. 기록이 많은 선생님·앱 찾기
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT MaestroID, AppID, COUNT(*) AS cnt
|
||||||
|
FROM best_record
|
||||||
|
WHERE RecordDateTime >= NOW() - INTERVAL 30 DAY
|
||||||
|
GROUP BY MaestroID, AppID
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 5;" > premeasure_top_maestro_app.txt
|
||||||
|
|
||||||
|
cat premeasure_top_maestro_app.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과 (2026-09-15):**
|
||||||
|
```
|
||||||
|
MaestroID AppID cnt
|
||||||
|
181 21 1089
|
||||||
|
181 1 591
|
||||||
|
389 103 562
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 다음 단계에서 MaestroID=181, AppID=21을 사용합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1-3-2. 일간 랭킹 쿼리 EXPLAIN
|
||||||
|
|
||||||
|
위에서 나온 MaestroID, AppID를 사용하세요:
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -N -r -e "
|
||||||
|
EXPLAIN FORMAT=JSON
|
||||||
|
SELECT BR.PlayerID, U.Name, MAX(BR.BestRecord) AS HighScore
|
||||||
|
FROM best_record BR, player U
|
||||||
|
WHERE BR.MaestroID = 181
|
||||||
|
AND BR.PlayerID = U.PlayerID
|
||||||
|
AND YEAR(BR.RecordDateTime) = YEAR(NOW())
|
||||||
|
AND MONTH(BR.RecordDateTime) = MONTH(NOW())
|
||||||
|
AND DAYOFMONTH(BR.RecordDateTime) = DAYOFMONTH(NOW())
|
||||||
|
AND BR.AppID = 21
|
||||||
|
GROUP BY BR.PlayerID
|
||||||
|
ORDER BY MAX(BR.BestRecord) DESC;" > premeasure_explain_daily_ranking.json
|
||||||
|
|
||||||
|
cat premeasure_explain_daily_ranking.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> `-N`(열 이름 생략)과 `-r`(이스케이프 없이 출력)이 없으면 파일 첫 줄에 `EXPLAIN` 헤더가 붙고 줄바꿈이 `\n` 글자로 저장되어 올바른 JSON이 되지 않습니다.
|
||||||
|
|
||||||
|
**확인할 점:** MariaDB JSON에서는 `type`이 아니라 `access_type` 필드를 봅니다.
|
||||||
|
|
||||||
|
| 필드 | 의미 | 나쁜 값 | 운영 측정값 (2026-09-15) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `access_type` | 접근 방식 | `ALL` (전체 스캔) | `index_merge` |
|
||||||
|
| `key` 또는 `index_merge` | 사용한 인덱스 | 없음 | `MaestroID` ∩ `AppID` |
|
||||||
|
| `rows` | 검사 예상 행 수 | 전체 행 수에 가까움 | 10,100 |
|
||||||
|
|
||||||
|
> 운영에는 `MaestroID`, `AppID` 단일 인덱스가 있어 전체 스캔은 아닙니다. 하지만 날짜 조건은 인덱스로 거르지 못해 10,100행을 하나씩 확인합니다. 복합 인덱스 `(MaestroID, AppID, RecordDateTime)`로 이 수를 해당 날짜의 실제 기록 수 수준으로 줄이는 것이 목표입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-4. Slow Query Log 현재 설정 저장
|
||||||
|
|
||||||
|
> 설정 변경(`SET GLOBAL`)은 SUPER 권한이 필요하고 운영에 영향을 주므로, 여기서는 **현재 값만 저장**합니다. 켜는 작업은 02단계 2-1에서, 원래 값 복원은 05단계에서 이 파일을 보고 합니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SHOW GLOBAL VARIABLES WHERE Variable_name IN
|
||||||
|
('slow_query_log', 'long_query_time', 'log_output',
|
||||||
|
'log_queries_not_using_indexes', 'slow_query_log_file');" > premeasure_slow_log_settings.txt
|
||||||
|
|
||||||
|
cat premeasure_slow_log_settings.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이전 버전 문서의 1-4(`SET GLOBAL ...`)를 이미 성공적으로 실행했다면 저장된 값은 원래 값이 아닙니다. MariaDB 기본값은 `slow_query_log=OFF`, `long_query_time=10`, `log_output=FILE`입니다. 서버 설정 파일의 값을 확인해 05단계 복원에 사용하세요.
|
||||||
|
|
||||||
|
**결과 (2026-09-15):** `slow_query_log=ON`, `long_query_time=3`, `log_output=FILE`, `log_queries_not_using_indexes=OFF`, 로그 파일 `/var/log/mysql/mariadb-slow.log`
|
||||||
|
|
||||||
|
> 이전 버전 1-4의 `SET GLOBAL`은 적용되지 않았습니다 (SUPER 권한 없음. 적용됐다면 `long_query_time=1`, `log_output=TABLE`). 따라서 위 값이 05단계에서 복원할 원래 값입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-5. 최고기록 테이블 중복 점검
|
||||||
|
|
||||||
|
### 1-5-1. 중복 상위 10개 (완료)
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
-- app_highest_record 중복 확인
|
||||||
|
SELECT MaestroID, PlayerID, AppID, COUNT(*) AS cnt
|
||||||
|
FROM app_highest_record
|
||||||
|
GROUP BY MaestroID, PlayerID, AppID
|
||||||
|
HAVING cnt > 1
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 10;" > premeasure_app_highest_duplicates.txt
|
||||||
|
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
-- typing_exam_highest_record 중복 확인
|
||||||
|
SELECT MaestroID, PlayerID, WritingID, COUNT(*) AS cnt
|
||||||
|
FROM typing_exam_highest_record
|
||||||
|
GROUP BY MaestroID, PlayerID, WritingID
|
||||||
|
HAVING cnt > 1
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 10;" > premeasure_exam_highest_duplicates.txt
|
||||||
|
|
||||||
|
echo "=== app_highest_record 중복 ===" && cat premeasure_app_highest_duplicates.txt
|
||||||
|
echo "=== typing_exam_highest_record 중복 ===" && cat premeasure_exam_highest_duplicates.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
> 결과가 없으면 `-e` 모드는 열 이름도 출력하지 않아 **빈 파일**이 됩니다. 에러는 파일이 아니라 터미널에 표시되므로, 에러 없이 빈 파일이면 중복 0건입니다.
|
||||||
|
|
||||||
|
**결과 (2026-09-15):** app_highest_record는 상위 10개 조합이 각 17~33행, typing_exam_highest_record는 0건.
|
||||||
|
|
||||||
|
### 1-5-2. 전체 중복 개수
|
||||||
|
|
||||||
|
`LIMIT 10`이라 위 결과만으로는 삭제할 행 수를 알 수 없습니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT COUNT(*) AS dup_groups, SUM(cnt - 1) AS rows_to_delete
|
||||||
|
FROM (SELECT COUNT(*) AS cnt FROM app_highest_record
|
||||||
|
GROUP BY MaestroID, PlayerID, AppID HAVING cnt > 1) t;" > premeasure_app_highest_dup_count.txt
|
||||||
|
|
||||||
|
cat premeasure_app_highest_dup_count.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
- `dup_groups`: 중복이 있는 조합 수
|
||||||
|
- `rows_to_delete`: 조합마다 1행만 남길 때 지워지는 행 수 → 03단계 3-0에서 실제 삭제 행 수와 비교
|
||||||
|
|
||||||
|
**결과 (2026-09-15):** `dup_groups` 4,823 / `rows_to_delete` 8,795 (app_highest_record 약 23만 행의 약 3.8%)
|
||||||
|
|
||||||
|
**중복이 있으면:**
|
||||||
|
- 03-add-indexes.md 3-0에서 UNIQUE 키 추가 전에 **점수 기준으로** 정리합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-6. 측정 결과 기록
|
||||||
|
|
||||||
|
모든 측정값을 [result/premeasure_summary.txt](result/premeasure_summary.txt)에 정리했습니다.
|
||||||
|
|
||||||
|
> 이전 버전의 `cat > premeasure_summary.txt << 'EOF'` 명령은 채워 둔 요약을 빈 양식으로 덮어쓰므로 삭제했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
✅ 사전 측정 완료 → **02-baseline.md로 이동**
|
||||||
|
|
||||||
|
(사전 측정 수치는 모두 `result/`의 txt/json 파일에 저장되어 있습니다)
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# 02단계: 개선 전 성능 측정 (베이스라인)
|
||||||
|
|
||||||
|
> 인덱스 추가 전 EXPLAIN과 실행 시간을 측정해 개선 효과의 기준점을 만듭니다.
|
||||||
|
> 04단계에서 **같은 스크립트, 같은 쿼리, 같은 시각**으로 다시 측정해 비교합니다.
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doc/plan/db/260915-improve-production/result
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 측정 방식
|
||||||
|
|
||||||
|
| 원칙 | 이유 |
|
||||||
|
|---|---|
|
||||||
|
| 02와 04가 같은 쿼리 파일(`queries/*.sql`)을 실행 | 쿼리가 다르면 실행 시간을 비교할 수 없음 |
|
||||||
|
| `NOW()` 대신 **지난달의 시각을 고정** (`params.sql`) | 새벽 작업 시간에는 현재 시각의 기록이 거의 없음. 지난달 데이터는 02와 04 사이에 바뀌지 않아 결과 행 수까지 비교 가능 |
|
||||||
|
| 같은 조건을 **함수형**과 **범위형** 두 가지로 측정 | 인덱스만의 효과(함수형)와 인덱스 + 쿼리 수정 효과(범위형)를 나눠 확인 |
|
||||||
|
| 쿼리 묶음을 2회 실행해 **2회차** 값 사용 | 1회차에는 디스크에서 페이지를 읽는 시간이 섞임 |
|
||||||
|
| `SQL_NO_CACHE` | 쿼리 캐시 결과로 시간이 왜곡되지 않게 함 |
|
||||||
|
|
||||||
|
| 쿼리 파일 | 조건 | 형태 |
|
||||||
|
|---|---|---|
|
||||||
|
| `q1_hour_func.sql` | 특정 1시간 기록 | `DATE()`, `HOUR()` 함수 (수정 전 코드 형태) |
|
||||||
|
| `q2_hour_range.sql` | 특정 1시간 기록 | 범위 조건 (Stage에서 수정한 코드 형태) |
|
||||||
|
| `q3_day_func.sql` | 일간 랭킹 | `YEAR()`, `MONTH()`, `DAYOFMONTH()` 함수 |
|
||||||
|
| `q4_day_range.sql` | 일간 랭킹 | 범위 조건 |
|
||||||
|
| `q5_month_func.sql` | 월간 랭킹 | `YEAR()`, `MONTH()` 함수 |
|
||||||
|
| `q6_month_range.sql` | 월간 랭킹 | 범위 조건 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-0. 측정 기준 시각 정하기
|
||||||
|
|
||||||
|
MaestroID 181 · AppID 21의 **지난달** 기록 중 가장 많은 시간대를 찾습니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT DATE(RecordDateTime) AS day, HOUR(RecordDateTime) AS hour, COUNT(*) AS cnt
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = 181 AND AppID = 21
|
||||||
|
AND RecordDateTime >= CAST(DATE_FORMAT(NOW() - INTERVAL 1 MONTH, '%Y-%m-01') AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-01') AS DATETIME)
|
||||||
|
GROUP BY day, hour
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 5;" > baseline_pick_time.txt
|
||||||
|
|
||||||
|
cat baseline_pick_time.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
첫 번째 행의 `day`와 `hour`로 `params.sql`을 만듭니다. **아래 값은 예시이니 반드시 바꾸세요.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > params.sql << 'EOF'
|
||||||
|
SET @maestro = 181, @app = 21, @day = '2026-08-20', @hour = 14;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat params.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
> 04단계가 끝날 때까지 `params.sql`을 바꾸지 마세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-1. Slow Query Log 켜기 (SUPER 권한)
|
||||||
|
|
||||||
|
> `SET GLOBAL`은 SUPER 권한이 필요합니다. 2026-09-15에 `jisangs` 계정에 임시 부여했으므로 jisangs로 실행하면 됩니다 (`mariadb -h chocomae.jinaju.com -u jisangs -p chocomae`로 접속 후 아래 SQL 실행). 운영의 원래 값은 `slow_query_log=ON`, `long_query_time=3`, `log_output=FILE`이며(01 1-4), 05단계에서 이 값으로 복원합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SET GLOBAL log_output = 'FILE,TABLE';
|
||||||
|
SET GLOBAL long_query_time = 0.5;
|
||||||
|
SET GLOBAL slow_query_log = 'ON';
|
||||||
|
|
||||||
|
SHOW GLOBAL VARIABLES WHERE Variable_name IN
|
||||||
|
('slow_query_log', 'long_query_time', 'log_output', 'log_queries_not_using_indexes');
|
||||||
|
```
|
||||||
|
|
||||||
|
- `log_output = 'FILE,TABLE'`: 기존 파일 로그(`/var/log/mysql/mariadb-slow.log`)는 유지하면서 04단계에서 `mysql.slow_log` 테이블로 조회할 수 있게 합니다.
|
||||||
|
- `long_query_time`은 **새 연결부터** 적용됩니다. PHP는 요청마다 새로 연결하므로 바로 반영됩니다.
|
||||||
|
- ⚠️ `log_queries_not_using_indexes`는 켜지 마세요. 인덱스를 쓰지 않는 모든 쿼리가 실행 시간과 상관없이 기록되어 `mysql.slow_log`가 빠르게 커집니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-2. 측정 파일 만들기
|
||||||
|
|
||||||
|
### 측정 쿼리 6개
|
||||||
|
|
||||||
|
> 쿼리 파일은 반드시 `SELECT`로 시작해야 합니다. 스크립트가 앞에 `EXPLAIN FORMAT=JSON `을 붙여 실행하므로 주석을 넣지 마세요.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p queries
|
||||||
|
|
||||||
|
cat > queries/q1_hour_func.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND DATE(RecordDateTime) = @day
|
||||||
|
AND HOUR(RecordDateTime) = @hour
|
||||||
|
ORDER BY RecordDateTime, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > queries/q2_hour_range.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(@day AS DATETIME) + INTERVAL @hour HOUR
|
||||||
|
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL (@hour + 1) HOUR
|
||||||
|
ORDER BY RecordDateTime, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > queries/q3_day_func.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND YEAR(RecordDateTime) = YEAR(@day)
|
||||||
|
AND MONTH(RecordDateTime) = MONTH(@day)
|
||||||
|
AND DAYOFMONTH(RecordDateTime) = DAYOFMONTH(@day)
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > queries/q4_day_range.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(@day AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL 1 DAY
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > queries/q5_month_func.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND YEAR(RecordDateTime) = YEAR(@day)
|
||||||
|
AND MONTH(RecordDateTime) = MONTH(@day)
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > queries/q6_month_range.sql << 'EOF'
|
||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME) + INTERVAL 1 MONTH
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ls queries/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 측정 스크립트
|
||||||
|
|
||||||
|
비밀번호를 한 번만 입력받아 EXPLAIN 6개와 실행 시간을 모두 측정합니다. 비밀번호는 파일이나 명령 인자로 남지 않습니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > run_measure.sh << 'EOF'
|
||||||
|
#!/bin/zsh
|
||||||
|
# 02·04단계 공통 측정 스크립트
|
||||||
|
# 사용법: zsh run_measure.sh baseline (02단계, 인덱스 추가 전)
|
||||||
|
# zsh run_measure.sh after (04단계, 인덱스 추가 후)
|
||||||
|
|
||||||
|
PREFIX=$1
|
||||||
|
if [[ $PREFIX != baseline && $PREFIX != after ]]; then
|
||||||
|
echo "사용법: zsh run_measure.sh baseline|after" >&2; exit 1
|
||||||
|
fi
|
||||||
|
cd "${0:A:h}" || exit 1
|
||||||
|
|
||||||
|
printf "Enter password: "; read -rs DBPW; echo
|
||||||
|
|
||||||
|
db() {
|
||||||
|
mariadb --defaults-extra-file=<(printf '[client]\npassword="%s"\n' "$DBPW") \
|
||||||
|
-h chocomae.jinaju.com -u jisangs chocomae "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) EXPLAIN: 쿼리마다 JSON 파일 1개
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
out="${PREFIX}_explain_${q:t:r}.json"
|
||||||
|
{ cat params.sql; printf 'EXPLAIN FORMAT=JSON '; cat "$q"; } | db -N -r > "$out" \
|
||||||
|
|| { echo "EXPLAIN 실패: $q" >&2; exit 1; }
|
||||||
|
echo "저장: $out"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2) 실행 시간: 같은 쿼리 묶음을 2회 실행 (2회차 값 사용)
|
||||||
|
for round in 1 2; do
|
||||||
|
{ cat params.sql
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
echo "SELECT '=== round ${round}: ${q:t:r} ===' AS msg;"
|
||||||
|
cat "$q"
|
||||||
|
done
|
||||||
|
} | db -vvv || { echo "실행 시간 측정 실패" >&2; exit 1; }
|
||||||
|
done > "${PREFIX}_perf.txt"
|
||||||
|
echo "저장: ${PREFIX}_perf.txt"
|
||||||
|
|
||||||
|
# 3) 요약: 쿼리 이름 / 결과 행 수 / 실행 시간 (2회차)
|
||||||
|
echo
|
||||||
|
grep -E '^\| === |rows? in set|Empty set' "${PREFIX}_perf.txt" | paste - - - | grep 'round 2' \
|
||||||
|
| sed -E 's/\| === round 2: (.*) === \|/\1/; s/\t1 row in set \([0-9.]+ sec\)//'
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### EXPLAIN 요약 스크립트
|
||||||
|
|
||||||
|
EXPLAIN JSON에서 `access_type`, 사용 인덱스, `rows`만 뽑아 한 줄씩 보여줍니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > summarize_explain.py << 'EOF'
|
||||||
|
# 사용법: python3 summarize_explain.py baseline [after]
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def find_table(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
if isinstance(node.get("table"), dict):
|
||||||
|
return node["table"]
|
||||||
|
children = node.values()
|
||||||
|
elif isinstance(node, list):
|
||||||
|
children = node
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
for child in children:
|
||||||
|
found = find_table(child)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def keys_of(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for k, v in node.items():
|
||||||
|
if k == "key":
|
||||||
|
yield v
|
||||||
|
else:
|
||||||
|
yield from keys_of(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for v in node:
|
||||||
|
yield from keys_of(v)
|
||||||
|
|
||||||
|
|
||||||
|
for prefix in sys.argv[1:]:
|
||||||
|
for path in sorted(glob.glob(f"{prefix}_explain_*.json")):
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
table = find_table(json.load(f))
|
||||||
|
key = table.get("key") or " ∩ ".join(keys_of(table.get("index_merge", {}))) or "-"
|
||||||
|
print(f"{path:40} access_type={str(table.get('access_type')):12} key={key:28} rows={table.get('rows')}")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-3. 베이스라인 측정 실행
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zsh run_measure.sh baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력 예 (숫자는 예시):**
|
||||||
|
```
|
||||||
|
Enter password:
|
||||||
|
저장: baseline_explain_q1_hour_func.json
|
||||||
|
...
|
||||||
|
저장: baseline_explain_q6_month_range.json
|
||||||
|
저장: baseline_perf.txt
|
||||||
|
|
||||||
|
q1_hour_func 38 rows in set (0.052 sec)
|
||||||
|
q2_hour_range 38 rows in set (0.049 sec)
|
||||||
|
q3_day_func 21 rows in set (0.061 sec)
|
||||||
|
q4_day_range 21 rows in set (0.058 sec)
|
||||||
|
q5_month_func 96 rows in set (0.410 sec)
|
||||||
|
q6_month_range 96 rows in set (0.395 sec)
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
- 함수형과 범위형 짝(q1↔q2, q3↔q4, q5↔q6)의 **결과 행 수가 같아야** 합니다. 다르면 범위 조건이 잘못된 것이니 03단계로 가지 말고 원인을 확인하세요.
|
||||||
|
- `Empty set`이면 그 시각에 기록이 없는 것이니 2-0에서 다른 시각을 고르세요.
|
||||||
|
- 비밀번호를 틀리면 `Access denied`와 함께 멈춥니다. 다시 실행하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-4. EXPLAIN 요약
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 summarize_explain.py baseline | tee baseline_explain_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**운영 예상:** 01단계 사전 측정처럼 `index_merge`(MaestroID ∩ AppID)나 단일 인덱스 접근이 나오고, `rows`는 수천~1만 행 수준입니다. 전체 스캔(`ALL`)이 아닐 수 있으니 나온 값을 그대로 기준값으로 기록하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-4-1. 서버 측 실행 시간 (ANALYZE)
|
||||||
|
|
||||||
|
2-3의 실행 시간은 Mac ↔ AWS 네트워크 왕복이 포함된 클라이언트 시간입니다. 서버가 쿼리를 처리한 시간(`r_total_time_ms`)을 따로 기록해 두면 네트워크 영향 없이 비교할 수 있습니다.
|
||||||
|
|
||||||
|
> 운영 측정 (2026-09-15): 클라이언트 시간 약 90ms 중 서버 처리 시간이 약 75ms였습니다. 6개 쿼리 모두 `index_merge`로 1만여 행을 읽기 때문입니다. `ANALYZE`는 쿼리를 실제로 실행하지만 조회(SELECT)만 하므로 데이터는 바뀌지 않습니다.
|
||||||
|
|
||||||
|
**요약 스크립트 만들기:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > summarize_analyze.py << 'EOF'
|
||||||
|
# 사용법: python3 summarize_analyze.py baseline [after]
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
for prefix in sys.argv[1:]:
|
||||||
|
text = open(f"{prefix}_analyze.txt", encoding="utf-8").read()
|
||||||
|
names = [line.split("=== ")[1].split(" ===")[0] for line in text.splitlines() if line.startswith("=== ")]
|
||||||
|
docs, pos = [], 0
|
||||||
|
while True:
|
||||||
|
start = text.find("{", pos)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
doc, pos = decoder.raw_decode(text, start)
|
||||||
|
docs.append(doc)
|
||||||
|
for name, doc in zip(names, docs):
|
||||||
|
block = doc["query_block"]
|
||||||
|
print(f"{prefix:8} {name:16} server_time_ms={block.get('r_total_time_ms')}")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**측정 (비밀번호 한 번 입력):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{ cat params.sql
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
echo "SELECT '=== ${q:t:r} ===';"
|
||||||
|
printf 'ANALYZE FORMAT=JSON '
|
||||||
|
cat "$q"
|
||||||
|
done
|
||||||
|
} | mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -N -r > baseline_analyze.txt
|
||||||
|
|
||||||
|
python3 summarize_analyze.py baseline | tee baseline_analyze_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2-5. 결과 기록
|
||||||
|
|
||||||
|
**파일로 저장된 결과:**
|
||||||
|
```
|
||||||
|
params.sql, queries/*.sql ← 04단계에서 그대로 재사용 (수정 금지)
|
||||||
|
run_measure.sh, summarize_explain.py ← 04단계에서 그대로 재사용
|
||||||
|
baseline_pick_time.txt ← 측정 기준 시각 선정 근거
|
||||||
|
baseline_explain_q*.json (6개) ← EXPLAIN 결과
|
||||||
|
baseline_explain_summary.txt ← EXPLAIN 요약
|
||||||
|
baseline_perf.txt ← 실행 시간 (-vvv 전체 출력, 네트워크 포함)
|
||||||
|
summarize_analyze.py ← 04단계에서 그대로 재사용
|
||||||
|
baseline_analyze.txt ← ANALYZE 결과 (서버 측 실행 통계)
|
||||||
|
baseline_analyze_summary.txt ← 서버 측 실행 시간 요약
|
||||||
|
```
|
||||||
|
|
||||||
|
2-3 요약과 2-4 결과는 04단계 4-4의 `comparison.txt`에 옮겨 적습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
✅ 베이스라인 측정 완료 → **03-add-indexes.md로 이동**
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# 03단계: 중복 정리 + 인덱스 추가
|
||||||
|
|
||||||
|
> 온라인 DDL로 서비스 중단 없이 인덱스를 추가합니다. UNIQUE 키를 넣기 전에 최고기록 테이블의 중복을 정리합니다.
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-0. 중복 데이터 정리 (필수)
|
||||||
|
|
||||||
|
> UNIQUE 키 추가 전에 `app_highest_record`의 중복을 정리합니다. `typing_exam_highest_record`는 01단계에서 중복 0건이라 확인만 합니다.
|
||||||
|
|
||||||
|
### 왜 점수 기준으로 남기나
|
||||||
|
|
||||||
|
- 중복은 같은 학생의 결과 저장 요청이 동시에 들어올 때 생깁니다. `src/web/server/record/update_result_record.php`는 "조회 → 없으면 INSERT"와 "DELETE → INSERT"를 트랜잭션 없이 실행하므로 두 요청이 모두 INSERT할 수 있습니다.
|
||||||
|
- 그래서 **가장 최근 행이 최고 점수라는 보장이 없습니다.** 이전 버전 문서처럼 `MAX(ID)`를 남기면 최고기록이 낮아질 수 있습니다.
|
||||||
|
- 좋은 기록의 기준은 [util_app.php](../../../../src/web/server/lib/util_app.php)의 `is_highest_record_prefer_app()`을 따릅니다. **AppID 105는 낮은 점수**, 나머지 앱은 **높은 점수**가 좋은 기록입니다. 점수가 같으면 최근 행(ID가 큰 행)을 남깁니다.
|
||||||
|
- `typing_exam_highest_record`는 높은 점수가 좋은 기록입니다 (`update_typing_exam_record.php`).
|
||||||
|
|
||||||
|
### UNIQUE 키 추가 후 앱 동작
|
||||||
|
|
||||||
|
- 같은 조합의 INSERT가 또 들어오면 `ERROR 1062 Duplicate entry`로 거부되어 중복이 더 생기지 않습니다.
|
||||||
|
- PHP 7.3 mysqli는 기본 설정에서 예외를 던지지 않고 `execute()`가 false만 반환하므로 **화면이 멈추지 않습니다.** 먼저 저장된 행이 남습니다.
|
||||||
|
- 드물게 동시에 저장한 두 기록 중 덜 좋은 기록이 남을 수 있습니다 (지금은 같은 상황에서 중복 행이 생깁니다).
|
||||||
|
|
||||||
|
### 3-0-1. 중복 규모 확인
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT COUNT(*) AS dup_groups, SUM(cnt - 1) AS rows_to_delete
|
||||||
|
FROM (SELECT COUNT(*) AS cnt FROM app_highest_record
|
||||||
|
GROUP BY MaestroID, PlayerID, AppID HAVING cnt > 1) t;" > dedupe_before_count.txt
|
||||||
|
|
||||||
|
cat dedupe_before_count.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`rows_to_delete` 값을 메모합니다. 3-0-2 백업 행 수, 3-0-3 삭제 행 수와 같아야 합니다. (01단계 측정: 4,823조합 / 8,795행. 작업 당일에는 조금 늘어날 수 있습니다)
|
||||||
|
|
||||||
|
### 3-0-2. 삭제 대상 행 백업
|
||||||
|
|
||||||
|
삭제할 행만 INSERT 문으로 저장합니다. 되돌릴 때 06단계에서 이 파일을 실행합니다. (SELECT 권한만 필요)
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb-dump -h chocomae.jinaju.com -u jisangs -p \
|
||||||
|
--single-transaction --no-create-info --skip-triggers --no-tablespaces --skip-add-locks \
|
||||||
|
--where="AppHighestRecordID IN (SELECT AppHighestRecordID FROM (SELECT AppHighestRecordID, ROW_NUMBER() OVER (PARTITION BY MaestroID, PlayerID, AppID ORDER BY CASE WHEN AppID = 105 THEN HighestRecord ELSE -HighestRecord END, AppHighestRecordID DESC) AS rn FROM app_highest_record) ranked WHERE rn > 1)" \
|
||||||
|
chocomae app_highest_record > backup_app_highest_dup_rows.sql
|
||||||
|
|
||||||
|
echo "백업한 행 수: $(grep -c '^(' backup_app_highest_dup_rows.sql)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:** 백업한 행 수 = 3-0-1의 `rows_to_delete`
|
||||||
|
|
||||||
|
### 3-0-3. 중복 삭제 (DELETE 권한 — jisangs에 임시 부여됨)
|
||||||
|
|
||||||
|
> 3-2 인덱스 추가 **직전**에 실행하세요. 삭제 후 UNIQUE 키가 생기기 전까지 새 중복이 생길 수 있습니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -vvv << 'EOF' | tee dedupe_result.txt
|
||||||
|
DELETE AHR FROM app_highest_record AHR
|
||||||
|
JOIN (
|
||||||
|
SELECT AppHighestRecordID FROM (
|
||||||
|
SELECT AppHighestRecordID,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY MaestroID, PlayerID, AppID
|
||||||
|
ORDER BY CASE WHEN AppID = 105 THEN HighestRecord ELSE -HighestRecord END,
|
||||||
|
AppHighestRecordID DESC
|
||||||
|
) AS rn
|
||||||
|
FROM app_highest_record
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
) dup ON dup.AppHighestRecordID = AHR.AppHighestRecordID;
|
||||||
|
|
||||||
|
SELECT 'app_highest_record' AS table_name,
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(DISTINCT MaestroID, PlayerID, AppID) AS unique_combos
|
||||||
|
FROM app_highest_record;
|
||||||
|
|
||||||
|
SELECT 'typing_exam_highest_record' AS table_name,
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(DISTINCT MaestroID, PlayerID, WritingID) AS unique_combos
|
||||||
|
FROM typing_exam_highest_record;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
- DELETE 결과 `Query OK, N rows affected`의 N = 3-0-1의 `rows_to_delete`
|
||||||
|
- 두 테이블 모두 `total_rows` = `unique_combos` (중복 없음)
|
||||||
|
|
||||||
|
> `typing_exam_highest_record`에서 `total_rows` ≠ `unique_combos`이면, 3-0-2와 같은 방식으로 백업한 뒤 아래 SQL로 정리합니다 (높은 점수, 같으면 최근 행을 남김).
|
||||||
|
>
|
||||||
|
> ```sql
|
||||||
|
> DELETE TEHR FROM typing_exam_highest_record TEHR
|
||||||
|
> JOIN (
|
||||||
|
> SELECT TypingExamHighestRecordID FROM (
|
||||||
|
> SELECT TypingExamHighestRecordID,
|
||||||
|
> ROW_NUMBER() OVER (
|
||||||
|
> PARTITION BY MaestroID, PlayerID, WritingID
|
||||||
|
> ORDER BY HighestRecord DESC, TypingExamHighestRecordID DESC
|
||||||
|
> ) AS rn
|
||||||
|
> FROM typing_exam_highest_record
|
||||||
|
> ) ranked
|
||||||
|
> WHERE rn > 1
|
||||||
|
> ) dup ON dup.TypingExamHighestRecordID = TEHR.TypingExamHighestRecordID;
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-1. 인덱스 추가 SQL 준비
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > add_indexes.sql << 'EOF'
|
||||||
|
-- best_record: 랭킹용
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- best_record: 히스토리/저장용
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- typing_exam_record
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_writing_dt (MaestroID, PlayerID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- license_score
|
||||||
|
ALTER TABLE license_score
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_dt (MaestroID, PlayerID, ScoreDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- app_highest_record: UNIQUE 키
|
||||||
|
ALTER TABLE app_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_app (MaestroID, PlayerID, AppID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- typing_exam_highest_record: UNIQUE 키
|
||||||
|
ALTER TABLE typing_exam_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_writing (MaestroID, PlayerID, WritingID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat add_indexes.sql # 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
> `IF NOT EXISTS`: 중간에 실패해 다시 실행해도 이미 만든 인덱스는 건너뜁니다 (`Note 1061 Duplicate key name`만 표시되고 에러는 아님).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-2. 인덱스 추가 실행
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -vvv < add_indexes.sql | tee add_indexes_result.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:** ALTER 7개가 모두 `Query OK`로 끝남
|
||||||
|
|
||||||
|
**`Duplicate entry ... for key 'uk_...'` 에러가 나면:**
|
||||||
|
- 3-0 이후 새 중복이 생긴 것입니다. 에러가 난 지점에서 실행이 멈추고, 뒤의 ALTER는 실행되지 않습니다.
|
||||||
|
- 3-0-2(백업 파일 이름을 바꿔서)와 3-0-3을 다시 실행한 뒤 이 명령을 다시 실행하세요. 이미 만든 인덱스는 건너뜁니다.
|
||||||
|
|
||||||
|
### 진행 상황 모니터링 (다른 터미널)
|
||||||
|
|
||||||
|
**다른 터미널을 열어서 아래 명령어를 반복 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "SHOW PROCESSLIST;" | grep -E "ALTER|Query|State"
|
||||||
|
```
|
||||||
|
|
||||||
|
**자동 반복 실행 (선택사항):**
|
||||||
|
|
||||||
|
비밀번호는 처음에 한 번만 입력합니다. 10초마다 다시 묻지 않습니다. 비밀번호는 파일이나 명령 인자로 남기지 않고 메모리에만 두며, `Ctrl+C`로 멈추면 함께 사라집니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(
|
||||||
|
printf "Enter password: "; read -rs DBPW; echo
|
||||||
|
while true; do
|
||||||
|
clear
|
||||||
|
echo "=== $(date) === (종료: Ctrl+C)"
|
||||||
|
mariadb --defaults-extra-file=<(printf '[client]\npassword="%s"\n' "$DBPW") \
|
||||||
|
-h chocomae.jinaju.com -u jisangs chocomae -e "SHOW PROCESSLIST;" \
|
||||||
|
| grep -E "ALTER|Query|State"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--defaults-extra-file`은 반드시 첫 번째 옵션이어야 합니다. 비밀번호를 틀리면 매번 `Access denied`가 출력되니 `Ctrl+C`로 멈추고 다시 실행하세요.
|
||||||
|
>
|
||||||
|
> PROCESS 권한이 없으면 `SHOW PROCESSLIST`에는 내 계정의 연결만 보입니다. ALTER는 같은 `jisangs` 계정으로 실행하므로 보입니다.
|
||||||
|
|
||||||
|
**나타날 메시지 (State 문구는 버전에 따라 다름):**
|
||||||
|
```
|
||||||
|
| 123 | jisangs | ... | chocomae | Query | 45 | ... | ALTER TABLE best_record ADD INDEX IF NOT EXISTS idx_maestro_app_dt ... |
|
||||||
|
```
|
||||||
|
|
||||||
|
**완료되면:** ALTER TABLE 줄이 사라짐 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-3. 인덱스 생성 확인
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE,
|
||||||
|
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = 'chocomae'
|
||||||
|
AND INDEX_NAME IN ('idx_maestro_app_dt', 'idx_maestro_player_app_dt',
|
||||||
|
'idx_maestro_writing_dt', 'idx_maestro_player_writing_dt',
|
||||||
|
'idx_maestro_player_dt', 'uk_maestro_player_app', 'uk_maestro_player_writing')
|
||||||
|
GROUP BY TABLE_NAME, INDEX_NAME, NON_UNIQUE
|
||||||
|
ORDER BY TABLE_NAME, INDEX_NAME;" > add_indexes_check.txt
|
||||||
|
|
||||||
|
cat add_indexes_check.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력 예 (7행이어야 함):**
|
||||||
|
```
|
||||||
|
TABLE_NAME INDEX_NAME NON_UNIQUE cols
|
||||||
|
app_highest_record uk_maestro_player_app 0 MaestroID,PlayerID,AppID
|
||||||
|
best_record idx_maestro_app_dt 1 MaestroID,AppID,RecordDateTime
|
||||||
|
best_record idx_maestro_player_app_dt 1 MaestroID,PlayerID,AppID,RecordDateTime
|
||||||
|
license_score idx_maestro_player_dt 1 MaestroID,PlayerID,ScoreDateTime
|
||||||
|
typing_exam_highest_record uk_maestro_player_writing 0 MaestroID,PlayerID,WritingID
|
||||||
|
typing_exam_record idx_maestro_player_writing_dt 1 MaestroID,PlayerID,WritingID,RecordDateTime
|
||||||
|
typing_exam_record idx_maestro_writing_dt 1 MaestroID,WritingID,RecordDateTime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-4. 쿼리 조건 수정 (선택: 인덱스 최대 활용)
|
||||||
|
|
||||||
|
> **중요:** 인덱스만으로도 성능이 개선되지만, 함수 기반 조건을 범위 조건으로 변경하면 **1,770배** 추가 성능 향상 가능 (스테이징 기준)
|
||||||
|
>
|
||||||
|
> 04단계는 함수형(q1·q3·q5)과 범위형(q2·q4·q6)을 모두 측정하므로, 운영에서도 인덱스만 적용했을 때와 쿼리까지 수정했을 때의 차이를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
### 수정 대상 (7개 파일, 8+ 함수)
|
||||||
|
|
||||||
|
**꼭 필요한 파일:**
|
||||||
|
1. `src/web/server/record/app_ranking.php` (3개 함수)
|
||||||
|
2. `src/web/server/record/ranking_record_*.php` (3개 파일)
|
||||||
|
3. `src/web/server/record/history_record.php`
|
||||||
|
4. `src/web/php/db/typing_exam_collection.php` (8개 함수)
|
||||||
|
|
||||||
|
### 변경 예시
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 변경 전 (인덱스 일부만 사용)
|
||||||
|
WHERE DATE(RecordDateTime) = DATE(NOW())
|
||||||
|
AND HOUR(RecordDateTime) = HOUR(NOW())
|
||||||
|
|
||||||
|
-- 변경 후 (인덱스 완전 활용)
|
||||||
|
WHERE RecordDateTime >= CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS DATETIME) + INTERVAL 1 HOUR
|
||||||
|
```
|
||||||
|
|
||||||
|
**자세한 변경 규칙:** [02-option1-index-and-query-rewrite.md section 3-3](../02-option1-index-and-query-rewrite.md)
|
||||||
|
|
||||||
|
### 수정 진행 상황 (Stage에서 완료됨)
|
||||||
|
|
||||||
|
- [x] ✅ Stage 서버에서 7개 파일 모두 수정 완료
|
||||||
|
- [x] ✅ 결과 동일성 검증 완료 (EXCEPT로 확인)
|
||||||
|
- [x] ✅ EXPLAIN 성능 개선 확인 (rows: 5,804 → 1, cost: 8.634 → 0.00488)
|
||||||
|
- [x] ⚠️ 후속 수정 (2026-09-15): 히스토리 쿼리 2곳(`history_record.php`, `typing_exam_collection.php` `getHistoryRecord()`)의 하한 조건 `>= DATE(?) - INTERVAL 7 DAY` 삭제 → "기록이 있는 최근 7일" 동작 복원. **운영 배포 코드에 반드시 포함** ([상세](../260915-improve-stage/result/FINAL-REPORT.md))
|
||||||
|
- [ ] Stage에서 히스토리 재검증 (8일보다 전 기록만 있는 학생으로 일반 앱·긴글의 시작·결과 화면 확인)
|
||||||
|
|
||||||
|
### Production 적용 방법
|
||||||
|
|
||||||
|
> **실제 적용 (2026-09-15):** 인덱스 추가(3-2) 직후, 04단계 측정 전에 애플리케이션(PHP, 화면) 코드도 운영에 배포했습니다. 04단계 측정 쿼리는 `queries/*.sql`을 직접 실행하므로 코드 배포의 영향을 받지 않습니다.
|
||||||
|
|
||||||
|
**Option A: 단계적 적용 (권장)**
|
||||||
|
1. 먼저 인덱스만 적용 (이 파일)
|
||||||
|
2. 1~2일 모니터링
|
||||||
|
3. 이후 코드 배포
|
||||||
|
|
||||||
|
**Option B: 한 번에 적용**
|
||||||
|
1. 인덱스 추가 + 코드 배포 동시 진행
|
||||||
|
2. 더 빠른 성능 향상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
✅ 인덱스 추가 완료 → **04-verify.md로 이동**
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 04단계: 개선 효과 검증
|
||||||
|
|
||||||
|
> 인덱스 추가 후 02단계와 **같은 스크립트, 같은 쿼리, 같은 시각**으로 다시 측정해 비교합니다.
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다. `params.sql`, `queries/`는 02단계 그대로 두세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doc/plan/db/260915-improve-production/result
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-1. 측정 실행
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zsh run_measure.sh after
|
||||||
|
```
|
||||||
|
|
||||||
|
`after_explain_q*.json` 6개와 `after_perf.txt`가 저장되고 요약이 출력됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-2. EXPLAIN 비교
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 summarize_explain.py baseline after | tee explain_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인할 점:**
|
||||||
|
|
||||||
|
| 쿼리 | 기대 결과 |
|
||||||
|
|---|---|
|
||||||
|
| q2, q4, q6 (범위형) | `access_type` = `range`, 새 인덱스(`idx_maestro_app_dt` 또는 `idx_maestro_player_app_dt`) 사용, `rows`가 베이스라인보다 크게 감소 |
|
||||||
|
| q1, q3, q5 (함수형) | 새 인덱스를 써도 날짜 조건은 인덱스로 거르지 못해 `rows` 감소 폭이 작음 → 3-4 쿼리 수정이 필요한 이유 |
|
||||||
|
|
||||||
|
> 옵티마이저가 두 새 인덱스 중 어느 쪽을 고를지는 데이터 분포에 따라 다릅니다. 새 인덱스 중 하나를 쓰고 `rows`가 줄었으면 정상입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-3. 결과 행 수 비교
|
||||||
|
|
||||||
|
인덱스는 조회 결과를 바꾸지 않아야 합니다.
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rowcount() {
|
||||||
|
grep -E '^\| === |rows? in set|Empty set' "$1" | paste - - - | grep 'round 2' \
|
||||||
|
| sed -E 's/ \([0-9.]+ sec\)//g'
|
||||||
|
}
|
||||||
|
diff <(rowcount baseline_perf.txt) <(rowcount after_perf.txt) && echo "결과 행 수 동일 ✅"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `@day`가 지난달이라 02와 04 사이에 데이터가 거의 바뀌지 않습니다. 다르게 나오면 그 사이 선생님이 기록을 삭제했는지 확인하세요. (3-0 중복 정리는 `best_record`를 건드리지 않습니다)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-4. 실행 시간 비교
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
timing() {
|
||||||
|
grep -E '^\| === |rows? in set|Empty set' "$1" | paste - - - | grep 'round 2' \
|
||||||
|
| sed -E 's/\| === round 2: (.*) === \|/\1/; s/\t1 row in set \([0-9.]+ sec\)//'
|
||||||
|
}
|
||||||
|
paste <(timing baseline_perf.txt) <(timing after_perf.txt | cut -f2) | tee timing_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**출력 형식:** `쿼리 이름` · `베이스라인 행 수 (시간)` · `개선 후 행 수 (시간)`
|
||||||
|
|
||||||
|
4-2와 4-4-1 결과를 비교표에 옮깁니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > comparison.txt << 'EOF'
|
||||||
|
=== 성능 개선 효과 (운영 DB) ===
|
||||||
|
측정 기준: params.sql (MaestroID 181, AppID 21, @day ____, @hour ____)
|
||||||
|
|
||||||
|
| 쿼리 | access_type (전→후) | rows (전→후) | 서버 시간 ms (전→후, 4-4-1) |
|
||||||
|
|--------------------|---------------------|----------------|-------------------|
|
||||||
|
| q1 시간별 · 함수 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
| q2 시간별 · 범위 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
| q3 일간 · 함수 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
| q4 일간 · 범위 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
| q5 월간 · 함수 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
| q6 월간 · 범위 | ____ → ____ | ____ → ____ | ____ → ____ |
|
||||||
|
|
||||||
|
참고 (스테이징): rows 5,804 → 1, cost 8.63 → 0.004
|
||||||
|
참고 (운영 01 사전 측정): 일간 랭킹 index_merge, rows 10,100
|
||||||
|
|
||||||
|
결과 행 수 동일 (4-3): [ ] 예 [ ] 아니오
|
||||||
|
|
||||||
|
결론:
|
||||||
|
- 인덱스만의 효과 (q1·q3·q5): ____
|
||||||
|
- 인덱스 + 쿼리 수정 효과 (q2·q4·q6): ____
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat comparison.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-4-1. 서버 측 실행 시간 비교 (ANALYZE)
|
||||||
|
|
||||||
|
4-4의 시간에는 네트워크 왕복(운영 측정 기준 약 15ms)이 포함됩니다. 02단계 2-4-1과 같은 방법으로 서버 처리 시간을 측정해 비교합니다. (베이스라인: 6개 모두 약 75~80ms)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{ cat params.sql
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
echo "SELECT '=== ${q:t:r} ===';"
|
||||||
|
printf 'ANALYZE FORMAT=JSON '
|
||||||
|
cat "$q"
|
||||||
|
done
|
||||||
|
} | mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -N -r > after_analyze.txt
|
||||||
|
|
||||||
|
python3 summarize_analyze.py after | tee after_analyze_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 summarize_analyze.py baseline after | tee analyze_summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4-5. Slow Query Log 확인 (`mysql.slow_log` SELECT 권한 필요)
|
||||||
|
|
||||||
|
**아래 명령어를 터미널에 복사-붙여넣기해서 실행하세요:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT start_time, query_time, rows_examined, LEFT(sql_text, 120) AS sql_text
|
||||||
|
FROM mysql.slow_log
|
||||||
|
WHERE start_time >= NOW() - INTERVAL 1 HOUR
|
||||||
|
ORDER BY query_time DESC
|
||||||
|
LIMIT 10;" > after_slow_log.txt
|
||||||
|
|
||||||
|
cat after_slow_log.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- 측정 스크립트의 쿼리도 0.5초를 넘으면 기록됩니다. 운영 앱 쿼리 중 느린 것이 남아 있는지 확인하세요.
|
||||||
|
- `log_output`에 `TABLE`이 없으면 이 테이블은 비어 있습니다 (02단계 2-1 참고).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
✅ 개선 효과 검증 완료 → **05-checklist.md로 이동**
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 05단계: 최종 체크리스트
|
||||||
|
|
||||||
|
> 운영 서버 인덱스 적용 전후 모든 단계를 최종 확인합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 준비 단계
|
||||||
|
|
||||||
|
- [x] DB 백업 (전체 `mariadb-dump` → `backup_chocomae_full_20260915.sql.gz`, 로컬 복원 테스트 통과)
|
||||||
|
- [x] 운영 DB 현황 측정 (01-premeasure.md, 2026-09-15)
|
||||||
|
- [x] 계정 권한 확인 (01 1-0: `SELECT, ALTER`만 있음)
|
||||||
|
- [x] 작업 권한 임시 부여 (SUPER, 최고기록 테이블 2개 INSERT·DELETE, `mysql.slow_log` SELECT — `premeasure_grants_after.txt`)
|
||||||
|
- [x] Slow query log 원래 설정 저장 (01 1-4: ON, long_query_time=3, log_output=FILE)
|
||||||
|
- [x] app_highest_record 전체 중복 개수 확인 (01 1-5-2: 4,823조합, 삭제 대상 8,795행)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 베이스라인 측정 (02-baseline.md)
|
||||||
|
|
||||||
|
- [x] 지난달 측정 기준 시각 선정, `params.sql` 작성
|
||||||
|
- [x] Slow query log 켜기 (jisangs 계정, `log_queries_not_using_indexes`는 켜지 않음)
|
||||||
|
- [x] `queries/` 6개, `run_measure.sh`, `summarize_explain.py` 작성
|
||||||
|
- [x] `zsh run_measure.sh baseline` 실행
|
||||||
|
- `baseline_explain_q*.json` 6개
|
||||||
|
- `baseline_perf.txt`
|
||||||
|
- [x] 확인: 함수형·범위형 짝(q1↔q2, q3↔q4, q5↔q6)의 결과 행 수가 같음
|
||||||
|
- [x] 기준값 기록: `baseline_explain_summary.txt` (운영은 ALL이 아니라 index_merge일 수 있음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 중복 정리·인덱스 추가 (03-add-indexes.md)
|
||||||
|
|
||||||
|
- [x] 3-0-1 중복 규모 확인 (`rows_to_delete` 메모)
|
||||||
|
- [x] 3-0-2 삭제 대상 행 백업 `backup_app_highest_dup_rows.sql` (백업 행 수 = `rows_to_delete`)
|
||||||
|
- [x] 3-0-3 중복 삭제 (`rows affected` = `rows_to_delete`)
|
||||||
|
- [x] 확인: 두 테이블 모두 `total_rows` = `unique_combos`
|
||||||
|
- [x] `add_indexes.sql` 준비
|
||||||
|
- [x] 인덱스 추가 실행 (`add_indexes_result.txt`, ALTER 7개 모두 `Query OK`)
|
||||||
|
- [x] ~~SHOW PROCESSLIST로 진행 상황 모니터링~~ 생략 (ALTER 7개 합계 약 12초)
|
||||||
|
- [x] 새 인덱스 7개 확인 (`add_indexes_check.txt`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개선 효과 검증 (04-verify.md)
|
||||||
|
|
||||||
|
- [x] `zsh run_measure.sh after` 실행
|
||||||
|
- [x] 확인: 범위형 쿼리(q2, q4, q6)에서 `access_type` = `range`, 새 인덱스 사용
|
||||||
|
- [x] 확인: `rows`가 베이스라인보다 크게 감소
|
||||||
|
- [x] 확인: 결과 행 수가 베이스라인과 같음 (4-3)
|
||||||
|
- [x] 실행 시간 비교, `comparison.txt` 작성
|
||||||
|
- [x] Slow query log에서 느린 쿼리 확인 (`after_slow_log.txt`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 성공 기준
|
||||||
|
|
||||||
|
모두 체크되면 **운영 서버 적용 완료:**
|
||||||
|
|
||||||
|
- [x] 인덱스 7개 추가 성공
|
||||||
|
- [x] 범위형 쿼리가 새 인덱스를 사용 (`access_type` = `range`)
|
||||||
|
- [x] 쿼리 실행 시간 개선 확인
|
||||||
|
- [x] 스캔 행 수 대폭 감소
|
||||||
|
- [x] 결과 행 수 변화 없음
|
||||||
|
- [x] 서비스 정상 운영 (게임 결과 저장, 최고기록·랭킹·기록 화면) — 2026-09-15 운영에서 확인: 게임 결과 저장·최고기록, 시간·일·월 랭킹, 최근 7번 기록 화면, 타자 시험 순위와 기록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사후 작업
|
||||||
|
|
||||||
|
- [x] Slow query log 설정을 원래 값으로 복원 (jisangs 계정 — **SUPER 회수 전에** 실행, 2026-09-15 완료: ON / 3 / FILE / OFF)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 01 1-4에서 저장한 원래 값 (premeasure_slow_log_settings.txt)
|
||||||
|
SET GLOBAL slow_query_log = 'ON';
|
||||||
|
SET GLOBAL long_query_time = 3;
|
||||||
|
SET GLOBAL log_output = 'FILE';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 임시로 부여한 권한 회수 (관리자 계정, slow log 복원 후) — 2026-09-15 완료, `result/grants_after_revoke.txt`: `USAGE` + `SELECT, ALTER ON chocomae.*`만 남음
|
||||||
|
|
||||||
|
```sql
|
||||||
|
REVOKE DELETE, INSERT ON chocomae.app_highest_record FROM 'jisangs'@'182.217.174.221';
|
||||||
|
REVOKE DELETE, INSERT ON chocomae.typing_exam_highest_record FROM 'jisangs'@'182.217.174.221';
|
||||||
|
REVOKE SELECT ON mysql.slow_log FROM 'jisangs'@'182.217.174.221';
|
||||||
|
REVOKE SUPER ON *.* FROM 'jisangs'@'182.217.174.221';
|
||||||
|
|
||||||
|
-- 기대 결과: GRANT USAGE ... 와 GRANT SELECT, ALTER ON `chocomae`.* 두 줄만 남음
|
||||||
|
SHOW GRANTS FOR 'jisangs'@'182.217.174.221';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 임시 계정 `jisangs`@`182.217.174.221` 삭제 (2026-09-15, root 계정으로 `DROP USER`)
|
||||||
|
- ~~[ ] 같은 비밀번호를 쓰는 다른 계정 교체 (Gmail SMTP, Python 배치의 DB root 설정)~~ — 취소 (2026-09-15)
|
||||||
|
- ~~[ ] 팀원 통보 (작업 완료)~~ — 취소 (2026-09-15)
|
||||||
|
- [ ] 모니터링 (24시간)
|
||||||
|
- [ ] 문제 발생 시 롤백 준비 완료 (06-rollback.md)
|
||||||
|
- [ ] 사후 분석 (07-post-analysis.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**운영 서버 적용 완료!** ✅
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 06단계: 롤백 계획
|
||||||
|
|
||||||
|
> 문제 발생 시 인덱스를 삭제하고, 필요하면 중복 정리 전 상태로 되돌립니다.
|
||||||
|
>
|
||||||
|
> ⚠️ 작업용 `jisangs` 계정은 2026-09-15에 삭제했습니다. 이 문서의 `-u jisangs` 명령은 관리자 계정으로 바꿔 실행하세요.
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 긴급 상황 대응
|
||||||
|
|
||||||
|
### 1. 진행 중인 ALTER 중단
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "SHOW PROCESSLIST;" | grep ALTER
|
||||||
|
|
||||||
|
# 첫 번째 열(Id)의 값을 넣어 실행
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "KILL QUERY <Id>;"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 온라인 DDL을 중단하면 그 인덱스만 만들어지지 않고 테이블 데이터는 그대로입니다. 내 계정이 실행한 쿼리는 별도 권한 없이 중단할 수 있습니다.
|
||||||
|
|
||||||
|
### 2. 인덱스 삭제
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae << 'EOF'
|
||||||
|
-- 모든 인덱스 삭제
|
||||||
|
ALTER TABLE best_record DROP INDEX IF EXISTS idx_maestro_app_dt;
|
||||||
|
ALTER TABLE best_record DROP INDEX IF EXISTS idx_maestro_player_app_dt;
|
||||||
|
ALTER TABLE typing_exam_record DROP INDEX IF EXISTS idx_maestro_writing_dt;
|
||||||
|
ALTER TABLE typing_exam_record DROP INDEX IF EXISTS idx_maestro_player_writing_dt;
|
||||||
|
ALTER TABLE license_score DROP INDEX IF EXISTS idx_maestro_player_dt;
|
||||||
|
ALTER TABLE app_highest_record DROP INDEX IF EXISTS uk_maestro_player_app;
|
||||||
|
ALTER TABLE typing_exam_highest_record DROP INDEX IF EXISTS uk_maestro_player_writing;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
> UNIQUE 키 2개는 성능과 무관하므로, 성능 문제로 롤백할 때는 남겨 두어도 됩니다. 남겨 두면 중복이 다시 생기지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 중복 정리 되돌리기 (필요할 때만)
|
||||||
|
|
||||||
|
삭제한 행은 같은 조합의 **덜 좋은 기록**이라 보통 되돌릴 필요가 없습니다. 되돌려야 하면 UNIQUE 키를 먼저 삭제한 뒤 03단계 3-0-2의 백업 파일을 실행합니다. (INSERT 권한 — jisangs에 임시 부여됨)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
ALTER TABLE app_highest_record DROP INDEX IF EXISTS uk_maestro_player_app;"
|
||||||
|
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae < backup_app_highest_dup_rows.sql
|
||||||
|
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT COUNT(*) AS total_rows FROM app_highest_record;"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 백업 파일은 원래 ID 그대로 INSERT하므로 기존 행과 충돌하지 않습니다. 복원 후 `total_rows`는 3-0-3 삭제 전 행 수(그 사이 새로 저장된 기록 제외)와 같아야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 백업에서 복원 (최후의 수단)
|
||||||
|
|
||||||
|
운영 DB는 AWS RDS가 아니라 **EC2의 Docker MariaDB**입니다. 인덱스 문제는 DROP INDEX로 충분하므로 데이터 손상이 있을 때만 사용하세요. **백업 이후 저장된 게임 기록은 모두 사라집니다.**
|
||||||
|
|
||||||
|
### 방법 1: 덤프 파일 복원
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# EC2 서버의 MariaDB 컨테이너 안에서 관리자 계정으로 실행
|
||||||
|
mariadb -u <관리자> -p chocomae < backup_2026-09-15.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방법 2: EBS 스냅샷 복원
|
||||||
|
|
||||||
|
1. AWS 콘솔 → EC2 → 스냅샷에서 작업 전 스냅샷 선택
|
||||||
|
2. 스냅샷으로 볼륨 생성 (EC2 인스턴스와 같은 가용 영역)
|
||||||
|
3. MariaDB 컨테이너 중지 → 기존 데이터 볼륨 분리 → 새 볼륨 연결·마운트
|
||||||
|
4. 컨테이너 시작 후 접속 확인
|
||||||
|
|
||||||
|
### 복제 상태 확인 (복제를 사용 중인 경우, 관리자 계정)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SHOW MASTER STATUS;
|
||||||
|
SHOW REPLICA STATUS\G
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 정상 확인
|
||||||
|
- 웹 서비스 접속 테스트
|
||||||
|
- 타이핑 게임 플레이 테스트 (결과 저장, 최고기록 표시)
|
||||||
|
- 랭킹 조회 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 원인 분석
|
||||||
|
|
||||||
|
| 증상 | 원인 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| 인덱스 생성 실패 | 디스크 부족 | DROP INDEX + 디스크 정리 |
|
||||||
|
| `Duplicate entry ... for key 'uk_...'` | 3-0 이후 새 중복 발생 | 3-0-2·3-0-3 다시 실행 후 `add_indexes.sql` 재실행 (이미 만든 인덱스는 건너뜀) |
|
||||||
|
| 서비스 느려짐 | 옵티마이저가 새 인덱스를 잘못 선택, 통계 부정확 | `ANALYZE TABLE best_record;` (관리자) → 그래도 느리면 해당 인덱스 DROP |
|
||||||
|
| 최고기록 화면 값 이상 | 중복 정리 결과 | 백업 파일에서 해당 조합 확인, 필요하면 "중복 정리 되돌리기" |
|
||||||
|
| 복제 지연·오류 | 복제 서버에서 DDL 적용 중 또는 실패 | `SHOW REPLICA STATUS`로 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 복구 후 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 테이블 상태 확인
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SHOW INDEXES FROM best_record;
|
||||||
|
SELECT COUNT(*) FROM best_record;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
성능 확인은 `zsh run_measure.sh after`로 다시 측정해 `baseline_perf.txt`와 비교합니다. 기존 `after_*` 파일을 덮어쓰므로 먼저 다른 폴더로 옮기세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 발생 시 연락
|
||||||
|
|
||||||
|
- **팀 리더**: [연락처]
|
||||||
|
- **DBA**: [연락처]
|
||||||
|
- **운영팀**: [연락처]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**롤백은 최후의 수단입니다. 충분한 검토 후 신중하게 진행하세요.** ⚠️
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 07단계: 사후 분석 및 최적화
|
||||||
|
|
||||||
|
> 운영 서버 적용 후 실제 성능을 분석하고 추가 최적화를 검토합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 실제 성능 측정 결과
|
||||||
|
|
||||||
|
### 운영 DB에서 측정한 개선율
|
||||||
|
|
||||||
|
`comparison.txt`(04단계)의 값을 옮겨 적었습니다 (2026-09-15). 6개 쿼리 전체 값은 [result/comparison.txt](result/comparison.txt)를 참고하세요.
|
||||||
|
|
||||||
|
| 항목 | 베이스라인 (02) | 개선 후 (04) | 개선율 | 상태 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **EXPLAIN rows (q2 시간별·범위)** | 10,103 | 22 | 459배 ⬇️ | [x] 확인 |
|
||||||
|
| **EXPLAIN rows (q4 일간·범위)** | 10,103 | 99 | 102배 ⬇️ | [x] 확인 |
|
||||||
|
| **EXPLAIN rows (q3 일간·함수)** | 10,103 | 5,851 | 1.7배 ⬇️ | [x] 확인 |
|
||||||
|
| **서버 처리 시간 (q4, ANALYZE)** | 75.2ms | 0.47ms | 161배 ⬇️ | [x] 확인 |
|
||||||
|
| **서버 처리 시간 (q3 일간·함수, ANALYZE)** | 75.3ms | 2.44ms | 31배 ⬇️ | [x] 확인 |
|
||||||
|
| **느린 쿼리 수 (0.5초 이상)** | 측정 안 함 (당시 기준 3초·파일 로그) | 앱 쿼리 0건 (작업 후 1시간, 작업 SQL 5건만) | - | [ ] 24시간 후 재확인 |
|
||||||
|
|
||||||
|
> 참고: 01단계 사전 측정의 일간 랭킹(함수형)은 `index_merge`, rows 10,100이었습니다.
|
||||||
|
>
|
||||||
|
> MariaDB 10.11의 `EXPLAIN FORMAT=JSON`에는 cost 필드가 없어 스테이징의 cost 수치와 직접 비교할 수 없습니다. rows와 실행 시간으로 비교하고, 실제 실행 통계가 필요하면 `ANALYZE FORMAT=JSON <쿼리>`의 `r_rows`, `r_total_time_ms`를 사용하세요 (쿼리를 실제로 실행함).
|
||||||
|
|
||||||
|
### 저장된 파일
|
||||||
|
```
|
||||||
|
result/
|
||||||
|
├── premeasure_*.txt, premeasure_*.json ← 01 사전 측정
|
||||||
|
├── baseline_explain_q*.json ← 02 EXPLAIN
|
||||||
|
├── baseline_perf.txt ← 02 실행 시간
|
||||||
|
├── backup_app_highest_dup_rows.sql ← 03 삭제 행 백업
|
||||||
|
├── add_indexes_result.txt ← 03 인덱스 추가 결과
|
||||||
|
├── after_explain_q*.json ← 04 EXPLAIN
|
||||||
|
├── after_perf.txt ← 04 실행 시간
|
||||||
|
├── explain_summary.txt, timing_summary.txt
|
||||||
|
└── comparison.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 성능 분석
|
||||||
|
|
||||||
|
### 쿼리 응답 시간 추이 (24시간 모니터링)
|
||||||
|
|
||||||
|
```
|
||||||
|
시간대별 성능:
|
||||||
|
- 평상시: _____ ms
|
||||||
|
- 피크 시간: _____ ms
|
||||||
|
- 최고 응답 시간: _____ ms
|
||||||
|
|
||||||
|
느린 쿼리 로그:
|
||||||
|
- 0.5초 이상: _____ 개 (개선 전: _____)
|
||||||
|
- 1초 이상: _____ 개 (개선 전: _____)
|
||||||
|
- 5초 이상: _____ 개 (개선 전: _____)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 비즈니스 영향
|
||||||
|
|
||||||
|
| 지표 | 개선 전 | 개선 후 | 변화 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 동시 사용자 | _____ | _____ | _____ |
|
||||||
|
| 평균 응답 시간 | _____ ms | _____ ms | _____ ms |
|
||||||
|
| 실패율 | _____ % | _____ % | _____ % |
|
||||||
|
| CPU 사용률 | _____ % | _____ % | _____ % |
|
||||||
|
| 메모리 사용률 | _____ % | _____ % | _____ % |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 성공 여부 판단
|
||||||
|
|
||||||
|
### 성공 기준
|
||||||
|
|
||||||
|
- [ ] 범위형 쿼리(q2·q4·q6)의 rows가 베이스라인 대비 대폭 감소 (목표: 조회 기간의 실제 기록 수 수준)
|
||||||
|
- [ ] 느린 쿼리 대폭 감소
|
||||||
|
- [ ] 결과 행 수 변화 없음, 최고기록 화면 정상
|
||||||
|
- [ ] 서비스 가용성 100% 유지
|
||||||
|
- [ ] 사용자 불만 없음
|
||||||
|
|
||||||
|
### 결론
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 성공: 모든 기준 달성, 운영 정상화
|
||||||
|
[ ] 부분 성공: 대부분 달성, 모니터링 계속
|
||||||
|
[ ] 실패: 목표 미달, 추가 최적화 필요
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 추가 최적화 제안
|
||||||
|
|
||||||
|
### 단계별 추가 개선
|
||||||
|
|
||||||
|
#### **1단계: 쿼리 최적화 (안 1)**
|
||||||
|
- [ ] 함수 제거 쿼리 적용 (03단계 3-4, Stage에서 수정 완료한 코드 배포)
|
||||||
|
- [ ] 예상 효과: 04단계의 함수형↔범위형 비교값(q1↔q2, q3↔q4, q5↔q6) 참고
|
||||||
|
|
||||||
|
#### **2단계: 집계 테이블 도입 (안 2)**
|
||||||
|
- [ ] daily_best_record, daily_typing_exam_record 생성
|
||||||
|
- [ ] 예상 효과: 히스토리/랭킹 조회 극적 개선
|
||||||
|
|
||||||
|
#### **3단계: 아카이빙 (안 3)**
|
||||||
|
- [ ] 13개월 이전 기록 아카이빙
|
||||||
|
- [ ] 예상 효과: 테이블 크기 감소, 백업 시간 단축
|
||||||
|
- [ ] 참고: 월 적재량이 전년 대비 약 2.1배로 늘고 있음 (01 사전 측정)
|
||||||
|
|
||||||
|
#### **4단계: 애플리케이션 최적화 (안 5)**
|
||||||
|
- [ ] N+1 쿼리 제거
|
||||||
|
- [ ] SQL Injection 보안 강화
|
||||||
|
- [ ] 최고기록 저장을 `INSERT ... ON DUPLICATE KEY UPDATE`로 변경 (UNIQUE 키 활용, 동시 저장 시 더 좋은 기록 유지)
|
||||||
|
- [ ] 예상 효과: 동시 사용자 처리량 2~3배 향상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 모니터링 계획
|
||||||
|
|
||||||
|
### 지속적 모니터링 (24시간 이후)
|
||||||
|
|
||||||
|
`mysql.slow_log` SELECT 권한이 필요하고, `log_output`에 `TABLE`이 있어야 합니다. 조회가 끝나면 05단계 사후 작업대로 slow query log 설정을 원래 값으로 복원하세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 일일 성능 리포트
|
||||||
|
mariadb -h chocomae.jinaju.com -u jisangs -p chocomae -e "
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(start_time, '%Y-%m-%d %H:00:00') AS hour,
|
||||||
|
COUNT(*) AS slow_query_count,
|
||||||
|
AVG(query_time) AS avg_time,
|
||||||
|
MAX(query_time) AS max_time
|
||||||
|
FROM mysql.slow_log
|
||||||
|
WHERE start_time >= CURDATE()
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 최종 보고서 항목
|
||||||
|
|
||||||
|
1. **실행 요약**
|
||||||
|
- 작업 목표 및 결과
|
||||||
|
- 성과 지표
|
||||||
|
|
||||||
|
2. **기술 분석**
|
||||||
|
- EXPLAIN 개선 결과
|
||||||
|
- 실행 시간 비교
|
||||||
|
- 중복 정리 결과 (삭제 행 수, 백업 파일)
|
||||||
|
|
||||||
|
3. **비즈니스 임팩트**
|
||||||
|
- 사용자 경험 개선
|
||||||
|
- 서버 비용 절감 효과
|
||||||
|
|
||||||
|
4. **추가 제안**
|
||||||
|
- 다음 단계 최적화 계획
|
||||||
|
- 예상 추가 효과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 다음 단계
|
||||||
|
|
||||||
|
- [ ] 스테이징에서 안 2 (집계 테이블) 테스트
|
||||||
|
- [ ] 운영 DB 추가 최적화 검토
|
||||||
|
- [ ] 팀 회의에서 결과 공유
|
||||||
|
- [ ] 성능 모니터링 대시보드 구축
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**운영 서버 성능 개선 프로젝트 완료!** 🎉
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 260915-improve-production: 운영 서버 DB 성능 개선
|
||||||
|
|
||||||
|
> 스테이징에서 성공한 인덱스 추가를 운영 서버(chocomae.jinaju.com)에 적용합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 파일 가이드
|
||||||
|
|
||||||
|
### 🎯 먼저 읽기
|
||||||
|
- **[00-overview.md](00-overview.md)** — 전체 개요 및 작업 일정
|
||||||
|
- 운영 사전 측정 결과
|
||||||
|
- 단계별 필요 권한
|
||||||
|
- 예상 효과 및 주의사항
|
||||||
|
|
||||||
|
### 📋 단계별 작업
|
||||||
|
|
||||||
|
#### **Step 1: 운영 DB 현황 측정** (2026-09-15 측정 완료)
|
||||||
|
- **[01-premeasure.md](01-premeasure.md)** — 권한, 테이블 크기, 월별 적재량, 기준값, 중복 확인
|
||||||
|
- 결과 요약: [result/premeasure_summary.txt](result/premeasure_summary.txt)
|
||||||
|
- 주요 결과: 계정 권한은 `SELECT, ALTER`만, app_highest_record 중복 삭제 대상 8,795행
|
||||||
|
|
||||||
|
#### **Step 2: 성능 기준선 측정**
|
||||||
|
- **[02-baseline.md](02-baseline.md)** — 인덱스 추가 전 성능 측정
|
||||||
|
- 지난달 측정 기준 시각 선정 (`params.sql`)
|
||||||
|
- Slow query log 켜기
|
||||||
|
- 공통 측정 스크립트로 EXPLAIN 6개 + 실행 시간 측정
|
||||||
|
|
||||||
|
#### **Step 3: 중복 정리 + 인덱스 추가**
|
||||||
|
- **[03-add-indexes.md](03-add-indexes.md)** — 온라인 DDL로 인덱스 추가
|
||||||
|
- app_highest_record 삭제 대상 백업 후 점수 기준으로 중복 정리
|
||||||
|
- 인덱스 5개 + UNIQUE 키 2개 추가
|
||||||
|
- 진행 상황 모니터링
|
||||||
|
- **옵션:** 쿼리 조건 수정 (DATE()/HOUR() → 범위 조건) - 추가 1,770배 성능 향상 (스테이징 기준)
|
||||||
|
|
||||||
|
#### **Step 4: 개선 효과 검증**
|
||||||
|
- **[04-verify.md](04-verify.md)** — 인덱스 추가 후 성능 검증
|
||||||
|
- 02와 같은 스크립트로 재측정
|
||||||
|
- EXPLAIN·결과 행 수·실행 시간 비교
|
||||||
|
- 성능 개선율 확인
|
||||||
|
|
||||||
|
#### **Step 5: 최종 체크리스트**
|
||||||
|
- **[05-checklist.md](05-checklist.md)** — 모든 단계 최종 확인
|
||||||
|
- 성공 기준 검증
|
||||||
|
- slow query log 복원, 임시 권한 회수
|
||||||
|
|
||||||
|
### 🔧 추가 문서
|
||||||
|
|
||||||
|
- **[06-rollback.md](06-rollback.md)** — 문제 발생 시 롤백 계획
|
||||||
|
- ALTER 중단, DROP INDEX 명령어
|
||||||
|
- 중복 정리 되돌리기
|
||||||
|
- 백업 복원 절차 (EC2)
|
||||||
|
|
||||||
|
- **[07-post-analysis.md](07-post-analysis.md)** — 사후 분석 및 최적화
|
||||||
|
- 실제 성능 측정 결과
|
||||||
|
- 추가 개선 사항 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빠른 시작
|
||||||
|
|
||||||
|
모든 명령은 `result/` 디렉터리에서 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd doc/plan/db/260915-improve-production/result
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 01 사전 측정·작업 권한 준비 완료 (2026-09-15)
|
||||||
|
2. `../02-baseline.md` 실행 → `zsh run_measure.sh baseline`
|
||||||
|
3. `../03-add-indexes.md` 실행 → 중복 백업·정리 후 `add_indexes.sql`
|
||||||
|
4. `../04-verify.md` 실행 → `zsh run_measure.sh after`
|
||||||
|
5. `../05-checklist.md` 체크리스트 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 운영 DB 접속 정보
|
||||||
|
|
||||||
|
```
|
||||||
|
# 임시 계정
|
||||||
|
Host: chocomae.jinaju.com
|
||||||
|
Port: 3306
|
||||||
|
User: jisangs (2026-09-15 작업 후 삭제)
|
||||||
|
Password: 문서에 적지 않음 (별도로 전달받은 값 사용)
|
||||||
|
Database: chocomae
|
||||||
|
|
||||||
|
# 권한 (2026-09-15 SHOW GRANTS로 확인)
|
||||||
|
GRANT SELECT, ALTER ON chocomae.* TO jisangs@'182.217.174.221'
|
||||||
|
# 작업 기간 임시 권한 (05단계에서 회수): SUPER, 최고기록 테이블 2개 INSERT·DELETE, mysql.slow_log SELECT
|
||||||
|
```
|
||||||
|
|
||||||
|
> 중복 삭제(DELETE), slow query log 설정(SUPER), `mysql.slow_log` 조회 권한은 작업 기간 동안 임시로 부여했고 05단계에서 회수합니다. [00-overview.md의 단계별 필요 권한](00-overview.md#단계별-필요-권한)을 참고하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
260915-improve-production/
|
||||||
|
├── README.md ← 이 파일
|
||||||
|
├── 00-overview.md ← 전체 개요
|
||||||
|
├── 01-premeasure.md ← 운영 DB 현황 측정
|
||||||
|
├── 02-baseline.md ← 성능 기준선 측정
|
||||||
|
├── 03-add-indexes.md ← 중복 정리 + 인덱스 추가
|
||||||
|
├── 04-verify.md ← 개선 효과 검증
|
||||||
|
├── 05-checklist.md ← 최종 체크리스트
|
||||||
|
├── 06-rollback.md ← 롤백 계획
|
||||||
|
├── 07-post-analysis.md ← 사후 분석
|
||||||
|
└── result/ ← 결과 파일 저장 폴더
|
||||||
|
├── premeasure_* ← 01
|
||||||
|
├── params.sql, queries/ ← 02·04 공통 측정 쿼리
|
||||||
|
├── run_measure.sh, summarize_explain.py
|
||||||
|
├── baseline_explain_q*.json, baseline_perf.txt
|
||||||
|
├── backup_app_highest_dup_rows.sql
|
||||||
|
├── add_indexes.sql, add_indexes_result.txt
|
||||||
|
├── after_explain_q*.json, after_perf.txt
|
||||||
|
└── comparison.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
운영 서버 적용 전 확인:
|
||||||
|
|
||||||
|
- [ ] **스테이징 결과 검토** (260915-improve-stage 참고)
|
||||||
|
- [ ] **백업 계획 수립** (EC2 EBS 스냅샷 또는 전체 `mariadb-dump`)
|
||||||
|
- [x] **01-premeasure.md 실행** (운영 DB 현황 파악, 2026-09-15)
|
||||||
|
- [x] **계정 권한 확인** (`SELECT, ALTER`만 있음)
|
||||||
|
- [x] **작업 권한 임시 부여** (SUPER, 최고기록 테이블 2개 INSERT·DELETE, `mysql.slow_log` SELECT)
|
||||||
|
- [x] **작업 후 임시 권한 회수** (05-checklist.md)
|
||||||
|
- [ ] **02-baseline.md 실행** (기준선 측정)
|
||||||
|
- [ ] **새벽 시간 예약** (02:00~03:00 권장)
|
||||||
|
- [ ] **모니터링 준비** (CPU, 메모리, 프로세스)
|
||||||
|
- [ ] **롤백 계획 검토** (06-rollback.md)
|
||||||
|
- [ ] **팀원 통보** (예정된 작업 공지)
|
||||||
|
- [x] **작업 후 임시 계정 삭제** (2026-09-15)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 도움말
|
||||||
|
|
||||||
|
| 상황 | 참고 |
|
||||||
|
|---|---|
|
||||||
|
| 인덱스 추가 방법 | 03-add-indexes.md |
|
||||||
|
| 성능 검증 방법 | 04-verify.md |
|
||||||
|
| 문제 발생 시 | 06-rollback.md |
|
||||||
|
| 최적화 제안 | 07-post-analysis.md |
|
||||||
|
| 전체 계획 | 00-overview.md |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 작업 일정
|
||||||
|
|
||||||
|
**권장: 새벽 2시~3시 (트래픽 최소 시간)**
|
||||||
|
|
||||||
|
| 시간 | 작업 | 소요 |
|
||||||
|
|---|---|---|
|
||||||
|
| 02:00 | DB 백업 | 5분 |
|
||||||
|
| 02:05 | 기준선 측정 (02-baseline) | 10분 |
|
||||||
|
| 02:15 | 중복 정리 + 인덱스 추가 (03-add-indexes) | 30분 |
|
||||||
|
| 02:45 | 인덱스 생성 확인 | 5분 |
|
||||||
|
| 02:50 | 검증 (04-verify) | 10분 |
|
||||||
|
| 03:00 | 체크리스트, slow log 복원, 권한 회수 (05-checklist) | 10분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**준비 완료! 시작하려면 [00-overview.md](00-overview.md)부터 읽으세요.** 🚀
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- best_record: 랭킹용
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- best_record: 히스토리/저장용
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- typing_exam_record
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_writing_dt (MaestroID, PlayerID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- license_score
|
||||||
|
ALTER TABLE license_score
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_dt (MaestroID, PlayerID, ScoreDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- app_highest_record: UNIQUE 키
|
||||||
|
ALTER TABLE app_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_app (MaestroID, PlayerID, AppID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
|
|
||||||
|
-- typing_exam_highest_record: UNIQUE 키
|
||||||
|
ALTER TABLE typing_exam_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_writing (MaestroID, PlayerID, WritingID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
TABLE_NAME INDEX_NAME NON_UNIQUE cols
|
||||||
|
app_highest_record uk_maestro_player_app 0 MaestroID,PlayerID,AppID
|
||||||
|
best_record idx_maestro_app_dt 1 MaestroID,AppID,RecordDateTime
|
||||||
|
best_record idx_maestro_player_app_dt 1 MaestroID,PlayerID,AppID,RecordDateTime
|
||||||
|
license_score idx_maestro_player_dt 1 MaestroID,PlayerID,ScoreDateTime
|
||||||
|
typing_exam_highest_record uk_maestro_player_writing 0 MaestroID,PlayerID,WritingID
|
||||||
|
typing_exam_record idx_maestro_player_writing_dt 1 MaestroID,PlayerID,WritingID,RecordDateTime
|
||||||
|
typing_exam_record idx_maestro_writing_dt 1 MaestroID,WritingID,RecordDateTime
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
--------------
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (4.803 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE best_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (4.832 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (0.735 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE typing_exam_record
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_writing_dt (MaestroID, PlayerID, WritingID, RecordDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (0.370 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE license_score
|
||||||
|
ADD INDEX IF NOT EXISTS idx_maestro_player_dt (MaestroID, PlayerID, ScoreDateTime),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (0.042 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE app_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_app (MaestroID, PlayerID, AppID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (1.076 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
--------------
|
||||||
|
ALTER TABLE typing_exam_highest_record
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_writing (MaestroID, PlayerID, WritingID),
|
||||||
|
ALGORITHM=INPLACE, LOCK=NONE
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 0 rows affected (0.156 sec)
|
||||||
|
Records: 0 Duplicates: 0 Warnings: 0
|
||||||
|
|
||||||
|
Bye
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
=== q1_hour_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.303570412
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 2.069071756,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"r_rows": 22,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 2.050037968,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 22,
|
||||||
|
"r_buffer_size": "2047Kb",
|
||||||
|
"r_sort_mode": "sort_key,addon_fields",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 5851,
|
||||||
|
"r_rows": 22,
|
||||||
|
"r_table_time_ms": 2.024712928,
|
||||||
|
"r_other_time_ms": 0.015343053,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 77
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "cast(best_record.RecordDateTime as date) = @`day` and hour(best_record.RecordDateTime) = 9",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q2_hour_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.23481673
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.259231588,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"r_rows": 22,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.241578075,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 22,
|
||||||
|
"r_buffer_size": "2047Kb",
|
||||||
|
"r_sort_mode": "sort_key,addon_fields",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 22,
|
||||||
|
"r_rows": 22,
|
||||||
|
"r_table_time_ms": 0.143388535,
|
||||||
|
"r_other_time_ms": 0.086477209,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 69
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-26 09:00:00' and best_record.RecordDateTime < '2026-08-26 10:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q3_day_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.260151772
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 2.440415655,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.019813943,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 61,
|
||||||
|
"r_buffer_size": "1Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 5851,
|
||||||
|
"r_rows": 99,
|
||||||
|
"r_table_time_ms": 2.331223925,
|
||||||
|
"r_other_time_ms": 0.071694268,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 308
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8 and dayofmonth(best_record.RecordDateTime) = 26",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q4_day_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.335646795
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.467062948,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.017993581,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 61,
|
||||||
|
"r_buffer_size": "1Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 99,
|
||||||
|
"r_rows": 99,
|
||||||
|
"r_table_time_ms": 0.318203324,
|
||||||
|
"r_other_time_ms": 0.112702428,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 300
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-26 00:00:00' and best_record.RecordDateTime < '2026-08-27 00:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q5_month_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.248649482
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 4.299725667,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.068003533,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 262,
|
||||||
|
"r_buffer_size": "6Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 5851,
|
||||||
|
"r_rows": 709,
|
||||||
|
"r_table_time_ms": 3.78789381,
|
||||||
|
"r_other_time_ms": 0.408851363,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 2138
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q6_month_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.306621019
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 2.496056728,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.065773089,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 262,
|
||||||
|
"r_buffer_size": "6Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 709,
|
||||||
|
"r_rows": 709,
|
||||||
|
"r_table_time_ms": 2.033224622,
|
||||||
|
"r_other_time_ms": 0.360211684,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 2131
|
||||||
|
},
|
||||||
|
"filtered": 0.057728633,
|
||||||
|
"r_filtered": 100,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-01 00:00:00' and best_record.RecordDateTime < '2026-09-01 00:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"rows": 5851,
|
||||||
|
"filtered": 100,
|
||||||
|
"index_condition": "cast(best_record.RecordDateTime as date) = @`day` and hour(best_record.RecordDateTime) = 9",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"rows": 22,
|
||||||
|
"filtered": 100,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(@`day` as datetime) + interval @`hour` hour) and best_record.RecordDateTime < <cache>(cast(@`day` as datetime) + interval @`hour` + 1 hour)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"rows": 5851,
|
||||||
|
"filtered": 100,
|
||||||
|
"index_condition": "year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8 and dayofmonth(best_record.RecordDateTime) = 26",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"rows": 99,
|
||||||
|
"filtered": 100,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(@`day` as datetime)) and best_record.RecordDateTime < <cache>(cast(@`day` as datetime) + interval 1 day)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "8",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID"],
|
||||||
|
"ref": ["const", "const"],
|
||||||
|
"rows": 5851,
|
||||||
|
"filtered": 100,
|
||||||
|
"index_condition": "year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8",
|
||||||
|
"attached_condition": "best_record.MaestroID <=> @maestro and best_record.AppID <=> @app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "range",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"idx_maestro_app_dt",
|
||||||
|
"idx_maestro_player_app_dt"
|
||||||
|
],
|
||||||
|
"key": "idx_maestro_app_dt",
|
||||||
|
"key_length": "13",
|
||||||
|
"used_key_parts": ["MaestroID", "AppID", "RecordDateTime"],
|
||||||
|
"rows": 709,
|
||||||
|
"filtered": 0.057728633,
|
||||||
|
"index_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(date_format(@`day`,'%Y-%m-01') as datetime)) and best_record.RecordDateTime < <cache>(cast(date_format(@`day`,'%Y-%m-01') as datetime) + interval 1 month)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
start_time query_time rows_examined sql_text
|
||||||
|
2026-09-15 23:09:13.487911 00:00:04.819872 0 ALTER TABLE best_record \nADD INDEX IF NOT EXISTS idx_maestro_player_app_dt (MaestroID, PlayerID, AppID, RecordDateTime),
|
||||||
|
2026-09-15 23:09:08.684811 00:00:04.790929 0 ALTER TABLE best_record \nADD INDEX IF NOT EXISTS idx_maestro_app_dt (MaestroID, AppID, RecordDateTime),\nALGORITHM=INPLAC
|
||||||
|
2026-09-15 23:09:19.466802 00:00:01.063310 0 ALTER TABLE app_highest_record \nADD UNIQUE KEY IF NOT EXISTS uk_maestro_player_app (MaestroID, PlayerID, AppID),\nALGORIT
|
||||||
|
2026-09-15 23:08:21.126639 00:00:00.929698 935362 DELETE AHR FROM app_highest_record AHR\nJOIN (\n SELECT AppHighestRecordID FROM (\n SELECT AppHighestRecordID,\n
|
||||||
|
2026-09-15 23:09:18.319470 00:00:00.723314 0 ALTER TABLE typing_exam_record \nADD INDEX IF NOT EXISTS idx_maestro_writing_dt (MaestroID, WritingID, RecordDateTime),\nA
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
baseline q1_hour_func server_time_ms=74.61373851
|
||||||
|
baseline q2_hour_range server_time_ms=75.58458171
|
||||||
|
baseline q3_day_func server_time_ms=75.28739257
|
||||||
|
baseline q4_day_range server_time_ms=75.24298373
|
||||||
|
baseline q5_month_func server_time_ms=76.26824776
|
||||||
|
baseline q6_month_range server_time_ms=79.70009072
|
||||||
|
after q1_hour_func server_time_ms=2.069071756
|
||||||
|
after q2_hour_range server_time_ms=0.259231588
|
||||||
|
after q3_day_func server_time_ms=2.440415655
|
||||||
|
after q4_day_range server_time_ms=0.467062948
|
||||||
|
after q5_month_func server_time_ms=4.299725667
|
||||||
|
after q6_month_range server_time_ms=2.496056728
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
=== q1_hour_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.175154857
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 74.61373851,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"r_rows": 22,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 74.59065391,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 22,
|
||||||
|
"r_buffer_size": "2047Kb",
|
||||||
|
"r_sort_mode": "sort_key,addon_fields",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 15.56706792,
|
||||||
|
"r_other_time_ms": 52.50187814,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 0.376004102,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and cast(best_record.RecordDateTime as date) = @`day` and hour(best_record.RecordDateTime) = 9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q2_hour_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.183126443
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 75.58458171,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"r_rows": 22,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 75.56186719,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 22,
|
||||||
|
"r_buffer_size": "2047Kb",
|
||||||
|
"r_sort_mode": "sort_key,addon_fields",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 15.67992038,
|
||||||
|
"r_other_time_ms": 53.82031051,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 0.376004102,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-26 09:00:00' and best_record.RecordDateTime < '2026-08-26 10:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q3_day_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.205960987
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 75.28739257,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.028745721,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 61,
|
||||||
|
"r_buffer_size": "1Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 15.68132066,
|
||||||
|
"r_other_time_ms": 53.54159505,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 1.692018458,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8 and dayofmonth(best_record.RecordDateTime) = 26"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q4_day_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.200909982
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 75.24298373,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.023154608,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 61,
|
||||||
|
"r_buffer_size": "1Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 15.79226274,
|
||||||
|
"r_other_time_ms": 52.7619799,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 1.692018458,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-26 00:00:00' and best_record.RecordDateTime < '2026-08-27 00:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q5_month_func ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.213092406
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 76.26824776,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.069783887,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 262,
|
||||||
|
"r_buffer_size": "6Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 16.3543846,
|
||||||
|
"r_other_time_ms": 53.27444188,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 12.11758674,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
=== q6_month_range ===
|
||||||
|
{
|
||||||
|
"query_optimization": {
|
||||||
|
"r_total_time_ms": 0.215072801
|
||||||
|
},
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 79.70009072,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"r_loops": 1,
|
||||||
|
"r_total_time_ms": 0.072174363,
|
||||||
|
"r_used_priority_queue": false,
|
||||||
|
"r_output_rows": 262,
|
||||||
|
"r_buffer_size": "6Kb",
|
||||||
|
"r_sort_mode": "sort_key,rowid",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"r_loops": 1,
|
||||||
|
"rows": 10103,
|
||||||
|
"r_rows": 5851,
|
||||||
|
"r_table_time_ms": 19.07379578,
|
||||||
|
"r_other_time_ms": 54.36322855,
|
||||||
|
"r_engine_stats": {
|
||||||
|
"pages_accessed": 17780
|
||||||
|
},
|
||||||
|
"filtered": 100,
|
||||||
|
"r_filtered": 12.11758674,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= '2026-08-01 00:00:00' and best_record.RecordDateTime < '2026-09-01 00:00:00'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
baseline q1_hour_func server_time_ms=74.61373851
|
||||||
|
baseline q2_hour_range server_time_ms=75.58458171
|
||||||
|
baseline q3_day_func server_time_ms=75.28739257
|
||||||
|
baseline q4_day_range server_time_ms=75.24298373
|
||||||
|
baseline q5_month_func server_time_ms=76.26824776
|
||||||
|
baseline q6_month_range server_time_ms=79.70009072
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and cast(best_record.RecordDateTime as date) = @`day` and hour(best_record.RecordDateTime) = 9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"read_sorted_file": {
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "best_record.RecordDateTime, best_record.PlayerID",
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(@`day` as datetime) + interval @`hour` hour) and best_record.RecordDateTime < <cache>(cast(@`day` as datetime) + interval @`hour` + 1 hour)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8 and dayofmonth(best_record.RecordDateTime) = 26"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(@`day` as datetime)) and best_record.RecordDateTime < <cache>(cast(@`day` as datetime) + interval 1 day)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and year(best_record.RecordDateTime) = 2026 and month(best_record.RecordDateTime) = 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(best_record.BestRecord) desc, best_record.PlayerID",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "best_record",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": ["MaestroID", "AppID"],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": ["MaestroID"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": ["AppID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10103,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "best_record.MaestroID = @maestro and best_record.AppID = @app and best_record.RecordDateTime >= <cache>(cast(date_format(@`day`,'%Y-%m-01') as datetime)) and best_record.RecordDateTime < <cache>(cast(date_format(@`day`,'%Y-%m-01') as datetime) + interval 1 month)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
baseline_explain_q1_hour_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q2_hour_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q3_day_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q4_day_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q5_month_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q6_month_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
day hour cnt
|
||||||
|
2026-08-26 9 22
|
||||||
|
2026-08-28 9 22
|
||||||
|
2026-08-26 10 21
|
||||||
|
2026-08-31 10 20
|
||||||
|
2026-08-25 10 19
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
=== 성능 개선 효과 (운영 DB, 2026-09-15) ===
|
||||||
|
측정 기준: params.sql (MaestroID 181, AppID 21, @day 2026-08-26, @hour 9)
|
||||||
|
측정 시점: 베이스라인 22:5x (인덱스 추가 전) / 개선 후 23:1x (인덱스 7개 추가 후)
|
||||||
|
|
||||||
|
| 쿼리 | access_type (전→후) | rows (전→후) | 서버 시간 ms (전→후, ANALYZE) | 결과 행 수 |
|
||||||
|
|--------------------|-----------------------|-----------------------|-------------------------------|-----------|
|
||||||
|
| q1 시간별 · 함수 | index_merge → ref | 10,103 → 5,851 (1.7배) | 74.61 → 2.07 (36배) | 22 = 22 |
|
||||||
|
| q2 시간별 · 범위 | index_merge → range | 10,103 → 22 (459배) | 75.58 → 0.26 (292배) | 22 = 22 |
|
||||||
|
| q3 일간 · 함수 | index_merge → ref | 10,103 → 5,851 (1.7배) | 75.29 → 2.44 (31배) | 61 = 61 |
|
||||||
|
| q4 일간 · 범위 | index_merge → range | 10,103 → 99 (102배) | 75.24 → 0.47 (161배) | 61 = 61 |
|
||||||
|
| q5 월간 · 함수 | index_merge → ref | 10,103 → 5,851 (1.7배) | 76.27 → 4.30 (18배) | 262 = 262 |
|
||||||
|
| q6 월간 · 범위 | index_merge → range | 10,103 → 709 (14배) | 79.70 → 2.50 (32배) | 262 = 262 |
|
||||||
|
|
||||||
|
사용 인덱스: 개선 후 6개 모두 idx_maestro_app_dt (MaestroID, AppID, RecordDateTime)
|
||||||
|
클라이언트 시간 (네트워크 포함, 2회차): 약 88~92ms → 13~18ms
|
||||||
|
|
||||||
|
참고 (스테이징): rows 5,804 → 1, cost 8.63 → 0.004
|
||||||
|
참고 (운영 01 사전 측정): 일간 랭킹 index_merge, rows 10,100
|
||||||
|
|
||||||
|
결과 행 수 동일 (4-3): [x] 예 [ ] 아니오
|
||||||
|
|
||||||
|
결론:
|
||||||
|
- 인덱스만의 효과 (q1·q3·q5, 함수형 조건):
|
||||||
|
rows 추정은 1.7배만 줄었지만 서버 처리 시간은 18~36배 단축.
|
||||||
|
기존에는 MaestroID·AppID 단일 인덱스 두 개의 교집합(index_merge)을 구하느라 느렸고,
|
||||||
|
복합 인덱스의 (MaestroID, AppID) 앞부분으로 바로 찾게(ref) 되었기 때문.
|
||||||
|
날짜 조건은 여전히 인덱스로 거르지 못해 해당 선생님·앱의 기록 약 5,851행을 확인함.
|
||||||
|
- 인덱스 + 쿼리 수정 효과 (q2·q4·q6, 범위 조건):
|
||||||
|
날짜까지 인덱스로 걸러(range) 조회 기간의 실제 기록만 읽음.
|
||||||
|
서버 처리 시간 32~292배 단축, 검사 행 수 14~459배 감소.
|
||||||
|
같은 조건에서 함수형 대비 추가로 1.7~9배 빠름 (q1→q2 8배, q3→q4 5배, q5→q6 1.7배).
|
||||||
|
|
||||||
|
중복 정리 (03-0):
|
||||||
|
- app_highest_record 8,795행 삭제 (4,823조합), 220,648행 = 220,648조합
|
||||||
|
- UNIQUE 키 2개 추가 완료 → 이후 중복 저장은 DB가 거부
|
||||||
|
|
||||||
|
Slow query log (04-5, 23:08 이후 1시간):
|
||||||
|
- 0.5초 이상은 작업 SQL 5건(ALTER 4건, 중복 DELETE 1건)뿐, 애플리케이션 쿼리 0건
|
||||||
|
- 새벽이 아닌 밤 시간이고 기간이 짧으므로 24시간 뒤 다시 확인 필요 (07단계)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dup_groups rows_to_delete
|
||||||
|
4823 8795
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
--------------
|
||||||
|
DELETE AHR FROM app_highest_record AHR
|
||||||
|
JOIN (
|
||||||
|
SELECT AppHighestRecordID FROM (
|
||||||
|
SELECT AppHighestRecordID,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY MaestroID, PlayerID, AppID
|
||||||
|
ORDER BY CASE WHEN AppID = 105 THEN HighestRecord ELSE -HighestRecord END,
|
||||||
|
AppHighestRecordID DESC
|
||||||
|
) AS rn
|
||||||
|
FROM app_highest_record
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
) dup ON dup.AppHighestRecordID = AHR.AppHighestRecordID
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Query OK, 8795 rows affected (0.942 sec)
|
||||||
|
|
||||||
|
--------------
|
||||||
|
SELECT 'app_highest_record' AS table_name,
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(DISTINCT MaestroID, PlayerID, AppID) AS unique_combos
|
||||||
|
FROM app_highest_record
|
||||||
|
--------------
|
||||||
|
|
||||||
|
+--------------------+------------+---------------+
|
||||||
|
| table_name | total_rows | unique_combos |
|
||||||
|
+--------------------+------------+---------------+
|
||||||
|
| app_highest_record | 220648 | 220648 |
|
||||||
|
+--------------------+------------+---------------+
|
||||||
|
1 row in set (0.190 sec)
|
||||||
|
|
||||||
|
--------------
|
||||||
|
SELECT 'typing_exam_highest_record' AS table_name,
|
||||||
|
COUNT(*) AS total_rows,
|
||||||
|
COUNT(DISTINCT MaestroID, PlayerID, WritingID) AS unique_combos
|
||||||
|
FROM typing_exam_highest_record
|
||||||
|
--------------
|
||||||
|
|
||||||
|
+----------------------------+------------+---------------+
|
||||||
|
| table_name | total_rows | unique_combos |
|
||||||
|
+----------------------------+------------+---------------+
|
||||||
|
| typing_exam_highest_record | 20413 | 20413 |
|
||||||
|
+----------------------------+------------+---------------+
|
||||||
|
1 row in set (0.024 sec)
|
||||||
|
|
||||||
|
Bye
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
baseline_explain_q1_hour_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q2_hour_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q3_day_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q4_day_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q5_month_func.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
baseline_explain_q6_month_range.json access_type=index_merge key=MaestroID ∩ AppID rows=10103
|
||||||
|
after_explain_q1_hour_func.json access_type=ref key=idx_maestro_app_dt rows=5851
|
||||||
|
after_explain_q2_hour_range.json access_type=range key=idx_maestro_app_dt rows=22
|
||||||
|
after_explain_q3_day_func.json access_type=ref key=idx_maestro_app_dt rows=5851
|
||||||
|
after_explain_q4_day_range.json access_type=range key=idx_maestro_app_dt rows=99
|
||||||
|
after_explain_q5_month_func.json access_type=ref key=idx_maestro_app_dt rows=5851
|
||||||
|
after_explain_q6_month_range.json access_type=range key=idx_maestro_app_dt rows=709
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SET @maestro = 181, @app = 21, @day = '2026-08-26', @hour = 9;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dup_groups rows_to_delete
|
||||||
|
4823 8795
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
MaestroID PlayerID AppID cnt
|
||||||
|
223 115830 2 33
|
||||||
|
306 93738 106 25
|
||||||
|
123 86825 106 21
|
||||||
|
82 2245 106 21
|
||||||
|
278 84908 106 20
|
||||||
|
49 114356 106 20
|
||||||
|
184 112221 101 19
|
||||||
|
127 71176 3 19
|
||||||
|
363 105234 106 18
|
||||||
|
75 10923 106 17
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"filesort": {
|
||||||
|
"sort_key": "max(BR.BestRecord) desc",
|
||||||
|
"temporary_table": {
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "BR",
|
||||||
|
"access_type": "index_merge",
|
||||||
|
"possible_keys": [
|
||||||
|
"MaestroID",
|
||||||
|
"AppID",
|
||||||
|
"PlayerID"
|
||||||
|
],
|
||||||
|
"key_length": "4,4",
|
||||||
|
"index_merge": {
|
||||||
|
"intersect": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "MaestroID",
|
||||||
|
"used_key_parts": [
|
||||||
|
"MaestroID"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"key": "AppID",
|
||||||
|
"used_key_parts": [
|
||||||
|
"AppID"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": 10100,
|
||||||
|
"filtered": 100,
|
||||||
|
"attached_condition": "BR.MaestroID = 181 and BR.AppID = 21 and year(BR.RecordDateTime) = 2026 and month(BR.RecordDateTime) = 9 and dayofmonth(BR.RecordDateTime) = 15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "U",
|
||||||
|
"access_type": "eq_ref",
|
||||||
|
"possible_keys": [
|
||||||
|
"PRIMARY"
|
||||||
|
],
|
||||||
|
"key": "PRIMARY",
|
||||||
|
"key_length": "4",
|
||||||
|
"used_key_parts": [
|
||||||
|
"PlayerID"
|
||||||
|
],
|
||||||
|
"ref": [
|
||||||
|
"chocomae.BR.PlayerID"
|
||||||
|
],
|
||||||
|
"rows": 1,
|
||||||
|
"filtered": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
ym cnt
|
||||||
|
2022-04 3
|
||||||
|
2022-05 3544
|
||||||
|
2022-06 7730
|
||||||
|
2022-07 7543
|
||||||
|
2022-08 4605
|
||||||
|
2022-09 4164
|
||||||
|
2022-10 4702
|
||||||
|
2022-11 4052
|
||||||
|
2022-12 2446
|
||||||
|
2023-01 3903
|
||||||
|
2023-02 1580
|
||||||
|
2023-03 18646
|
||||||
|
2023-04 22750
|
||||||
|
2023-05 19749
|
||||||
|
2023-06 18620
|
||||||
|
2023-07 16490
|
||||||
|
2023-08 13244
|
||||||
|
2023-09 17892
|
||||||
|
2023-10 19045
|
||||||
|
2023-11 23144
|
||||||
|
2023-12 17608
|
||||||
|
2024-01 15262
|
||||||
|
2024-02 6745
|
||||||
|
2024-03 19414
|
||||||
|
2024-04 27242
|
||||||
|
2024-05 25057
|
||||||
|
2024-06 21613
|
||||||
|
2024-07 23446
|
||||||
|
2024-08 17951
|
||||||
|
2024-09 21469
|
||||||
|
2024-10 22776
|
||||||
|
2024-11 22526
|
||||||
|
2024-12 19082
|
||||||
|
2025-01 12170
|
||||||
|
2025-02 8558
|
||||||
|
2025-03 26788
|
||||||
|
2025-04 30366
|
||||||
|
2025-05 29850
|
||||||
|
2025-06 32421
|
||||||
|
2025-07 28872
|
||||||
|
2025-08 19853
|
||||||
|
2025-09 33908
|
||||||
|
2025-10 22878
|
||||||
|
2025-11 28723
|
||||||
|
2025-12 28649
|
||||||
|
2026-01 16532
|
||||||
|
2026-02 8980
|
||||||
|
2026-03 66105
|
||||||
|
2026-04 70072
|
||||||
|
2026-05 58444
|
||||||
|
2026-06 65606
|
||||||
|
2026-07 56307
|
||||||
|
2026-08 51991
|
||||||
|
2026-09 39366
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Variable_name Value
|
||||||
|
log_output FILE
|
||||||
|
log_queries_not_using_indexes OFF
|
||||||
|
long_query_time 3.000000
|
||||||
|
slow_query_log ON
|
||||||
|
slow_query_log_file /var/log/mysql/mariadb-slow.log
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
=== 운영 DB 사전 측정 결과 (2026-09-15) ===
|
||||||
|
|
||||||
|
1. 테이블 크기 (premeasure_table_sizes.txt)
|
||||||
|
- best_record data_mb: 72.5
|
||||||
|
- best_record index_mb: 72.1
|
||||||
|
- best_record 행 수: 약 1,152,399 (information_schema 추정치) / 월별 합계 1,210,482
|
||||||
|
- typing_exam_record data_mb: 10.3
|
||||||
|
- typing_exam_record index_mb: 10.9
|
||||||
|
|
||||||
|
2. 월별 적재량 (premeasure_monthly_load.txt)
|
||||||
|
- 최근 1개월 (2026-08): 51,991 행
|
||||||
|
- 2026-09 (15일까지): 39,366 행
|
||||||
|
- 최근 3개월 평균 (2026-06~08): 57,968 행/월
|
||||||
|
- 전년 같은 기간 평균 (2025-06~08): 27,049 행/월 → 약 2.1배 증가
|
||||||
|
|
||||||
|
3. 테스트용 선생님·앱 (premeasure_top_maestro_app.txt)
|
||||||
|
- MaestroID: 181
|
||||||
|
- AppID: 21
|
||||||
|
- 30일 기록 수: 1,089
|
||||||
|
|
||||||
|
4. 일간 랭킹 EXPLAIN (premeasure_explain_daily_ranking.json)
|
||||||
|
- access_type: index_merge (예상했던 ALL 아님)
|
||||||
|
- key: MaestroID ∩ AppID (기존 단일 인덱스 2개의 교집합)
|
||||||
|
- rows: 10,100 (예상했던 1,206,768 아님)
|
||||||
|
- 날짜 조건(YEAR/MONTH/DAYOFMONTH)은 인덱스로 거르지 못하고 10,100행을 하나씩 확인
|
||||||
|
- 참고: 원본 출력은 헤더와 \n 이스케이프 때문에 JSON이 아니었음 → 내용 변경 없이 JSON으로 복원
|
||||||
|
|
||||||
|
5. 최고기록 테이블 중복 (premeasure_*_duplicates.txt, premeasure_app_highest_dup_count.txt)
|
||||||
|
- app_highest_record 중복: 4,823 조합 / 삭제 대상 8,795 행
|
||||||
|
· 테이블 약 229,459행(추정치)의 약 3.8%
|
||||||
|
· 상위 10개 조합은 각 17~33행
|
||||||
|
· 03-0-1에서 작업 당일 다시 세어 비교 (그 사이 조금 늘어날 수 있음)
|
||||||
|
- typing_exam_highest_record 중복: 0 건 (빈 파일 = 조회 결과 없음)
|
||||||
|
|
||||||
|
6. 계정 권한 (premeasure_grants.txt)
|
||||||
|
- jisangs@182.217.174.221: SELECT, ALTER ON chocomae.* 만 있음
|
||||||
|
- 2026-09-15 임시 부여 후 (premeasure_grants_after.txt):
|
||||||
|
+ SUPER ON *.*
|
||||||
|
+ INSERT, DELETE ON chocomae.app_highest_record, chocomae.typing_exam_highest_record
|
||||||
|
+ SELECT ON mysql.slow_log
|
||||||
|
→ 05단계에서 회수 (slow log 복원 후)
|
||||||
|
- 원래 권한으로 할 수 없던 작업:
|
||||||
|
· 02-1 slow query log 변경 (SET GLOBAL → SUPER)
|
||||||
|
· 03-0-3 중복 삭제 (DELETE)
|
||||||
|
· 04-5, 07 mysql.slow_log 조회 (SELECT ON mysql.slow_log)
|
||||||
|
· 05 slow query log 복원 (SET GLOBAL)
|
||||||
|
· 06 삭제 행 복원 (INSERT) → 임시 부여됨
|
||||||
|
· 06 ANALYZE TABLE best_record (best_record INSERT 필요, 부여 안 함) → 관리자 계정
|
||||||
|
|
||||||
|
7. Slow query log 원래 설정 (premeasure_slow_log_settings.txt) — 05단계 복원 기준값
|
||||||
|
- slow_query_log: ON
|
||||||
|
- long_query_time: 3
|
||||||
|
- log_output: FILE
|
||||||
|
- log_queries_not_using_indexes: OFF
|
||||||
|
- slow_query_log_file: /var/log/mysql/mariadb-slow.log
|
||||||
|
- 이전 문서의 1-4 SET GLOBAL은 적용되지 않았음 (SUPER 권한 없음, 적용됐다면 long_query_time=1·log_output=TABLE)
|
||||||
|
|
||||||
|
8. 준비 확인
|
||||||
|
☑ 테이블 크기 확인
|
||||||
|
☑ 월별 적재량 확인
|
||||||
|
☑ EXPLAIN 결과 저장
|
||||||
|
☑ 중복 확인 (전체 개수 포함)
|
||||||
|
☑ 계정 권한 확인
|
||||||
|
☑ Slow query log 현재 설정 저장
|
||||||
|
☑ 작업 권한 임시 부여
|
||||||
|
|
||||||
|
다음: 02-baseline.md
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
TABLE_NAME approx_rows data_mb index_mb
|
||||||
|
best_record 1152399 72.5 72.1
|
||||||
|
app_highest_record 229459 16.5 15.8
|
||||||
|
typing_exam_record 131675 10.3 10.9
|
||||||
|
player 22394 3.5 0.4
|
||||||
|
active_app 17661 1.5 0.6
|
||||||
|
typing_exam_highest_record 21355 1.4 1.2
|
||||||
|
maestro_log 1944 0.4 0.0
|
||||||
|
maestro 418 0.1 0.0
|
||||||
|
license_score 514 0.1 0.0
|
||||||
|
license_maestro_password 1024 0.1 0.0
|
||||||
|
maestro_extension 450 0.0 0.0
|
||||||
|
admin 0 0.0 0.0
|
||||||
|
app 48 0.0 0.0
|
||||||
|
ads_client 0 0.0 0.0
|
||||||
|
license_time 0 0.0 0.0
|
||||||
|
writing 20 0.0 0.0
|
||||||
|
typing_exam_ads 0 0.0 0.0
|
||||||
|
banned_word 22 0.0 0.0
|
||||||
|
maestro_upgrade 109 0.0 0.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
MaestroID AppID cnt
|
||||||
|
181 21 1089
|
||||||
|
181 1 591
|
||||||
|
389 103 562
|
||||||
|
230 21 546
|
||||||
|
389 104 541
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND DATE(RecordDateTime) = @day
|
||||||
|
AND HOUR(RecordDateTime) = @hour
|
||||||
|
ORDER BY RecordDateTime, PlayerID;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, BestRecord, RecordDateTime
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(@day AS DATETIME) + INTERVAL @hour HOUR
|
||||||
|
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL (@hour + 1) HOUR
|
||||||
|
ORDER BY RecordDateTime, PlayerID;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND YEAR(RecordDateTime) = YEAR(@day)
|
||||||
|
AND MONTH(RecordDateTime) = MONTH(@day)
|
||||||
|
AND DAYOFMONTH(RecordDateTime) = DAYOFMONTH(@day)
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(@day AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(@day AS DATETIME) + INTERVAL 1 DAY
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND YEAR(RecordDateTime) = YEAR(@day)
|
||||||
|
AND MONTH(RecordDateTime) = MONTH(@day)
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
SELECT SQL_NO_CACHE PlayerID, MAX(BestRecord) AS HighScore
|
||||||
|
FROM best_record
|
||||||
|
WHERE MaestroID = @maestro AND AppID = @app
|
||||||
|
AND RecordDateTime >= CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME)
|
||||||
|
AND RecordDateTime < CAST(DATE_FORMAT(@day, '%Y-%m-01') AS DATETIME) + INTERVAL 1 MONTH
|
||||||
|
GROUP BY PlayerID
|
||||||
|
ORDER BY HighScore DESC, PlayerID;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# 02·04단계 공통 측정 스크립트
|
||||||
|
# 사용법: zsh run_measure.sh baseline (02단계, 인덱스 추가 전)
|
||||||
|
# zsh run_measure.sh after (04단계, 인덱스 추가 후)
|
||||||
|
|
||||||
|
PREFIX=$1
|
||||||
|
if [[ $PREFIX != baseline && $PREFIX != after ]]; then
|
||||||
|
echo "사용법: zsh run_measure.sh baseline|after" >&2; exit 1
|
||||||
|
fi
|
||||||
|
cd "${0:A:h}" || exit 1
|
||||||
|
|
||||||
|
printf "Enter password: "; read -rs DBPW; echo
|
||||||
|
|
||||||
|
db() {
|
||||||
|
mariadb --defaults-extra-file=<(printf '[client]\npassword="%s"\n' "$DBPW") \
|
||||||
|
-h chocomae.jinaju.com -u jisangs chocomae "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) EXPLAIN: 쿼리마다 JSON 파일 1개
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
out="${PREFIX}_explain_${q:t:r}.json"
|
||||||
|
{ cat params.sql; printf 'EXPLAIN FORMAT=JSON '; cat "$q"; } | db -N -r > "$out" \
|
||||||
|
|| { echo "EXPLAIN 실패: $q" >&2; exit 1; }
|
||||||
|
echo "저장: $out"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2) 실행 시간: 같은 쿼리 묶음을 2회 실행 (2회차 값 사용)
|
||||||
|
for round in 1 2; do
|
||||||
|
{ cat params.sql
|
||||||
|
for q in queries/*.sql; do
|
||||||
|
echo "SELECT '=== round ${round}: ${q:t:r} ===' AS msg;"
|
||||||
|
cat "$q"
|
||||||
|
done
|
||||||
|
} | db -vvv || { echo "실행 시간 측정 실패" >&2; exit 1; }
|
||||||
|
done > "${PREFIX}_perf.txt"
|
||||||
|
echo "저장: ${PREFIX}_perf.txt"
|
||||||
|
|
||||||
|
# 3) 요약: 쿼리 이름 / 결과 행 수 / 실행 시간 (2회차)
|
||||||
|
echo
|
||||||
|
grep -E '^\| === |rows? in set|Empty set' "${PREFIX}_perf.txt" | paste - - - | grep 'round 2' \
|
||||||
|
| sed -E 's/\| === round 2: (.*) === \|/\1/; s/\t1 row in set \([0-9.]+ sec\)//'
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 사용법: python3 summarize_analyze.py baseline [after]
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
for prefix in sys.argv[1:]:
|
||||||
|
text = open(f"{prefix}_analyze.txt", encoding="utf-8").read()
|
||||||
|
names = [line.split("=== ")[1].split(" ===")[0] for line in text.splitlines() if line.startswith("=== ")]
|
||||||
|
docs, pos = [], 0
|
||||||
|
while True:
|
||||||
|
start = text.find("{", pos)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
doc, pos = decoder.raw_decode(text, start)
|
||||||
|
docs.append(doc)
|
||||||
|
for name, doc in zip(names, docs):
|
||||||
|
block = doc["query_block"]
|
||||||
|
print(f"{prefix:8} {name:16} server_time_ms={block.get('r_total_time_ms')}")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 사용법: python3 summarize_explain.py baseline [after]
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def find_table(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
if isinstance(node.get("table"), dict):
|
||||||
|
return node["table"]
|
||||||
|
children = node.values()
|
||||||
|
elif isinstance(node, list):
|
||||||
|
children = node
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
for child in children:
|
||||||
|
found = find_table(child)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def keys_of(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for k, v in node.items():
|
||||||
|
if k == "key":
|
||||||
|
yield v
|
||||||
|
else:
|
||||||
|
yield from keys_of(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for v in node:
|
||||||
|
yield from keys_of(v)
|
||||||
|
|
||||||
|
|
||||||
|
for prefix in sys.argv[1:]:
|
||||||
|
for path in sorted(glob.glob(f"{prefix}_explain_*.json")):
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
table = find_table(json.load(f))
|
||||||
|
key = table.get("key") or " ∩ ".join(keys_of(table.get("index_merge", {}))) or "-"
|
||||||
|
print(f"{path:40} access_type={str(table.get('access_type')):12} key={key:28} rows={table.get('rows')}")
|
||||||
Reference in New Issue
Block a user