명령어/DB

[PostgreSQL] 손상 의심 릴레이션 부검 — amcheck로 발견하고 pageinspect + pg_visibility로 규명

jykim23 2026. 7. 21. 21:55
반응형

설치·접속: PostgreSQL 설치와 접속

부제: 인덱스가 논리적으로 깨진 것 같을 때, REINDEX로 무작정 재구축하기 전에 손상을 확진하는 절차

데이터 체크섬은 물리적 비트 손상만 잡는다. 인덱스-힙 불일치, 누락 다운링크, VM 비트 어긋남 같은 "논리적" 손상은 체크섬을 통과해도 잘못된 SELECT 결과를 낼 수 있다. amcheck로 손상 여부를 판정하고, pg_visibility로 visibility map 일치를 확인하고, pageinspect로 구조를 눈으로 확인하는 확진 절차를 만든다.

문제 상황

인덱스가 있는 테이블. index-only scan 결과가 이상하거나 특정 행이 인덱스에서 안 걸리는 것 같아 손상을 의심하는 상황이다.

CREATE TABLE chk (id int primary key, email text);
INSERT INTO chk SELECT g, 'user'||g||'@ex.com' FROM generate_series(1, 20000) g;
CREATE INDEX chk_email_idx ON chk (email);
VACUUM (ANALYZE) chk;

1단계 — amcheck로 인덱스 논리 검증 (운영 중 실행 가능)

CREATE EXTENSION IF NOT EXISTS amcheck;
-- heapallindexed=true: 힙의 모든 튜플이 인덱스에도 있는지까지 교차 검증
SELECT bt_index_check(index => 'chk_pkey'::regclass,      heapallindexed => true) AS pkey_check;
SELECT bt_index_check(index => 'chk_email_idx'::regclass, heapallindexed => true) AS email_idx_check;
 pkey_check
------------

(1 row)

 email_idx_check
-----------------

(1 row)

bt_index_check는 정상이면 아무것도(NULL) 반환하고 조용히 끝난다. 오류를 던지면 그것은 100% 진짜 손상이다(false positive 없음). heapallindexed => true를 주면 B-tree 내부 정합성뿐 아니라 "힙에 있는 모든 튜플이 인덱스에 존재하는지"까지 대조한다 — 인덱스에서 행이 누락되는 유형의 손상을 여기서 잡는다. 중요한 건 이 함수가 AccessShareLock(SELECT와 같은 수준)만 잡는다는 점이다. 쓰기를 막지 않으므로 운영 트래픽 중에도 돌릴 수 있다.

2단계 — verify_heapam으로 힙까지 검사

-- 손상 행이 있으면 (blkno, offnum, attnum, msg)로 한 줄씩 반환. 0행 = 정상.
SELECT count(*) AS corruption_rows FROM verify_heapam('chk');
 corruption_rows
-----------------
               0

인덱스가 깨끗해도 힙 자체가 손상됐을 수 있다. verify_heapam은 힙 페이지를 스캔해 튜플 헤더의 xmin/xmax 정합성, TOAST 포인터 유효성, 라인 포인터 범위 등을 검사한다. 0행이면 정상이다. 손상이 있으면 아래 5단계에서 보듯 위치와 이유가 한 줄씩 나온다.

3단계 — pg_visibility로 visibility map 일치 확인

CREATE EXTENSION IF NOT EXISTS pg_visibility;
SELECT * FROM pg_visibility_map_summary('chk');
 all_visible | all_frozen
-------------+------------
         128 |          0
-- all-visible이라 표시됐는데 실제로는 안 보이는 튜플의 TID를 반환. 0행 = 일치.
SELECT count(*) AS vm_mismatch   FROM pg_check_visible('chk');
SELECT count(*) AS frozen_mismatch FROM pg_check_frozen('chk');
 vm_mismatch
-------------
           0

 frozen_mismatch
-----------------
               0

VM이 깨지면 index-only scan이 힙을 확인하지 않고 잘못된 결과를 줄 수 있고, VACUUM이 페이지를 잘못 건너뛴다. pg_visibility_map_summary는 VM에서 all-visible로 표시된 페이지가 128개임을 보여준다(VACUUM 후 전 페이지가 all-visible). pg_check_visible는 "VM은 all-visible이라는데 실제로는 모든 튜플이 보이지 않는" 불일치 페이지의 TID를 콕 집어준다. 0행이면 VM과 힙 상태가 일치한다.

4단계 — pageinspect로 구조 확인

CREATE EXTENSION IF NOT EXISTS pageinspect;
-- B-tree 메타페이지: magic/version과 루트 위치·트리 높이
SELECT magic, version, root, level, fastroot, fastlevel
FROM bt_metap('chk_pkey');
 magic  | version | root | level | fastroot | fastlevel
--------+---------+------+-------+----------+-----------
 340322 |       4 |    3 |     1 |        3 |         1
