Zettelkasten

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_digestSUM_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=NULL catch-all 행으로 뭉친다. 매 요청 도는 쿼리(auth 토큰 조회 등)의 COUNT_STAR가 작게 보이는 건, 테이블이 반복적으로 차서 auto-truncate된 직후를 보고 있을 가능성이 크다. 그래서 절대값이 아니라 랭킹으로만 읽어야 한다는 실무 결론은 유효하지만, 근본 처방은 "수 분 윈도우로 간주"가 아니라 performance_schema_digests_size를 키워 포화를 푸는 것이다.
  • digest 슬롯(performance_schema_digests_size, 문서상 기본값은 -1=시작 시 autosize, 흔히 1만 선)이 차면 새 digest는 DIGEST=NULL catch-all 행으로 뭉치고, instrument 자체가 안 된 건 Performance_schema_digest_lost로 카운트된다.
  • SUM_ROWS_SENT는 행 수지 셀 수가 아니다. 컬럼이 좁은 쿼리(예: id 2~3개 컬럼)는 rows_sent가 커도 셀 디코드 비용이 상대적으로 작다. 컬럼 폭을 같이 봐야 한다.
  • 앱 DB 계정에 performance_schema SELECT 권한이 있으면 VPC 내부 컨테이너에서 read-only 쿼리 한 방으로 끝난다. 별도 인프라 작업 불필요.

관련 노트

참고