PostgreSQL 지표 수집
PostgreSQL 패널별 통계와 함수, psycopg 부하 발생기 및 긴 SQL의 누적 통계 반영 지연
#핵심 요약
실습 미니 PG 게이지는 Oracle판과 같은 구조로, 통계 뷰를 3초마다 조회해 화면에 그림
초당 값(Transactions/s, Rows Read/s, Disk Blocks Read/s)은 pg_stat_database 누적값의 3초 전 값과의 차이를 경과 초로 나눠 구함
PostgreSQL은 pg_stat_database 숫자를 SQL이 끝날 때 반영함
몇 분째 도는 긴 SQL은 끝날 때까지 그래프에 보이지 않으므로, 긴 SQL은 경보와 세션 표로 잡음
실습 부하 결과: 평상시 초당 트랜잭션 약 590, 풀스캔 부하에서 행 읽기 약 2,900만/s, 디스크 블록 읽기 약 29만/s, 캐시 적중률 45%
#개념과 동작 원리
| 패널 | 읽는 뷰, 함수 | 계산과 읽는 법 |
|---|---|---|
| KPI 카드 | pg_stat_database 누적값 |
3초 전 값과의 차이 ÷ 경과 초 |
| Connections | pg_stat_activity ÷ max_connections |
최대 접속의 몇 %인가 |
| Buffer Cache Hit | blks_hit ÷ (blks_hit + blks_read) |
shared_buffers에서 바로 찾은 비율, 90% 미만이면 빨강 |
| Running SQL Elapsed Time | pg_stat_activity.query_start |
3초마다 실행 중인 SQL을 점으로, 10초 넘으면 분홍 |
| Server Log | pg_ls_logdir(), pg_read_file() |
최신 로그 파일 끝 64KB에서 ERROR, FATAL, deadlock 줄 |
| Wait Event Type | pg_stat_activity.wait_event_type |
비어 있으면 CPU, IO는 디스크, Lock은 락 대기 |
| Database Size / Seq Scan Tables | pg_database_size() / pg_stat_user_tables |
풀스캔으로 많이 읽힌 테이블 순위 |
패널 SQL 본문과 수집 함수 이름은 실습 텍스트에 없음
전용 계정의 권한 구성은 모니터링 연결과 계정에 있음
#필수 명령어 또는 SQL
#실행 파일
set MON_HOST=<HOST>
set MON_PORT=5432
set MON_DB=postgres
set MON_USER=<MONITOR_USER>
set MON_PASSWORD=<PASSWORD>
set MON_WATCH_DB=<LOAD_USER>
python -m streamlit run app.py --server.port 8501결과 해석: MON_* 환경 변수가 사이드바 기본값이 되고, 비밀번호까지 들어 있어 띄우면 바로 연결됨
실습 확인 결과는 사이드바 입력 없이 PostgreSQL 15.19 연결임
Anaconda를 찾아 켜는 방법은 Oracle과 PostgreSQL 연결에서 다룸
#부하 발생기(load.py 일부)
ARGS = dict(host=os.getenv("PG_HOST", "<HOST>"), port=int(os.getenv("PG_PORT", "5432")),
dbname="<LOAD_USER>", user="<LOAD_USER>", password=os.getenv("PG_PASSWORD", "<PASSWORD>"))
stop = threading.Event()
def oltp():
"""인덱스로 1건 조회 + 1건 입력·커밋 반복"""
with psycopg.connect(application_name="load_oltp", autocommit=True, **ARGS) as c:
while not stop.is_set():
c.execute("select amount from big_sales where id = %s", (random.randint(1, 2_000_000),)).fetchone()
c.execute("insert into commit_log(id) values (%s)", (random.randint(1, 1000),))
time.sleep(0.01)
def full_scan():
"""인덱스 없는 조건으로 200만 건 풀스캔"""
with psycopg.connect(application_name="load_scan", autocommit=True, **ARGS) as c:
while not stop.is_set():
c.execute("select count(*), avg(amount) from big_sales where filler like %s",
(f"%{random.randint(0, 9)}%",)).fetchone()
def main():
heavy = len(sys.argv) > 1 and sys.argv[1] == "heavy"
workers = [threading.Thread(target=oltp, daemon=True) for _ in range(4)]
if heavy:
workers += [threading.Thread(target=full_scan, daemon=True) for _ in range(2)]
for w in workers:
w.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
stop.set()결과 해석:
읽는 법: 스레드 하나가 연결 하나를 열고, threading.Event로 모든 스레드를 한 번에 멈춤autocommit=True라 INSERT마다 커밋됨%s는 psycopg의 바인드 자리표시라 값을 SQL 문자열에 직접 붙이지 않음
python load.py
python load.py heavy결과 해석: 평상시는 세션 4개, heavy는 풀스캔 세션 2개가 더해져 6개임
창에는 시작 한 줄만 나오고 결과는 대시보드에서 봄
Ctrl+C로 멈춤
| 부하 | 실습 대시보드 결과 |
|---|---|
| 평상시 | 초당 트랜잭션 약 590, 행 읽기 수천/s |
| 풀스캔 | 행 읽기 약 2,900만/s, 디스크 블록 읽기 약 29만/s, 캐시 적중률 45%(빨강), 대기는 CPU와 IO |
#다른 세션이 보이는지(check_monitor.py 핵심)
rows = mon.execute("""select pid, usename, datname, application_name, state, left(query, 40)
from pg_stat_activity
where backend_type = 'client backend'
order by pid""").fetchall()
name, size = mon.execute("select name, size from pg_ls_logdir() order by modification desc limit 1").fetchone()결과 해석: 부하 계정 세션의 QUERY가 실제 SQL로 보이고 최근 로그 파일 이름과 크기가 나오면 됨
로그 파일 이름은 요일별이라 날마다 다름
#악성 SQL 5종 탐지와 세션 종료
악성 판정 기본값은 실행 10초 이상, 트랜잭션 방치 30초 이상, 다른 세션 블로킹이며, 대시보드가 경보 → SQL 전문, 실행계획 → 진단 → pg_terminate_backend 명령까지 보여 주는지 다섯 시나리오로 확인함
\timing on
-- 1 카테시안 곱
select count(*) from big_sales a, big_sales b where a.amount < 50000;
-- 2 상관 서브쿼리 반복
select count(*) from big_sales s
where s.amount > (select avg(x.amount) from big_sales x where x.cust_id = s.cust_id);
-- 3 인덱스 컬럼 가공(DO 블록 안 1000번 반복)
do $$ declare n int; begin
for i in 1..1000 loop
select count(*) into n from big_sales where to_char(sale_date, 'YYYYMMDD') = '20240101';
end loop; end $$;
-- 4 락 걸고 방치: 창 A에서 begin; update hot_row set cnt = cnt + 1 where id = 1; (커밋 안 함)
-- 창 B·C에서 같은 update 실행 → 대기
-- 5 PL/pgSQL 무한 루프
do $$ declare x bigint := 0; begin loop x := x + 1; end loop; end $$;select pid, usename, state, wait_event_type, wait_event from pg_stat_activity where pid = <PID>;
select pg_terminate_backend(<PID>);
select pg_sleep(1);
select pid from pg_stat_activity where pid = <PID>;결과 해석:
| 시나리오 | 실행계획, 진단 | 실습 기록 |
|---|---|---|
| 1 카테시안 곱 | 조인 조건 없는 Nested Loop(빨강), Seq Scan | 종료까지 약 1분 38초 |
| 2 상관 서브쿼리 | Aggregate(SubPlan 1)와 Seq Scan 두 번, 대기 IO: DataFileRead |
약 1분 36초 |
| 3 인덱스 컬럼 가공 | DO 블록 안 SELECT를 꺼내 만든 계획, to_char 조건 |
약 1분 17초 |
| 4 락 방치 | 보유 세션만 경보, 대기 2개는 피해 세션 표시, 실행계획 대신 기다리는 세션 목록 | 보유 세션 idle in transaction, 끊은 뒤 hot_row.cnt가 2 |
| 5 무한 루프 | 실행계획 없음, 대기 이벤트도 없이 CPU만 씀 | 약 1분 21초 |
종료 전에 pg_stat_activity로 PID가 정말 loadtest의 악성 세션인지 확인하고, 끊은 뒤 1초 쉬었다가 (0개 행)으로 사라졌는지 봄
끊긴 쪽 psql에는 FATAL: terminating connection due to administrator command가 찍히고 psql이 다시 접속함idle in transaction 세션은 끊겨도 바로 모르고 다음 문장을 보낼 때 알게 됨
락 시나리오의 cnt = 2는 끊긴 창 A의 UPDATE가 롤백되고 창 B, C의 UPDATE만 반영됐다는 뜻임
실무에서는 끊기 전에 그 변경을 버려도 되는지 업무 담당자에게 확인함
같은 행을 기다리는 세션은 A ← B ← C처럼 줄을 서고 pg_blocking_pids()는 바로 앞 세션만 알려 주므로, 수집기는 줄 맨 앞까지 따라가 원인 세션에 대기 수를 몰아 줌
monitor는 읽기 전용이라 UPDATE, DELETE는 EXPLAIN할 수 없고, DO 블록은 통째로 EXPLAIN할 수 없어 블록 안 첫 SELECT에서 into를 빼고 계획을 만듦
pg_cancel_backend(PID)는 실행 중인 SQL만 취소하고 세션은 남기며, pg_terminate_backend(PID)는 세션을 끊고 열린 트랜잭션을 롤백함
psql 창을 여러 개 띄우기 어려우면 PC에서 python bad_sql.py 1~`5`로 같은 상황을 만듦(4번은 보유 1 + 대기 2)
#Oracle판과 PostgreSQL판 비교
| 항목 | Oracle 19c | PostgreSQL 15 |
|---|---|---|
| 접속 라이브러리 | python-oracledb | psycopg 3 |
| 모니터링 계정 권한 | SELECT_CATALOG_ROLE, SELECT ANY DICTIONARY | pg_monitor, pg_read_all_data |
| 세션, 대기 | V$SESSION(event, wait_class) | pg_stat_activity(wait_event_type, wait_event) |
| 블로킹 | V$SESSION.BLOCKING_SESSION | pg_blocking_pids(pid), 줄 선 대기자는 바로 앞 세션을 가리킴 |
| 초당 지표 | V$SYSSTAT, V$OSSTAT(CPU, 메모리) | pg_stat_database(OS CPU, 메모리는 SQL로 못 봄) |
| SQL 통계 | V$SQLSTATS | pg_stat_statements는 재시작 필요해 실행 중 SQL을 3초마다 관측 |
| 에러 로그 | V$DIAG_ALERT_EXT | pg_ls_logdir()와 pg_read_file() |
| 실행계획 | V$SQL_PLAN(실제 커서 계획) | EXPLAIN (FORMAT JSON)으로 다시 만듦, SELECT만 |
| 세션 종료 | ALTER SYSTEM KILL SESSION 'sid,serial#' IMMEDIATE | SELECT pg_terminate_backend(pid) |
도구 파일은 collector.py(패널 SQL, 수집기, 악성 판정, 종료 명령), app.py(화면), run_monitor.bat, load.py, bad_sql.py, sql/01_create_monitor_user.sql, sql/02_create_load_schema.sql 등으로 나뉨
검증 중 big_sales에 id 기본 키를 추가해 평상시 부하(id로 1건 조회)가 풀 스캔이 되지 않게 고쳤음
#사용 조건과 주의사항
실습에서는 캐시 적중률이 평상시에도 90% 근처인 이유를, 큰 테이블(341MB)이 shared_buffers(128MB)보다 커서 임의의 id를 찾을 때도 디스크에서 읽기 때문이라고 설명하고, 운영 DB라면 shared_buffers 증설을 검토할 신호라고 적었음
캐시 적중률만으로 운영 DB의 메모리 증설을 판단할 수 없음
메모장으로 bat 파일을 만들 때 줄바꿈이 LF면 cmd가 한글 줄을 잘못 나눠 명령으로 실행함
CRLF로 저장하고 맨 위에 chcp 65001을 둠
악성 SQL 탐지의 Oracle판은 문제 SQL 탐지와 세션 진단, PostgreSQL 세션 종료 일반은 세션 종료와 영향에 있음