-- 힙 페이지 0: 살아있는 튜플은 t_xmax=0, xmin은 commit됨
SELECT lp, t_xmin, t_xmax, t_ctid,
       (t_infomask & 256) <> 0 AS xmin_committed
FROM heap_page_items(get_raw_page('chk', 0))
ORDER BY lp LIMIT 5;
 lp | t_xmin | t_xmax | t_ctid | xmin_committed
----+--------+--------+--------+----------------
  1 |   1243 |      0 | (0,1)  | t
  2 |   1243 |      0 | (0,2)  | t
  3 |   1243 |      0 | (0,3)  | t
  4 |   1243 |      0 | (0,4)  | t
  5 |   1243 |      0 | (0,5)  | t

bt_metapmagic=340322는 유효한 B-tree 메타페이지 매직 넘버다(값이 다르면 메타페이지 자체가 깨진 것). version 4, level 1은 정상적인 2레벨 트리 구조를 뜻한다. 힙 페이지 0의 튜플들은 모두 t_xmax=0(삭제 안 됨), t_ctid가 자기 자신을 가리키고((0,1)→lp 1), xmin_committed=t(infomask의 HEAP_XMIN_COMMITTED 비트) — 전부 정상적으로 커밋된 살아있는 튜플이다.

5단계 — 손상이면 무엇이 어떻게 뜨나

위 검사는 전부 "정상"으로 나왔다. 만약 손상이 있었다면 verify_heapam은 아래 컬럼 구조로 손상을 하나씩 지목한다.

SELECT blkno, offnum, attnum, msg FROM verify_heapam('chk') LIMIT 3;
 blkno | offnum | attnum | msg
-------+--------+--------+-----
(0 rows)

정상이라 0행이지만, 손상 시엔 blkno(손상 블록), offnum(라인 포인터), attnum(문제 컬럼), msg(예: xmin 12345 precedes relfrozenxid 20000, data begins at offset ... beyond block size 같은 진단 메시지)가 채워진다. bt_index_check는 반환값 대신 ERROR: heap tuple (X,Y) from table ... lacks matching index tuple within index ... 형태로 예외를 던진다. pg_check_visible은 불일치 튜플의 TID를 행으로 돌려준다. 이 위치 정보를 heap_page_items(get_raw_page('chk', <blkno>))에 넣어 해당 페이지 튜플 헤더를 부검하면 xmin/xmax가 왜 어긋났는지 원인을 좁힌다.

결론 — 언제 이 조합을 쓰나

"REINDEX 한 번 돌리면 되겠지"로 넘어가기 전에, 진짜 손상인지·어디가 손상인지를 확진하는 절차다. amcheck(bt_index_check + verify_heapam)로 손상을 발견하고(SELECT 수준 락이라 운영 중 가능), pg_visibility로 VM 일관성을 확인하고, pageinspect(bt_metap, heap_page_items)로 메타페이지·튜플 헤더를 눈으로 검증한다. amcheck가 오류를 내면 그건 100% 진짜 손상이니, 그때 비로소 REINDEX(인덱스 손상)나 백업 복구·pg_dump 후 재적재(힙 손상)를 결정한다. 검사 없이 무작정 REINDEX하면 원인을 못 없앤 채 증상만 덮을 수 있다.

이렇게도 쓴다

부모 레벨 다운링크까지 더 엄격하게 검사한다. 대신 ShareLock이라 쓰기를 막는다. (조합: bt_index_parent_check)

-- ShareLock: 운영 쓰기 차단 + 스탠바이 불가. 유지보수 창에서.
SELECT bt_index_parent_check(index => 'chk_pkey'::regclass, heapallindexed => true);

 

DB 전체 인덱스를 한 번에 훑어 손상을 찾는다. (조합: 인덱스 순회 크론)

SELECT c.relname, bt_index_check(c.oid, true)
FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid
WHERE c.relkind = 'i' AND i.indisready AND c.relam = 403;  -- btree

 

VM이 실제로 깨졌을 때 재구축한다. (조합: pg_truncate_visibility_map → VACUUM)

SELECT pg_truncate_visibility_map('chk');  -- superuser 전용
VACUUM chk;                                 -- VM 재생성

 

B-tree 특정 페이지의 라인 아이템을 열어 트리 구조를 직접 본다. (조합: bt_page_items)

SELECT itemoffset, ctid, itemlen, dead
FROM bt_page_items('chk_pkey', 1) LIMIT 5;

 

verify_heapam으로 힙과 함께 TOAST까지 검사한다. (조합: check_toast)

SELECT count(*) FROM verify_heapam('chk',
  check_toast => true, skip => 'none');

 

손상 발견 후 해당 블록의 헤더 상태를 부검한다. (조합: page_header)

SELECT lsn, checksum, flags, lower, upper
FROM page_header(get_raw_page('chk', 0));
반응형