pymysql 파싱 CPU의 원인 쿼리는 performance_schema digest의 SUM_ROWS_SENT 랭킹으로 특정한다
·수정 2026.06.17·수정 3회
요약
- py-spy self-time(leaf 프레임)은 "pymysql 파싱이 CPU를 먹는다"까지만 알려준다. 스택에 SQL이 없어서 어느 쿼리 때문인지는 못 알려준다.
- 그 다음 단계는
performance_schema.events_statements_summary_by_digest를SUM_ROWS_SENT내림차순으로 뽑는 것. 파싱 비용은 전송된 행 수에 비례하므로, rows_sent 랭킹 = 파싱 CPU 공급원 랭킹이다.
본문
1단계: py-spy로 레이어 특정
gunicorn+gevent 워커에 py-spy record --pid <worker> --rate 100 --format raw로 샘플을 뜨고, folded 스택의 마지막 프레임(leaf) 을 집계하면 self-time 랭킹이 나온다. 순수 파이썬 드라이버(PyMySQL)를 쓰는 앱이라면 read_length_coded_string(protocol.py), _read_row_from_packet(connections.py), convert_datetime(converters.py) 같은 pymysql 프레임이 상위에 찍힌다.
gevent 환경에서 이 해석이 유효한 이유: I/O 대기 중인 greenlet은 suspend되어 스레드의 현재 스택에 보이지 않는다(스레드 스택은 hub로 넘어감). 따라서 샘플에 잡힌 pymysql 프레임은 DB 응답 대기가 아니라 진짜 파싱 CPU다.
단, py-spy leaf로는 여기까지다. 스택 프레임은 함수 위치만 담고 실행 중인 SQL 텍스트를 담지 않는다.
2단계: digest 테이블로 쿼리 특정
SELECT LEFT(DIGEST_TEXT, 100),
COUNT_STAR AS calls,
SUM_ROWS_SENT AS rows_sent,
ROUND(SUM_ROWS_SENT / COUNT_STAR, 1) AS rows_per_call
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = '<app_db>'
ORDER BY SUM_ROWS_SENT DESC
LIMIT 25;
파싱 CPU는 대략 세 항으로 분해되고, digest 컬럼이 각각을 가리킨다:
| 비용 항 | py-spy leaf 증거 | 비례 대상 | digest에서 보는 법 |
|---|---|---|---|
| 행/셀 디코드 | read_length_coded_string, _read_row_from_packet |
rows × columns | SUM_ROWS_SENT 큰 쿼리 |
| datetime 변환 | convert_datetime |
datetime 셀 수 | rows_sent 큰 쿼리 중 datetime 컬럼 많은 것 |
| 쿼리당 고정비 | description(컬럼 메타 파싱), escape |
쿼리 횟수 | COUNT_STAR 큰 쿼리 (N+1 신호) |
rows_per_call이 진단 키다: 수백 rows/call이면 "이력 전체 스캔" 류의 설계 문제(기간 제한·컬럼 축소·캐시로 해결), 1 row/call인데 COUNT_STAR가 폭발하면 N+1(prefetch로 해결). 같은 "파싱 CPU"라도 fix가 완전히 다르다.
함정들
- RDS에서 digest 테이블 값을 누적치로 신뢰하지 마라 — 단, 메커니즘은 "주기적 truncate"가 아니라 "테이블 포화"다. AWS 문서상 digest 테이블엔 eviction policy가 없고, Performance Insights는 테이블이 가득 찼고 + PI가 Performance Schema를 자동 관리할 때만 auto-truncate한다(시간 기반 주기 없음).
Performance_schema_digest_lost가 수백만이면 윈도우잉 신호가 아니라 **digest 슬롯이 꽉 찼다(performance_schema_digests_size부족)**는 신호이고, 넘친 통계는DIGEST=NULLcatch-all 행으로 뭉친다. 매 요청 도는 쿼리(auth 토큰 조회 등)의 COUNT_STAR가 작게 보이는 건, 테이블이 반복적으로 차서 auto-truncate된 직후를 보고 있을 가능성이 크다. 그래서 절대값이 아니라 랭킹으로만 읽어야 한다는 실무 결론은 유효하지만, 근본 처방은 "수 분 윈도우로 간주"가 아니라performance_schema_digests_size를 키워 포화를 푸는 것이다. - digest 슬롯(
performance_schema_digests_size, 문서상 기본값은-1=시작 시 autosize, 흔히 1만 선)이 차면 새 digest는DIGEST=NULLcatch-all 행으로 뭉치고, instrument 자체가 안 된 건Performance_schema_digest_lost로 카운트된다. SUM_ROWS_SENT는 행 수지 셀 수가 아니다. 컬럼이 좁은 쿼리(예: id 2~3개 컬럼)는 rows_sent가 커도 셀 디코드 비용이 상대적으로 작다. 컬럼 폭을 같이 봐야 한다.- 앱 DB 계정에 performance_schema SELECT 권한이 있으면 VPC 내부 컨테이너에서 read-only 쿼리 한 방으로 끝난다. 별도 인프라 작업 불필요.
관련 노트
- N+1 쿼리 해결은 쿼리당 고정비용만 회수하고 행당 파싱 비용은 줄이지 않는다
- Django 느린 API의 CPU 병목은 DB가 아니라 직렬화 계층인 경우가 많다
- pt-query-digest로 슬로우 쿼리에 대한 정보를 자세히 볼 수 있다.
- 변수 하나만 바꾼 카나리를 같은 타깃그룹에 동시 투입하면 부하 교란 없이 회귀 원인을 격리한다