[{"content":"개요 MongoDB에 문서를 넣으면 그 문서는 결국 디스크의 어떤 파일 어딘가에 들어갑니다. 그 사이를 맡는 것이 스토리지 엔진이고, MongoDB 3.2부터 기본 엔진은 WiredTiger입니다. WiredTiger는 MongoDB가 인수한 별도의 키-값 저장 라이브러리로, mongod 프로세스 안에 링크되어 돌아갑니다. mongod의 나머지 부분(쿼리 실행, 인덱스 관리, 복제)은 WiredTiger에 \u0026ldquo;이 키에 이 값을 넣어라\u0026rdquo;, \u0026ldquo;이 키부터 차례로 읽어라\u0026quot;라고 요청할 뿐이고, 파일 형식, 캐시, 트랜잭션, 체크포인트, 저널은 모두 WiredTiger가 처리합니다.\n이 연재는 그 WiredTiger부터 시작합니다. 뒤의 편에서 다룰 체크포인트와 저널(2편), 스냅샷과 history store(3편), 인덱스와 쿼리 플래너(4편)가 모두 여기서 보는 구조 위에 있습니다.\n이 글에서 답할 질문은 다음과 같습니다.\nmongod 안에서 WiredTiger는 어디에 있고, 둘은 어떤 인터페이스로 이야기하는가 dbPath의 파일들은 각각 무엇이고, 컬렉션 이름은 어떻게 파일과 짝지어지는가 컬렉션과 인덱스는 WiredTiger 안에서 어떤 키와 값으로 저장되는가 압축은 어디에서 일어나고, 메모리의 페이지와 디스크의 블록은 무엇이 다른가 캐시 크기는 어떻게 정해지고, 캐시가 차면 무슨 일이 일어나는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다.\nmongod 안에서 WiredTiger의 자리 mongod의 쿼리 계층은 WiredTiger를 직접 부르지 않습니다. 가운데에 스토리지 엔진 API라는 C++ 추상 클래스 몇 개가 있고, 쿼리 계층은 이 인터페이스만 압니다.\n인터페이스 역할 WiredTiger 구현 KVEngine 엔진 전체. 테이블 만들기, 지우기, 체크포인트 WiredTigerKVEngine RecordStore 컬렉션. RecordId → 문서(BSON) WiredTigerRecordStore SortedDataInterface 인덱스. 정렬된 키 → RecordId WiredTigerIndex RecoveryUnit 한 작업의 트랜잭션 WiredTigerRecoveryUnit WiredTiger 쪽 구현은 이 요청을 WiredTiger의 C API 호출로 바꿉니다. WiredTiger에서 연결 하나는 WT_CONNECTION, 작업하는 스레드마다 쓰는 문맥은 WT_SESSION, 테이블 하나를 읽고 쓰는 손잡이는 WT_CURSOR입니다. 예를 들어 컬렉션에 문서를 넣는 WiredTigerRecordStore::_insertRecords()는 결국 커서에 키(RecordId)와 값(BSON)을 넣고 insert를 부릅니다(wiredtiger_record_store.cpp). 한 작업의 쓰기들은 WiredTigerRecoveryUnit이 연 WiredTiger 트랜잭션(_txnOpen()) 안에서 일어나고, 트랜잭션이 커밋될 때 한꺼번에 보이게 됩니다. 트랜잭션과 스냅샷은 3편에서 자세히 봅니다.\n쿼리 계층은 스토리지 엔진 API만 알고, WiredTiger 구현이 이를 커서 호출로 바꿉니다. 커서가 다루는 것은 캐시의 B-tree 페이지이고, 파일에는 압축된 블록만 갑니다. 새 창에서 크게 보기 그림의 아래 줄이 이 글의 나머지 내용입니다. 커서는 파일을 직접 읽고 쓰지 않습니다. WiredTiger 캐시에 올라온 B-tree 페이지를 찾아 읽거나 고치고, 페이지가 캐시에 없으면 그때 파일에서 읽어 옵니다. 캐시의 페이지를 파일에 쓰는 것은 나중에 eviction이나 체크포인트가 합니다.\nmongod가 WiredTiger를 여는 설정 mongod는 시작할 때 설정 문자열 하나를 만들어 wiredtiger_open()을 부르고, 그 문자열을 Opening WiredTiger 로그로 남깁니다(WiredTigerKVEngine 생성자). 메모리 제한이 없는 컨테이너에서 기본 설정으로 띄운 mongod의 로그입니다.\n$ jq -r \u0026#39;select(.msg == \u0026#34;Opening WiredTiger\u0026#34;) | .attr.config\u0026#39; /data/mongod.log create,cache_size=15508M,session_max=33000,eviction=(threads_min=4,threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,remove=true,path=journal,compressor=snappy),builtin_extension_config=(zstd=(compression_level=6)),file_manager=(close_idle_time=600,close_scan_interval=10,close_handle_minimum=2000),statistics_log=(wait=0),json_output=(error,message),verbose=[recovery_progress:1,checkpoint_progress:1,compact_progress:1,backup:0,checkpoint:0,compact:0,evict:0,history_store:0,recovery:0,rts:0,salvage:0,tiered:0,timestamp:0,transaction:0,verify:0,log:0],prefetch=(available=true,default=false), 이 글과 다음 글에 나오는 설정이 거의 다 여기 있습니다.\ncache_size=15508M: WiredTiger 캐시 크기입니다. 이 값이 어떻게 나왔는지는 아래에서 계산해 봅니다. eviction=(threads_min=4,threads_max=4): 캐시에서 페이지를 내보내는 eviction worker 스레드를 4개로 고정합니다(wiredtiger_kv_engine.cpp). WiredTiger 자체의 기본값은 최소 1개, 최대 8개입니다(api_data.py). log=(enabled=true,remove=true,path=journal,compressor=snappy): WiredTiger의 로그가 곧 MongoDB의 저널입니다. journal 디렉터리에 쓰고, 필요 없어진 파일은 지우고, snappy로 압축합니다(wiredtiger_kv_engine.cpp). 2편의 주제입니다. statistics=(fast): serverStatus와 collStats의 wiredTiger 섹션이 이 통계입니다. fast는 트리 전체를 훑어야 하는 통계는 모으지 않는 모드입니다. file_manager=(close_idle_time=600,...): 600초 동안 쓰지 않은 파일 핸들은 닫습니다. 운영 이야기에서 다시 봅니다. verbose=[...checkpoint_progress:1...]: 체크포인트와 복구 진행 메시지를 로그에 남깁니다. 2편에서 이 메시지로 체크포인트 주기를 봅니다. dbPath: 컬렉션과 인덱스마다 파일이 하나씩 WiredTiger에서 테이블 하나는 파일 하나입니다. MongoDB는 컬렉션 하나를 테이블 하나로, 인덱스 하나를 또 다른 테이블 하나로 만듭니다. 그래서 컬렉션 하나에 인덱스가 둘이면 파일이 셋입니다.\n컬렉션과 인덱스를 만든 뒤의 dbPath 빈 dbPath로 mongod를 띄우고, orders 컬렉션에 문서 셋과 item 인덱스를 만듭니다.\ntest\u003e db.orders.insertMany([{_id: 1, item: \u0026#34;apple\u0026#34;, qty: 10}, {_id: 2, item: \u0026#34;banana\u0026#34;, qty: 20}, {_id: 3, item: \u0026#34;cherry\u0026#34;, qty: 30}]) { acknowledged: true, insertedIds: { \u0026#39;0\u0026#39;: 1, \u0026#39;1\u0026#39;: 2, \u0026#39;2\u0026#39;: 3 } } test\u003e db.orders.createIndex({item: 1}) item_1 $ ls -l /data/db total 104 -rw------- 1 mongod mongod 50 Sep 27 21:41 WiredTiger -rw------- 1 mongod mongod 21 Sep 27 21:41 WiredTiger.lock -rw------- 1 mongod mongod 1165 Sep 27 21:41 WiredTiger.turtle -rw------- 1 mongod mongod 4096 Sep 27 21:41 WiredTiger.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 WiredTigerHS.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 _mdb_catalog.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 collection-0-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 collection-2-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 collection-4-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 collection-7-7164268511808313993.wt drwx------ 2 mongod mongod 4096 Sep 27 21:41 diagnostic.data -rw------- 1 mongod mongod 4096 Sep 27 21:41 index-1-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 index-3-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 index-5-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 index-6-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 index-8-7164268511808313993.wt -rw------- 1 mongod mongod 20480 Sep 27 21:41 index-9-7164268511808313993.wt -rw------- 1 mongod mongod 4096 Sep 27 21:41 internal-10-7164268511808313993.wt drwx------ 2 mongod mongod 4096 Sep 27 21:41 journal -rw------- 1 mongod mongod 3 Sep 27 21:41 mongod.lock -rw------- 1 mongod mongod 4096 Sep 27 21:41 sizeStorer.wt -rw------- 1 mongod mongod 114 Sep 27 21:41 storage.bson $ ls -l /data/db/journal total 204800 -rw------- 1 mongod mongod 104857600 Sep 27 21:41 WiredTigerLog.0000000001 -rw------- 1 mongod mongod 104857600 Sep 27 21:41 WiredTigerPreplog.0000000001 $ cat /data/db/WiredTiger WiredTiger WiredTiger 11.3.0: (November 16, 2023) $ bsondump --quiet /data/db/storage.bson {\u0026#34;storage\u0026#34;:{\u0026#34;engine\u0026#34;:\u0026#34;wiredTiger\u0026#34;,\u0026#34;options\u0026#34;:{\u0026#34;directoryPerDB\u0026#34;:false,\u0026#34;directoryForIndexes\u0026#34;:false,\u0026#34;groupCollections\u0026#34;:false}}} $ cat /data/db/mongod.lock; echo; pgrep -x mongod 33 33 사용자가 만든 것은 컬렉션 하나와 인덱스 하나인데, 파일은 훨씬 많습니다. 하나씩 보면 다음과 같습니다.\n파일 누가 쓰나 내용 collection-*.wt MongoDB 컬렉션 하나. admin.system.version, local.startup_log, config.system.sessions 같은 내부 컬렉션도 각각 파일이 있음 index-*.wt MongoDB 인덱스 하나. _id 인덱스도 파일 하나 _mdb_catalog.wt MongoDB 컬렉션 이름과 옵션, 인덱스 정의, 그리고 각각의 ident(파일 이름) sizeStorer.wt MongoDB 컬렉션마다 문서 수와 데이터 크기. count()를 매번 세지 않으려고 따로 저장(WiredTigerSizeStorer::flush()) storage.bson MongoDB 이 dbPath를 만든 엔진 이름과 옵션(storage_engine_metadata.cpp). 다른 엔진으로 띄우는 실수를 막음 mongod.lock MongoDB 실행 중인 mongod의 pid. 정상 종료하면 비워짐 WiredTiger.wt WiredTiger WiredTiger의 메타데이터 테이블. 모든 테이블의 설정과 체크포인트 위치 WiredTiger.turtle WiredTiger 메타데이터 테이블 자신의 설정과 체크포인트 위치. 복구가 여기서 시작 WiredTiger, WiredTiger.lock WiredTiger 버전 문자열, 다른 프로세스가 같은 디렉터리를 여는 것을 막는 잠금 WiredTigerHS.wt WiredTiger history store. 옛 버전의 값을 캐시 밖으로 내보낼 때 쓰는 테이블(3편) journal/WiredTigerLog.* WiredTiger 저널(WiredTiger 로그). 100MB짜리 파일로 미리 잡아 두고 씀(2편) diagnostic.data/ MongoDB FTDC. serverStatus 등을 주기적으로 모아 둔 진단 데이터 internal-10-...wt는 createIndex가 인덱스를 만드는 동안 들어오는 쓰기를 모아 두는 임시 테이블입니다(IndexBuildInterceptor). 빌드가 끝나면 지워져서, 뒤에서 wt list로 볼 때는 없습니다. mongod.lock에 적힌 33은 pgrep으로 찾은 mongod의 pid와 같습니다.\n파일 이름(ident)은 collection-\u0026lt;번호\u0026gt;-\u0026lt;난수\u0026gt; 모양입니다. 번호는 mongod가 이번에 켜진 뒤 ident를 만들 때마다 1씩 늘고, 뒤의 난수는 mongod가 켜질 때 한 번 뽑습니다(DurableCatalog::generateUniqueIdent(), _newRand()). 그래서 mongod를 다시 켠 뒤에 만든 컬렉션은 난수 부분이 다른 파일 이름을 받습니다. 이름에 네임스페이스가 들어가지 않으므로 컬렉션 이름을 바꿔도(renameCollection) 파일은 그대로입니다.\n_mdb_catalog: 이름과 ident를 짝짓는 카탈로그 그렇다면 test.orders가 collection-7-...이라는 것은 어디에 적혀 있을까요. _mdb_catalog.wt입니다. 이것도 평범한 WiredTiger 테이블이고, 컬렉션마다 문서 하나가 들어 있습니다. 문서에는 네임스페이스(ns), 컬렉션 옵션과 인덱스 정의(md), 컬렉션의 ident(ident), 인덱스 이름별 ident(idxIdent)가 들어 있습니다(durable_catalog.cpp). mongod는 시작할 때 이 테이블을 처음부터 끝까지 읽어 메모리에 카탈로그를 만듭니다(DurableCatalog::init()).\n$listCatalog로 본 카탈로그 $listCatalog 집계 단계는 이 카탈로그를 그대로 보여 줍니다.\ntest\u003e db.getSiblingDB(\u0026#34;admin\u0026#34;).aggregate([{$listCatalog: {}}, {$project: {_id: 0, ns: 1, ident: 1, idxIdent: 1}}]) [ { idxIdent: { _id_: \u0026#39;index-1-7164268511808313993\u0026#39; }, ns: \u0026#39;admin.system.version\u0026#39;, ident: \u0026#39;collection-0-7164268511808313993\u0026#39; }, { idxIdent: { _id_: \u0026#39;index-3-7164268511808313993\u0026#39; }, ns: \u0026#39;local.startup_log\u0026#39;, ident: \u0026#39;collection-2-7164268511808313993\u0026#39; }, { idxIdent: { _id_: \u0026#39;index-5-7164268511808313993\u0026#39;, lsidTTLIndex: \u0026#39;index-6-7164268511808313993\u0026#39; }, ns: \u0026#39;config.system.sessions\u0026#39;, ident: \u0026#39;collection-4-7164268511808313993\u0026#39; }, { idxIdent: { _id_: \u0026#39;index-8-7164268511808313993\u0026#39;, item_1: \u0026#39;index-9-7164268511808313993\u0026#39; }, ns: \u0026#39;test.orders\u0026#39;, ident: \u0026#39;collection-7-7164268511808313993\u0026#39; } ] test\u003e db.orders.stats().wiredTiger.uri statistics:table:collection-7-7164268511808313993 test\u003e Object.entries(db.orders.stats({indexDetails: true}).indexDetails).map(([name, d]) =\u0026gt; name + \u0026#34; -\u0026gt; \u0026#34; + d.uri) [ \u0026#39;_id_ -\u0026gt; statistics:table:index-8-7164268511808313993\u0026#39;, \u0026#39;item_1 -\u0026gt; statistics:table:index-9-7164268511808313993\u0026#39; ] 앞의 ls에 있던 collection-*과 index-* 파일이 하나도 빠짐없이 네 컬렉션에 짝지어집니다. test.orders는 collection-7이고, 인덱스 _id_와 item_1은 index-8, index-9입니다. collStats의 wiredTiger.uri도 같은 ident를 알려 줍니다. table:collection-7-...이 WiredTiger 안의 테이블 이름이고, 파일은 그 뒤에 .wt를 붙인 것입니다. 카탈로그 자체도 WiredTiger 테이블이므로, WiredTiger에게 _mdb_catalog는 다른 컬렉션과 다를 것이 없습니다. \u0026ldquo;이 ident가 어떤 컬렉션인가\u0026quot;는 MongoDB만 아는 정보이고, WiredTiger의 메타데이터(WiredTiger.wt)에는 \u0026ldquo;이 테이블은 이런 설정의 B-tree이고 파일은 여기다\u0026quot;만 있습니다.\n컬렉션은 RecordId를 키로 하는 B-tree WiredTiger 테이블은 키와 값의 쌍을 키 순서로 정렬해 담는 B-tree입니다. 테이블을 만들 때 키와 값의 형식, 페이지 크기, 압축 방식을 정하고, MongoDB는 이 설정 문자열을 직접 만듭니다. 컬렉션은 WiredTigerRecordStore::generateCreateString(), 인덱스는 WiredTigerIndex::generateCreateString()이 만듭니다. 만들 때 쓴 설정은 collStats의 creationString으로 볼 수 있습니다.\ncreationString: 컬렉션과 인덱스의 테이블 설정 creationString은 WiredTiger의 모든 설정 항목을 펼쳐 놓아서 길기 때문에, 필요한 항목만 골라 봅니다.\ntest\u003e const pick = (cs) =\u0026gt; cs.split(\u0026#34;,\u0026#34;).filter(kv =\u0026gt; /^(type|key_format|value_format|block_compressor|internal_page_max|leaf_page_max|memory_page_max|split_pct|prefix_compression|log)=/.test(kv)) test\u003e pick(db.orders.stats().wiredTiger.creationString) [ \u0026#39;block_compressor=snappy\u0026#39;, \u0026#39;internal_page_max=4KB\u0026#39;, \u0026#39;key_format=q\u0026#39;, \u0026#39;leaf_page_max=32KB\u0026#39;, \u0026#39;log=(enabled=true)\u0026#39;, \u0026#39;memory_page_max=10m\u0026#39;, \u0026#39;prefix_compression=false\u0026#39;, \u0026#39;split_pct=90\u0026#39;, \u0026#39;type=file\u0026#39;, \u0026#39;value_format=u\u0026#39; ] test\u003e pick(db.orders.stats({indexDetails: true}).indexDetails.item_1.creationString) [ \u0026#39;block_compressor=\u0026#39;, \u0026#39;internal_page_max=16k\u0026#39;, \u0026#39;key_format=u\u0026#39;, \u0026#39;leaf_page_max=16k\u0026#39;, \u0026#39;log=(enabled=true)\u0026#39;, \u0026#39;memory_page_max=5MB\u0026#39;, \u0026#39;prefix_compression=true\u0026#39;, \u0026#39;split_pct=90\u0026#39;, \u0026#39;type=file\u0026#39;, \u0026#39;value_format=u\u0026#39; ] 항목 컬렉션 인덱스 뜻 key_format q (64비트 정수) u (바이트열) 컬렉션의 키는 RecordId, 인덱스의 키는 KeyString으로 인코딩한 인덱스 키 value_format u u 컬렉션의 값은 BSON 문서 그대로. 인덱스의 값은 비어 있거나 RecordId block_compressor snappy 없음 컬렉션은 기본 snappy(wiredtiger_global_options.idl). 인덱스는 블록 압축 대신 prefix 압축 leaf_page_max, internal_page_max 32KB, 4KB 16k, 16k 디스크에 쓰는 페이지의 최대 크기. 인덱스는 키(최대 1024바이트)가 넘치지 않도록 16KB(wiredtiger_index.cpp) memory_page_max 10m 5MB 메모리에서 페이지가 이만큼 커지면 강제로 나눔. 컬렉션은 MongoDB가 10MB로 정함(wiredtiger_record_store.cpp) split_pct 90 90 페이지를 나눌 때 앞쪽을 90%까지 채움. 컬렉션은 대부분 뒤에 붙이는 쓰기라서 높게 잡음(wiredtiger_record_store.cpp) log (enabled=true) (enabled=true) 이 테이블의 변경을 저널에 쓰는가. standalone은 모두 쓰고, 레플리카셋은 사용자 컬렉션을 쓰지 않음(2편) RecordId는 컬렉션 안에서 문서를 가리키는 64비트 정수로, 문서를 넣을 때 1씩 늘려 붙입니다. _id와는 별개입니다. 컬렉션 테이블의 키가 RecordId이므로 컬렉션 파일은 넣은 순서대로 정렬되어 있고, _id로 찾으려면 _id 인덱스에서 RecordId를 얻은 뒤 컬렉션 테이블을 한 번 더 찾아야 합니다. (클러스터드 컬렉션은 예외로, _id 자체를 키로 쓰는 key_format=u 테이블을 만듭니다(wiredtiger_record_store.cpp).)\nwt 유틸리티로 본 컬렉션 테이블 WiredTiger 소스에 들어 있는 wt 유틸리티로 파일을 직접 열어 봅니다. 실행 중인 mongod의 파일을 다른 프로세스가 열면 안 되므로, mongod를 정상 종료한 뒤에 엽니다. mongod --shutdown은 pid 파일의 프로세스에 종료 신호를 보내고 끝나기를 기다립니다.\n$ mongod --dbpath /data/db --shutdown | grep -v \u0026#39;^{\u0026#39; Killing process with pid: 33 $ wt -h /data/db list | grep \u0026#39;^table:\u0026#39; table:_mdb_catalog table:collection-0-7164268511808313993 table:collection-11-7164268511808313993 table:collection-13-7164268511808313993 table:collection-15-7164268511808313993 table:collection-17-7164268511808313993 table:collection-2-7164268511808313993 table:collection-4-7164268511808313993 table:collection-7-7164268511808313993 table:index-1-7164268511808313993 table:index-12-7164268511808313993 table:index-14-7164268511808313993 table:index-16-7164268511808313993 table:index-18-7164268511808313993 table:index-3-7164268511808313993 table:index-5-7164268511808313993 table:index-6-7164268511808313993 table:index-8-7164268511808313993 table:index-9-7164268511808313993 table:sizeStorer wt list는 WiredTiger 메타데이터에 있는 테이블 목록입니다. collection-11부터 collection-17까지와 짝이 되는 인덱스는 뒤에서 압축을 비교하려고 만든 컬렉션 넷입니다. 인덱스 빌드용 임시 테이블 internal-10은 이미 없습니다. WiredTiger에게는 _mdb_catalog와 sizeStorer도 그냥 테이블 가운데 하나입니다.\norders 컬렉션 테이블을 16진수로 덤프합니다.\n$ wt -h /data/db dump -x table:collection-7-7164268511808313993 | sed -n \u0026#39;/^Data/,$p\u0026#39; Data 81 27000000105f69640001000000026974656d00060000006170706c650010717479000a00000000 82 28000000105f69640002000000026974656d000700000062616e616e610010717479001400000000 83 28000000105f69640003000000026974656d00070000006368657272790010717479001e00000000 $ wt -h /data/db dump -x table:collection-7-7164268511808313993 | sed -n \u0026#39;/^Data/,$p\u0026#39; | sed -n 3p | xxd -r -p | bsondump --quiet {\u0026#34;_id\u0026#34;:{\u0026#34;$numberInt\u0026#34;:\u0026#34;1\u0026#34;},\u0026#34;item\u0026#34;:\u0026#34;apple\u0026#34;,\u0026#34;qty\u0026#34;:{\u0026#34;$numberInt\u0026#34;:\u0026#34;10\u0026#34;}} 키와 값이 한 줄씩 번갈아 나옵니다. 키 81, 82, 83은 RecordId 1, 2, 3을 WiredTiger의 정수 포맷으로 담은 것입니다. 0부터 63까지의 양수는 0x80에 값을 더한 한 바이트로 씁니다(intpack_inline.h). 값은 BSON 문서 그대로입니다. 첫 4바이트 27000000은 BSON의 전체 길이(리틀엔디언 0x27 = 39바이트)이고, 이어서 10(int32 타입) 5f696400(_id)\u0026hellip; 이 이어집니다. 첫 값을 바이트로 되돌려 bsondump에 넣으면 넣었던 문서가 그대로 나옵니다. MongoDB는 문서를 WiredTiger에 넘길 때 아무것도 덧붙이지 않습니다. 이 덤프는 파일에 기록된 마지막 체크포인트 기준의 내용이고, 파일 안의 실제 바이트는 압축된 블록입니다. wt가 블록을 읽어 압축을 풀고 B-tree를 따라가며 보여 준 결과입니다.\n인덱스의 키: KeyString 인덱스 테이블의 키는 인덱스 키를 KeyString으로 인코딩한 바이트열입니다. KeyString은 어떤 BSON 값이든 바이트 단위 비교(memcmp)만으로 MongoDB의 정렬 순서가 나오도록 만든 형식이라서, WiredTiger는 키가 문자열인지 숫자인지 몰라도 정렬할 수 있습니다.\n$ wt -h /data/db dump -x table:index-9-7164268511808313993 | sed -n \u0026#39;/^Data/,$p\u0026#39; Data 3c6170706c6500040008 3c62616e616e6100040010 3c63686572727900040018 $ wt -h /data/db dump -x table:index-8-7164268511808313993 | sed -n \u0026#39;/^Data/,$p\u0026#39; Data 2b0204 0008 2b0404 0010 2b0604 0018 item_1 인덱스(index-9)의 첫 키 3c6170706c6500040008을 나눠 보면 이렇습니다.\n바이트 뜻 3c 타입 바이트. 문자열 계열(kStringLike = 60, key_string.cpp) 6170706c65 00 apple과 끝 표시 04 키의 끝(kEnd, key_string.cpp) 0008 RecordId 1. 첫 바이트의 위 3비트와 끝 바이트의 아래 3비트에 가운데 바이트 수를 적는 형식으로, 1은 00 08이 됨(_appendRecordIdLong()) item_1 같은 일반 인덱스는 키 뒤에 RecordId를 붙이고 값은 비워 둡니다(WiredTigerIndexStandard::_insert(), 값은 wiredtiger_index.cpp). 같은 키가 여러 문서에 있어도 RecordId가 달라서 B-tree의 키는 모두 다릅니다. 덤프에서 키 다음 줄이 빈 줄인 것이 빈 값입니다. _id_ 인덱스(index-8)는 반대로 키에는 _id 값만(2b 02 04: 1바이트 양의 정수 타입 kNumericPositive1ByteInt, 값, 끝), 값에 RecordId(0008)를 담습니다(WiredTigerIdIndex::_insert()). 키가 유일해야 하는 인덱스라서 키가 겹치면 WiredTiger가 바로 중복 키 오류를 돌려줄 수 있습니다. 인덱스를 따라 문서를 찾는 과정은 결국 \u0026ldquo;인덱스 B-tree에서 KeyString으로 찾아 RecordId를 얻고, 컬렉션 B-tree에서 그 RecordId로 문서를 얻는\u0026rdquo; 두 번의 B-tree 탐색입니다. 쿼리 플래너가 이 비용을 어떻게 따지는지는 4편에서 봅니다.\nB-tree의 모양: internal 페이지와 leaf 페이지 문서가 셋뿐이면 페이지 하나로 끝나므로, 문서 10만 개(BSON으로 약 27MB)를 넣은 컬렉션 둘을 봅니다. 하나(collection-11)는 기본 snappy 압축, 다른 하나(collection-17)는 압축 없이 만들었습니다. wt verify -d dump_layout은 트리의 층마다 페이지 수를 보여 줍니다.\n$ wt -h /data/db verify -d dump_layout table:collection-11-7164268511808313993 2\u0026gt;\u0026amp;1 | grep -vE \u0026#39;^\\[\u0026#39; =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= file:collection-11-7164268511808313993.wt, ckpt_name: WiredTigerCheckpoint.1 Root: \u0026gt; addr: [0: 10199040-10203136, 4096, 3539864155] Internal page tree-depth (total 4): 001: 1 002: 3 Leaf page tree-depth (total 580): 003: 580 $ wt -h /data/db verify -d dump_layout table:collection-17-7164268511808313993 2\u0026gt;\u0026amp;1 | grep -vE \u0026#39;^\\[\u0026#39; =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= file:collection-17-7164268511808313993.wt, ckpt_name: WiredTigerCheckpoint.1 Root: \u0026gt; addr: [0: 28393472-28397568, 4096, 2100132084] Internal page tree-depth (total 6): 001: 1 002: 5 Leaf page tree-depth (total 991): 003: 991 두 트리 모두 깊이가 3입니다. 1층에 root internal 페이지 하나, 2층에 internal 페이지 몇 개, 3층에 leaf 페이지가 있습니다. 문서(키와 값)는 모두 leaf 페이지에 있고, internal 페이지에는 \u0026ldquo;이 키부터는 저 자식 페이지로\u0026quot;라는 안내만 있습니다. ckpt_name: WiredTigerCheckpoint.1은 이 트리가 체크포인트 1번의 모습이라는 뜻입니다. 파일에는 체크포인트 단위로 완성된 트리가 들어 있습니다. 2편에서 자세히 봅니다. Root: addr: [0: 10199040-10203136, 4096, ...]은 root 페이지가 파일의 10199040바이트 위치에서 4096바이트 크기로 있다는 뜻입니다. 마지막 숫자는 블록의 체크섬입니다. 같은 문서인데 압축한 쪽은 leaf가 580개, 압축하지 않은 쪽은 991개입니다. 이 차이는 바로 아래에서 봅니다. dump_address로 페이지 하나하나의 블록 주소를 보면 차이가 보입니다.\n$ wt -h /data/db verify -d dump_address table:collection-11-7164268511808313993 2\u0026gt;\u0026amp;1 | head -9 | cut -c1-44 =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- file:collection-11-7164268511808313993.wt, c Root: \u0026gt; addr: [0: 10199040-10203136, 4096, 353986 [NoAddr] -/-,-/- row-store internal write ge [0: 10186752-10190848, 4096, 4021143610] new [0: 4096-40960, 36864, 1162190748] newest_du [0: 40960-77824, 36864, 3643813452] newest_d [0: 77824-114688, 36864, 3315467987] newest_ $ wt -h /data/db verify -d dump_address table:collection-17-7164268511808313993 2\u0026gt;\u0026amp;1 | head -9 | cut -c1-44 =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- file:collection-17-7164268511808313993.wt, c Root: \u0026gt; addr: [0: 28393472-28397568, 4096, 210013 [NoAddr] -/-,-/- row-store internal write ge [0: 28372992-28377088, 4096, 3467847535] new [0: 4096-32768, 28672, 2770148487] newest_du [0: 32768-61440, 28672, 1153302453] newest_d [0: 61440-90112, 28672, 553415368] newest_du $ wt -h /data/db stat table:collection-11-7164268511808313993 | grep -E \u0026#39;btree: (maximum (internal|leaf) page size|number of key/value pairs|row-store (internal|leaf) pages)|block-manager: file size\u0026#39; block-manager: file size in bytes=10M (10211328) btree: maximum internal page size=4096 btree: maximum leaf page size=32768 btree: number of key/value pairs=100000 btree: row-store internal pages=4 btree: row-store leaf pages=580 트리를 훑는 순서대로 root(메모리에서 연 것이라 [NoAddr]), 2층 internal 페이지(4096바이트 블록), 그 아래 leaf들이 나옵니다. 압축하지 않은 컬렉션의 leaf 블록은 28672바이트입니다. leaf_page_max(32KB)를 넘지 않도록 페이지를 나누고, 블록은 allocation_size(4KB)의 배수로 잡습니다. snappy로 압축한 컬렉션의 leaf 블록은 36864바이트로, 오히려 leaf_page_max보다 큽니다. 압축을 쓰는 테이블은 WiredTiger가 \u0026ldquo;압축하기 전 페이지\u0026quot;를 memory_page_image_max(기본은 페이지 최대 크기의 4배, bt_handle.c)까지 키우고(bt_handle.c), 실제로 압축된 크기를 보며 그 기준을 계속 조정합니다(rec_write.c). 압축된 블록이 leaf_page_max 근처가 되도록 겨냥하는 것이라, 압축한 쪽은 leaf 하나에 더 많은 문서가 들어가 leaf 수가 580개로 줄었습니다. 정리하면 디스크의 블록은 가변 크기입니다. 페이지마다 압축된 크기가 달라서, 블록은 4KB 단위로 잡히지만 크기가 제각각입니다. PostgreSQL처럼 모든 페이지가 8kB로 같고 파일 안의 자리가 고정되어 있지 않습니다(PG 3편). WiredTiger는 페이지를 쓸 때마다 파일의 빈 자리에 새 블록을 잡고, 부모 페이지에 새 주소를 적습니다. 이 \u0026ldquo;제자리에 덮어쓰지 않는\u0026rdquo; 방식이 체크포인트의 바탕이 됩니다(2편).\n압축: 디스크에 쓰는 블록만 압축된다 WiredTiger의 압축은 블록 압축입니다. 캐시의 페이지를 디스크에 쓸 때 페이지 이미지 전체를 압축해서 블록으로 쓰고, 읽을 때 압축을 풀어 캐시에 올립니다. 문서 하나하나를 압축하는 것이 아니고, 캐시 안의 페이지는 압축이 풀린 상태입니다. 압축 방식은 컬렉션을 만들 때 정하고, 기본값은 storage.wiredTiger.collectionConfig.blockCompressor(snappy)입니다. 컬렉션마다 storageEngine.wiredTiger.configString으로 다르게 정할 수도 있습니다.\n같은 문서 10만 개, 압축 방식만 다르게 압축 방식만 다른 컬렉션 넷을 만들고, 같은 난수 씨앗으로 만든 같은 문서 10만 개를 넣습니다.\ntest\u003e db.createCollection(\u0026#34;c_snappy\u0026#34;) { ok: 1 } test\u003e db.createCollection(\u0026#34;c_zstd\u0026#34;, {storageEngine: {wiredTiger: {configString: \u0026#34;block_compressor=zstd\u0026#34;}}}) { ok: 1 } test\u003e db.createCollection(\u0026#34;c_zlib\u0026#34;, {storageEngine: {wiredTiger: {configString: \u0026#34;block_compressor=zlib\u0026#34;}}}) { ok: 1 } test\u003e db.createCollection(\u0026#34;c_none\u0026#34;, {storageEngine: {wiredTiger: {configString: \u0026#34;block_compressor=none\u0026#34;}}}) { ok: 1 } test\u003e [\u0026#34;c_snappy\u0026#34;, \u0026#34;c_zstd\u0026#34;, \u0026#34;c_zlib\u0026#34;, \u0026#34;c_none\u0026#34;].map(c =\u0026gt; c + \u0026#34;: \u0026#34; + db[c].stats().wiredTiger.creationString.match(/block_compressor=\\w*/)[0]) [ \u0026#39;c_snappy: block_compressor=snappy\u0026#39;, \u0026#39;c_zstd: block_compressor=zstd\u0026#39;, \u0026#39;c_zlib: block_compressor=zlib\u0026#39;, \u0026#39;c_none: block_compressor=none\u0026#39; ] 넣은 문서는 이런 모양입니다. 필드 이름이 문서마다 반복되고, memo는 단어 20개 가운데서 골라 만든 문장이라 실제 서비스 데이터처럼 어느 정도 압축이 됩니다.\ntest\u003e db.c_snappy.findOne({_id: 7}) { _id: 7, user: \u0026#39;user18224\u0026#39;, email: \u0026#39;user7@example.com\u0026#39;, city: \u0026#39;Seoul\u0026#39;, status: \u0026#39;inactive\u0026#39;, amount: 6594.56, tags: [ \u0026#39;member\u0026#39;, \u0026#39;cart\u0026#39; ], createdAt: ISODate(\u0026#39;2026-06-16T23:31:02.336Z\u0026#39;), memo: \u0026#39;order order cart sale return cart order sale order order sale order return sale return\u0026#39; } fsync 명령으로 체크포인트를 한 번 해서 캐시의 내용을 파일에 모두 쓴 뒤 크기를 비교합니다.\ntest\u003e db.adminCommand({fsync: 1}) { numFiles: 1, ok: 1 } test\u003e [\u0026#34;c_snappy\u0026#34;, \u0026#34;c_zstd\u0026#34;, \u0026#34;c_zlib\u0026#34;, \u0026#34;c_none\u0026#34;].map(c =\u0026gt; { const s = db[c].stats(); return {coll: c, count: s.count, size: s.size, storageSize: s.storageSize, ratio: (s.size / s.storageSize).toFixed(2)}; }) [ { coll: \u0026#39;c_snappy\u0026#39;, count: 100000, size: 27461165, storageSize: 10211328, ratio: \u0026#39;2.69\u0026#39; }, { coll: \u0026#39;c_zstd\u0026#39;, count: 100000, size: 27461165, storageSize: 4886528, ratio: \u0026#39;5.62\u0026#39; }, { coll: \u0026#39;c_zlib\u0026#39;, count: 100000, size: 27461165, storageSize: 4882432, ratio: \u0026#39;5.62\u0026#39; }, { coll: \u0026#39;c_none\u0026#39;, count: 100000, size: 27461165, storageSize: 28405760, ratio: \u0026#39;0.97\u0026#39; } ] $ ls -l /data/db/collection-11-7164268511808313993.wt /data/db/collection-13-7164268511808313993.wt /data/db/collection-17-7164268511808313993.wt -rw------- 1 mongod mongod 10211328 Sep 27 21:41 /data/db/collection-11-7164268511808313993.wt -rw------- 1 mongod mongod 4886528 Sep 27 21:41 /data/db/collection-13-7164268511808313993.wt -rw------- 1 mongod mongod 28405760 Sep 27 21:41 /data/db/collection-17-7164268511808313993.wt 압축 size (BSON 합계) storageSize (파일) 비율 snappy (기본) 27461165 10211328 2.69 zstd 27461165 4886528 5.62 zlib 27461165 4882432 5.62 none 27461165 28405760 0.97 size는 문서(BSON) 크기의 합이라 네 컬렉션이 같습니다. MongoDB가 sizeStorer에 따로 세어 두는 값입니다. storageSize는 파일 크기이고, ls -l의 크기와 같습니다. 이 데이터에서는 zstd와 zlib가 snappy보다 두 배쯤 더 줄였습니다. zstd의 압축 수준은 mongod가 builtin_extension_config=(zstd=(compression_level=6))으로 넘긴 6입니다(wiredtiger_global_options.idl). 압축률은 데이터에 따라 크게 달라지니 이 비율을 일반화하면 안 됩니다. 압축하지 않은 쪽은 파일이 BSON 합계보다 큽니다(0.97). 블록 헤더, internal 페이지, 4KB 단위로 잡은 블록의 빈 공간 때문입니다. snappy는 압축률보다 속도를 택한 기본값이고, zstd는 CPU를 조금 더 쓰는 대신 디스크와 I/O를 줄입니다. 압축은 블록을 쓰고 읽을 때만 일어나므로, 캐시 안에서 자주 쓰이는 데이터에는 압축 방식이 영향을 주지 않습니다. 영향을 받는 것은 캐시로 읽어 올 때(압축 풀기)와 eviction, 체크포인트로 쓸 때(압축)입니다.\n캐시: 메모리의 페이지는 디스크의 블록과 다르다 캐시에 올라온 페이지는 디스크 블록을 그대로 옮긴 것이 아닙니다. WiredTiger는 블록을 읽어 압축을 풀고(__page_read()), 그 이미지 위에 키를 빨리 찾기 위한 배열 같은 메모리 구조를 붙입니다. 페이지를 고치면 이미지 자체는 건드리지 않고, 바뀐 키마다 update 목록을 달고, 새로 들어온 키는 insert 목록에 달아 둡니다. 그래서 캐시의 페이지는 \u0026ldquo;디스크에서 읽은 이미지 + 그 뒤에 쌓인 변경\u0026quot;입니다. 같은 키의 여러 버전이 update 목록에 함께 있을 수 있고, 이것이 3편에서 볼 MVCC의 바탕입니다.\n변경이 달린 페이지를 dirty 페이지라고 합니다. dirty 페이지를 디스크에 쓰려면 이미지와 변경을 합쳐 새 페이지 이미지를 만들어야 하고, 이 과정을 reconciliation이라고 합니다(__wt_reconcile()). 페이지가 너무 크면 이때 여러 블록으로 나뉘고(split), 압축된 뒤 파일의 새 자리에 쓰입니다.\n캐시의 페이지 디스크의 블록 형태 압축 풀린 이미지 + 메모리 구조 + update/insert 목록 reconciliation으로 만든 이미지를 압축한 것 크기 memory_page_max(컬렉션 10MB)까지 커질 수 있음 4KB 단위, 페이지마다 다름 버전 한 키의 여러 버전이 함께 있을 수 있음 쓸 때 고른 버전(과 시간 정보) 위치 부모 페이지가 메모리 주소로 가리킴 부모 페이지가 파일 주소(offset, size, checksum)로 가리킴 읽으면 압축이 풀린 채로 캐시에 올라온다 mongod를 다시 띄워 캐시가 빈 상태에서, 컬렉션의 문서를 전부 읽는 집계를 돌리고 컬렉션별 캐시 통계를 봅니다.\ntest\u003e function cacheOf(c) { const s = db[c].stats(); return {coll: c, storageSize: s.storageSize, size: s.size, inCache: s.wiredTiger.cache[\u0026#34;bytes currently in the cache\u0026#34;], readIntoCache: s.wiredTiger.cache[\u0026#34;bytes read into cache\u0026#34;]}; } [Function: cacheOf] test\u003e cacheOf(\u0026#34;c_snappy\u0026#34;) { coll: \u0026#39;c_snappy\u0026#39;, storageSize: 10211328, size: 27461165, inCache: 383, readIntoCache: 9059 } test\u003e db.c_snappy.aggregate([{$group: {_id: null, n: {$sum: 1}, total: {$sum: \u0026#34;$amount\u0026#34;}}}]) [ { _id: null, n: 100000, total: 494173742.55 } ] test\u003e cacheOf(\u0026#34;c_snappy\u0026#34;) { coll: \u0026#39;c_snappy\u0026#39;, storageSize: 10211328, size: 27461165, inCache: 31422776, readIntoCache: 28220035 } test\u003e db.c_none.aggregate([{$group: {_id: null, n: {$sum: 1}, total: {$sum: \u0026#34;$amount\u0026#34;}}}]) [ { _id: null, n: 100000, total: 494173742.55 } ] test\u003e cacheOf(\u0026#34;c_none\u0026#34;) { coll: \u0026#39;c_none\u0026#39;, storageSize: 28405760, size: 27461165, inCache: 31511577, readIntoCache: 28249003 } 재시작 직후 c_snappy는 캐시에 383바이트뿐입니다. 컬렉션을 열 때 읽은 root 페이지 정도입니다. 전체를 읽은 뒤 캐시에 올라온 양(bytes read into cache)은 28220035바이트로, 파일 크기(10211328)가 아니라 압축을 푼 크기입니다. 캐시에 지금 있는 양(bytes currently in the cache)은 31422776바이트로 더 큽니다. 페이지 이미지에 메모리 구조가 붙고, WiredTiger가 메모리 할당기의 오버헤드로 8%를 더 얹어 세기 때문입니다(cache_overhead). 압축하지 않은 c_none도 캐시에서는 거의 같은 크기(31511577)입니다. 압축 방식은 디스크 크기를 바꿀 뿐, 캐시에 필요한 메모리는 바꾸지 않습니다. WiredTiger 캐시 밖에는 운영체제의 페이지 캐시가 따로 있습니다. .wt 파일을 읽으면 압축된 블록이 운영체제 페이지 캐시에 남고, 압축이 풀린 페이지는 WiredTiger 캐시에 들어갑니다. 같은 데이터가 두 형태로 메모리에 있을 수 있는 셈입니다. 기본 캐시가 메모리의 절반 정도인 것도, 나머지를 연결과 쿼리 실행, 그리고 이 운영체제 캐시에 남겨 두기 위해서입니다(WiredTiger Storage Engine 문서). WiredTiger 캐시에서 밀려난 페이지라도 압축된 블록이 운영체제 캐시에 있으면 디스크를 읽지 않고 다시 올릴 수 있습니다. PostgreSQL이 shared_buffers와 운영체제 캐시를 함께 쓰는 것과 비슷하지만(PG 2편), WiredTiger 쪽은 두 캐시의 형태가 다릅니다.\n캐시 크기는 어떻게 정해지는가 --wiredTigerCacheSizeGB(storage.wiredTiger.engineConfig.cacheSizeGB)를 주지 않으면 mongod가 크기를 계산합니다(WiredTigerUtil::getCacheSizeMB()).\n// Set a minimum of 256MB, otherwise use 50% of available memory over 1GB. cacheSizeMB = std::max((memSizeMB - 1024) * 0.5, 256.0); 여기서 memSizeMB는 ProcessInfo::getMemSizeMB()로, 시스템 메모리가 아니라 memLimit입니다. memLimit은 cgroup v2의 /sys/fs/cgroup/memory.max(v1이면 memory.limit_in_bytes)를 읽어 시스템 메모리와 비교해 작은 쪽을 씁니다(getMemorySizeLimit()). 그래서 컨테이너에 메모리 제한을 걸면 캐시도 그 제한을 기준으로 잡힙니다. 크기를 직접 주면 그 값을 그대로 쓰고, 메모리의 80%를 넘으면 시작할 때 경고만 남깁니다(wiredtiger_init.cpp).\n메모리 제한이 다른 컨테이너 넷 제한이 없는 컨테이너와, 1GB, 2GB, 4GB로 제한한 컨테이너에서 각각 기본 설정으로 mongod를 띄웠습니다. 제한이 없는 컨테이너부터 봅니다.\n$ cat /sys/fs/cgroup/memory.max max $ jq -r \u0026#39;select(.msg == \u0026#34;Opening WiredTiger\u0026#34;) | .attr.config\u0026#39; /data/mongod.log | grep -oE \u0026#39;cache_size=[0-9]+M\u0026#39; cache_size=15508M test\u003e const h = db.adminCommand({hostInfo: 1}).system; ({memSizeMB: h.memSizeMB, memLimitMB: h.memLimitMB, cacheMaxBytes: db.serverStatus().wiredTiger.cache[\u0026#34;maximum bytes configured\u0026#34;]}) { memSizeMB: Long(\u0026#39;32041\u0026#39;), memLimitMB: Long(\u0026#39;32041\u0026#39;), cacheMaxBytes: Long(\u0026#39;16261316608\u0026#39;) } 1GB로 제한한 컨테이너입니다.\n$ cat /sys/fs/cgroup/memory.max 1073741824 $ jq -r \u0026#39;select(.msg == \u0026#34;Opening WiredTiger\u0026#34;) | .attr.config\u0026#39; /data/mongod.log | grep -oE \u0026#39;cache_size=[0-9]+M\u0026#39; cache_size=256M test\u003e const h = db.adminCommand({hostInfo: 1}).system; ({memSizeMB: h.memSizeMB, memLimitMB: h.memLimitMB, cacheMaxBytes: db.serverStatus().wiredTiger.cache[\u0026#34;maximum bytes configured\u0026#34;]}) { memSizeMB: Long(\u0026#39;32041\u0026#39;), memLimitMB: Long(\u0026#39;1024\u0026#39;), cacheMaxBytes: 268435456 } 2GB와 4GB로 제한한 컨테이너의 결과까지 모으면 다음과 같습니다.\n컨테이너 memory.max memSizeMB memLimitMB 계산 cache_size 제한 없음 max 32041 32041 (32041 − 1024) × 0.5 15508M 1GB 1073741824 32041 1024 max(0, 256) 256M 2GB 2147483648 32041 2048 (2048 − 1024) × 0.5 512M 4GB 4294967296 32041 4096 (4096 − 1024) × 0.5 1536M hostInfo의 memSizeMB는 어느 컨테이너에서나 호스트(Docker VM)의 메모리 32041MB이고, memLimitMB만 cgroup 제한을 따라갑니다. 캐시는 memLimitMB로 계산합니다. 1GB 컨테이너는 공식대로면 0MB지만 최솟값 256MB가 적용되었습니다. 메모리 1GB 가운데 256MB가 WiredTiger 캐시이고, 나머지로 연결, 쿼리 실행, 운영체제 캐시를 감당해야 합니다. serverStatus의 maximum bytes configured는 cache_size를 바이트로 바꾼 값입니다(15508 × 1048576 = 16261316608). 캐시 크기는 실행 중에도 wiredTigerEngineRuntimeConfig 파라미터로 바꿀 수 있지만(cache_size=...), 재시작하면 설정 파일의 값으로 돌아갑니다.\neviction: 캐시에서 페이지를 내보내기 캐시가 차면 페이지를 내보내야 새 페이지를 올릴 수 있습니다. 이것이 eviction입니다. 깨끗한(clean) 페이지는 메모리에서 버리기만 하면 되고, dirty 페이지는 reconciliation으로 디스크에 쓴 뒤에 버립니다. 언제 누가 eviction을 하는지는 캐시에 대한 비율 네 쌍으로 정합니다. MongoDB는 이 값들을 설정 문자열에 넣지 않으므로(gWiredTigerEvictionDirtyTargetGB 같은 숨은 파라미터를 줄 때만 넣습니다, wiredtiger_kv_engine.cpp) WiredTiger의 기본값이 쓰입니다(api_data.py).\n대상 target: eviction 스레드가 시작 trigger: 애플리케이션 스레드도 참여 캐시 전체 사용량 eviction_target 80% eviction_trigger 95% dirty 데이터 eviction_dirty_target 5% eviction_dirty_trigger 20% update 목록의 크기 eviction_updates_target (dirty target의 절반) eviction_updates_trigger (dirty trigger의 절반) eviction server가 캐시 상태를 보고 할 일을 정하고(__evict_update_work()), 트리를 훑어 내보낼 후보를 큐에 넣습니다(__evict_server()). 큐의 페이지를 실제로 내보내는 것은 eviction worker 스레드(mongod에서는 4개)입니다. target을 넘은 동안 이 스레드들이 일합니다. trigger를 넘으면 백그라운드 스레드만으로는 부족하다고 보고, 캐시에 접근하는 애플리케이션 스레드(mongod에서는 요청을 처리하던 스레드)가 자기 일을 하기 전에 eviction을 거듭니다(__wt_cache_eviction_check(), __wt_eviction_needed()). 쿼리를 처리하던 스레드가 페이지를 디스크에 쓰는 일까지 하게 되니, 그만큼 응답이 늦어집니다. eviction server가 trigger를 넘은 것을 볼 때마다 number of times eviction trigger was reached, number of times dirty trigger was reached 통계가 1씩 늘어납니다(evict_lru.c). 256MB 캐시에 400MB 넣기 mongod를 --wiredTigerCacheSizeGB 0.25로 다시 띄우고, 1KB 남짓한 문서 40만 개(약 400MB)를 넣습니다. 캐시 통계는 아래 함수 하나로 모아 봅니다. 바이트 값은 MB로, 캐시 사용량과 dirty 양은 캐시 크기에 대한 비율로 바꿨습니다.\n// /data/cache.js function cache() { const c = db.serverStatus().wiredTiger.cache, mb = (b) =\u0026gt; Math.round(b / 1048576); const max = c[\u0026#34;maximum bytes configured\u0026#34;]; return {maxMB: mb(max), inCacheMB: mb(c[\u0026#34;bytes currently in the cache\u0026#34;]), usedPct: +(100 * c[\u0026#34;bytes currently in the cache\u0026#34;] / max).toFixed(1), dirtyMB: mb(c[\u0026#34;tracked dirty bytes in the cache\u0026#34;]), dirtyPct: +(100 * c[\u0026#34;tracked dirty bytes in the cache\u0026#34;] / max).toFixed(1), pagesRead: c[\u0026#34;pages read into cache\u0026#34;], pagesWritten: c[\u0026#34;pages written from cache\u0026#34;], workerEvict: c[\u0026#34;evict page attempts by eviction worker threads\u0026#34;], appEvict: c[\u0026#34;page evict attempts by application threads\u0026#34;], appEvictUsecs: c[\u0026#34;application thread time evicting (usecs)\u0026#34;], cleanEvicted: c[\u0026#34;unmodified pages evicted\u0026#34;], dirtyEvicted: c[\u0026#34;modified pages evicted\u0026#34;], dirtyTrigger: c[\u0026#34;number of times dirty trigger was reached\u0026#34;], evictionTrigger: c[\u0026#34;number of times eviction trigger was reached\u0026#34;]}; } // /data/load-ev.js: 1KB 남짓한 문서 40만 개(약 400MB)를 넣는다 let seed = 7; const rnd = () =\u0026gt; (seed = (seed * 1103515245 + 12345) % 2147483648); let pool = \u0026#34;\u0026#34;; while (pool.length \u0026lt; 1000000) pool += rnd().toString(36); for (let b = 0; b \u0026lt; 400; b++) { const docs = []; for (let i = 0; i \u0026lt; 1000; i++) docs.push({_id: b * 1000 + i, user: \u0026#34;user\u0026#34; + (rnd() % 100000), amount: rnd() % 100000, memo: pool.substr(rnd() % 990000, 1000)}); db.ev.insertMany(docs); } test\u003e cache() { maxMB: 256, inCacheMB: 0, usedPct: 0, dirtyMB: 0, dirtyPct: 0, pagesRead: 23, pagesWritten: 3, workerEvict: 0, appEvict: 0, appEvictUsecs: 0, cleanEvicted: 0, dirtyEvicted: 0, dirtyTrigger: 0, evictionTrigger: 0 } $ time mongosh --quiet /data/load-ev.js real\t0m2.046s user\t0m1.521s sys\t0m0.176s test\u003e cache() { maxMB: 256, inCacheMB: 180, usedPct: 70.4, dirtyMB: 1, dirtyPct: 0.5, pagesRead: 32, pagesWritten: 8979, workerEvict: 688, appEvict: 63, appEvictUsecs: 17973, cleanEvicted: 601, dirtyEvicted: 248, dirtyTrigger: 0, evictionTrigger: 0 } test\u003e const s = db.ev.stats(); ({count: s.count, sizeMB: Math.round(s.size / 1048576), storageSizeMB: Math.round(s.storageSize / 1048576)}) { count: 400000, sizeMB: 403, storageSizeMB: 336 } 403MB를 넣었는데 캐시에는 180MB(70.4%)만 있습니다. 캐시보다 큰 데이터는 넣는 동안 계속 캐시 밖으로 나갔다는 뜻입니다. pagesWritten 8979: 넣은 페이지를 디스크에 쓴 횟수입니다. dirty가 0.5%로 낮은 것은 쓴 페이지가 금방 디스크로 나가 clean이 되었기 때문입니다. eviction worker가 688번, 애플리케이션 스레드가 63번 페이지를 내보내려 했습니다. 문서를 넣던 요청 스레드가 eviction에 쓴 시간이 모두 17973µs(약 18ms)입니다. 이번 적재에서는 대부분 worker가 감당했고, 트리거 도달 횟수도 0입니다. 모든 문서를 고치면 dirty가 늘어난다 이번에는 400MB 전체를 한 번씩 고칩니다. 캐시에 없는 페이지는 읽어 와야 하고, 고친 페이지는 dirty가 되므로 eviction이 dirty 페이지를 써서 내보내야 합니다.\ntest\u003e let t = Date.now(); db.ev.updateMany({}, {$inc: {amount: 1}}).modifiedCount + \u0026#34; docs, \u0026#34; + (Date.now() - t) + \u0026#34; ms\u0026#34; 400000 docs, 967 ms test\u003e cache() { maxMB: 256, inCacheMB: 206, usedPct: 80.6, dirtyMB: 11, dirtyPct: 4.3, pagesRead: 5128, pagesWritten: 22401, workerEvict: 10952, appEvict: 68, appEvictUsecs: 18383, cleanEvicted: 2395, dirtyEvicted: 8770, dirtyTrigger: 9, evictionTrigger: 0 } 캐시 사용량이 80.6%로, eviction_target(80%) 바로 위에 머물러 있습니다. worker 스레드가 80% 근처를 지키도록 계속 내보낸 결과입니다. 내보낸 페이지 가운데 dirty 페이지(dirtyEvicted)가 8770개로, 앞 단계의 248개에서 크게 늘었습니다. pagesWritten도 8979에서 22401로 늘었습니다. 고친 페이지는 디스크에 써야 내보낼 수 있기 때문입니다. dirtyTrigger가 9입니다. 업데이트 도중 dirty가 eviction_dirty_trigger(20%)를 넘은 적이 9번 있었다는 뜻입니다. 끝난 뒤 찍은 순간 값(4.3%)만 봐서는 알 수 없는 일입니다. 애플리케이션 스레드의 eviction은 68번, 18383µs로 앞 단계에서 거의 늘지 않았습니다. 요청 스레드는 트랜잭션 도중(쓰기를 해서 트랜잭션 ID를 받은 뒤 등)에는 dirty trigger를 보지 않고 캐시 전체의 trigger만 봅니다(cache_inline.h, cache_inline.h). 이번 실행에서는 요청이 updateMany 하나뿐이라 worker 네 개가 대부분을 처리했습니다. 동시에 들어오는 요청이 많은 서버라면 이 몫이 요청 스레드로 넘어올 수 있습니다. 캐시보다 큰 컬렉션 훑기 마지막으로 400MB 전체를 읽기만 합니다.\ntest\u003e t = Date.now(); db.ev.aggregate([{$group: {_id: null, n: {$sum: 1}, total: {$sum: \u0026#34;$amount\u0026#34;}}}]).toArray()[0].n + \u0026#34; docs, \u0026#34; + (Date.now() - t) + \u0026#34; ms\u0026#34; 400000 docs, 143 ms test\u003e cache() { maxMB: 256, inCacheMB: 199, usedPct: 77.8, dirtyMB: 11, dirtyPct: 4.3, pagesRead: 15308, pagesWritten: 22401, workerEvict: 21297, appEvict: 72, appEvictUsecs: 18388, cleanEvicted: 12701, dirtyEvicted: 8770, dirtyTrigger: 9, evictionTrigger: 1 } 읽기만 했으므로 pagesWritten(22401)과 dirtyEvicted(8770)는 그대로이고, cleanEvicted만 2395에서 12701로 늘었습니다. 읽어 온 페이지는 clean이라 쓰지 않고 버립니다. pagesRead가 5128에서 15308로 약 1만 페이지 늘었습니다. 캐시에 다 들어가지 않는 컬렉션을 훑으면, 앞에서 읽은 페이지를 내보내면서 뒤의 페이지를 읽어 오게 됩니다. 이 스캔이 끝난 뒤 캐시에 남은 것은 컬렉션의 뒷부분입니다. 다른 쿼리가 자주 쓰던 페이지도 이때 함께 밀려났을 수 있습니다. evictionTrigger가 1이 되었습니다. 스캔 도중 캐시 사용량이 한 번 95%를 넘은 것입니다. 이 실습의 수치(소요 시간 포함)는 다른 실습이 함께 돌던 한 대의 Docker VM에서, 운영체제 페이지 캐시에 파일이 다 들어 있는 상태로 잰 것입니다. 실제 디스크에서 읽어야 하는 서버라면 eviction과 캐시 miss의 비용은 훨씬 큽니다.\n운영에서는 이렇게 나타납니다 캐시 사용률과 dirty 비율을 본다 앞의 실습처럼, 캐시가 데이터보다 작으면 캐시 사용률은 80% 근처에서 머물고 이것은 정상입니다. 봐야 할 것은 trigger를 넘는 상황입니다. serverStatus().wiredTiger.cache에서 다음 값을 주기적으로 모읍니다.\n지표 계산 주의할 수준 캐시 사용률 bytes currently in the cache / maximum bytes configured 80%에 머무는 것은 정상, 95%(eviction trigger)에 닿으면 요청 스레드가 eviction에 참여 dirty 비율 tracked dirty bytes in the cache / maximum bytes configured 5%를 넘으면 worker가 dirty 페이지를 쓰기 시작, 20%(dirty trigger)에 닿으면 요청 스레드 참여 요청 스레드의 eviction page evict attempts by application threads, application thread time evicting (usecs) 늘어나는 속도. 이 시간이 곧 요청의 지연 trigger 도달 횟수 number of times eviction trigger was reached, number of times dirty trigger was reached 순간 값으로는 놓치는 짧은 초과를 잡음 캐시 miss pages read into cache, application threads page read from disk to cache time (usecs) 작업 집합이 캐시보다 큰지 순간 값은 앞 실습의 dirty 4.3%처럼 이미 지나간 초과를 보여 주지 않으므로, 누적 카운터의 증가량을 함께 봐야 합니다. 요청 스레드의 eviction 시간이 늘어나는 시점과 느린 쿼리 로그가 늘어나는 시점이 겹친다면 캐시 압박이 원인일 가능성이 큽니다.\n컨테이너의 메모리 제한과 캐시 크기 mongod는 cgroup의 메모리 제한을 읽어 캐시를 잡습니다. 반대로 말하면, 제한 없이 큰 호스트에 컨테이너 여러 개를 띄우면 mongod마다 호스트 메모리의 절반을 캐시로 잡으려 합니다. hostInfo의 memLimitMB와 Opening WiredTiger 로그의 cache_size로 실제 값을 확인하고, 한 호스트에 여러 인스턴스를 둘 때는 cacheSizeGB를 직접 정해야 합니다. WiredTiger 캐시는 mongod가 쓰는 메모리의 전부가 아닙니다. 연결마다 쓰는 메모리, 집계와 정렬의 작업 메모리, 인덱스 빌드가 따로 있고, 압축된 블록을 담는 운영체제 캐시도 필요합니다. 1GB 컨테이너처럼 제한이 작으면 최솟값 256MB가 적용되어, 캐시가 공식보다 크게 잡힙니다.\n컬렉션과 인덱스가 많으면 파일이 많다 컬렉션 하나에 인덱스가 다섯이면 파일이 여섯입니다. 컬렉션이 수만 개인 멀티테넌트 구조에서는 dbPath에 파일이 수십만 개가 되고, 파일 핸들과 WiredTiger의 data handle도 그만큼 필요합니다. mongod는 600초 동안 쓰지 않은 핸들을 닫고(file_manager=(close_idle_time=600,...)), 핸들이 2000개 이상일 때만 닫기를 시도합니다(close_handle_minimum=2000). 파일이 많으면 체크포인트가 훑어야 할 테이블도 많아지고(2편), 시작할 때 _mdb_catalog를 읽어 모든 테이블을 확인하는 시간도 길어집니다. 운영체제의 파일 디스크립터 제한(ulimit -n)은 이 수를 넉넉히 감당하도록 잡아야 합니다.\n압축 방식은 디스크와 CPU의 교환 압축은 블록 단위라서 캐시에 필요한 메모리를 줄이지 않고, 디스크 용량과 읽기 I/O를 줄입니다. 데이터가 캐시보다 훨씬 크고 디스크 I/O가 병목이라면 zstd로 얻는 것이 많고, 캐시 안에서 대부분 처리되는 작업이라면 차이가 작습니다. 압축 방식은 컬렉션을 만들 때 정해지므로, 기존 컬렉션을 바꾸려면 새 컬렉션을 만들어 옮기거나 새 멤버를 초기 동기화로 만들어야 합니다.\n정리 mongod의 쿼리 계층은 KVEngine, RecordStore, SortedDataInterface라는 스토리지 엔진 API만 알고, WiredTiger 구현이 이를 WT_SESSION과 WT_CURSOR 호출로 바꿉니다. 컬렉션과 인덱스는 각각 WiredTiger 테이블 하나, 파일 하나입니다. 파일 이름(ident)과 컬렉션 이름의 짝은 _mdb_catalog.wt에 있고, WiredTiger 자신의 메타데이터는 WiredTiger.wt와 WiredTiger.turtle에 있습니다. 컬렉션 테이블은 RecordId → BSON, 일반 인덱스는 KeyString+RecordId → 빈 값, _id 인덱스는 KeyString → RecordId인 B-tree입니다. 디스크의 블록은 압축된 가변 크기이고, 캐시의 페이지는 압축이 풀린 이미지에 update 목록이 붙은 것입니다. 압축은 디스크 크기만 바꿉니다. 기본 캐시 크기는 max((메모리 − 1GB) × 50%, 256MB)이고, 메모리는 cgroup 제한을 따릅니다. 캐시 사용량이 80%, dirty가 5%를 넘으면 eviction 스레드가, 95%와 20%를 넘으면 요청 스레드까지 페이지를 내보냅니다. 다음 글에서는 캐시의 변경이 파일에 확정되는 체크포인트와, 그 사이의 변경을 지키는 저널을 봅니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/db/storage/kv/kv_engine.h, record_store.h, sorted_data_interface.h: 스토리지 엔진 API src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp: wiredtiger_open 설정 src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp, wiredtiger_index.cpp: 컬렉션과 인덱스의 테이블 설정, 키와 값 src/mongo/db/storage/key_string.cpp: KeyString 인코딩 src/mongo/db/storage/durable_catalog.cpp: _mdb_catalog, ident src/mongo/db/storage/wiredtiger/wiredtiger_util.cpp, src/mongo/util/processinfo_linux.cpp: 캐시 크기 계산, cgroup 메모리 제한 src/third_party/wiredtiger/dist/api_data.py: WiredTiger 설정 기본값 src/third_party/wiredtiger/src/evict/evict_lru.c, src/include/cache_inline.h: eviction src/third_party/wiredtiger/src/reconcile/rec_write.c, src/btree/bt_handle.c: reconciliation, 압축 전 페이지 크기 조정 MongoDB 8.0 공식 문서\nWiredTiger Storage Engine serverStatus Configuration File Options ","permalink":"https://jinlog.dev/posts/mongodb/01-wiredtiger-architecture/","summary":"MongoDB의 데이터는 실제로 어디에 어떻게 저장되고, 메모리에서는 어떻게 관리되는가","title":"MongoDB 인터널 1: WiredTiger 스토리지 엔진 구조와 캐시"},{"content":"개요 인터널 9편에서 스트리밍 복제로 standby를 만들었습니다. standby는 primary의 WAL을 계속 받아 재생하므로 primary가 죽으면 pg_ctl promote 한 번으로 새 primary가 될 수 있습니다. 문제는 그 한 번을 누가, 언제 하느냐입니다. 사람이 새벽에 알람을 받고 판단하는 동안 서비스는 멈춰 있고, 서두르다 보면 더 나쁜 일이 생깁니다.\n자동으로 하려면 생각보다 많은 것을 풀어야 합니다.\nprimary가 정말 죽었는지, 아니면 나와의 네트워크만 끊겼는지 어떻게 아는가 두 서버가 동시에 primary가 되어 각자 쓰기를 받는 split-brain은 어떻게 막는가 standby가 여러 대이면 누구를 올려야 데이터를 가장 적게 잃는가 남은 standby와 되살아난 옛 primary를 새 primary에 어떻게 다시 붙이는가 애플리케이션은 새 primary를 어떻게 찾아가는가 Patroni는 이 문제를 각 PostgreSQL 노드에 붙는 데몬과, 여러 서버가 합의해서 값을 저장하는 분산 저장소(DCS, Distributed Configuration Store)로 풉니다. 이 글에서는 Patroni가 무엇으로 이루어져 있고 어떤 규칙으로 primary를 정하고 바꾸는지를 실제 클러스터에서 확인합니다. 클러스터를 처음부터 구성하는 방법은 2편에서 다룹니다.\n이 글에서 답할 질문은 다음과 같습니다.\nPatroni 클러스터는 어떤 구성 요소로 이루어지는가 DCS에는 무엇이 저장되고, \u0026ldquo;leader\u0026quot;라는 자격은 어떻게 표현되는가 Patroni는 얼마나 자주 무엇을 확인하는가 primary가 죽으면 replica들은 무엇을 근거로 새 primary를 고르는가 DCS와 연락이 끊긴 primary는 어떻게 행동하는가 기준 버전: Patroni 4.1.5, PostgreSQL 18.6, etcd 3.7.2, HAProxy 3.4.5. 모두 PGDG 저장소의 Rocky Linux 9 RPM입니다. 실습은 rockylinux:9 이미지(Rocky Linux 9.8로 업데이트)로 만든 컨테이너 7대(etcd 3대, PostgreSQL과 Patroni 3대, HAProxy 1대)에서 systemd로 서비스를 띄워 실행했습니다. Patroni 소스 링크는 v4.1.5 태그 기준입니다.\nPatroni 클러스터의 구성 요소 각 노드의 Patroni가 자기 PostgreSQL을 관리하고, etcd의 leader key로 누가 primary인지 정합니다. 애플리케이션은 HAProxy를 거쳐 들어오고, HAProxy는 Patroni REST API로 primary를 찾습니다. 새 창에서 크게 보기 실습 클러스터의 이름(scope)은 pg-ha이고 노드는 pg1, pg2, pg3입니다.\nPatroni 데몬: PostgreSQL 노드마다 하나씩 돕니다. PostgreSQL을 직접 시작하고, 멈추고, promote하고, standby로 되돌립니다. 운영자가 pg_ctl을 직접 쓰는 대신 Patroni에게 맡기는 구조입니다. DCS: 클러스터 상태의 유일한 기준입니다. 누가 leader인지, 멤버가 누구인지, 클러스터 전체에 적용할 설정이 무엇인지가 여기에 있습니다. etcd, Consul, ZooKeeper, Kubernetes API를 쓸 수 있습니다. 이 연재는 etcd(v3 API)를 씁니다. etcd 자체도 3대로 쿼럼을 이루므로 한 대가 죽어도 동작합니다. REST API: 각 Patroni가 8008번 포트로 엽니다. Patroni끼리 서로의 WAL 위치를 물어볼 때, HAProxy가 primary를 찾을 때, patronictl이 명령을 보낼 때 씁니다. patronictl: 운영자용 명령행 도구입니다. 클러스터 상태 보기, switchover, 재시작, 설정 변경을 합니다. HAProxy: Patroni는 애플리케이션의 연결을 옮겨 주지 않습니다. 새 primary가 생겨도 애플리케이션이 그쪽으로 가게 하는 일은 별도 계층의 몫입니다. 여기서는 HAProxy가 REST API의 응답을 보고 연결을 보냅니다. 복제 자체는 PostgreSQL의 스트리밍 복제 그대로입니다. Patroni는 WAL을 나르지 않고, 누가 primary인지 정하고 나머지가 그 primary를 따르도록 설정을 써 줄 뿐입니다.\nPatroni가 PostgreSQL을 자식 프로세스로 띄운다 systemd가 띄우는 것은 Patroni 하나이고 PostgreSQL은 Patroni의 자식 프로세스로 뜹니다. pg1에서 서비스 상태를 봅니다.\n$ systemctl status patroni --no-pager ● patroni.service - Runners to orchestrate a high-availability PostgreSQL Loaded: loaded (/usr/lib/systemd/system/patroni.service; disabled; preset: disabled) Active: active (running) since Fri 2026-09-25 22:22:14 UTC; 12s ago Main PID: 100 (patroni) Tasks: 28 (limit: 205294) Memory: 96.4M (peak: 108.7M) CPU: 593ms CGroup: /docker/81cd58c6b4fd2aa5b4a7ce063c1b562f74ecf2f391458e69e06715db6787b997/system.slice/patroni.service ├─100 /usr/bin/python3.12 /usr/bin/patroni /etc/patroni/patroni.yml ├─131 /usr/pgsql-18/bin/postgres -D /var/lib/pgsql/18/data --config-file=/var/lib/pgsql/18/data/postgresql.conf --listen_addresses=0.0.0.0 --port=5432 --cluster_name=pg-ha --wal_level=replica --hot_standby=on --max_connections=100 --max_wal_senders=10 --max_prepared_transactions=0 --max_locks_per_transaction=64 --track_commit_timestamp=off --max_replication_slots=10 --max_worker_processes=8 --wal_log_hints=on ├─132 \u0026#34;postgres: pg-ha: logger \u0026#34; ├─134 \u0026#34;postgres: pg-ha: io worker 0\u0026#34; ├─135 \u0026#34;postgres: pg-ha: io worker 1\u0026#34; ├─136 \u0026#34;postgres: pg-ha: io worker 2\u0026#34; ├─137 \u0026#34;postgres: pg-ha: checkpointer \u0026#34; ├─138 \u0026#34;postgres: pg-ha: background writer \u0026#34; ├─142 \u0026#34;postgres: pg-ha: walwriter \u0026#34; ├─143 \u0026#34;postgres: pg-ha: autovacuum launcher \u0026#34; ├─144 \u0026#34;postgres: pg-ha: logical replication launcher \u0026#34; └─146 \u0026#34;postgres: pg-ha: postgres postgres 127.0.0.1(50430) idle\u0026#34; patroni.service의 cgroup 안에 Patroni(PID 100)와 postmaster(PID 131), 그리고 인터널 1편에서 본 백그라운드 프로세스들이 함께 있습니다. postmaster의 명령행에 --max_connections=100, --wal_level=replica 같은 파라미터가 직접 붙어 있습니다. Patroni는 복제에 꼭 맞아야 하는 몇몇 파라미터를 명령행으로 넘깁니다. 명령행 값은 postgresql.conf나 ALTER SYSTEM보다 우선하므로 이 값들은 DCS 설정으로만 바꿀 수 있습니다(3편에서 확인합니다). 마지막 줄의 127.0.0.1(50430) idle은 Patroni가 PostgreSQL 상태를 확인하려고 붙어 있는 연결입니다. DCS에 무엇이 저장되는가 Patroni는 etcd의 /service/\u0026lt;scope\u0026gt;/ 아래에 키를 만듭니다(namespace 기본값이 /service/). pg1 하나만 부트스트랩한 직후의 키입니다.\n$ etcdctl get --prefix /service/pg-ha --keys-only | grep . /service/pg-ha/config /service/pg-ha/initialize /service/pg-ha/leader /service/pg-ha/members/pg1 /service/pg-ha/status $ etcdctl get /service/pg-ha/initialize --print-value-only 7689601790135361659 $ etcdctl get /service/pg-ha/leader --print-value-only pg1 $ etcdctl get /service/pg-ha/members/pg1 --print-value-only | jq . { \u0026#34;conn_url\u0026#34;: \u0026#34;postgres://172.30.0.21:5432/postgres\u0026#34;, \u0026#34;api_url\u0026#34;: \u0026#34;http://172.30.0.21:8008/patroni\u0026#34;, \u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;primary\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;4.1.5\u0026#34;, \u0026#34;xlog_location\u0026#34;: 24506360, \u0026#34;timeline\u0026#34;: 1 } $ etcdctl get /service/pg-ha/status --print-value-only {\u0026#34;optime\u0026#34;:24506416,\u0026#34;slots\u0026#34;:{\u0026#34;pg1\u0026#34;:24506416},\u0026#34;retain_slots\u0026#34;:[\u0026#34;pg1\u0026#34;]} 키 내용 initialize 클러스터를 처음 만든 노드가 적은 system identifier(pg_controldata의 값). 이 키가 있으면 다른 노드는 initdb 대신 기존 클러스터를 복제해 합류합니다 config 클러스터 전체에 적용하는 동적 설정(ttl, loop_wait, PostgreSQL 파라미터 등). patronictl edit-config로 바꿉니다 leader 지금 primary인 멤버의 이름. 이 키를 가진 노드만 primary로 동작할 수 있습니다 members/\u0026lt;이름\u0026gt; 각 멤버가 스스로 보고하는 접속 주소, REST API 주소, 역할, WAL 위치, timeline status leader가 기록하는 마지막 WAL 위치(optime)와 replication slot 위치 클러스터를 운영하다 보면 키가 더 생깁니다. 실습을 모두 마친 뒤의 목록입니다.\n$ etcdctl get --prefix /service/pg-ha --keys-only | grep . /service/pg-ha/config /service/pg-ha/failover /service/pg-ha/failsafe /service/pg-ha/history /service/pg-ha/initialize /service/pg-ha/leader /service/pg-ha/members/pg1 /service/pg-ha/members/pg2 /service/pg-ha/members/pg3 /service/pg-ha/status /service/pg-ha/sync history는 timeline이 바뀔 때마다(leader가 바뀔 때마다) 쌓이는 기록이고, failover는 switchover 요청을 전달하는 데, sync와 failsafe는 3편에서 볼 동기 복제와 failsafe 모드에 씁니다.\nleader key: TTL이 붙은 잠금 leader key는 스스로 사라지는 잠금입니다. etcd v3에서는 키에 lease를 붙이고, lease를 갱신하지 않으면 TTL이 지난 뒤 키가 지워집니다. leader인 Patroni는 주기적으로 lease를 갱신해 키를 지킵니다. 갱신이 멈추면 키가 사라져 다른 노드가 차지할 수 있게 됩니다.\nlease는 10초마다 30초로 되돌아간다 $ etcdctl get /service/pg-ha/leader -w json | jq -c \u0026#34;.kvs[0] | {lease}\u0026#34; {\u0026#34;lease\u0026#34;:6242728894488118532} $ printf \u0026#34;%x\\n\u0026#34; 6242728894488118532 56a2a0daa9080504 $ etcdctl lease timetolive 56a2a0daa9080504 --keys lease 56a2a0daa9080504 granted with TTL(30s), remaining(28s), attached keys([/service/pg-ha/members/pg1 /service/pg-ha/leader]) $ for i in $(seq 1 8); do echo \u0026#34;$(date +%T) $(etcdctl lease timetolive 56a2a0daa9080504 | grep -o \u0026#34;remaining([0-9]*s)\u0026#34;)\u0026#34;; sleep 2; done 22:22:44 remaining(20s) 22:22:46 remaining(28s) 22:22:48 remaining(26s) 22:22:50 remaining(24s) 22:22:52 remaining(22s) 22:22:54 remaining(20s) 22:22:56 remaining(28s) 22:22:58 remaining(26s) 같은 시각 pg1의 Patroni 로그입니다.\n$ journalctl -u patroni -o cat --since \u0026#34;-30s\u0026#34; | tail -3 2026-09-25 22:22:35,530 INFO: no action. I am (pg1), the leader with the lock 2026-09-25 22:22:45,431 INFO: no action. I am (pg1), the leader with the lock 2026-09-25 22:22:55,433 INFO: no action. I am (pg1), the leader with the lock lease의 TTL은 30초(ttl)이고 남은 시간이 20초까지 줄었다가 28초로 돌아갑니다. Patroni가 10초(loop_wait)마다 한 번씩 갱신하기 때문이고, 로그의 the leader with the lock이 찍히는 시각(:45, :55)과 같습니다. leader key와 pg1의 members/pg1 키가 같은 lease에 붙어 있습니다. pg1이 갱신을 멈추면 leader 자격과 멤버 정보가 함께 사라집니다. Patroni는 세 값이 loop_wait + 2 × retry_timeout \u0026lt;= ttl 규칙을 지키도록 요구하고, 어기면 retry_timeout을 줄여 맞춥니다(config.py). 기본값 ttl 30, loop_wait 10, retry_timeout 10이 이 규칙을 딱 맞춘 값입니다. 이유는 뒤에서 봅니다. leader가 DCS에 닿지 못하면 lease가 만료되기 전에 스스로 물러날 시간이 있어야 하기 때문입니다.\nHA loop: 10초마다 하는 판단 모든 Patroni는 loop_wait마다 같은 순서로 한 바퀴를 돕니다(Ha._run_cycle()). DCS에서 클러스터 상태를 읽고, 자기 PostgreSQL이 어떤 상태인지 확인한 뒤, 둘을 맞추는 행동을 하나 고릅니다. 로그의 no action. I am (...) 한 줄이 이 한 바퀴의 결론입니다.\n상황 하는 일 로그 leader key가 내 것 lease 갱신, status에 WAL 위치 기록 the leader with the lock leader key가 남의 것 그 leader를 따르도록 primary_conninfo를 맞춤 a secondary, and following a leader (pg1) leader key가 없음 leader 경쟁(leader race) Lock owner: None key는 내 것인데 PostgreSQL이 죽음 제자리에서 다시 시작 starting primary after failure DCS에 닿지 못함 primary였다면 강등 demoting self because DCS is not accessible loop 사이에도 etcd의 watch로 leader key가 바뀌는 것을 알아채면 바로 다음 바퀴를 돕니다. 그래서 switchover처럼 key를 일부러 내려놓는 경우에는 10초를 다 기다리지 않습니다(2편에서 switchover는 1.2초 만에 끝납니다).\nPostgreSQL만 죽으면 failover가 아니라 재시작한다 primary pg2의 postmaster를 kill -9로 죽여 봅니다. Patroni 프로세스는 살아 있습니다.\n$ kill -9 $(head -1 /var/lib/pgsql/18/data/postmaster.pid) $ journalctl -u patroni -o cat --since \u0026#34;-16s\u0026#34; | grep -E \u0026#34;INFO|WARN\u0026#34; | grep -v \u0026#34;heartbeat connection\u0026#34; 2026-09-25 22:28:51,093 INFO: establishing a new patroni restapi connection to postgres 2026-09-25 22:28:56,690 WARNING: Postgresql is not running. 2026-09-25 22:28:56,691 INFO: Lock owner: pg2; I am pg2 2026-09-25 22:28:56,737 INFO: pg_controldata: 2026-09-25 22:28:56,788 INFO: starting primary after failure 2026-09-25 22:28:56,929 INFO: postmaster pid=702 2026-09-25 22:28:57,158 INFO: establishing a new patroni heartbeat connection to postgres 2026-09-25 22:28:57,169 INFO: establishing a new patroni restapi connection to postgres 2026-09-25 22:28:58,082 INFO: no action. I am (pg2), the leader with the lock $ patronictl -c /etc/patroni/patroni.yml list + Cluster: pg-ha (7689601790135361659) ----+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Replica | running | 4 | 0/54CBDE0 | 0 | 0/54CBDE0 | 0 | | pg2 | 172.30.0.22 | Leader | running | 4 | | | | | | pg3 | 172.30.0.23 | Replica | running | 4 | 0/54CBDE0 | 0 | 0/54CBDE0 | 0 | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ 22:28:50.2에 죽였고 다음 loop인 22:28:56.7에 Postgresql is not running을 확인한 뒤 starting primary after failure로 같은 노드에서 다시 띄웠습니다. leader key는 pg2가 계속 쥐고 있었으므로(Lock owner: pg2) failover는 일어나지 않았고, timeline도 4 그대로입니다. 0.5초마다 쓰던 클라이언트는 약 8초 동안 쓰지 못했고, 22:28:57.9부터 같은 pg2에 다시 썼습니다. 재시작이 계속 실패하면 언제 포기하고 failover할지는 primary_start_timeout(기본 300초)이 정합니다. Patroni가 먼저 재시작을 시도하는 것은 합리적입니다. failover는 timeline을 바꾸고 비동기 복제라면 데이터를 잃을 수도 있는 큰 사건이지만, 제자리 재시작은 crash recovery만 하면 되기 때문입니다.\nleader가 사라지면: leader race leader key가 사라지면 replica들이 경쟁합니다. 각자 몇 가지를 확인한 뒤에야 key를 잡으려고 시도합니다(Ha.is_healthiest_node(), Ha._is_healthiest_node()).\n자기 점검(nofailover 태그, pause, 복제 지연)을 통과한 replica만 다른 멤버와 WAL 위치를 비교하고, 가장 앞선 노드가 leader key를 만듭니다. key는 없을 때만 만들 수 있으므로 한 노드만 성공합니다. 새 창에서 크게 보기 후보인가: nofailover: true 태그가 붙은 노드와, 클러스터가 pause 상태일 때는 후보가 아닙니다. 너무 뒤처지지 않았는가: 내가 받은 WAL 위치가 DCS status에 적힌 leader의 마지막 위치보다 maximum_lag_on_failover(실습은 1MB)보다 더 뒤처졌으면 빠집니다(Ha.is_lagging()). 나보다 앞선 멤버가 있는가: 다른 멤버의 REST API(GET /patroni)에 물어 WAL 위치를 비교합니다. 나보다 앞선 멤버가 있으면 양보합니다. 위치가 같으면 failover_priority 태그가 높은 쪽이 이깁니다. leader key 만들기: etcd에 \u0026ldquo;키가 없을 때만 생성\u0026quot;을 요청합니다. 두 노드가 동시에 시도해도 etcd는 하나만 성공시키므로 leader는 하나만 나옵니다. 이긴 노드는 PostgreSQL을 promote하고(timeline이 1 올라갑니다), 진 노드는 새 leader를 따릅니다. primary 노드를 통째로 죽인다 primary pg1 컨테이너를 docker kill로 죽입니다. 전원이 나간 것과 같아서 pg1의 Patroni도 함께 죽고 아무도 lease를 갱신하지 않습니다. 다른 노드에서 2초마다 leader key를 봅니다.\n$ docker kill pg1 # 22:27:11.440 $ for i in $(seq 1 20); do echo \u0026#34;$(date +%T) leader=$(etcdctl get /service/pg-ha/leader --print-value-only)\u0026#34;; sleep 2; done 22:27:11 leader=pg1 22:27:13 leader=pg1 22:27:15 leader=pg1 22:27:17 leader=pg1 22:27:20 leader=pg1 22:27:22 leader=pg1 22:27:24 leader=pg1 22:27:26 leader=pg1 22:27:28 leader=pg1 22:27:30 leader=pg1 22:27:32 leader=pg1 22:27:35 leader= 22:27:37 leader=pg2 22:27:39 leader=pg2 pg1이 죽은 뒤에도 약 22초 동안 leader key는 pg1으로 남아 있었습니다. 마지막 갱신에서 30초가 지나 lease가 만료되고 나서야 key가 사라졌습니다. 그 순간 pg2와 pg3의 로그입니다(Got response 줄의 JSON은 줄였습니다).\n$ journalctl -u patroni -o cat --since 22:27:05 --until 22:27:42 | grep -E \u0026#34;INFO|WARN\u0026#34; # pg2 2026-09-25 22:27:13,501 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:27:23,454 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:27:33,355 INFO: Got response from pg3 http://172.30.0.23:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, ... \u0026#34;role\u0026#34;: \u0026#34;replica\u0026#34;, ... 2026-09-25 22:27:35,348 WARNING: Request failed to pg1: GET http://172.30.0.21:8008/patroni (HTTPConnectionPool(host=\u0026#39;172.30.0.21\u0026#39;, port=8008): Max retries exceeded ... 2026-09-25 22:27:35,486 INFO: promoted self to leader by acquiring session lock 2026-09-25 22:27:35,486 INFO: Lock owner: pg2; I am pg2 2026-09-25 22:27:35,571 INFO: updated leader lock during promote 2026-09-25 22:27:36,684 INFO: no action. I am (pg2), the leader with the lock $ journalctl -u patroni -o cat --since 22:27:05 --until 22:27:42 | grep -E \u0026#34;INFO|WARN\u0026#34; # pg3 2026-09-25 22:27:33,355 INFO: Got response from pg2 http://172.30.0.22:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, ... \u0026#34;role\u0026#34;: \u0026#34;replica\u0026#34;, ... 2026-09-25 22:27:35,358 WARNING: Request failed to pg1: GET http://172.30.0.21:8008/patroni (HTTPConnectionPool(host=\u0026#39;172.30.0.21\u0026#39;, port=8008): Max retries exceeded ... 2026-09-25 22:27:35,446 INFO: Could not take out TTL lock 2026-09-25 22:27:35,455 INFO: following new leader after trying and failing to obtain lock 2026-09-25 22:27:35,455 INFO: Lock owner: pg2; I am pg3 2026-09-25 22:27:35,507 INFO: no action. I am (pg3), a secondary, and following a leader (pg2) pg2와 pg3는 서로의 REST API에 WAL 위치를 물었고(Got response from ...), 옛 leader pg1에게도 물었지만 응답이 없었습니다. 둘의 위치가 같아 둘 다 key를 만들려고 했습니다. pg2가 먼저 성공했고(promoted self to leader by acquiring session lock), pg3는 Could not take out TTL lock으로 실패한 뒤 pg2를 따르기 시작했습니다. etcd의 원자적 생성 덕분에 leader는 하나만 나왔습니다. 22:27:11.4에 죽이고 22:27:35.5에 promote했으니 failover에 24초가 걸렸습니다. 0.5초마다 HAProxy로 쓰던 클라이언트는 22:27:11.2의 성공 뒤 22:27:35.8까지 쓰지 못했습니다. 이 시간의 대부분은 lease 만료를 기다린 시간입니다. Patroni가 \u0026ldquo;죽었다\u0026quot;를 판단하는 근거는 lease 만료입니다. 노드가 갑자기 죽으면 최악의 경우 ttl만큼 기다린 뒤에야 failover가 시작됩니다. ttl을 줄이면 빨라지지만 그만큼 짧은 네트워크 지연이나 etcd의 일시적인 느려짐도 \u0026ldquo;leader가 죽었다\u0026quot;로 해석될 수 있습니다.\nDCS와 끊긴 primary는 스스로 내려간다 leader key 방식에는 빈틈이 하나 있어 보입니다. primary가 살아 있는데 etcd와의 연결만 끊기면 어떻게 될까요? lease는 만료되고 다른 노드가 promote합니다. 그런데 옛 primary가 계속 쓰기를 받으면 split-brain입니다.\nPatroni의 답은 leader가 DCS에 닿지 못하면 스스로 primary를 그만둔다입니다. primary pg2에서 etcd 포트(2379)로 나가는 연결만 막아 확인합니다. HAProxy와 다른 노드에서 pg2로의 연결은 그대로입니다.\n$ iptables -A OUTPUT -p tcp --dport 2379 -j REJECT --reject-with tcp-reset # pg2, 22:32:32.484 $ for i in $(seq 1 12); do echo \u0026#34;$(date +%T) pg2_in_recovery=$(psql -h /run/postgresql -Atc \u0026#39;select pg_is_in_recovery()\u0026#39;) leader=$(etcdctl get /service/pg-ha/leader --print-value-only)\u0026#34;; sleep 3; done 22:32:32 pg2_in_recovery=f leader=pg2 22:32:35 pg2_in_recovery=f leader=pg2 22:32:38 pg2_in_recovery=f leader=pg2 22:32:42 pg2_in_recovery=f leader=pg2 22:32:45 pg2_in_recovery=f leader=pg2 22:32:48 pg2_in_recovery=t leader=pg2 22:32:51 pg2_in_recovery=t leader=pg2 22:32:54 pg2_in_recovery=t leader=pg2 22:32:58 pg2_in_recovery=t leader=pg2 22:33:01 pg2_in_recovery=t leader=pg1 22:33:04 pg2_in_recovery=t leader=pg1 22:33:07 pg2_in_recovery=t leader=pg1 (psql은 pg2에서, etcdctl은 etcd 노드에서 실행했습니다.) 22:32:48부터 pg2는 pg_is_in_recovery() = t, 즉 standby가 되었습니다. 그런데 etcd의 leader key는 22:33:01까지 여전히 pg2입니다. pg2가 먼저 내려가고 한참 뒤에 pg1이 올라왔습니다. pg2의 로그에서 반복되는 etcd 재시도 줄을 빼고 보면 이렇습니다.\n$ journalctl -u patroni -o cat --since 22:32:10 --until 22:33:10 | grep -vE \u0026#34;Request to server|Reconnection allowed|Retrying on|Failed to get list|KVCache\u0026#34; 2026-09-25 22:32:18,082 INFO: no action. I am (pg2), the leader with the lock 2026-09-25 22:32:28,080 INFO: no action. I am (pg2), the leader with the lock 2026-09-25 22:32:37,946 INFO: Lock owner: pg2; I am pg2 2026-09-25 22:32:38,620 ERROR: watchprefix failed: ProtocolError(\u0026#34;Connection broken: ConnectionResetError(104, \u0026#39;Connection reset by peer\u0026#39;)\u0026#34;, ConnectionResetError(104, \u0026#39;Connection reset by peer\u0026#39;)) 2026-09-25 22:32:46,056 ERROR: Error communicating with DCS 2026-09-25 22:32:46,057 INFO: demoting self because DCS is not accessible and I was a leader 2026-09-25 22:32:46,057 INFO: Demoting self (offline) 2026-09-25 22:32:47,084 INFO: closed patroni connections to postgres 2026-09-25 22:32:47,229 INFO: postmaster pid=1599 2026-09-25 22:32:47,414 INFO: establishing a new patroni heartbeat connection to postgres 2026-09-25 22:32:47,424 INFO: establishing a new patroni restapi connection to postgres 2026-09-25 22:32:48,246 INFO: demoted self because DCS is not accessible and I was a leader 2026-09-25 22:32:58,249 ERROR: Error communicating with DCS 2026-09-25 22:32:58,250 INFO: DCS is not accessible 같은 시각 pg1입니다.\n$ journalctl -u patroni -o cat --since 22:32:55 --until 22:33:05 | grep -E \u0026#34;INFO|WARN\u0026#34; | cut -c1-110 2026-09-25 22:32:58,339 INFO: Got response from pg3 http://172.30.0.23:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, \u0026#34;pos 2026-09-25 22:32:58,340 INFO: Got response from pg2 http://172.30.0.22:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, \u0026#34;pos 2026-09-25 22:32:58,468 INFO: promoted self to leader by acquiring session lock 2026-09-25 22:32:59,654 INFO: no action. I am (pg1), the leader with the lock pg2는 retry_timeout 안에 lease를 갱신하지 못하자 22:32:46에 스스로 read-only로 내려갑니다. leader key는 마지막 갱신(22:32:28) 뒤 30초가 지난 22:32:58에야 만료되고, 그때 pg1이 key를 잡습니다. 새 창에서 크게 보기 pg2가 마지막으로 lease를 갱신한 것은 22:32:28입니다. 다음 loop(22:32:37.9)부터 etcd에 닿지 못했고, retry_timeout(10초) 동안 재시도하다가 22:32:46에 demoting self because DCS is not accessible and I was a leader로 스스로 내려갔습니다(Ha._handle_dcs_error()). leader key는 22:32:28 + 30초 = 22:32:58에 만료되었고, pg1이 바로 그 시각(22:32:58.468)에 key를 잡았습니다. 그 사이 12초 동안은 primary가 하나도 없었습니다. 두 primary가 겹치는 순간은 없었습니다. HAProxy로 쓰던 클라이언트가 본 것도 같습니다.\n$ awk \u0026#39;$1\u0026gt;=\u0026#34;22:32:44\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:33:00\u0026#34;\u0026#39; /root/writer.log 22:32:44.401 172.30.0.22 INSERT 0 1 22:32:44.926 172.30.0.22 INSERT 0 1 22:32:45.437 172.30.0.22 INSERT 0 1 22:32:45.962 172.30.0.22 INSERT 0 1 22:32:47.492 ERROR: cannot execute INSERT in a read-only transaction 22:32:48.018 ERROR: cannot execute INSERT in a read-only transaction 22:32:48.533 ERROR: cannot execute INSERT in a read-only transaction 22:32:49.048 psql: error: connection to server at \u0026#34;127.0.0.1\u0026#34;, port 5000 failed: server closed the connection ... 22:32:59.887 172.30.0.21 INSERT 0 1 (writer.log는 0.5초마다 HAProxy 5000번 포트로 한 행씩 넣고 결과를 적는 스크립트의 출력입니다. 2편에서 만듭니다. ...는 같은 줄이 반복되는 부분을 줄였습니다.) pg2(172.30.0.22)는 22:32:45.9까지 쓰기를 받았고, 강등된 뒤에는 read-only 오류를 냈습니다. pg1(172.30.0.21)의 첫 쓰기는 22:32:59.9입니다. 쓰기를 받은 서버는 어느 순간에도 하나였습니다.\n이제 loop_wait + 2 × retry_timeout \u0026lt;= ttl 규칙의 뜻이 보입니다. leader가 마지막으로 갱신한 직후 DCS가 끊겨도, 다음 loop까지 loop_wait, 재시도에 retry_timeout을 쓰고 강등할 여유가 lease 만료 전에 남아야 합니다. 이 순서가 지켜지는 한 옛 primary의 강등이 새 primary의 승격보다 먼저 일어납니다.\n이 보장에는 옛 primary의 Patroni가 살아서 판단할 수 있어야 한다는 전제가 있습니다. Patroni 프로세스가 죽거나 멈추면 PostgreSQL은 primary로 남은 채 아무도 강등시키지 않습니다. 이 틈을 막는 것이 watchdog이고, 3편에서 실제로 두 primary가 생기는 것을 확인합니다.\n운영에서는 이렇게 나타납니다 failover 시간은 대부분 TTL이다. 노드가 통째로 죽으면 lease가 만료될 때까지 기다립니다. 실습에서는 24초였고, 기본값에서 최악은 30초 남짓입니다. 이 시간을 줄이려고 ttl을 낮추면 DCS나 네트워크가 잠깐 느려질 때도 멀쩡한 primary가 강등됩니다. 실제 장애 대응 시간 목표와 오탐 비용을 같이 놓고 정해야 합니다. PostgreSQL 프로세스 장애는 대개 failover가 아니다. postmaster가 죽으면 Patroni는 같은 노드에서 다시 띄웁니다. 로그에 starting primary after failure가 있으면 failover가 일어나지 않은 것이고, timeline도 그대로입니다. DCS는 PostgreSQL만큼 중요하다. leader가 DCS에 닿지 못하면 PostgreSQL이 멀쩡해도 강등됩니다. etcd 쿼럼이 깨지면 모든 노드가 DCS에 닿지 못하므로 클러스터 전체가 read-only가 됩니다(3편). Patroni는 연결을 옮겨 주지 않는다. 새 primary가 생겨도 애플리케이션이 옛 주소로 붙고 있으면 소용이 없습니다. HAProxy, pgBouncer, DNS, libpq의 target_session_attrs=read-write 같은 별도 수단이 필요합니다(2편). 정리 Patroni는 노드마다 도는 Patroni 데몬과 DCS(etcd 등)로 이루어집니다. Patroni가 PostgreSQL을 자식 프로세스로 띄우고, 누가 primary인지는 DCS의 leader key 하나로 정합니다. 복제는 PostgreSQL 스트리밍 복제 그대로입니다. leader key는 TTL(ttl, 기본 30초)이 붙은 잠금이고, leader는 loop_wait(기본 10초)마다 lease를 갱신합니다. 갱신이 멈추면 key가 사라지고 replica들이 경쟁합니다. 경쟁에서는 nofailover 태그, 복제 지연(maximum_lag_on_failover), 다른 멤버와의 WAL 위치 비교를 거쳐 가장 앞선 노드가 key를 만듭니다. etcd의 원자적 생성 덕분에 leader는 하나만 나옵니다. PostgreSQL만 죽으면 제자리에서 재시작하고, 노드가 죽으면 lease 만료 뒤 failover합니다(실습 24초). DCS에 닿지 못한 primary는 retry_timeout 안에 스스로 강등합니다. loop_wait + 2 × retry_timeout \u0026lt;= ttl 덕분에 강등이 새 primary의 승격보다 먼저 일어납니다. 단, 이것은 Patroni 프로세스가 살아 있을 때의 이야기입니다. 다음 글에서는 이 클러스터를 Rocky Linux 9에서 처음부터 구성하고, HAProxy를 붙여 switchover와 failover를 직접 해 봅니다.\n참고 자료 Patroni 소스 코드 (v4.1.5 태그 기준)\npatroni/ha.py: HA loop(_run_cycle), leader 경쟁(is_healthiest_node, is_lagging), DCS 장애 처리(_handle_dcs_error) patroni/config.py: loop_wait + 2*retry_timeout \u0026lt;= ttl 검사 patroni/dcs/etcd3.py: etcd v3 lease와 leader key 처리 Patroni 공식 문서\nIntroduction Dynamic Configuration Settings: ttl, loop_wait, retry_timeout, maximum_lag_on_failover YAML Configuration Settings: tags(nofailover, failover_priority) REST API etcd\netcd: Lease API ","permalink":"https://jinlog.dev/posts/postgresql/patroni-01-architecture/","summary":"primary가 죽었을 때 누가, 무엇을 근거로 새 primary를 정하는가","title":"Patroni HA 1: Patroni의 구조와 동작 원리"},{"content":"개요 장애가 나면 가장 먼저 부딪히는 문제는 \u0026ldquo;지금 무슨 일이 일어나고 있는지 볼 수단이 없다\u0026quot;는 것입니다. 로그에 아무것도 남지 않았거나, 필요한 확장이 설치되어 있지 않거나, 설치하려면 재시작이 필요한 경우가 많습니다. 이 연재의 나머지 편은 모두 여기서 준비하는 도구를 가져다 씁니다.\n이 글에서 답할 질문은 다음과 같습니다.\nPGDG 패키지로 설치한 직후에는 무엇이 기록되고 무엇이 기록되지 않는가 장애 전에 미리 켜 둘 설정은 무엇이고, 켤 때 어떤 실수로 서버가 뜨지 않게 되는가 pg_stat_activity의 state와 wait event로 \u0026ldquo;누가 무엇을 기다리는지\u0026rdquo; 어떻게 읽는가 pg_stat_statements, 서버 로그, auto_explain, pg_stat_io는 각각 무엇을 알려 주는가 같은 순간 OS에서는 무엇을 봐야 하는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n설치 직후 무엇이 기록되는가 pg_settings의 boot_val은 PostgreSQL에 컴파일된 기본값이고, source는 지금 값이 어디서 왔는지입니다. 둘을 같이 보면 설치 패키지가 무엇을 바꿔 놓았는지 알 수 있습니다.\npostgres=# SELECT name, setting, boot_val, source FROM pg_settings postgres-# WHERE name IN (\u0026#39;logging_collector\u0026#39;, \u0026#39;log_directory\u0026#39;, \u0026#39;log_filename\u0026#39;, postgres-# \u0026#39;log_rotation_age\u0026#39;, \u0026#39;log_rotation_size\u0026#39;, \u0026#39;log_truncate_on_rotation\u0026#39;, postgres-# \u0026#39;log_line_prefix\u0026#39;, \u0026#39;log_lock_waits\u0026#39;, \u0026#39;log_autovacuum_min_duration\u0026#39;, postgres-# \u0026#39;log_checkpoints\u0026#39;, \u0026#39;log_temp_files\u0026#39;, \u0026#39;log_min_duration_statement\u0026#39;, postgres-# \u0026#39;track_io_timing\u0026#39;, \u0026#39;shared_preload_libraries\u0026#39;, \u0026#39;deadlock_timeout\u0026#39;) postgres-# ORDER BY source, name; name | setting | boot_val | source -----------------------------+-------------------+--------------------------------+-------------------- log_directory | log | log | configuration file log_filename | postgresql-%a.log | postgresql-%Y-%m-%d_%H%M%S.log | configuration file logging_collector | on | off | configuration file log_line_prefix | %m [%p] | %m [%p] | configuration file log_rotation_age | 1440 | 1440 | configuration file log_rotation_size | 0 | 10240 | configuration file log_truncate_on_rotation | on | off | configuration file deadlock_timeout | 1000 | 1000 | default log_autovacuum_min_duration | 600000 | 600000 | default log_checkpoints | on | on | default log_lock_waits | off | off | default log_min_duration_statement | -1 | -1 | default log_temp_files | -1 | -1 | default shared_preload_libraries | | | default track_io_timing | off | off | default (15 rows) source가 configuration file인 값은 initdb가 만든 postgresql.conf에 적혀 있는 값입니다. PGDG 패키지는 로그를 이렇게 바꿔 둡니다.\nlogging_collector = on: 서버 로그를 데이터 디렉터리 아래 log/ 파일로 모읍니다. 컴파일 기본값은 off라서 소스로 설치하면 로그가 표준 에러로 나갑니다. log_filename = postgresql-%a.log, log_rotation_age = 1440(하루), log_truncate_on_rotation = on: 요일 이름 파일에 하루씩 쓰고, 일주일 뒤 같은 요일이 오면 그 파일을 비우고 다시 씁니다. 문서가 \u0026ldquo;일주일치 로그를 요일별 파일로 보관\u0026quot;하는 예로 드는 조합입니다(log_truncate_on_rotation). log_rotation_size = 0: 크기로는 파일을 바꾸지 않습니다. $ ls -l $PGDATA/log total 4 -rw------- 1 postgres postgres 693 Sep 25 23:06 postgresql-Fri.log 로그는 일주일만 남습니다. 장애를 8일 뒤에 조사하면 그날의 로그는 이미 덮어쓰였습니다. 오래 보관해야 한다면 로그 파일을 따로 수집해 두거나 log_filename을 날짜 형식으로 바꿔야 합니다.\nsource가 default인 쪽이 더 중요합니다. 장애를 진단할 때 필요한 정보가 기본값으로는 대부분 꺼져 있습니다.\n설정 기본값 꺼져 있으면 잃는 것 log_lock_waits off 락을 오래 기다린 세션과 락을 쥔 세션 log_temp_files -1 메모리가 모자라 디스크로 넘친 정렬, 해시 log_min_duration_statement -1 느린 쿼리 log_autovacuum_min_duration 600000(10분) 10분 안에 끝난 autovacuum의 기록 track_io_timing off EXPLAIN (BUFFERS)와 통계 뷰의 I/O 시간 shared_preload_libraries 비어 있음 pg_stat_statements, auto_explain log_checkpoints만 기본으로 켜져 있습니다. 로그 한 줄의 앞머리(log_line_prefix)는 %m [%p], 즉 시각과 PID뿐이라 어느 사용자, 어느 DB, 어느 애플리케이션에서 나온 로그인지 알 수 없습니다.\n진단용 설정 켜기 대부분은 ALTER SYSTEM과 설정 다시 읽기(pg_reload_conf())로 바로 켜집니다.\npostgres=# ALTER SYSTEM SET log_lock_waits = on; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_temp_files = 0; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_min_duration_statement = \u0026#39;500ms\u0026#39;; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_autovacuum_min_duration = 0; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_line_prefix = \u0026#39;%m [%p] %q%u@%d/%a \u0026#39;; ALTER SYSTEM postgres=# ALTER SYSTEM SET track_io_timing = on; ALTER SYSTEM log_line_prefix에 %u@%d/%a(사용자, DB, application_name)를 넣었습니다. 앞의 %q는 세션이 없는 프로세스(체크포인터, autovacuum 등)의 로그에서는 여기서부터를 찍지 말라는 표시입니다(log_line_prefix).\ntrack_io_timing은 I/O마다 시간을 재므로, 시계를 읽는 비용이 비싼 플랫폼에서는 부담이 될 수 있습니다. 켜기 전에 pg_test_timing으로 그 비용을 재 보라고 문서가 권합니다(track_io_timing).\n흔한 실수: shared_preload_libraries를 따옴표 하나로 묶기 pg_stat_statements와 auto_explain은 서버가 기동할 때 올라와야 하므로 shared_preload_libraries에 넣고 재시작해야 합니다. 여기서 많이 하는 실수가 목록 전체를 따옴표 하나로 묶는 것입니다.\npostgres=# ALTER SYSTEM SET shared_preload_libraries = \u0026#39;pg_stat_statements, auto_explain\u0026#39;; ALTER SYSTEM 명령은 성공합니다. 이 설정은 재시작해야 바뀌므로 지금 값은 비어 있고, pending_restart만 t가 됩니다.\npostgres=# SELECT name, setting, pending_restart FROM pg_settings postgres-# WHERE name IN (\u0026#39;log_lock_waits\u0026#39;, \u0026#39;log_temp_files\u0026#39;, \u0026#39;shared_preload_libraries\u0026#39;) postgres-# ORDER BY name; name | setting | pending_restart --------------------------+---------+----------------- log_lock_waits | on | f log_temp_files | 0 | f shared_preload_libraries | | t (3 rows) 파일에 무엇이 적혔는지 보면 문제가 드러납니다.\n$ grep shared_preload_libraries $PGDATA/postgresql.auto.conf shared_preload_libraries = \u0026#39;\u0026#34;pg_stat_statements, auto_explain\u0026#34;\u0026#39; 따옴표로 묶은 값 전체가 큰따옴표에 싸여 이름이 pg_stat_statements, auto_explain인 라이브러리 하나가 되었습니다. 이 상태로 재시작하면 서버가 뜨지 않습니다.\n$ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w restart -m fast waiting for server to shut down.... done server stopped waiting for server to start.... stopped waiting pg_ctl: could not start server Examine the log output. $ tail -n 3 /var/lib/pgsql/startup.log 2026-09-25 23:06:35.414 UTC [26] HINT: Future log output will appear in directory \u0026#34;log\u0026#34;. 2026-09-25 23:06:36.468 UTC [145] FATAL: could not access file \u0026#34;pg_stat_statements, auto_explain\u0026#34;: No such file or directory 2026-09-25 23:06:36.468 UTC [145] LOG: database system is shut down 에러 메시지 could not access file \u0026quot;pg_stat_statements, auto_explain\u0026quot;에 쉼표가 들어 있는 것이 단서입니다. 서버가 멈춘 상태에서는 ALTER SYSTEM을 쓸 수 없으므로, postgresql.auto.conf를 직접 고쳐야 합니다. 파일 머리에 \u0026ldquo;직접 고치지 말라\u0026quot;고 적혀 있지만, 서버가 뜨지 않을 때는 이것이 유일한 방법입니다.\n$ sed -i \u0026#39;/^shared_preload_libraries/d\u0026#39; $PGDATA/postgresql.auto.conf $ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w start waiting for server to start.... done server started 목록 값은 따옴표 없이 쉼표로 나열합니다.\npostgres=# ALTER SYSTEM SET shared_preload_libraries = pg_stat_statements, auto_explain; ALTER SYSTEM $ grep shared_preload_libraries $PGDATA/postgresql.auto.conf shared_preload_libraries = \u0026#39;pg_stat_statements, auto_explain\u0026#39; $ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w restart -m fast waiting for server to shut down.... done server stopped waiting for server to start.... done server started 이번에는 'pg_stat_statements, auto_explain'으로, 두 이름이 쉼표로 나뉜 목록이 되었습니다. 운영 서버에서 이 실수를 하면 다음 재시작 때 서버가 내려간 채로 올라오지 않습니다. 설정을 바꾼 사람과 재시작하는 사람이 다를 수도 있으니, shared_preload_libraries를 바꾼 뒤에는 postgresql.auto.conf의 줄을 눈으로 확인하는 습관을 들이는 편이 안전합니다.\n재시작한 뒤 auto_explain의 기준 시간을 정하고, pg_stat_statements를 쓸 DB에 확장을 만듭니다.\npostgres=# ALTER SYSTEM SET auto_explain.log_min_duration = \u0026#39;500ms\u0026#39;; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# CREATE EXTENSION pg_stat_statements; CREATE EXTENSION postgres=# SELECT name, setting, pending_restart FROM pg_settings postgres-# WHERE name IN (\u0026#39;shared_preload_libraries\u0026#39;, \u0026#39;track_io_timing\u0026#39;, \u0026#39;auto_explain.log_min_duration\u0026#39;, \u0026#39;log_line_prefix\u0026#39;) postgres-# ORDER BY name; name | setting | pending_restart -------------------------------+----------------------------------+----------------- auto_explain.log_min_duration | 500 | f log_line_prefix | %m [%p] %q%u@%d/%a | f shared_preload_libraries | pg_stat_statements, auto_explain | f track_io_timing | on | f (4 rows) 지금까지 바꾼 설정은 모두 postgresql.auto.conf에 모입니다.\n$ cat $PGDATA/postgresql.auto.conf # Do not edit this file manually! # It will be overwritten by the ALTER SYSTEM command. log_lock_waits = \u0026#39;on\u0026#39; log_temp_files = \u0026#39;0\u0026#39; log_min_duration_statement = \u0026#39;500ms\u0026#39; log_autovacuum_min_duration = \u0026#39;0\u0026#39; log_line_prefix = \u0026#39;%m [%p] %q%u@%d/%a \u0026#39; track_io_timing = \u0026#39;on\u0026#39; autovacuum_naptime = \u0026#39;10s\u0026#39; shared_preload_libraries = \u0026#39;pg_stat_statements, auto_explain\u0026#39; auto_explain.log_min_duration = \u0026#39;500ms\u0026#39; autovacuum_naptime = '10s'는 실습에서 autovacuum 로그를 빨리 보려고 줄인 값입니다. 운영 서버에 그대로 옮길 값이 아닙니다.\npg_stat_activity: 누가 무엇을 기다리는가 장애가 났을 때 가장 먼저 보는 곳입니다. 세션 두 개로 흔한 상황을 만듭니다. 세션 A가 행을 고치고 커밋하지 않은 채 멈춰 있고, 세션 B가 같은 행을 고치려 합니다.\npostgres=# CREATE TABLE t (id int PRIMARY KEY, v int); CREATE TABLE postgres=# INSERT INTO t VALUES (1, 0), (2, 0); INSERT 0 2 A=# BEGIN; BEGIN A=# UPDATE t SET v = v + 1 WHERE id = 1; UPDATE 1 B=# UPDATE t SET v = v + 10 WHERE id = 1; 세션 B의 UPDATE는 끝나지 않습니다. 다른 세션에서 pg_stat_activity를 보면 이렇습니다.\npostgres=# SELECT pid, application_name AS app, state, wait_event_type, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 40) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY pid; pid | app | state | wait_event_type | wait_event | blocked_by | query -----+-------+---------------------+-----------------+---------------+------------+--------------------------------------- 268 | app_a | idle in transaction | Client | ClientRead | {} | UPDATE t SET v = v + 1 WHERE id = 1; 283 | app_b | active | Lock | transactionid | {268} | UPDATE t SET v = v + 10 WHERE id = 1; (2 rows) 읽는 법은 다음과 같습니다.\nstate: active는 쿼리를 실행 중, idle은 다음 명령을 기다리는 중, idle in transaction은 트랜잭션을 연 채로 다음 명령을 기다리는 중입니다. 세션 A는 쿼리를 실행하고 있지 않은데도 락을 쥐고 있습니다. query는 그 세션이 마지막으로 실행한 쿼리라서, 세션 A가 이미 끝낸 UPDATE가 보입니다. wait_event_type/wait_event: 지금 무엇을 기다리는지입니다. 세션 A의 Client/ClientRead는 클라이언트가 다음 명령을 보내기를 기다리는 것이고, 세션 B의 Lock/transactionid는 다른 트랜잭션이 끝나기를 기다리는 것입니다(wait event 표). 행 락을 기다리면 행이 아니라 그 행을 고친 트랜잭션의 ID를 기다리는 것으로 보이는데, 이유는 인터널 4편에서 다뤘습니다. pg_blocking_pids(): 이 세션을 막고 있는 PID 목록입니다. 세션 B는 {268}, 즉 세션 A에게 막혀 있습니다. 얼마나 오래 그 상태였는지도 같은 뷰에서 봅니다.\npostgres=# SELECT pid, now() - xact_start AS xact_age, now() - state_change AS in_state postgres-# FROM pg_stat_activity postgres-# WHERE application_name IN (\u0026#39;app_a\u0026#39;, \u0026#39;app_b\u0026#39;) postgres-# ORDER BY pid; pid | xact_age | in_state -----+-----------------+----------------- 268 | 00:00:05.193128 | 00:00:04.185379 283 | 00:00:03.051489 | 00:00:03.051486 (2 rows) xact_start는 트랜잭션이 시작된 시각, state_change는 지금 상태가 된 시각입니다. idle in transaction인 세션의 in_state가 길다면, 애플리케이션이 트랜잭션을 열어 둔 채 다른 일을 하고 있다는 뜻입니다. 이런 세션을 찾고 막는 방법은 4편에서 다룹니다.\nps로도 보인다 DB에 접속할 수 없을 만큼 상황이 나쁠 때는 OS에서 봅니다. backend 프로세스는 자기 상태를 프로세스 제목에 씁니다(인터널 1편).\n$ ps -u postgres -o pid,cmd | grep \u0026#39;postgres:\u0026#39; | grep -v grep 195 postgres: logger 196 postgres: io worker 0 197 postgres: io worker 1 198 postgres: io worker 2 199 postgres: checkpointer 200 postgres: background writer 202 postgres: walwriter 203 postgres: autovacuum launcher 204 postgres: logical replication launcher 268 postgres: postgres postgres [local] idle in transaction 283 postgres: postgres postgres [local] UPDATE waiting idle in transaction과 UPDATE waiting이 그대로 보입니다. io worker는 PostgreSQL 18의 기본 io_method = worker가 띄우는 비동기 I/O 프로세스입니다.\n서버 로그: log_lock_waits log_lock_waits를 켰으므로, 세션 B가 deadlock_timeout(1초)보다 오래 기다리자 로그가 남았습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;still waiting|Process holding|Wait queue|STATEMENT\u0026#39; 2026-09-25 23:06:45.880 UTC [283] postgres@postgres/app_b LOG: process 283 still waiting for ShareLock on transaction 766 after 1000.715 ms 2026-09-25 23:06:45.880 UTC [283] postgres@postgres/app_b DETAIL: Process holding the lock: 268. Wait queue: 283. 2026-09-25 23:06:45.880 UTC [283] postgres@postgres/app_b STATEMENT: UPDATE t SET v = v + 10 WHERE id = 1; 누가(Process holding the lock: 268) 누구를(Wait queue: 283) 막았는지, 막힌 쪽의 쿼리가 무엇인지가 한 번에 남습니다. pg_stat_activity는 지금 이 순간만 보여 주므로, 장애가 지나간 뒤에 원인을 찾을 수 있는 곳은 이 로그뿐입니다. 이제 세션 A를 끝냅니다.\nA=# ROLLBACK; ROLLBACK # 세션 B: 앞 명령의 결과를 기다림 UPDATE 1 $ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;acquired\u0026#39; 2026-09-25 23:06:48.754 UTC [283] postgres@postgres/app_b LOG: process 283 acquired ShareLock on transaction 766 after 3874.166 ms 락을 얻을 때도 몇 ms를 기다렸는지 한 줄이 남습니다. 같은 UPDATE는 log_min_duration_statement(500ms)도 넘겼으므로 느린 쿼리로도 기록됩니다.\n... -\u0026gt; Index Scan using t_pkey on t (cost=0.15..8.17 rows=1 width=10) Index Cond: (id = 1) 2026-09-25 23:06:48.756 UTC [283] postgres@postgres/app_b LOG: duration: 3876.695 ms statement: UPDATE t SET v = v + 10 WHERE id = 1; 앞의 두 줄은 auto_explain이 남긴 이 UPDATE의 실행 계획 끝부분입니다. 기본 키 인덱스로 한 행을 찾는 계획이라 느릴 이유가 없습니다. 느린 쿼리 로그에 단순한 쿼리가 보이면 실행 계획보다 락 대기를 먼저 의심하고, 같은 시각의 still waiting for 로그를 찾아봅니다.\npg_stat_statements: 무엇이 시간을 가장 많이 쓰는가 pg_stat_activity가 지금 이 순간이라면, pg_stat_statements는 누적입니다. 쿼리를 상수만 뺀 모양으로 묶어서 호출 수, 실행 시간, 읽은 블록 수를 쌓습니다(pg_stat_statements). pgbench로 부하를 30초 줍니다.\n$ pgbench -i -s 10 -q postgres 2\u0026gt;\u0026amp;1 | tail -n 3 vacuuming... creating primary keys... done in 0.46 s (drop tables 0.00 s, create tables 0.00 s, client-side generate 0.33 s, vacuum 0.03 s, primary keys 0.09 s). postgres=# SELECT pg_stat_statements_reset() IS NOT NULL AS reset; reset ------- t (1 row) 부하가 도는 동안 pg_stat_activity를 wait event별로 묶어 세 번 찍었습니다.\npostgres=# SELECT wait_event_type, wait_event, state, count(*) postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# GROUP BY 1, 2, 3 ORDER BY 4 DESC; wait_event_type | wait_event | state | count -----------------+---------------+--------+------- Client | ClientRead | active | 2 IO | DataFileWrite | active | 2 Lock | transactionid | active | 2 IO | WalSync | active | 1 LWLock | WALWrite | active | 1 (5 rows) ... postgres=# SELECT wait_event_type, wait_event, state, count(*) postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# GROUP BY 1, 2, 3 ORDER BY 4 DESC; wait_event_type | wait_event | state | count -----------------+---------------+---------------------+------- Lock | transactionid | active | 2 LWLock | WALWrite | active | 2 Client | ClientRead | idle | 1 IO | WalSync | active | 1 Lock | transactionid | idle in transaction | 1 | | active | 1 (6 rows) 한 번 찍은 것은 한 순간의 표본일 뿐이라, 이렇게 몇 번 반복해 찍어서 자주 보이는 wait event를 찾는 것이 요령입니다. 여기서는 Lock/transactionid(다른 트랜잭션을 기다림)와 WAL 쓰기(LWLock/WALWrite, IO/WalSync)가 계속 보입니다. wait_event가 비어 있는 active는 CPU에서 실제로 일하는 중입니다.\n$ cat /var/lib/pgsql/bench.log pgbench (18.6) starting vacuum...end. transaction type: \u0026lt;builtin: TPC-B (sort of)\u0026gt; scaling factor: 10 query mode: simple number of clients: 8 number of threads: 2 maximum number of tries: 1 duration: 30 s number of transactions actually processed: 397987 number of failed transactions: 0 (0.000%) latency average = 0.603 ms initial connection time = 4.039 ms tps = 13267.572474 (without initial connection time) postgres=# SELECT left(query, 60) AS query, calls, postgres-# round(total_exec_time::numeric, 1) AS total_ms, postgres-# round(mean_exec_time::numeric, 3) AS mean_ms, postgres-# rows, shared_blks_hit, shared_blks_read postgres-# FROM pg_stat_statements postgres-# ORDER BY total_exec_time DESC postgres-# LIMIT 5; query | calls | total_ms | mean_ms | rows | shared_blks_hit | shared_blks_read --------------------------------------------------------------+--------+----------+---------+--------+-----------------+------------------ UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE b | 397987 | 30506.9 | 0.077 | 397987 | 2527835 | 55 UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE ti | 397987 | 6478.7 | 0.016 | 397987 | 2092643 | 64 UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE a | 397987 | 3602.5 | 0.009 | 397987 | 2463974 | 93994 SELECT abalance FROM pgbench_accounts WHERE aid = $1 | 397987 | 1079.7 | 0.003 | 397987 | 1618963 | 0 INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VA | 397987 | 832.5 | 0.002 | 397987 | 403349 | 9 (5 rows) total_ms로 정렬하면 서버 시간을 가장 많이 쓴 쿼리가 위로 옵니다. 1위인 pgbench_branches UPDATE는 한 번에 0.077ms밖에 안 걸리지만 전체 시간의 대부분을 차지합니다. scale 10이라 pgbench_branches에는 행이 10개뿐이고, 클라이언트 8개가 이 10개 행을 번갈아 고치니 서로의 트랜잭션을 기다리게 됩니다. pg_stat_statements의 실행 시간에는 이렇게 행 락을 기다린 시간도 들어갑니다. 앞의 wait event 표본에서 Lock/transactionid가 계속 보인 것과 맞아떨어집니다.\nshared_blks_read는 shared buffers에 없어서 읽어 온 블록 수입니다. 100만 행짜리 pgbench_accounts만 read가 큰 것은 shared buffers(기본 128MB)에 다 들어가지 않기 때문입니다(인터널 2편).\n장애 조사에서는 보통 \u0026ldquo;평소와 비교해 무엇이 달라졌는가\u0026quot;가 궁금하므로, 이 뷰를 주기적으로 떠서 저장해 두어야 쓸모가 커집니다. 여기서는 실습을 위해 부하 직전에 pg_stat_statements_reset()으로 비웠습니다.\n서버 로그: temp file, 느린 쿼리, auto_explain temp file work_mem보다 큰 정렬이나 해시는 디스크의 임시 파일로 넘칩니다. EXPLAIN (ANALYZE, BUFFERS)로 보면 이렇습니다.\npostgres=# SET work_mem = \u0026#39;4MB\u0026#39;; postgres-# EXPLAIN (ANALYZE, BUFFERS) postgres-# SELECT * FROM pgbench_accounts ORDER BY filler, aid; SET QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------------------------- Gather Merge (cost=83949.87..200888.86 rows=1004057 width=97) (actual time=138.771..241.890 rows=1000000.00 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=10153 read=6759 dirtied=6782 written=5663, temp read=13097 written=13131 I/O Timings: shared read=2.250 write=8.489, temp read=5.687 write=20.195 -\u0026gt; Sort (cost=82949.85..83995.74 rows=418357 width=97) (actual time=134.849..157.217 rows=333333.33 loops=3) Sort Key: filler, aid Sort Method: external merge Disk: 36704kB Buffers: shared hit=10153 read=6759 dirtied=6782 written=5663, temp read=13097 written=13131 I/O Timings: shared read=2.250 write=8.489, temp read=5.687 write=20.195 Worker 0: Sort Method: external merge Disk: 35064kB Worker 1: Sort Method: external merge Disk: 33008kB -\u0026gt; Parallel Seq Scan on pgbench_accounts (cost=0.00..21007.57 rows=418357 width=97) (actual time=0.058..37.077 rows=333333.33 loops=3) Buffers: shared hit=10065 read=6759 dirtied=6782 written=5663 I/O Timings: shared read=2.250 write=8.489 Planning: Buffers: shared hit=72 read=13 dirtied=1 I/O Timings: shared read=0.068 Planning Time: 0.225 ms Execution Time: 264.048 ms (20 rows) Sort Method: external merge Disk: 36704kB가 디스크로 넘쳤다는 표시입니다. 병렬 worker 두 개도 각자 정렬해서 각자 넘쳤습니다. track_io_timing을 켰으므로 I/O Timings에 임시 파일을 읽고 쓴 시간(temp read, temp write)도 나옵니다.\nlog_temp_files = 0이면 임시 파일이 지워질 때마다 크기가 한 줄씩 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 3 -E \u0026#39;temporary file\u0026#39; | tail -n 12 2026-09-25 23:07:26.271 UTC [600] LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp600.0\u0026#34;, size 33800192 2026-09-25 23:07:26.271 UTC [600] STATEMENT: SET work_mem = \u0026#39;4MB\u0026#39;; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM pgbench_accounts ORDER BY filler, aid; 2026-09-25 23:07:26.272 UTC [601] LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp601.0\u0026#34;, size 35905536 2026-09-25 23:07:26.272 UTC [601] STATEMENT: SET work_mem = \u0026#39;4MB\u0026#39;; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM pgbench_accounts ORDER BY filler, aid; 2026-09-25 23:07:26.276 UTC [599] postgres@postgres/psql LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp599.0\u0026#34;, size 37584896 2026-09-25 23:07:26.276 UTC [599] postgres@postgres/psql STATEMENT: SET work_mem = \u0026#39;4MB\u0026#39;; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM pgbench_accounts ORDER BY filler, aid; 프로세스마다 한 줄씩, 세 줄입니다. 앞머리에 postgres@postgres/psql이 붙은 것은 세션의 backend이고, 붙지 않은 두 줄은 병렬 worker입니다. log_line_prefix의 %q 뒤를 찍지 않는 프로세스로 취급된 것입니다. 누적 값은 pg_stat_database에 있습니다.\npostgres=# SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_bytes postgres-# FROM pg_stat_database WHERE datname = \u0026#39;postgres\u0026#39;; datname | temp_files | temp_bytes ----------+------------+------------ postgres | 8 | 251 MB (1 row) 8개에는 pgbench -i가 기본 키를 만들 때 쓴 임시 파일도 들어 있습니다. 임시 파일이 디스크를 채우는 문제는 6편에서 다룹니다.\n느린 쿼리와 auto_explain 500ms를 넘기는 쿼리를 하나 돌립니다.\npostgres=# SET max_parallel_workers_per_gather = 0; postgres-# SELECT count(DISTINCT md5(filler || aid)) FROM pgbench_accounts; SET count --------- 1000000 (1 row) 로그에는 세 가지가 남았습니다.\n2026-09-25 23:07:28.031 UTC [620] postgres@postgres/psql LOG: duration: 1624.034 ms plan: Query Text: SET max_parallel_workers_per_gather = 0; SELECT count(DISTINCT md5(filler || aid)) FROM pgbench_accounts; Aggregate (cost=247483.05..247483.06 rows=1 width=8) -\u0026gt; Sort (cost=229912.05..232422.19 rows=1004057 width=89) Sort Key: (md5(((filler)::text || (aid)::text))) -\u0026gt; Seq Scan on pgbench_accounts (cost=0.00..26864.57 rows=1004057 width=89) 2026-09-25 23:07:28.039 UTC [620] postgres@postgres/psql LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp620.0\u0026#34;, size 135331840 2026-09-25 23:07:28.039 UTC [620] postgres@postgres/psql STATEMENT: SET max_parallel_workers_per_gather = 0; SELECT count(DISTINCT md5(filler || aid)) FROM pgbench_accounts; 2026-09-25 23:07:28.039 UTC [620] postgres@postgres/psql LOG: duration: 1632.938 ms statement: SET max_parallel_workers_per_gather = 0; SELECT count(DISTINCT md5(filler || aid)) FROM pgbench_accounts; duration: ... plan:은 auto_explain이 남긴 그때의 실행 계획입니다. 나중에 같은 쿼리를 EXPLAIN하면 통계나 설정이 달라져 다른 계획이 나올 수 있으므로, 느렸던 그 순간의 계획이 남는 것이 중요합니다. temporary file:은 이 정렬이 약 130MB(135331840바이트)를 디스크에 썼다는 기록입니다. duration: ... statement:는 log_min_duration_statement가 남긴 느린 쿼리 기록입니다. Query Text에 SET까지 붙은 것은 psql로 두 문장을 한 번에 보냈기 때문입니다. auto_explain은 기본으로 실제 행 수나 시간 없이 계획만 남깁니다. auto_explain.log_analyze를 켜면 실제 값도 남지만, 기준 시간을 넘지 않는 쿼리까지 모두 노드별 시간을 재게 되어 성능에 큰 부담이 될 수 있다고 문서가 경고합니다(auto_explain).\npg_stat_io: I/O를 누가 어디서 했는가 PostgreSQL 16부터 생긴 pg_stat_io는 I/O를 프로세스 종류(backend_type), 대상(object), 용도(context)의 세 축으로 나눠 셉니다. 18에서는 바이트 수(read_bytes 등)와 WAL I/O(object = wal)도 들어왔습니다.\npostgres=# SELECT backend_type, object, context, reads, pg_size_pretty(read_bytes) AS read, postgres-# round(read_time::numeric, 1) AS read_ms, writes, extends, hits, evictions postgres-# FROM pg_stat_io postgres-# WHERE reads \u0026gt; 0 OR writes \u0026gt; 0 OR extends \u0026gt; 0 postgres-# ORDER BY coalesce(reads, 0) + coalesce(writes, 0) + coalesce(extends, 0) DESC postgres-# LIMIT 10; backend_type | object | context | reads | read | read_ms | writes | extends | hits | evictions --------------------+----------+-----------+-------+---------+---------+--------+---------+---------+----------- client backend | wal | normal | 0 | 0 bytes | 0.0 | 235168 | | | client backend | relation | normal | 94906 | 743 MB | 248.5 | 68505 | 3163 | 9133655 | 84417 autovacuum worker | relation | vacuum | 12943 | 191 MB | 45.9 | 16739 | 0 | 55412 | 2026 background writer | relation | normal | | | | 16432 | | | client backend | relation | bulkwrite | 0 | 0 bytes | 0.0 | 14404 | 261 | 16135 | 0 background worker | relation | bulkread | 3352 | 88 MB | 1.7 | 3778 | | 7560 | 4260 client backend | relation | bulkread | 4096 | 105 MB | 1.3 | 1921 | | 17750 | 2332 standalone backend | relation | normal | 479 | 4336 kB | 0.0 | 1037 | 597 | 91773 | 0 autovacuum worker | relation | normal | 630 | 6312 kB | 5.7 | 347 | 13 | 31120 | 573 client backend | relation | vacuum | 904 | 113 MB | 0.3 | 0 | 0 | 2113 | 0 (10 rows) client backend / wal / normal의 writes가 가장 많습니다. pgbench의 커밋마다 WAL을 쓰기 때문입니다(인터널 7편). client backend / relation / normal의 writes가 68505로, background writer(16432)보다 많습니다. backend가 새 페이지를 읽을 자리를 만들려고 dirty 페이지를 직접 내보낸 것입니다(evictions 84417). 문서는 client backend의 write가 많으면 shared buffers나 checkpointer 설정이 맞지 않을 수 있다고 설명합니다(pg_stat_io). 기본 128MB shared buffers에 이 부하는 버겁다는 뜻입니다. 이 문제는 10편에서 다시 봅니다. bulkread, bulkwrite, vacuum은 큰 테이블 순차 스캔, COPY 같은 대량 쓰기, VACUUM이 shared buffers를 다 차지하지 않도록 작은 링 버퍼를 따로 쓰는 경우입니다. standalone backend는 initdb입니다. read_ms는 track_io_timing을 켰기 때문에 채워진 값입니다. 꺼져 있으면 0으로 남습니다.\nautovacuum과 checkpoint 로그 log_autovacuum_min_duration = 0이면 autovacuum이 돌 때마다 무엇을 했는지 남습니다.\n2026-09-25 23:07:27.537 UTC [621] LOG: automatic vacuum of table \u0026#34;postgres.public.pgbench_tellers\u0026#34;: index scans: 0 pages: 0 removed, 96 remain, 13 scanned (13.54% of total), 0 eagerly scanned tuples: 956 removed, 186 remain, 0 are dead but not yet removable removable cutoff: 398780, which was 2 XIDs old when operation ended new relfrozenxid: 398214, which is 44329 XIDs ahead of previous value frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 12 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed I/O timings: read: 0.018 ms, write: 0.000 ms 여기서 가장 자주 보게 될 줄은 0 are dead but not yet removable입니다. 이 값이 크면 dead tuple이 있는데도 지우지 못했다는 뜻이고, 대개 오래 열린 트랜잭션이 원인입니다(인터널 5편). 4편과 5편에서 이 줄을 다시 씁니다.\n테이블별 누적은 pg_stat_user_tables에서 봅니다.\npostgres=# SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count postgres-# FROM pg_stat_user_tables ORDER BY relname; relname | n_live_tup | n_dead_tup | last_autovacuum | autovacuum_count ------------------+------------+------------+-------------------------------+------------------ pgbench_accounts | 998447 | 9653 | 2026-09-25 23:06:59.944923+00 | 1 pgbench_branches | 10 | 0 | 2026-09-25 23:07:27.394921+00 | 4 pgbench_history | 397987 | 0 | 2026-09-25 23:07:18.860528+00 | 3 pgbench_tellers | 100 | 0 | 2026-09-25 23:07:27.537528+00 | 4 t | 2 | 2 | | 0 (5 rows) t 테이블은 dead tuple이 2개 있지만 autovacuum이 한 번도 돌지 않았습니다. 기준이 \u0026ldquo;50 + 행 수의 20%\u0026ldquo;라서 행 2개짜리 테이블은 대상이 되지 않습니다. 이 기준을 조정하는 방법은 5편에서 다룹니다.\n체크포인트는 기본으로 기록됩니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;checkpoint (starting|complete)\u0026#39; | tail -n 4 2026-09-25 23:07:43.322 UTC [199] LOG: checkpoint starting: immediate force wait 2026-09-25 23:07:43.436 UTC [199] LOG: checkpoint complete: wrote 11616 buffers (70.9%), wrote 46 SLRU buffers; 0 WAL file(s) added, 0 removed, 18 recycled; write=0.031 s, sync=0.063 s, total=0.115 s; sync files=94, longest=0.050 s, average=0.001 s; distance=302508 kB, estimate=302508 kB; lsn=0/13EC6398, redo lsn=0/13EC6340 immediate force wait는 CHECKPOINT 명령으로 직접 요청했다는 뜻입니다. 평소에는 time(checkpoint_timeout)이나 wal(max_wal_size)이 적힙니다. wal이 자주 보이면 체크포인트가 너무 잦은 것이고, 10편의 주제입니다(인터널 8편).\nOS에서 먼저 볼 것 같은 부하를 OS에서 보면 이렇습니다.\n$ top -b -n 1 -u postgres | head -n 20 top - 23:07:03 up 56 min, 0 users, load average: 1.54, 1.42, 0.96 Tasks: 24 total, 6 running, 18 sleeping, 0 stopped, 0 zombie %Cpu(s): 12.6 us, 13.1 sy, 0.0 ni, 66.0 id, 4.9 wa, 0.0 hi, 3.4 si, 0.0 st MiB Mem : 32041.5 total, 19607.3 free, 2081.9 used, 11360.8 buff/cache MiB Swap: 1024.0 total, 1024.0 free, 0.0 used. 29959.6 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 517 postgres 20 0 87976 5360 4316 R 106.7 0.0 0:11.63 pgbench 520 postgres 20 0 227492 155116 151940 S 40.0 0.5 0:04.00 postgres 521 postgres 20 0 227500 155180 152000 D 40.0 0.5 0:04.00 postgres 526 postgres 20 0 227492 155012 151836 R 40.0 0.5 0:03.80 postgres 522 postgres 20 0 227492 155136 151964 R 33.3 0.5 0:03.92 postgres 523 postgres 20 0 227492 155116 151940 R 33.3 0.5 0:03.97 postgres 524 postgres 20 0 227492 155196 152040 R 33.3 0.5 0:03.84 postgres 525 postgres 20 0 227492 155192 152020 S 33.3 0.5 0:03.88 postgres 527 postgres 20 0 227500 155152 151976 S 33.3 0.5 0:03.79 postgres backend 하나하나가 CPU를 30~40%씩 쓰고, pgbench 자체가 CPU 하나를 다 씁니다. backend의 RES가 150MB 남짓인데 대부분이 SHR, 즉 shared buffers를 같이 쓰는 몫입니다. 이 값을 프로세스 수만큼 더하면 메모리 사용량이 부풀려집니다(인터널 1편).\n$ iostat -x 1 3 Linux 7.0.12-linuxkit (pgops) 09/25/2026 _aarch64_\t(14 CPU) avg-cpu: %user %nice %system %iowait %steal %idle 1.72 0.10 1.56 0.38 0.00 96.23 Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util vda 2.17 26.31 3.15 59.21 0.12 12.11 813.42 10206.07 99.52 10.90 0.40 12.55 20.32 232857.35 0.00 0.00 0.06 11460.95 388.66 0.07 0.35 3.85 vdb 0.22 102.59 0.00 0.27 0.35 460.19 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 avg-cpu: %user %nice %system %iowait %steal %idle 12.86 0.00 16.50 4.87 0.00 65.77 Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util vda 0.00 0.00 0.00 0.00 0.00 0.00 15213.00 66416.00 0.00 0.00 0.05 4.37 0.00 0.00 0.00 0.00 0.00 0.00 7606.00 0.05 1.13 69.50 vdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 avg-cpu: %user %nice %system %iowait %steal %idle 12.19 0.00 16.33 5.01 0.00 66.47 Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util vda 0.00 0.00 0.00 0.00 0.00 0.00 15329.00 94000.00 37.00 0.24 0.14 6.13 0.00 0.00 0.00 0.00 0.00 0.00 7346.00 0.05 2.60 73.30 vdb 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 iostat의 첫 번째 출력은 부팅 이후 평균이라 무시하고 두 번째부터 봅니다. 초당 쓰기 15,000번 남짓에 flush 요청(f/s)이 7,000번 넘게 들어옵니다. 커밋마다 WAL을 디스크에 확정하는(fsync) 요청입니다.\n$ free -m total used free shared buff/cache available Mem: 32041 2088 19581 631 11379 29952 Swap: 1023 0 1023 $ df -h $PGDATA Filesystem Size Used Avail Use% Mounted on overlay 911G 218G 647G 26% / 실습 환경과 실제 서버의 차이: 이 실습은 Docker Desktop의 Linux VM 위 컨테이너에서 돌았습니다. top, free의 메모리는 VM 전체(32GB)이고, iostat의 vda는 VM의 가상 디스크이며, df의 overlay는 컨테이너 파일 시스템입니다. 수치의 크기는 실제 서버와 다르게 읽어야 하고, 여기서는 어떤 지표를 어떤 순서로 보는지만 가져가면 됩니다.\n장애가 나면 이 순서로 본다 pg_stat_activity: state, wait_event_type/wait_event, pg_blocking_pids(), xact_start. 무엇을 기다리는 세션이 많은지 몇 번 반복해 찍습니다. 서버 로그 ($PGDATA/log/postgresql-요일.log): 같은 시각의 ERROR, FATAL, still waiting for, duration:, temporary file:, autovacuum과 checkpoint 기록. pg_stat_statements: 평소와 비교해 시간을 많이 쓰는 쿼리. OS: ps의 프로세스 제목, top, iostat -x, free, df. 미리 켜 둘 설정 설정 이 글에서 쓴 값 필요한 조치 메모 log_lock_waits on reload deadlock_timeout보다 오래 기다리면 기록 log_temp_files 0 reload 로그가 너무 많으면 kB 단위 기준을 준다 log_min_duration_statement 500ms reload 서비스 특성에 맞게. 너무 낮으면 로그가 폭증 log_autovacuum_min_duration 0 reload 기본 10분이면 대부분의 autovacuum이 기록되지 않음 log_line_prefix %m [%p] %q%u@%d/%a reload 사용자, DB, 애플리케이션을 남김 track_io_timing on reload 켜기 전에 pg_test_timing으로 비용 확인 shared_preload_libraries pg_stat_statements, auto_explain restart 따옴표로 묶지 말 것. 바꾼 뒤 postgresql.auto.conf 확인 auto_explain.log_min_duration 500ms reload log_analyze는 부담이 큼 shared_preload_libraries만 재시작이 필요하므로, 새 서버를 만들 때 넣어 두는 것이 가장 좋습니다. 장애가 난 뒤에는 이 설정 하나 때문에 재시작할 여유가 없는 경우가 많습니다.\n정리 PGDG 패키지는 logging_collector를 켜고 요일별 파일로 로그를 남깁니다. 로그는 일주일 뒤 덮어쓰입니다. 락 대기, temp file, 느린 쿼리, 짧은 autovacuum, I/O 시간은 기본으로 기록되지 않습니다. 장애 전에 켜 둬야 합니다. shared_preload_libraries를 'a, b'처럼 따옴표 하나로 묶으면 이름이 a, b인 라이브러리 하나가 되고, 다음 재시작 때 could not access file 에러로 서버가 뜨지 않습니다. 목록은 따옴표 없이 쉼표로 나열합니다. pg_stat_activity는 지금 이 순간을, 서버 로그는 지나간 일을, pg_stat_statements는 누적을 보여 줍니다. 셋을 같이 봐야 원인이 좁혀집니다. 느린 쿼리 로그에 단순한 쿼리가 보이면 락 대기를 먼저 의심합니다. 참고 자료 Error Reporting and Logging The Cumulative Statistics System: pg_stat_activity, wait event, pg_stat_io pg_stat_statements auto_explain ALTER SYSTEM shared_preload_libraries pg_test_timing PostgreSQL 인터널 1편 프로세스 구조, 2편 메모리 구조, 4편 MVCC, 5편 VACUUM, 7편 WAL, 8편 체크포인트 ","permalink":"https://jinlog.dev/posts/postgresql-ops/01-diagnostic-toolkit/","summary":"장애가 났을 때 원인을 찾으려면 무엇을 미리 켜 두고, 어디부터 봐야 하는가","title":"PostgreSQL 운영 1: 진단 도구상자"},{"content":"개요 pg_ctl start로 PostgreSQL을 띄우면 프로그램 하나가 도는 것처럼 보입니다. 실제로는 프로세스 여러 개가 역할을 나눠 맡은 작은 조직이 뜹니다. 접속을 받는 프로세스, 쿼리를 처리하는 프로세스, 메모리에 쌓인 변경을 디스크로 내보내는 프로세스, 죽은 데이터를 청소하는 프로세스가 모두 따로 있습니다.\n이 글은 다음 질문에 답합니다.\n서버를 띄우면 어떤 프로세스들이 생기고, 각각 무슨 일을 하는가 클라이언트가 접속하면 내부에서 어떤 순서로 무슨 일이 일어나는가 backend 하나가 죽었는데 왜 모든 접속이 끊기는가 DB 내부를 처음 보는 개발자도 따라올 수 있도록 용어가 처음 나올 때마다 뜻을 풀어 쓰겠습니다.\n기준 버전: 이 연재는 PostgreSQL 18을 기준으로 합니다. 정확히는 REL_18_STABLE 브랜치의 커밋 39a0db1(18.6 개발 버전)입니다. 본문의 소스 링크는 모두 이 커밋에 고정했고, 실습 결과는 이 소스를 Rocky Linux 9.8에서 그대로 빌드해 실행한 출력입니다.\n먼저 결론부터 정리하면 이렇습니다.\n프로세스 한 줄 설명 postmaster 문지기. 접속을 받아 fork하고, 자식 프로세스를 감시합니다. 쿼리는 처리하지 않습니다. client backend 접속 하나를 전담하는 프로세스. 클라이언트 하나당 하나씩 생깁니다. 백그라운드 프로세스 디스크 쓰기, 읽기(PG18), 청소, WAL 보관 같은 뒷일을 나눠 맡습니다. 한눈에 보기 아래 그림은 PostgreSQL 18이 기본 설정으로 떠 있을 때의 프로세스와 그 사이의 관계입니다. 그림 위쪽의 버튼으로 \u0026ldquo;연결 처리\u0026rdquo;, \u0026ldquo;디스크 쓰기 담당\u0026rdquo; 같은 관점별로 강조해서 볼 수 있고, SRC 표시가 있는 상자는 해당 소스 위치로 연결됩니다.\nPostgreSQL 18 프로세스 구조. 실선은 데이터나 요청이 흐르는 방향, 점선은 비동기 요청입니다. 새 창에서 크게 보기 그림을 읽는 요령은 세 가지입니다.\n왼쪽의 postmaster는 입구만 지킵니다. 접속을 받으면 자신을 복제(fork)해 backend를 만들고, 그다음부터 클라이언트는 backend와 직접 이야기합니다. 가운데의 공유 메모리가 모든 프로세스의 작업대입니다. 테이블 데이터를 캐시하는 shared buffers, 변경 기록(WAL)을 잠시 모아 두는 WAL buffers, 누가 어떤 락을 잡았는지 같은 정보가 여기 있습니다. 오른쪽의 백그라운드 프로세스들이 디스크를 상대합니다. backend는 되도록 메모리에서 일을 끝내고, 느린 디스크 쓰기는 뒤에서 따로 처리합니다. 용어 정리\n프로세스: 운영체제가 따로 메모리 공간을 주고 실행하는 프로그램 단위입니다. ps 명령에 한 줄씩 보이는 것이 프로세스입니다. fork: 실행 중인 프로세스가 자신을 그대로 복제해 자식 프로세스를 만드는 유닉스 시스템 콜입니다. 부모가 열어 둔 파일, 메모리 매핑 같은 것을 자식이 물려받습니다. WAL(Write-Ahead Log): 데이터 파일을 고치기 전에 \u0026ldquo;무엇을 바꿀지\u0026quot;를 먼저 적어 두는 로그입니다. 장애가 나면 이 로그를 다시 재생해서 복구합니다. 7편에서 자세히 다룹니다. 서버를 띄우면 생기는 프로세스 서버를 시작하고 아무도 접속하지 않은 상태에서 postmaster와 그 자식들을 봅니다. ps -p $PM --ppid $PM은 postmaster 자신과 postmaster를 부모로 둔 프로세스만 보여 줍니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 33 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data 34 33 \\_ postgres: io worker 0 35 33 \\_ postgres: io worker 1 36 33 \\_ postgres: io worker 2 37 33 \\_ postgres: checkpointer 38 33 \\_ postgres: background writer 40 33 \\_ postgres: walwriter 41 33 \\_ postgres: autovacuum launcher 42 33 \\_ postgres: logical replication launcher $ head -1 $PGDATA/postmaster.pid 33 $ cat /home/postgres/server.log 2026-09-26 10:24:03.341 UTC [33] LOG: starting PostgreSQL 18.6 on aarch64-unknown-linux-gnu, compiled by gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-14), 64-bit 2026-09-26 10:24:03.341 UTC [33] LOG: listening on IPv6 address \u0026#34;::1\u0026#34;, port 5432 2026-09-26 10:24:03.341 UTC [33] LOG: listening on IPv4 address \u0026#34;127.0.0.1\u0026#34;, port 5432 2026-09-26 10:24:03.342 UTC [33] LOG: listening on Unix socket \u0026#34;/tmp/.s.PGSQL.5432\u0026#34; 2026-09-26 10:24:03.346 UTC [39] LOG: database system was shut down at 2026-09-26 10:24:02 UTC 2026-09-26 10:24:03.347 UTC [33] LOG: database system is ready to accept connections 그림에 있던 프로세스들이 그대로 보이고, 모든 프로세스의 부모(PPID)가 33번 postmaster입니다. 백그라운드 프로세스든 뭐든 fork는 postmaster만 합니다. 39번이 비어 있는 이유는 기동 순서에서 다룹니다.\npostmaster: 문을 지키고 자식을 감시하는 프로세스 postmaster는 서버를 띄웠을 때 가장 먼저 실행되는 프로세스입니다. ps에서는 /usr/local/pgsql/bin/postgres -D ...처럼 실행 파일 이름 그대로 보이고, PID는 $PGDATA/postmaster.pid 파일의 첫 줄에 기록됩니다.\npostmaster.c 맨 위의 주석이 이 프로세스의 성격을 잘 설명합니다. 요약하면 다음과 같습니다.\n접속 요청이 오면 backend를 fork합니다. 기동, 종료처럼 시스템 전체에 걸친 일을 관리합니다. 다만 그 일을 직접 하지 않고, 알맞은 때에 자식 프로세스를 띄워 시킵니다. 공유 메모리를 만들기는 하지만 평소에는 건드리지 않습니다. 락 관리에도 참여하지 않습니다. backend가 죽으면 공유 메모리를 초기화해서 시스템을 되살립니다. 세 번째 항목이 postmaster 설계의 핵심입니다. 공유 메모리는 여러 프로세스가 함께 쓰는 곳이라, 어떤 backend가 락을 잡은 채로 죽으면 망가진 상태로 남을 수 있습니다. postmaster가 그 메모리를 만지지 않으면 자식이 어떻게 죽든 postmaster만은 멀쩡하게 남아서 뒷수습을 할 수 있습니다. 실제로 postmaster는 공유 메모리의 프로세스 목록(PGPROC 배열)에 들어 있지 않고, 그래서 pg_stat_activity에도 나오지 않습니다.\npg_stat_activity에는 postmaster가 없다 postgres=# SELECT pid, backend_type FROM pg_stat_activity ORDER BY pid; pid | backend_type -----+------------------------------ 34 | io worker 35 | io worker 36 | io worker 37 | checkpointer 38 | background writer 40 | walwriter 41 | autovacuum launcher 42 | logical replication launcher 66 | client backend (9 rows) backend_type 열의 문자열은 GetBackendTypeDesc()가 정한 이름입니다. 66번은 이 쿼리를 실행한 psql의 backend입니다. 앞의 ps에는 있던 postmaster(33)가 이 목록에는 없습니다. PGPROC 배열에 들어가지 않는다는 설계가 그대로 드러나는 부분입니다.\n본체는 기다렸다가 fork하는 무한 루프 postmaster의 본체는 ServerLoop()라는 무한 루프입니다. 핵심만 남기면 이렇습니다.\n/* src/backend/postmaster/postmaster.c, ServerLoop() (일부 생략) */ for (;;) { /* 소켓에 새 접속이 오거나, 시그널 핸들러가 latch를 세울 때까지 잠든다 */ nevents = WaitEventSetWait(pm_wait_set, DetermineSleepTime(), events, ...); for (int i = 0; i \u0026lt; nevents; i++) { if (pending_pm_shutdown_request) process_pm_shutdown_request(); /* 종료 요청 */ if (pending_pm_reload_request) process_pm_reload_request(); /* 설정 reload */ if (pending_pm_child_exit) process_pm_child_exit(); /* 자식 종료 */ if (pending_pm_pmsignal) process_pm_pmsignal(); /* 자식의 요청 */ if (events[i].events \u0026amp; WL_SOCKET_ACCEPT) { if (AcceptConnection(events[i].fd, \u0026amp;s) == STATUS_OK) BackendStartup(\u0026amp;s); /* 접속이 오면 fork */ closesocket(s.sock); /* postmaster 쪽 소켓은 바로 닫는다 */ } } LaunchMissingBackgroundProcesses(); /* 빠진 백그라운드 프로세스를 다시 띄운다 */ ... } 루프는 \u0026ldquo;기다린다 → 온 일을 처리한다 → 빠진 프로세스가 있으면 띄운다\u0026quot;를 반복합니다. 할 일이 없으면 잠들어 있으니 CPU를 거의 쓰지 않습니다.\npostmaster에게 일을 시키는 방법은 유닉스 시그널입니다. 시그널 핸들러는 PostmasterMain()에서 등록합니다.\n시그널 보내는 쪽 postmaster가 하는 일 SIGHUP pg_ctl reload, pg_reload_conf() 설정 파일을 다시 읽고 자식들에게도 전달 SIGTERM pg_ctl stop -m smart 새 접속을 막고, 기존 세션이 모두 끝나면 종료 SIGINT pg_ctl stop -m fast (기본값) 진행 중인 트랜잭션을 취소시키고 종료 SIGQUIT pg_ctl stop -m immediate 모든 자식을 즉시 끝내고 종료. 다음 기동 때 복구 과정을 거침 SIGCHLD 운영체제 자식 프로세스가 끝났다는 알림. 종료 코드를 보고 정상인지 판단 SIGUSR1 자식 프로세스 \u0026ldquo;autovacuum worker를 띄워 달라\u0026rdquo; 같은 자식의 요청 backend: 접속 하나에 프로세스 하나 클라이언트가 접속할 때마다 postmaster는 backend 프로세스를 하나씩 만듭니다. 접속이 100개면 backend도 100개입니다. 한 번 만들어진 backend는 그 접속이 끝날 때까지 그 클라이언트만 상대합니다. 요즘 많이 쓰는 \u0026ldquo;스레드 풀\u0026rdquo; 방식의 서버와 가장 다른 점입니다.\n접속 하나가 처리되는 순서 접속 하나가 처리되는 순서. postmaster는 fork까지만 하고, 인증부터는 backend가 맡습니다. 새 창에서 크게 보기 순서대로 짚어 보겠습니다.\n1. accept와 fork. 접속이 오면 postmaster는 BackendStartup()에서 자식 슬롯을 하나 잡고 곧바로 fork합니다. fork는 postmaster_child_launch()가 수행합니다. 눈여겨볼 점은 인증보다 fork가 먼저라는 것입니다. 소스 주석은 그 이유를 이렇게 설명합니다. 인증 코드를 단순한 단일 스레드 방식으로 짤 수 있고, 무엇보다 SSL이나 PAM처럼 느리게 막힐 수 있는 라이브러리가 postmaster를 붙잡아 다른 클라이언트까지 못 들어오게 하는 일을 막을 수 있습니다.\n2. 인증과 초기화. 자식 프로세스는 BackendMain()에서 시작합니다. 클라이언트가 보낸 첫 메시지(StartupMessage)에서 사용자와 DB 이름을 읽고, pg_hba.conf 규칙에 따라 인증합니다. 그다음 InitProcess()로 공유 메모리에 자기 자리(PGPROC)를 얻습니다. 이 자리를 얻어야 락을 잡고 공유 메모리를 쓸 수 있습니다.\n3. 쿼리 처리. 준비가 끝나면 backend는 PostgresMain()의 메인 루프에 들어갑니다. 클라이언트에 \u0026ldquo;준비됐다(ReadyForQuery)\u0026ldquo;를 보내고 다음 명령을 기다렸다가, 받은 쿼리를 파싱, 계획, 실행해 결과를 돌려주는 일을 반복합니다. 쿼리 처리 과정은 10편에서 다룹니다.\n4. 종료. 클라이언트가 Terminate 메시지를 보내거나 연결이 끊기면 backend 프로세스가 끝납니다. 운영체제는 부모인 postmaster에게 SIGCHLD를 보내고, postmaster는 CleanupBackend()에서 슬롯을 돌려받습니다.\n접속할 때마다 backend가 하나씩 생긴다 접속 과정을 로그로 보려고 PG18의 log_connections 세부 옵션을 켭니다. PG18부터 log_connections는 on/off 대신 receipt, authentication, authorization, setup_durations처럼 단계별로 고를 수 있습니다. log_line_prefix의 %b는 로그 줄에 backend 타입을 찍어 줍니다.\npostgres=# ALTER SYSTEM SET log_line_prefix = \u0026#39;%m [%p] %b \u0026#39;; postgres=# ALTER SYSTEM SET log_connections = \u0026#39;receipt,authentication,authorization,setup_durations\u0026#39;; postgres=# ALTER SYSTEM SET log_disconnections = on; postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) 세션 세 개를 백그라운드로 열어 둡니다. 하나는 TCP로 접속해 가만히 있고, 하나는 Unix 소켓으로 접속해 가만히 있고, 하나는 긴 쿼리를 실행합니다.\n$ psql -X -q -h 127.0.0.1 \u0026amp; $ psql -X -q \u0026amp; $ psql -X -c \u0026#34;SELECT pg_sleep(3600)\u0026#34; \u0026amp; $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 33 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data ... (앞과 같은 백그라운드 프로세스 8줄) 83 33 \\_ postgres: postgres postgres 127.0.0.1(45092) idle 92 33 \\_ postgres: postgres postgres [local] idle 99 33 \\_ postgres: postgres postgres [local] SELECT 접속 세 개에 backend 세 개(83, 92, 99)가 생겼고, 부모는 모두 postmaster입니다. backend는 ps의 프로세스 이름을 사용자 DB 접속지 상태 형식으로 바꿔 둡니다. idle은 명령을 기다리는 중, SELECT는 쿼리를 실행하는 중이라는 뜻이라, ps만으로도 각 세션이 무엇을 하는지 대략 알 수 있습니다.\npostgres=# SELECT pid, backend_type, client_addr, state, query FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; ORDER BY pid; pid | backend_type | client_addr | state | query -----+----------------+-------------+--------+------------------------------------------------------------------------------------------------------------------------------ 83 | client backend | 127.0.0.1 | idle | 92 | client backend | | idle | 99 | client backend | | active | SELECT pg_sleep(3600) 115 | client backend | | active | SELECT pid, backend_type, client_addr, state, query FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; ORDER BY pid (4 rows) $ grep -E \u0026#39;connection (received|authenticated|authorized|ready)\u0026#39; /home/postgres/server.log | head -8 2026-09-26 10:24:03.750 UTC [83] not initialized LOG: connection received: host=127.0.0.1 port=45092 2026-09-26 10:24:03.751 UTC [83] client backend LOG: connection authenticated: user=\u0026#34;postgres\u0026#34; method=trust (/var/lib/postgresql/data/pg_hba.conf:119) 2026-09-26 10:24:03.751 UTC [83] client backend LOG: connection authorized: user=postgres database=postgres application_name=psql 2026-09-26 10:24:03.752 UTC [83] client backend LOG: connection ready: setup total=1.821 ms, fork=0.140 ms, authentication=0.154 ms 2026-09-26 10:24:03.792 UTC [92] not initialized LOG: connection received: host=[local] 2026-09-26 10:24:03.792 UTC [92] client backend LOG: connection authenticated: user=\u0026#34;postgres\u0026#34; method=trust (/var/lib/postgresql/data/pg_hba.conf:117) 2026-09-26 10:24:03.792 UTC [92] client backend LOG: connection authorized: user=postgres database=postgres application_name=psql 2026-09-26 10:24:03.792 UTC [92] client backend LOG: connection ready: setup total=1.050 ms, fork=0.170 ms, authentication=0.089 ms 로그가 위 그림의 흐름을 그대로 보여 줍니다.\nconnection received를 남긴 것은 postmaster가 아니라 이미 fork된 83번 프로세스입니다. 그런데 backend 타입이 not initialized로 찍혀 있습니다. fork 직후에는 아직 자기가 일반 backend인지 walsender인지 모르는 상태이기 때문입니다. StartupMessage를 읽은 뒤에야 client backend로 정해집니다. setup total=1.821 ms 가운데 fork=0.140 ms입니다. 로컬에서 trust 인증을 쓴 조건이라 짧지만, 이 2ms 가까운 시간은 접속할 때마다 드는 비용입니다. 운영에서는: 커넥션이 많을수록 비싸다 접속 하나가 프로세스 하나이므로, 커넥션 수는 곧 프로세스 수입니다. 비용은 세 군데서 나옵니다.\n접속할 때마다 드는 비용: 위 로그에서 접속 준비에 1-2ms(fork 약 0.15ms)가 걸렸습니다. 로컬 소켓에 trust 인증을 쓴 가장 가벼운 조건입니다. 네트워크 왕복, TLS, 비밀번호 인증이 붙으면 더 걸립니다. 요청마다 새로 접속하는 애플리케이션이라면 이 비용을 매번 냅니다. 프로세스마다 드는 메모리: idle backend 하나의 개인 메모리는 약 1.6MB였습니다(공유 메모리에서 확인). 쿼리를 실행하면 카탈로그 캐시와 정렬, 해시에 쓰는 작업 메모리(work_mem)가 여기에 더해집니다. 작업 메모리는 2편에서 다룹니다. 공유 자원 경쟁: 모든 backend가 같은 공유 메모리의 락과 PGPROC 배열을 씁니다. 프로세스가 많아지면 그만큼 경쟁과 문맥 전환(context switch)이 늘어납니다. 그래서 수천 개의 애플리케이션 커넥션을 그대로 PostgreSQL에 붙이지 않고, PgBouncer 같은 커넥션 풀러를 앞에 둡니다. 풀러는 애플리케이션의 접속은 많이 받고, PostgreSQL에는 적은 수의 접속만 유지하면서 backend를 돌려 씁니다. fork와 인증 비용은 한 번만 내고, 프로세스 수도 일정하게 유지됩니다.\nmax_connections는 fork된 뒤에 확인한다 max_connections 제한이 실제로 걸리는 곳은 2단계(인증과 초기화)입니다. postmaster가 fork 전에 확인하는 자식 슬롯은 2 × (max_connections + max_wal_senders)개로 넉넉하게 잡혀 있습니다. 인증 도중에 실패하거나 먼저 나가는 세션이 있기 때문입니다. 정확한 제한은 PGPROC 자리가 모자랄 때 sorry, too many clients already로 걸립니다. 그래서 거절될 접속도 일단 fork는 됩니다.\nmax_connections를 넘으면: 먼저 fork하고 나중에 거절한다 max_connections를 5로 줄이고 재시작한 뒤, 세션 5개로 자리를 다 채우고 6번째로 접속해 봅니다.\npostgres=# ALTER SYSTEM SET max_connections = 5; postgres=# ALTER SYSTEM SET superuser_reserved_connections = 0; $ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast waiting for server to shut down.... done server stopped waiting for server to start.... done server started $ for i in 1 2 3 4 5; do psql -X -q \u0026amp; done $ psql -X -c \u0026#34;SELECT 1\u0026#34; psql: error: connection to server on socket \u0026#34;/tmp/.s.PGSQL.5432\u0026#34; failed: FATAL: sorry, too many clients already $ grep -B1 \u0026#39;too many clients\u0026#39; /home/postgres/server.log 2026-09-26 10:24:14.697 UTC [389] not initialized LOG: connection received: host=[local] 2026-09-26 10:24:14.697 UTC [389] client backend FATAL: sorry, too many clients already 거절 메시지를 남긴 것은 postmaster가 아니라 389번 프로세스입니다. 자리가 없는 접속도 먼저 fork되고, 자식이 PGPROC 자리를 얻으려다 실패해서 스스로 FATAL로 끝난 것입니다. (실습이 끝난 뒤 설정은 RESET하고 재시작했습니다.)\n운영에서는: 거절될 접속도 fork 비용을 낸다 애플리케이션이 접속 실패를 짧은 간격으로 재시도하도록 되어 있으면, 거절될 접속을 만드느라 fork가 계속 일어납니다. 한도에 걸리는 상황이라면 재시도 간격을 늘리고 풀러를 두는 것이 근본적인 해결책입니다.\n관리자용 여유분도 알아 두면 좋습니다. superuser_reserved_connections(기본 3)만큼의 자리는 superuser만 쓸 수 있어서, 일반 접속이 한도를 다 채워도 DBA는 들어와서 조치할 수 있습니다. 위 실습에서는 이 값을 0으로 두어서 superuser인 postgres도 거절되었습니다. PG16부터는 pg_use_reserved_connections 역할에게 줄 여유분을 reserved_connections로 따로 둘 수 있습니다.\nwalsender도 시작은 backend다 복제용 접속(standby나 pg_receivewal)도 처음에는 평범한 backend로 fork됩니다. StartupMessage에 복제 요청이 들어 있으면 그 자리에서 프로세스 타입이 B_WAL_SENDER로 바뀌고, 이후로는 쿼리 대신 WAL을 보내는 일을 합니다.\n일반 backend가 walsender로 바뀌는 경우 pg_receivewal로 복제 접속을 만들어 봅니다.\n$ mkdir -p /home/postgres/wal $ pg_receivewal -D /home/postgres/wal \u0026amp; $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 412 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data ... (백그라운드 프로세스 8줄) 530 412 \\_ postgres: walsender postgres [local] streaming 0/3FCE280 $ ps -o pid,ppid,cmd -C pg_receivewal PID PPID CMD 523 0 pg_receivewal -D /home/postgres/wal $ WS=$(pgrep -f \u0026#39;postgres: walsender\u0026#39;) $ grep \u0026#34;\\[$WS\\]\u0026#34; /home/postgres/server.log 2026-09-26 10:24:20.908 UTC [530] not initialized LOG: connection received: host=[local] 2026-09-26 10:24:20.909 UTC [530] walsender LOG: connection authenticated: user=\u0026#34;postgres\u0026#34; method=trust (/var/lib/postgresql/data/pg_hba.conf:124) 2026-09-26 10:24:20.909 UTC [530] walsender LOG: replication connection authorized: user=postgres application_name=pg_receivewal 2026-09-26 10:24:20.909 UTC [530] walsender LOG: connection ready: setup total=1.240 ms, fork=0.149 ms, authentication=0.218 ms postgres=# SELECT pid, backend_type, application_name, state FROM pg_stat_activity WHERE backend_type = \u0026#39;walsender\u0026#39;; pid | backend_type | application_name | state -----+--------------+------------------+-------- 530 | walsender | pg_receivewal | active (1 row) 530번은 일반 접속과 똑같이 not initialized 상태로 fork되었다가, StartupMessage에서 복제 요청을 확인한 순간부터 walsender로 찍힙니다. 인증에 쓰인 규칙도 일반 접속(117, 119행)과 달리 pg_hba.conf의 124행, 즉 replication 항목입니다. 복제의 동작은 9편에서 다룹니다.\n백그라운드 프로세스: 뒷일을 나눠 맡는 프로세스들 PostgreSQL 18이 만들 수 있는 프로세스 종류는 BackendType 열거형에 모두 정의되어 있고, 각 타입의 시작 함수는 child_process_kinds[] 표에 있습니다. 이 가운데 클라이언트 접속과 상관없이 뒤에서 도는 프로세스들을 정리하면 다음과 같습니다.\n프로세스 (ps 표시) 하는 일 언제 뜨나 checkpointer 주기적으로 메모리의 변경된 페이지(dirty page)를 모두 디스크에 쓰고 fsync합니다. 이 시점이 장애 복구의 출발점이 됩니다. 항상 background writer dirty page를 조금씩 미리 써 두어, backend가 빈 버퍼를 찾을 때 직접 디스크에 쓰는 일을 줄입니다. 항상 walwriter WAL buffers에 쌓인 WAL을 주기적으로 디스크에 씁니다. 정상 운영 중(PM_RUN) io worker PG18 신규. backend 대신 실제 파일 읽기를 수행합니다. 아래에서 따로 설명합니다. 항상, 기본 3개 autovacuum launcher 어느 DB를 청소할지 정하고 postmaster에 worker를 요청합니다. autovacuum = on일 때 autovacuum worker 실제로 VACUUM과 ANALYZE를 수행합니다. 필요할 때만 잠깐 archiver 다 쓴 WAL 파일을 archive_command로 다른 곳에 복사합니다. archive_mode = on일 때 startup 기동할 때 WAL을 재생(redo)해 DB를 일관된 상태로 맞춥니다. standby에서는 계속 돕니다. 기동과 복구 때 logger 모든 프로세스의 stderr 출력을 모아 로그 파일에 씁니다. logging_collector = on일 때 walsender standby에 WAL을 보냅니다. 복제 접속마다 하나씩 생깁니다. 복제 접속이 있을 때 walreceiver standby 쪽에서 primary의 WAL을 받습니다. standby에서 walsummarizer 증분 백업용 WAL 요약을 만듭니다. summarize_wal = on일 때 slotsync worker 논리 복제 슬롯을 standby로 동기화합니다. standby에서 sync_replication_slots = on일 때 logical replication launcher 논리 복제 구독(subscription)의 worker를 관리합니다. background worker의 한 종류입니다. 항상 이 조건들은 소스에 그대로 나와 있습니다. postmaster는 ServerLoop를 돌 때마다 LaunchMissingBackgroundProcesses()를 불러 \u0026ldquo;지금 상태에서 있어야 하는데 없는 프로세스\u0026quot;를 띄웁니다. 예를 들어 checkpointer와 background writer는 기동 중에도 떠 있어야 하고, walwriter는 정상 운영(PM_RUN)일 때만 필요합니다. 복구 중에는 새 WAL을 쓸 일이 없기 때문입니다.\n기동 순서 기동 순서도 소스에서 읽을 수 있습니다. PostmasterMain()의 끝부분은 io worker, checkpointer, background writer, startup 순서로 프로세스를 띄웁니다. startup이 WAL 재생을 마치고 끝나면 그제야 walwriter, autovacuum launcher 등이 뜹니다.\n앞에서 본 ps 결과의 PID 번호가 바로 이 순서입니다.\nio worker(34-36) → checkpointer(37) → background writer(38) 순서로, PostmasterMain()의 코드 순서와 같습니다. 39번이 비어 있습니다. 서버 로그에서 database system was shut down at ...을 남긴 프로세스가 39번이고, 이것이 startup 프로세스입니다. 정상 종료 후의 기동이라 재생할 WAL이 없어 바로 끝났습니다. 그 뒤에 walwriter(40), autovacuum launcher(41)가 떴습니다. startup이 끝나 PM_RUN이 된 뒤에 walwriter를 띄운다는 소스 조건과 정확히 맞습니다. 보조 프로세스와 logger 백그라운드 프로세스는 두 부류로 나뉩니다.\nbackend처럼 동작하는 프로세스: autovacuum worker, walsender, background worker 등. 특정 DB에 붙어 트랜잭션을 실행할 수 있습니다. 보조 프로세스(auxiliary process): checkpointer, background writer, walwriter, io worker, startup, archiver 등. 공유 메모리에 자리(PGPROC)는 있지만 특정 DB에 붙지 않고, 트랜잭션을 실행하거나 일반 락을 잡을 수 없습니다(miscadmin.h의 주석). logger는 예외입니다. 공유 메모리에 붙지 않고 PGPROC도 없어서, postmaster처럼 pg_stat_activity에 보이지 않습니다. 공유 메모리가 망가져도 로그만은 끝까지 남겨야 하기 때문입니다.\n설정에 따라 생기는 프로세스: logger, archiver logging_collector와 archive_mode를 켜고 재시작합니다. 두 설정 모두 재시작이 필요합니다.\n$ mkdir -p /home/postgres/archive postgres=# ALTER SYSTEM SET logging_collector = on; postgres=# ALTER SYSTEM SET archive_mode = on; postgres=# ALTER SYSTEM SET archive_command = \u0026#39;cp %p /home/postgres/archive/%f\u0026#39;; $ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast waiting for server to shut down.... done server stopped waiting for server to start.... done server started $ sleep 1 $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 641 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data 642 641 \\_ postgres: logger 643 641 \\_ postgres: io worker 0 644 641 \\_ postgres: io worker 1 645 641 \\_ postgres: io worker 2 646 641 \\_ postgres: checkpointer 647 641 \\_ postgres: background writer 649 641 \\_ postgres: walwriter 650 641 \\_ postgres: autovacuum launcher 651 641 \\_ postgres: archiver 652 641 \\_ postgres: logical replication launcher postgres=# SELECT pid, backend_type FROM pg_stat_activity ORDER BY pid; pid | backend_type -----+------------------------------ 643 | io worker 644 | io worker 645 | io worker 646 | checkpointer 647 | background writer 649 | walwriter 650 | autovacuum launcher 651 | archiver 652 | logical replication launcher 663 | client backend (10 rows) logger(642)는 다른 어떤 자식보다 먼저 떴습니다. 이후 프로세스들의 로그를 받아 적어야 하기 때문입니다. archiver(651)는 pg_stat_activity에 보이지만 logger는 보이지 않습니다. 공유 메모리에 붙지 않는 프로세스라서 그렇습니다.\nPG18의 새 프로세스: io worker PostgreSQL 18에는 비동기 I/O(AIO)가 들어왔고, 그 기본 구현이 io_method = worker입니다(aio.h). 18 이전에는 backend가 테이블을 읽을 때 read() 시스템 콜을 직접 불렀습니다. 18에서는 다음처럼 바뀌었습니다(method_worker.c).\nbackend가 \u0026ldquo;이 블록들을 읽어 달라\u0026quot;는 요청을 공유 메모리의 제출 큐(submission queue)에 넣습니다. io worker가 큐에서 요청을 꺼내 실제 읽기 시스템 콜을 수행하고, 완료 처리까지 합니다. backend는 필요한 시점에만 결과를 기다립니다. 그 사이에 다른 일을 하거나 다음 읽기를 미리 요청할 수 있습니다. 요청이 몰리면 깨어난 worker가 다른 worker를 둘씩 더 깨웁니다(fan-out). worker 수는 io_workers(기본 3, 1-32)로 정하며, 서버를 재시작하지 않고 reload만으로 바꿀 수 있습니다. postmaster의 maybe_adjust_io_workers()가 설정값에 맞춰 worker를 늘리거나 줄입니다. 리눅스에서는 io_method = io_uring을 고를 수도 있는데, 그때는 io worker 대신 커널의 io_uring을 씁니다.\nio worker 수를 재시작 없이 바꾸기 postgres=# SHOW io_method; io_method ----------- worker (1 row) postgres=# SHOW io_workers; io_workers ------------ 3 (1 row) postgres=# ALTER SYSTEM SET io_workers = 5; postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) $ sleep 1 $ ps -u postgres -o pid,ppid,cmd | grep \u0026#39;io worker\u0026#39; | grep -v grep 34 33 postgres: io worker 0 35 33 postgres: io worker 1 36 33 postgres: io worker 2 141 33 postgres: io worker 3 142 33 postgres: io worker 4 postgres=# ALTER SYSTEM RESET io_workers; postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) $ sleep 1 $ ps -u postgres -o pid,ppid,cmd | grep \u0026#39;io worker\u0026#39; | grep -v grep 34 33 postgres: io worker 0 35 33 postgres: io worker 1 36 33 postgres: io worker 2 reload만으로 io worker가 5개로 늘었다가(141, 142 추가) 다시 3개로 줄었습니다. reload 요청(SIGHUP)을 받은 postmaster가 maybe_adjust_io_workers()로 개수를 맞춘 결과입니다.\n프로세스끼리 소통하는 방법 프로세스는 원래 서로의 메모리를 볼 수 없습니다. PostgreSQL 프로세스들이 협업하는 방법은 세 가지입니다.\n공유 메모리 postmaster는 기동할 때 CreateSharedMemoryAndSemaphores()로 큰 공유 메모리 영역을 만듭니다. 자식은 fork할 때 이 매핑을 그대로 물려받으므로, 모든 프로세스가 같은 가상 주소에서 같은 메모리를 봅니다. shared buffers, WAL buffers, 락 테이블, PGPROC 배열, PG18의 I/O 큐가 모두 여기에 있습니다. 크기는 shared_memory_size로 확인할 수 있습니다. 공유 메모리의 구성은 2편에서 자세히 다룹니다.\n공유 메모리는 모든 프로세스에 같은 주소로 붙어 있다 postgres=# SHOW shared_buffers; shared_buffers ---------------- 128MB (1 row) postgres=# SHOW shared_memory_size; shared_memory_size -------------------- 150MB (1 row) postgres=# SHOW shared_memory_type; shared_memory_type -------------------- mmap (1 row) shared buffers는 128MB지만, 락 테이블과 PGPROC 등을 합친 공유 메모리 전체는 150MB입니다. 21MB짜리 테이블을 만든 뒤, 한 세션은 이 테이블을 한 번 읽고 idle로 두고, 다른 세션은 아무것도 하지 않고 idle로 둡니다.\npostgres=# CREATE TABLE big AS SELECT g AS id, repeat(\u0026#39;x\u0026#39;, 100) AS pad FROM generate_series(1, 150000) g; postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;big\u0026#39;)); pg_size_pretty ---------------- 21 MB (1 row) postmaster와 두 backend의 공유 매핑(rw-s)을 비교합니다.\n$ PM=$(head -1 $PGDATA/postmaster.pid) $ for p in $PM $(pgrep -f \u0026#39;postgres: postgres postgres \\[local\\] idle\u0026#39;); do \u003e echo \u0026#34;== PID $p $(ps -o cmd= -p $p)\u0026#34; \u003e grep \u0026#39; rw-s \u0026#39; /proc/$p/maps | awk \u0026#39;{print \u0026#34; \u0026#34;, $1, $6, $7}\u0026#39; \u003e done == PID 412 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data ffffaf5e3000-ffffaf5eb000 /dev/shm/PostgreSQL.1630556078 ffffaf5eb000-ffffaf5ec000 /SYSV00137e8a (deleted) ffffaf5ec000-ffffb8b9e000 /dev/zero (deleted) == PID 583 postgres: postgres postgres [local] idle ffffaf450000-ffffaf481000 /dev/shm/PostgreSQL.3330517350 ffffaf4c2000-ffffaf5c2000 /dev/shm/PostgreSQL.3445492072 ffffaf5e3000-ffffaf5eb000 /dev/shm/PostgreSQL.1630556078 ffffaf5eb000-ffffaf5ec000 /SYSV00137e8a (deleted) ffffaf5ec000-ffffb8b9e000 /dev/zero (deleted) == PID 593 postgres: postgres postgres [local] idle ffffaf4c2000-ffffaf5c2000 /dev/shm/PostgreSQL.3445492072 ffffaf5e3000-ffffaf5eb000 /dev/shm/PostgreSQL.1630556078 ffffaf5eb000-ffffaf5ec000 /SYSV00137e8a (deleted) ffffaf5ec000-ffffb8b9e000 /dev/zero (deleted) ffffaf5ec000-ffffb8b9e000 /dev/zero (deleted)가 본체 공유 메모리입니다. 주소 범위를 계산하면 156,966,912바이트, 약 150MB로 shared_memory_size와 같습니다. 세 프로세스 모두 정확히 같은 주소에 붙어 있습니다. postmaster가 만든 매핑을 fork로 물려받았기 때문입니다. /dev/zero (deleted)는 shared_memory_type = mmap일 때 익명 공유 매핑이 /proc에 표시되는 방식입니다. /SYSV00137e8a는 몇 바이트짜리 System V 공유 메모리입니다. 데이터를 담는 곳이 아니라, 같은 데이터 디렉터리에 postmaster가 두 개 뜨는 일을 막는 잠금 장치로 씁니다(sysv_shmem.c의 주석). /dev/shm/PostgreSQL.*는 필요할 때 따로 만들어 붙이는 동적 공유 메모리(DSM)입니다. 프로세스마다 붙어 있는 조각이 다른 것도 그래서입니다. 이번에는 메모리 사용량을 봅니다. VmRSS는 ps나 top이 보여 주는 RSS 값이고, 리눅스는 이를 개인 메모리(RssAnon), 파일 매핑(RssFile), 공유 메모리(RssShmem)로 나눠 보여 줍니다.\n$ PM=$(head -1 $PGDATA/postmaster.pid) $ for p in $PM $(pgrep -f \u0026#39;postgres: postgres postgres \\[local\\] idle\u0026#39;); do \u003e echo \u0026#34;== PID $p $(ps -o cmd= -p $p)\u0026#34; \u003e grep -E \u0026#39;^(VmRSS|RssAnon|RssShmem)\u0026#39; /proc/$p/status \u003e done == PID 412 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data VmRSS:\t22336 kB RssAnon:\t796 kB RssShmem:\t13372 kB == PID 583 postgres: postgres postgres [local] idle VmRSS:\t34524 kB RssAnon:\t1848 kB RssShmem:\t24520 kB == PID 593 postgres: postgres postgres [local] idle VmRSS:\t11388 kB RssAnon:\t1616 kB RssShmem:\t3280 kB 테이블을 읽은 583번은 RSS가 34MB로, 아무것도 안 한 593번(11MB)의 세 배입니다. 하지만 늘어난 부분은 거의 RssShmem(24MB)이고, 자기만 쓰는 RssAnon은 1.6-1.8MB로 비슷합니다. 21MB 테이블을 shared buffers로 읽어 들이면서 공유 메모리 페이지를 만진 만큼 그 프로세스의 RSS에 잡힌 것입니다. 같은 페이지를 다른 backend가 만지면 그쪽 RSS에도 똑같이 잡힙니다.\n운영에서는: RSS를 합산하면 메모리 사용량이 부풀려진다 같은 shared buffers 페이지를 backend 100개가 읽었다면, 그 페이지는 100개 프로세스의 RSS에 모두 잡힙니다. 모니터링 도구로 postgres 프로세스들의 RSS를 합산하면 실제 사용량보다 훨씬 큰 값이 나오는 이유입니다. 프로세스별 개인 메모리를 보려면 /proc/\u0026lt;pid\u0026gt;/status의 RssAnon이나 smem의 USS/PSS 같은 지표를 보는 것이 정확합니다.\n시그널과 latch \u0026ldquo;일이 생겼으니 깨어나라\u0026quot;는 알림은 시그널로 보냅니다. 받는 쪽은 latch라는 장치로 잠들어 있다가 깨어납니다. 앞에서 본 ServerLoop의 WaitEventSetWait()가 latch를 기다리는 부분입니다. 자식이 postmaster에게 부탁할 때도 같은 방식을 씁니다. 예를 들어 autovacuum launcher는 worker가 필요하면 SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_WORKER)로 요청만 보내고, fork는 postmaster가 StartAutovacuumWorker()에서 합니다. 그래서 autovacuum worker의 부모 프로세스는 launcher가 아니라 postmaster입니다.\nautovacuum worker는 누가 fork하나 autovacuum이 자주 돌도록 autovacuum_naptime을 1초로 줄이고, 테이블 전체를 UPDATE해서 청소할 거리(dead tuple)를 만듭니다. 그다음 worker가 나타날 때까지 ps를 0.01초 간격으로 확인합니다.\npostgres=# ALTER SYSTEM SET autovacuum_naptime = \u0026#39;1s\u0026#39;; postgres=# ALTER SYSTEM SET log_autovacuum_min_duration = 0; postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# CREATE TABLE av_test AS SELECT g AS id, 0 AS v FROM generate_series(1, 200000) g; postgres=# UPDATE av_test SET v = 1; $ for i in $(seq 1 1000); do \u003e ps -u postgres -o pid,ppid,cmd | grep \u0026#39;autovacuum worker\u0026#39; | grep -v grep \u0026amp;\u0026amp; break \u003e sleep 0.01 \u003e done 294 33 postgres: autovacuum worker postgres $ sleep 3 $ grep -E \u0026#39;autovacuum worker LOG: automatic (vacuum|analyze) of table \u0026#34;postgres.public.av_test\u0026#34;\u0026#39; /home/postgres/server.log | cut -c1-120 2026-09-26 10:24:09.034 UTC [294] autovacuum worker LOG: automatic vacuum of table \u0026#34;postgres.public.av_test\u0026#34;: index sca 2026-09-26 10:24:09.079 UTC [294] autovacuum worker LOG: automatic analyze of table \u0026#34;postgres.public.av_test\u0026#34; 2026-09-26 10:24:10.032 UTC [297] autovacuum worker LOG: automatic vacuum of table \u0026#34;postgres.public.av_test\u0026#34;: index sca 2026-09-26 10:24:11.028 UTC [299] autovacuum worker LOG: automatic vacuum of table \u0026#34;postgres.public.av_test\u0026#34;: index sca ps로 잡은 294번 worker의 부모는 launcher(41)가 아니라 postmaster(33)입니다. 로그를 보면 294, 297, 299처럼 worker가 약 1초마다 새 PID로 생겼다 사라집니다. worker는 할 일을 마치면 종료하고, 다음 주기에 launcher가 다시 요청하면 postmaster가 새로 fork합니다. (실습용 설정은 이 단계 끝에서 ALTER SYSTEM RESET으로 되돌렸습니다.) VACUUM 자체는 5편에서 다룹니다.\npostmaster 생존 확인 자식들은 postmaster가 살아 있는지 계속 확인합니다. 리눅스에서는 prctl(PR_SET_PDEATHSIG)로 \u0026ldquo;부모가 죽으면 나에게 시그널을 보내 달라\u0026quot;고 커널에 등록해 둡니다. postmaster가 사라지면 자식들도 스스로 종료합니다. 감독이 없는 상태로 공유 메모리를 계속 만지면 위험하기 때문입니다.\npostmaster가 죽으면 postmaster 자체에 kill -9를 보냅니다. 운영 서버에서는 절대 하면 안 되는 일입니다.\n$ kill -9 641 $ sleep 2 $ ps -u postgres -o pid,ppid,stat,cmd | grep -v -e \u0026#39;ps -u\u0026#39; -e \u0026#39;sleep infinity\u0026#39; PID PPID STAT CMD 664 0 Ss bash -s bash -s는 이 명령을 실행한 셸입니다. PostgreSQL 프로세스는 하나도 남지 않았습니다. 부모가 죽은 것을 알아챈 자식들이 스스로 종료했습니다.\n$ psql -X -c \u0026#34;SELECT 1\u0026#34; psql: error: connection to server on socket \u0026#34;/tmp/.s.PGSQL.5432\u0026#34; failed: Connection refused Is the server running locally and accepting connections on that socket? $ pg_ctl -D $PGDATA -l /home/postgres/server.log start pg_ctl: another server might be running; trying to start server anyway waiting for server to start.... done server started $ sleep 1 $ tail -n 8 $PGDATA/log/$(ls -t $PGDATA/log | head -1) 2026-09-26 10:24:29.194 UTC [689] postmaster LOG: listening on Unix socket \u0026#34;/tmp/.s.PGSQL.5432\u0026#34; 2026-09-26 10:24:29.196 UTC [696] startup LOG: database system was interrupted; last known up at 2026-09-26 10:24:25 UTC 2026-09-26 10:24:29.222 UTC [696] startup LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 10:24:29.223 UTC [696] startup LOG: unexpected pageaddr 0/26EA000 in WAL segment 000000010000000000000005, LSN 0/56EA000, offset 7249920 2026-09-26 10:24:29.223 UTC [696] startup LOG: redo is not required 2026-09-26 10:24:29.225 UTC [694] checkpointer LOG: checkpoint starting: end-of-recovery immediate wait 2026-09-26 10:24:29.229 UTC [694] checkpointer LOG: checkpoint complete: wrote 0 buffers (0.0%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.001 s, sync=0.001 s, total=0.005 s; sync files=2, longest=0.001 s, average=0.001 s; distance=6 kB, estimate=6 kB; lsn=0/56EA048, redo lsn=0/56EA048 2026-09-26 10:24:29.230 UTC [689] postmaster LOG: database system is ready to accept connections postmaster.pid가 남아 있어서 pg_ctl이 \u0026ldquo;다른 서버가 떠 있을 수도 있다\u0026quot;고 경고했지만, 파일에 적힌 PID의 프로세스가 없으니 그대로 기동했습니다. 정상 종료가 아니었으므로 startup이 복구 과정을 거쳤습니다. 마지막 체크포인트 뒤로 재생할 WAL 레코드가 없어서 redo is not required로 끝났습니다.\n운영에서는: 컨테이너에서는 PID 1이 좀비를 치워야 한다 PostgreSQL을 컨테이너에서 띄울 때 PID 1을 sleep infinity 같은 프로세스로 두면, 위처럼 postmaster가 죽은 뒤 재기동이 실패할 수 있습니다. --init 없이 띄운 컨테이너에서 재현한 기록입니다.\n2026-09-26 10:12:12.213 UTC [56] postmaster FATAL: lock file \u0026#34;postmaster.pid\u0026#34; already exists 2026-09-26 10:12:12.213 UTC [56] postmaster HINT: Is another postmaster (PID 26) running in data directory \u0026#34;/var/lib/postgresql/data\u0026#34;? PID PPID STAT CMD 1 0 Ss /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep infinity 26 1 Zs [postgres] \u0026lt;defunct\u0026gt; 27 1 Zs [postgres] \u0026lt;defunct\u0026gt; 28 1 Zs [postgres] \u0026lt;defunct\u0026gt; 29 1 Zs [postgres] \u0026lt;defunct\u0026gt; 30 1 Zs [postgres] \u0026lt;defunct\u0026gt; 31 1 Zs [postgres] \u0026lt;defunct\u0026gt; 33 1 Zs [postgres] \u0026lt;defunct\u0026gt; 34 1 Zs [postgres] \u0026lt;defunct\u0026gt; 35 1 Zs [postgres] \u0026lt;defunct\u0026gt; (ps 출력에서 이 명령을 실행한 셸(bash -s) 줄은 생략했습니다. PID 1은 sleep infinity이고, 실습 이미지에서는 sleep이 coreutils 단일 바이너리를 거쳐 실행되어 이렇게 표시됩니다.)\npostmaster가 죽자 자식들의 부모는 PID 1로 바뀌었습니다. 그런데 sleep은 끝난 자식 프로세스를 회수(wait)하지 않으므로, 죽은 postmaster(26)와 자식들이 모두 좀비(Z)로 남았습니다. 좀비도 PID는 차지하고 있어서, 새 postmaster는 postmaster.pid에 적힌 26번이 아직 살아 있다고 판단하고 기동을 거부했습니다. PostgreSQL을 컨테이너에서 직접 띄울 때 PID 1을 sleep이나 셸 스크립트로 두었다면 --init(tini)이나 좀비를 회수하는 init을 쓰는 것이 안전합니다. 공식 postgres 이미지처럼 postmaster 자체를 PID 1로 두는 경우에는, postmaster가 자식을 회수하므로 이 문제가 없습니다.\nbackend 하나가 죽으면 왜 모두 재시작하나 PostgreSQL을 운영하다 보면 \u0026ldquo;쿼리 하나가 죽었는데 모든 접속이 끊겼다\u0026quot;는 일을 겪게 됩니다. 이유는 postmaster의 판단 기준에 있습니다.\n자식이 끝나면 postmaster는 CleanupBackend()에서 종료 코드를 봅니다. 종료 코드가 0(정상)이나 1(FATAL 에러로 스스로 종료)이 아니면 크래시로 봅니다. kill -9, 세그멘테이션 폴트, 리눅스 OOM killer에 의한 종료가 모두 여기에 해당합니다. 그 프로세스가 공유 메모리를 쓰던 중에 죽었는지 postmaster로서는 알 방법이 없습니다. 공유 메모리가 망가졌을 수 있다고 가정하고 모두 정리하는 것이 안전하다고 판단합니다.\n크래시 후 postmaster의 상태(PMState) 변화. 위쪽 초록 화살표가 재시작 경로입니다. 새 창에서 크게 보기 그림의 상태 이름은 소스의 PMState 값 그대로입니다. 순서는 다음과 같습니다.\nPM_RUN → PM_WAIT_BACKENDS: HandleChildCrash()가 \u0026ldquo;terminating any other active server processes\u0026quot;를 로그에 남기고, 살아 있는 모든 자식에게 SIGQUIT을 보냅니다. SIGQUIT을 받은 backend는 quickdie()에서 클라이언트에게 경고를 보내고 곧바로 끝납니다. 응답 없는 자식은 SIGKILL: 5초 안에 끝나지 않는 자식에게는 SIGKILL을 보냅니다. PM_NO_CHILDREN → PM_STARTUP: 자식이 모두 사라지면 \u0026ldquo;all server processes terminated; reinitializing\u0026rdquo;을 남기고 공유 메모리를 새로 만든 뒤 startup 프로세스를 띄웁니다. startup은 마지막 체크포인트부터 WAL을 재생합니다. 커밋된 데이터는 WAL에 남아 있으므로 잃지 않습니다. 재시작하지 않는 경우: restart_after_crash = off이거나 startup 프로세스 자체가 실패하면, postmaster도 재시작하지 않고 종료합니다. startup이 실패했다면 다시 해도 또 실패할 가능성이 크기 때문입니다. 반대로 pg_terminate_backend()로 끝낸 backend는 종료 코드 1로 스스로 끝나므로 이 과정을 거치지 않습니다. 두 경우를 나란히 비교해 보겠습니다.\n정상 종료와 비정상 종료의 차이 세션 셋을 백그라운드로 엽니다. 둘은 idle, 하나는 긴 쿼리를 실행합니다.\n$ psql -X -q \u0026amp; $ psql -X -q \u0026amp; $ psql -X -c \u0026#34;SELECT pg_sleep(3600)\u0026#34; \u0026amp; postgres=# CREATE TABLE t AS SELECT generate_series(1, 1000) AS id; $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 412 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data 413 412 \\_ postgres: io worker 0 414 412 \\_ postgres: io worker 1 415 412 \\_ postgres: io worker 2 416 412 \\_ postgres: checkpointer 417 412 \\_ postgres: background writer 419 412 \\_ postgres: walwriter 420 412 \\_ postgres: autovacuum launcher 421 412 \\_ postgres: logical replication launcher 430 412 \\_ postgres: postgres postgres [local] idle 439 412 \\_ postgres: postgres postgres [local] idle 447 412 \\_ postgres: postgres postgres [local] SELECT 먼저 정상 종료. idle 세션 하나(430)를 pg_terminate_backend()로 끝냅니다.\npostgres=# SELECT pg_terminate_backend(430); pg_terminate_backend ---------------------- t (1 row) $ sleep 1 $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 412 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data ... (413-421, 위와 같은 백그라운드 프로세스 8줄) 439 412 \\_ postgres: postgres postgres [local] idle 447 412 \\_ postgres: postgres postgres [local] SELECT $ grep \u0026#39;\\[430\\]\u0026#39; /home/postgres/server.log | tail -2 2026-09-26 10:24:17.448 UTC [430] client backend FATAL: terminating connection due to administrator command 2026-09-26 10:24:17.448 UTC [430] client backend LOG: disconnection: session time: 0:00:02.281 user=postgres database=postgres host=[local] 430번만 사라졌고 나머지 PID는 그대로입니다. backend가 FATAL로 스스로 끝나면서 종료 코드 1을 냈기 때문에, postmaster는 정상 종료로 처리했습니다.\n이번에는 비정상 종료. 남은 idle 세션(439)에 kill -9를 보냅니다.\n$ kill -9 439 $ sleep 2 $ PM=$(head -1 $PGDATA/postmaster.pid); ps -o pid,ppid,cmd --forest -p $PM --ppid $PM PID PPID CMD 412 1 /usr/local/pgsql/bin/postgres -D /var/lib/postgresql/data 484 412 \\_ postgres: io worker 0 485 412 \\_ postgres: io worker 1 486 412 \\_ postgres: io worker 2 488 412 \\_ postgres: checkpointer 489 412 \\_ postgres: background writer 490 412 \\_ postgres: walwriter 491 412 \\_ postgres: autovacuum launcher 492 412 \\_ postgres: logical replication launcher postmaster(412)를 뺀 모든 프로세스의 PID가 바뀌었습니다. 백그라운드 프로세스까지 전부 새로 fork된 것이고, 아무 잘못이 없던 447번 세션도 사라졌습니다. 로그에 전 과정이 남아 있습니다.\n$ sed -n \u0026#39;/terminated by signal 9/,$p\u0026#39; /home/postgres/server.log 2026-09-26 10:24:18.553 UTC [412] postmaster LOG: client backend (PID 439) was terminated by signal 9: Killed 2026-09-26 10:24:18.553 UTC [412] postmaster LOG: terminating any other active server processes 2026-09-26 10:24:18.553 UTC [412] postmaster LOG: all server processes terminated; reinitializing 2026-09-26 10:24:18.564 UTC [487] startup LOG: database system was interrupted; last known up at 2026-09-26 10:24:14 UTC 2026-09-26 10:24:18.597 UTC [487] startup LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 10:24:18.597 UTC [487] startup LOG: redo starts at 0/3F9BF38 2026-09-26 10:24:18.600 UTC [487] startup LOG: invalid record length at 0/3FC3028: expected at least 24, got 0 2026-09-26 10:24:18.600 UTC [487] startup LOG: redo done at 0/3FC2E90 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s 2026-09-26 10:24:18.601 UTC [488] checkpointer LOG: checkpoint starting: end-of-recovery immediate wait 2026-09-26 10:24:18.605 UTC [488] checkpointer LOG: checkpoint complete: wrote 22 buffers (0.1%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.001 s, sync=0.003 s, total=0.005 s; sync files=20, longest=0.001 s, average=0.001 s; distance=156 kB, estimate=156 kB; lsn=0/3FC3028, redo lsn=0/3FC3028 2026-09-26 10:24:18.606 UTC [412] postmaster LOG: database system is ready to accept connections 위 그림의 상태 변화가 로그 한 줄 한 줄에 대응합니다.\n로그 상태 was terminated by signal 9 종료 코드가 0, 1이 아니므로 크래시로 판단 terminating any other active server processes PM_WAIT_BACKENDS. 모든 자식에게 SIGQUIT all server processes terminated; reinitializing PM_NO_CHILDREN. 공유 메모리 재생성 [487] startup ... automatic recovery in progress, redo starts PM_STARTUP. 새 startup 프로세스(487)가 WAL 재생 invalid record length ... WAL의 끝에 도달했다는 뜻으로, 복구 중에 흔히 보이는 정상 메시지 database system is ready to accept connections PM_RUN 복귀 크래시부터 복귀까지 약 50ms가 걸렸습니다. 재생할 WAL이 156kB뿐이라 빨랐던 것이고, 쓰기가 많은 운영 서버라면 마지막 체크포인트 이후 쌓인 WAL 양만큼 오래 걸립니다(8편에서 다룹니다).\n아무 잘못이 없던 옆 세션(447번)의 psql에는 다음 메시지가 찍혔습니다.\nWARNING: terminating connection because of crash of another server process DETAIL: The postmaster has commanded this server process to roll back the current transaction and exit, because another server process exited abnormally and possibly corrupted shared memory. HINT: In a moment you should be able to reconnect to the database and repeat your command. server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost \u0026ldquo;다른 서버 프로세스가 비정상 종료해서 공유 메모리가 망가졌을 수 있으니, 이 프로세스도 트랜잭션을 롤백하고 끝내라고 postmaster가 명령했다\u0026quot;는 뜻입니다. 앞에서 본 설계 이유가 메시지에 그대로 적혀 있습니다. 커밋까지 끝난 데이터는 WAL 재생으로 복구되었습니다.\npostgres=# SELECT count(*) FROM t; count ------- 1000 (1 row) 운영에서는: backend에 kill -9를 쓰면 안 된다 위 실습 결과가 곧 이유입니다. 문제가 된 세션 하나를 끝내려고 kill -9를 쓰면, 그 순간 서버의 모든 세션이 끊기고 복구 과정을 거칩니다. 세션을 끝낼 때는 다음을 씁니다.\nSELECT pg_cancel_backend(pid); -- 실행 중인 쿼리만 취소 (세션은 유지) SELECT pg_terminate_backend(pid); -- 세션 종료 (다른 세션에는 영향 없음) 운영자가 직접 kill -9를 쓰지 않아도 같은 일이 일어날 수 있습니다. 대표적인 것이 리눅스 OOM killer입니다. 메모리가 모자라 커널이 backend 하나를 죽이면 똑같이 전체 재시작이 일어나고, 커널이 postmaster를 골라 죽이면 서버가 아예 내려갑니다. 그래서 PostgreSQL 문서는 전용 DB 서버에서 vm.overcommit_memory = 2로 메모리 overcommit을 막아 OOM killer가 개입할 상황 자체를 줄이라고 권합니다(Linux Memory Overcommit).\n로그에 terminating any other active server processes와 all server processes terminated; reinitializing이 보이면 크래시 재시작이 일어난 것입니다. 바로 앞 줄에 어떤 프로세스가 어떤 시그널로 죽었는지(was terminated by signal 9) 나오므로, 그 PID를 기준으로 원인을 추적합니다.\n정리 PostgreSQL은 프로세스 기반입니다. postmaster가 모든 프로세스의 부모이고, fork는 postmaster만 합니다. postmaster는 접속을 받아 fork하고 자식을 감시할 뿐, 쿼리를 처리하거나 공유 메모리를 만지지 않습니다. 그래서 자식이 어떻게 죽든 뒷수습을 할 수 있습니다. backend는 접속 하나에 하나입니다. fork가 먼저이고, 인증과 max_connections 확인은 fork된 자식이 합니다. 백그라운드 프로세스들이 디스크 쓰기(checkpointer, background writer, walwriter), 읽기(PG18의 io worker), 청소(autovacuum), 보관(archiver)을 나눠 맡습니다. 어떤 프로세스가 뜰지는 postmaster 상태와 설정으로 정해집니다. 모든 프로세스는 같은 공유 메모리를 같은 주소로 봅니다. 그래서 프로세스 하나가 비정상 종료하면 postmaster는 공유 메모리를 믿을 수 없다고 보고, 모두 내린 뒤 WAL로 복구해서 다시 띄웁니다. 운영에서는 커넥션 풀러로 프로세스 수를 관리하고, 세션은 pg_terminate_backend()로 끝내고, OOM killer가 backend를 죽이지 않도록 메모리 설정을 관리합니다. 다음 글에서는 이 프로세스들이 함께 쓰는 공유 메모리 안쪽, 즉 shared buffers, WAL buffers, 그리고 backend마다 따로 쓰는 work_mem을 살펴봅니다.\n참고 자료 소스 코드 (모두 REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/postmaster/postmaster.c: postmaster 본체, ServerLoop, BackendStartup, 크래시 처리, PMState src/backend/postmaster/launch_backend.c: 프로세스 종류별 시작 함수 표와 fork src/backend/postmaster/pmchild.c: 자식 프로세스 슬롯 관리 src/backend/tcop/backend_startup.c: backend 시작, StartupMessage 처리, log_connections src/backend/tcop/postgres.c: PostgresMain 메인 루프, quickdie src/backend/storage/aio/method_worker.c: PG18 io worker src/include/miscadmin.h: BackendType 정의 PostgreSQL 18 공식 문서\nHow Connections Are Established Glossary: Postmaster, Auxiliary process io_method, io_workers log_connections restart_after_crash pg_stat_activity Linux Memory Overcommit ","permalink":"https://jinlog.dev/posts/postgresql/01-process-architecture/","summary":"postmaster와 backend, 백그라운드 프로세스가 각각 무슨 일을 하는지","title":"PostgreSQL 인터널 1: 프로세스 구조"},{"content":"개요 1편에서 본 것처럼, WiredTiger의 쓰기는 캐시의 페이지를 고치는 일입니다. 고친 페이지가 파일에 닿는 것은 나중에 eviction이나 체크포인트가 그 페이지를 쓸 때입니다. 그렇다면 쓰기가 성공했다는 응답을 받은 직후 mongod가 죽으면, 캐시에만 있던 변경은 어떻게 될까요.\nWiredTiger는 이 문제를 두 가지로 풉니다. 하나는 체크포인트입니다. 주기적으로 모든 테이블의 일관된 스냅샷을 파일에 새로 써서, 그 시점까지의 데이터를 확정합니다. 다른 하나는 저널(WiredTiger의 로그)입니다. 커밋할 때마다 변경을 로그 레코드로 남겨서, 마지막 체크포인트 뒤의 변경을 다시 만들 수 있게 합니다. 비정상 종료 뒤에는 마지막 체크포인트를 열고 그 뒤의 저널을 다시 적용합니다.\n이 글에서 답할 질문은 다음과 같습니다.\n체크포인트는 언제 돌고, 파일에 무엇을 남기는가 저널에는 무엇이 기록되고, 그 기록은 언제 디스크에 닿는가 write concern의 j:true와 j:false는 무엇이 다른가 mongod가 kill -9로 죽으면 재기동할 때 무슨 일이 일어나는가 PostgreSQL의 WAL, 체크포인트와는 무엇이 다른가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 레플리카셋과 비교하는 부분만 멤버 하나짜리 레플리카셋을 따로 띄웠습니다.\n체크포인트: 파일 안의 일관된 스냅샷 WiredTiger의 체크포인트는 \u0026ldquo;지금까지 캐시에서 고친 페이지를 파일에 쓴다\u0026quot;보다 조금 더 강한 약속입니다. 체크포인트 하나는 모든 테이블을 한 시점에서 본 일관된 스냅샷이고, 파일에는 체크포인트 단위로 완성된 B-tree가 들어 있습니다. 1편에서 wt verify가 보여 준 ckpt_name: WiredTigerCheckpoint.1이 그 체크포인트의 이름입니다.\n체크포인트 하나는 대략 이런 순서로 만들어집니다(__txn_checkpoint()).\n로그에 체크포인트 시작 레코드를 남깁니다(txn_ckpt.c). 이 위치가 나중에 복구를 시작할 LSN(ckpt_lsn)입니다. 체크포인트 트랜잭션의 스냅샷을 잡고, 테이블마다 그 스냅샷에서 보이는 내용으로 dirty 페이지를 reconciliation해서 파일의 빈 자리에 새 블록으로 씁니다. 제자리에 덮어쓰지 않습니다. 쓴 파일은 fsync합니다(txn_ckpt.c). 테이블마다 새 root 페이지의 주소를 메타데이터 테이블(WiredTiger.wt)에 적고, 메타데이터 테이블 자신도 체크포인트합니다. 메타데이터의 새 root 주소를 WiredTiger.turtle.set에 쓰고 fsync한 뒤 WiredTiger.turtle로 이름을 바꿉니다(__wti_turtle_update()). 이름 바꾸기는 원자적이므로, 이 순간 새 체크포인트가 확정됩니다. 이전 체크포인트만 쓰던 블록을 재사용할 수 있는 빈 자리로 돌립니다. 새 체크포인트의 위치가 안정된 저장소에 적히기 전에 옛 블록을 재사용하면, 그 사이 죽었을 때 옛 체크포인트까지 망가지기 때문에 이 순서를 지킵니다(__ckpt_process()). turtle 파일이 메타데이터의 체크포인트를 가리키고, 메타데이터가 각 테이블의 체크포인트를 가리키는 구조라서, 어느 순간에 죽더라도 파일에는 \u0026ldquo;완성된 마지막 체크포인트\u0026quot;가 온전히 남아 있습니다. 쓰다 만 새 블록은 아무도 가리키지 않는 빈 공간일 뿐입니다.\n체크포인트를 부르는 것은 mongod의 Checkpointer 스레드입니다. storage.syncPeriodSecs(서버 파라미터 syncdelay, 기본 60초, storage_options.h)만큼 잠들었다가 깨어나 체크포인트를 하고, 끝나면 다시 60초를 잡니다(Checkpointer::run()). 그래서 정확한 간격은 \u0026ldquo;60초 + 체크포인트에 걸린 시간\u0026quot;입니다. standalone에는 stable timestamp가 없어서 타임스탬프를 쓰지 않는 체크포인트(use_timestamp=false)를 하고, 레플리카셋에서는 과반수에 복제된 지점(stable timestamp)까지의 체크포인트를 합니다(WiredTigerKVEngine::_checkpoint()). 레플리카셋 쪽은 5편과 8편에서 다시 봅니다.\n기본 설정 기본 설정으로 standalone mongod를 띄우고 저널 설정과 파라미터를 봅니다.\n$ jq -r \u0026#39;select(.msg == \u0026#34;Opening WiredTiger\u0026#34;) | .attr.config\u0026#39; /data/mongod.log | grep -oE \u0026#39;log=\\([^)]*\\)\u0026#39; log=(enabled=true,remove=true,path=journal,compressor=snappy) log=(wait=0) $ ls -l /data/db/journal total 204800 -rw------- 1 mongod mongod 104857600 Sep 27 21:42 WiredTigerLog.0000000001 -rw------- 1 mongod mongod 104857600 Sep 27 21:42 WiredTigerPreplog.0000000001 test\u003e db.adminCommand({getParameter: 1, syncdelay: 1, journalCommitInterval: 1}) { journalCommitInterval: 100, syncdelay: 60, ok: 1 } 첫 줄이 저널 설정입니다. 둘째 줄 log=(wait=0)은 정규식에 statistics_log=(wait=0)의 뒷부분이 함께 걸린 것으로, 저널과는 관계없습니다. 체크포인트 주기 syncdelay는 60초, 저널을 fsync하는 주기 journalCommitInterval(storage.journal.commitIntervalMs)은 100ms입니다. 아무것도 쓰지 않았는데 journal에 100MB 파일이 둘 있습니다. WiredTigerLog.0000000001이 지금 쓰는 로그 파일이고, WiredTigerPreplog.0000000001은 다음에 쓸 파일을 미리 잡아 둔 것입니다(api_data.py의 prealloc, 파일 크기는 file_max 기본 100MB). 60초마다 도는 체크포인트 백그라운드에서 1초에 한 건씩 130초 동안 쓰게 해 두고, 2분쯤 뒤에 로그를 봅니다. mongod는 체크포인트마다 WiredTiger의 saving checkpoint snapshot 메시지를 남깁니다(verbose=[checkpoint_progress:1], meta_ckpt.c).\n$ setsid nohup mongosh --quiet --eval \u0026#39;for (let i = 0; i \u0026lt; 130; i++) { db.tick.insertOne({i: i, at: new Date()}); sleep(1000); }\u0026#39; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 \u0026amp; $ jq -r \u0026#39;select(.msg == \u0026#34;WiredTiger message\u0026#34; and (.attr.message.msg | test(\u0026#34;saving checkpoint snapshot\u0026#34;))) | .t.\u0026#34;$date\u0026#34; + \u0026#34; \u0026#34; + .attr.message.msg\u0026#39; /data/mongod.log 2026-09-27T21:43:42.896+00:00 saving checkpoint snapshot min: 99, snapshot max: 99 snapshot count: 0, oldest timestamp: (0, 0) , meta checkpoint timestamp: (0, 0) base write gen: 1 2026-09-27T21:44:42.916+00:00 saving checkpoint snapshot min: 161, snapshot max: 161 snapshot count: 0, oldest timestamp: (0, 0) , meta checkpoint timestamp: (0, 0) base write gen: 1 test\u003e function ckpt() { const c = db.serverStatus().wiredTiger.checkpoint; return {succeeded: c[\u0026#34;total succeed number of checkpoints\u0026#34;], startedByApi: c[\u0026#34;number of checkpoints started by api\u0026#34;], skippedClean: c[\u0026#34;checkpoints skipped because database was clean\u0026#34;], mostRecentMs: c[\u0026#34;most recent time (msecs)\u0026#34;], maxMs: c[\u0026#34;max time (msecs)\u0026#34;]}; } [Function: ckpt] test\u003e ckpt() { succeeded: 2, startedByApi: 2, skippedClean: 0, mostRecentMs: 11, maxMs: 16 } test\u003e db.tick.countDocuments() 130 $ grep -oE \u0026#39;WiredTigerCheckpoint\\.[0-9]+\u0026#39; /data/db/WiredTiger.turtle WiredTigerCheckpoint.2 1분을 더 기다린 뒤 다시 봅니다.\ntest\u003e ckpt() { succeeded: 3, startedByApi: 3, skippedClean: 0, mostRecentMs: 10, maxMs: 16 } $ jq -r \u0026#39;select(.msg == \u0026#34;WiredTiger message\u0026#34; and (.attr.message.msg | test(\u0026#34;saving checkpoint snapshot\u0026#34;))) | .t.\u0026#34;$date\u0026#34;\u0026#39; /data/mongod.log | tail -2 2026-09-27T21:44:42.916+00:00 2026-09-27T21:45:42.935+00:00 $ grep -oE \u0026#39;WiredTigerCheckpoint\\.[0-9]+\u0026#39; /data/db/WiredTiger.turtle WiredTigerCheckpoint.3 체크포인트 시각이 21:43:42.896, 21:44:42.916, 21:45:42.935로 60초와 약 20ms 간격입니다. 앞 체크포인트가 끝난 뒤부터 60초를 세기 때문에 체크포인트에 걸린 시간만큼 조금씩 밀립니다. serverStatus().wiredTiger.checkpoint의 성공 횟수가 2에서 3으로 늘었고, 모두 mongod가 WiredTiger API로 부른 것(startedByApi)입니다. 쓰는 양이 적어서 체크포인트 하나에 10~16ms가 걸렸습니다. turtle 파일이 가리키는 메타데이터의 체크포인트 이름도 WiredTigerCheckpoint.2에서 .3으로 바뀌었습니다. 체크포인트마다 turtle이 새로 쓰입니다. 메시지의 oldest timestamp: (0, 0), meta checkpoint timestamp: (0, 0)은 standalone이라 타임스탬프 없이 체크포인트했다는 뜻입니다. 저널: 체크포인트 뒤의 변경을 담는 로그 체크포인트가 60초마다라면, 그 사이의 변경은 저널이 지킵니다. MongoDB의 저널은 WiredTiger의 로그 기능 그대로이고, mongod가 log=(enabled=true,path=journal,...)로 켭니다. 저널은 페이지가 아니라 트랜잭션의 변경 내용을 기록합니다. 트랜잭션이 커밋할 때, 그 트랜잭션이 테이블마다 넣거나 고친 키와 값을 로그 레코드 하나로 만들어 로그에 씁니다(__wt_txn_log_commit()). 커밋하지 않은 변경은 로그에 없습니다.\n로그 레코드가 디스크에 닿기까지는 세 단계가 있습니다.\n단계 누가 언제 로그 버퍼(slot)에 복사 커밋하는 스레드 커밋할 때. MongoDB는 transaction_sync를 켜지 않으므로(기본 enabled=false, api_data.py) 여기서 커밋이 끝남 파일에 write() WiredTiger log server 스레드 등 버퍼가 차거나, log server가 50ms~1초 간격으로 깨어나 밀어낼 때(conn_log.c, __log_server()), 또는 fsync를 요청받을 때 fsync mongod의 JournalFlusher 스레드 journalCommitInterval(100ms)마다, 또는 j:true 쓰기가 요청할 때 JournalFlusher는 매번 waitUntilDurable()을 부르고 journalCommitInterval만큼 기다리는 일을 반복합니다(journal_flusher.cpp). waitUntilDurable()은 결국 WiredTiger의 log_flush(\u0026quot;sync=on\u0026quot;)입니다(wiredtiger_session_cache.cpp). 이 값은 1~500ms 사이로 실행 중에도 바꿀 수 있습니다(storage_parameters.idl).\n로그 파일은 100MB가 차면 미리 잡아 둔 다음 파일로 넘어갑니다. 지난 파일은 그 안의 레코드가 모두 마지막 체크포인트보다 앞일 때, 즉 복구에 더는 필요 없을 때 log server가 지웁니다(__compute_min_lognum()). 저널 파일을 지우는 것은 체크포인트입니다.\n250MB를 넣으면 저널 파일이 는다 1KB 남짓한 문서 25만 개를 넣기 전후로 로그 통계를 봅니다. bytes written for checkpoint는 체크포인트가 데이터 파일에 쓴 양입니다.\n// /data/load.js: 1KB 남짓한 문서 25만 개(약 250MB)를 넣는다 let seed = 7; const rnd = () =\u0026gt; (seed = (seed * 1103515245 + 12345) % 2147483648); let pool = \u0026#34;\u0026#34;; while (pool.length \u0026lt; 1000000) pool += rnd().toString(36); for (let b = 0; b \u0026lt; 250; b++) { const docs = []; for (let i = 0; i \u0026lt; 1000; i++) docs.push({_id: b * 1000 + i, amount: rnd() % 100000, memo: pool.substr(rnd() % 990000, 1000)}); db.bulk.insertMany(docs); } test\u003e function logstat() { const l = db.serverStatus().wiredTiger.log, b = db.serverStatus().wiredTiger[\u0026#34;block-manager\u0026#34;]; return {logBytesWritten: l[\u0026#34;log bytes written\u0026#34;], logSyncs: l[\u0026#34;log sync operations\u0026#34;], maxLogFileSize: l[\u0026#34;maximum log file size\u0026#34;], preallocUsed: l[\u0026#34;pre-allocated log files used\u0026#34;], ckptBytesWritten: b[\u0026#34;bytes written for checkpoint\u0026#34;]}; } [Function: logstat] test\u003e logstat() { logBytesWritten: 50688, logSyncs: 154, maxLogFileSize: 104857600, preallocUsed: 0, ckptBytesWritten: 327680 } $ mongosh --quiet /data/load.js $ ls -l /data/db/journal total 306796 -rw------- 1 mongod mongod 104605952 Sep 27 21:46 WiredTigerLog.0000000001 -rw------- 1 mongod mongod 104690048 Sep 27 21:46 WiredTigerLog.0000000002 -rw------- 1 mongod mongod 104857600 Sep 27 21:46 WiredTigerLog.0000000003 test\u003e logstat() { logBytesWritten: 224924160, logSyncs: 165, maxLogFileSize: 104857600, preallocUsed: 2, ckptBytesWritten: 327680 } 저널에 약 225MB(logBytesWritten 224924160)가 쓰였고, 로그 파일이 3개로 늘었습니다. 미리 잡아 둔 파일을 2개 썼습니다(preallocUsed: 2). 1번과 2번 파일이 100MB보다 조금 작은 것은, 다 쓴 로그 파일을 닫을 때 마지막으로 쓴 위치 뒤의 빈 부분을 잘라 내기 때문입니다(conn_log.c). 아직 체크포인트 전이라 ckptBytesWritten은 그대로입니다. 이 순간 25만 개 문서는 캐시(와 eviction이 쓴 블록)와 저널에만 있고, 파일의 체크포인트에는 없습니다. 이제 fsync 명령으로 체크포인트를 바로 부릅니다. WiredTiger에서 fsync는 체크포인트를 강제하는 명령입니다(flushAllFiles(), wiredtiger_session_cache.cpp).\ntest\u003e db.adminCommand({fsync: 1}) { numFiles: 1, ok: 1 } test\u003e logstat() { logBytesWritten: 224926848, logSyncs: 170, maxLogFileSize: 104857600, preallocUsed: 2, ckptBytesWritten: 249569280 } $ ls -l /data/db/journal total 204800 -rw------- 1 mongod mongod 104857600 Sep 27 21:46 WiredTigerLog.0000000003 -rw------- 1 mongod mongod 104857600 Sep 27 21:46 WiredTigerPreplog.0000000003 체크포인트 한 번에 데이터 파일로 약 250MB(327680 → 249569280)가 쓰였습니다. 같은 데이터가 저널에 한 번(snappy로 압축된 로그 레코드로), 체크포인트로 데이터 파일에 또 한 번 쓰인 것입니다. 체크포인트 뒤 2초 안에 1번과 2번 로그 파일이 지워졌습니다. 그 안의 레코드는 모두 새 체크포인트 앞이라 복구에 필요 없기 때문입니다. 지금 쓰는 3번과, 다음을 위해 새로 잡은 WiredTigerPreplog.0000000003만 남았습니다. 레플리카셋에서는 사용자 컬렉션을 저널에 쓰지 않는다 여기까지는 standalone의 동작입니다. 레플리카셋에서는 사용자 컬렉션의 변경을 저널에 쓰지 않습니다. 멤버 하나짜리 레플리카셋을 따로 띄워 테이블 설정을 봅니다.\ntest\u003e rs.initiate().ok 1 test\u003e db.acct.insertOne({_id: 1, balance: 100}).acknowledged true test\u003e db.acct.stats().wiredTiger.creationString.match(/log=\\([^)]*\\)/)[0] log=(enabled=false) test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.stats().wiredTiger.creationString.match(/log=\\([^)]*\\)/)[0] log=(enabled=true) test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).system.replset.stats().wiredTiger.creationString.match(/log=\\([^)]*\\)/)[0] log=(enabled=true) 사용자 컬렉션 acct는 log=(enabled=false)이고, local 데이터베이스의 oplog와 레플리카셋 설정은 log=(enabled=true)입니다. 레플리카셋이면 복제되는 컬렉션은 테이블 로깅을 끕니다(WiredTigerUtil::useTableLogging(), 설정은 wiredtiger_record_store.cpp). 모든 쓰기는 oplog에도 기록되므로, 비정상 종료 뒤에는 마지막 stable 체크포인트를 열고 oplog를 다시 적용해 사용자 데이터를 복구합니다. 같은 변경을 저널과 oplog에 두 번 쓰지 않는 셈입니다. 이 경로는 5편에서 다룹니다. 이 글의 나머지는 standalone 기준입니다.\nj 옵션: 응답 전에 저널 fsync를 기다리는가 write concern의 j는 \u0026ldquo;응답하기 전에 이 쓰기가 저널에서 fsync될 때까지 기다리라\u0026quot;는 뜻입니다. j:true인 w:1 쓰기는 JournalFlusher에게 지금 flush하라고 요청하고, 그 flush가 끝날 때까지 기다립니다(write_concern.cpp). j를 주지 않은 w:1 쓰기는 SyncMode::NONE이 되어 기다리지 않습니다(replication_coordinator_impl.cpp). 이때 쓰기는 로그 버퍼에 들어간 채로 응답을 받고, 다음 JournalFlusher 차례(최대 100ms 뒤)에 디스크에 닿습니다. 아래 그림이 이 과정입니다.\nj:false는 로그 버퍼에 들어간 뒤 바로 응답하고, j:true는 JournalFlusher의 fsync를 기다린 뒤 응답합니다. 데이터 파일의 체크포인트는 60초마다 따로 확정됩니다. 새 창에서 크게 보기 log sync 횟수로 본 j:true와 j:false serverStatus().wiredTiger.log의 log sync operations는 로그 파일을 fsync한 횟수입니다. 아무것도 쓰지 않을 때, j:false로 계속 쓸 때, j:false와 j:true로 500건씩 몰아 쓸 때 이 값이 얼마나 느는지 봅니다.\ntest\u003e function syncs() { return db.serverStatus().wiredTiger.log[\u0026#34;log sync operations\u0026#34;]; } [Function: syncs] test\u003e let a = syncs(); sleep(5000); \u0026#34;idle 5s: log syncs +\u0026#34; + (syncs() - a) idle 5s: log syncs +0 test\u003e a = syncs(); let t = Date.now(), n = 0; while (Date.now() - t \u0026lt; 5000) { db.jt.insertOne({n: n++}, {writeConcern: {w: 1, j: false}}); sleep(10); } n + \u0026#34; inserts (j:false) in 5s: log syncs +\u0026#34; + (syncs() - a) 324 inserts (j:false) in 5s: log syncs +50 test\u003e a = syncs(); t = Date.now(); for (let i = 0; i \u0026lt; 500; i++) db.jt.insertOne({i: i}, {writeConcern: {w: 1, j: false}}); \u0026#34;500 inserts j:false: \u0026#34; + (Date.now() - t) + \u0026#34; ms, log syncs +\u0026#34; + (syncs() - a) 500 inserts j:false: 145 ms, log syncs +2 test\u003e a = syncs(); t = Date.now(); for (let i = 0; i \u0026lt; 500; i++) db.jt.insertOne({i: i}, {writeConcern: {w: 1, j: true}}); \u0026#34;500 inserts j:true: \u0026#34; + (Date.now() - t) + \u0026#34; ms, log syncs +\u0026#34; + (syncs() - a) 500 inserts j:true: 174 ms, log syncs +500 아무것도 쓰지 않은 5초 동안 fsync는 0번입니다. JournalFlusher는 100ms마다 깨어나지만 새 레코드가 없으면 fsync하지 않습니다. j:false로 10ms마다 쓰면 5초에 fsync가 50번, 즉 100ms에 한 번입니다. 쓰기 324건이 50번의 fsync에 묶여 디스크에 닿았습니다. 이것이 journalCommitInterval의 뜻입니다. j:false 500건은 145ms 동안 fsync가 2번뿐이고, j:true 500건은 쓰기마다 한 번씩 500번 fsync했습니다. 쓰기가 하나씩 차례로 들어와서 fsync를 묶을 다른 쓰기가 없었기 때문입니다. 동시에 여러 연결이 j:true로 쓰면 한 번의 fsync가 그 사이에 들어온 쓰기를 함께 처리합니다. 시간은 145ms와 174ms로 차이가 작습니다. 이 실습 환경(Docker Desktop VM)의 fsync가 매우 빠르기 때문으로, 실제 디스크에서는 fsync 한 번이 수 ms가 걸리기도 합니다. 여러 실습이 함께 돈 환경이라 이 시간은 이 실행 안에서의 비교로만 봐야 합니다. journalCommitInterval을 500ms로 늘리면 fsync 간격도 그대로 늘어납니다.\ntest\u003e db.adminCommand({setParameter: 1, journalCommitInterval: 500}) { was: 100, ok: 1 } test\u003e a = syncs(); t = Date.now(); n = 0; while (Date.now() - t \u0026lt; 5000) { db.jt.insertOne({n: n++}, {writeConcern: {w: 1, j: false}}); sleep(10); } n + \u0026#34; inserts (j:false, journalCommitInterval 500) in 5s: log syncs +\u0026#34; + (syncs() - a) 332 inserts (j:false, journalCommitInterval 500) in 5s: log syncs +10 test\u003e db.adminCommand({setParameter: 1, journalCommitInterval: 100}) { was: 500, ok: 1 } 같은 쓰기에 fsync가 10번, 500ms에 한 번입니다. 간격을 늘리면 fsync는 줄지만, j:false 쓰기가 응답을 받은 뒤 디스크에 닿지 않은 채 머무는 시간이 그만큼 길어집니다.\n비정상 종료와 복구 mongod를 다시 띄울 때 WiredTiger는 wiredtiger_open() 안에서 복구를 합니다(__wt_txn_recover()).\nturtle 파일에서 메타데이터의 마지막 체크포인트를 열고, 그 체크포인트가 시작된 로그 위치(ckpt_lsn)를 얻습니다. 그 위치부터 로그를 읽어 먼저 메타데이터에 대한 레코드를 적용하고(txn_recover.c), 그다음 모든 테이블의 레코드를 적용합니다(Main recovery loop, txn_recover.c). 레코드마다 그 테이블의 체크포인트 LSN보다 뒤인 것만 적용합니다(txn_recover.c). 타임스탬프를 쓰는 테이블은 stable timestamp로 되돌리고(rollback to stable, standalone에서는 할 일이 없음), 체크포인트를 한 번 해서 복구 결과를 확정합니다. mongod 쪽에서는 mongod.lock이 비어 있지 않으면 비정상 종료로 판단하고 경고를 남깁니다(storage_engine_init.cpp, wiredtiger_init.cpp).\n체크포인트 뒤에 쓰고 kill -9 컬렉션 acct에 문서 1000개(balance 100)를 넣고 fsync로 체크포인트를 합니다. 그 뒤에 j:true로 3건(balance 200)을 넣고 한 건을 고칩니다.\ntest\u003e db.acct.insertMany(Array.from({length: 1000}, (_, i) =\u0026gt; ({_id: i, balance: 100}))).insertedIds[999] 999 test\u003e db.adminCommand({fsync: 1}) { numFiles: 1, ok: 1 } test\u003e for (let i = 1000; i \u0026lt; 1003; i++) db.acct.insertOne({_id: i, balance: 200}, {writeConcern: {w: 1, j: true}}); db.acct.countDocuments() 1003 test\u003e db.acct.updateOne({_id: 1}, {$inc: {balance: 5}}, {writeConcern: {w: 1, j: true}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } 이어서 j:false로 100건(balance 300)을 넣고, 그 mongosh가 끝나자마자 mongod를 kill -9로 죽입니다. 죽은 직후의 dbPath를 복사해 두고 들여다봅니다.\n$ mongosh --quiet --eval \u0026#39;for (let i = 2000; i \u0026lt; 2100; i++) db.acct.insertOne({_id: i, balance: 300}, {writeConcern: {w: 1, j: false}}); print(db.acct.countDocuments())\u0026#39; \u0026amp;\u0026amp; kill -9 $(pgrep -x mongod) 1103 $ sleep 1; pgrep -x mongod || echo \u0026#34;mongod is gone\u0026#34; mongod is gone $ cp -a /data/db /tmp/crash-copy j:false 100건은 모두 성공 응답을 받았고, 죽기 직전의 countDocuments()는 1103입니다.\n파일에는 마지막 체크포인트까지만 있다 복사본을 wt로 읽기 전용(-r)으로 엽니다. wt는 저널 경로를 모르므로 로그를 적용하지 않고, 파일에 있는 체크포인트만 봅니다.\n$ wt -r -h /tmp/crash-copy list -v file:collection-13-4293686799854559489.wt | tr \u0026#39;,\u0026#39; \u0026#39;\\n\u0026#39; | grep -E \u0026#39;^(id|checkpoint)=\u0026#39; id=17 checkpoint=(WiredTigerCheckpoint.1=(addr=\u0026#34;018281e47f2f0c1a8381e4ebfdf67c8481e4bdc8df7b808080e23fc0dfc0\u0026#34; $ wt -r -h /tmp/crash-copy list -c file:collection-13-4293686799854559489.wt file:collection-13-4293686799854559489.wt WiredTigerCheckpoint.1: Sun Sep 27 21:46:26 2026 (size 12 KB) file-size: 24 KB, checkpoint-size: 8 KB offset, size, checksum root : 12288, 4096, 2133797978 (0x7f2f2c5a) alloc : 16384, 4096, 3959297724 (0xebfe16bc) discard : 0, 0, 0 (0) avail : 20480, 4096, 3184066491 (0xbdc8ffbb) $ wt -r -h /tmp/crash-copy dump table:collection-13-4293686799854559489 | sed -n \u0026#39;/^Data/,$p\u0026#39; | sed -n \u0026#39;2~2p\u0026#39; | wc -l 1000 메타데이터에 적힌 acct 컬렉션 파일의 설정에는 WiredTiger 안의 파일 번호(id=17)와 체크포인트 WiredTigerCheckpoint.1의 root 주소(addr)가 있습니다. 메타데이터가 곧 \u0026ldquo;이 파일의 체크포인트는 여기\u0026quot;라는 기록입니다. list -c는 그 체크포인트의 내용입니다. root 페이지의 위치 외에, 이 체크포인트가 할당한 블록(alloc), 버린 블록(discard), 쓸 수 있는 빈 자리(avail)의 목록이 각각 블록 하나로 저장되어 있습니다. 앞에서 본 \u0026ldquo;새 체크포인트가 확정된 뒤에야 옛 블록을 재사용한다\u0026quot;를 이 목록으로 관리합니다. 체크포인트 기준으로 덤프하면 문서가 1000개입니다. 체크포인트 뒤에 넣은 j:true 3건도, j:false 100건도 파일에는 없습니다. 이 파일만으로 복구하면 체크포인트 시점으로 돌아갑니다. 저널에는 그 뒤가 있다 같은 복사본의 저널을 wt printlog로 읽습니다. printlog는 로그를 dbPath에서 찾으므로 복사본의 로그 파일을 dbPath로 옮기고, 로그 레코드가 snappy로 압축되어 있으니 압축 방식을 WiredTiger.config에 적어 줍니다. 커밋 레코드와 체크포인트 레코드만 골라, 레코드마다 어느 파일(fileid)에 몇 번 쓰는지로 줄였습니다.\n$ mv /tmp/crash-copy/journal/WiredTigerLog.* /tmp/crash-copy/ $ echo \u0026#39;log=(compressor=snappy)\u0026#39; \u0026gt; /tmp/crash-copy/WiredTiger.config $ wt -h /tmp/crash-copy printlog -u \u0026gt; /tmp/printlog.json $ jq -c \u0026#39;.[] | select(.type == \u0026#34;checkpoint\u0026#34; or .type == \u0026#34;commit\u0026#34;) | {lsn, type, txnid, ops: ([.ops[]? | \u0026#34;\\(.optype) fileid=\\(.fileid)\u0026#34;] | group_by(.) | map(\u0026#34;\\(.[0]) x\\(length)\u0026#34;))}\u0026#39; /tmp/printlog.json | tail -8 {\u0026#34;lsn\u0026#34;:[3,15858048],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2856,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=17 x500\u0026#34;,\u0026#34;row_put fileid=18 x500\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15865088],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2857,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=2 x3\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15865472],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2858,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=0 x8\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15867648],\u0026#34;type\u0026#34;:\u0026#34;checkpoint\u0026#34;,\u0026#34;txnid\u0026#34;:null,\u0026#34;ops\u0026#34;:[]} {\u0026#34;lsn\u0026#34;:[3,15867776],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2859,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=17 x1\u0026#34;,\u0026#34;row_put fileid=18 x1\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15867904],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2860,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=17 x1\u0026#34;,\u0026#34;row_put fileid=18 x1\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15868032],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2861,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=17 x1\u0026#34;,\u0026#34;row_put fileid=18 x1\u0026#34;]} {\u0026#34;lsn\u0026#34;:[3,15868160],\u0026#34;type\u0026#34;:\u0026#34;commit\u0026#34;,\u0026#34;txnid\u0026#34;:2862,\u0026#34;ops\u0026#34;:[\u0026#34;row_put fileid=17 x1\u0026#34;]} $ wt -r -h /tmp/crash-copy list -v file:index-14-4293686799854559489.wt | tr \u0026#39;,\u0026#39; \u0026#39;\\n\u0026#39; | grep -E \u0026#39;^id=\u0026#39; id=18 fileid 17은 acct 컬렉션, 18은 그 _id 인덱스(index-14)입니다. 0은 WiredTiger 메타데이터 테이블입니다. 레코드를 순서대로 읽으면 이렇습니다.\ntxnid 2856: insertMany 1000건 가운데 뒤의 500건입니다. insertMany는 500건씩 묶어 한 트랜잭션으로 넣으므로(write_ops_exec.cpp, 기본값 storage_options.h) 컬렉션에 500번, _id 인덱스에 500번 row_put이 한 레코드에 들어 있습니다. 로그 레코드는 페이지가 아니라 키와 값이고, 커밋할 때 트랜잭션 하나가 레코드 하나가 됩니다. txnid 2858의 메타데이터 쓰기 8번과 checkpoint 레코드는 fsync가 부른 체크포인트입니다. 체크포인트 뒤의 txnid 2859~2861은 j:true로 넣은 3건입니다. 각각 컬렉션과 _id 인덱스에 한 번씩 씁니다. txnid 2862는 balance를 고친 updateOne으로, 인덱스 키가 바뀌지 않아 컬렉션에만 씁니다. j:false로 넣은 100건은 저널에 없습니다. 로그의 마지막 레코드가 updateOne입니다. 재기동: 마지막 체크포인트 + 저널 원래 dbPath로 mongod를 다시 띄우고 복구 로그를 봅니다. WiredTiger의 복구 메시지는 WTRECOV 컴포넌트로 남습니다.\n$ mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork | grep -E \u0026#39;forked|ERROR\u0026#39; forked process: 626 $ jq -c \u0026#39;select(.id == 22271 or .id == 22302 or .id == 4795906 or .c == \u0026#34;WTRECOV\u0026#34;) | {t: .t.\u0026#34;$date\u0026#34;, c, msg: (.attr.message.msg // .msg), attr: (if .c == \u0026#34;WTRECOV\u0026#34; then null else .attr end)} | del(..|nulls)\u0026#39; /data/mongod.log | sed -n \u0026#39;/unclean shutdown/,$p\u0026#39; {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.681+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;STORAGE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Detected unclean shutdown - Lock file is not empty\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;lockFile\u0026#34;:\u0026#34;/data/db/mongod.lock\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.681+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;STORAGE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Recovering data from the last clean checkpoint.\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.863+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Recovering log 3 through 4\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.870+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Recovering log 4 through 4\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.881+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Main recovery loop: starting at 3/15865344 to 4/256\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.881+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Recovering log 3 through 4\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.889+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Recovering log 4 through 4\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.898+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;recovery log replay has successfully finished and ran for 35 milliseconds\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.898+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Set global recovery timestamp: (0, 0)\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.898+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Set global oldest timestamp: (0, 0)\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.899+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;recovery rollback to stable has successfully finished and ran for 0 milliseconds\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.902+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;recovery checkpoint has successfully finished and ran for 2 milliseconds\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.902+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;recovery was completed successfully and took 39ms, including 35ms for the log replay, 0ms for the rollback to stable, and 2ms for the checkpoint.\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.902+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;WTRECOV\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;recovery was completed successfully and took 39ms, including 35ms for the log replay, 0ms for the rollback to stable, and 2ms for the checkpoint.\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T21:46:29.902+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;STORAGE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;WiredTiger opened\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;durationMillis\u0026#34;:221}} test\u003e db.acct.countDocuments({balance: 100}) + \u0026#34; / \u0026#34; + db.acct.countDocuments({balance: 200}) + \u0026#34; / \u0026#34; + db.acct.countDocuments({balance: 300}) 999 / 3 / 0 test\u003e db.acct.findOne({_id: 1}) { _id: 1, balance: 105 } mongod가 mongod.lock이 비어 있지 않은 것을 보고 비정상 종료를 알아챘습니다(Detected unclean shutdown). WiredTiger는 Main recovery loop: starting at 3/15865344, 즉 로그 파일 3번의 15865344 위치부터 재생했습니다. 앞의 printlog에서 체크포인트 레코드는 15867648에 있었는데, 재생은 그보다 앞에서 시작합니다. 체크포인트 레코드에 적힌 ckpt_lsn은 체크포인트를 시작한 위치이고, 체크포인트가 도는 동안 커밋된 변경도 있을 수 있기 때문입니다. 이미 체크포인트에 들어간 변경은 테이블별 체크포인트 LSN과 비교해 건너뜁니다. 재생은 35ms, 복구 전체는 39ms가 걸렸습니다. 재생할 것이 체크포인트 뒤의 레코드 몇 개뿐이었기 때문입니다. 마지막에 복구 체크포인트를 한 번 하고 WiredTiger opened로 끝났습니다. 복구 뒤 데이터는 balance 100이 999건(1건은 105로 바뀜), 200이 3건, 300이 0건입니다. 체크포인트(1000건) 위에 저널의 j:true 쓰기 4건이 재생되었고, 저널에 없던 j:false 100건은 사라졌습니다. j:false 100건은 클라이언트가 성공 응답을 받았고, 죽기 직전의 mongod에서는 조회도 되었습니다. 그런데도 사라진 것은 레코드가 아직 mongod 프로세스 메모리의 로그 버퍼에 있었기 때문입니다. 커밋은 레코드를 버퍼에 넣고 끝나고, 파일에 write()하는 것은 log server가 깨어날 때(50ms~1초 간격)나 JournalFlusher가 fsync할 때(100ms마다)입니다. 100건을 넣고 곧바로 죽였더니 그 틈에 걸린 것입니다. write()까지 된 레코드라면 프로세스가 죽어도 운영체제 페이지 캐시에 남아 살아남지만, 서버 전원이 나가면 fsync하지 않은 레코드는 그것도 장담할 수 없습니다. 이 실습에서는 운영체제 장애를 재현하지 않았습니다. 어느 쪽이든 j:false 쓰기는 응답을 받은 뒤 최대 journalCommitInterval만큼의 쓰기를 잃을 수 있다는 것이 이 실험의 결론입니다. 반대로 j:true 쓰기는 fsync가 끝난 뒤에 응답했으므로 모두 복구되었습니다.\nPostgreSQL의 WAL, 체크포인트와 비교 PostgreSQL 연재의 WAL과 체크포인트와 장애 복구와 나란히 놓으면 차이가 분명합니다.\nPostgreSQL WiredTiger (MongoDB standalone) 로그의 내용 페이지에 대한 변경(블록 번호와 그 안의 변경) 트랜잭션이 테이블에 쓴 키와 값 로그를 쓰는 때 페이지를 고칠 때마다 WAL 레코드를 만들고, 커밋할 때 flush 커밋할 때 트랜잭션 하나를 레코드 하나로. 커밋 안 된 변경은 로그에 없음 데이터 파일 8kB 페이지를 제자리에 덮어씀 빈 자리에 새 블록을 쓰고, 옛 체크포인트의 블록은 새 체크포인트가 확정된 뒤 재사용 full page write 필요. 덮어쓰다 만 페이지(torn page)를 되살리려고 체크포인트 뒤 첫 변경 때 페이지 전체를 WAL에 씀 없음. 체크포인트가 가리키는 블록은 덮어쓰지 않으므로 반쯤 쓴 블록이 체크포인트에 섞이지 않음 체크포인트 REDO 시작점을 정하고 그때까지의 dirty 페이지를 씀. 데이터 파일은 체크포인트 사이에도 계속 바뀜 체크포인트 자체가 모든 테이블의 일관된 스냅샷. turtle 파일 교체로 확정 복구 REDO 위치부터 WAL을 재생해 페이지를 고침 마지막 체크포인트를 열고, ckpt_lsn부터 커밋 레코드를 키와 값으로 다시 적용 로그 보존 체크포인트의 REDO 위치 이전 WAL을 지우거나 재활용 체크포인트 LSN 이전의 로그 파일을 지움 PostgreSQL에서는 체크포인트가 \u0026ldquo;여기부터 재생하면 된다\u0026quot;는 표시이고 데이터 파일은 체크포인트 사이에도 계속 덮어쓰입니다. 그래서 복구의 기준은 WAL이고, 페이지가 찢어진 경우를 대비한 full page write가 필요합니다. WiredTiger는 파일 안의 체크포인트가 그 자체로 완결된 데이터베이스이고, eviction이 체크포인트 사이에 dirty 페이지를 써도 새 블록에 쓸 뿐 체크포인트의 블록은 건드리지 않습니다. 저널은 체크포인트 뒤만 채우면 되므로, 커밋된 변경의 키와 값만 담아도 충분합니다.\n운영에서는 이렇게 나타납니다 체크포인트마다 쓰기 I/O가 몰린다 앞의 실습에서 체크포인트 한 번이 250MB를 데이터 파일에 썼습니다. 쓰기가 많은 서버에서는 60초 동안 쌓인 dirty 페이지를 체크포인트가 한꺼번에 쓰므로, 디스크 쓰기 지표가 60초 주기로 튀는 모습이 보입니다. serverStatus().wiredTiger.checkpoint의 most recent time (msecs)와 max time (msecs)로 체크포인트 하나에 걸리는 시간을 보고, 이 값이 주기(60초)에 가까워지면 체크포인트가 끝나자마자 다음 체크포인트가 도는 셈이라 디스크가 쓰기 양을 감당하지 못하고 있다는 뜻입니다. 이때 dirty 페이지가 캐시에 오래 남으면 1편의 dirty trigger(20%)에 닿아 요청 스레드까지 eviction을 하게 됩니다. syncdelay를 바꾸는 것은 권장되지 않는 설정이고(storage_options.h), 보통은 디스크의 쓰기 성능을 먼저 봅니다.\n저널은 별도 디스크에 둘 수 있다 j:true 쓰기와 JournalFlusher의 fsync는 저널 디렉터리의 fsync 지연에 그대로 묶입니다. 체크포인트의 대량 쓰기와 저널 fsync가 같은 디스크에서 경쟁하면 체크포인트 동안 j:true 쓰기가 느려질 수 있습니다. 이때 journal 디렉터리를 다른 디스크에 심볼릭 링크로 두는 방법이 있습니다. 다만 스냅샷 방식 백업은 데이터 파일과 저널을 같은 순간에 떠야 하므로, 디스크를 나누면 백업 절차도 함께 확인해야 합니다.\n저널 파일이 줄지 않는다면 로그 파일은 체크포인트가 끝나야 지워집니다. 쓰기가 많은 동안 journal 디렉터리에 100MB 파일이 여러 개 생기는 것은 정상이고, 다음 체크포인트 뒤에 줄어듭니다. 저널 파일이 계속 쌓이기만 한다면 체크포인트가 끝나지 못하고 있는지(most recent time, 로그의 saving checkpoint snapshot 시각), 백업 커서가 열려 있어 로그 삭제가 막혀 있는지를 봅니다.\n비정상 종료 뒤 재기동 시간 standalone의 복구는 마지막 체크포인트 뒤의 저널만 재생하므로, 재생할 양은 많아야 체크포인트 주기 동안의 쓰기입니다. 실습에서는 39ms였지만, 쓰기가 많은 서버라면 1분치 저널을 재생해야 합니다. 체크포인트가 느려 주기가 길어진 상태에서 죽으면 재생할 양도 그만큼 늘어납니다. 재기동 로그에서 Main recovery loop와 recovery was completed successfully and took ... 사이의 시간이 복구 시간입니다. 레플리카셋 멤버는 여기에 oplog 재적용 시간이 더해집니다(5편).\nj 옵션을 고르는 기준 w:1에 j를 주지 않으면 응답은 로그 버퍼에 들어간 순간에 옵니다. 실습처럼 mongod가 죽으면 응답을 받은 쓰기 가운데 최대 journalCommitInterval(100ms)만큼이 사라질 수 있습니다. 한 건도 잃으면 안 되는 쓰기라면 j:true를 써야 하고, 대가는 쓰기마다 fsync를 기다리는 지연입니다. 레플리카셋에서 w:\u0026quot;majority\u0026quot;는 writeConcernMajorityJournalDefault(기본 true)에 따라 저널까지 기다리므로 j를 따로 줄 필요가 없습니다. 복제와 함께 본 write concern의 보장은 8편에서 정리합니다.\n정리 WiredTiger의 체크포인트는 모든 테이블의 일관된 스냅샷입니다. dirty 페이지를 새 블록에 쓰고, 메타데이터와 turtle 파일을 바꿔 확정합니다. mongod의 Checkpointer가 앞 체크포인트가 끝난 뒤 syncdelay(60초)마다 부릅니다. 저널은 WiredTiger의 로그로, 커밋마다 트랜잭션이 쓴 키와 값을 레코드로 남깁니다. 100MB 파일을 미리 잡아 쓰고, 체크포인트 뒤 필요 없어진 파일은 지웁니다. 로그 레코드는 커밋 때 버퍼에 들어가고, JournalFlusher가 journalCommitInterval(100ms)마다 fsync합니다. j:true는 그 fsync를 바로 요청하고 기다립니다. 비정상 종료 뒤에는 마지막 체크포인트를 열고 ckpt_lsn부터 저널을 재생합니다. 실습에서 j:true 쓰기는 모두 복구되었고, 죽기 직전의 j:false 쓰기 100건은 저널에 닿지 못해 사라졌습니다. 레플리카셋에서는 사용자 컬렉션을 저널에 쓰지 않고, oplog로 복구합니다. PostgreSQL과 달리 데이터 파일을 제자리에 덮어쓰지 않으므로 full page write가 없고, 저널은 체크포인트 뒤의 논리적 변경만 담습니다. 다음 글에서는 캐시의 페이지에 달린 update 목록으로 돌아가, WiredTiger가 여러 버전을 어떻게 관리하는지 MVCC와 스냅샷을 봅니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/db/storage/checkpointer.cpp: 체크포인트 스레드 src/mongo/db/storage/control/journal_flusher.cpp: 저널 fsync 스레드 src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp: 로그 설정, _checkpoint src/mongo/db/storage/wiredtiger/wiredtiger_util.cpp: useTableLogging src/mongo/db/write_concern.cpp: j 옵션 처리 src/third_party/wiredtiger/src/txn/txn_ckpt.c, src/block/block_ckpt.c, src/meta/meta_turtle.c: 체크포인트, 블록 재사용, turtle src/third_party/wiredtiger/src/txn/txn_log.c, src/conn/conn_log.c: 커밋 로그 레코드, log server, 로그 파일 삭제 src/third_party/wiredtiger/src/txn/txn_recover.c: 복구 MongoDB 8.0 공식 문서\nJournaling Write Concern WiredTiger Storage Engine ","permalink":"https://jinlog.dev/posts/mongodb/02-checkpoint-and-journal/","summary":"MongoDB는 캐시의 변경을 언제 파일에 확정하고, 비정상 종료 뒤에는 무엇으로 복구하는가","title":"MongoDB 인터널 2: 체크포인트와 저널"},{"content":"개요 애플리케이션 응답이 멈추고, 커넥션 풀이 가득 차고, DB 서버의 CPU와 I/O는 오히려 한가합니다. 세션들이 무언가를 기다리고 있을 때의 전형적인 모습입니다. 대부분은 락 대기이고, 그 끝에는 락을 쥔 채 아무 일도 하지 않는 세션 하나가 있는 경우가 많습니다.\n이 글에서 답할 질문은 다음과 같습니다.\n멈춘 세션들 가운데 처음 원인이 된 세션은 어떻게 찾는가 행 락, DDL이 만드는 락 큐, deadlock은 각각 어떻게 보이는가 pg_cancel_backend와 pg_terminate_backend는 무엇이 다른가 테이블 하나에 컬럼을 추가하려다 서비스 전체를 멈추지 않으려면 어떻게 하는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습에는 1편에서 켠 log_lock_waits와 log_line_prefix를 그대로 씁니다.\npostgres=# CREATE TABLE orders (id int PRIMARY KEY, status text); CREATE TABLE postgres=# INSERT INTO orders SELECT g, \u0026#39;new\u0026#39; FROM generate_series(1, 3) g; INSERT 0 3 postgres=# SHOW deadlock_timeout; deadlock_timeout ------------------ 1s (1 row) 먼저 확인할 것 세션이 멈췄다는 신고가 오면 가장 먼저 이 쿼리를 돌립니다.\nSELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() ORDER BY xact_start NULLS LAST, pid; 보는 순서는 이렇습니다.\nwtype이 Lock인 세션이 있는가. 있다면 락 대기입니다. LWLock이나 IO라면 락이 아니라 내부 자원이나 디스크를 기다리는 것이니 다른 편의 주제입니다. blocked_by를 따라 올라갑니다. 자기 blocked_by는 비어 있는데 다른 세션의 blocked_by에 이름이 오르는 세션이 뿌리입니다. 뿌리 세션의 state를 봅니다. idle in transaction이면 트랜잭션을 열어 둔 채 아무것도 하지 않고 있는 것이고, active면 무언가를 오래 실행 중인 것입니다. ORDER BY xact_start로 정렬하면 트랜잭션을 가장 먼저 연 세션이 위로 오므로, 뿌리가 대개 첫 줄 근처에 있습니다.\npg_blocking_pids()는 편리하지만 호출할 때마다 락 관리자를 잠깐 잠그므로, 수백 개 세션에 대해 초 단위로 반복 호출하는 모니터링에 넣는 것은 피하라고 문서가 권합니다(pg_blocking_pids). 장애 조사처럼 가끔 돌리는 용도라면 문제없습니다.\n원인별 진단 행 락: 커밋하지 않은 세션 하나가 줄을 세운다 세 세션이 같은 행을 고칩니다. A는 고친 뒤 커밋하지 않고 멈춰 있습니다.\nA=# BEGIN; BEGIN A=# UPDATE orders SET status = \u0026#39;paid\u0026#39; WHERE id = 1; UPDATE 1 B=# UPDATE orders SET status = \u0026#39;canceled\u0026#39; WHERE id = 1; C=# UPDATE orders SET status = \u0026#39;shipped\u0026#39; WHERE id = 1; B와 C의 UPDATE는 끝나지 않습니다.\npostgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+-------+---------------------+--------+---------------+------------+----------------------------------------------- 104 | app_a | idle in transaction | Client | ClientRead | {} | UPDATE orders SET status = \u0026#39;paid\u0026#39; WHERE id = 141 | app_b | active | Lock | transactionid | {104} | UPDATE orders SET status = \u0026#39;canceled\u0026#39; WHERE i 178 | app_c | active | Lock | tuple | {141} | UPDATE orders SET status = \u0026#39;shipped\u0026#39; WHERE id (3 rows) A는 idle in transaction입니다. 아무 쿼리도 실행하지 않지만 id = 1 행의 락을 쥐고 있습니다. B는 Lock/transactionid, 즉 A의 트랜잭션이 끝나기를 기다립니다(blocked_by {104}). C는 A가 아니라 B에게 막혀 있고(blocked_by {141}), 기다리는 대상도 tuple입니다. C가 왜 B를 기다리는지는 pg_locks에서 보입니다.\npostgres=# SELECT l.pid, a.application_name AS app, l.locktype, l.transactionid AS xid, postgres-# l.page, l.tuple, l.mode, l.granted postgres-# FROM pg_locks l JOIN pg_stat_activity a USING (pid) postgres-# WHERE l.locktype IN (\u0026#39;transactionid\u0026#39;, \u0026#39;tuple\u0026#39;) AND a.application_name LIKE \u0026#39;app_%\u0026#39; postgres-# ORDER BY l.granted DESC, l.pid; pid | app | locktype | xid | page | tuple | mode | granted -----+-------+---------------+-----+------+-------+---------------+--------- 104 | app_a | transactionid | 754 | | | ExclusiveLock | t 141 | app_b | tuple | | 0 | 1 | ExclusiveLock | t 141 | app_b | transactionid | 755 | | | ExclusiveLock | t 178 | app_c | transactionid | 756 | | | ExclusiveLock | t 141 | app_b | transactionid | 754 | | | ShareLock | f 178 | app_c | tuple | | 0 | 1 | ExclusiveLock | f (6 rows) 모든 트랜잭션은 자기 트랜잭션 ID에 ExclusiveLock을 걸어 둡니다. 행을 고치려다 다른 트랜잭션이 이미 고친 행을 만나면, 그 트랜잭션 ID에 ShareLock을 요청해서 끝나기를 기다립니다(B의 754 ShareLock f). 이 방식은 인터널 4편에서 다뤘습니다.\n같은 행을 여러 세션이 기다릴 때는 순서를 정하는 장치가 하나 더 있습니다. 가장 먼저 온 B가 그 행의 튜플 락(tuple, 페이지 0의 1번 튜플)을 잡고 줄 맨 앞에 서고, 뒤에 온 C는 그 튜플 락을 기다립니다. 그래서 C의 blocked_by에는 B가 나옵니다. 행 하나에 대기자가 열 명이면 한 명은 transactionid, 나머지 아홉 명은 tuple을 기다리는 모습으로 보입니다.\n서버 로그에도 두 가지 대기가 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;still waiting|Process holding|STATEMENT\u0026#39; 2026-09-26 09:49:39.566 UTC [141] postgres@postgres/app_b LOG: process 141 still waiting for ShareLock on transaction 754 after 1000.154 ms 2026-09-26 09:49:39.566 UTC [141] postgres@postgres/app_b DETAIL: Process holding the lock: 104. Wait queue: 141. 2026-09-26 09:49:39.566 UTC [141] postgres@postgres/app_b STATEMENT: UPDATE orders SET status = \u0026#39;canceled\u0026#39; WHERE id = 1; 2026-09-26 09:49:40.864 UTC [178] postgres@postgres/app_c LOG: process 178 still waiting for ExclusiveLock on tuple (0,1) of relation 16384 of database 5 after 1000.504 ms 2026-09-26 09:49:40.864 UTC [178] postgres@postgres/app_c DETAIL: Process holding the lock: 141. Wait queue: 178. 2026-09-26 09:49:40.864 UTC [178] postgres@postgres/app_c STATEMENT: UPDATE orders SET status = \u0026#39;shipped\u0026#39; WHERE id = 1; 튜플 락 대기는 테이블과 DB를 OID로 적습니다.\npostgres=# SELECT (SELECT oid FROM pg_class WHERE relname = \u0026#39;orders\u0026#39;) AS orders_oid, postgres-# (SELECT oid FROM pg_database WHERE datname = \u0026#39;postgres\u0026#39;) AS postgres_oid; orders_oid | postgres_oid ------------+-------------- 16384 | 5 (1 row) relation 16384 of database 5는 postgres DB의 orders 테이블입니다.\nDDL 락 큐: 컬럼 하나 추가하다 서비스가 멈춘다 운영에서 가장 흔하고 피해가 큰 경우입니다. 세션 A가 트랜잭션 안에서 orders를 읽고 커밋하지 않은 채 멈춰 있습니다. 여기에 B가 컬럼을 추가합니다.\nA=# BEGIN; BEGIN A=# SELECT count(*) FROM orders; count ------- 3 (1 row) B=# ALTER TABLE orders ADD COLUMN note text; A는 읽기만 했으므로 AccessShareLock만 쥐고 있습니다. 읽기끼리는 서로 막지 않지만, ALTER TABLE ... ADD COLUMN은 테이블에 AccessExclusiveLock을 요청하고, 이 락은 읽기를 포함한 모든 락과 충돌합니다. 그래서 B는 A가 끝나기를 기다립니다. 여기까지는 B 하나만 멈춘 것입니다.\n문제는 그 뒤에 들어오는 평범한 SELECT입니다.\n$ for i in 1 2 3 4 5; do PGAPPNAME=reader nohup psql -X -c \u0026#34;SELECT count(*) FROM orders\u0026#34; \u0026gt; /tmp/reader_$i.out 2\u0026gt;\u0026amp;1 \u0026amp; done postgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+--------+---------------------+--------+------------+------------+------------------------------------------ 445 | app_a | idle in transaction | Client | ClientRead | {} | SELECT count(*) FROM orders; 482 | app_b | active | Lock | relation | {445} | ALTER TABLE orders ADD COLUMN note text; 582 | reader | active | Lock | relation | {482} | SELECT count(*) FROM orders 583 | reader | active | Lock | relation | {482} | SELECT count(*) FROM orders 584 | reader | active | Lock | relation | {482} | SELECT count(*) FROM orders 585 | reader | active | Lock | relation | {482} | SELECT count(*) FROM orders 586 | reader | active | Lock | relation | {482} | SELECT count(*) FROM orders (7 rows) SELECT 다섯 개가 모두 멈췄습니다. 그런데 막고 있는 것은 A가 아니라 B(blocked_by {482})입니다. SELECT가 요청하는 AccessShareLock은 A가 쥔 락과는 충돌하지 않지만, 줄 앞에서 기다리는 B의 AccessExclusiveLock과는 충돌합니다. PostgreSQL은 먼저 줄을 선 요청을 앞지르지 못하게 하므로, 새 SELECT도 B 뒤에 섭니다. 문서는 이것을 락을 쥐고 막는 \u0026ldquo;hard block\u0026quot;과 구분해 soft block이라고 부릅니다(pg_blocking_pids).\npg_locks로 줄의 모양이 그대로 보입니다.\npostgres=# SELECT l.pid, a.application_name AS app, l.mode, l.granted, postgres-# now() - l.waitstart AS waiting postgres-# FROM pg_locks l JOIN pg_stat_activity a USING (pid) postgres-# WHERE l.locktype = \u0026#39;relation\u0026#39; AND l.relation = \u0026#39;orders\u0026#39;::regclass postgres-# ORDER BY l.granted DESC, l.waitstart, l.pid; pid | app | mode | granted | waiting -----+--------+---------------------+---------+----------------- 445 | app_a | AccessShareLock | t | 482 | app_b | AccessExclusiveLock | f | 00:00:03.994895 582 | reader | AccessShareLock | f | 00:00:02.147873 583 | reader | AccessShareLock | f | 00:00:02.147817 584 | reader | AccessShareLock | f | 00:00:02.147746 585 | reader | AccessShareLock | f | 00:00:02.147622 586 | reader | AccessShareLock | f | 00:00:02.147417 (7 rows) granted = t인 것은 A 하나뿐이고, 나머지는 waitstart 순서대로 줄을 섰습니다. 이 테이블을 읽는 모든 요청이 이 줄에 합류하므로, 몇 초 만에 커넥션 풀이 가득 차고 서비스 전체가 멈춘 것처럼 보입니다. 서버 로그에는 평범한 SELECT가 AccessShareLock을 기다린다는 줄이 쌓입니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;still waiting for AccessShareLock\u0026#39; | head -n 2 2026-09-26 09:49:59.638 UTC [583] postgres@postgres/reader LOG: process 583 still waiting for AccessShareLock on relation 16384 of database 5 after 1001.405 ms at character 22 2026-09-26 09:49:59.638 UTC [585] postgres@postgres/reader LOG: process 585 still waiting for AccessShareLock on relation 16384 of database 5 after 1001.535 ms at character 22 SELECT가 AccessShareLock을 1초 넘게 기다린다는 로그는 거의 언제나 누군가 AccessExclusiveLock을 기다리며 줄 앞을 막고 있다는 신호입니다. 같은 테이블에 대한 ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, CLUSTER, LOCK TABLE을 찾아보고, 그 앞에서 트랜잭션을 열어 둔 세션을 찾습니다.\nA가 트랜잭션을 끝내면 줄은 한꺼번에 풀립니다.\nA=# ROLLBACK; ROLLBACK # 세션 B: 앞 명령의 결과를 기다림 ALTER TABLE $ cat /tmp/reader_1.out count ------- 3 (1 row) deadlock: 서로가 서로를 기다린다 A는 1번 행을, B는 2번 행을 고친 뒤, 서로 상대의 행을 고치려 합니다.\nA=# BEGIN; BEGIN A=# UPDATE orders SET status = \u0026#39;a\u0026#39; WHERE id = 1; UPDATE 1 B=# BEGIN; BEGIN B=# UPDATE orders SET status = \u0026#39;b\u0026#39; WHERE id = 2; UPDATE 1 A=# UPDATE orders SET status = \u0026#39;a\u0026#39; WHERE id = 2; B=# UPDATE orders SET status = \u0026#39;b\u0026#39; WHERE id = 1; ERROR: deadlock detected DETAIL: Process 1002 waits for ShareLock on transaction 760; blocked by process 965. Process 965 waits for ShareLock on transaction 772; blocked by process 1002. HINT: See server log for query details. CONTEXT: while updating tuple (0,6) in relation \u0026#34;orders\u0026#34; A의 두 번째 UPDATE는 B를 기다리고, B의 두 번째 UPDATE는 A를 기다립니다. 둘 다 영원히 기다릴 상황입니다. PostgreSQL은 락을 deadlock_timeout(기본 1초)만큼 기다린 세션에서 대기 관계에 순환이 있는지 검사하고, 순환이 있으면 한쪽 트랜잭션을 에러로 끝냅니다. 어느 쪽이 끝날지는 미리 알기 어렵다고 문서가 말합니다(Deadlocks).\nB가 에러를 받는 순간 A가 풀립니다.\n# 세션 A: 앞 명령의 결과를 기다림 UPDATE 1 B=# COMMIT; ROLLBACK A=# COMMIT; COMMIT postgres=# SELECT id, status FROM orders ORDER BY id; id | status ----+-------- 1 | a 2 | a 3 | new (3 rows) B의 트랜잭션은 이미 실패했으므로 COMMIT을 보내도 ROLLBACK으로 끝납니다. 애플리케이션은 deadlock 에러를 받으면 트랜잭션을 처음부터 다시 실행해야 합니다.\n클라이언트가 받은 에러에는 PID와 트랜잭션 ID만 있고, HINT가 말하듯 각 프로세스가 실행하던 쿼리는 서버 로그에만 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 6 -E \u0026#39;ERROR: deadlock detected\u0026#39; 2026-09-26 09:50:34.438 UTC [1002] postgres@postgres/app_b ERROR: deadlock detected 2026-09-26 09:50:34.438 UTC [1002] postgres@postgres/app_b DETAIL: Process 1002 waits for ShareLock on transaction 760; blocked by process 965. Process 965 waits for ShareLock on transaction 772; blocked by process 1002. Process 1002: UPDATE orders SET status = \u0026#39;b\u0026#39; WHERE id = 1; Process 965: UPDATE orders SET status = \u0026#39;a\u0026#39; WHERE id = 2; 2026-09-26 09:50:34.438 UTC [1002] postgres@postgres/app_b HINT: See server log for query details. 2026-09-26 09:50:34.438 UTC [1002] postgres@postgres/app_b CONTEXT: while updating tuple (0,6) in relation \u0026#34;orders\u0026#34; Process 1002: ..., Process 965: ... 두 줄이 deadlock의 양쪽 쿼리입니다. deadlock이 났다는 신고를 받으면 이 두 줄부터 찾습니다. 누적 횟수는 pg_stat_database에 있습니다.\npostgres=# SELECT datname, deadlocks FROM pg_stat_database WHERE datname = \u0026#39;postgres\u0026#39;; datname | deadlocks ----------+----------- postgres | 1 (1 row) 조치 pg_cancel_backend는 idle in transaction을 풀지 못한다 행 락 상황으로 돌아갑니다. 뿌리인 A는 idle in transaction입니다. 먼저 pg_cancel_backend를 보냅니다.\npostgres=# SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE application_name = \u0026#39;app_a\u0026#39;; pg_cancel_backend ------------------- t (1 row) postgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+-------+---------------------+--------+---------------+------------+----------------------------------------------- 104 | app_a | idle in transaction | Client | ClientRead | {} | UPDATE orders SET status = \u0026#39;paid\u0026#39; WHERE id = 141 | app_b | active | Lock | transactionid | {104} | UPDATE orders SET status = \u0026#39;canceled\u0026#39; WHERE i 178 | app_c | active | Lock | tuple | {141} | UPDATE orders SET status = \u0026#39;shipped\u0026#39; WHERE id (3 rows) 함수는 t를 돌려주지만 아무것도 바뀌지 않았습니다. pg_cancel_backend는 세션이 실행 중인 쿼리를 취소하는데, A는 실행 중인 쿼리가 없습니다. 트랜잭션은 그대로 열려 있고 락도 그대로입니다. active 상태로 오래 도는 쿼리가 뿌리라면 pg_cancel_backend가 먼저 시도할 방법이지만, idle in transaction에는 소용이 없습니다.\n세션 자체를 끊어야 합니다.\npostgres=# SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE application_name = \u0026#39;app_a\u0026#39;; pg_terminate_backend ---------------------- t (1 row) # 세션 B: 앞 명령의 결과를 기다림 UPDATE 1 # 세션 C: 앞 명령의 결과를 기다림 UPDATE 1 postgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+-------+-------+--------+------------+------------+----------------------------------------------- 141 | app_b | idle | Client | ClientRead | {} | UPDATE orders SET status = \u0026#39;canceled\u0026#39; WHERE i 178 | app_c | idle | Client | ClientRead | {} | UPDATE orders SET status = \u0026#39;shipped\u0026#39; WHERE id (2 rows) A가 끊기자 B와 C가 차례로 끝났습니다. A 쪽 클라이언트는 다음 명령을 보낼 때에야 연결이 끊겼다는 것을 압니다.\nA=# SELECT 1; FATAL: terminating connection due to administrator command server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost pg_terminate_backend는 그 세션의 트랜잭션을 롤백합니다. A가 고쳐 둔 'paid'는 사라지고, B와 C의 UPDATE가 차례로 반영되었습니다.\npostgres=# SELECT id, status FROM orders WHERE id = 1; id | status ----+--------- 1 | shipped (1 row) 그러니 세션을 끊기 전에 그 세션이 무슨 일을 하던 중인지(query, xact_start, application_name, client_addr) 확인하고 기록해 두어야 합니다. 배치 작업이 몇 시간째 돌던 트랜잭션이라면 끊는 순간 그 작업 전체가 롤백됩니다.\n함수 하는 일 효과가 있는 경우 클라이언트가 받는 것 pg_cancel_backend(pid) 실행 중인 쿼리 취소 active 상태의 오래 걸리는 쿼리 쿼리만 에러로 끝나고 세션은 유지 pg_terminate_backend(pid) 세션 종료, 트랜잭션 롤백 idle in transaction 포함 모든 경우 FATAL: terminating connection due to administrator command DDL에는 lock_timeout을 건다 DDL 락 큐는 DDL이 오래 기다리기 때문에 생깁니다. DDL이 금방 포기하면 줄도 금방 풀립니다. 같은 상황을 lock_timeout = '2s'로 다시 만듭니다.\nA=# BEGIN; BEGIN A=# SELECT count(*) FROM orders; count ------- 3 (1 row) B=# SET lock_timeout = \u0026#39;2s\u0026#39;; SET B=# ALTER TABLE orders ADD COLUMN memo text; $ for i in 1 2 3; do PGAPPNAME=reader nohup psql -X -c \u0026#34;SELECT count(*) FROM orders\u0026#34; \u0026gt; /tmp/reader2_$i.out 2\u0026gt;\u0026amp;1 \u0026amp; done postgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+--------+---------------------+--------+------------+------------+------------------------------------------ 695 | app_a | idle in transaction | Client | ClientRead | {} | SELECT count(*) FROM orders; 732 | app_b | active | Lock | relation | {695} | ALTER TABLE orders ADD COLUMN memo text; 853 | reader | active | Lock | relation | {732} | SELECT count(*) FROM orders 852 | reader | active | Lock | relation | {732} | SELECT count(*) FROM orders 854 | reader | active | Lock | relation | {732} | SELECT count(*) FROM orders (5 rows) 2초 동안은 앞과 똑같이 SELECT가 B 뒤에 줄을 섭니다. 2초가 지나면 B가 포기합니다.\n# 세션 B: 앞 명령의 결과를 기다림 ERROR: canceling statement due to lock timeout postgres=# SELECT pid, application_name AS app, state, wait_event_type AS wtype, wait_event, postgres-# pg_blocking_pids(pid) AS blocked_by, left(query, 45) AS query postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start NULLS LAST, pid; pid | app | state | wtype | wait_event | blocked_by | query -----+-------+---------------------+--------+------------+------------+------------------------------------------ 695 | app_a | idle in transaction | Client | ClientRead | {} | SELECT count(*) FROM orders; 732 | app_b | idle | Client | ClientRead | {} | ALTER TABLE orders ADD COLUMN memo text; (2 rows) $ cat /tmp/reader2_1.out count ------- 3 (1 row) B는 canceling statement due to lock timeout으로 끝났고, 줄 서 있던 SELECT는 모두 결과를 받았습니다. A는 여전히 idle in transaction이지만 이제 아무도 막지 않습니다. 서비스는 2초 동안만 느려졌다가 돌아옵니다. A가 끝난 뒤 다시 시도하면 DDL은 바로 성공합니다.\nA=# ROLLBACK; ROLLBACK # A가 끝난 뒤 다시 시도하면 바로 성공한다 B=# ALTER TABLE orders ADD COLUMN memo text; ALTER TABLE 운영에서 DDL을 실행할 때는 이렇게 합니다.\nSET lock_timeout을 서비스가 견딜 수 있는 시간(수백 ms에서 수 초)으로 겁니다. lock_timeout은 락을 얻으려고 기다리는 시간에만 적용되고, 락을 얻은 뒤 실행하는 시간에는 적용되지 않습니다(lock_timeout). 실패하면 잠시 쉬었다가 다시 시도합니다. 스크립트나 마이그레이션 도구에서 재시도 루프로 감쌉니다. 계속 실패하면 위의 \u0026ldquo;먼저 확인할 것\u0026rdquo; 쿼리로 줄 앞을 막는 세션을 찾습니다. lock_timeout을 서버 전체에 거는 것은 권하지 않습니다. 일반 쿼리까지 락 대기 중에 에러가 나기 시작합니다. DDL을 실행하는 세션에서만 겁니다.\ndeadlock은 순서를 맞추고 재시도한다 deadlock은 PostgreSQL이 알아서 풀어 주므로 \u0026ldquo;조치\u0026quot;라기보다 애플리케이션을 고칠 일입니다.\n여러 행을 고치는 트랜잭션은 항상 같은 순서(예: 기본 키 순서)로 행을 고칩니다. 위 실습에서 A와 B가 둘 다 1번, 2번 순서로 고쳤다면 B는 1번에서 기다렸다가 A가 끝난 뒤 진행했을 것입니다. 트랜잭션 초반에 SELECT ... FOR UPDATE ... ORDER BY id로 필요한 행을 한꺼번에 순서대로 잠그는 방법도 있습니다. deadlock 에러(SQLSTATE 40P01)는 재시도하면 대개 성공합니다. 애플리케이션에 재시도 로직을 둡니다. 재발 방지 알람 쿼리. 락을 기다리는 세션 수와 가장 오래 기다린 시간을 주기적으로 수집합니다. DDL 락 큐 상황에서 이 쿼리는 이렇게 나왔습니다.\npostgres=# SELECT count(*) FILTER (WHERE wait_event_type = \u0026#39;Lock\u0026#39;) AS waiting, postgres-# max(now() - query_start) FILTER (WHERE wait_event_type = \u0026#39;Lock\u0026#39;) AS longest_wait postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39;; waiting | longest_wait ---------+----------------- 6 | 00:00:04.054387 (1 row) waiting이 평소보다 크거나 longest_wait가 수 초를 넘으면 알람을 보내고, 그때 \u0026ldquo;먼저 확인할 것\u0026rdquo; 쿼리를 돌립니다. 이 쿼리는 pg_blocking_pids()를 부르지 않으므로 자주 돌려도 부담이 적습니다.\n로그. log_lock_waits = on이면 deadlock_timeout보다 오래 기다린 락이 모두 기록됩니다(1편). 장애가 지나간 뒤 원인을 찾을 수 있는 곳은 이 로그뿐입니다.\nidle in transaction 막기. 이 글의 세 사례 모두 뿌리는 트랜잭션을 열어 둔 채 멈춘 세션이었습니다. idle_in_transaction_session_timeout으로 이런 세션을 자동으로 끊을 수 있고, 이 설정과 오래 열린 트랜잭션의 다른 피해는 4편에서 다룹니다.\n에러 코드별 대응.\n에러 SQLSTATE 의미 애플리케이션 대응 deadlock detected 40P01 deadlock으로 트랜잭션이 취소됨 트랜잭션 전체 재시도 canceling statement due to lock timeout 55P03 lock_timeout 초과 잠시 뒤 재시도 terminating connection due to administrator command 57P01 관리자가 세션을 끊음 재접속, 작업 재실행 여부 판단 정리 세션이 멈추면 pg_stat_activity의 wait_event_type과 pg_blocking_pids()로 줄을 따라 올라가 자기는 막히지 않고 남을 막는 세션을 찾습니다. 대개 idle in transaction입니다. 같은 행을 여러 세션이 기다리면 맨 앞 하나만 transactionid를, 나머지는 tuple을 기다립니다. AccessExclusiveLock을 기다리는 DDL은 뒤에 오는 평범한 SELECT까지 막습니다(soft block). SELECT가 AccessShareLock을 기다린다는 로그가 그 신호입니다. pg_cancel_backend는 idle in transaction을 풀지 못합니다. pg_terminate_backend는 풀지만 그 트랜잭션을 롤백합니다. DDL에는 세션 단위로 lock_timeout을 걸고 재시도합니다. deadlock은 PostgreSQL이 한쪽을 에러로 끝내 풀어 줍니다. 양쪽 쿼리는 서버 로그에 남고, 애플리케이션은 순서를 맞추고 재시도합니다. 참고 자료 Explicit Locking: 테이블 락 충돌 표, 행 락, deadlock pg_locks pg_blocking_pids와 세션 정보 함수 Server Signaling Functions: pg_cancel_backend, pg_terminate_backend lock_timeout, deadlock_timeout PostgreSQL Error Codes PostgreSQL 인터널 4편 MVCC ","permalink":"https://jinlog.dev/posts/postgresql-ops/02-blocked-sessions/","summary":"세션이 줄줄이 멈췄을 때 누가 막고 있는지 찾고, 어떻게 풀고, 어떻게 막는가","title":"PostgreSQL 운영 2: 세션들이 멈춰 있다"},{"content":"개요 1편에서 Patroni가 etcd의 leader key 하나로 primary를 정하고, 그 key가 사라지면 replica들이 경쟁한다는 것을 봤습니다. 이번 글에서는 그 클러스터를 Rocky Linux 9에서 처음부터 만듭니다. 순서는 다음과 같습니다.\nPGDG 저장소에서 PostgreSQL 18, Patroni, etcd, HAProxy 설치 etcd 3대로 DCS 구성 patroni.yml 작성, 첫 노드 부트스트랩, replica 2대 추가 HAProxy로 쓰기와 읽기 연결 나누기 switchover(계획된 교대)와 failover(장애 교대) 확인 구성 중에 실제로 걸린 함정 두 가지(replica의 pg_hba.conf 누락, HAProxy 검사 주기 때문에 생긴 read-only 오류)도 그대로 보여 드립니다.\n기준 버전: Rocky Linux 9.8, PostgreSQL 18.6, Patroni 4.1.5, etcd 3.7.2, HAProxy 3.4.5. 모두 PGDG 저장소의 aarch64 RPM입니다. 서버 대신 rockylinux:9 이미지로 만든 컨테이너 7대를 쓰고, 컨테이너 안에서 systemd로 서비스를 띄웠습니다. 명령은 실제 서버에서도 같습니다.\n실습 구성은 다음과 같습니다.\n호스트 주소 역할 etcd1, etcd2, etcd3 172.30.0.11-13 etcd (DCS) pg1, pg2, pg3 172.30.0.21-23 PostgreSQL 18 + Patroni haproxy 172.30.0.31 HAProxy (쓰기 5000, 읽기 5001) 실습은 편의상 etcd, Patroni REST API, PostgreSQL 연결을 모두 평문으로 둡니다. 운영에서는 etcd의 client/peer 통신과 Patroni REST API에 TLS를 켜고, PostgreSQL 연결에도 SSL을 쓰는 것이 기본입니다. 인증은 3편에서 다룹니다.\n패키지 설치 PGDG 저장소를 등록하고 Rocky Linux 기본 AppStream의 postgresql 모듈을 끈 뒤 설치합니다. Patroni는 pgdg-common 저장소에 있고, etcd와 HAProxy는 기본으로 꺼져 있는 pgdg-rhel9-extras 저장소에 있습니다. 모든 노드에 같은 패키지를 넣었습니다(etcd 노드에는 etcd만, pg 노드에는 PostgreSQL과 Patroni만 있으면 됩니다).\n$ dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-aarch64/pgdg-redhat-repo-latest.noarch.rpm $ dnf -y install epel-release $ dnf -qy module disable postgresql $ dnf -y --enablerepo=pgdg-rhel9-extras install postgresql18-server postgresql18-contrib patroni patroni-etcd etcd haproxy $ rpm -q postgresql18-server patroni patroni-etcd etcd haproxy postgresql18-server-18.6-1PGDG.rhel9.8.aarch64 patroni-4.1.5-1PGDG.rhel9.8.noarch patroni-etcd-4.1.5-1PGDG.rhel9.8.noarch etcd-3.7.2-1PGDG.rhel9.8.aarch64 haproxy-3.4.5-1PGDG.rhel9.8.aarch64 patroni-etcd는 Patroni가 etcd와 통신하는 데 필요한 의존성을 모은 패키지입니다. Consul이나 ZooKeeper를 쓰면 patroni-consul, patroni-zookeeper를 넣습니다. EPEL을 켜 두어야 Patroni의 Python 의존성이 풀립니다. Patroni RPM은 Python 3.12로 동작합니다(/usr/bin/patroni의 첫 줄이 #!/usr/bin/python3.12). pgdg-common에는 이전 버전인 patroni-3.0.4 패키지도 함께 있습니다. 버전을 고정하려면 patroni-4.1.5처럼 버전까지 적습니다. RPM이 넣어 주는 systemd unit $ grep -vE \u0026#34;^#|^$\u0026#34; /usr/lib/systemd/system/patroni.service [Unit] Description=Runners to orchestrate a high-availability PostgreSQL After=syslog.target network.target [Service] Type=notify User=postgres Group=postgres EnvironmentFile=-/etc/patroni_env.conf Environment=MALLOC_ARENA_MAX=1 Environment=PG_MALLOC_ARENA_MAX= ExecStart=/usr/bin/patroni /etc/patroni/patroni.yml ExecReload=/usr/bin/kill -s HUP $MAINPID KillMode=process Restart=on-failure TimeoutSec=30 Restart=no [Install] WantedBy=multi-user.target 설정 파일은 /etc/patroni/patroni.yml이고, /etc/patroni_env.conf가 있으면 환경 변수로 읽습니다. 비밀번호는 이 파일에 넣을 것입니다. KillMode=process: 서비스를 멈출 때 Patroni 프로세스에만 신호를 보냅니다. Patroni가 PostgreSQL을 순서대로 내리게 하려는 설정입니다. Restart=가 두 번 나오고 뒤의 Restart=no가 이깁니다. Patroni가 죽어도 systemd가 되살리지 않습니다. 원본 주석에는 \u0026ldquo;장애 때 사람이 먼저 살펴보게 하려는 것\u0026quot;이라고 적혀 있습니다. Patroni가 죽은 채 PostgreSQL만 남는 상황의 위험은 3편에서 봅니다. 이 파일을 직접 고치지 말고 /etc/systemd/system/patroni.service.d/ 아래 drop-in 파일로 덮어쓰라고 파일 머리 주석이 안내합니다. etcd 클러스터 만들기 etcd는 Raft로 합의하므로 홀수 대로 구성합니다. 3대면 1대, 5대면 2대까지 죽어도 쿼럼이 유지됩니다. etcd RPM의 unit은 /etc/etcd/etcd.conf를 환경 변수로 읽으니, 이 파일을 노드마다 이름과 주소만 바꿔 씁니다. etcd1의 설정입니다.\n$ cat /etc/etcd/etcd.conf ETCD_NAME=etcd1 ETCD_DATA_DIR=/var/lib/etcd/default.etcd ETCD_LISTEN_PEER_URLS=http://172.30.0.11:2380 ETCD_LISTEN_CLIENT_URLS=http://172.30.0.11:2379,http://127.0.0.1:2379 ETCD_INITIAL_ADVERTISE_PEER_URLS=http://172.30.0.11:2380 ETCD_ADVERTISE_CLIENT_URLS=http://172.30.0.11:2379 ETCD_INITIAL_CLUSTER=etcd1=http://172.30.0.11:2380,etcd2=http://172.30.0.12:2380,etcd3=http://172.30.0.13:2380 ETCD_INITIAL_CLUSTER_STATE=new ETCD_INITIAL_CLUSTER_TOKEN=pg-ha-etcd 세 노드에서 거의 동시에 띄웁니다. 첫 노드는 나머지가 올라와 쿼럼이 생길 때까지 기다립니다.\n$ systemctl start etcd # etcd1, etcd2, etcd3에서 $ etcdctl --endpoints=172.30.0.11:2379,172.30.0.12:2379,172.30.0.13:2379 endpoint health -w table ┌──────────────────┬────────┬────────────┬───────┐ │ ENDPOINT │ HEALTH │ TOOK │ ERROR │ ├──────────────────┼────────┼────────────┼───────┤ │ 172.30.0.12:2379 │ true │ 1.951041ms │ │ │ 172.30.0.13:2379 │ true │ 1.945791ms │ │ │ 172.30.0.11:2379 │ true │ 1.656417ms │ │ └──────────────────┴────────┴────────────┴───────┘ $ etcdctl member list -w table ┌──────────────────┬─────────┬───────┬─────────────────────────┬─────────────────────────┬────────────┐ │ ID │ STATUS │ NAME │ PEER ADDRS │ CLIENT ADDRS │ IS LEARNER │ ├──────────────────┼─────────┼───────┼─────────────────────────┼─────────────────────────┼────────────┤ │ 72e66ce07e4d6a2 │ started │ etcd3 │ http://172.30.0.13:2380 │ http://172.30.0.13:2379 │ false │ │ efd75d861ad66f39 │ started │ etcd1 │ http://172.30.0.11:2380 │ http://172.30.0.11:2379 │ false │ │ f0e8a8f83b808c1e │ started │ etcd2 │ http://172.30.0.12:2380 │ http://172.30.0.12:2379 │ false │ └──────────────────┴─────────┴───────┴─────────────────────────┴─────────────────────────┴────────────┘ 세 멤버가 모두 started이고 건강합니다. endpoint status로 보면 etcd2가 Raft leader였습니다. etcd는 디스크 fsync 지연과 네트워크 지연에 민감해서 운영에서는 PostgreSQL과 디스크를 나눠 쓰거나 아예 별도 서버에 두는 경우가 많습니다. 이 실습도 etcd를 PostgreSQL 노드와 분리했습니다.\npatroni.yml 작성 pg1의 /etc/patroni/patroni.yml입니다. pg2, pg3는 name과 두 connect_address만 다릅니다.\nscope: pg-ha namespace: /service/ name: pg1 restapi: listen: 0.0.0.0:8008 connect_address: 172.30.0.21:8008 etcd3: hosts: - 172.30.0.11:2379 - 172.30.0.12:2379 - 172.30.0.13:2379 bootstrap: dcs: ttl: 30 loop_wait: 10 retry_timeout: 10 maximum_lag_on_failover: 1048576 postgresql: use_pg_rewind: true use_slots: true parameters: wal_level: replica hot_standby: \u0026#34;on\u0026#34; wal_log_hints: \u0026#34;on\u0026#34; max_wal_senders: 10 max_replication_slots: 10 wal_keep_size: 128MB password_encryption: scram-sha-256 initdb: - encoding: UTF8 - locale: C.UTF-8 - data-checksums postgresql: listen: 0.0.0.0:5432 connect_address: 172.30.0.21:5432 data_dir: /var/lib/pgsql/18/data bin_dir: /usr/pgsql-18/bin pgpass: /var/lib/pgsql/.pgpass_patroni pg_hba: - local all all peer - host all all 127.0.0.1/32 scram-sha-256 - host replication replicator 172.30.0.0/24 scram-sha-256 - host all all 172.30.0.0/24 scram-sha-256 parameters: unix_socket_directories: /run/postgresql tags: nofailover: false noloadbalance: false clonefrom: false nosync: false 구역 뜻 scope, namespace, name 클러스터 이름(DCS의 /service/pg-ha/), 이 노드의 멤버 이름 restapi REST API가 들을 주소와, 다른 멤버와 HAProxy가 찾아올 주소 etcd3 etcd v3 API로 접속할 etcd 노드들 bootstrap.dcs 클러스터를 처음 만들 때 한 번만 DCS의 config 키에 들어가는 설정. 그 뒤에 이 파일을 고쳐도 반영되지 않고, patronictl edit-config로 바꿔야 합니다 bootstrap.initdb 첫 노드가 실행할 initdb 옵션 postgresql 이 노드에만 적용되는 로컬 설정. 데이터 디렉터리, 바이너리 경로, pg_hba.conf 내용 tags failover 후보 제외(nofailover), 읽기 분산 제외(noloadbalance), 동기 standby 제외(nosync) 등 노드별 표시 use_pg_rewind: true: failover 뒤 옛 primary를 다시 붙일 때 pg_rewind로 갈라진 부분만 되돌립니다. pg_rewind에는 wal_log_hints = on이나 data checksums가 필요해서 둘 다 켰습니다(PG18의 initdb는 checksums를 기본으로 켭니다). use_slots: true: replica마다 primary에 physical replication slot을 만들어 줍니다. slot 이름은 멤버 이름과 같습니다. pgpass: Patroni가 여기에 비밀번호 파일을 만들고 복제나 pg_rewind로 다른 노드에 접속할 때 씁니다. 비밀번호는 환경 변수 파일로 postgresql.authentication 아래에 superuser, 복제, rewind 사용자의 이름과 비밀번호를 적을 수 있지만, 설정 파일에 비밀번호를 두지 않으려고 unit이 읽는 /etc/patroni_env.conf에 환경 변수로 넣었습니다. Patroni는 PATRONI_ 접두사의 환경 변수로 대부분의 설정을 덮어쓸 수 있습니다.\n$ cat /etc/patroni_env.conf # 비밀번호는 가렸습니다 PATRONI_SUPERUSER_USERNAME=postgres PATRONI_SUPERUSER_PASSWORD=******** PATRONI_REPLICATION_USERNAME=replicator PATRONI_REPLICATION_PASSWORD=******** PATRONI_REWIND_USERNAME=rewind_user PATRONI_REWIND_PASSWORD=******** $ chown postgres:postgres /etc/patroni/patroni.yml /etc/patroni_env.conf $ chmod 600 /etc/patroni/patroni.yml /etc/patroni_env.conf patroni --validate-config로 설정을 미리 검사할 수 있습니다. 이때 환경 변수를 읽지 않으면 인증 설정이 없다고 나옵니다.\n$ patroni --validate-config /etc/patroni/patroni.yml; echo rc=$? postgresql.authentication is not defined. rc=1 $ (set -a; . /etc/patroni_env.conf; set +a; patroni --validate-config /etc/patroni/patroni.yml); echo rc=$? rc=0 첫 노드 부트스트랩 pg1에서 Patroni를 띄웁니다. 데이터 디렉터리는 비어 있고 DCS에도 아직 아무것도 없습니다.\n$ systemctl start patroni $ journalctl -u patroni -o cat Starting Runners to orchestrate a high-availability PostgreSQL... 2026-09-25 22:22:13,994 INFO: Using default value thread_stack_size = 524288 2026-09-25 22:22:13,997 INFO: Patroni global thread_pool_size = 5 2026-09-25 22:22:14,052 INFO: Selected new etcd server http://172.30.0.13:2379 2026-09-25 22:22:14,056 INFO: No PostgreSQL configuration items changed, nothing to reload. 2026-09-25 22:22:14,056 INFO: REST API thread_pool_size = 5 Started Runners to orchestrate a high-availability PostgreSQL. 2026-09-25 22:22:14,100 INFO: Lock owner: None; I am pg1 2026-09-25 22:22:14,189 INFO: trying to bootstrap a new cluster The files belonging to this database system will be owned by user \u0026#34;postgres\u0026#34;. ... Data page checksums are enabled. ... Success. You can now start the database server using: /usr/pgsql-18/bin/pg_ctl -D /var/lib/pgsql/18/data -l logfile start 2026-09-25 22:22:15,046 INFO: postmaster pid=131 localhost:5432 - accepting connections 2026-09-25 22:22:15,144 INFO: running post_bootstrap 2026-09-25 22:22:15,379 INFO: initialized a new cluster 2026-09-25 22:22:15,524 INFO: no action. I am (pg1), the leader with the lock (initdb 출력 일부를 ...로 줄였고, PostgreSQL이 뜨는 동안의 메시지 몇 줄을 뺐습니다.)\nLock owner: None: leader key도, initialize 키도 없으니 pg1이 새 클러스터를 만들기로 합니다(trying to bootstrap a new cluster). 이 결정도 DCS의 initialize 키를 먼저 차지하는 방식이라, 여러 노드를 동시에 띄워도 initdb는 한 노드에서만 일어납니다. initdb, PostgreSQL 시작, post_bootstrap(복제용 사용자 등 생성) 뒤 initialized a new cluster, 그리고 leader key를 잡았습니다. 여기까지 1.5초입니다. Patroni가 만든 사용자를 봅니다.\npg1=# SELECT rolname, rolsuper, rolreplication FROM pg_roles WHERE rolname IN (\u0026#39;postgres\u0026#39;, \u0026#39;replicator\u0026#39;, \u0026#39;rewind_user\u0026#39;); rolname | rolsuper | rolreplication -------------+----------+---------------- postgres | t | t replicator | f | t rewind_user | f | f (3 rows) rewind_user는 superuser가 아닌 대신 pg_rewind에 필요한 함수(pg_ls_dir, pg_stat_file, pg_read_binary_file)의 실행 권한을 받았습니다. 이때 DCS에 생긴 키는 1편에서 봤습니다.\nreplica 추가 pg2에서 Patroni를 띄웁니다. 설정은 pg1과 같고 이름과 주소만 다릅니다.\n$ systemctl start patroni $ journalctl -u patroni -o cat | grep -vE \u0026#34;^ |^Traceback|^ \u0026#34; | grep -E \u0026#34;INFO|ERROR|FATAL\u0026#34; 2026-09-25 22:23:05,427 INFO: Lock owner: pg1; I am pg2 2026-09-25 22:23:05,470 INFO: trying to bootstrap from leader \u0026#39;pg1\u0026#39; 2026-09-25 22:23:07,304 INFO: replica has been created using basebackup 2026-09-25 22:23:07,305 INFO: bootstrapped from leader \u0026#39;pg1\u0026#39; 2026-09-25 22:23:07,420 INFO: postmaster pid=102 2026-09-25 22:23:08,442 INFO: Lock owner: pg1; I am pg2 2026-09-25 22:23:08,442 INFO: establishing a new patroni heartbeat connection to postgres 2026-09-25 22:23:08,458 ERROR: Can not fetch local timeline and lsn from replication connection connection to server at \u0026#34;localhost\u0026#34; (127.0.0.1), port 5432 failed: FATAL: no pg_hba.conf entry for replication connection from host \u0026#34;127.0.0.1\u0026#34;, user \u0026#34;replicator\u0026#34;, no encryption 2026-09-25 22:23:08,547 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) (시작 직후의 설정 로그 몇 줄과, 오류와 함께 찍히는 Python traceback은 뺐습니다.)\nleader가 이미 있으니 pg_basebackup으로 pg1을 복제해 합류했습니다(bootstrapped from leader 'pg1'). 그런데 곧바로 ERROR: Can not fetch local timeline and lsn from replication connection이 10초마다 반복됩니다. Patroni는 replica의 timeline과 WAL 위치를 알아내려고 자기 노드의 PostgreSQL에 localhost로 복제 연결을 합니다. pg_hba에 복제 연결은 172.30.0.0/24에서만 허용했으니 127.0.0.1에서 온 연결이 거절된 것입니다. 복제 자체는 문제없이 돌지만 이 정보는 failover 때 WAL 위치 비교와 timeline 확인에 쓰입니다. 그대로 두면 안 됩니다. 모든 노드의 patroni.yml에 한 줄을 넣고 reload합니다. postgresql.pg_hba는 로컬 설정이므로 파일을 고치고 systemctl reload patroni(SIGHUP)만 하면 Patroni가 pg_hba.conf를 다시 쓰고 PostgreSQL을 reload합니다.\n$ grep -A6 \u0026#34;pg_hba:\u0026#34; /etc/patroni/patroni.yml pg_hba: - local all all peer - host all all 127.0.0.1/32 scram-sha-256 - host replication replicator 127.0.0.1/32 scram-sha-256 - host replication replicator 172.30.0.0/24 scram-sha-256 - host all all 172.30.0.0/24 scram-sha-256 parameters: $ systemctl reload patroni $ journalctl -u patroni -o cat --since \u0026#34;-15s\u0026#34; 2026-09-25 22:23:45,988 INFO: Reloading PostgreSQL configuration. server signaled Reloaded Runners to orchestrate a high-availability PostgreSQL. 2026-09-25 22:23:47,046 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:23:56,031 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 오류가 사라졌습니다. pg3도 같은 방법으로 붙이면 클러스터가 완성됩니다.\n$ patronictl -c /etc/patroni/patroni.yml list + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Leader | running | 1 | | | | | | pg2 | 172.30.0.22 | Replica | streaming | 1 | 0/504DB10 | 0 | 0/504DB10 | 0 | | pg3 | 172.30.0.23 | Replica | streaming | 1 | 0/504DB10 | 0 | 0/504DB10 | 0 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ patronictl list의 TL은 timeline, Lag는 leader보다 뒤처진 양(MB)입니다. primary 쪽에서 보면 인터널 9편에서 본 그대로입니다.\npg1=# SELECT slot_name, slot_type, active, restart_lsn FROM pg_replication_slots; slot_name | slot_type | active | restart_lsn -----------+-----------+--------+------------- pg2 | physical | t | 0/504DB10 pg3 | physical | t | 0/504DB10 (2 rows) pg1=# SELECT application_name, client_addr, state, sync_state FROM pg_stat_replication; application_name | client_addr | state | sync_state ------------------+-------------+-----------+------------ pg3 | 172.30.0.23 | streaming | async pg2 | 172.30.0.22 | streaming | async (2 rows) Patroni가 써 준 replica 설정 $ head -3 /var/lib/pgsql/18/data/postgresql.conf # pg2 # Do not edit this file manually! # It will be overwritten by Patroni! include \u0026#39;postgresql.base.conf\u0026#39; $ grep -E \u0026#34;^(primary_conninfo|primary_slot_name|recovery_target_timeline)\u0026#34; /var/lib/pgsql/18/data/postgresql.conf primary_conninfo = \u0026#39;dbname=postgres user=replicator passfile=/var/lib/pgsql/.pgpass_patroni host=172.30.0.21 port=5432 sslmode=prefer application_name=pg2 gssencmode=prefer channel_binding=prefer sslnegotiation=postgres\u0026#39; primary_slot_name = \u0026#39;pg2\u0026#39; recovery_target_timeline = \u0026#39;latest\u0026#39; $ ls /var/lib/pgsql/18/data/standby.signal /var/lib/pgsql/18/data/standby.signal postgresql.conf는 Patroni가 매번 새로 씁니다. initdb가 만든 원래 파일은 postgresql.base.conf로 이름이 바뀌어 include됩니다. 이 파일을 직접 고치면 안 되는 이유입니다. primary_conninfo의 host는 지금 leader(pg1)의 주소이고, primary_slot_name은 자기 이름입니다. leader가 바뀌면 Patroni가 이 값을 새 leader로 고쳐 씁니다. 인터널 9편에서 pg_basebackup -R이 해 주던 일을 Patroni가 계속 해 주는 셈입니다. HAProxy로 연결 보내기 애플리케이션이 primary를 찾아가게 하는 방법은 여럿이지만 가장 흔한 것은 HAProxy가 Patroni REST API로 health check를 하는 방식입니다. REST API의 endpoint들은 노드의 역할에 따라 다른 HTTP 상태 코드를 돌려줍니다. primary pg1과 replica pg2에 각각 물어본 결과입니다.\nendpoint pg1 (primary) pg2 (replica) 쓰임 GET /primary 200 503 쓰기 연결을 보낼 곳 GET /read-write 200 503 /primary와 같음 GET /replica 503 200 읽기 연결을 보낼 곳 GET /health 200 200 PostgreSQL이 떠 있는가 GET /liveness 200 200 Patroni가 살아 있는가 (Kubernetes liveness probe용) GET /readiness 200 200 요청을 받을 준비가 되었는가 /replica?lag=1MB처럼 허용할 지연을 붙이면 너무 뒤처진 replica를 읽기에서 뺄 수 있습니다. HAProxy 설정입니다(/etc/haproxy/haproxy.cfg).\nglobal log 127.0.0.1 local2 maxconn 1000 user haproxy group haproxy defaults mode tcp log global option tcplog retries 3 timeout connect 4s timeout client 30m timeout server 30m timeout check 5s listen stats mode http bind *:7000 stats enable stats uri / listen primary bind *:5000 option httpchk GET /primary http-check expect status 200 default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions server pg1 172.30.0.21:5432 maxconn 100 check port 8008 server pg2 172.30.0.22:5432 maxconn 100 check port 8008 server pg3 172.30.0.23:5432 maxconn 100 check port 8008 listen replicas bind *:5001 balance roundrobin option httpchk GET /replica http-check expect status 200 default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions server pg1 172.30.0.21:5432 maxconn 100 check port 8008 server pg2 172.30.0.22:5432 maxconn 100 check port 8008 server pg3 172.30.0.23:5432 maxconn 100 check port 8008 연결은 PostgreSQL 포트(5432)로 보내고, 검사는 check port 8008로 REST API에 합니다. 세 서버를 모두 등록해 두고 검사에 통과한 서버로만 보냅니다. inter 3s fall 3 rise 2: 3초마다 검사하고, 3번 연속 실패하면 내리고, 2번 연속 성공하면 올립니다. Patroni 저장소의 예제 설정과 같은 값인데 뒤에서 이 값 때문에 문제가 생깁니다. on-marked-down shutdown-sessions: 서버가 내려가면 그 서버로 붙어 있던 연결을 끊습니다. 강등된 옛 primary에 연결이 남아 있지 않게 하려는 것입니다. $ haproxy -c -f /etc/haproxy/haproxy.cfg \u0026amp;\u0026amp; systemctl start haproxy $ curl -s \u0026#34;http://localhost:7000/;csv\u0026#34; | cut -d, -f1,2,18,37 | grep -E \u0026#34;primary|replicas\u0026#34; | grep -v FRONTEND primary,pg1,UP,L7OK primary,pg2,DOWN,L7STS primary,pg3,DOWN,L7STS primary,BACKEND,UP, replicas,pg1,DOWN,L7STS replicas,pg2,UP,L7OK replicas,pg3,UP,L7OK replicas,BACKEND,UP, 5000번은 pg1만, 5001번은 pg2와 pg3만 UP입니다. 애플리케이션용 사용자 app과 데이터베이스 appdb를 만들고 HAProxy로 접속해 봅니다.\n$ for p in 5000 5001 5001 5001; do psql -h localhost -p $p -U app -d appdb -Atc \u0026#34;select $p, inet_server_addr(), pg_is_in_recovery()\u0026#34;; done 5000|172.30.0.21|f 5001|172.30.0.22|t 5001|172.30.0.23|t 5001|172.30.0.22|t 5000번은 primary pg1로, 5001번은 replica 둘로 번갈아 갑니다.\nswitchover: 계획된 교대 switchover는 점검이나 재시작을 위해 primary를 계획적으로 다른 노드로 넘기는 것입니다. 교대하는 동안 클라이언트가 무엇을 보는지 확인하려고, HAProxy 서버에서 0.5초마다 5000번 포트로 한 행씩 넣는 스크립트를 돌립니다.\n$ psql -h 127.0.0.1 -p 5000 -U app -d appdb -c \u0026#34;CREATE TABLE heartbeat (id bigserial PRIMARY KEY, at timestamptz DEFAULT clock_timestamp(), server inet DEFAULT inet_server_addr())\u0026#34; $ cat /root/writer.sh #!/bin/bash # 0.5초마다 5000번 포트(primary)로 한 행씩 쓴다 while true; do r=$(psql \u0026#34;host=127.0.0.1 port=5000 user=app dbname=appdb connect_timeout=2\u0026#34; -Atc \\ \u0026#34;INSERT INTO heartbeat DEFAULT VALUES RETURNING server\u0026#34; 2\u0026gt;\u0026amp;1 | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39; | cut -c1-140) echo \u0026#34;$(date +%T.%N | cut -c1-12) $r\u0026#34; sleep 0.5 done $ /root/writer.sh \u0026gt; /root/writer.log 2\u0026gt;\u0026amp;1 \u0026amp; (이 절의 첫 실험만은 스크립트가 -h localhost로 접속하고 오류의 첫 줄만 남겼습니다. 그래서 로그에 IPv6 주소 ::1로 먼저 시도했다가 실패한 줄이 보입니다. HAProxy는 IPv4에서만 듣고 있습니다.)\npg1에서 pg2로 넘깁니다.\n$ patronictl -c /etc/patroni/patroni.yml switchover pg-ha --leader pg1 --candidate pg2 --force Current cluster topology + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Leader | running | 1 | | | | | | pg2 | 172.30.0.22 | Replica | streaming | 1 | 0/549A288 | 0 | 0/549A288 | 0 | | pg3 | 172.30.0.23 | Replica | streaming | 1 | 0/549A288 | 0 | 0/549A288 | 0 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ 2026-09-25 22:25:47.47296 Successfully switched over to \u0026#34;pg2\u0026#34; + Cluster: pg-ha (7689601790135361659) ----+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Replica | stopped | | unknown | | unknown | | | pg2 | 172.30.0.22 | Leader | running | 2 | | | | | | pg3 | 172.30.0.23 | Replica | running | 1 | 0/54A0BA0 | 0 | 0/54A0BA0 | 0 | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ 명령을 낸 것이 22:25:45.2, 완료 표시가 22:25:47.5입니다. 양쪽 Patroni 로그로 순서를 봅니다.\n$ journalctl -u patroni -o cat --since 22:25:44 --until 22:25:52 | grep -v \u0026#34;Got response\u0026#34; # pg1 2026-09-25 22:25:45,375 INFO: received switchover request with leader=pg1 candidate=pg2 scheduled_at=None 2026-09-25 22:25:45,598 INFO: switchover: demoting myself 2026-09-25 22:25:45,598 INFO: Demoting self (graceful) 2026-09-25 22:25:46,911 INFO: Leader key released 2026-09-25 22:25:46,912 INFO: Lock owner: None; I am pg1 2026-09-25 22:25:46,912 INFO: not healthy enough for leader race 2026-09-25 22:25:46,969 INFO: Lock owner: pg2; I am pg1 2026-09-25 22:25:48,913 INFO: Local timeline=1 lsn=0/54A0B28 2026-09-25 22:25:48,923 INFO: primary_timeline=2 2026-09-25 22:25:49,054 INFO: postmaster pid=427 2026-09-25 22:25:50,173 INFO: no action. I am (pg1), a secondary, and following a leader (pg2) $ journalctl -u patroni -o cat --since 22:25:44 --until 22:25:56 # pg2 2026-09-25 22:25:46,882 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:25:46,969 INFO: Cleaning up failover key after acquiring leader lock... 2026-09-25 22:25:47,065 INFO: promoted self to leader by acquiring session lock 2026-09-25 22:25:47,066 INFO: Lock owner: pg2; I am pg2 2026-09-25 22:25:47,158 INFO: updated leader lock during promote 2026-09-25 22:25:48,255 INFO: no action. I am (pg2), the leader with the lock (Got response 줄 말고도 switchover: demote in progress가 반복되는 줄, 연결을 다시 맺는 줄 등을 뺐습니다.)\npg1은 REST API로 요청을 받고, pg2의 상태를 확인한 뒤 PostgreSQL을 정상 종료했습니다(Demoting self (graceful)). 정상 종료이므로 남은 WAL을 모두 replica에 보낸 다음 내려갑니다. 그리고 leader key를 스스로 지웠습니다(Leader key released). 1편의 failover처럼 lease 만료를 기다리지 않습니다. pg2는 key가 사라진 것을 watch로 바로 알아채고 key를 잡아 promote했습니다(timeline 2). pg1은 standby로 다시 시작해 pg2를 따릅니다. timeline이 바뀌었지만 pg1이 가진 WAL은 전부 pg2에도 있으므로 pg_rewind는 필요 없습니다. HAProxy 검사 주기가 read-only 오류를 만든다 같은 시각 클라이언트가 본 것입니다.\n$ awk \u0026#39;$1\u0026gt;=\u0026#34;22:25:44\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:25:56\u0026#34;\u0026#39; /root/writer.log 22:25:44.399 172.30.0.21 22:25:44.923 172.30.0.21 22:25:45.433 172.30.0.21 22:25:48.958 psql: error: connection to server at \u0026#34;localhost\u0026#34; (::1), port 5000 failed: Connection refused 22:25:49.483 ERROR: cannot execute INSERT in a read-only transaction 22:25:50.007 ERROR: cannot execute INSERT in a read-only transaction 22:25:50.532 ERROR: cannot execute INSERT in a read-only transaction 22:25:51.058 ERROR: cannot execute INSERT in a read-only transaction 22:25:51.587 ERROR: cannot execute INSERT in a read-only transaction 22:25:52.113 ERROR: cannot execute INSERT in a read-only transaction 22:25:52.645 ERROR: cannot execute INSERT in a read-only transaction 22:25:53.160 172.30.0.22 22:25:53.692 ERROR: cannot execute INSERT in a read-only transaction 22:25:54.224 172.30.0.22 22:25:54.751 172.30.0.22 22:25:45.4 이후 3.5초 동안은 pg1이 내려가는 중이라 연결이 되지 않았습니다. 문제는 그다음입니다. 22:25:49.5부터 약 4초 동안 read-only transaction 오류가 났습니다. pg1은 이미 standby로 다시 떴는데, HAProxy가 아직 pg1을 primary 쪽 UP으로 보고 5000번 연결을 pg1으로 보낸 것입니다. inter 3s fall 3이면 내리기까지 최대 9초가 걸립니다. 22:25:53.7의 오류 한 줄은 pg2가 이미 UP이 되었는데 pg1이 아직 내려가지 않아, 두 서버에 번갈아 보낸 결과입니다. 잠깐이지만 쓰기 포트에 서버가 둘 걸려 있었습니다. 검사를 촘촘하게 바꾸고(inter 1s fall 2 rise 1), 반대로 pg2에서 pg1으로 다시 넘겨 봅니다.\n$ sed -i \u0026#39;s/inter 3s fall 3 rise 2/inter 1s fall 2 rise 1/\u0026#39; /etc/haproxy/haproxy.cfg \u0026amp;\u0026amp; systemctl reload haproxy $ patronictl -c /etc/patroni/patroni.yml switchover pg-ha --leader pg2 --candidate pg1 --force \u0026gt;/dev/null # 22:26:29.9 $ awk \u0026#39;$1\u0026gt;=\u0026#34;22:26:28\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:26:35\u0026#34;\u0026#39; /root/writer.log 22:26:28.459 172.30.0.22 22:26:28.995 172.30.0.22 22:26:29.523 172.30.0.22 22:26:30.032 172.30.0.22 22:26:33.059 psql: error: connection to server at \u0026#34;localhost\u0026#34; (::1), port 5000 failed: Connection refused 22:26:33.569 172.30.0.21 22:26:34.085 172.30.0.21 22:26:34.619 172.30.0.21 이번에는 read-only 오류 없이 약 3.5초 쓰기가 끊겼다가 바로 새 primary로 이어졌습니다. 검사를 1초마다 하면 노드 3대 × 포트 2개로 초당 6번 REST API를 부르게 되지만, Patroni REST API에는 가벼운 요청입니다.\nHAProxy 대신 또는 함께 쓸 수 있는 장치도 있습니다. libpq 연결 문자열에 호스트를 여러 개 적고 target_session_attrs=read-write를 주면, 클라이언트가 스스로 쓰기 가능한 서버를 찾아 붙습니다. 이 방법은 연결을 맺을 때만 확인하므로, 이미 맺은 연결이 강등된 서버에 남는 문제는 풀링 계층이나 애플리케이션의 재접속으로 풀어야 합니다.\nfailover: 장애 교대 이번에는 primary pg1 컨테이너를 강제로 죽입니다. 과정은 1편에서 자세히 봤으니 클라이언트 쪽 결과만 봅니다.\n$ docker kill pg1 # 22:27:11.440 $ awk \u0026#39;$1\u0026gt;=\u0026#34;22:27:10\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:27:40\u0026#34;\u0026#39; /root/writer.log | awk \u0026#39;{k=$2\u0026#34; \u0026#34;$3\u0026#34; \u0026#34;$4; if (k!=p) print; p=k}\u0026#39; 22:27:10.173 172.30.0.21 INSERT 0 1 22:27:13.731 psql: error: connection to server at \u0026#34;127.0.0.1\u0026#34;, port 5000 failed: timeout expired 22:27:35.826 172.30.0.22 INSERT 0 1 $ patronictl -c /etc/patroni/patroni.yml list # pg2에서 + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg2 | 172.30.0.22 | Leader | running | 4 | | | | | | pg3 | 172.30.0.23 | Replica | streaming | 4 | 0/54C34C8 | 0 | 0/54C34C8 | 0 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ (두 번째 awk는 같은 종류의 줄이 이어지면 첫 줄만 남깁니다.) 쓰기는 22:27:11 무렵부터 22:27:35.8까지 약 24초 끊겼습니다. switchover의 3.5초와 달리 lease 만료를 기다렸기 때문입니다. 죽은 pg1은 멤버 key도 같은 lease에 붙어 있어 목록에서 아예 사라졌습니다.\n죽었던 옛 primary를 다시 붙인다 pg1 서버를 다시 켜고 Patroni를 띄웁니다. 운영자가 한 일은 이것뿐입니다.\n$ systemctl start patroni # pg1 $ journalctl -u patroni -o cat -b | grep -E \u0026#34;INFO|WARN\u0026#34; | grep -vE \u0026#34;thread_|etcd server|nothing to reload\u0026#34; 2026-09-25 22:28:12,974 WARNING: Postgresql is not running. 2026-09-25 22:28:12,974 INFO: Lock owner: pg2; I am pg1 2026-09-25 22:28:12,975 INFO: pg_controldata: 2026-09-25 22:28:13,020 INFO: doing crash recovery in a single user mode ... 2026-09-25 22:28:13,118 INFO: Local timeline=3 lsn=0/6000028 2026-09-25 22:28:13,125 INFO: primary_timeline=4 2026-09-25 22:28:13,125 INFO: primary: history=1\t0/54A0BA0\tno recovery target specified 2026-09-25 22:28:13,170 INFO: running pg_rewind from pg2 2026-09-25 22:28:13,183 INFO: running pg_rewind from dbname=postgres user=rewind_user host=172.30.0.22 port=5432 target_session_attrs=read-write 2026-09-25 22:28:13,559 INFO: pg_rewind exit code=0 2026-09-25 22:28:13,559 INFO: stdout= 2026-09-25 22:28:13,559 INFO: stderr=pg_rewind: servers diverged at WAL location 0/54B9380 on timeline 3 2026-09-25 22:28:13,601 INFO: starting as a secondary 2026-09-25 22:28:13,720 INFO: postmaster pid=96 2026-09-25 22:28:14,843 INFO: no action. I am (pg1), a secondary, and following a leader (pg2) $ patronictl -c /etc/patroni/patroni.yml list + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Replica | streaming | 4 | 0/54C9D20 | 0 | 0/54C9D20 | 0 | | pg2 | 172.30.0.22 | Leader | running | 4 | | | | | | pg3 | 172.30.0.23 | Replica | streaming | 4 | 0/54C9D20 | 0 | 0/54C9D20 | 0 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ (...는 같은 상태 줄이 반복되는 부분을 줄였습니다.)\npg1은 비정상 종료된 상태였으므로 먼저 single user mode로 crash recovery를 해서 데이터 디렉터리를 일관된 상태로 만듭니다(인터널 8편). 자신은 timeline 3, leader pg2는 timeline 4입니다. pg1이 죽기 직전에 쓴 WAL 중 pg2가 받지 못한 부분이 있으면 두 서버의 역사가 갈라진 상태입니다. pg_rewind가 두 서버가 갈라진 위치(0/54B9380)를 찾아 pg1을 그 지점으로 되돌리고, standby로 시작해 pg2를 따릅니다. use_pg_rewind가 없었다면 이 노드는 스스로 합류하지 못하고, 데이터 디렉터리를 지우고 새로 복제(patronictl reinit)해야 했을 것입니다.\n\u0026ldquo;되돌린다\u0026quot;는 말은 pg1에만 있던 WAL을 버린다는 뜻입니다. 그 WAL에 클라이언트가 커밋 응답을 받은 트랜잭션이 들어 있었다면 그 데이터는 사라집니다. 이번에는 확인해 보니 잃은 것이 없었습니다.\n$ psql -h 127.0.0.1 -p 5000 -U app -d appdb -c \u0026#34;select server, count(*), max(at)::time(3) as last_at from heartbeat group by server order by 3\u0026#34; server | count | last_at -------------+-------+-------------- 172.30.0.21 | 88 | 22:27:11.221 172.30.0.22 | 196 | 22:28:41.355 (2 rows) $ grep 172.30.0.21 /root/writer.log | tail -1 22:27:11.224 172.30.0.21 INSERT 0 1 pg1에 성공한 마지막 쓰기(22:27:11.224)가 새 primary에도 있습니다(22:27:11.221에 커밋된 행). 0.5초에 한 행이라는 가벼운 부하였고, pg2가 WAL을 거의 실시간으로 받고 있었기 때문입니다. 부하가 크거나 복제가 지연된 순간에 primary가 죽으면 결과가 다릅니다. 3편에서 수만 건을 잃는 경우를 재현합니다.\n운영에서는 이렇게 나타납니다 replica의 pg_hba에 localhost 복제 연결이 없으면 Patroni 로그에 Can not fetch local timeline and lsn from replication connection이 계속 찍힙니다. 복제는 되니 놓치기 쉽지만 failover 판단에 쓰는 정보이므로 host replication \u0026lt;복제 사용자\u0026gt; 127.0.0.1/32를 꼭 넣습니다. HAProxy 검사 주기는 switchover의 품질을 정한다. 느슨하면 강등된 옛 primary로 쓰기가 가서 read-only 오류가 나고, 잠깐 두 서버에 쓰기 연결이 나뉩니다. inter를 1-2초로 줄이고 on-marked-down shutdown-sessions를 둡니다. switchover는 수 초, failover는 TTL. 실습에서 switchover의 쓰기 중단은 약 3.5초, 노드 장애 failover는 약 24초였습니다. 점검은 항상 switchover로 합니다. bootstrap.dcs는 처음 한 번만 쓰인다. 클러스터가 만들어진 뒤 patroni.yml의 bootstrap 구역을 고쳐도 아무 일도 일어나지 않습니다. 클러스터 전체 설정은 patronictl edit-config로 바꿉니다(3편). 정리 Rocky Linux 9에서는 PGDG 저장소 하나로 PostgreSQL 18, Patroni 4.1, etcd, HAProxy를 모두 설치할 수 있습니다. etcd와 HAProxy는 pgdg-rhel9-extras 저장소에 있습니다. etcd는 홀수 대로 쿼럼을 만들고, Patroni는 노드마다 patroni.yml 하나로 설정합니다. 비밀번호는 unit이 읽는 /etc/patroni_env.conf에 환경 변수로 둘 수 있습니다. 첫 노드가 initdb로 클러스터를 만들고 leader key를 잡으면, 나머지 노드는 pg_basebackup으로 복제해 합류합니다. Patroni가 replica의 primary_conninfo와 slot을 관리합니다. HAProxy는 REST API(/primary, /replica)로 health check를 해서 쓰기와 읽기 연결을 나눕니다. switchover는 옛 primary가 정상 종료하고 key를 넘기므로 수 초에 끝나고, failover는 lease 만료를 기다립니다. 죽었던 옛 primary는 pg_rewind로 갈라진 부분을 되돌리고 스스로 합류합니다. 다음 글에서는 이 클러스터를 운영할 때 실제로 사고가 나는 지점들을 봅니다. 설정을 어디서 바꿔야 하는지, etcd가 멈추면 무슨 일이 생기는지, failover에서 데이터를 얼마나 잃을 수 있는지, Patroni가 죽으면 무엇이 위험한지를 재현합니다.\n참고 자료 PostgreSQL Yum Repository (PGDG) Patroni: YAML Configuration Settings Patroni: Environment Configuration Settings Patroni: REST API Patroni: patronictl Patroni 저장소의 HAProxy 예제 설정 (haproxy.cfg) etcd: Clustering Guide PostgreSQL 18: pg_rewind PostgreSQL 18: libpq target_session_attrs ","permalink":"https://jinlog.dev/posts/postgresql/patroni-02-setup/","summary":"Rocky Linux 9에 etcd 3대, Patroni 3대, HAProxy를 올리고 switchover와 failover를 해 본다","title":"Patroni HA 2: 3노드 클러스터 구성하기"},{"content":"개요 1편에서 PostgreSQL이 프로세스 여러 개로 동작하고, 그 프로세스들이 공유 메모리라는 작업대를 함께 쓴다는 것을 봤습니다. 이번 글은 그 작업대 위에 무엇이 놓여 있는지, 그리고 backend 프로세스가 자기만 쓰는 메모리에는 무엇이 있는지를 살펴봅니다.\n이 글에서 답할 질문은 다음과 같습니다.\nshared buffers는 어떤 구조이고, 꽉 차면 어떤 페이지를 내보내는가 큰 테이블을 한 번 훑으면 캐시가 다 밀려나지 않을까 work_mem은 무엇에 쓰이고, 넘치면 어떻게 되는가 WAL buffers는 왜 따로 있는가 메모리 위치 기본값 쓰임 shared_buffers 공유 128MB 테이블과 인덱스 페이지 캐시 wal_buffers 공유 shared_buffers의 1/32 (최대 16MB) 디스크에 쓰기 전의 WAL 레코드 work_mem backend 개인 4MB 정렬, 해시 테이블 하나당 사용량 hash_mem_multiplier backend 개인 2.0 해시 테이블은 work_mem의 몇 배까지 쓸지 maintenance_work_mem backend 개인 64MB VACUUM, CREATE INDEX 같은 유지보수 작업 temp_buffers backend 개인 8MB 임시 테이블 전용 페이지 캐시 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n한눈에 보기 PostgreSQL 18 메모리 구조. 가운데가 모든 프로세스가 함께 쓰는 공유 메모리, 왼쪽이 backend마다 따로 있는 개인 메모리입니다. 새 창에서 크게 보기 PostgreSQL의 메모리는 크게 두 종류입니다.\n공유 메모리: postmaster가 기동할 때 한 번 만들고 모든 프로세스가 같이 씁니다. 이 메인 영역은 크기가 고정이라 운영 중에는 늘거나 줄지 않습니다(병렬 쿼리처럼 필요할 때만 잡는 동적 공유 메모리는 따로 있습니다). 기본 설정에서 약 150MB이고, 그 대부분이 shared buffers(128MB)입니다. backend 개인 메모리: 각 backend가 필요할 때 운영체제에서 받아 쓰고, 쿼리나 세션이 끝나면 돌려줍니다. work_mem, temp_buffers처럼 \u0026ldquo;한도\u0026quot;를 정하는 설정만 있고, 실제 사용량은 하는 일에 따라 달라집니다. 메모리 관련 기본 설정 $ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# SELECT name, setting, unit, short_desc postgres-# FROM pg_settings postgres-# WHERE name IN (\u0026#39;shared_buffers\u0026#39;, \u0026#39;wal_buffers\u0026#39;, \u0026#39;work_mem\u0026#39;, \u0026#39;hash_mem_multiplier\u0026#39;, postgres-# \u0026#39;maintenance_work_mem\u0026#39;, \u0026#39;temp_buffers\u0026#39;, \u0026#39;block_size\u0026#39;, \u0026#39;shared_memory_size\u0026#39;) postgres-# ORDER BY name; name | setting | unit | short_desc ----------------------+---------+------+---------------------------------------------------------------------------------------- block_size | 8192 | | Shows the size of a disk block. hash_mem_multiplier | 2 | | Multiple of \u0026#34;work_mem\u0026#34; to use for hash tables. maintenance_work_mem | 65536 | kB | Sets the maximum memory to be used for maintenance operations. shared_buffers | 16384 | 8kB | Sets the number of shared memory buffers used by the server. shared_memory_size | 150 | MB | Shows the size of the server\u0026#39;s main shared memory area (rounded up to the nearest MB). temp_buffers | 1024 | 8kB | Sets the maximum number of temporary buffers used by each session. wal_buffers | 512 | 8kB | Sets the number of disk-page buffers in shared memory for WAL. work_mem | 4096 | kB | Sets the maximum memory to be used for query workspaces. (8 rows) shared_buffers와 wal_buffers, temp_buffers의 단위는 8kB 페이지입니다. shared_buffers 16384 × 8kB = 128MB, wal_buffers 512 × 8kB = 4MB(128MB의 1/32), temp_buffers 1024 × 8kB = 8MB입니다.\nshared buffers: 테이블 페이지를 담는 캐시 PostgreSQL은 테이블과 인덱스를 8kB 페이지 단위로 읽고 씁니다(block_size = 8192). 디스크에서 읽은 페이지는 shared buffers의 한 칸(버퍼)에 올라가고, 다른 backend도 같은 페이지가 필요하면 디스크 대신 이 칸을 읽습니다. 기본값 128MB는 8kB 칸 16384개입니다.\nshared buffers는 세 부분으로 이루어져 있습니다.\nBuffer Blocks: 8kB 칸 16384개가 이어진 큰 배열. 실제 페이지 내용이 여기 들어갑니다. Buffer Descriptors: 칸마다 하나씩 있는 관리 정보(BufferDesc). CPU 캐시 라인에 맞춰 64바이트로 채워 둡니다. 이 칸에 어느 파일의 몇 번째 블록이 들어 있는지를 나타내는 태그(BufferTag: 테이블스페이스, DB, 파일 번호, fork, 블록 번호)와 상태 값을 담습니다. 버퍼 매핑 해시 테이블: \u0026ldquo;이 블록이 몇 번 칸에 있나\u0026quot;를 찾는 해시 테이블(BufTableLookup()). descriptor의 상태 값은 32비트 정수 하나에 여러 정보를 담아 둡니다(buf_internals.h). 락 없이 원자적 연산 한 번으로 바꿀 수 있게 하려는 설계입니다.\n비트 이름 뜻 18비트 refcount (pin 수) 지금 이 칸을 쓰고 있는 프로세스 수. 0이 아니면 내보낼 수 없습니다. 4비트 usage count 최근에 얼마나 자주 쓰였는지. 0에서 5까지 올라갑니다. 10비트 플래그 BM_DIRTY(디스크보다 새 내용), BM_VALID, BM_IO_IN_PROGRESS 등 공유 메모리는 무엇으로 채워져 있나 pg_shmem_allocations는 공유 메모리를 이름별로 얼마나 잡았는지 보여 줍니다.\npostgres=# SELECT name, pg_size_pretty(allocated_size) AS size postgres-# FROM pg_shmem_allocations postgres-# ORDER BY allocated_size DESC postgres-# LIMIT 10; name | size --------------------+--------- Buffer Blocks | 128 MB \u0026lt;anonymous\u0026gt; | 4637 kB XLOG Ctl | 4110 kB AioHandleIOV | 2784 kB | 2222 kB AioHandle | 1566 kB AioHandleData | 1392 kB Buffer Descriptors | 1024 kB transaction | 517 kB Checkpointer Data | 512 kB (10 rows) postgres=# SELECT count(*) AS entries, pg_size_pretty(sum(allocated_size)) AS total postgres-# FROM pg_shmem_allocations; entries | total ---------+-------- 73 | 150 MB (1 row) 150MB 가운데 128MB가 Buffer Blocks(페이지 칸들)이고, Buffer Descriptors가 정확히 1MB입니다. descriptor 하나가 64바이트이므로 16384 × 64B = 1MB입니다. XLOG Ctl(4110kB)은 WAL buffers(4MB)에 WAL 관리 구조를 더한 영역입니다. AioHandle*로 시작하는 항목들은 PG18의 비동기 I/O가 쓰는 공간입니다. 이름이 빈 줄은 아직 쓰지 않은 여유 공간이고, \u0026lt;anonymous\u0026gt;는 이름을 붙이지 않고 잡은 영역들의 합입니다.\n테이블을 읽으면 shared buffers에 페이지가 올라온다 pg_buffercache 확장을 설치하고, 14MB(1728페이지) 테이블을 만들어 이 테이블의 페이지를 캐시에서 내보낸 뒤 한 번 읽어 봅니다. 캐시를 비우는 데는 PG18의 pg_buffercache_evict_relation()을 씁니다. 서버를 재시작하지 않고 특정 테이블의 페이지만 shared buffers에서 내보냅니다.\npostgres=# CREATE EXTENSION pg_buffercache; postgres=# CREATE TABLE small AS SELECT g AS id, repeat(\u0026#39;x\u0026#39;, 100) AS pad FROM generate_series(1, 100000) g; SELECT 100000 postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;small\u0026#39;)) AS size, pg_relation_size(\u0026#39;small\u0026#39;) / 8192 AS pages; size | pages -------+------- 14 MB | 1728 (1 row) postgres=# SELECT pg_buffercache_evict_relation(\u0026#39;small\u0026#39;); pg_buffercache_evict_relation ------------------------------- (1728,1725,0) (1 row) postgres=# SELECT * FROM pg_buffercache_summary(); buffers_used | buffers_unused | buffers_dirty | buffers_pinned | usagecount_avg --------------+----------------+---------------+----------------+------------------- 300 | 16084 | 64 | 0 | 3.183333333333333 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT * FROM pg_buffercache_summary(); buffers_used | buffers_unused | buffers_dirty | buffers_pinned | usagecount_avg --------------+----------------+---------------+----------------+-------------------- 2034 | 14350 | 1789 | 0 | 1.3421828908554572 (1 row) postgres=# SELECT count(*) AS buffers_of_small postgres-# FROM pg_buffercache postgres-# WHERE relfilenode = pg_relation_filenode(\u0026#39;small\u0026#39;); buffers_of_small ------------------ 1728 (1 row) pg_buffercache_evict_relation의 결과 (1728,1725,0)은 (내보낸 칸, 그중 먼저 디스크에 쓴 칸, 건너뛴 칸)입니다. 방금 만든 테이블이라 대부분 dirty여서 쓰고 나서 내보냈습니다. 테이블을 한 번 읽자 이 테이블의 페이지 1728개가 모두 shared buffers에 올라왔습니다(buffers_of_small). 읽기만 했는데 buffers_dirty가 64에서 1789로 늘었습니다. 새로 만든 행을 처음 읽을 때 PostgreSQL이 \u0026ldquo;이 행을 만든 트랜잭션은 커밋되었다\u0026quot;는 표시(hint bit)를 페이지에 적어 두기 때문입니다. 4편(MVCC)에서 자세히 다룹니다. 페이지 하나를 읽는 과정 backend가 어떤 블록을 읽으려 할 때의 흐름입니다.\n캐시에 있으면(hit) pin하고 바로 쓰고, 없으면(miss) 비울 칸을 골라 디스크에서 읽어 옵니다. 새 창에서 크게 보기 hit. 매핑 해시에서 블록을 찾으면 그 칸을 pin(refcount +1)하고 usage count를 1 올립니다. 이미 5면 그대로 둡니다(PinBuffer()). 디스크 I/O가 전혀 없습니다.\nmiss. 없으면 새 페이지를 담을 칸을 구해야 합니다(StrategyGetBuffer()). 아래에서 볼 ring buffer를 쓰는 중이면 ring에서 먼저 고르고, 아직 한 번도 쓰지 않은 칸의 목록(freelist)에 남은 칸이 있으면 그 칸을 씁니다. 둘 다 없을 때 clock sweep을 돌립니다(freelist.c). 서버를 띄운 직후에는 빈 칸이 많아 대부분 freelist에서 해결되고, clock sweep은 캐시가 가득 찬 뒤에 본격적으로 일합니다.\n시계 바늘처럼 칸을 하나씩 돌아가며 봅니다. pin된 칸은 건너뜁니다. usage count가 0이 아니면 1 깎고 넘어갑니다. usage count가 0인 칸을 만나면 그 칸을 씁니다. 자주 쓰이는 페이지는 usage count가 높아서 바늘이 여러 바퀴 돌아야 0이 됩니다. 그래서 오래 살아남습니다. 한 번 쓰고 만 페이지는 금방 0이 되어 먼저 밀려납니다. 정확한 LRU(가장 오래 안 쓴 것을 내보내기)는 아니지만 비슷한 효과를 냅니다. LRU처럼 hit할 때마다 전역 목록의 순서를 고칠 필요가 없어서, 여러 backend가 동시에 읽어도 락 경쟁이 적습니다.\n처음 읽으면 read, 다시 읽으면 hit PG18부터는 EXPLAIN ANALYZE가 BUFFERS를 기본으로 보여 줍니다(explain_state.c).\npostgres=# SELECT pg_buffercache_evict_relation(\u0026#39;small\u0026#39;); pg_buffercache_evict_relation ------------------------------- (1728,1725,0) (1 row) postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM small; QUERY PLAN ----------------------------------------------------------------------------- Finalize Aggregate (actual rows=1.00 loops=1) Buffers: shared read=1728 -\u0026gt; Gather (actual rows=2.00 loops=1) Workers Planned: 1 Workers Launched: 1 Buffers: shared read=1728 -\u0026gt; Partial Aggregate (actual rows=1.00 loops=2) Buffers: shared read=1728 -\u0026gt; Parallel Seq Scan on small (actual rows=50000.00 loops=2) Buffers: shared read=1728 Planning: Buffers: shared hit=27 (12 rows) postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM small; QUERY PLAN ----------------------------------------------------------------------------- Finalize Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=1728 -\u0026gt; Gather (actual rows=2.00 loops=1) Workers Planned: 1 Workers Launched: 1 Buffers: shared hit=1728 -\u0026gt; Partial Aggregate (actual rows=1.00 loops=2) Buffers: shared hit=1728 -\u0026gt; Parallel Seq Scan on small (actual rows=50000.00 loops=2) Buffers: shared hit=1728 (10 rows) 같은 쿼리인데 첫 번째는 shared read=1728, 두 번째는 shared hit=1728입니다. read는 shared buffers에 없어서 파일에서 읽어 온 페이지 수, hit는 shared buffers에서 바로 찾은 페이지 수입니다.\nusage count: 자주 읽는 페이지일수록 오래 살아남는다 postgres=# SELECT pg_buffercache_evict_relation(\u0026#39;small\u0026#39;); pg_buffercache_evict_relation ------------------------------- (1728,0,0) (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT usagecount, count(*) FROM pg_buffercache postgres-# WHERE relfilenode = pg_relation_filenode(\u0026#39;small\u0026#39;) GROUP BY 1 ORDER BY 1; usagecount | count ------------+------- 1 | 1728 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT count(*) FROM small; count -------- 100000 (1 row) postgres=# SELECT usagecount, count(*) FROM pg_buffercache postgres-# WHERE relfilenode = pg_relation_filenode(\u0026#39;small\u0026#39;) GROUP BY 1 ORDER BY 1; usagecount | count ------------+------- 5 | 1728 (1 row) 캐시에 처음 올라온 페이지는 usage count가 1입니다. 그 뒤로 다섯 번 더 읽었으니 제한이 없다면 6이어야 하지만, 5에서 멈췄습니다. 최댓값 BM_MAX_USAGE_COUNT = 5 그대로입니다. clock sweep의 바늘이 이 칸들을 내보내려면 다섯 바퀴를 더 돌아야 합니다.\n칸을 비우고 파일에서 읽어 오기 고른 칸이 dirty(디스크보다 새 내용)면 먼저 디스크에 써야 합니다. 이때 규칙이 하나 있습니다. 그 페이지의 변경을 기록한 WAL이 먼저 디스크에 있어야 합니다(FlushBuffer()가 XLogFlush()를 먼저 부릅니다). 이 규칙 덕분에 장애가 나도 WAL로 데이터 파일을 복구할 수 있습니다. 7편에서 다시 다룹니다.\n마지막으로 칸의 태그를 새 블록으로 바꾸고 파일에서 읽어 옵니다. PG18에서 순차 스캔처럼 여러 블록을 미리 읽어 두는 경로는 1편에서 본 io worker가 대신 읽습니다. 반면 인덱스로 행 하나를 찾을 때처럼 블록 하나가 당장 필요한 경우에는, 비동기로 넘겨 봐야 기다리기만 하므로 backend가 직접 읽습니다(bufmgr.c).\nread는 디스크에서 읽었을까, 운영체제 캐시에서 읽었을까 postgres=# SET track_io_timing = on; SET postgres=# SET max_parallel_workers_per_gather = 0; SET postgres=# SELECT pg_buffercache_evict_relation(\u0026#39;small\u0026#39;); pg_buffercache_evict_relation ------------------------------- (1728,0,0) (1 row) postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM small; QUERY PLAN --------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared read=1728 I/O Timings: shared read=0.246 -\u0026gt; Seq Scan on small (actual rows=100000.00 loops=1) Buffers: shared read=1728 I/O Timings: shared read=0.246 Planning: Buffers: shared hit=24 (8 rows) $ grep -E \u0026#39;^(MemTotal|Cached):\u0026#39; /proc/meminfo MemTotal: 32810480 kB Cached: 13826596 kB 1728페이지(14MB)를 read했는데 I/O Timings는 0.246ms입니다. PG18에서 이 값은 backend가 읽기를 요청하는 데 쓴 시간과 완료를 기다린 시간의 합입니다(요청, 대기). io worker가 미리 읽어 두면 기다리는 시간이 줄어드니, 파일을 읽는 데 실제로 걸린 시간과는 다릅니다. 그래도 14MB에 0.246ms라면 실제 디스크를 거쳤다고 보기는 어렵습니다. 방금 만든 파일이라 운영체제의 페이지 캐시(Cached 약 13GB)에 남아 있었을 가능성이 큽니다.\nPostgreSQL은 파일을 운영체제를 통해 읽습니다(direct I/O를 쓰지 않는 기본 설정). 그래서 같은 페이지가 shared buffers와 운영체제 페이지 캐시에 두 번 캐시될 수 있습니다. PostgreSQL 입장의 \u0026ldquo;read\u0026quot;가 곧 느린 디스크 읽기는 아니라는 점을 기억해 두면 됩니다.\n수정된 페이지(dirty)는 체크포인트가 디스크로 내보낸다 postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT buffers_dirty FROM pg_buffercache_summary(); buffers_dirty --------------- 0 (1 row) postgres=# UPDATE small SET pad = repeat(\u0026#39;y\u0026#39;, 100) WHERE id % 10 = 0; UPDATE 10000 postgres=# SELECT buffers_dirty FROM pg_buffercache_summary(); buffers_dirty --------------- 1899 (1 row) postgres=# SELECT count(*) FILTER (WHERE isdirty) AS dirty_of_small postgres-# FROM pg_buffercache WHERE relfilenode = pg_relation_filenode(\u0026#39;small\u0026#39;); dirty_of_small ---------------- 1899 (1 row) postgres=# SELECT buffers_written FROM pg_stat_checkpointer; buffers_written ----------------- 328 (1 row) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT buffers_dirty FROM pg_buffercache_summary(); buffers_dirty --------------- 0 (1 row) postgres=# SELECT buffers_written FROM pg_stat_checkpointer; buffers_written ----------------- 2227 (1 row) UPDATE로 이 테이블의 칸 1899개가 dirty가 되었습니다. 테이블 페이지(1728)보다 많은 이유는 UPDATE가 새 버전의 행을 쓰느라 테이블 끝에 페이지를 더 붙였기 때문입니다(4편). 빈 공간 지도(FSM, 5편) 같은 부가 파일의 페이지 몇 칸도 여기에 함께 잡힙니다. 이 상태에서는 디스크의 파일이 아직 옛 내용이고, 최신 내용은 메모리와 WAL에만 있습니다. CHECKPOINT 뒤에는 dirty가 0이 되었고, checkpointer가 쓴 버퍼 수가 328에서 2227로 정확히 1899 늘었습니다.\n운영에서는: shared_buffers를 무작정 키우면 안 되는 이유 shared buffers가 클수록 hit가 늘어나는 것은 맞습니다. 하지만 앞에서 본 것처럼 PostgreSQL은 운영체제 페이지 캐시 위에서 동작합니다. shared buffers를 너무 크게 잡으면 운영체제가 쓸 캐시가 줄어들고, 같은 페이지를 두 곳에 들고 있는 낭비도 커집니다. 또 shared buffers는 기동할 때 한 번에 잡는 고정 영역이라, 크게 잡을수록 기동할 때 확보할 메모리와 체크포인트 때 내보낼 dirty 페이지 양이 늘어납니다. PostgreSQL 문서는 전용 서버에서 메모리의 25% 정도에서 시작하고, 40%를 넘기면 이득이 적다고 안내합니다(shared_buffers). 실제 값은 pg_buffercache와 hit 비율을 보면서 조정합니다.\nring buffer: 큰 테이블 스캔이 캐시를 망치지 않게 shared buffers가 128MB인데 1GB짜리 테이블을 처음부터 끝까지 한 번 읽으면 어떻게 될까요? 그대로라면 자주 쓰던 페이지까지 모두 밀려납니다. 이를 막으려고 PostgreSQL은 테이블이 shared buffers의 1/4보다 크면(heapam.c) 순차 스캔에 ring buffer라는 작은 전용 영역만 돌려 씁니다. 스캔이 칸 몇 개만 계속 재사용하므로, 나머지 캐시는 그대로 남습니다.\nring의 크기는 작업 종류에 따라 다릅니다(GetAccessStrategy()).\n작업 ring 크기 큰 테이블 순차 스캔 (BAS_BULKREAD) 256kB에서 시작해 I/O 동시성만큼 늘림. 단, \u0026lsquo;backend 하나의 pin 한도\u0026rsquo;와 256kB 중 큰 값을 넘지 않음 COPY, CREATE TABLE AS 같은 대량 쓰기 (BAS_BULKWRITE) 16MB VACUUM (BAS_VACUUM) vacuum_buffer_usage_limit (기본 2MB) PG18에서 순차 스캔의 ring 계산이 바뀌었습니다. 비동기 I/O로 여러 블록을 미리 읽어 두려면 칸이 더 필요하기 때문에, 256kB에 io_combine_limit × effective_io_concurrency만큼을 더합니다. 대신 backend 하나가 pin할 수 있는 칸 수(GetPinLimit(), NBuffers / (MaxBackends + NUM_AUXILIARY_PROCS))를 넘지 못합니다. 아래 실습에서 이 값을 직접 계산해 맞춰 봅니다.\n또 하나, ring으로 읽은 페이지는 usage count를 1보다 올리지 않습니다. 한 번 훑고 지나가는 페이지가 캐시에 오래 눌러앉지 않게 하려는 것입니다.\n큰 테이블 순차 스캔은 ring buffer만 쓴다 81MB(10368페이지)짜리 테이블을 만듭니다. shared buffers의 1/4인 4096페이지보다 크므로 ring buffer 대상입니다.\npostgres=# CREATE TABLE big AS SELECT g AS id, repeat(\u0026#39;x\u0026#39;, 100) AS pad FROM generate_series(1, 600000) g; SELECT 600000 postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;big\u0026#39;)) AS size, postgres-# pg_relation_size(\u0026#39;big\u0026#39;) / 8192 AS pages, postgres-# current_setting(\u0026#39;shared_buffers\u0026#39;) AS shared_buffers, postgres-# (SELECT setting::int FROM pg_settings WHERE name = \u0026#39;shared_buffers\u0026#39;) / 4 AS bulkread_threshold_pages; size | pages | shared_buffers | bulkread_threshold_pages -------+-------+----------------+-------------------------- 81 MB | 10368 | 128MB | 4096 (1 row) postgres=# SELECT pg_buffercache_evict_relation(\u0026#39;big\u0026#39;); pg_buffercache_evict_relation ------------------------------- (2048,2025,0) (1 row) postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM big; QUERY PLAN ---------------------------------------------------------------------------- Finalize Aggregate (actual rows=1.00 loops=1) Buffers: shared read=10368 dirtied=10345 written=10086 -\u0026gt; Gather (actual rows=3.00 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared read=10368 dirtied=10345 written=10086 -\u0026gt; Partial Aggregate (actual rows=1.00 loops=3) Buffers: shared read=10368 dirtied=10345 written=10086 -\u0026gt; Parallel Seq Scan on big (actual rows=200000.00 loops=3) Buffers: shared read=10368 dirtied=10345 written=10086 Planning: Buffers: shared hit=12 (12 rows) postgres=# SELECT count(*) AS buffers_of_big postgres-# FROM pg_buffercache postgres-# WHERE relfilenode = pg_relation_filenode(\u0026#39;big\u0026#39;); buffers_of_big ---------------- 282 (1 row) 테이블 전체(10368페이지)를 읽었는데 끝나고 남은 이 테이블의 페이지는 282개뿐입니다. 이 숫자를 소스로 계산해 보겠습니다.\npostgres=# SELECT current_setting(\u0026#39;max_connections\u0026#39;)::int postgres-# + current_setting(\u0026#39;autovacuum_worker_slots\u0026#39;)::int postgres-# + current_setting(\u0026#39;max_worker_processes\u0026#39;)::int postgres-# + current_setting(\u0026#39;max_wal_senders\u0026#39;)::int + 2 AS max_backends, postgres-# (SELECT setting::int FROM pg_settings WHERE name = \u0026#39;shared_buffers\u0026#39;) postgres-# / (current_setting(\u0026#39;max_connections\u0026#39;)::int postgres-# + current_setting(\u0026#39;autovacuum_worker_slots\u0026#39;)::int postgres-# + current_setting(\u0026#39;max_worker_processes\u0026#39;)::int postgres-# + current_setting(\u0026#39;max_wal_senders\u0026#39;)::int + 2 + 38) AS pin_limit_buffers; max_backends | pin_limit_buffers --------------+------------------- 136 | 94 (1 row) postgres=# SHOW io_combine_limit; io_combine_limit ------------------ 128kB (1 row) postgres=# SHOW effective_io_concurrency; effective_io_concurrency -------------------------- 16 (1 row) ring의 최대 크기는 backend 하나가 pin할 수 있는 칸 수입니다. 16384 / (136 + 38) = 94칸입니다. 136은 MaxBackends(max_connections + autovacuum_worker_slots + max_worker_processes + max_wal_senders + 2. 마지막 2는 autovacuum launcher와 slotsync worker 몫인 NUM_SPECIAL_WORKER_PROCS, postinit.c), 38은 보조 프로세스 수(NUM_AUXILIARY_PROCS = 6 + io worker 최대 32)입니다. PG18의 원래 계산은 256kB + 8kB × 16(io_combine_limit 128kB = 16블록) × 16(effective_io_concurrency) = 2304kB(288칸)이지만, 94칸 한도에 걸려 94칸이 됩니다. 실행 계획을 보면 이 스캔은 leader와 worker 2개, 모두 3개 프로세스가 나눠 맡았습니다(Workers Launched: 2). 프로세스마다 ring을 따로 두므로 94 × 3 = 282칸입니다. 측정값과 정확히 같습니다. 같은 실행 계획의 written=10086도 10368에서 ring에 마지막까지 남은 282칸을 뺀 값이라, 계산이 서로 맞아떨어집니다. Buffers: shared read=10368 dirtied=10345 written=10086도 눈여겨볼 만합니다. 앞에서 본 것과 같은 이유(처음 읽을 때 hint bit를 적음)로 읽은 페이지가 dirty가 되었고, ring의 칸을 다시 쓰려면 dirty 페이지를 먼저 디스크에 써야 해서 읽기 쿼리가 약 10000페이지를 썼습니다. 대량 적재 직후의 첫 SELECT가 유난히 느린 이유 중 하나입니다.\n운영에서는: 대량 적재 직후 첫 조회가 느리다 shared buffers에 페이지가 올라오는 실습과 위 실습에서 본 것처럼, 새로 쓴 행을 처음 읽는 쿼리는 hint bit를 적느라 페이지를 dirty로 만들고, 큰 테이블이면 ring buffer 때문에 그 페이지를 곧바로 디스크에 씁니다. 대량 적재 뒤에 VACUUM(또는 VACUUM (FREEZE))을 한 번 돌려 두면 이 작업이 미리 끝나서, 사용자 쿼리가 그 비용을 떠안지 않습니다. VACUUM은 5편에서 다룹니다.\nbackend 개인 메모리: work_mem과 메모리 컨텍스트 backend는 쿼리를 처리하면서 개인 메모리를 씁니다. PostgreSQL은 이 메모리를 메모리 컨텍스트라는 단위로 관리합니다(mmgr/README). 컨텍스트는 \u0026ldquo;이 쿼리를 실행하는 동안\u0026rdquo;, \u0026ldquo;이 트랜잭션 동안\u0026rdquo;, \u0026ldquo;세션 내내\u0026quot;처럼 수명별로 나뉘어 있고, 수명이 끝나면 컨텍스트째 한 번에 해제합니다. 메모리를 하나하나 free()하지 않아도 새지 않게 하는 장치입니다.\nbackend의 메모리 컨텍스트 postgres=# SELECT name, level, pg_size_pretty(total_bytes) AS total, pg_size_pretty(used_bytes) AS used postgres-# FROM pg_backend_memory_contexts postgres-# ORDER BY total_bytes DESC postgres-# LIMIT 8; name | level | total | used -------------------------+-------+--------+----------- CacheMemoryContext | 2 | 512 kB | 449 kB Timezones | 2 | 102 kB | 99 kB TopMemoryContext | 1 | 97 kB | 91 kB MessageContext | 2 | 64 kB | 32 kB WAL record construction | 2 | 49 kB | 42 kB ExecutorState | 4 | 48 kB | 39 kB TupleSort main | 5 | 32 kB | 25 kB TransactionAbortContext | 2 | 32 kB | 240 bytes (8 rows) postgres=# SELECT count(*) AS contexts, pg_size_pretty(sum(total_bytes)) AS total postgres-# FROM pg_backend_memory_contexts; contexts | total ----------+--------- 124 | 1495 kB (1 row) 이 쿼리를 실행한 backend에는 메모리 컨텍스트가 124개 있고 모두 합쳐 약 1.5MB입니다. 가장 큰 CacheMemoryContext(512kB)는 테이블 정의, 함수 정보 같은 카탈로그 캐시입니다. 세션이 여러 테이블을 건드릴수록 커지고 세션이 끝날 때까지 유지됩니다. ExecutorState와 TupleSort main은 지금 실행 중인 바로 이 쿼리(ORDER BY가 있음)의 실행 상태와 정렬용 메모리로, 쿼리가 끝나면 컨텍스트째 해제됩니다.\nwork_mem: 정렬과 해시 backend 개인 메모리에서 가장 큰 영향을 주는 설정이 work_mem입니다.\n정렬(ORDER BY, DISTINCT, merge join 준비): 데이터가 work_mem 안에 들어가면 메모리에서 quicksort하고, 넘치면 정렬된 조각(run)을 임시 파일에 쓰고 나중에 병합하는 external merge로 바꿉니다(tuplesort.c). 해시 테이블(hash join, hash aggregate): work_mem × hash_mem_multiplier까지 씁니다. 기본값으로는 4MB × 2.0 = 8MB입니다. 넘치면 데이터를 여러 batch로 나눠 임시 파일에 두고 하나씩 처리합니다. work_mem을 넘는 정렬은 임시 파일로 간다 postgres=# SET log_temp_files = 0; SET postgres=# SET work_mem = \u0026#39;4MB\u0026#39;; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT * FROM big ORDER BY pad, id DESC; QUERY PLAN --------------------------------------------------------------------- Sort (actual rows=600000.00 loops=1) Sort Key: pad, id DESC Sort Method: external merge Disk: 67576kB Buffers: shared hit=288 read=10086, temp read=16888 written=16910 -\u0026gt; Seq Scan on big (actual rows=600000.00 loops=1) Buffers: shared hit=282 read=10086 Planning: Buffers: shared hit=49 (8 rows) postgres=# SET work_mem = \u0026#39;256MB\u0026#39;; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT * FROM big ORDER BY pad, id DESC; QUERY PLAN ------------------------------------------------------- Sort (actual rows=600000.00 loops=1) Sort Key: pad, id DESC Sort Method: quicksort Memory: 99577kB Buffers: shared hit=376 read=9992 -\u0026gt; Seq Scan on big (actual rows=600000.00 loops=1) Buffers: shared hit=376 read=9992 (6 rows) $ grep \u0026#39;temporary file\u0026#39; /home/postgres/server.log | tail -3 2026-09-26 10:07:55.017 UTC [137] LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp137.0\u0026#34;, size 69197824 같은 정렬인데 work_mem이 4MB일 때는 external merge Disk: 67576kB, 즉 약 66MB를 임시 파일에 쓰고 병합했습니다. 서버 로그에도 temporary file: ... size 69197824가 남았습니다(log_temp_files = 0은 모든 임시 파일을 로그에 남기라는 뜻입니다). 256MB로 올리자 quicksort Memory: 99577kB, 즉 약 97MB를 메모리에서 정렬했습니다. 디스크에 쓴 크기(66MB)보다 메모리에서 쓴 크기(97MB)가 큰 이유는 메모리 안에서는 행마다 포인터와 관리 정보가 붙기 때문입니다.\n해시 테이블은 work_mem × hash_mem_multiplier까지 쓴다 postgres=# SET max_parallel_workers_per_gather = 0; SET postgres=# SET enable_mergejoin = off; SET postgres=# SET work_mem = \u0026#39;1MB\u0026#39;; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) postgres-# SELECT count(*) FROM big b1 JOIN big b2 USING (id); QUERY PLAN -------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=1037 read=19699, temp read=3526 written=3526 -\u0026gt; Hash Join (actual rows=600000.00 loops=1) Hash Cond: (b1.id = b2.id) Buffers: shared hit=1037 read=19699, temp read=3526 written=3526 -\u0026gt; Seq Scan on big b1 (actual rows=600000.00 loops=1) Buffers: shared hit=564 read=9804 -\u0026gt; Hash (actual rows=600000.00 loops=1) Buckets: 65536 Batches: 64 Memory Usage: 840kB Buffers: shared hit=473 read=9895, temp written=1700 -\u0026gt; Seq Scan on big b2 (actual rows=600000.00 loops=1) Buffers: shared hit=473 read=9895 Planning: Buffers: shared hit=130 read=3 (14 rows) postgres=# SET work_mem = \u0026#39;64MB\u0026#39;; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) postgres-# SELECT count(*) FROM big b1 JOIN big b2 USING (id); QUERY PLAN ---------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=1413 read=19323 -\u0026gt; Hash Join (actual rows=600000.00 loops=1) Hash Cond: (b1.id = b2.id) Buffers: shared hit=1413 read=19323 -\u0026gt; Seq Scan on big b1 (actual rows=600000.00 loops=1) Buffers: shared hit=752 read=9616 -\u0026gt; Hash (actual rows=600000.00 loops=1) Buckets: 2097152 Batches: 1 Memory Usage: 37478kB Buffers: shared hit=661 read=9707 -\u0026gt; Seq Scan on big b2 (actual rows=600000.00 loops=1) Buffers: shared hit=661 read=9707 (12 rows) work_mem = 1MB에서는 해시 테이블 한도가 2MB(× 2.0)라 60만 행이 들어가지 않습니다. 그래서 데이터를 64개 batch로 나눠 임시 파일에 두고 하나씩 처리했습니다(temp read=3526 written=3526). 64MB로 올리자 Batches: 1, 메모리 37MB 안에서 한 번에 처리했고 임시 파일 I/O가 사라졌습니다.\n운영에서는: 정렬이 디스크로 넘어가는 순간 찾기 log_temp_files를 켜 두면 임시 파일을 만든 쿼리가 서버 로그에 남습니다. 0이면 모든 임시 파일, 예를 들어 10MB로 두면 10MB 이상인 것만 남습니다.\n2026-09-26 10:07:55.017 UTC [137] LOG: temporary file: path \u0026#34;base/pgsql_tmp/pgsql_tmp137.0\u0026#34;, size 69197824 EXPLAIN (ANALYZE)에서 Sort Method: external merge나 Batches:가 1보다 큰 해시가 보이면 work_mem이 부족하다는 신호입니다. 이런 쿼리만 골라 세션 단위로 work_mem을 올리면, 전역 메모리 위험 없이 성능을 높일 수 있습니다.\nwork_mem은 노드마다 따로 잡힌다 주의할 점은 work_mem이 쿼리 하나의 한도가 아니라 정렬이나 해시 노드 하나의 한도라는 것입니다. 정렬 두 개와 해시 하나가 있는 쿼리는 work_mem의 몇 배를 쓸 수 있고, 그런 쿼리가 동시에 여러 개 돌면 곱절로 늘어납니다.\nmerge join의 정렬 두 개가 각각 work_mem을 쓴다 hash join을 끄고 같은 조인을 merge join으로 실행하면, 양쪽 입력을 각각 정렬합니다.\npostgres=# SET max_parallel_workers_per_gather = 0; SET postgres=# SET enable_hashjoin = off; SET postgres=# SET work_mem = \u0026#39;32MB\u0026#39;; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) postgres-# SELECT count(*) FROM big b1 JOIN big b2 USING (id); QUERY PLAN ---------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=1790 read=18950 -\u0026gt; Merge Join (actual rows=600000.00 loops=1) Merge Cond: (b1.id = b2.id) Buffers: shared hit=1790 read=18950 -\u0026gt; Sort (actual rows=600000.00 loops=1) Sort Key: b1.id Sort Method: quicksort Memory: 24577kB Buffers: shared hit=850 read=9522 -\u0026gt; Seq Scan on big b1 (actual rows=600000.00 loops=1) Buffers: shared hit=846 read=9522 -\u0026gt; Materialize (actual rows=600000.00 loops=1) Storage: Memory Maximum Storage: 17kB Buffers: shared hit=940 read=9428 -\u0026gt; Sort (actual rows=600000.00 loops=1) Sort Key: b2.id Sort Method: quicksort Memory: 24577kB Buffers: shared hit=940 read=9428 -\u0026gt; Seq Scan on big b2 (actual rows=600000.00 loops=1) Buffers: shared hit=940 read=9428 Planning: Buffers: shared hit=139 (22 rows) work_mem을 32MB로 두었습니다. 정렬 노드가 두 개이고 각각 Memory: 24577kB(약 24MB)를 썼습니다. 정렬 하나하나는 한도(32MB) 안이지만, 쿼리 하나로 보면 약 48MB로 work_mem보다 많이 썼습니다. 한도는 노드마다 따로 적용되므로, 정렬이나 해시가 더 많은 쿼리라면 work_mem의 몇 배까지 갈 수 있습니다.\n운영에서는: work_mem이 메모리 폭증을 부르는 경우 위 실습에서 본 것처럼 work_mem은 정렬이나 해시 노드 하나의 한도입니다. 대략적인 최악의 사용량은 다음과 같습니다.\n동시 실행 쿼리 수 × 쿼리당 정렬, 해시 노드 수 × work_mem (해시는 × hash_mem_multiplier)\nwork_mem을 전역으로 256MB로 올려 두고 커넥션 200개가 복잡한 리포트 쿼리를 동시에 돌리면, 이론상 수십 GB가 필요해질 수 있습니다. 메모리가 모자라면 리눅스 OOM killer가 backend를 죽이고, 1편에서 본 것처럼 서버 전체가 재시작합니다. 그래서 전역 값은 보수적으로 두고, 큰 정렬이 필요한 배치나 리포트 세션에서만 SET work_mem으로 올리는 방식을 권합니다.\ntemp_buffers: 임시 테이블 전용 캐시 temp_buffers는 임시 테이블(CREATE TEMP TABLE) 전용 캐시입니다. 임시 테이블은 그 세션만 보므로 공유 메모리를 쓸 이유가 없고, backend 개인 메모리의 local buffer에 올라갑니다.\n임시 테이블은 temp_buffers를 쓴다 postgres=# CREATE TEMP TABLE tmp AS SELECT g AS id FROM generate_series(1, 100000) g; SELECT 100000 postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM tmp; QUERY PLAN ------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: local hit=448 -\u0026gt; Seq Scan on tmp (actual rows=100000.00 loops=1) Buffers: local hit=448 Planning: Buffers: shared hit=22 (6 rows) postgres=# SELECT count(*) AS shared_buffers_used_by_tmp postgres-# FROM pg_buffercache WHERE relfilenode = pg_relation_filenode(\u0026#39;tmp\u0026#39;); shared_buffers_used_by_tmp ---------------------------- 0 (1 row) Buffers: local hit=448로, 임시 테이블 페이지는 shared가 아니라 local 버퍼에서 읽었습니다. shared buffers에서 이 테이블의 페이지를 찾아보면 0개입니다.\nWAL buffers 테이블을 바꾸는 모든 작업은 WAL 레코드를 남깁니다. 레코드는 바로 디스크에 쓰지 않고 공유 메모리의 WAL buffers에 먼저 모읍니다. 커밋할 때, 또는 walwriter가 주기적으로 깨어날 때 디스크로 내보냅니다. 여러 트랜잭션의 WAL을 모아 한 번에 쓰면 디스크 쓰기 횟수가 줄어듭니다.\n크기의 기본값은 -1이고, 이 경우 XLOGChooseNumBuffers()가 shared_buffers의 1/32로 정합니다. 최소는 8페이지(64kB), 최대는 WAL 세그먼트 하나(기본 16MB)입니다. 기본 설정(shared_buffers 128MB)에서는 4MB가 됩니다.\nWAL buffers가 가득 차서 빈자리가 없으면, WAL을 쓰려던 backend가 직접 앞쪽 WAL을 디스크에 써서 자리를 만들어야 합니다. 이 횟수가 pg_stat_wal.wal_buffers_full입니다.\nWAL buffers가 꽉 차면 WAL buffers 기본값(4MB)과 최소에 가까운 64kB에서 같은 대량 INSERT(약 46MB의 WAL)를 실행해 비교합니다.\npostgres=# SHOW wal_buffers; wal_buffers ------------- 4MB (1 row) postgres=# SELECT pg_stat_reset_shared(\u0026#39;wal\u0026#39;); pg_stat_reset_shared ---------------------- (1 row) postgres=# INSERT INTO big SELECT g, repeat(\u0026#39;z\u0026#39;, 100) FROM generate_series(1, 300000) g; postgres=# SELECT wal_records, pg_size_pretty(wal_bytes) AS wal_bytes, wal_buffers_full FROM pg_stat_wal; wal_records | wal_bytes | wal_buffers_full -------------+-----------+------------------ 300003 | 46 MB | 5368 (1 row) postgres=# ALTER SYSTEM SET wal_buffers = \u0026#39;64kB\u0026#39;; $ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast \u0026gt; /dev/null postgres=# SHOW wal_buffers; wal_buffers ------------- 64kB (1 row) postgres=# SELECT pg_stat_reset_shared(\u0026#39;wal\u0026#39;); pg_stat_reset_shared ---------------------- (1 row) postgres=# INSERT INTO big SELECT g, repeat(\u0026#39;z\u0026#39;, 100) FROM generate_series(1, 300000) g; postgres=# SELECT wal_records, pg_size_pretty(wal_bytes) AS wal_bytes, wal_buffers_full FROM pg_stat_wal; wal_records | wal_bytes | wal_buffers_full -------------+-----------+------------------ 300004 | 46 MB | 5874 (1 row) 두 경우 모두 wal_buffers_full이 5천 번대입니다(5368, 5874). 트랜잭션 하나가 46MB의 WAL을 쏟아내면 4MB든 64kB든 버퍼는 금방 차고, backend가 직접 WAL을 써서 자리를 만들어야 합니다. 이 실습에서 차이는 약 9%였습니다. WAL buffers의 크기는 대량 적재 한 건보다, 짧은 트랜잭션이 동시에 많이 커밋될 때 WAL을 모아 쓰는 효과에서 더 중요합니다. 이 실습만으로 그 효과를 보여 주지는 못했으므로, 부하 테스트는 WAL을 다루는 7편에서 이어 가겠습니다.\n정리 공유 메모리의 대부분은 shared buffers입니다. 8kB 칸 배열(Buffer Blocks), 칸마다 하나씩 있는 descriptor, 블록을 칸 번호로 바꾸는 매핑 해시로 이루어져 있습니다. 캐시가 가득 차면 clock sweep이 usage count(최대 5)를 깎아 가며 내보낼 칸을 고릅니다. dirty 칸을 내보낼 때는 WAL을 먼저 디스크에 씁니다. shared buffers의 1/4보다 큰 테이블을 순차 스캔하면 ring buffer만 씁니다. PG18에서는 ring 크기가 I/O 동시성에 따라 커지지만 pin 한도를 넘지 못하고, 병렬 스캔이면 프로세스마다 ring을 따로 둡니다. work_mem은 정렬이나 해시 노드 하나의 한도이고, 넘치면 임시 파일을 씁니다. 해시는 hash_mem_multiplier배까지 씁니다. backend 개인 메모리는 메모리 컨텍스트 단위로 관리되고, 수명이 끝난 컨텍스트는 통째로 해제됩니다. WAL buffers는 기본적으로 shared buffers의 1/32(최대 16MB)입니다. 다음 글에서는 shared buffers의 한 칸에 들어가는 8kB 페이지의 안쪽, 즉 페이지 레이아웃, 튜플 구조, 그리고 큰 값을 따로 보관하는 TOAST를 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/include/storage/buf_internals.h: BufferDesc, 버퍼 상태 비트 src/backend/storage/buffer/freelist.c: clock sweep, ring buffer src/backend/storage/buffer/bufmgr.c: 버퍼 읽기, pin, 쓰기 src/backend/storage/buffer/README: 버퍼 관리 설계 설명 src/backend/utils/mmgr/README: 메모리 컨텍스트 src/backend/utils/sort/tuplesort.c: 정렬과 external merge PostgreSQL 18 공식 문서\nResource Consumption: Memory pg_buffercache pg_shmem_allocations pg_backend_memory_contexts pg_stat_wal ","permalink":"https://jinlog.dev/posts/postgresql/02-memory-architecture/","summary":"shared buffers, work_mem, WAL buffers는 어디에 어떻게 쓰이는가","title":"PostgreSQL 인터널 2: 메모리 구조"},{"content":"개요 1편에서 컬렉션은 WiredTiger의 B-tree 파일 하나이고, 페이지는 캐시에서 고쳐졌다가 eviction이나 체크포인트 때 디스크에 쓰인다고 했습니다. 그렇다면 한 세션이 도큐먼트를 고치는 동안 다른 세션이 같은 도큐먼트를 읽으면 무엇을 볼까요? 트랜잭션 안에서 읽은 값은 트랜잭션이 끝날 때까지 그대로일까요?\nWiredTiger도 PostgreSQL처럼 MVCC(Multi-Version Concurrency Control)로 이 문제를 풉니다. 도큐먼트를 고치면 제자리에서 덮어쓰지 않고 새 버전을 하나 더 만들고, 읽는 쪽은 자기 스냅샷에 보이는 버전을 고릅니다. 다만 버전을 두는 곳이 PostgreSQL과 다릅니다. PostgreSQL은 옛 버전을 테이블 파일에 그대로 남기고 나중에 VACUUM으로 치우지만, WiredTiger는 옛 버전을 메모리의 update chain에 두고, 페이지를 디스크에 쓸 때 아직 필요한 옛 버전만 history store라는 별도 테이블로 옮깁니다. 데이터 파일에는 최신 버전만 들어갑니다.\n이 글에서 답할 질문은 다음과 같습니다.\n도큐먼트를 고치면 메모리에서는 무슨 일이 일어나는가 트랜잭션의 스냅샷은 무엇으로 이루어지고, \u0026ldquo;보인다\u0026quot;는 어떻게 판단하는가 MongoDB는 WiredTiger에 어떤 timestamp를 넘기고, 그것은 가시성에 어떻게 쓰이는가 두 트랜잭션이 같은 도큐먼트를 고치면 왜 기다리지 않고 WriteConflict가 나는가 옛 버전은 언제 history store로 가고, 무엇이 그것을 붙잡아 두는가 minSnapshotHistoryWindowInSeconds와 transactionLifetimeLimitSeconds는 무엇을 제한하는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 멀티 도큐먼트 트랜잭션과 timestamp를 쓰려고 컨테이너 1대에 단일 노드 레플리카셋(rs0)을 띄웠습니다.\nupdate chain: 새 버전은 체인 맨 앞에 붙는다 WiredTiger의 leaf page는 디스크에서 읽어 온 이미지(각 행의 key와 value)를 그대로 두고, 그 뒤에 일어난 변경을 행마다 WT_UPDATE 구조체의 연결 리스트로 붙여 둡니다(WT_UPDATE). 이것이 update chain입니다. 주요 필드는 다음과 같습니다.\n필드 뜻 txnid 이 버전을 만든 WiredTiger 트랜잭션 ID. abort되면 WT_TXN_ABORTED로 바뀜 start_ts, durable_ts 이 버전의 commit timestamp와 durable timestamp(btmem.h) next 한 단계 옛 버전(btmem.h) type 전체 값(STANDARD), 일부만 바꾼 값(MODIFY), 삭제(TOMBSTONE) 등(btmem.h) data[] 값 자체. MongoDB에서는 BSON 도큐먼트 수정할 때 WiredTiger는 새 WT_UPDATE를 만들어 txnid에 자기 트랜잭션 ID를 적고(__wt_txn_modify()), 체인의 머리에 compare-and-swap으로 끼워 넣습니다(__wt_update_serial()). 그래서 체인은 항상 새 버전이 앞, 옛 버전이 뒤입니다. 끼워 넣기 직전에 쓰기 충돌 검사를 하는데, 이것은 뒤에서 봅니다. 읽는 쪽은 체인을 앞에서부터 따라가며 자기에게 보이는 첫 버전을 고르고, 체인에서 못 찾으면 디스크 이미지의 값을, 그것도 보이지 않으면 history store를 찾습니다.\nPostgreSQL의 튜플처럼 버전마다 \u0026ldquo;만든 트랜잭션\u0026quot;이 적혀 있다는 점은 같지만, \u0026ldquo;지운 트랜잭션(xmax)\u0026ldquo;은 따로 없습니다. 다음 버전의 시작이 곧 앞 버전의 끝입니다. 페이지를 디스크에 쓸 때는 이 관계가 버전마다 time window(시작 timestamp와 끝 timestamp)로 기록되는데, 이것은 history store 실습에서 직접 봅니다.\n한 도큐먼트를 두 번 고치면 메모리의 update chain에 버전이 셋 생깁니다(새 버전이 앞). 페이지를 디스크에 쓸 때 최신 버전은 데이터 파일로, 아직 누군가 볼 수 있는 옛 버전은 WiredTigerHS.wt로 갑니다. 그림의 값은 뒤의 wt 실습에서 본 것입니다. 새 창에서 크게 보기 스냅샷: 트랜잭션 ID로 본 \u0026ldquo;이미 끝난 것\u0026rdquo; WiredTiger 트랜잭션은 데이터를 처음 바꿀 때 64비트 트랜잭션 ID를 받습니다. 읽기를 시작할 때는 스냅샷을 만드는데, 스냅샷은 PostgreSQL의 xmin:xmax:xip와 거의 같은 구조입니다(WT_TXN_SNAPSHOT).\n필드 뜻 snap_max 스냅샷을 만들 때의 전역 \u0026ldquo;다음 트랜잭션 ID\u0026rdquo;. 이 값 이상인 ID는 보이지 않음 snap_min 동시에 실행 중이던 트랜잭션 중 가장 작은 ID. 이보다 작은 ID는 보임 snapshot[], snapshot_count 그 사이에서 스냅샷을 만들 때 실행 중이던 트랜잭션 ID 목록. 목록에 있으면 보이지 않음 스냅샷은 모든 세션의 공유 트랜잭션 상태 배열을 훑어서 만듭니다(__txn_get_snapshot_int(), 훑는 부분은 txn.c). 자기 ID는 목록에 넣지 않습니다. 자기가 만든 버전은 스냅샷과 상관없이 항상 보이기 때문입니다. 가장 작은 ID는 세션의 pinned_id로 공개되고, 모든 세션의 pinned_id 중 최솟값이 전역 oldest_id가 됩니다. 이 값보다 오래된 트랜잭션이 만든 버전은 \u0026ldquo;모두에게 보이는\u0026rdquo; 버전이라 더 옛 버전을 버려도 됩니다(__txn_visible_all_id()).\nID만 보고 판단하는 부분은 __wt_txn_visible_id_snapshot()입니다. snap_max 이상이면 안 보이고, snap_min보다 작으면 보이고, 그 사이면 목록을 이진 탐색합니다. 그 앞에서 __txn_visible_id()가 abort된 버전은 아무에게도 안 보이고(txn_inline.h), 내 버전은 항상 보인다는 것을 먼저 처리합니다. PostgreSQL처럼 커밋 여부를 따로 기록한 파일(pg_xact)을 찾아볼 필요가 없습니다. abort하면 체인에 있는 버전의 txnid가 곧바로 WT_TXN_ABORTED로 바뀌기 때문입니다.\ntimestamp: MongoDB가 WiredTiger에 넘기는 시각 트랜잭션 ID만으로도 스냅샷 격리는 되지만, MongoDB는 여기에 timestamp를 하나 더 얹습니다. 레플리카셋에서는 모든 쓰기가 oplog 엔트리가 되고 엔트리마다 Timestamp(초, 증가값)이 붙는데, MongoDB는 그 값을 그대로 WiredTiger 트랜잭션의 commit timestamp로 넘깁니다(WiredTigerRecoveryUnit::_txnClose()). WiredTiger는 이것을 버전의 start_ts에 적습니다(__wt_txn_op_set_timestamp()). 그래서 데이터의 각 버전이 oplog의 어느 지점에서 생겼는지 알 수 있고, \u0026ldquo;oplog의 이 지점까지 반영된 상태\u0026quot;를 읽을 수 있습니다. 5편의 복제와 8편의 read concern이 이 위에 서 있습니다.\n읽는 쪽은 read timestamp를 정할 수 있습니다. read timestamp가 있으면 트랜잭션 ID 검사를 통과한 버전이라도 start_ts가 read timestamp 이하여야 보입니다(__wt_txn_timestamp_visible()). 두 검사를 합친 것이 __wt_txn_visible()입니다.\n체인의 버전을 새것부터 하나씩 트랜잭션 ID와 timestamp로 검사하고, 처음 보이는 버전을 읽습니다. 체인이 끝나면 디스크 이미지의 값, 그다음 history store를 찾습니다. 새 창에서 크게 보기 MongoDB 트랜잭션이 read timestamp를 쓰는지는 read concern에 따라 다릅니다(_setReadSnapshot()).\n트랜잭션의 readConcern read timestamp snapshot 구멍 없는(all durable) 시점의 timestamp. 그 시점까지의 oplog로 재구성할 수 있는 상태를 읽음 atClusterTime 지정 지정한 timestamp local, majority (기본은 local) 없음(kNoTimestamp). 트랜잭션 ID 스냅샷만으로 가장 최근 상태를 읽음 어느 경우든 트랜잭션의 첫 명령에서 스냅샷을 한 번 만들고 커밋이나 abort까지 그대로 씁니다. PostgreSQL로 치면 REPEATABLE READ에 가깝습니다.\n스냅샷 격리: 트랜잭션은 시작할 때의 스냅샷을 끝까지 본다 mongosh 세션 두 개를 열어 두고 번갈아 명령을 보냅니다. A, B는 세션 이름이고, 이름이 없는 프롬프트는 잠깐 따로 접속한 세션입니다. 컬렉션 acct에는 {_id: 1, balance: 100}과 {_id: 2, balance: 100}을 넣어 두었습니다.\nA rs0 [direct: primary] test\u003e sA = db.getMongo().startSession(); cA = sA.getDatabase(\u0026#34;test\u0026#34;).acct; test.acct B rs0 [direct: primary] test\u003e sB = db.getMongo().startSession(); cB = sB.getDatabase(\u0026#34;test\u0026#34;).acct; test.acct A rs0 [direct: primary] test\u003e sA.startTransaction({readConcern: {level: \u0026#34;snapshot\u0026#34;}}) A rs0 [direct: primary] test\u003e cA.findOne({_id: 1}) { _id: 1, balance: 100 } B rs0 [direct: primary] test\u003e sB.startTransaction() B rs0 [direct: primary] test\u003e cB.updateOne({_id: 1}, {$set: {balance: 200}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } B rs0 [direct: primary] test\u003e sB.commitTransaction() B rs0 [direct: primary] test\u003e sB.getOperationTime() Timestamp({ t: 1790513779, i: 1 }) A rs0 [direct: primary] test\u003e cA.findOne({_id: 1}) { _id: 1, balance: 100 } A가 트랜잭션에서 balance: 100을 읽은 뒤, B가 200으로 바꾸고 커밋했습니다. 그런데 A가 같은 트랜잭션 안에서 다시 읽어도 여전히 100입니다. 다른 연결에서 보면 이렇습니다.\nrs0 [direct: primary] test\u003e db.acct.findOne({_id: 1}) { _id: 1, balance: 200 } rs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;admin\u0026#34;).aggregate([{$currentOp: {idleSessions: true}}, {$match: {\u0026#34;transaction.parameters.autocommit\u0026#34;: false}}, {$project: {_id: 0, active: 1, readConcern: \u0026#34;$transaction.parameters.readConcern.level\u0026#34;, readTimestamp: \u0026#34;$transaction.readTimestamp\u0026#34;, startWallClockTime: \u0026#34;$transaction.startWallClockTime\u0026#34;, expiryTime: \u0026#34;$transaction.expiryTime\u0026#34;}}]) [ { active: false, readConcern: \u0026#39;snapshot\u0026#39;, readTimestamp: Timestamp({ t: 1790513767, i: 2 }), startWallClockTime: \u0026#39;2026-09-27T12:56:15.681+00:00\u0026#39;, expiryTime: \u0026#39;2026-09-27T12:57:15.681+00:00\u0026#39; } ] 트랜잭션 밖에서 읽으면 새 값 200이 보입니다. $currentOp에 idleSessions: true를 주면 명령을 실행하고 있지 않은(active: false) 트랜잭션도 보입니다. A의 트랜잭션은 readTimestamp가 Timestamp(1790513767, 2)이고, B의 커밋은 Timestamp(1790513779, 1)입니다. B의 버전은 start_ts가 A의 read timestamp보다 크므로 timestamp 검사에서 걸립니다. A가 트랜잭션을 시작할 때 B가 아직 트랜잭션 ID를 받지 않았으니 ID로 보아도 snap_max 이상이라 보이지 않습니다. read timestamp는 벽시계 시각이 아니라 그 순간까지 커밋된 마지막 쓰기의 timestamp입니다. A가 트랜잭션을 시작한 것은 12:56:15인데 read timestamp의 초 값(1790513767)은 그보다 8초 앞입니다. 그동안 이 서버에는 쓰기가 없었기 때문입니다. expiryTime은 시작 시각에 정확히 60초를 더한 값입니다. 뒤에서 볼 transactionLifetimeLimitSeconds의 기본값입니다. 쓰기 충돌: 기다리지 않고 바로 실패한다 PostgreSQL에서는 다른 트랜잭션이 고치고 있는 행을 고치려 하면 그 트랜잭션이 끝날 때까지 기다립니다(PG 4편). WiredTiger에는 행 잠금이 없습니다. 대신 체인 머리에 새 버전을 끼워 넣기 직전에 __txn_modify_block()이 체인의 버전을 앞에서부터 보며, abort되지 않았는데 내 스냅샷에서 보이지 않는 버전이 하나라도 있으면 곧바로 실패를 돌려줍니다(txn_inline.h, txn_inline.h). 이유 문자열은 conflict between concurrent operations이고(txn.h), 반환 코드는 WT_ROLLBACK입니다. 보이지 않는 버전은 두 가지입니다.\n아직 커밋하지 않은 다른 트랜잭션의 버전 커밋은 되었지만 내 스냅샷을 만든 뒤에 커밋된 버전 어느 쪽이든 그 위에 덮어쓰면 상대의 변경을 잃거나 내가 보지 못한 값을 덮게 됩니다. 이 규칙을 first-updater-wins라고 부르기도 합니다. MongoDB는 WT_ROLLBACK을 WriteConflict 예외로 바꿉니다(wiredtiger_util.cpp, throwWriteConflictException()). 그다음 처리는 트랜잭션 안인지 밖인지에 따라 갈립니다.\n스냅샷이 붙잡은 옛 버전 위에 쓰면 WriteConflict 앞의 실습에서 A의 트랜잭션은 아직 열려 있습니다. A의 스냅샷에서는 _id: 1이 100이지만, 체인 머리에는 B가 커밋한 200이 있습니다. A가 이 도큐먼트를 고쳐 봅니다.\nA rs0 [direct: primary] test\u003e try { cA.updateOne({_id: 1}, {$inc: {balance: 1}}) } catch (e) { printjson({codeName: e.codeName, errorLabels: e.errorLabels, errmsg: e.errmsg}) } { codeName: \u0026#39;WriteConflict\u0026#39;, errorLabels: [ \u0026#39;TransientTransactionError\u0026#39; ], errmsg: \u0026#39;Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.\u0026#39; } A rs0 [direct: primary] test\u003e sA.commitTransaction() MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 1 } has been aborted. 오류 코드는 WriteConflict(112), 레이블은 TransientTransactionError입니다. WriteConflict는 이 레이블을 받는 오류 코드 목록에 들어 있습니다(isTransientTransactionError()). \u0026ldquo;트랜잭션을 처음부터 다시 하면 성공할 수 있다\u0026quot;는 뜻입니다. 메시지의 yielding is disabled는 트랜잭션 안에서는 서버가 스냅샷을 놓고 다시 시도할 수 없다는 뜻입니다. 쿼리 실행기는 yield할 수 없는 실행에서 충돌이 나면 복구하지 않고 이 문구로 던집니다(일반 경로의 _handleNeedYield(), _id 조회 같은 express 경로의 DoNotRecoverPolicy). 스냅샷을 새로 만들면 트랜잭션이 앞에서 읽은 것과 일관성이 깨지기 때문입니다. 오류가 난 트랜잭션은 서버에서 이미 abort되었습니다. 커밋하려 하면 has been aborted가 돌아옵니다. 두 트랜잭션이 같은 도큐먼트를 고치면 뒤에 쓴 쪽이 바로 실패한다 이번에는 A가 _id: 2를 고치고 커밋하지 않은 상태에서 B가 같은 도큐먼트를 고칩니다. WiredTiger의 충돌 횟수(update conflicts)도 앞뒤로 봅니다.\nrs0 [direct: primary] test\u003e db.serverStatus().wiredTiger.transaction[\u0026#34;update conflicts\u0026#34;] 1 A rs0 [direct: primary] test\u003e sA.startTransaction(); cA.updateOne({_id: 2}, {$inc: {balance: 10}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } B rs0 [direct: primary] test\u003e sB.startTransaction(); try { cB.updateOne({_id: 2}, {$inc: {balance: 1}}) } catch (e) { printjson({codeName: e.codeName, errorLabels: e.errorLabels}) } { codeName: \u0026#39;WriteConflict\u0026#39;, errorLabels: [ \u0026#39;TransientTransactionError\u0026#39; ] } B rs0 [direct: primary] test\u003e sB.abortTransaction() A rs0 [direct: primary] test\u003e sA.commitTransaction() rs0 [direct: primary] test\u003e db.acct.findOne({_id: 2}) { _id: 2, balance: 110 } rs0 [direct: primary] test\u003e db.serverStatus().wiredTiger.transaction[\u0026#34;update conflicts\u0026#34;] 2 B의 update는 A를 기다리지 않고 즉시 WriteConflict로 끝났습니다. 체인 머리에 있는 A의 버전이 아직 커밋되지 않아 B에게 보이지 않기 때문입니다. PostgreSQL이라면 B가 A의 커밋이나 롤백을 기다렸을 상황입니다. A는 문제없이 커밋했고 결과는 110입니다. B의 +1은 반영되지 않았으므로, 애플리케이션이 B의 트랜잭션을 처음부터 다시 해야 합니다. update conflicts가 1에서 2로 늘었습니다. 처음의 1은 바로 앞 실습에서 A가 받은 충돌입니다. 이 지표는 WiredTiger가 충돌을 돌려줄 때마다 늘어납니다. 트랜잭션 밖의 쓰기는 서버 안에서 재시도하며 기다린다 트랜잭션 밖의 단일 쓰기는 사정이 다릅니다. 이런 쓰기는 도큐먼트 하나를 고치는 WiredTiger 트랜잭션 하나라서, 충돌이 나면 서버가 스냅샷을 버리고 처음부터 다시 시도해도 잃을 것이 없습니다. 서버는 이 재시도를 횟수 제한 없이 하고, 횟수에 따라 쉬는 시간을 늘립니다. 처음 4번은 바로, 10번째까지는 1ms, 100번째까지는 5ms, 200번째까지는 10ms, 그 뒤로는 100ms씩 쉽니다(logAndBackoffImpl(), express 경로의 호출은 plan_executor_express.cpp, 일반적인 재시도 루프는 writeConflictRetry()).\nA가 트랜잭션에서 _id: 1을 고쳐 두고, B가 트랜잭션 없이 같은 도큐먼트를 고칩니다.\nrs0 [direct: primary] test\u003e db.serverStatus().metrics.operation.writeConflicts Long(\u0026#39;2\u0026#39;) A rs0 [direct: primary] test\u003e sA.startTransaction(); cA.updateOne({_id: 1}, {$inc: {balance: 1000}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } B rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 1}, {$inc: {balance: 1}}) B의 명령이 돌아오지 않습니다. 3초 뒤 다른 연결에서 봅니다.\nrs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;admin\u0026#34;).aggregate([{$currentOp: {}}, {$match: {ns: \u0026#34;test.acct\u0026#34;, op: \u0026#34;update\u0026#34;}}, {$project: {_id: 0, op: 1, secs_running: 1, writeConflicts: 1, numYields: 1, waitingForLock: 1}}]) [ { secs_running: Long(\u0026#39;2\u0026#39;), op: \u0026#39;update\u0026#39;, writeConflicts: Long(\u0026#39;208\u0026#39;), numYields: 207, waitingForLock: false } ] rs0 [direct: primary] test\u003e db.serverStatus().metrics.operation.writeConflicts Long(\u0026#39;2\u0026#39;) A가 커밋하자 B의 update가 끝났습니다.\nA rs0 [direct: primary] test\u003e sA.commitTransaction() B rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 1}, {$inc: {balance: 1}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] test\u003e db.acct.findOne({_id: 1}) { _id: 1, balance: 1201 } rs0 [direct: primary] test\u003e db.serverStatus().metrics.operation.writeConflicts Long(\u0026#39;213\u0026#39;) $ jq -c \u0026#39;select(.msg == \u0026#34;Slow query\u0026#34; and .attr.ns == \u0026#34;test.acct\u0026#34; and .attr.writeConflicts) | {msg, type: .attr.type, planSummary: .attr.planSummary, writeConflicts: .attr.writeConflicts, numYields: .attr.numYields, durationMillis: .attr.durationMillis}\u0026#39; /data/mongod.log {\u0026#34;msg\u0026#34;:\u0026#34;Slow query\u0026#34;,\u0026#34;type\u0026#34;:\u0026#34;update\u0026#34;,\u0026#34;planSummary\u0026#34;:\u0026#34;EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE\u0026#34;,\u0026#34;writeConflicts\u0026#34;:211,\u0026#34;numYields\u0026#34;:211,\u0026#34;durationMillis\u0026#34;:3012} 겉으로는 B가 A를 기다린 것처럼 보이지만, waitingForLock: false입니다. 잠금을 기다린 것이 아니라 2초 동안 208번 충돌하고 다시 시도하고 있었습니다(writeConflicts: 208). 매번 yield하고 스냅샷을 새로 잡았으므로 numYields도 거의 같이 늘었습니다. A가 커밋한 뒤의 시도에서는 A의 버전이 보이므로 그 위에 +1을 했습니다. 결과는 1201입니다(200 + 1000 + 1). 트랜잭션끼리와 달리 B의 변경이 사라지지 않았습니다. serverStatus의 metrics.operation.writeConflicts는 B가 재시도하는 동안에는 2 그대로였다가, 끝난 뒤 213이 되었습니다. 이 카운터는 연산이 끝날 때 그 연산의 충돌 횟수를 더합니다(curop_metrics.cpp). 진행 중인 충돌은 $currentOp으로 봐야 합니다. slow query 로그에도 writeConflicts: 211이 남았습니다. $currentOp을 본 뒤 A가 커밋하기까지 몇 번 더 충돌했습니다. 운영에서는: 느린 단일 쓰기의 원인이 트랜잭션일 수 있다 트랜잭션 밖의 단순한 updateOne이 수 초씩 걸리고 slow query 로그에 writeConflicts가 수백으로 찍힌다면, 같은 도큐먼트를 잡고 있는 열린 트랜잭션이나 같은 도큐먼트(카운터, 상태 도큐먼트 등)에 몰리는 쓰기를 의심합니다. $currentOp에서 writeConflicts가 빠르게 늘고 waitingForLock은 false인 연산이 이 경우입니다. 반대로 트랜잭션을 쓰는 애플리케이션은 TransientTransactionError 레이블이 붙은 오류를 받으면 트랜잭션 전체를 다시 실행해야 합니다. 드라이버의 withTransaction()(callback API)은 이 재시도를 대신 해 줍니다.\nhistory store: 옛 버전은 페이지를 쓸 때 옮겨진다 update chain은 메모리에만 있습니다. 페이지를 디스크에 쓰는 일(reconciliation)은 eviction과 체크포인트 때 일어나는데(1편, 2편), 이때 WiredTiger는 행마다 디스크 이미지에 쓸 버전 하나를 고르고(__rec_upd_select()), 그보다 옛 버전을 남길지 판단합니다(__rec_need_save_upd()). 고른 버전이 이미 모두에게 보이는(globally visible) 버전이면 그 뒤의 옛 버전은 아무도 볼 일이 없으니 버립니다(rec_visibility.c). 아직 누군가 옛 버전을 볼 수 있으면 그 버전들을 history store에 넣습니다(__rec_hs_wrapup(), __wt_hs_insert_updates()).\nhistory store는 dbPath의 WiredTigerHS.wt 파일 하나에 담긴 WiredTiger 테이블입니다(meta.h). 모든 컬렉션과 인덱스의 옛 버전이 이 테이블 하나에 모입니다. 읽는 쪽이 체인과 디스크 이미지에서 보이는 버전을 찾지 못하면 여기를 찾습니다(txn_inline.h).\n\u0026ldquo;모두에게 보인다\u0026quot;는 두 조건을 모두 만족하는 것입니다(__wt_txn_visible_all()).\n버전을 만든 트랜잭션 ID가 전역 oldest_id보다 작다. 즉 그 버전을 못 보는 스냅샷이 남아 있지 않다. 버전의 timestamp가 pinned timestamp 이하다. pinned timestamp는 oldest timestamp와, 실행 중인 트랜잭션들의 read timestamp 중 가장 작은 값입니다(__wti_txn_get_pinned_timestamp()). oldest timestamp는 MongoDB가 WiredTiger에 \u0026ldquo;이보다 옛 시점으로는 읽지 않겠다\u0026quot;고 알려 주는 값입니다. 레플리카셋에서 MongoDB는 stable timestamp(majority로 커밋된 지점, 체크포인트의 기준)가 움직일 때마다(setStableTimestamp(), wiredtiger_kv_engine.cpp) oldest timestamp를 stable보다 minSnapshotHistoryWindowInSeconds초 뒤로 맞춥니다(setOldestTimestampFromStable(), 계산은 _calculateHistoryLagFromStableTimestamp()). 이 파라미터의 기본값은 300초입니다(snapshot_window_options.idl). stable timestamp가 무엇이고 어떻게 움직이는지는 5편과 8편에서 다룹니다.\n정리하면 history store에 옛 버전을 붙잡아 두는 것은 둘입니다.\n붙잡는 것 효과 minSnapshotHistoryWindowInSeconds(기본 300초) 최근 300초 안에 생긴 옛 버전은 트랜잭션이 없어도 남김. atClusterTime으로 과거를 읽을 수 있게 하려는 것 오래 열린 스냅샷(트랜잭션, snapshot 읽기) 그 스냅샷의 트랜잭션 ID와 read timestamp가 oldest_id와 pinned timestamp를 붙잡아, 그 뒤의 옛 버전을 모두 남김 기본 300초 창에서는 트랜잭션이 없어도 옛 버전이 쌓인다 200바이트짜리 도큐먼트 10만 개를 넣고, history store 지표를 보는 함수 hs()를 만듭니다. onDiskBytes는 history store 파일 크기, insertCalls는 history store에 버전을 넣은 누적 횟수입니다. idsPinned와 tsPinnedByReader는 뒤에서 봅니다. fsync는 체크포인트를 바로 하게 해서 reconciliation을 일으키려고 불렀습니다.\nrs0 [direct: primary] test\u003e db.adminCommand({getParameter: 1, minSnapshotHistoryWindowInSeconds: 1}) { minSnapshotHistoryWindowInSeconds: 300, ok: 1, ... } rs0 [direct: primary] test\u003e db.bulk.insertMany(Array.from({length: 100000}, (_, i) =\u0026gt; ({_id: i, balance: 100, pad: \u0026#34;x\u0026#34;.repeat(200)}))).acknowledged true rs0 [direct: primary] test\u003e db.bulk.countDocuments() 100000 rs0 [direct: primary] test\u003e hs = () =\u0026gt; { const w = db.serverStatus().wiredTiger; return {onDiskBytes: w.cache[\u0026#34;history store table on-disk size\u0026#34;], insertCalls: w.cache[\u0026#34;history store table insert calls\u0026#34;], idsPinned: w.transaction[\u0026#34;transaction range of IDs currently pinned\u0026#34;], tsPinnedByReader: w.transaction[\u0026#34;transaction range of timestamps pinned by the oldest active read timestamp\u0026#34;]} } [Function: hs] rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 24576, insertCalls: 209, idsPinned: 0, tsPinnedByReader: 0 } rs0 [direct: primary] test\u003e db.bulk.updateMany({}, {$inc: {balance: 1}}).modifiedCount 100000 rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 3420160, insertCalls: 100209, idsPinned: 0, tsPinnedByReader: 0 } $ ls -l /data/db/WiredTigerHS.wt -rw------- 1 mongod mongod 3420160 Sep 27 12:56 /data/db/WiredTigerHS.wt 열려 있는 트랜잭션은 없는데도, 10만 개를 한 번씩 고치고 체크포인트하자 insertCalls가 정확히 100000 늘었습니다. 도큐먼트마다 고치기 전 버전(balance: 100)이 history store로 갔습니다. history store 파일은 24576바이트에서 3420160바이트(약 3.3MB)가 되었습니다. onDiskBytes와 ls의 크기가 같습니다. 이 옛 버전들은 방금 생겼으므로 oldest timestamp(stable보다 300초 전)보다 새롭습니다. 그래서 \u0026ldquo;모두에게 보이는\u0026rdquo; 버전이 아니고, 체크포인트가 디스크 이미지에 새 버전을 쓰면서 옛 버전을 history store로 옮겼습니다. 창을 0으로 줄이면 쌓이지 않고, 스냅샷을 열어 두면 다시 쌓인다 minSnapshotHistoryWindowInSeconds를 0으로 줄여 oldest timestamp가 stable timestamp를 바로 따라가게 한 뒤 같은 update를 합니다.\nrs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, minSnapshotHistoryWindowInSeconds: 0}).was 300 rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 3420160, insertCalls: 100209, idsPinned: 1, tsPinnedByReader: 0 } rs0 [direct: primary] test\u003e db.bulk.updateMany({}, {$inc: {balance: 1}}).modifiedCount 100000 rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 3420160, insertCalls: 119691, idsPinned: 0, tsPinnedByReader: 0 } 이번에는 파일 크기가 3420160바이트 그대로이고, insertCalls도 19482만 늘어 도큐먼트 수의 5분의 1 정도입니다. 이제 세션 A가 snapshot 트랜잭션을 열어 _id: 7을 읽어 두고, 다른 연결에서 같은 update를 합니다.\nA rs0 [direct: primary] test\u003e sA.startTransaction({readConcern: {level: \u0026#34;snapshot\u0026#34;}}); sA.getDatabase(\u0026#34;test\u0026#34;).bulk.findOne({_id: 7}) { _id: 7, balance: 102, pad: \u0026#39;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\u0026#39; } rs0 [direct: primary] test\u003e db.bulk.updateMany({}, {$inc: {balance: 1}}).modifiedCount 100000 rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 6504448, insertCalls: 300214, idsPinned: 100006, tsPinnedByReader: Long(\u0026#39;17179869184\u0026#39;) } rs0 [direct: primary] test\u003e db.bulk.findOne({_id: 7}) { _id: 7, balance: 103, ... } A rs0 [direct: primary] test\u003e sA.getDatabase(\u0026#34;test\u0026#34;).bulk.findOne({_id: 7}) { _id: 7, balance: 102, ... } $ ls -l /data/db/WiredTigerHS.wt -rw------- 1 mongod mongod 6504448 Sep 27 12:56 /data/db/WiredTigerHS.wt 창이 0인데도 A의 스냅샷 하나 때문에 insertCalls가 180523 늘고 파일이 6504448바이트(약 6.2MB)로 커졌습니다. A가 볼 수 있는 balance: 102 버전을 버릴 수 없어서, 10만 개의 옛 버전이 다시 history store로 갔습니다. 늘어난 횟수가 도큐먼트 수보다 많은데, 이 지표는 옮긴 버전 수가 아니라 history store 테이블에 insert를 부른 횟수라서 둘이 꼭 같지는 않습니다. 이 실습에서는 그 내역까지 나눠 보지 않았습니다. idsPinned(transaction range of IDs currently pinned)는 전역 \u0026ldquo;다음 트랜잭션 ID\u0026quot;와 oldest_id의 차이입니다(__wt_txn_stats_update()). updateMany는 도큐먼트마다 WiredTiger 트랜잭션을 따로 쓰므로 트랜잭션 ID가 10만 개 넘게 지나갔는데, A의 스냅샷이 oldest_id를 붙잡아 차이가 100006이 되었습니다. tsPinnedByReader는 durable timestamp와 가장 오래된 read timestamp의 차이입니다(txn.c). timestamp는 위 32비트가 초, 아래 32비트가 증가값이라, 17179869184 = 4 × 2³²는 \u0026ldquo;4초\u0026quot;입니다. A의 read timestamp가 최신 쓰기보다 4초 뒤에 머물러 있습니다. 트랜잭션 밖에서는 103, A에서는 여전히 102가 보입니다. A가 트랜잭션을 끝낸 뒤 같은 update를 한 번 더 합니다.\nA rs0 [direct: primary] test\u003e sA.abortTransaction() rs0 [direct: primary] test\u003e hs() { onDiskBytes: 6504448, insertCalls: 300214, idsPinned: 0, tsPinnedByReader: 0 } rs0 [direct: primary] test\u003e db.bulk.updateMany({}, {$inc: {balance: 1}}).modifiedCount 100000 rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e hs() { onDiskBytes: 6504448, insertCalls: 300214, idsPinned: 0, tsPinnedByReader: 0 } 트랜잭션을 끝내자 두 pinned 지표가 곧바로 0이 되었고, 이번 update는 history store에 하나도 넣지 않았습니다(insertCalls 그대로). 파일 크기는 6504448바이트에서 줄지 않았습니다. history store에서 쓸모없어진 버전은 이후 reconciliation이 지우지만, 비워진 공간은 파일 안에서 다시 쓰일 뿐 파일이 곧바로 줄지는 않습니다. 세 번의 update를 나란히 놓으면 이렇습니다.\n조건 insertCalls 증가 파일 크기 창 300초, 트랜잭션 없음 100000 24576 → 3420160 창 0초, 트랜잭션 없음 19482 3420160 그대로 창 0초, snapshot 트랜잭션 열림 180523 3420160 → 6504448 창 0초, 트랜잭션 끝낸 뒤 0 6504448 그대로 wt로 본 history store: 데이터 파일에는 최신 버전만 있다 작은 컬렉션 ver에 도큐먼트 하나를 넣고 두 번 고쳐, 각 쓰기의 timestamp(operationTime)를 기록해 둡니다. 창은 다시 300초로 돌렸습니다.\nrs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, minSnapshotHistoryWindowInSeconds: 300}).was 0 rs0 [direct: primary] test\u003e db.runCommand({insert: \u0026#34;ver\u0026#34;, documents: [{_id: 1, v: \u0026#34;v1-first\u0026#34;}]}).operationTime Timestamp({ t: 1790513815, i: 2 }) rs0 [direct: primary] test\u003e db.runCommand({update: \u0026#34;ver\u0026#34;, updates: [{q: {_id: 1}, u: {$set: {v: \u0026#34;v2-second\u0026#34;}}}]}).operationTime Timestamp({ t: 1790513815, i: 3 }) rs0 [direct: primary] test\u003e db.runCommand({update: \u0026#34;ver\u0026#34;, updates: [{q: {_id: 1}, u: {$set: {v: \u0026#34;v3-third\u0026#34;}}}]}).operationTime Timestamp({ t: 1790513815, i: 4 }) rs0 [direct: primary] test\u003e db.adminCommand({fsync: 1}).ok 1 rs0 [direct: primary] test\u003e db.ver.stats().wiredTiger.uri statistics:table:collection-42-10313453530085067927 mongod를 정상 종료하고 wt 유틸리티로 컬렉션 파일과 history store 파일을 그대로 읽습니다. dump -p는 key와 value를 테이블 형식(key_format, value_format)대로 풀어서 보여 줍니다.\n$ URI=$(mongosh --quiet --eval \u0026#39;db.ver.stats().wiredTiger.uri.replace(\u0026#34;statistics:table:\u0026#34;, \u0026#34;\u0026#34;)\u0026#39;) $ mongod --dbpath /data/db --shutdown | grep -v \u0026#39;^{\u0026#39; $ ID=$(wt -h /data/db list -v file:$URI.wt | tr \u0026#39;,\u0026#39; \u0026#39;\\n\u0026#39; | grep \u0026#39;^id=\u0026#39; | cut -d= -f2) $ echo \u0026#34;file:$URI.wt id=$ID\u0026#34; $ wt -h /data/db list -v file:WiredTigerHS.wt | tr \u0026#39;,\u0026#39; \u0026#39;\\n\u0026#39; | grep -E \u0026#39;^(key|value)_format=\u0026#39; $ wt -h /data/db dump -p file:$URI.wt | sed -n \u0026#39;/^Data/,$p\u0026#39; $ wt -h /data/db dump -p file:WiredTigerHS.wt | sed -n \u0026#39;/^Data/,$p\u0026#39; | grep -A1 \u0026#34;^$ID,\u0026#34; $ for ts in $(wt -h /data/db dump -p file:WiredTigerHS.wt | grep \u0026#34;^$ID,\u0026#34; | cut -d, -f3); do echo \u0026#34;$ts = Timestamp($((ts \u0026gt;\u0026gt; 32)), $((ts \u0026amp; 0xffffffff)))\u0026#34;; done Killing process with pid: 32 file:collection-42-10313453530085067927.wt id=46 key_format=IuQQ value_format=QQQu Data 1 \\1e\\00\\00\\00\\10_id\\00\\01\\00\\00\\00\\02v\\00\\09\\00\\00\\00v3-third\\00\\00 46,\\81,7690198278461194242,0 7690198278461194243,7690198278461194242,3,\\1e\\00\\00\\00\\10_id\\00\\01\\00\\00\\00\\02v\\00\\09\\00\\00\\00v1-first\\00\\00 46,\\81,7690198278461194243,0 7690198278461194244,7690198278461194243,3,\\1f\\00\\00\\00\\10_id\\00\\01\\00\\00\\00\\02v\\00\\0a\\00\\00\\00v2-second\\00\\00 7690198278461194242 = Timestamp(1790513815, 2) 7690198278461194243 = Timestamp(1790513815, 3) 컬렉션 파일(collection-42-...wt)에는 key 1(RecordId)에 BSON 도큐먼트 하나, v3-third만 있습니다. 데이터 파일에는 최신 버전만 들어갑니다. history store의 key 형식은 IuQQ입니다. 순서대로 원래 테이블의 btree ID(메타데이터의 id=46), 원래 key(RecordId 1을 WiredTiger 방식으로 패킹한 \\81), 이 버전의 시작 timestamp, 같은 timestamp 안의 순번입니다. value 형식 QQQu는 끝 timestamp, durable timestamp, 버전 종류(3 = WT_UPDATE_STANDARD, 전체 값), 값 자체입니다. 행이 둘이고 각각 v1-first, v2-second입니다. 시작 timestamp를 풀면 Timestamp(1790513815, 2), Timestamp(1790513815, 3)으로, 앞에서 기록한 insert와 첫 update의 operationTime과 같습니다. v1-first의 끝 timestamp(\u0026hellip;243)는 v2-second의 시작과 같고, v2-second의 끝(\u0026hellip;244)은 v3-third를 쓴 Timestamp(1790513815, 4)입니다. 앞에서 말한 time window가 이것입니다. 버전마다 \u0026ldquo;언제부터 언제까지 유효했는가\u0026quot;를 적어 두어, 과거의 어느 read timestamp로 읽든 맞는 버전 하나를 고를 수 있습니다. 과거 시점 읽기와 SnapshotTooOld history store에 옛 버전이 있으니, read timestamp를 과거로 주면 과거 상태를 읽을 수 있습니다. readConcern snapshot에 atClusterTime을 주면 됩니다. 단 그 시점이 oldest timestamp보다 옛날이면 WiredTiger가 read timestamp 설정을 거부하고, MongoDB는 SnapshotTooOld를 돌려줍니다(wiredtiger_recovery_unit.cpp).\natClusterTime으로 읽고, 창을 줄여 SnapshotTooOld 만들기 mongod를 다시 띄운 뒤, _id: 1을 0으로 바꾼 시점 T를 기록하고 다시 -1로 바꿉니다. 재시작했으므로 창은 기본값 300초입니다.\nrs0 [direct: primary] test\u003e T = db.runCommand({update: \u0026#34;acct\u0026#34;, updates: [{q: {_id: 1}, u: {$set: {balance: 0}}}]}).operationTime Timestamp({ t: 1790513819, i: 1 }) rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 1}, {$set: {balance: -1}}).modifiedCount 1 rs0 [direct: primary] test\u003e db.runCommand({find: \u0026#34;acct\u0026#34;, filter: {_id: 1}, readConcern: {level: \u0026#34;snapshot\u0026#34;, atClusterTime: T}}).cursor.firstBatch [ { _id: 1, balance: 0 } ] rs0 [direct: primary] test\u003e db.acct.findOne({_id: 1}) { _id: 1, balance: -1 } rs0 [direct: primary] test\u003e db.serverStatus().wiredTiger[\u0026#34;snapshot-window-settings\u0026#34;] { \u0026#39;total number of SnapshotTooOld errors\u0026#39;: Long(\u0026#39;0\u0026#39;), \u0026#39;minimum target snapshot window size in seconds\u0026#39;: 300, \u0026#39;current available snapshot window size in seconds\u0026#39;: 7, \u0026#39;latest majority snapshot timestamp available\u0026#39;: \u0026#39;Sep 27 12:56:59:2\u0026#39;, \u0026#39;oldest majority snapshot timestamp available\u0026#39;: \u0026#39;Sep 27 12:56:52:4404\u0026#39;, \u0026#39;pinned timestamp requests\u0026#39;: 0, \u0026#39;min pinned timestamp\u0026#39;: Timestamp({ t: 4294967295, i: 4294967295 }) } atClusterTime: T로 읽으면 이미 덮어쓴 값 0이 보입니다. 최신 값은 -1입니다. snapshot-window-settings에서 oldest majority snapshot timestamp available이 oldest timestamp, latest ...가 stable timestamp에 해당하는 값입니다. 목표 창은 300초지만 재시작한 지 얼마 되지 않아 지금 읽을 수 있는 창(current available snapshot window size)은 7초뿐입니다. oldest timestamp는 앞으로만 움직이므로, 재시작 전의 과거는 이미 읽을 수 없습니다. 창을 5초로 줄이고 12초 기다린 뒤, stable timestamp가 움직이도록 쓰기를 하나 하고 다시 T로 읽습니다.\nrs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, minSnapshotHistoryWindowInSeconds: 5}).was 300 rs0 [direct: primary] test\u003e sleep(12000) rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 2}, {$inc: {balance: 1}}).modifiedCount 1 rs0 [direct: primary] test\u003e sleep(1000) rs0 [direct: primary] test\u003e db.serverStatus().wiredTiger[\u0026#34;snapshot-window-settings\u0026#34;] { \u0026#39;total number of SnapshotTooOld errors\u0026#39;: Long(\u0026#39;0\u0026#39;), \u0026#39;minimum target snapshot window size in seconds\u0026#39;: 5, \u0026#39;current available snapshot window size in seconds\u0026#39;: 5, \u0026#39;latest majority snapshot timestamp available\u0026#39;: \u0026#39;Sep 27 12:57:11:1\u0026#39;, \u0026#39;oldest majority snapshot timestamp available\u0026#39;: \u0026#39;Sep 27 12:57:06:1\u0026#39;, \u0026#39;pinned timestamp requests\u0026#39;: 0, \u0026#39;min pinned timestamp\u0026#39;: Timestamp({ t: 4294967295, i: 4294967295 }) } rs0 [direct: primary] test\u003e db.runCommand({find: \u0026#34;acct\u0026#34;, filter: {_id: 1}, readConcern: {level: \u0026#34;snapshot\u0026#34;, atClusterTime: T}}) MongoServerError[SnapshotTooOld]: Read timestamp Timestamp(1790513819, 1) is older than the oldest available timestamp. rs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, minSnapshotHistoryWindowInSeconds: 300}).was 5 oldest timestamp가 stable보다 정확히 5초 뒤(12:57:06 ~ 12:57:11)로 올라갔고, T(12:56:59 무렵)는 그보다 옛날이 되었습니다. 같은 읽기가 SnapshotTooOld로 실패했습니다. 메시지대로 read timestamp가 \u0026ldquo;가장 오래된 읽을 수 있는 timestamp\u0026quot;보다 옛날입니다. 창을 줄인 뒤 곧바로가 아니라 쓰기를 한 뒤에 oldest가 움직인 것은, oldest timestamp를 stable timestamp가 바뀔 때 함께 계산하기 때문입니다. 한가한 레플리카셋에서도 primary는 주기적으로 no-op을 oplog에 쓰므로 결국 움직입니다. 창이 넓을수록 과거 읽기가 쉬워지지만, 그만큼 옛 버전을 history store에 더 오래 두어야 합니다. 기본 300초는 샤드 클러스터에서 여러 샤드가 같은 cluster time으로 읽는 경우 등을 위한 여유입니다.\ntransactionLifetimeLimitSeconds: 60초가 지나면 서버가 abort한다 앞에서 본 것처럼 열린 트랜잭션 하나가 그 뒤의 옛 버전을 전부 붙잡습니다. 트랜잭션이 끝나지 않으면 history store와 캐시가 한없이 커질 수 있으므로, MongoDB는 트랜잭션에 수명을 둡니다. transactionLifetimeLimitSeconds의 기본값은 60초이고, 설명에 \u0026ldquo;스토리지 캐시 압박이 시스템을 멈추게 하는 것을 막으려고\u0026quot;라고 적혀 있습니다(transaction_participant.idl). 수명을 넘긴 트랜잭션은 백그라운드 작업 abortExpiredTransactions가 찾아 abort합니다(killAllExpiredTransactions()). 이 작업은 수명의 절반마다(최소 1초, 최대 60초) 돕니다(getPeriod()). 그래서 실제로 abort되는 시점은 수명보다 최대 그 주기만큼 늦을 수 있습니다.\n수명을 5초로 줄여 트랜잭션이 abort되는 모습 rs0 [direct: primary] test\u003e db.adminCommand({getParameter: 1, transactionLifetimeLimitSeconds: 1}) { transactionLifetimeLimitSeconds: 60, ok: 1, ... } rs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, transactionLifetimeLimitSeconds: 5}).was 60 C rs0 [direct: primary] test\u003e sC = db.getMongo().startSession(); cC = sC.getDatabase(\u0026#34;test\u0026#34;).acct; test.acct C rs0 [direct: primary] test\u003e sC.startTransaction(); cC.insertOne({_id: 3, balance: 1}) { acknowledged: true, insertedId: 3 } rs0 [direct: primary] test\u003e db.serverStatus().transactions.currentOpen Long(\u0026#39;1\u0026#39;) C rs0 [direct: primary] test\u003e sleep(8000) C rs0 [direct: primary] test\u003e cC.insertOne({_id: 4, balance: 1}) MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 1 } has been aborted. C rs0 [direct: primary] test\u003e try { cC.insertOne({_id: 5, balance: 1}) } catch (e) { printjson({codeName: e.codeName, errorLabels: e.errorLabels}) } { codeName: \u0026#39;NoSuchTransaction\u0026#39;, errorLabels: [ \u0026#39;TransientTransactionError\u0026#39; ] } C rs0 [direct: primary] test\u003e sC.commitTransaction() MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 1 } has been aborted. rs0 [direct: primary] test\u003e db.acct.find({_id: {$gte: 3}}).toArray() [] rs0 [direct: primary] test\u003e db.serverStatus().metrics.abortExpiredTransactions { passes: Long(\u0026#39;8\u0026#39;), successfulKills: Long(\u0026#39;1\u0026#39;), timedOutKills: Long(\u0026#39;0\u0026#39;) } rs0 [direct: primary] test\u003e db.adminCommand({setParameter: 1, transactionLifetimeLimitSeconds: 60}).was 5 $ jq -c \u0026#39;select(.id == 20707) | {msg, txnNumber: .attr.txnNumberAndRetryCounter.txnNumber}\u0026#39; /data/mongod.log {\u0026#34;msg\u0026#34;:\u0026#34;Aborting transaction because it has been running for longer than \u0026#39;transactionLifetimeLimitSeconds\u0026#39;\u0026#34;,\u0026#34;txnNumber\u0026#34;:1} 세션 C가 트랜잭션에서 insert를 하고 8초 동안 아무것도 하지 않았습니다. 그 사이 서버가 트랜잭션을 abort했고, mongod 로그에 Aborting transaction because it has been running for longer than 'transactionLifetimeLimitSeconds'가 남았습니다. 세션 C는 abort된 줄 모르고 있다가 다음 명령에서 NoSuchTransaction(has been aborted)을 받습니다. 이 오류에도 TransientTransactionError 레이블이 붙습니다. 트랜잭션을 처음부터 다시 하라는 뜻입니다. 먼저 넣은 _id: 3도 abort와 함께 사라졌습니다. abortExpiredTransactions.successfulKills가 1입니다. 수명은 트랜잭션이 명령을 실행하지 않고 쉬는 시간까지 포함한 전체 시간입니다. 앞의 $currentOp에서 본 expiryTime이 시작 시각 + 60초였던 것도 이 때문입니다. PostgreSQL MVCC와 비교 PostgreSQL WiredTiger(MongoDB) 옛 버전을 두는 곳 테이블 파일(힙) 안, 새 버전 옆 메모리의 update chain. 페이지를 쓸 때 필요한 것만 WiredTigerHS.wt 데이터 파일의 내용 살아 있는 버전과 dead tuple이 섞여 있음 최신 버전만 옛 버전 정리 VACUUM이 나중에 따로 페이지를 쓸 때 버리거나 history store로 옮김. history store는 쓸모없어진 뒤 정리 커밋 여부 pg_xact 조회, hint bit abort 시 체인의 txnid를 WT_TXN_ABORTED로 바꿈 스냅샷 xmin:xmax:xip snap_min, snap_max, 동시 트랜잭션 목록 + read timestamp 같은 행을 동시에 수정 앞 트랜잭션이 끝날 때까지 대기 즉시 WriteConflict. 트랜잭션 밖이면 서버가 재시도 긴 트랜잭션의 대가 테이블 bloat, VACUUM 지연 캐시 압박, history store 증가. 60초 제한으로 abort 긴 트랜잭션이 옛 버전 정리를 막는다는 점은 같습니다. 다른 점은 그 비용이 PostgreSQL에서는 테이블 파일이 커지는 것(bloat)으로, WiredTiger에서는 캐시와 history store가 커지는 것으로 나타난다는 것입니다. 그리고 MongoDB는 트랜잭션에 기본 60초의 수명을 두어 이 비용에 상한을 둡니다.\n운영에서는 이렇게 나타납니다 긴 트랜잭션과 긴 snapshot 쿼리가 캐시를 압박한다 열린 스냅샷은 그 뒤에 고쳐진 모든 도큐먼트의 옛 버전을 붙잡습니다. 이 옛 버전은 먼저 캐시의 update chain에 쌓이고, 페이지를 내보낼 때는 history store로 옮겨야 하므로 eviction이 느려집니다. 쓰기가 많은 시스템에서 오래 열린 트랜잭션이나 readConcern: \u0026quot;snapshot\u0026quot;으로 오래 도는 집계가 있으면 캐시의 dirty 비율이 올라가고 애플리케이션 스레드가 eviction을 돕게 되어(1편) 전체 지연이 늘어납니다. 먼저 볼 지표는 다음과 같습니다.\nserverStatus().wiredTiger.transaction의 transaction range of IDs currently pinned, transaction range of timestamps pinned by the oldest active read timestamp: 값이 크게 벌어져 있으면 오래된 스냅샷이 있습니다. serverStatus().wiredTiger.cache의 history store table on-disk size, history store table insert calls, bytes belonging to the history store table in the cache $currentOp에 idleSessions: true를 주고 transaction.timeOpenMicros나 startWallClockTime이 오래된 트랜잭션을 찾습니다. active: false인데 오래 열린 트랜잭션은 애플리케이션이 트랜잭션을 연 채 다른 일을 하고 있을 가능성이 큽니다. 트랜잭션이 아니어도 minSnapshotHistoryWindowInSeconds 동안의 옛 버전은 남는다는 점도 기억해 둡니다. 전체 도큐먼트를 한 번에 고치는 배치 작업은 트랜잭션이 없어도 실습에서 본 것처럼 고친 양만큼 history store를 키웁니다. 이 파라미터를 줄이면 캐시와 디스크 부담은 줄지만 atClusterTime 읽기와 샤드 간 snapshot 읽기가 SnapshotTooOld로 실패하기 쉬워집니다.\nWriteConflict는 재시도로 대응한다 트랜잭션 안의 WriteConflict는 정상 동작의 일부입니다. 오류에 TransientTransactionError 레이블이 있으면 트랜잭션 전체를 다시 실행합니다. 드라이버의 withTransaction()을 쓰면 이 재시도와, 커밋 결과를 알 수 없을 때의 UnknownTransactionCommitResult 재시도를 대신 해 줍니다. 충돌이 잦다면 같은 도큐먼트에 쓰기가 몰리는 설계(전역 카운터 하나, 상태 도큐먼트 하나를 여러 요청이 고치는 구조)를 먼저 의심합니다. 트랜잭션 밖의 쓰기는 서버가 알아서 재시도하지만, 실습처럼 그 시간이 그대로 지연이 되고 slow query 로그의 writeConflicts로만 드러납니다.\n60초 제한과 트랜잭션 크기 has been aborted 또는 NoSuchTransaction이 간헐적으로 나오면, 트랜잭션 안에서 외부 호출이나 사용자 입력을 기다리느라 60초를 넘긴 것이 아닌지 봅니다. mongod 로그의 Aborting transaction because it has been running for longer than 'transactionLifetimeLimitSeconds'와 metrics.abortExpiredTransactions.successfulKills로 확인할 수 있습니다. transactionLifetimeLimitSeconds를 늘리는 것은 대개 답이 아닙니다. 그만큼 옛 버전을 오래 붙잡게 되기 때문입니다. 트랜잭션은 짧게, 도큐먼트 수를 적게(공식 문서는 한 트랜잭션에서 1,000개 이하의 도큐먼트를 고치기를 권합니다) 유지하고, 큰 일괄 작업은 여러 트랜잭션으로 나눕니다. 트랜잭션이 고친 버전은 커밋 전까지 캐시에 머물러야 하므로, 캐시에 비해 너무 큰 트랜잭션은 TransactionTooLargeForCache 같은 오류로 끝나기도 합니다(wiredtiger_util.cpp).\n정리 WiredTiger는 도큐먼트를 고칠 때마다 WT_UPDATE를 update chain 맨 앞에 붙입니다. 버전마다 만든 트랜잭션 ID와 commit timestamp가 적힙니다. 스냅샷은 snap_min, snap_max, 동시 트랜잭션 목록으로 이루어지고, MongoDB는 여기에 oplog 순서와 같은 read timestamp를 더합니다. 트랜잭션은 첫 명령에서 만든 스냅샷을 끝까지 씁니다. 내 스냅샷에서 보이지 않는 버전 위에 쓰려 하면 WiredTiger는 기다리지 않고 바로 실패하고, MongoDB는 이것을 WriteConflict로 바꿉니다. 트랜잭션 안이면 TransientTransactionError로 돌려주고, 트랜잭션 밖이면 서버가 뒤로 물러나며 재시도합니다. 페이지를 디스크에 쓸 때 데이터 파일에는 최신 버전만 쓰고, 아직 누군가 볼 수 있는 옛 버전은 WiredTigerHS.wt(history store)에 time window와 함께 넣습니다. 옛 버전을 붙잡는 것은 oldest timestamp(stable보다 minSnapshotHistoryWindowInSeconds, 기본 300초 뒤)와 오래 열린 스냅샷입니다. 창보다 옛 시점을 읽으면 SnapshotTooOld입니다. transactionLifetimeLimitSeconds(기본 60초)가 지난 트랜잭션은 서버가 abort합니다. 다음 글에서는 컬렉션 옆의 또 다른 WiredTiger 테이블인 인덱스의 구조와, 쿼리 플래너가 여러 인덱스 가운데 하나를 고르는 방식을 살펴봅니다.\n참고 자료 소스 코드 (r8.0.32, 커밋 8f1f561 기준)\nsrc/third_party/wiredtiger/src/include/btmem.h: WT_UPDATE src/third_party/wiredtiger/src/include/txn.h, src/third_party/wiredtiger/src/txn/txn.c: 트랜잭션, 스냅샷 src/third_party/wiredtiger/src/include/txn_inline.h: 가시성 판단, 쓰기 충돌 검사 src/third_party/wiredtiger/src/txn/txn_timestamp.c: oldest, stable, pinned timestamp src/third_party/wiredtiger/src/reconcile/rec_visibility.c, src/third_party/wiredtiger/src/history/hs_rec.c: reconciliation, history store src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp, wiredtiger_recovery_unit.cpp: MongoDB가 timestamp를 넘기는 곳 src/mongo/db/transaction/transaction_participant.cpp: 트랜잭션의 read source src/mongo/db/concurrency/exception_util.h: WriteConflict와 재시도 MongoDB 8.0 공식 문서\nTransactions, Production Considerations Read Concern \u0026ldquo;snapshot\u0026rdquo; minSnapshotHistoryWindowInSeconds, transactionLifetimeLimitSeconds ","permalink":"https://jinlog.dev/posts/mongodb/03-mvcc-and-snapshot/","summary":"동시에 읽고 쓸 때 WiredTiger는 어떤 버전을 보여 주고, 옛 버전은 어디에 두는가","title":"MongoDB 인터널 3: WiredTiger의 MVCC와 스냅샷"},{"content":"개요 배포한 것도 없고 쿼리도 그대로인데 어제까지 수십 ms에 끝나던 쿼리가 오늘은 타임아웃이 납니다. 이럴 때 바뀐 것은 대개 실행 계획입니다. PostgreSQL의 플래너는 통계로 행 수를 추정하고 그 추정에 맞는 계획을 고르는데(인터널 10편), 추정이 크게 빗나가면 몇 배에서 몇만 배까지 느린 계획을 고를 수 있습니다.\n이 글에서 답할 질문은 다음과 같습니다.\n쿼리가 느린 것인지, 무언가를 기다리는 것인지 어떻게 구분하는가 대량 적재 직후 통계가 데이터를 따라오지 못하면 무슨 일이 생기는가 prepared statement는 왜 여섯 번째 실행부터 느려질 수 있는가 psql에서 EXPLAIN하면 빠른데 애플리케이션에서는 느린 이유는 무엇인가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습에는 1편에서 다룬 pg_stat_statements와 auto_explain을 켜 둡니다. auto_explain의 기준은 실습 데이터에 맞춰 20ms로 낮췄습니다.\npostgres=# ALTER SYSTEM SET shared_preload_libraries = pg_stat_statements, auto_explain; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_line_prefix = \u0026#39;%m [%p] %q%u@%d/%a \u0026#39;; ALTER SYSTEM postgres=# ALTER SYSTEM SET log_min_duration_statement = \u0026#39;1s\u0026#39;; ALTER SYSTEM postgres=# ALTER SYSTEM SET auto_explain.log_min_duration = \u0026#39;20ms\u0026#39;; ALTER SYSTEM 먼저 확인할 것 기다리는가, 일하는가 느리다는 신고를 받으면 먼저 그 쿼리가 무언가를 기다리는지 봅니다. 대량 적재 직후 배치가 돌리는 조인 쿼리가 끝나지 않는 상황입니다.\npostgres=# SELECT pid, state, wait_event_type, wait_event, now() - query_start AS running, left(query, 40) AS query postgres-# FROM pg_stat_activity WHERE application_name = \u0026#39;batch\u0026#39;; pid | state | wait_event_type | wait_event | running | query -----+--------+-----------------+------------+-----------------+------------------------------------------ 236 | active | | | 00:00:02.706459 | SELECT count(*) FROM orders o JOIN shipm (1 row) state = active인데 wait_event_type이 비어 있습니다. 락도 I/O도 기다리지 않고 CPU에서 계속 일하고 있다는 뜻입니다. 같은 순간 OS에서 보면 그 backend가 CPU 하나를 다 쓰고 있습니다.\n$ top -b -n 1 -u postgres | sed -n \u0026#39;7,9p\u0026#39; PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 236 postgres 20 0 227632 51228 47976 R 100.0 0.2 0:02.90 postgres wait_event_type이 Lock이면 2편의 락 대기이고, IO면 디스크를 기다리는 것입니다. 여기처럼 아무것도 기다리지 않으면서 오래 도는 쿼리는 실행 계획을 의심합니다.\n평소와 무엇이 다른가 pg_stat_statements에서 그 쿼리의 실행 시간 분포를 봅니다. 평균만 보지 말고 최솟값, 최댓값, 표준편차를 같이 봅니다.\npostgres=# SELECT left(query, 60) AS query, calls, postgres-# round(min_exec_time::numeric, 2) AS min_ms, round(max_exec_time::numeric, 2) AS max_ms, postgres-# round(mean_exec_time::numeric, 2) AS mean_ms, round(stddev_exec_time::numeric, 2) AS stddev_ms postgres-# FROM pg_stat_statements WHERE query LIKE \u0026#39;%customer_id = $1%\u0026#39;; query | calls | min_ms | max_ms | mean_ms | stddev_ms --------------------------------------------------------------+-------+--------+--------+---------+----------- EXPLAIN (ANALYZE, BUFFERS) SELECT count(*), sum(amount) FROM | 1 | 19.73 | 19.73 | 19.73 | 0.00 PREPARE by_cust(int) AS SELECT count(*), sum(amount) FROM or | 7 | 6.72 | 34.44 | 17.34 | 9.22 (2 rows) 같은 쿼리인데 최소 6.72ms, 최대 34.44ms입니다. 입력값에 따라 처리할 행 수가 다를 수도 있지만, 실행 계획이 바뀌었을 때도 이렇게 분포가 벌어집니다. 어느 쪽인지는 그때의 실행 계획을 봐야 압니다.\n그때의 실행 계획 나중에 EXPLAIN을 돌리면 지금의 계획이 나옵니다. 느렸던 순간의 계획은 auto_explain이 로그에 남긴 것을 봅니다. 이 로그를 어떻게 읽는지는 아래 generic plan 사례에서 다룹니다.\n원인별 진단 대량 적재 직후: 통계가 데이터를 따라오지 못한다 주문 100만 행, 배송 100만 행이 있는 테이블을 ANALYZE해 두었습니다. 여기에 배치가 새 주문 10만 건과 배송 10만 건을 넣고, 곧바로 두 테이블을 조인합니다.\n재현을 위해 두 테이블의 autovacuum을 꺼 두었습니다. 실제로는 적재 직후 autoanalyze가 돌기 전의 몇십 초에서 몇 분 사이에, 또는 아래에서 보듯 autoanalyze 기준에 못 미치는 적재에서 같은 일이 생깁니다.\npostgres=# INSERT INTO orders SELECT g, 12 + g % 20000, \u0026#39;pending\u0026#39;, 1 FROM generate_series(1000001, 1100000) g; INSERT 0 100000 postgres=# INSERT INTO shipments (order_id, state) SELECT g, \u0026#39;ready\u0026#39; FROM generate_series(1000001, 1100000) g; INSERT 0 100000 통계가 얼마나 낡았는지는 pg_stat_user_tables에서 봅니다.\npostgres=# SELECT relname, n_live_tup, n_mod_since_analyze, last_analyze, last_autoanalyze postgres-# FROM pg_stat_user_tables WHERE relname IN (\u0026#39;orders\u0026#39;, \u0026#39;shipments\u0026#39;) ORDER BY relname; relname | n_live_tup | n_mod_since_analyze | last_analyze | last_autoanalyze -----------+------------+---------------------+-------------------------------+------------------ orders | 1100000 | 100000 | 2026-09-26 10:01:27.095322+00 | shipments | 1100000 | 100000 | 2026-09-26 10:01:27.164381+00 | (2 rows) n_mod_since_analyze는 마지막 ANALYZE 이후 바뀐 행 수입니다. 두 테이블 모두 10만 행이 바뀌었는데 통계는 그 전 상태입니다. 플래너가 쓰는 통계를 보면 이렇습니다.\npostgres=# SELECT tablename, attname, most_common_vals, most_common_freqs postgres-# FROM pg_stats WHERE attname IN (\u0026#39;status\u0026#39;, \u0026#39;state\u0026#39;) ORDER BY tablename; tablename | attname | most_common_vals | most_common_freqs -----------+---------+------------------+------------------- orders | status | {done} | {1} shipments | state | {delivered} | {1} (2 rows) 통계상 orders.status에는 done밖에 없고, shipments.state에는 delivered밖에 없습니다. 플래너 입장에서 status = 'pending'인 주문과 state = 'ready'인 배송은 거의 없습니다.\npostgres=# EXPLAIN SELECT count(*) FROM orders o JOIN shipments s ON s.order_id = o.id postgres-# WHERE o.status = \u0026#39;pending\u0026#39; AND s.state = \u0026#39;ready\u0026#39;; QUERY PLAN --------------------------------------------------------------------------------------------------- Aggregate (cost=8.90..8.91 rows=1 width=8) -\u0026gt; Nested Loop (cost=0.85..8.90 rows=1 width=0) Join Filter: (o.id = s.order_id) -\u0026gt; Index Scan using orders_status_idx on orders o (cost=0.43..4.45 rows=1 width=8) Index Cond: (status = \u0026#39;pending\u0026#39;::text) -\u0026gt; Index Scan using shipments_state_idx on shipments s (cost=0.43..4.45 rows=1 width=8) Index Cond: (state = \u0026#39;ready\u0026#39;::text) (7 rows) 양쪽 모두 1행으로 추정했습니다. 1행과 1행을 조인하는 가장 싼 방법은 nested loop입니다. 바깥쪽 한 행마다 안쪽을 한 번 읽으면 되니까요. 그런데 실제로는 양쪽이 10만 행씩이라, 바깥쪽 10만 행마다 안쪽 10만 행을 읽으며 Join Filter를 검사합니다. 100억 번입니다.\nA=# SELECT count(*) FROM orders o JOIN shipments s ON s.order_id = o.id A-# WHERE o.status = \u0026#39;pending\u0026#39; AND s.state = \u0026#39;ready\u0026#39;; # 세션 A: 앞 명령의 결과를 기다림 ERROR: canceling statement due to statement timeout Time: 10000.218 ms (00:10.000) 배치 세션에 걸어 둔 statement_timeout = '10s' 덕분에 10초에서 끊겼습니다. 이것이 없었다면 이 쿼리는 CPU 하나를 차지한 채 계속 돌았을 것입니다. 서버 로그에도 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 1 -E \u0026#39;statement timeout\u0026#39; 2026-09-26 10:01:42.785 UTC [236] postgres@postgres/batch ERROR: canceling statement due to statement timeout 2026-09-26 10:01:42.785 UTC [236] postgres@postgres/batch STATEMENT: SELECT count(*) FROM orders o JOIN shipments s ON s.order_id = o.id ANALYZE로 통계를 새로 모읍니다.\npostgres=# ANALYZE orders; ANALYZE postgres=# ANALYZE shipments; ANALYZE postgres=# SELECT tablename, attname, most_common_vals, most_common_freqs postgres-# FROM pg_stats WHERE attname IN (\u0026#39;status\u0026#39;, \u0026#39;state\u0026#39;) ORDER BY tablename; tablename | attname | most_common_vals | most_common_freqs -----------+---------+-------------------+-------------------------- orders | status | {done,pending} | {0.91033334,0.089666665} shipments | state | {delivered,ready} | {0.9112333,0.088766664} (2 rows) 이제 pending이 약 9%, ready가 약 9%라는 것을 압니다.\npostgres=# EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders o JOIN shipments s ON s.order_id = o.id postgres-# WHERE o.status = \u0026#39;pending\u0026#39; AND s.state = \u0026#39;ready\u0026#39;; QUERY PLAN --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Finalize Aggregate (cost=6533.59..6533.60 rows=1 width=8) (actual time=16.348..17.140 rows=1.00 loops=1) Buffers: shared hit=1590 -\u0026gt; Gather (cost=6533.37..6533.58 rows=2 width=8) (actual time=16.294..17.138 rows=2.00 loops=1) Workers Planned: 1 Workers Launched: 1 Buffers: shared hit=1590 -\u0026gt; Partial Aggregate (cost=5533.37..5533.38 rows=1 width=8) (actual time=15.436..15.437 rows=1.00 loops=2) Buffers: shared hit=1590 -\u0026gt; Parallel Hash Join (cost=3014.03..5520.50 rows=5150 width=0) (actual time=6.971..14.137 rows=50000.00 loops=2) Hash Cond: (s.order_id = o.id) Buffers: shared hit=1590 -\u0026gt; Parallel Index Scan using shipments_state_idx on shipments s (cost=0.43..2356.12 rows=57437 width=8) (actual time=0.020..2.557 rows=50000.00 loops=2) Index Cond: (state = \u0026#39;ready\u0026#39;::text) Index Searches: 1 Buffers: shared hit=798 -\u0026gt; Parallel Hash (cost=2288.37..2288.37 rows=58019 width=8) (actual time=6.609..6.609 rows=50000.00 loops=2) Buckets: 131072 Batches: 1 Memory Usage: 4960kB Buffers: shared hit=792 -\u0026gt; Parallel Index Scan using orders_status_idx on orders o (cost=0.43..2288.37 rows=58019 width=8) (actual time=0.014..3.291 rows=50000.00 loops=2) Index Cond: (status = \u0026#39;pending\u0026#39;::text) Index Searches: 1 Buffers: shared hit=792 Planning: Buffers: shared hit=269 Planning Time: 0.343 ms Execution Time: 17.181 ms (26 rows) 추정이 5만 8천 행 안팎으로 실제(한 worker당 5만 행)와 비슷해졌고, 계획은 hash join으로 바뀌었습니다. 같은 쿼리가 17ms에 끝납니다.\nA=# SELECT count(*) FROM orders o JOIN shipments s ON s.order_id = o.id A-# WHERE o.status = \u0026#39;pending\u0026#39; AND s.state = \u0026#39;ready\u0026#39;; count -------- 100000 (1 row) Time: 30.682 ms 10초 타임아웃과 30ms의 차이가 통계 하나에서 나왔습니다.\nautovacuum이 켜져 있어도 생긴다 autovacuum은 마지막 ANALYZE 이후 바뀐 행 수가 autovacuum_analyze_threshold(기본 50) + autovacuum_analyze_scale_factor(기본 0.1) × 행 수를 넘으면 테이블을 ANALYZE합니다(Automatic Vacuuming). 100만 행 테이블이면 100,050행이 바뀌어야 합니다. 이 실습에서 넣은 10만 행은 기준에 50행 모자랍니다. autovacuum이 켜져 있었어도 이 적재로는 autoanalyze가 돌지 않았다는 뜻입니다. 테이블이 클수록 기준도 커지므로, 큰 테이블에 \u0026ldquo;전체의 10% 미만이지만 새로운 값으로만 이루어진\u0026rdquo; 데이터가 들어오면 통계는 한참 동안 그 값을 모릅니다.\nprepared statement: 여섯 번째 실행부터 계획이 바뀐다 두 번째 사례는 \u0026ldquo;psql에서 돌리면 빠른데 애플리케이션에서만 느리다\u0026quot;는 신고로 시작하는 경우가 많습니다. 주문 100만 행의 고객 분포는 이렇게 만들었습니다.\n고객 1: 40만 행(40%) 고객 2~11: 3만 행씩(각 3%) 고객 12~20011: 15행씩 애플리케이션처럼 prepared statement로 고객별 합계를 구합니다. 고객 2~6을 차례로 조회합니다.\nB=# PREPARE by_cust(int) AS SELECT count(*), sum(amount) FROM orders WHERE customer_id = $1; PREPARE Time: 0.823 ms B=# EXECUTE by_cust(2); count | sum -------+---------- 30000 | 14936400 (1 row) Time: 26.800 ms ... B=# EXECUTE by_cust(6); count | sum -------+---------- 30000 | 14940000 (1 row) Time: 7.095 ms B=# SELECT name, generic_plans, custom_plans FROM pg_prepared_statements; name | generic_plans | custom_plans ---------+---------------+-------------- by_cust | 0 | 5 (1 row) Time: 1.384 ms 다섯 번 모두 custom plan으로 실행되었습니다. 여섯 번째로 고객 7을 조회하면 달라집니다.\nB=# EXECUTE by_cust(7); count | sum -------+---------- 30000 | 14940900 (1 row) Time: 19.671 ms B=# SELECT name, generic_plans, custom_plans FROM pg_prepared_statements; name | generic_plans | custom_plans ---------+---------------+-------------- by_cust | 1 | 5 (1 row) Time: 0.641 ms generic_plans가 1이 되었습니다. 이제 이 세션에서 by_cust는 generic plan을 씁니다. 그리고 대형 고객 1을 조회합니다.\nB=# EXECUTE by_cust(1); count | sum --------+----------- 400000 | 199198818 (1 row) Time: 34.710 ms B=# EXPLAIN (ANALYZE, BUFFERS) EXECUTE by_cust(1); QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=378.70..378.71 rows=1 width=16) (actual time=45.930..45.931 rows=1.00 loops=1) Buffers: shared hit=6709 -\u0026gt; Index Scan using orders_customer_id_idx on orders (cost=0.43..378.18 rows=104 width=4) (actual time=0.043..26.661 rows=400000.00 loops=1) Index Cond: (customer_id = $1) Index Searches: 1 Buffers: shared hit=6709 Planning Time: 0.023 ms Execution Time: 46.001 ms (8 rows) Time: 46.294 ms Index Cond: (customer_id = $1)처럼 값 대신 $1이 보이면 generic plan입니다. 플래너는 이 계획을 만들 때 $1이 무엇일지 모르므로 \u0026ldquo;평균적인 고객\u0026quot;을 가정해 104행으로 추정했습니다. 실제로는 40만 행을 인덱스로 하나씩 찾았습니다.\n같은 쿼리를 psql에서 상수로 넣어 확인하면 전혀 다른 계획이 나옵니다.\npostgres=# EXPLAIN (ANALYZE, BUFFERS) SELECT count(*), sum(amount) FROM orders WHERE customer_id = 1; QUERY PLAN --------------------------------------------------------------------------------------------------------------------------------------------- Finalize Aggregate (cost=14575.83..14575.84 rows=1 width=16) (actual time=18.435..19.389 rows=1.00 loops=1) Buffers: shared hit=7007 -\u0026gt; Gather (cost=14575.61..14575.82 rows=2 width=16) (actual time=18.378..19.385 rows=3.00 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=7007 -\u0026gt; Partial Aggregate (cost=13575.61..13575.62 rows=1 width=16) (actual time=17.176..17.176 rows=1.00 loops=3) Buffers: shared hit=7007 -\u0026gt; Parallel Seq Scan on orders (cost=0.00..12736.17 rows=167888 width=4) (actual time=0.008..12.608 rows=133333.33 loops=3) Filter: (customer_id = 1) Rows Removed by Filter: 233333 Buffers: shared hit=7007 Planning: Buffers: shared hit=103 Planning Time: 0.239 ms Execution Time: 19.441 ms (16 rows) 값이 1인 것을 아니까 프로세스당 16만 8천 행(프로세스 3개)을 추정하고 병렬 순차 스캔을 골랐습니다. 이 실습에서는 19ms 대 46ms, 두 배 남짓입니다. 행 40만 개를 인덱스로 하나씩 찾는 계획이라 데이터가 캐시에 없으면 차이는 더 벌어집니다. psql에서 상수를 넣어 EXPLAIN하면 애플리케이션이 실제로 쓰는 계획을 볼 수 없다는 것이 핵심입니다.\n왜 여섯 번째인가 prepared statement는 처음 다섯 번은 매번 입력값을 넣어 계획을 새로 세웁니다(custom plan). 그다음부터는 입력값과 상관없는 generic plan의 추정 비용을 custom plan들의 평균 비용과 비교해, generic plan이 크게 비싸지 않으면 generic plan을 계속 씁니다(PREPARE). 계획을 세우는 비용을 아끼려는 장치입니다.\n문제는 generic plan의 추정입니다. customer_id = $1의 행 수는 값을 모르므로 대략 \u0026ldquo;전체 행 수 ÷ 서로 다른 값의 수\u0026quot;로 추정합니다. 고객이 2만 명 넘게 있으니 104행이 나왔고, 이는 고객 2~6의 custom plan(각 3만 행)보다 싸 보였습니다. 그래서 generic plan이 채택되었고, 대형 고객 1에게는 최악의 계획이 되었습니다. 분포가 치우친 컬럼에서 이런 일이 생깁니다.\n애플리케이션 드라이버가 서버 쪽 prepared statement를 쓰면 같은 일이 생깁니다. 드라이버가 prepared statement를 쓰는지, 몇 번째 실행부터 쓰는지는 드라이버 설정에 따라 다르므로 확인해 둡니다.\nauto_explain 로그에서 구분하기 auto_explain은 prepared statement의 계획을 입력값과 함께 남깁니다.\n2026-09-26 10:01:51.084 UTC [447] postgres@postgres/app LOG: duration: 25.169 ms plan: Query Text: PREPARE by_cust(int) AS SELECT count(*), sum(amount) FROM orders WHERE customer_id = $1; Query Parameters: $1 = \u0026#39;2\u0026#39; Aggregate (cost=7971.08..7971.09 rows=1 width=16) -\u0026gt; Bitmap Heap Scan on orders (cost=398.78..7809.56 rows=32303 width=4) Recheck Cond: (customer_id = 2) -\u0026gt; Bitmap Index Scan on orders_customer_id_idx (cost=0.00..390.70 rows=32303 width=0) Index Cond: (customer_id = 2) 2026-09-26 10:02:00.107 UTC [447] postgres@postgres/app LOG: duration: 34.445 ms plan: Query Text: PREPARE by_cust(int) AS SELECT count(*), sum(amount) FROM orders WHERE customer_id = $1; Query Parameters: $1 = \u0026#39;1\u0026#39; Aggregate (cost=378.70..378.71 rows=1 width=16) -\u0026gt; Index Scan using orders_customer_id_idx on orders (cost=0.43..378.18 rows=104 width=4) Index Cond: (customer_id = $1) 2026-09-26 10:02:03.120 UTC [447] postgres@postgres/app LOG: duration: 45.933 ms plan: Query Text: PREPARE by_cust(int) AS SELECT count(*), sum(amount) FROM orders WHERE customer_id = $1; Query Parameters: $1 = \u0026#39;1\u0026#39; Aggregate (cost=378.70..378.71 rows=1 width=16) -\u0026gt; Index Scan using orders_customer_id_idx on orders (cost=0.43..378.18 rows=104 width=4) Index Cond: (customer_id = $1) 첫 번째($1 = '2')는 custom plan입니다. 조건에 customer_id = 2처럼 값이 박혀 있고, 3만 2천 행을 추정해 bitmap scan을 골랐습니다. 나머지 둘($1 = '1')은 generic plan입니다. 조건이 customer_id = $1이고 추정은 104행입니다. 느린 쿼리 로그에서 계획의 조건에 $1이 보이고 추정 행 수가 실제와 크게 다르면 generic plan을 의심합니다. Query Parameters로 어떤 값에서 느렸는지도 알 수 있습니다.\n조치 통계: ANALYZE 통계가 낡은 것이 원인이면 해당 테이블을 ANALYZE합니다. ANALYZE는 테이블 전체가 아니라 표본만 읽고, 읽기와 쓰기를 막지 않습니다.\n근본적으로는 이렇게 합니다.\n대량 적재를 하는 배치는 적재 직후 스스로 ANALYZE를 실행합니다. autoanalyze를 기다리지 않습니다. 큰 테이블은 테이블 단위로 autovacuum_analyze_scale_factor를 낮추거나 autovacuum_analyze_threshold를 조정합니다(ALTER TABLE ... SET (...)). 이 조정은 5편에서 VACUUM 쪽 설정과 함께 다룹니다. 계획의 폭주를 끊는 안전장치: statement_timeout 통계를 아무리 관리해도 추정은 빗나갈 수 있습니다. 위 실습에서 서버를 지킨 것은 statement_timeout이었습니다. 배치나 애플리케이션 계정에 ALTER ROLE ... SET statement_timeout으로 그 작업이 넘을 리 없는 시간을 걸어 두면, 잘못된 계획이 CPU를 몇 시간씩 차지하는 일을 막을 수 있습니다. 서버 전체에 거는 것은 긴 관리 작업까지 끊기므로 권하지 않습니다(statement_timeout).\ngeneric plan: plan_cache_mode plan_cache_mode = force_custom_plan이면 prepared statement도 매번 입력값으로 계획을 세웁니다.\nB=# SET plan_cache_mode = force_custom_plan; SET Time: 0.479 ms B=# EXECUTE by_cust(1); count | sum --------+----------- 400000 | 199198818 (1 row) Time: 14.115 ms B=# EXPLAIN (ANALYZE, BUFFERS) EXECUTE by_cust(1); QUERY PLAN --------------------------------------------------------------------------------------------------------------------------------------------- Finalize Aggregate (cost=14575.83..14575.84 rows=1 width=16) (actual time=18.611..19.685 rows=1.00 loops=1) Buffers: shared hit=7007 -\u0026gt; Gather (cost=14575.61..14575.82 rows=2 width=16) (actual time=18.553..19.681 rows=3.00 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=7007 -\u0026gt; Partial Aggregate (cost=13575.61..13575.62 rows=1 width=16) (actual time=17.297..17.297 rows=1.00 loops=3) Buffers: shared hit=7007 -\u0026gt; Parallel Seq Scan on orders (cost=0.00..12736.17 rows=167888 width=4) (actual time=0.008..12.643 rows=133333.33 loops=3) Filter: (customer_id = 1) Rows Removed by Filter: 233333 Buffers: shared hit=7007 Planning Time: 0.067 ms Execution Time: 19.704 ms (14 rows) Time: 19.936 ms B=# EXECUTE by_cust(2); count | sum -------+---------- 30000 | 14936400 (1 row) Time: 5.853 ms B=# SELECT name, generic_plans, custom_plans FROM pg_prepared_statements; name | generic_plans | custom_plans ---------+---------------+-------------- by_cust | 3 | 8 같은 세션, 같은 prepared statement인데 이제 고객 1에게는 병렬 순차 스캔을, 고객 2에게는 그에 맞는 계획을 씁니다. custom_plans만 늘어나고 generic_plans는 3에서 멈췄습니다.\n대가는 매 실행마다 계획을 세우는 비용입니다. 이 쿼리의 계획 시간은 0.067ms로 실행 시간에 비하면 무시할 만합니다. 계획이 복잡한 쿼리를 초당 수천 번 실행한다면 이 비용이 커질 수 있으므로, 서버 전체보다 문제가 되는 계정이나 DB에만 거는 편이 좋습니다.\nALTER ROLE app SET plan_cache_mode = force_custom_plan; -- 또는 DB 단위 ALTER DATABASE appdb SET plan_cache_mode = force_custom_plan; 새로 접속하는 세션부터 적용되므로, 커넥션 풀을 쓰는 애플리케이션은 커넥션을 새로 맺어야 반영됩니다.\n재발 방지 통계 신선도 감시: pg_stat_user_tables의 n_mod_since_analyze와 last_analyze, last_autoanalyze를 주기적으로 봅니다. 큰 테이블에서 n_mod_since_analyze가 크게 쌓인 채 오래 머물면 그 테이블의 쿼리 계획이 위험합니다. 실행 시간 분포 감시: pg_stat_statements의 max_exec_time과 stddev_exec_time이 평균보다 크게 튀는 쿼리를 찾습니다. 주기적으로 스냅샷을 떠 두면 \u0026ldquo;언제부터\u0026rdquo; 느려졌는지도 알 수 있습니다. auto_explain을 켜 둔다: 느렸던 순간의 계획은 여기에만 남습니다. 기준은 서비스의 느린 쿼리 기준과 맞춥니다. 배치의 적재 단계 끝에 ANALYZE, 배치와 애플리케이션 계정에 statement_timeout. 분포가 치우친 컬럼을 조건으로 쓰는 prepared statement는 generic plan 전환을 염두에 두고, 필요하면 그 계정에 plan_cache_mode를 겁니다. 정리 오래 도는 쿼리가 wait_event 없이 active면 CPU에서 일하는 중이고, 실행 계획을 의심합니다. 대량 적재 직후에는 통계가 새 값을 모릅니다. 1행으로 추정한 nested loop가 실제로는 10만 × 10만이 되어 canceling statement due to statement timeout으로 끝났고, ANALYZE 뒤에는 17ms에 끝났습니다. autoanalyze 기준은 행 수의 10%라서, 큰 테이블에는 기준에 못 미치는 적재로도 통계가 한참 낡을 수 있습니다. 적재한 쪽에서 ANALYZE합니다. prepared statement는 여섯 번째 실행부터 generic plan으로 바뀔 수 있고, 분포가 치우친 컬럼에서는 특정 값에 나쁜 계획이 됩니다. 계획의 조건에 $1이 보이면 generic plan입니다. psql에서 상수로 EXPLAIN하면 애플리케이션이 쓰는 계획이 아닐 수 있습니다. auto_explain 로그나 같은 세션의 EXPLAIN EXECUTE로 확인합니다. 참고 자료 Using EXPLAIN Statistics Used by the Planner Updating Planner Statistics PREPARE, plan_cache_mode statement_timeout auto_explain, pg_stat_statements PostgreSQL 인터널 10편 쿼리 처리 과정 ","permalink":"https://jinlog.dev/posts/postgresql-ops/03-sudden-slow-query/","summary":"코드도 쿼리도 그대로인데 어제까지 빠르던 쿼리가 느려졌다면, 무엇이 바뀐 것인가","title":"PostgreSQL 운영 3: 쿼리가 갑자기 느려졌다"},{"content":"개요 2편까지 오면 클러스터는 스스로 failover하고, 옛 primary도 pg_rewind로 알아서 합류합니다. 여기서 \u0026ldquo;이제 HA는 끝났다\u0026quot;고 생각하기 쉽지만, Patroni 클러스터의 사고는 대부분 그다음에 일어납니다. 자동화가 무엇을 기준으로 움직이는지 모르고 운영하면, 멀쩡한 서버를 read-only로 만들거나, 커밋된 데이터를 잃거나, primary가 두 대가 됩니다.\n이 글에서는 운영할 때 알아야 할 지점을 하나씩 재현합니다.\n설정은 어디서 바꿔야 하는가 (ALTER SYSTEM이 먹히지 않는 이유) 재시작과 점검은 어떤 순서로 해야 하는가 etcd가 멈추면 무슨 일이 생기고, failsafe_mode는 무엇을 막아 주는가 failover에서 데이터를 얼마나 잃을 수 있고, 동기 복제는 무엇을 바꾸는가 Patroni 프로세스가 죽으면 무엇이 위험한가 (watchdog) 오래 멈춘 replica의 slot과 reinit REST API 인증 기준 버전: Patroni 4.1.5, PostgreSQL 18.6, etcd 3.7.2, HAProxy 3.4.5, Rocky Linux 9.8. 2편에서 만든 클러스터(etcd 3대, pg1-pg3, HAProxy)에서 이어서 실행했습니다. 실습 중에 failover를 여러 번 일으켰기 때문에 절마다 leader와 timeline이 다릅니다. writer.log는 2편의 0.5초 간격 쓰기 스크립트 출력입니다. psql 결과 중 일부는 값만 출력하는 -At 옵션으로 받은 것을 읽기 쉽게 기본 표 형식으로 옮겼습니다.\n설정은 DCS에서 바꾼다 Patroni 클러스터의 PostgreSQL 설정은 네 군데에서 옵니다. 우선순위가 높은 것부터 적습니다.\n위치 바꾸는 방법 범위 postmaster 명령행 (--max_connections=...) DCS 설정. 직접 바꿀 수 없음 클러스터 전체 postgresql.auto.conf ALTER SYSTEM 그 노드만 postgresql.conf (Patroni가 생성) DCS 설정(patronictl edit-config), 노드의 patroni.yml 클러스터 전체, 또는 그 노드 postgresql.base.conf 직접 편집 그 노드만 1편에서 본 것처럼 Patroni는 max_connections, max_wal_senders, max_replication_slots, wal_level처럼 primary와 replica가 맞아야 하는 파라미터를 명령행으로 넘깁니다. 이 값들을 ALTER SYSTEM으로 바꾸면 어떻게 되는지 봅니다.\nALTER SYSTEM은 조용히 무시되거나, 한 노드에만 먹힌다 pg1=# ALTER SYSTEM SET max_connections = 200; ALTER SYSTEM pg1=# ALTER SYSTEM SET work_mem = \u0026#39;64MB\u0026#39;; ALTER SYSTEM $ cat /var/lib/pgsql/18/data/postgresql.auto.conf # Do not edit this file manually! # It will be overwritten by the ALTER SYSTEM command. max_connections = \u0026#39;200\u0026#39; work_mem = \u0026#39;64MB\u0026#39; $ patronictl -c /etc/patroni/patroni.yml restart pg-ha pg1 --force pg1=# SELECT name, setting, source FROM pg_settings WHERE name IN (\u0026#39;max_connections\u0026#39;, \u0026#39;work_mem\u0026#39;); name | setting | source -----------------+---------+-------------------- max_connections | 100 | command line work_mem | 65536 | configuration file (2 rows) pg2=# SHOW work_mem; work_mem ---------- 4MB (1 row) 재시작까지 했는데 max_connections는 100 그대로이고, 출처가 command line입니다. ALTER SYSTEM은 성공했다고 답하지만 명령행 값을 이길 수 없습니다. work_mem은 64MB(65536kB)로 바뀌었지만 pg1에서만입니다. pg2는 4MB입니다. postgresql.auto.conf는 복제되는 파일이 아니라서, failover로 pg2가 primary가 되면 설정이 바뀐 것처럼 보입니다. ALTER SYSTEM RESET ALL로 되돌리고, 클러스터 전체 설정은 DCS에서 바꿉니다.\npatronictl edit-config와 pending restart $ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -p max_connections=200 -p work_mem=64MB --- +++ @@ -3,12 +3,14 @@ postgresql: parameters: hot_standby: \u0026#39;on\u0026#39; + max_connections: 200 max_replication_slots: 10 max_wal_senders: 10 password_encryption: scram-sha-256 wal_keep_size: 128MB wal_level: replica wal_log_hints: \u0026#39;on\u0026#39; + work_mem: 64MB use_pg_rewind: true use_slots: true retry_timeout: 10 Configuration changed $ patronictl -c /etc/patroni/patroni.yml list + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+-----------------+---------------------------+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | Pending restart | Pending restart reason | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+-----------------+---------------------------+ | pg1 | 172.30.0.21 | Leader | running | 5 | | | | | * | max_connections: 100-\u0026gt;200 | | pg2 | 172.30.0.22 | Replica | streaming | 5 | 0/54F9D50 | 0 | 0/54F9D50 | 0 | * | max_connections: 100-\u0026gt;200 | | pg3 | 172.30.0.23 | Replica | streaming | 5 | 0/54F9D50 | 0 | 0/54F9D50 | 0 | * | max_connections: 100-\u0026gt;200 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+-----------------+---------------------------+ -p는 PostgreSQL 파라미터를, -s는 Patroni 설정(ttl, synchronous_mode 등)을 바꿉니다. 옵션 없이 실행하면 편집기로 YAML을 엽니다. 바뀐 내용을 diff로 보여 주고 DCS의 config 키에 씁니다. 각 노드의 Patroni가 다음 loop에서 이 값을 읽어 적용합니다. work_mem은 reload로 세 노드에 모두 반영되었고, 재시작이 필요한 max_connections는 Pending restart로 표시됩니다. $ patronictl -c /etc/patroni/patroni.yml restart pg-ha --pending --force Success: restart on member pg1 Success: restart on member pg2 Success: restart on member pg3 $ awk \u0026#39;$1\u0026gt;=\u0026#34;22:34:32\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:34:37\u0026#34;\u0026#39; /root/writer.log 22:34:32.145 172.30.0.21 INSERT 0 1 22:34:32.671 172.30.0.21 INSERT 0 1 22:34:33.189 172.30.0.21 INSERT 0 1 22:34:34.731 172.30.0.21 INSERT 0 1 22:34:35.255 172.30.0.21 INSERT 0 1 22:34:35.772 172.30.0.21 INSERT 0 1 patronictl restart는 failover 없이 그 자리에서 재시작합니다. primary pg1이 재시작하는 동안(22:34:33.2-34.7) 쓰기가 1.5초 끊겼습니다. 데이터가 적은 실습이라 짧았을 뿐이고, shared_buffers가 크거나 종료 체크포인트가 길면 이 시간은 늘어납니다. 쓰기 중단을 줄이려면 replica를 먼저 재시작하고, switchover한 뒤, 옛 primary를 재시작합니다.\n줄이는 변경은 Patroni가 순서를 지켜 준다 hot standby에는 \u0026ldquo;replica의 max_connections 같은 값이 primary보다 작으면 안 된다\u0026quot;는 규칙이 있습니다(Hot Standby Parameter Settings). 늘릴 때는 괜찮지만 줄일 때 replica를 먼저 재시작하면 규칙을 어기게 됩니다. max_connections를 50으로 줄이고 replica pg2만 먼저 재시작해 봅니다.\n$ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -p max_connections=50 $ patronictl -c /etc/patroni/patroni.yml restart pg-ha pg2 --pending --force Success: restart on member pg2 $ journalctl -u patroni -o cat --since \u0026#34;-10s\u0026#34; | grep max_connections # pg2 2026-09-25 22:35:16,387 INFO: max_connections value in pg_controldata: 200, in the global configuration: 50. pg_controldata value will be used. Setting \u0026#39;Pending restart\u0026#39; flag Patroni는 primary가 마지막으로 WAL에 기록한 값(pg_controldata의 200)을 보고, replica를 그 값으로 띄운 뒤 Pending restart를 남겨 둡니다. primary를 먼저 재시작해 값이 50으로 내려간 뒤 replica를 다시 재시작하면 됩니다.\n서비스 재시작은 replica부터 patronictl restart와 systemctl restart patroni는 전혀 다릅니다. 앞의 것은 PostgreSQL만 재시작하고 leader key를 지킵니다. 뒤의 것은 Patroni를 멈추므로, leader의 Patroni는 PostgreSQL을 내리고 leader key를 내려놓습니다. 그러면 failover가 일어납니다.\nREST API 인증을 켜려고(뒤에서 봅니다) 세 노드에서 차례로 systemctl restart patroni를 했더니 timeline이 두 번 올라갔습니다.\n$ for n in pg1 pg2 pg3; do ssh $n systemctl restart patroni; done # 실습에서는 docker exec로 차례로 실행 $ journalctl -u patroni -o cat --since 22:56:25 --until 22:56:50 | grep -E \u0026#34;INFO|WARN\u0026#34; | grep -vE \u0026#34;no action|connection\u0026#34; # pg2 2026-09-25 22:56:43,329 INFO: Got response from pg3 http://172.30.0.23:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, ... 2026-09-25 22:56:43,364 WARNING: Request failed to pg1: GET http://172.30.0.21:8008/patroni ... 2026-09-25 22:56:43,489 INFO: promoted self to leader by acquiring session lock 2026-09-25 22:56:43,821 WARNING: Cancelling long running task promote 2026-09-25 22:56:45,881 WARNING: Postgresql is not running. 2026-09-25 22:56:45,881 INFO: Lock owner: pg1; I am pg2 ... 2026-09-25 22:56:45,927 INFO: starting as a secondary $ patronictl -c /etc/patroni/patroni.yml history | tail -3 | 9 | 122658128 | no recovery target specified | 2026-09-25T22:47:39.425190+00:00 | pg1 | | 10 | 1644203792 | no recovery target specified | | | | 11 | 1644204024 | no recovery target specified | 2026-09-25T22:56:45.821684+00:00 | pg1 | pg1(leader)의 Patroni를 재시작하자 pg1이 key를 내려놓았고, pg2가 곧바로 promote했습니다(timeline 10 → 11). 바로 이어서 pg2의 Patroni를 재시작하자 promote하던 pg2가 작업을 취소하고 내려갔고(Cancelling long running task promote), 이번에는 다시 올라온 pg1이 promote했습니다(timeline 11 → 12). 2초 사이에 leader가 두 번 바뀌었고 그때마다 쓰기가 끊겼습니다. history의 timeline 10 줄에 시각과 leader가 비어 있는 것도 promote가 도중에 끊긴 흔적입니다. Patroni 자체를 재시작해야 할 때(버전 업그레이드, patroni.yml의 재시작이 필요한 항목 변경)는 replica를 하나씩 재시작하고, 멤버가 streaming으로 돌아온 것을 확인한 뒤, switchover로 leader를 옮기고, 마지막으로 옛 leader를 재시작합니다.\n유지보수는 pause로 PostgreSQL을 손으로 내려야 하는 작업이 있습니다. 이때 Patroni는 PostgreSQL이 죽었다고 보고 다시 띄우거나 failover합니다. 이것을 막는 것이 pause(maintenance mode) 입니다.\n$ patronictl -c /etc/patroni/patroni.yml pause pg-ha --wait \u0026#39;pause\u0026#39; request sent, waiting until it is recognized by all nodes Success: cluster management is paused $ su - postgres -c \u0026#34;/usr/pgsql-18/bin/pg_ctl -D /var/lib/pgsql/18/data stop -m fast\u0026#34; # primary pg1 waiting for server to shut down.... done server stopped $ patronictl -c /etc/patroni/patroni.yml list # 25초 뒤 + Cluster: pg-ha (7689601790135361659) ----+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Replica | stopped | | unknown | | unknown | | | pg2 | 172.30.0.22 | Replica | running | 5 | 0/5508380 | 0 | 0/5508380 | 0 | | pg3 | 172.30.0.23 | Replica | running | 5 | 0/5508380 | 0 | 0/5508380 | 0 | +--------+-------------+---------+---------+----+-------------+-----+------------+-----+ Maintenance mode: on $ journalctl -u patroni -o cat --since \u0026#34;-25s\u0026#34; | grep -E \u0026#34;INFO|WARN\u0026#34; | tail -4 # pg1 2026-09-25 22:36:15,742 INFO: PAUSE: postgres is not running 2026-09-25 22:36:25,701 WARNING: Postgresql is not running. 2026-09-25 22:36:25,701 INFO: Lock owner: None; I am pg1 2026-09-25 22:36:25,744 INFO: PAUSE: postgres is not running (pg2, pg3의 Pending restart 열은 줄였습니다.) primary를 내렸는데 아무도 재시작하지 않고, leader key가 비었는데도(Lock owner: None) 누구도 promote하지 않습니다. pg2의 로그는 PAUSE: no action. I am (pg2)만 반복합니다. 작업을 마치고 PostgreSQL을 직접 띄운 뒤 patronictl resume하면, Patroni가 다시 관리를 시작하고 pg1이 key를 되찾습니다.\npause 중에는 자동 failover가 없다는 점을 잊으면 안 됩니다. 작업이 끝나면 반드시 resume하고, 모니터링에서 Maintenance mode: on(REST API의 pause: true)이 오래 켜져 있으면 알리게 해 둡니다.\nDCS가 멈추면 클러스터 전체가 read-only가 된다 1편에서 DCS와 끊긴 primary는 스스로 강등한다는 것을 봤습니다. split-brain을 막는 장치이지만 끊긴 것이 primary 한 대가 아니라 DCS 자체라면 어떻게 될까요? etcd 3대 중 2대를 멈춰 쿼럼을 깹니다. PostgreSQL 3대는 모두 멀쩡합니다.\n$ systemctl stop etcd # etcd2, etcd3에서, 22:37:08.6 $ for n in pg1 pg2 pg3; do echo \u0026#34;$n in_recovery=$(ssh $n psql -h /run/postgresql -Atc \u0026#39;select pg_is_in_recovery()\u0026#39;)\u0026#34;; done # 30초 뒤 pg1 in_recovery=t pg2 in_recovery=t pg3 in_recovery=t $ curl -s \u0026#34;http://localhost:7000/;csv\u0026#34; | cut -d, -f1,2,18 | grep -E \u0026#34;^(primary|replicas),pg\u0026#34; primary,pg1,DOWN primary,pg2,DOWN primary,pg3,DOWN replicas,pg1,UP replicas,pg2,UP replicas,pg3,UP primary였던 pg1의 로그입니다.\n2026-09-25 22:37:17,876 INFO: Lock owner: pg1; I am pg1 2026-09-25 22:37:27,875 INFO: Lock owner: pg1; I am pg1 2026-09-25 22:37:37,893 ERROR: Error communicating with DCS 2026-09-25 22:37:37,894 INFO: demoting self because DCS is not accessible and I was a leader 2026-09-25 22:37:39,072 INFO: demoted self because DCS is not accessible and I was a leader 세 노드가 모두 standby가 되었고, HAProxy의 쓰기 포트에는 서버가 하나도 없습니다. 클라이언트는 22:37:37.8까지 쓰다가 그 뒤로 read-only transaction 오류와 연결 오류만 받았습니다. 데이터베이스 서버는 하나도 죽지 않았는데 쓰기 서비스 전체가 멈췄습니다. Patroni 입장에서는 옳은 판단입니다. DCS에 닿지 못하는 동안에는 다른 노드가 promote했는지 알 수 없으니, 두 primary가 생길 위험보다 쓰기 중단을 택합니다.\netcd를 되살리자 pg1이 다시 leader가 되었는데 이때도 promote를 거치므로 timeline이 하나 올라갔습니다(history의 timeline 5 → 6, 22:38:12).\nfailsafe_mode: 모든 멤버와 연락되면 primary를 유지한다 failsafe_mode는 이 상황을 완화합니다. DCS에 닿지 못해도 leader가 다른 모든 멤버와 REST API로 연락되고 그들이 자신을 leader로 인정하면 primary를 유지합니다. 모든 멤버가 \u0026ldquo;나는 promote하지 않겠다\u0026quot;고 답했다면 두 primary가 생길 수 없다는 논리입니다.\n$ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -s failsafe_mode=true $ etcdctl get /service/pg-ha/failsafe --print-value-only {\u0026#34;pg1\u0026#34;:\u0026#34;http://172.30.0.21:8008/patroni\u0026#34;,\u0026#34;pg2\u0026#34;:\u0026#34;http://172.30.0.22:8008/patroni\u0026#34;,\u0026#34;pg3\u0026#34;:\u0026#34;http://172.30.0.23:8008/patroni\u0026#34;} $ systemctl stop etcd # etcd2, etcd3에서, 22:38:50.3 DCS의 failsafe 키에는 leader가 연락해야 할 멤버 목록이 적힙니다. 같은 장애를 다시 일으킨 뒤의 pg1 로그입니다.\n2026-09-25 22:39:01,800 INFO: Lock owner: pg1; I am pg1 2026-09-25 22:39:11,821 INFO: Got response from pg2 http://172.30.0.22:8008/failsafe: Accepted 2026-09-25 22:39:11,821 INFO: Got response from pg3 http://172.30.0.23:8008/failsafe: Accepted 2026-09-25 22:39:11,822 INFO: continue to run as a leader because failsafe mode is enabled and all members are accessible 2026-09-25 22:39:21,894 INFO: Got response from pg2 http://172.30.0.22:8008/failsafe: Accepted 2026-09-25 22:39:21,894 INFO: Got response from pg3 http://172.30.0.23:8008/failsafe: Accepted 2026-09-25 22:39:21,895 INFO: continue to run as a leader because failsafe mode is enabled and all members are accessible 45초가 지나도 pg1은 primary(in_recovery=f)였고, 클라이언트의 쓰기도 끊기지 않았습니다. 하지만 조건은 \u0026ldquo;모든 멤버\u0026quot;입니다. DCS가 멈춘 채로 replica pg3가 죽으면 어떻게 되는지 봅니다.\n$ docker kill pg3 # 22:39:43.8 $ journalctl -u patroni -o cat --since \u0026#34;-27s\u0026#34; | grep -iE \u0026#34;failsafe|demot\u0026#34; | cut -c1-150 # pg1 2026-09-25 22:39:51,967 INFO: Got response from pg2 http://172.30.0.22:8008/failsafe: Accepted 2026-09-25 22:39:55,974 WARNING: Request failed to pg3: POST http://172.30.0.23:8008/failsafe (HTTPConnectionPool(host=\u0026#39;172.30.0.23\u0026#39;, port=8008): Max retries exceeded 2026-09-25 22:39:55,974 INFO: demoting self because DCS is not accessible and I was a leader 2026-09-25 22:39:55,974 INFO: Demoting self (offline) 2026-09-25 22:39:57,142 INFO: demoted self because DCS is not accessible and I was a leader pg3가 응답하지 않자 pg1은 곧바로 강등했습니다. pg3가 정말 죽었는지, 아니면 pg1과만 끊겨서 promote했을지 모르기 때문입니다. failsafe_mode는 \u0026ldquo;DCS만 잠깐 멈춘\u0026rdquo; 경우를 살려 주는 장치이지 DCS 장애를 견디는 장치가 아닙니다. 가장 확실한 대책은 etcd를 PostgreSQL만큼 튼튼하게 운영하는 것입니다. 홀수 대, 서로 다른 장애 도메인, 빠른 디스크, 그리고 etcd 자체의 모니터링입니다.\n비동기 복제는 커밋 응답을 받은 데이터도 잃을 수 있다 기본 설정의 스트리밍 복제는 비동기입니다. primary는 커밋을 자기 디스크에 기록하면 바로 클라이언트에 성공을 돌려주고, WAL은 그 뒤에 replica로 갑니다. 그 사이에 primary가 죽으면 클라이언트는 성공 응답을 받았는데 새 primary에는 없는 트랜잭션이 생깁니다.\n얼마나 잃는지 정확히 세려고 클라이언트를 만들었습니다. 스레드 4개가 번호를 1씩 올리며 INSERT하고 COMMIT 성공 응답을 받은 번호만 파일에 적습니다.\n# 4개 스레드가 각자 번호를 1씩 올리며 INSERT하고, COMMIT 응답을 받은 번호만 파일에 적는다 import sys, threading, time, psycopg2 DSN = \u0026#34;host=127.0.0.1 port=5000 user=app dbname=appdb connect_timeout=2\u0026#34; def worker(w): seq, conn = 0, None with open(f\u0026#34;/root/ack.{w}\u0026#34;, \u0026#34;w\u0026#34;) as f: while True: try: if conn is None: conn = psycopg2.connect(DSN); conn.autocommit = True seq += 1 conn.cursor().execute(\u0026#34;INSERT INTO ack(worker, seq) VALUES (%s, %s)\u0026#34;, (w, seq)) f.write(f\u0026#34;{seq} {time.time():.3f}\\n\u0026#34;); f.flush() except Exception as e: conn = None; time.sleep(0.2) for w in range(4): threading.Thread(target=worker, args=(w,), daemon=True).start() while True: time.sleep(1) failover가 끝나면 파일에 적힌(응답을 받은) 번호 가운데 새 primary의 ack 테이블에 없는 것을 셉니다. 장애 시나리오는 \u0026ldquo;primary와 replica 사이의 네트워크가 잠깐 막히고, 그 사이에 primary가 죽는\u0026rdquo; 경우입니다. primary pg1에서 replica로 가는 복제 트래픽(출발 포트 5432)만 막으면, 클라이언트는 HAProxy로 계속 쓰지만 WAL은 replica에 가지 못합니다.\n$ python3.12 /root/acker.py \u0026amp; # haproxy 서버, 5초 동안 쓰기 $ for r in 22 23; do iptables -A OUTPUT -p tcp --sport 5432 -d 172.30.0.$r -j DROP; done # pg1, 22:41:32.5 pg1=# SELECT pg_current_wal_lsn(); pg_current_wal_lsn -------------------- 0/6781D80 (1 row) pg1=# SELECT application_name, sent_lsn, replay_lsn, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) FROM pg_stat_replication; application_name | sent_lsn | replay_lsn | pg_wal_lsn_diff ------------------+-----------+------------+----------------- pg2 | 0/60B8D90 | 0/5ED77E0 | 9088504 pg3 | 0/60D6118 | 0/5ED7C00 | 9087448 (2 rows) 3초 만에 replica는 약 9MB(9,088,504바이트) 뒤처졌습니다. 이 상태에서 pg1을 죽입니다.\n$ docker kill pg1 # 22:41:35.7 $ etcdctl get /service/pg-ha/leader --print-value-only # 22:42:00 pg3 $ /root/check_loss.sh worker 0: acked=35267 missing=12906 worker 1: acked=35460 missing=13045 worker 2: acked=35389 missing=12948 worker 3: acked=35396 missing=13037 total acked=141512, lost=51936 클라이언트가 성공 응답을 받은 141,512건 중 51,936건이 새 primary에 없습니다. 나중에 pg1을 다시 붙이자 Patroni는 pg_rewind로 pg1을 두 서버가 갈라진 위치 0/5ED7C00까지 되돌렸고(pg1의 마지막 위치는 0/6781D80 이후), 그 9MB 남짓의 WAL과 함께 5만여 건은 영영 사라졌습니다.\nmaximum_lag_on_failover가 막지 못한 이유 이상한 점이 있습니다. 지연이 약 9MB였는데 maximum_lag_on_failover는 1MB입니다. 1MB 넘게 뒤처진 replica는 후보에서 빠져야 하는데 pg3가 승격했습니다.\n$ journalctl -u patroni -o cat --since 22:41:30 --until 22:42:02 | grep -E \u0026#34;INFO|WARN\u0026#34; | cut -c1-110 # pg2 2026-09-25 22:41:38,630 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:41:48,586 INFO: no action. I am (pg2), a secondary, and following a leader (pg1) 2026-09-25 22:41:58,319 INFO: Got response from pg3 http://172.30.0.23:8008/patroni: {\u0026#34;state\u0026#34;: \u0026#34;running\u0026#34;, \u0026#34;p 2026-09-25 22:42:00,307 WARNING: Request failed to pg1: GET http://172.30.0.21:8008/patroni (HTTPConnection 2026-09-25 22:42:00,308 INFO: Wal position of pg3 is ahead of my wal position 2026-09-25 22:42:00,363 INFO: following a different leader because i am not the healthiest node pg2는 지연 때문이 아니라 \u0026ldquo;pg3가 나보다 앞서서\u0026rdquo; 양보했을 뿐입니다. 이유는 지연을 계산하는 기준에 있습니다(Ha.is_lagging()).\nlag = self.cluster.status.last_lsn - wal_position return lag \u0026gt; global_config.maximum_lag_on_failover last_lsn은 leader가 loop마다 DCS status 키에 적어 둔 마지막 WAL 위치입니다. 실제 primary의 현재 위치가 아닙니다. leader는 이 값을 loop_wait(10초)마다 한 번 적습니다. pg3가 후보에서 빠지지 않았다는 것은 DCS의 last_lsn이 pg3의 위치(0/5ED7C00)에서 1MB 이내였고 실제 primary 위치(0/6781D80)보다 적어도 8MB 뒤의 값이었다는 뜻입니다. 마지막 기록 뒤에 쌓인 WAL은 replica들에게 보이지 않았습니다.\nmaximum_lag_on_failover는 오래 뒤처져 있던 replica를 걸러 내는 장치입니다. 최대 loop_wait 동안 쌓인 WAL은 보지 못하므로, 유실의 상한을 보장하지 않습니다.\nsynchronous_mode: 응답을 받은 커밋은 잃지 않는다 같은 실험을 동기 복제로 반복합니다. Patroni의 synchronous_mode를 켜면 Patroni가 synchronous_standby_names를 관리하고, 동기 standby만 failover 후보로 삼습니다.\n$ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -s synchronous_mode=on $ patronictl -c /etc/patroni/patroni.yml list + Cluster: pg-ha (7689601790135361659) -----------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+--------------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Replica | streaming | 8 | 0/67307B0 | 0 | 0/67307B0 | 0 | | pg2 | 172.30.0.22 | Sync Standby | streaming | 8 | 0/67307B0 | 0 | 0/67307B0 | 0 | | pg3 | 172.30.0.23 | Leader | running | 8 | | | | | +--------+-------------+--------------+-----------+----+-------------+-----+------------+-----+ $ etcdctl get /service/pg-ha/sync --print-value-only {\u0026#34;leader\u0026#34;:\u0026#34;pg3\u0026#34;,\u0026#34;quorum\u0026#34;:0,\u0026#34;sync_standby\u0026#34;:\u0026#34;pg2\u0026#34;} leader는 pg3, 동기 standby는 pg2입니다(synchronous_standby_names = 'pg2'). 누가 동기 standby인지도 DCS의 sync 키에 기록됩니다. 이제 pg3에서 replica로 가는 복제를 막습니다.\n$ for r in 21 22; do iptables -A OUTPUT -p tcp --sport 5432 -d 172.30.0.$r -j DROP; done # pg3, 22:43:20.0 pg3=# SELECT pid, wait_event_type, wait_event, left(query, 40) FROM pg_stat_activity WHERE usename = \u0026#39;app\u0026#39;; pid | wait_event_type | wait_event | left -----+-----------------+------------+------------------------------------------ 195 | IPC | SyncRep | INSERT INTO ack(worker, seq) VALUES (2, 196 | IPC | SyncRep | INSERT INTO ack(worker, seq) VALUES (1, 197 | IPC | SyncRep | INSERT INTO ack(worker, seq) VALUES (0, 198 | IPC | SyncRep | INSERT INTO ack(worker, seq) VALUES (3, (4 rows) 네 세션이 모두 SyncRep에서 기다립니다(인터널 9편에서 본 대기입니다). 커밋은 로컬에 기록됐지만 동기 standby의 확인이 오지 않으니 클라이언트에 응답하지 않습니다. 이 상태에서 pg3를 죽입니다.\n$ docker kill pg3 # 22:43:23.2 $ etcdctl get /service/pg-ha/leader --print-value-only # 22:43:48 pg2 $ /root/check_loss.sh worker 0: acked=17200 missing=0 worker 1: acked=17200 missing=0 worker 2: acked=17146 missing=0 worker 3: acked=17156 missing=0 total acked=68702, lost=0 응답을 받은 68,702건 중 잃은 것은 0건입니다. 옛 primary pg3에도 pg_rewind로 버려진 WAL이 있었지만, 그 안의 트랜잭션은 클라이언트가 성공 응답을 받지 못한 것들입니다.\n동기 복제의 비용 동기 복제는 공짜가 아닙니다. 세 가지를 측정했습니다.\n처리량: 같은 클러스터에서 pgbench -c 8 -j 4 -T 20 -N(scale 10)을 돌렸습니다.\n모드 tps 평균 지연 비동기 7,950 1.006 ms synchronous_mode 6,031 1.326 ms 컨테이너가 모두 한 서버에 있어 네트워크 왕복이 거의 0인 조건입니다. 실제 네트워크에서는 커밋마다 standby까지의 왕복 시간이 더해지므로 차이가 더 커집니다.\n동기 standby가 죽으면 커밋이 멈춘다: 동기 standby pg1을 죽이고 2편의 쓰기 스크립트를 봅니다.\n$ docker kill pg1 # 22:44:33.3, 동기 standby $ awk \u0026#39;$1\u0026gt;=\u0026#34;22:44:32\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:44:42\u0026#34;\u0026#39; /root/writer.log 22:44:32.388 172.30.0.22 INSERT 0 1 22:44:32.920 172.30.0.22 INSERT 0 1 22:44:40.959 172.30.0.22 INSERT 0 1 22:44:41.485 172.30.0.22 INSERT 0 1 $ journalctl -u patroni -o cat --since \u0026#34;-42s\u0026#34; | grep -iE \u0026#34;sync\u0026#34; # pg2(leader) 2026-09-25 22:44:40,903 INFO: Updating synchronous privilege temporarily from [\u0026#39;pg1\u0026#39;] to [] 2026-09-25 22:44:40,950 INFO: Assigning synchronous standby status to [\u0026#39;pg3\u0026#39;] 2026-09-25 22:44:43,109 INFO: Synchronous standby status assigned to [\u0026#39;pg3\u0026#39;] 다음 HA loop에서 Patroni가 pg3를 새 동기 standby로 정하기까지 8초 동안 모든 커밋이 멈췄습니다.\nreplica가 모두 사라지면: 남은 replica pg3까지 죽이면 기본값(synchronous_mode_strict: false)에서는 Patroni가 동기 복제를 풀어 버립니다.\n$ docker kill pg3 # 22:45:25.8 $ journalctl -u patroni -o cat --since \u0026#34;-32s\u0026#34; | grep -iE \u0026#34;sync\u0026#34; # pg2 2026-09-25 22:45:30,904 INFO: Updating synchronous privilege temporarily from [\u0026#39;pg3\u0026#39;] to [] 2026-09-25 22:45:30,950 INFO: Assigning synchronous standby status to [] $ awk \u0026#39;$1\u0026gt;=\u0026#34;22:45:24\u0026#34; \u0026amp;\u0026amp; $1\u0026lt;=\u0026#34;22:45:32\u0026#34;\u0026#39; /root/writer.log 22:45:24.777 172.30.0.22 INSERT 0 1 22:45:25.306 172.30.0.22 INSERT 0 1 22:45:25.819 172.30.0.22 INSERT 0 1 22:45:30.957 172.30.0.22 INSERT 0 1 22:45:31.489 172.30.0.22 INSERT 0 1 5초 멈춘 뒤 쓰기가 다시 됩니다. 가용성은 지켰지만 이제 데이터는 pg2 한 대에만 있습니다. synchronous_mode_strict: true로 바꾸면 반대로 동작합니다.\n$ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -s synchronous_mode_strict=on $ journalctl -u patroni -o cat --since \u0026#34;-17s\u0026#34; | grep -iE \u0026#34;sync\u0026#34; # pg2 2026-09-25 22:46:07,097 INFO: Changed synchronous_standby_names from \u0026#39;\u0026#39; to \u0026#39;*\u0026#39; 2026-09-25 22:46:17,075 INFO: Assigning synchronous standby status to [\u0026#39;*\u0026#39;] pg2=# SELECT wait_event, left(query, 30) FROM pg_stat_activity WHERE usename = \u0026#39;app\u0026#39;; wait_event | left ------------+-------------------------------- SyncRep | INSERT INTO heartbeat DEFAULT (1 row) synchronous_standby_names가 '*'가 되어, 어떤 standby든 하나가 붙을 때까지 모든 커밋이 기다립니다. 데이터는 잃지 않지만 그동안은 쓰지 못합니다.\n설정 failover 유실 standby 장애 때 비동기 (기본) 응답을 받은 커밋도 잃을 수 있음 영향 없음 synchronous_mode: on 동기 standby로만 failover, 응답을 받은 커밋은 잃지 않음 새 동기 standby를 정할 때까지 멈춤, 모두 사라지면 비동기로 전환 synchronous_mode_strict: on 위와 같음 standby가 하나도 없으면 쓰기 불가 어느 쪽이 맞는지는 서비스가 정합니다. 다만 \u0026ldquo;기본값에서 failover하면 잃을 수 있다\u0026quot;는 것을 알고 고르는 것과 모르고 쓰는 것은 다릅니다. 동기 복제를 쓴다면 replica는 최소 2대를 두어, 동기 standby 하나가 죽어도 다른 replica가 이어받게 합니다.\nPatroni가 죽으면 PostgreSQL은 계속 primary다 1편의 자진 강등은 Patroni가 살아 있을 때만 일어납니다. primary 노드의 Patroni 프로세스만 kill -9로 죽여 봅니다. OOM killer, Python 예외, 운영자의 실수로 실제로 일어날 수 있는 일입니다. 2편에서 본 것처럼 systemd unit은 KillMode=process, Restart=no라서 PostgreSQL은 그대로 남고 Patroni는 되살아나지 않습니다.\n$ kill -9 $(systemctl show -p MainPID --value patroni) # primary pg2, 22:47:16.3 $ patronictl -c /etc/patroni/patroni.yml list # 40초 뒤, pg1에서 + Cluster: pg-ha (7689601790135361659) ------+----+-------------+-----+------------+-----+ | Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Leader | running | 10 | | | | | | pg3 | 172.30.0.23 | Replica | streaming | 10 | 0/74FFED8 | 0 | 0/74FFED8 | 0 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ $ systemctl is-active patroni # pg2 failed $ ps -eo pid,args | grep \u0026#34;postgres -D\u0026#34; | grep -v grep | cut -c1-80 # pg2 2396 /usr/pgsql-18/bin/postgres -D /var/lib/pgsql/18/data --config-file=/var/li pg2의 lease가 만료되자 pg1이 22:47:39.4에 promote했습니다(timeline 10). 그런데 pg2의 PostgreSQL도 그대로 살아 있습니다.\npg2=# SELECT pg_is_in_recovery(); pg_is_in_recovery ------------------- f (1 row) pg2=# INSERT INTO heartbeat(server) VALUES (\u0026#39;172.30.0.22\u0026#39;) RETURNING id, at::time(0), \u0026#39;written on pg2 (old primary)\u0026#39;; id | at | ?column? ------+----------+------------------------------ 1862 | 22:48:03 | written on pg2 (old primary) (1 row) primary가 두 대입니다. pg1은 timeline 10의 primary, pg2는 timeline 9의 primary로 각자 쓰기를 받습니다. HAProxy는 REST API가 죽은 pg2를 DOWN으로 보고 pg1으로만 보내므로, HAProxy를 거치는 클라이언트는 안전합니다. 하지만 pg2에 직접 붙어 있던 연결이나, 주소를 직접 적은 배치 작업은 pg2에 계속 씁니다.\npg2의 Patroni를 다시 띄우면 정리됩니다.\n$ systemctl start patroni # pg2 $ journalctl -u patroni -o cat --since \u0026#34;-22s\u0026#34; | grep -E \u0026#34;INFO\u0026#34; | grep -E \u0026#34;Lock owner|emot|rewind|diverged\u0026#34; | cut -c1-120 # 일부 줄만 옮김 2026-09-25 22:48:11,714 INFO: Lock owner: pg1; I am pg2 2026-09-25 22:48:11,714 INFO: Demoting self (immediate-nolock) 2026-09-25 22:48:12,776 INFO: demoting self because I do not have the lock and I was a leader 2026-09-25 22:48:14,037 INFO: running pg_rewind from pg1 2026-09-25 22:48:15,594 INFO: stderr=pg_rewind: servers diverged at WAL location 0/74F9D50 on timeline 9 pg1=# SELECT count(*) FROM heartbeat WHERE id = 1862; count ------- 0 (1 row) Patroni는 자기가 leader key를 갖지 않은 primary라는 것을 알고 즉시 강등했고, pg_rewind로 pg1을 따르게 했습니다. 그 사이 pg2에 쓴 id 1862번 행은 사라졌습니다. 30초 남짓이었지만 그동안 pg2에 쓴 것은 모두 잃었습니다.\nwatchdog 이 틈을 막는 장치가 watchdog입니다. Patroni는 leader인 동안 리눅스 watchdog 장치(/dev/watchdog)를 주기적으로 건드리고, Patroni가 멈춰서 제때 건드리지 못하면 커널이 노드를 재부팅합니다. PostgreSQL도 함께 죽으므로 두 primary가 생기지 않습니다. Patroni는 lease가 만료되기 전에 재부팅이 일어나도록 타이머를 ttl보다 safety_margin(기본 5초)만큼 짧게 잡습니다.\nwatchdog: mode: required # off, automatic(기본), required device: /dev/watchdog safety_margin: 5 기본값 automatic은 장치가 있으면 쓰고 없으면 그냥 넘어갑니다. 이 실습 환경(Docker Desktop의 리눅스 VM)에는 /dev/watchdog이 없었고, Patroni 로그에는 watchdog에 관한 줄이 한 줄도 없었습니다. 운영에서 watchdog에 기대려면 required로 두어, 장치가 없으면 leader가 되지 못하게 합니다. 하드웨어 watchdog이 없는 서버는 softdog 커널 모듈을 씁니다. Patroni RPM의 unit 파일에도 modprobe softdog와 /dev/watchdog 소유권 변경이 주석으로 들어 있고, postgres 사용자가 장치를 열 수 있어야 합니다. 이 실습 환경에서는 watchdog을 켤 수 없어서 재부팅 동작은 직접 확인하지 못했습니다. 동기 복제도 이 상황의 피해를 줄입니다. 옛 primary의 동기 standby가 새 primary를 따라가면 옛 primary의 커밋은 SyncRep에서 멈춰 응답을 돌려주지 못하기 때문입니다.\n멈춘 멤버의 slot과 reinit use_slots: true이면 primary에 멤버마다 replication slot이 있습니다. replica를 멈추면 slot은 어떻게 될까요? pg3의 Patroni를 멈추고 멤버 목록에서 pg3가 사라진 뒤 primary에서 WAL을 많이 만듭니다.\n$ systemctl stop patroni # pg3, 22:48:40 pg1=# SELECT pg_size_pretty(sum(size)) AS pg_wal FROM pg_ls_waldir(); pg_wal -------- 208 MB (1 row) pg1=# CREATE TABLE big AS SELECT g, repeat(md5(g::text), 8) pad FROM generate_series(1, 1500000) g; pg1=# CHECKPOINT; pg1=# SELECT pg_size_pretty(sum(size)) AS pg_wal FROM pg_ls_waldir(); pg_wal -------- 672 MB (1 row) pg1=# SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots ORDER BY 1; slot_name | active | retained -----------+--------+---------- pg2 | t | 0 bytes pg3 | f | 459 MB (2 rows) pg3는 이미 멤버 목록에 없는데, slot pg3는 남아서 459MB의 WAL을 붙잡고 있습니다. Patroni는 사라진 멤버의 slot을 member_slots_ttl(기본 30분) 동안 유지합니다(global_config.py). 잠깐 재시작하는 replica가 돌아왔을 때 WAL이 남아 있게 하려는 것입니다. 거꾸로 말하면, replica가 멈춘 뒤 30분 동안 primary의 pg_wal은 계속 커질 수 있습니다. 쓰기가 많은 시스템이라면 30분이면 디스크를 채우기에 충분합니다(인터널 9편).\n실습에서는 기다리지 않도록 60초로 줄였습니다.\n$ patronictl -c /etc/patroni/patroni.yml edit-config pg-ha --force -s member_slots_ttl=60s $ journalctl -u patroni -o cat --since \u0026#34;-3min\u0026#34; | grep -i slot # pg1 2026-09-25 22:50:16,124 INFO: Replication slot \u0026#39;pg3\u0026#39; for absent cluster member is expired after 60 sec. 2026-09-25 22:50:26,165 INFO: Trying to drop unknown replication slot \u0026#39;pg3\u0026#39; 2026-09-25 22:50:26,167 INFO: Dropped replication slot \u0026#39;pg3\u0026#39; slot이 사라진 뒤 돌아온 replica slot이 지워졌으니 pg3가 필요로 하던 WAL도 곧 지워집니다. 이제 pg3를 다시 띄웁니다.\n$ systemctl start patroni # pg3 $ patronictl -c /etc/patroni/patroni.yml list | tail -5 +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ | pg1 | 172.30.0.21 | Leader | running | 10 | | | | | | pg2 | 172.30.0.22 | Replica | streaming | 10 | 0/3F1BC838 | 0 | 0/3F1BC838 | 0 | | pg3 | 172.30.0.23 | Replica | running | 10 | 0/7000000 | 898 | 0/75060A0 | 893 | +--------+-------------+---------+-----------+----+-------------+-----+------------+-----+ $ grep -hE \u0026#34;FATAL\u0026#34; /var/lib/pgsql/18/data/log/*.log | tail -2 # pg3 2026-09-25 22:51:02.847 UTC [257] FATAL: could not receive data from WAL stream: ERROR: requested WAL segment 0000000A0000000000000007 has already been removed 2026-09-25 22:51:07.839 UTC [258] FATAL: could not receive data from WAL stream: ERROR: requested WAL segment 0000000A0000000000000007 has already been removed $ journalctl -u patroni -o cat --since \u0026#34;-30s\u0026#34; | tail -2 # pg3 2026-09-25 22:50:56,223 INFO: no action. I am (pg3), a secondary, and following a leader (pg1) 2026-09-25 22:51:06,224 INFO: no action. I am (pg3), a secondary, and following a leader (pg1) pg3의 상태는 streaming이 아니라 running이고, 지연은 898MB에서 줄지 않습니다. PostgreSQL 로그에는 필요한 WAL 파일이 이미 지워졌다는 오류가 5초마다 찍힙니다. 그런데 Patroni는 no action입니다. Patroni는 이 상태를 스스로 고치지 않습니다. WAL 아카이브가 있고 restore_command를 설정해 두었다면 아카이브에서 WAL을 가져와 따라잡을 수 있지만, 이 실습처럼 없으면 다시 복제하는 수밖에 없습니다. 모니터링은 State가 streaming인지와 Lag를 함께 봐야 합니다.\n$ date +%T; patronictl -c /etc/patroni/patroni.yml reinit pg-ha pg3 --force --wait; date +%T 22:51:13 Success: reinitialize for member pg3 Waiting for reinitialize to complete on: pg3 Reinitialize is completed on: pg3 22:54:42 reinit은 pg3의 데이터 디렉터리를 지우고 leader에서 새로 pg_basebackup을 받습니다. 데이터베이스 전체가 469MB인데 3분 29초가 걸렸습니다. primary의 로그에서 원인을 확인합니다.\n$ grep -hE \u0026#34;checkpoint (starting|complete)\u0026#34; /var/lib/pgsql/18/data/log/*.log | cut -c1-110 # pg1, 그 시각의 줄만 옮김 2026-09-25 22:51:14.684 UTC [74] LOG: checkpoint starting: force wait 2026-09-25 22:54:40.405 UTC [74] LOG: checkpoint complete: wrote 2002 buffers (12.2%), wrote 1 SLRU buffers pg_basebackup은 시작할 때 체크포인트를 요청하는데, 기본값은 --checkpoint=spread입니다. 체크포인트가 checkpoint_completion_target(0.9) × checkpoint_timeout(5분)에 걸쳐 천천히 쓰도록 속도를 조절하므로, 실제 복사는 1초 남짓인데 체크포인트를 3분 26초 동안 기다렸습니다. Patroni가 pg_basebackup에 넘기는 옵션은 postgresql.basebackup에서 정할 수 있습니다. 모든 노드의 patroni.yml에 넣고 reload합니다.\npostgresql: basebackup: - checkpoint: fast dirty buffer를 다시 만든 뒤(UPDATE big SET pad = pad WHERE g % 10 = 0) 같은 reinit을 합니다.\n$ date +%T; patronictl -c /etc/patroni/patroni.yml reinit pg-ha pg3 --force --wait \u0026gt;/dev/null; date +%T 22:56:20 22:56:25 $ grep -hE \u0026#34;checkpoint (starting|complete)\u0026#34; /var/lib/pgsql/18/data/log/*.log | tail -2 | cut -c1-110 # pg1 2026-09-25 22:56:22.151 UTC [74] LOG: checkpoint starting: immediate force wait 2026-09-25 22:56:22.286 UTC [74] LOG: checkpoint complete: wrote 15642 buffers (95.5%), wrote 2 SLRU buffers; 체크포인트가 immediate로 바뀌어 15,642개 버퍼를 0.14초에 썼고, reinit 전체가 5초에 끝났습니다. 새 노드를 붙이거나 장애 노드를 다시 만들 때마다 이 차이가 납니다. 다만 즉시 체크포인트는 순간적으로 primary의 I/O를 많이 쓰므로, 바쁜 시간대라면 max-rate 옵션으로 복사 속도를 제한하는 것도 함께 고려합니다.\nREST API에는 인증을 건다 Patroni REST API는 상태 조회뿐 아니라 재시작, switchover, reinit 같은 명령도 받습니다. 기본 설정에서는 8008번 포트에 닿는 누구나 이 명령을 보낼 수 있습니다.\n$ curl -s -w \u0026#34; (HTTP %{http_code})\\n\u0026#34; -XPOST http://localhost:8008/restart # pg3 restarted successfully (HTTP 200) 인증 정보도 비밀번호와 같이 /etc/patroni_env.conf에 환경 변수로 넣고 Patroni를 재시작했습니다(restapi.authentication의 username, password에 해당합니다). 이때 순서를 지키지 않아 앞에서 본 두 번의 failover가 일어났습니다.\n$ cat \u0026gt;\u0026gt; /etc/patroni_env.conf \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; # 모든 pg 노드, 값은 가렸습니다 \u003e PATRONI_RESTAPI_USERNAME=patroni_api \u003e PATRONI_RESTAPI_PASSWORD=******** \u003e EOF $ curl -s -w \u0026#34; (HTTP %{http_code})\\n\u0026#34; -XPOST http://localhost:8008/restart no auth header received (HTTP 401) $ curl -s -o /dev/null -w \u0026#34;GET /health (HTTP %{http_code})\\n\u0026#34; http://localhost:8008/health GET /health (HTTP 200) $ patronictl -c /etc/patroni/patroni.yml restart pg-ha pg3 --force ... Failed: restart for member pg3, status code=401, (no auth header received) $ (set -a; . /etc/patroni_env.conf; set +a; patronictl -c /etc/patroni/patroni.yml restart pg-ha pg3 --force) ... Success: restart on member pg3 인증을 켜면 명령(POST, PUT, PATCH, DELETE)에는 인증이 필요하고, GET /health 같은 조회는 그대로 열려 있어서 HAProxy의 health check는 바꿀 것이 없습니다. patronictl도 REST API로 명령을 보내므로 같은 인증 정보가 필요합니다. 여기서는 환경 변수를 읽혀서 실행했습니다. 운영자가 쓰는 계정에서 이 파일을 읽을 수 있게 할지, patronictl용 설정 파일을 따로 둘지 정해 두어야 합니다. 인증은 평문 HTTP 위에서 Basic 인증이므로 운영에서는 restapi.certfile, restapi.keyfile로 TLS를 함께 켭니다. 운영 점검표 설정: PostgreSQL 파라미터는 patronictl edit-config로 바꾼다. ALTER SYSTEM은 명령행 파라미터에 무시되고, 한 노드에만 적용된다. Pending restart가 남아 있지 않은지 본다. 재시작 순서: patronictl restart는 제자리 재시작(primary는 잠깐 쓰기 중단), systemctl restart patroni는 leader에서 failover를 일으킨다. Patroni를 재시작할 때는 replica부터, leader는 switchover한 뒤에. pause: 수동 작업 전에 pause, 끝나면 반드시 resume. Maintenance mode: on을 모니터링한다. DCS: etcd 쿼럼이 깨지면 클러스터 전체가 read-only가 된다. etcd는 홀수 대, 분리된 장애 도메인, 빠른 디스크로 운영하고 따로 모니터링한다. failsafe_mode는 모든 멤버와 연락될 때만 primary를 지켜 준다. 데이터 유실: 비동기 복제의 failover는 응답을 받은 커밋도 잃을 수 있고, maximum_lag_on_failover는 그 상한을 보장하지 않는다. 잃으면 안 되는 데이터라면 synchronous_mode를 쓰고 replica를 2대 이상 둔다. watchdog: Patroni가 죽으면 옛 primary가 계속 쓰기를 받는다. watchdog.mode: required와 /dev/watchdog(softdog)을 준비한다. slot: 멈춘 멤버의 slot은 member_slots_ttl(기본 30분) 동안 WAL을 붙잡는다. pg_wal 크기와 비활성 slot을 모니터링한다. reinit: WAL을 잃은 replica는 Patroni가 고치지 않는다(running, 지연이 줄지 않음). postgresql.basebackup에 checkpoint: fast를 넣어 두면 reinit이 빠르다. WAL 아카이브(restore_command)가 있으면 재복제 없이 따라잡을 수 있다. REST API: 인증과 TLS를 켠다. 켜지 않으면 8008번에 닿는 누구나 재시작과 switchover를 할 수 있다. 정리 Patroni 클러스터의 PostgreSQL 설정은 DCS가 기준입니다. 핵심 파라미터는 명령행으로 넘어가서 ALTER SYSTEM이 먹히지 않고, 재시작이 필요한 변경은 Pending restart로 표시됩니다. leader의 Patroni를 멈추면 failover가 일어납니다. 점검은 pause, 교대는 switchover, 재시작은 replica부터 합니다. DCS에 닿지 못하면 primary는 강등합니다. etcd 쿼럼이 깨지면 멀쩡한 클러스터 전체가 read-only가 되고, failsafe_mode는 모든 멤버와 연락될 때만 이를 막아 줍니다. 비동기 복제의 failover에서 응답을 받은 커밋 51,936건(141,512건 중)을 잃었고, maximum_lag_on_failover는 DCS에 기록된 마지막 위치만 보기 때문에 막지 못했습니다. synchronous_mode에서는 0건이었지만 처리량이 줄고, standby 장애 때 커밋이 멈춥니다. Patroni 프로세스가 죽으면 PostgreSQL은 primary로 남아 split-brain이 됩니다. watchdog이 이 틈을 막습니다. 멈춘 멤버의 slot은 30분 동안 WAL을 붙잡고, 그 뒤 WAL을 잃은 replica는 reinit해야 합니다. basebackup에 checkpoint: fast를 주면 3분 29초가 5초가 됩니다. 참고 자료 Patroni 소스 코드 (v4.1.5 태그 기준)\npatroni/ha.py: is_lagging(L1280), _handle_dcs_error(L2352), check_failsafe_topology(L1228) patroni/global_config.py: member_slots_ttl 기본값 patroni/dcs/__init__.py: 사라진 멤버의 slot 보존과 만료 Patroni 공식 문서\nDynamic Configuration Settings: failsafe_mode, synchronous_mode, member_slots_ttl Replication modes DCS Failsafe Mode Pause/Resume mode for the cluster Watchdog support REST API, YAML Configuration: REST API PostgreSQL 18 공식 문서\nHot Standby: Administrator\u0026rsquo;s Overview Synchronous Replication pg_basebackup: --checkpoint, --max-rate ","permalink":"https://jinlog.dev/posts/postgresql/patroni-03-operations/","summary":"설정 변경, DCS 장애, failover 데이터 유실, split-brain, 방치된 slot을 재현해 본다","title":"Patroni HA 3: 운영할 때 주의할 점"},{"content":"개요 2편에서 shared buffers가 8kB 칸의 배열이라는 것을 봤습니다. 이번 글은 그 칸 하나에 들어가는 8kB 페이지의 안쪽을 들여다봅니다. INSERT 한 줄이 디스크에서 어떤 바이트가 되는지를 끝까지 따라가는 것이 목표입니다.\n이 글에서 답할 질문은 다음과 같습니다.\n테이블은 디스크의 어떤 파일이고, 파일 안은 어떻게 나뉘어 있는가 행(튜플) 하나는 어떤 헤더와 데이터로 이루어져 있는가 열 순서만 바꿔도 테이블 크기가 달라지는 이유는 무엇인가 100kB짜리 텍스트는 8kB 페이지에 어떻게 들어가는가 (TOAST) 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n용어 정리\n힙(heap): PostgreSQL에서 테이블 데이터를 담는 파일 형식을 부르는 이름입니다. 행을 정렬하지 않고 빈 곳에 쌓아 두기 때문에 이렇게 부릅니다. 튜플(tuple): 테이블의 행 하나가 페이지에 저장된 모습입니다. 이 글에서는 행과 거의 같은 뜻으로 씁니다. ctid: 튜플의 물리적 주소입니다. (블록 번호, 블록 안의 순번) 형태입니다. 테이블은 파일이다 테이블 하나는 $PGDATA/base/\u0026lt;DB OID\u0026gt;/\u0026lt;파일 번호\u0026gt; 경로의 파일 하나입니다. 파일은 8kB 블록(페이지)의 연속이고, 블록 번호 0, 1, 2 \u0026hellip;로 부릅니다. 파일이 1GB(131072블록)를 넘으면 16430.1, 16430.2처럼 다음 파일(세그먼트)로 이어집니다.\n같은 테이블에 파일이 몇 개 더 붙습니다. 이를 fork라고 합니다.\nfork 파일 이름 쓰임 main 16430 실제 데이터 fsm 16430_fsm 페이지마다 빈 공간이 얼마나 있는지(Free Space Map). INSERT가 넣을 곳을 찾을 때 씁니다. vm 16430_vm 페이지의 모든 행이 모두에게 보이는지(Visibility Map). VACUUM과 index-only scan이 씁니다. fsm과 vm은 5편(VACUUM)에서 자세히 다룹니다.\n테이블 파일과 fork 확인하기 페이지 내용을 보는 데는 contrib 확장인 pageinspect를 씁니다. initdb에는 체크섬 옵션을 따로 주지 않았습니다.\n$ initdb -D $PGDATA \u0026gt; /home/postgres/initdb.log 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; echo \u0026#34;initdb ok\u0026#34; initdb ok $ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION pageinspect; postgres=# CREATE TABLE fruit (id int, name text); CREATE TABLE postgres=# INSERT INTO fruit VALUES (1, \u0026#39;apple\u0026#39;), (2, \u0026#39;banana\u0026#39;), (3, \u0026#39;cherry\u0026#39;); INSERT 0 3 postgres=# SELECT oid AS db_oid, datname FROM pg_database WHERE datname = current_database(); db_oid | datname --------+---------- 5 | postgres (1 row) postgres=# SELECT \u0026#39;fruit\u0026#39;::regclass::oid AS table_oid, pg_relation_filenode(\u0026#39;fruit\u0026#39;) AS filenode, postgres-# pg_relation_filepath(\u0026#39;fruit\u0026#39;) AS path; table_oid | filenode | path -----------+----------+-------------- 16430 | 16430 | base/5/16430 (1 row) $ ls -l $PGDATA/base/5/16430* -rw------- 1 postgres postgres 8192 Sep 26 10:07 /var/lib/postgresql/data/base/5/16430 postgres=# VACUUM fruit; $ ls -l $PGDATA/base/5/16430* -rw------- 1 postgres postgres 8192 Sep 26 10:07 /var/lib/postgresql/data/base/5/16430 -rw------- 1 postgres postgres 24576 Sep 26 10:07 /var/lib/postgresql/data/base/5/16430_fsm -rw------- 1 postgres postgres 8192 Sep 26 10:07 /var/lib/postgresql/data/base/5/16430_vm $ pg_controldata $PGDATA | grep -E \u0026#39;Database block size|Blocks per segment|Data page checksum\u0026#39; Database block size: 8192 Blocks per segment of large relation: 131072 Data page checksum version: 1 DB postgres의 OID는 5, 테이블 fruit의 파일 번호는 16430이라 경로는 base/5/16430입니다. 처음에는 main fork 파일(8kB, 1페이지) 하나뿐입니다. VACUUM 뒤에 _fsm(3페이지)과 _vm(1페이지)이 생겼습니다. Blocks per segment of large relation: 131072는 131072블록 × 8kB = 1GB마다 파일을 나눈다는 뜻입니다. Data page checksum version: 1은 데이터 체크섬이 켜져 있다는 뜻입니다. PG18부터 initdb가 체크섬을 기본으로 켭니다(initdb.c). 이전 버전에서는 --data-checksums를 따로 줘야 했습니다. 8kB 페이지의 구조 모든 페이지는 같은 틀을 씁니다. bufpage.h의 주석 그림이 그 틀입니다(공백만 정리했습니다).\n+----------------+---------------------------------+ | PageHeaderData | linp1 linp2 linp3 ... | +-----------+----+---------------------------------+ | ... linpN | | +-----------+--------------------------------------+ | ^ pd_lower | | | | v pd_upper | +-------------+------------------------------------+ | | tupleN ... | +-------------+------------------+-----------------+ | ... tuple3 tuple2 tuple1 | \u0026#34;special space\u0026#34; | +--------------------------------+-----------------+ ^ pd_special 페이지 헤더(24바이트, PageHeaderData): 이 페이지를 마지막으로 바꾼 WAL 위치(pd_lsn), 체크섬, 빈 공간의 시작과 끝(pd_lower, pd_upper) 등을 담습니다. line pointer 배열: 헤더 바로 뒤에서 시작해 페이지 끝(높은 주소) 쪽으로 자랍니다. 하나에 4바이트이고, 튜플이 페이지의 몇 번째 바이트에서 시작해 몇 바이트인지를 적습니다(ItemIdData: lp_off 15비트, lp_flags 2비트, lp_len 15비트). 튜플: 페이지 끝에서부터 거꾸로 쌓입니다. 빈 공간: pd_lower와 pd_upper 사이입니다. 둘이 만나면 페이지가 가득 찬 것입니다. special space: 인덱스 페이지가 자기 정보를 두는 곳입니다. 힙 페이지에서는 크기가 0입니다. line pointer를 한 단계 거치는 이유가 있습니다. 행의 주소(ctid)는 바이트 위치가 아니라 (블록 번호, line pointer 번호)입니다. 그래서 페이지 안에서 튜플을 옮겨 빈 공간을 정리해도 line pointer의 lp_off만 고치면 되고, 인덱스가 가리키는 주소는 바뀌지 않습니다.\nlp_flags는 line pointer의 상태를 나타냅니다(itemid.h).\n값 이름 뜻 0 LP_UNUSED 비어 있어 다시 쓸 수 있음 1 LP_NORMAL 정상 튜플을 가리킴 2 LP_REDIRECT HOT 업데이트로 다른 line pointer로 넘겨 줌 (5편) 3 LP_DEAD 죽은 튜플. 공간 회수 대기 pageinspect로 페이지 헤더 읽기 postgres=# SELECT * FROM page_header(get_raw_page(\u0026#39;fruit\u0026#39;, 0)); lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid -----------+----------+-------+-------+-------+---------+----------+---------+----------- 0/17E1D28 | 0 | 4 | 36 | 8072 | 8192 | 8192 | 4 | 0 (1 row) 값 해석 lower = 36 헤더 24바이트 + line pointer 3개 × 4바이트 upper = 8072 튜플 3개가 페이지 끝(8192)에서부터 8072까지 차지 special = 8192 힙 페이지라 special space가 없음 flags = 4 PD_ALL_VISIBLE. 앞에서 실행한 VACUUM이 \u0026ldquo;이 페이지의 모든 행은 모두에게 보인다\u0026quot;고 표시함 checksum = 0 체크섬이 켜져 있는데 0입니다. 다음 실습에서 이유를 봅니다. 파일의 실제 바이트 CHECKPOINT로 이 페이지를 디스크에 쓰게 한 뒤, 파일의 앞 40바이트와 끝부분을 16진수로 봅니다.\npostgres=# CHECKPOINT; $ xxd -l 40 $PGDATA/base/5/16430 00000000: 0000 0000 281d 7e01 6a8e 0400 2400 881f ....(.~.j...$... 00000010: 0020 0420 0000 0000 d89f 4400 b09f 4600 . . ......D...F. 00000020: 889f 4600 0000 0000 ..F..... $ xxd -s 8096 -l 96 $PGDATA/base/5/16430 00001fa0: 0300 0000 0f63 6865 7272 7900 0000 0000 .....cherry..... 00001fb0: f202 0000 0000 0000 0000 0000 0000 0000 ................ 00001fc0: 0200 0200 0209 1800 0200 0000 0f62 616e .............ban 00001fd0: 616e 6100 0000 0000 f202 0000 0000 0000 ana............. 00001fe0: 0000 0000 0000 0000 0100 0200 0209 1800 ................ 00001ff0: 0100 0000 0d61 7070 6c65 0000 0000 0000 .....apple...... postgres=# SELECT checksum AS checksum_in_buffer, page_checksum(get_raw_page(\u0026#39;fruit\u0026#39;, 0), 0) AS computed, to_hex(page_checksum(get_raw_page(\u0026#39;fruit\u0026#39;, 0), 0) \u0026amp; 65535) AS computed_hex FROM page_header(get_raw_page(\u0026#39;fruit\u0026#39;, 0)); checksum_in_buffer | computed | computed_hex --------------------+----------+-------------- 0 | -29078 | 8e6a (1 row) 앞 40바이트를 필드별로 끊어 읽으면 pageinspect가 보여 준 값과 그대로 맞습니다. 데이터 파일은 CPU의 바이트 순서를 그대로 쓰는데, 일반적인 x86-64와 ARM64 환경은 리틀엔디언이라 여러 바이트짜리 숫자는 뒤집어 읽습니다.\n바이트 필드 값 0000 0000 281d 7e01 pd_lsn 상위 0, 하위 0x017e1d28 → 0/17E1D28 6a8e pd_checksum 0x8e6a 0400 pd_flags 4 (PD_ALL_VISIBLE) 2400 pd_lower 0x24 = 36 881f pd_upper 0x1f88 = 8072 0020 pd_special 0x2000 = 8192 0420 pd_pagesize_version 0x2004 = 8192 + 버전 4 0000 0000 pd_prune_xid 0 d89f 4400 line pointer 1 0x00449fd8 → 하위 15비트 lp_off 8152, 다음 2비트 lp_flags 1(LP_NORMAL), 상위 15비트 lp_len 34 체크섬의 비밀. 디스크의 pd_checksum은 0x8e6a인데, shared buffers에 있는 페이지(checksum_in_buffer)는 0입니다. 이 페이지는 메모리에서 처음 만들어진 뒤 디스크에서 다시 읽은 적이 없기 때문입니다(디스크에서 읽어 온 페이지라면 그때의 체크섬 값이 남아 있습니다). PostgreSQL은 체크섬을 디스크에 쓰는 순간 페이지 복사본에 계산해 넣고, 메모리의 페이지에는 적지 않습니다(bufmgr.c, PageSetChecksumCopy()). 메모리에 있는 동안에는 여러 번 바뀌어도 체크섬을 다시 계산하지 않아도 되고, 디스크의 페이지가 깨졌는지는 읽어 올 때 확인합니다. pageinspect로 계산한 값(computed_hex = 8e6a)이 디스크의 값과 같습니다.\n파일 끝부분은 apple 튜플(8152 = 0x1fd8부터)입니다. f202 0000은 t_xmin 0x2f2 = 754, 이어서 t_xmax 0과 t_cid 0, t_ctid(0,1), 0200은 열 2개, 0209는 t_infomask 0x0902, 18은 t_hoff 24입니다. 한 바이트를 건너뛴 뒤 0100 0000이 id = 1, 0d가 varlena 1바이트 헤더(0x0d = 6 \u0026laquo; 1 | 1, 헤더 자신 1바이트를 포함한 길이 6), 61 70 70 6c 65가 apple입니다.\n튜플 하나의 구조 튜플은 23바이트 헤더와 데이터로 이루어집니다(HeapTupleHeaderData).\n필드 크기 뜻 t_xmin 4 이 튜플을 만든 트랜잭션 ID (HeapTupleFields) t_xmax 4 이 튜플을 지웠거나 잠근 트랜잭션 ID. 아직 아무도 안 건드렸으면 0 t_cid 4 같은 트랜잭션 안에서 몇 번째 명령이 만들었는지 t_ctid 6 이 튜플의 주소, 또는 UPDATE로 생긴 새 버전의 주소 t_infomask2 2 열 개수(하위 11비트)와 HOT 관련 플래그 t_infomask 2 NULL이 있는지, 가변 길이 열이 있는지, xmin/xmax가 커밋되었는지 등 (htup_details.h) t_hoff 1 헤더 전체 크기. 데이터가 시작하는 위치 t_xmin, t_xmax는 4편(MVCC)의 주인공입니다. \u0026ldquo;누가 만들었고 누가 지웠는지\u0026quot;가 행마다 헤더에 적혀 있기 때문에, PostgreSQL은 행을 제자리에서 고치지 않고도 트랜잭션마다 다른 버전을 보여 줄 수 있습니다.\nline pointer와 튜플 헤더 postgres=# SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, t_infomask2, t_infomask, t_hoff, t_data postgres-# FROM heap_page_items(get_raw_page(\u0026#39;fruit\u0026#39;, 0)); lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | t_infomask2 | t_infomask | t_hoff | t_data ----+--------+----------+--------+--------+--------+--------+-------------+------------+--------+-------------------------- 1 | 8152 | 1 | 34 | 754 | 0 | (0,1) | 2 | 2306 | 24 | \\x010000000d6170706c65 2 | 8112 | 1 | 35 | 754 | 0 | (0,2) | 2 | 2306 | 24 | \\x020000000f62616e616e61 3 | 8072 | 1 | 35 | 754 | 0 | (0,3) | 2 | 2306 | 24 | \\x030000000f636865727279 (3 rows) postgres=# SELECT lp, raw_flags, combined_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;fruit\u0026#39;, 0)), postgres-# LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2); lp | raw_flags | combined_flags ----+----------------------------------------------------------+---------------- 1 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID} | {} 2 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID} | {} 3 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID} | {} (3 rows) lp_len 34는 헤더 24 + int 4 + varlena 헤더 1 + apple 5입니다. banana, cherry는 한 글자가 더 길어 35입니다. 튜플 1은 8152에서 34바이트이지만, 튜플 2는 8152 − 40 = 8112에서 시작합니다. 35바이트를 8의 배수로 올리면 40이기 때문입니다. t_xmin은 세 행 모두 754입니다. 한 번의 INSERT(트랜잭션 754)로 넣었기 때문입니다. t_ctid는 자기 자신을 가리킵니다. t_infomask 2306 = 0x0902는 HEAP_HASVARWIDTH(가변 길이 열 있음), HEAP_XMIN_COMMITTED(만든 트랜잭션이 커밋됨), HEAP_XMAX_INVALID(지운 트랜잭션 없음)입니다. HEAP_XMAX_INVALID는 INSERT할 때 이미 켜집니다(heapam.c). 반면 HEAP_XMIN_COMMITTED는 나중에 누군가 \u0026ldquo;이 행을 만든 트랜잭션이 커밋되었다\u0026quot;는 것을 확인하고 적어 넣은 표시로, 이런 비트를 hint bit라고 부릅니다. 여기서는 앞에서 실행한 VACUUM이 적었습니다. 일반 조회도 행을 처음 읽을 때 이 비트를 적는데, 2편에서 읽기만 했는데 페이지가 dirty가 된 이유가 이것입니다. 운영에서는: 체크섬이 켜진 PG18 클러스터 PG18부터 새로 만드는 클러스터는 체크섬이 기본으로 켜집니다. 체크섬이 있으면 디스크에서 읽은 페이지가 깨졌을 때 invalid page in block ... 오류로 바로 알 수 있습니다. 대신 페이지를 쓸 때마다 체크섬을 계산하는 CPU 비용이 조금 들고, 더 눈에 띄는 비용은 WAL입니다. 체크섬이 켜져 있으면 hint bit만 바꿔도, 체크포인트 뒤 그 페이지를 처음 고칠 때 페이지 전체 이미지(full-page image)를 WAL에 남깁니다(XLogHintBitIsNeeded()). 읽기 위주 작업에서도 WAL이 늘 수 있다는 뜻입니다(7편). 기존 클러스터(체크섬 꺼짐)를 pg_upgrade로 올릴 때는 새 클러스터도 체크섬 설정이 같아야 하므로(controldata.c), 18에서 initdb할 때 --no-data-checksums를 주거나 기존 클러스터에서 서버를 내린 상태에서 pg_checksums --enable로 먼저 켜야 합니다(모든 데이터 파일을 다시 쓰므로 시간이 걸립니다).\nNULL 비트맵 헤더 뒤에는 NULL 비트맵이 올 수 있습니다. 한 열이라도 NULL이면 열 개수만큼의 비트를 두고, 값이 있으면 1, NULL이면 0을 적습니다. NULL인 열은 데이터 영역에 아무 바이트도 차지하지 않습니다. 비트맵은 열 8개까지는 23바이트 헤더 뒤의 빈 1바이트에 들어가서 추가 비용이 없고, 열이 그보다 많으면 헤더가 8바이트 단위로 늘어납니다.\nNULL은 비트맵 한 비트 postgres=# INSERT INTO fruit VALUES (4, NULL); INSERT 0 1 postgres=# SELECT lp, lp_len, t_hoff, t_bits, t_infomask, t_data postgres-# FROM heap_page_items(get_raw_page(\u0026#39;fruit\u0026#39;, 0)) WHERE lp IN (3, 4); lp | lp_len | t_hoff | t_bits | t_infomask | t_data ----+--------+--------+----------+------------+-------------------------- 3 | 35 | 24 | | 2306 | \\x030000000f636865727279 4 | 28 | 24 | 10000000 | 2049 | \\x04000000 (2 rows) name이 NULL인 4번 행은 t_bits = 10000000(첫 열 있음, 두 번째 열 NULL)이고 데이터는 id 4바이트뿐이라 lp_len이 28입니다. NULL 비트맵 1바이트는 23바이트 헤더 뒤의 빈 1바이트에 들어가서 t_hoff는 그대로 24입니다. t_infomask 2049 = 0x0801은 HEAP_HASNULL + HEAP_XMAX_INVALID입니다. 아직 아무도 이 행을 읽지 않아서 HEAP_XMIN_COMMITTED hint bit는 없습니다.\n데이터 영역과 정렬(alignment) 데이터 영역에는 열 값이 정의 순서대로 들어갑니다. 이때 CPU가 빠르게 읽을 수 있도록 타입마다 시작 위치를 정렬합니다. bigint(8바이트)는 8의 배수 위치에서, int(4바이트)는 4의 배수 위치에서 시작해야 합니다. 앞 열이 그 위치에서 끝나지 않으면 빈 바이트(padding)를 채워 넣습니다. 튜플 전체도 8바이트 단위(MAXALIGN)로 맞춰 페이지에 놓입니다. 23바이트 헤더가 실제로 24바이트를 차지하는 것도 이 때문입니다(t_hoff = 24).\n열 순서와 정렬 패딩 같은 네 열(bool 둘, bigint 둘)을 순서만 바꿔 두 테이블을 만듭니다.\npostgres=# CREATE TABLE pad_bad (a bool, b bigint, c bool, d bigint); CREATE TABLE postgres=# CREATE TABLE pad_good (b bigint, d bigint, a bool, c bool); CREATE TABLE postgres=# INSERT INTO pad_bad VALUES (true, 1, true, 2); INSERT 0 1 postgres=# INSERT INTO pad_good VALUES (1, 2, true, true); INSERT 0 1 postgres=# SELECT \u0026#39;pad_bad\u0026#39; AS tbl, lp_len, t_hoff, t_data FROM heap_page_items(get_raw_page(\u0026#39;pad_bad\u0026#39;, 0)) postgres-# UNION ALL postgres-# SELECT \u0026#39;pad_good\u0026#39;, lp_len, t_hoff, t_data FROM heap_page_items(get_raw_page(\u0026#39;pad_good\u0026#39;, 0)); tbl | lp_len | t_hoff | t_data ----------+--------+--------+-------------------------------------------------------------------- pad_bad | 56 | 24 | \\x0100000000000000010000000000000001000000000000000200000000000000 pad_good | 42 | 24 | \\x010000000000000002000000000000000101 (2 rows) postgres=# SELECT attname, typname, typlen, typalign postgres-# FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid postgres-# WHERE attrelid = \u0026#39;pad_bad\u0026#39;::regclass AND attnum \u0026gt; 0 ORDER BY attnum; attname | typname | typlen | typalign ---------+---------+--------+---------- a | bool | 1 | c b | int8 | 8 | d c | bool | 1 | c d | int8 | 8 | d (4 rows) pad_bad는 bool, bigint, bool, bigint 순서라 bool(1바이트) 뒤마다 7바이트 패딩이 들어가서 데이터가 32바이트, 튜플이 56바이트입니다. t_data에 01 뒤로 00이 일곱 개씩 이어지는 것이 그 패딩입니다. 큰 타입을 앞에 둔 pad_good은 데이터 18바이트, 튜플 42바이트입니다. 튜플 길이로는 14바이트 차이지만, 튜플은 페이지에 8바이트 단위로 놓이므로 실제로 차지하는 크기는 56 대 48바이트이고, line pointer까지 더하면 60 대 52바이트입니다. 행 하나에 8바이트, 약 13% 차이입니다. 행이 수억 개인 테이블이라면 무시할 수 없는 크기입니다.\n운영에서는: 열 순서로 테이블 크기가 달라진다 위 실습처럼 고정 길이 타입을 큰 것부터(bigint, timestamp → int → smallint → bool) 앞에 두고 가변 길이 타입(text 등)을 뒤에 두면 패딩이 줄어듭니다. 이미 만든 테이블의 열 순서는 바꿀 수 없으므로, 수억 행이 쌓일 테이블이라면 설계할 때 한 번 따져 볼 만합니다. 반대로 행이 적은 테이블이라면 가독성을 위해 논리적인 순서를 두는 편이 낫습니다.\n한 페이지에 들어가는 행 수와 fillfactor 헤더와 정렬에 드는 비용은 한 페이지에 들어가는 행 수에도 그대로 드러납니다. int 열 하나짜리 테이블에 1만 행을 넣고, fillfactor 50으로 만든 테이블과 비교합니다.\npostgres=# CREATE TABLE narrow (id int); CREATE TABLE postgres=# INSERT INTO narrow SELECT generate_series(1, 10000); INSERT 0 10000 postgres=# CREATE TABLE narrow_ff (id int) WITH (fillfactor = 50); CREATE TABLE postgres=# INSERT INTO narrow_ff SELECT generate_series(1, 10000); INSERT 0 10000 postgres=# SELECT \u0026#39;narrow\u0026#39; AS tbl, pg_relation_size(\u0026#39;narrow\u0026#39;) / 8192 AS pages, postgres-# (SELECT count(*) FROM heap_page_items(get_raw_page(\u0026#39;narrow\u0026#39;, 0))) AS rows_in_page0, postgres-# (SELECT lower || \u0026#39;/\u0026#39; || upper FROM page_header(get_raw_page(\u0026#39;narrow\u0026#39;, 0))) AS lower_upper postgres-# UNION ALL postgres-# SELECT \u0026#39;narrow_ff\u0026#39;, pg_relation_size(\u0026#39;narrow_ff\u0026#39;) / 8192, postgres-# (SELECT count(*) FROM heap_page_items(get_raw_page(\u0026#39;narrow_ff\u0026#39;, 0))), postgres-# (SELECT lower || \u0026#39;/\u0026#39; || upper FROM page_header(get_raw_page(\u0026#39;narrow_ff\u0026#39;, 0))); tbl | pages | rows_in_page0 | lower_upper -----------+-------+---------------+------------- narrow | 45 | 226 | 928/960 narrow_ff | 89 | 113 | 476/4576 (2 rows) 행 하나는 헤더 24 + 데이터 4 = 28바이트, 8의 배수로 올려 32바이트에 line pointer 4바이트를 더해 36바이트입니다. (8192 − 24) ÷ 36 = 226.9라 페이지당 226행이 들어갑니다. lower = 24 + 226 × 4 = 928, upper = 8192 − 226 × 32 = 960으로 계산과 정확히 맞습니다. 남은 32바이트로는 한 행(36바이트)을 더 넣을 수 없습니다. fillfactor = 50은 INSERT할 때 페이지를 절반까지만 채우라는 뜻입니다. 페이지당 113행, 페이지 수는 두 배(45 → 89)입니다. 남겨 둔 공간은 나중에 UPDATE가 새 버전을 같은 페이지에 넣는 데 씁니다(HOT 업데이트, 5편). 운영에서는: 행 하나에도 23바이트 이상의 비용이 든다 PostgreSQL의 행은 데이터 말고도 헤더 24바이트(정렬 포함)와 line pointer 4바이트를 씁니다. 위 실습에서 int 하나만 있는 행이 36바이트를 차지한 것이 그 예입니다. 작은 행이 아주 많은 테이블(로그, 이벤트 등)은 데이터 크기로 짐작한 것보다 디스크를 훨씬 많이 씁니다. 용량을 추정할 때는 이 고정 비용을 함께 계산해야 합니다.\n가변 길이 값: varlena 가변 길이 값(text, bytea 등)은 varlena 형식으로 저장됩니다. 앞에 길이를 적는 헤더가 붙는데, 값이 126바이트 이하면 1바이트, 그보다 크면 4바이트 헤더를 씁니다(varatt.h). 짧은 값이 많은 테이블에서 공간을 아끼려는 설계입니다.\n아래 그림은 앞에서 만든 fruit 테이블의 첫 페이지를 행이 3개일 때 그대로 그린 것입니다.\n실습 fruit 테이블 블록 0. 괄호 안 숫자는 페이지 안의 바이트 위치입니다. line pointer는 앞에서, 튜플은 페이지 끝에서부터 채워집니다. 새 창에서 크게 보기 TOAST: 페이지보다 큰 값을 저장하는 방법 튜플은 한 페이지 안에 들어가야 합니다. 그러면 100kB짜리 텍스트는 어떻게 저장할까요? PostgreSQL은 큰 값을 TOAST(The Oversized-Attribute Storage Technique)로 처리합니다.\n튜플이 TOAST_TUPLE_THRESHOLD를 넘으면(heapam.c), heap_toast_insert_or_update()가 튜플을 줄입니다. 이 값은 \u0026ldquo;한 페이지에 튜플 4개가 들어가는 크기\u0026quot;로 정의되어 있고, 8kB 페이지에서는 2032바이트입니다.\n먼저 압축하고, 그래도 크면 큰 값부터 TOAST 테이블로 옮깁니다. 새 창에서 크게 보기 줄이는 순서는 네 단계입니다.\n저장 전략이 EXTENDED인 값을 큰 것부터 압축합니다. 압축해도 값 하나가 목표 크기(2032바이트에서 튜플 헤더를 뺀 데이터 크기)보다 크면 바로 TOAST 테이블로 옮깁니다. EXTERNAL인 값은 압축하지 않고, 크면 이 단계에서 바로 옮깁니다. 아직 크면, EXTENDED나 EXTERNAL인 값을 큰 것부터 TOAST 테이블로 옮깁니다. 아직 크면, MAIN인 값을 압축합니다. 그래도 크면 MAIN인 값도 옮깁니다. 이때는 목표를 페이지 하나 크기로 넓혀서, MAIN은 정말 어쩔 수 없을 때만 옮깁니다. 옮긴 값은 테이블마다 따로 있는 TOAST 테이블(pg_toast.pg_toast_\u0026lt;OID\u0026gt;)에 1996바이트짜리 조각(TOAST_MAX_CHUNK_SIZE)으로 나뉘어 들어가고, 원래 튜플에는 18바이트짜리 포인터(TOAST_POINTER_SIZE, varatt_external)만 남습니다.\n큰 값은 TOAST로 text 열에 세 가지 값을 넣습니다. 짧은 값(100바이트), 압축이 잘 되는 긴 값(a 10만 개), 압축이 안 되는 긴 값(md5 해시를 이어 붙인 10만 바이트)입니다.\npostgres=# CREATE TABLE doc (id int, body text); CREATE TABLE postgres=# SELECT reltoastrelid::regclass AS toast_table FROM pg_class WHERE relname = \u0026#39;doc\u0026#39;; toast_table ------------------------- pg_toast.pg_toast_16447 (1 row) postgres=# INSERT INTO doc VALUES postgres-# (1, repeat(\u0026#39;a\u0026#39;, 100)), postgres-# (2, repeat(\u0026#39;a\u0026#39;, 100000)), postgres-# (3, (SELECT string_agg(md5(g::text), \u0026#39;\u0026#39;) FROM generate_series(1, 3125) g)); INSERT 0 3 postgres=# SELECT id, octet_length(body) AS original_bytes, pg_column_size(body) AS stored_bytes, postgres-# pg_column_compression(body) AS compression postgres-# FROM doc ORDER BY id; id | original_bytes | stored_bytes | compression ----+----------------+--------------+------------- 1 | 100 | 101 | 2 | 100000 | 1156 | pglz 3 | 100000 | 100000 | (3 rows) postgres=# SELECT lp, lp_len FROM heap_page_items(get_raw_page(\u0026#39;doc\u0026#39;, 0)); lp | lp_len ----+-------- 1 | 129 2 | 1184 3 | 46 (3 rows) postgres=# SELECT chunk_id, count(*) AS chunks, min(chunk_seq) AS first_seq, max(chunk_seq) AS last_seq, postgres-# max(octet_length(chunk_data)) AS max_chunk_bytes, sum(octet_length(chunk_data)) AS total_bytes postgres-# FROM pg_toast.pg_toast_16447 GROUP BY chunk_id; chunk_id | chunks | first_seq | last_seq | max_chunk_bytes | total_bytes ----------+--------+-----------+----------+-----------------+------------- 16452 | 51 | 0 | 50 | 1996 | 100000 (1 row) id 원래 크기 저장 크기 튜플 크기 어떻게 저장되었나 1 100 101 129 그대로. varlena 1바이트 헤더 2 100000 1156 1184 pglz로 압축하니 2032 아래로 줄어 압축본을 페이지 안에 저장 3 100000 100000 46 압축이 안 되어 TOAST 테이블로 이동. 튜플에는 18바이트 포인터만 3번 값은 TOAST 테이블에 51조각(chunk_seq 0-50)으로 들어갔고, 조각 하나의 최대 크기는 정확히 1996바이트입니다. 튜플 크기 46 = 헤더 24 + id 4 + 포인터 18입니다.\nstored_bytes(pg_column_size)를 읽을 때 주의할 점이 있습니다. TOAST로 나간 값이면 포인터 크기(18)가 아니라 TOAST 테이블에 저장된 크기를 varlena 헤더 없이 돌려줍니다(detoast.c). 3번 행이 100000으로 나온 이유이고, 다음 실습에서 2001바이트 값이 2005가 아닌 2001로 나오는 이유도 같습니다.\nTOAST가 시작되는 정확한 크기 압축을 끄고(STORAGE EXTERNAL) 1990, 2000, 2001, 2010바이트 값을 넣어 경계를 찾습니다.\npostgres=# CREATE TABLE edge (id int, body text); CREATE TABLE postgres=# ALTER TABLE edge ALTER COLUMN body SET STORAGE EXTERNAL; ALTER TABLE postgres=# INSERT INTO edge postgres-# SELECT n, left((SELECT string_agg(md5(g::text), \u0026#39;\u0026#39;) FROM generate_series(1, 100) g), n) postgres-# FROM (VALUES (1990), (2000), (2001), (2010)) v(n); INSERT 0 4 postgres=# SELECT e.id AS body_bytes, pg_column_size(e.body) AS stored_bytes, h.lp_len AS tuple_bytes postgres-# FROM edge e JOIN heap_page_items(get_raw_page(\u0026#39;edge\u0026#39;, 0)) h ON h.t_ctid = e.ctid postgres-# ORDER BY e.id; body_bytes | stored_bytes | tuple_bytes ------------+--------------+------------- 1990 | 1994 | 2022 2000 | 2004 | 2032 2001 | 2001 | 46 2010 | 2010 | 46 (4 rows) 값이 2000바이트면 varlena 헤더 4바이트를 더해 2004바이트, 튜플은 2032바이트로 페이지 안에 저장되었습니다. 1바이트만 늘린 2001바이트부터 튜플이 46바이트로 줄었습니다. TOAST 테이블로 옮겨졌다는 뜻입니다. 튜플이 2032바이트를 넘어야 TOAST가 시작된다는 소스 조건(t_len \u0026gt; TOAST_TUPLE_THRESHOLD)과 정확히 맞습니다.\n열마다 다른 저장 전략 열마다 저장 전략(attstorage)이 있습니다(pg_type.h).\n전략 압축 밖으로 옮김 기본값인 타입 p PLAIN 안 함 안 함 int, bigint 같은 고정 길이 타입 x EXTENDED 함 함 text, bytea, jsonb 등 대부분의 가변 길이 타입 e EXTERNAL 안 함 함 (직접 지정) m MAIN 함 최후의 수단 numeric 등 압축 방식은 default_toast_compression으로 정하고 기본값은 pglz입니다(TOAST_PGLZ_COMPRESSION). lz4도 쓸 수 있지만 빌드할 때 --with-lz4가 필요합니다. 이 글의 실습 서버는 lz4 없이 빌드해서 pglz만 씁니다.\n저장 전략 바꾸기 postgres=# SELECT attname, attstorage FROM pg_attribute WHERE attrelid = \u0026#39;doc\u0026#39;::regclass AND attnum \u0026gt; 0; attname | attstorage ---------+------------ id | p body | x (2 rows) postgres=# CREATE TABLE doc_ext (id int, body text); CREATE TABLE postgres=# ALTER TABLE doc_ext ALTER COLUMN body SET STORAGE EXTERNAL; ALTER TABLE postgres=# INSERT INTO doc_ext VALUES (2, repeat(\u0026#39;a\u0026#39;, 100000)); INSERT 0 1 postgres=# SELECT \u0026#39;doc (EXTENDED)\u0026#39; AS tbl, pg_column_size(body) AS stored_bytes, pg_column_compression(body) AS compression FROM doc WHERE id = 2 postgres-# UNION ALL postgres-# SELECT \u0026#39;doc_ext (EXTERNAL)\u0026#39;, pg_column_size(body), pg_column_compression(body) FROM doc_ext; tbl | stored_bytes | compression --------------------+--------------+------------- doc (EXTENDED) | 1156 | pglz doc_ext (EXTERNAL) | 100000 | (2 rows) 같은 값(a 10만 개)이 기본 전략(EXTENDED)에서는 압축되어 1156바이트였지만, EXTERNAL로 바꾸면 압축 없이 10만 바이트 그대로 TOAST 테이블에 들어갑니다. 공간은 더 쓰지만 대신 substr()처럼 값의 일부만 읽을 때 필요한 조각만 가져올 수 있습니다(TOAST 문서). 압축된 값은 원하는 위치까지 앞에서부터 풀어야 합니다. pglz는 필요한 앞부분 조각만 가져와 풀 수 있지만 값의 뒤쪽을 읽을수록 더 많이 풀어야 합니다. lz4는 항상 전체를 가져옵니다(detoast.c).\n운영에서는: 큰 값을 자주 바꾸면 TOAST 테이블이 커진다 TOAST로 나간 값을 UPDATE하면 TOAST 테이블에도 새 조각이 생기고 옛 조각은 dead tuple이 됩니다. 큰 jsonb나 text를 자주 고치는 테이블은 본 테이블보다 TOAST 테이블이 훨씬 커지는 일이 흔합니다. 테이블 크기를 볼 때는 pg_relation_size()(main fork만)가 아니라 pg_total_relation_size()(TOAST와 인덱스 포함)나 pg_table_size()를 봐야 합니다. TOAST 테이블도 VACUUM 대상입니다.\n또한 SELECT *는 TOAST로 나간 큰 값을 매번 모아서 읽어 옵니다. 큰 열이 필요 없는 조회라면 열을 골라 쓰는 것만으로 I/O가 크게 줄어듭니다.\n정리 테이블은 base/\u0026lt;DB OID\u0026gt;/\u0026lt;파일 번호\u0026gt; 파일이고, 1GB마다 세그먼트로 나뉘며, fsm과 vm fork 파일이 따로 붙습니다. 8kB 페이지는 헤더(24바이트) → line pointer 배열(앞에서부터) → 빈 공간 → 튜플(끝에서부터) 구조입니다. 행의 주소 ctid는 (블록, line pointer 번호)입니다. 튜플은 23바이트 헤더(t_xmin, t_xmax, t_ctid, t_infomask 등)와 데이터로 이루어지고, 데이터는 타입별 정렬 규칙에 따라 패딩이 들어갑니다. NULL인 값은 데이터 영역을 쓰지 않고 비트맵에 한 비트로만 표시됩니다(비트맵은 열 8개까지 헤더의 빈 1바이트에 들어갑니다). 튜플이 2032바이트를 넘으면 TOAST가 먼저 압축하고, 그래도 크면 값을 1996바이트 조각으로 TOAST 테이블에 옮기고 18바이트 포인터만 남깁니다. PG18부터 데이터 체크섬이 기본으로 켜지고, 체크섬은 페이지를 디스크에 쓸 때 계산됩니다. 다음 글에서는 튜플 헤더의 t_xmin과 t_xmax가 실제로 어떻게 쓰이는지, 즉 MVCC와 스냅샷, 가시성 판단을 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/include/storage/bufpage.h: 페이지 레이아웃, PageHeaderData src/include/storage/itemid.h: line pointer src/include/access/htup_details.h: 튜플 헤더, infomask 비트 src/include/access/heaptoast.h, src/backend/access/heap/heaptoast.c: TOAST 임계값과 처리 순서 src/include/varatt.h: varlena 헤더, TOAST 포인터 PostgreSQL 18 공식 문서\nDatabase Page Layout Database File Layout TOAST pageinspect ","permalink":"https://jinlog.dev/posts/postgresql/03-storage-layout/","summary":"테이블의 행은 디스크에 어떤 모양으로 저장되는가","title":"PostgreSQL 인터널 3: 데이터 저장 구조"},{"content":"개요 1편에서 컬렉션과 인덱스는 각각 WiredTiger 테이블 파일 하나라고 했고, 3편에서는 그 테이블 안에서 버전이 어떻게 관리되는지 봤습니다. 이번에는 인덱스 파일 안에 실제로 무엇이 들어 있는지, 그리고 쿼리가 들어왔을 때 MongoDB가 여러 인덱스 가운데 무엇을 쓸지 어떻게 정하는지를 봅니다.\nMongoDB의 쿼리 플래너는 PostgreSQL처럼 통계로 비용을 추정하지 않습니다(PG 10편). 쓸 수 있는 계획이 여럿이면 모두 조금씩 실제로 실행해 보고, 가장 일을 잘한 계획을 고릅니다. 그리고 그 결과를 plan cache에 기록해 같은 모양의 쿼리에 다시 씁니다. 이 방식은 통계가 없어도 되지만, 데이터 분포가 값마다 크게 다르면 캐시된 계획이 어떤 값에서는 나쁜 계획이 되는 문제가 있습니다. 운영에서 \u0026ldquo;어제까지 빠르던 쿼리가 갑자기 느려졌다\u0026quot;의 흔한 원인입니다.\n이 글에서 답할 질문은 다음과 같습니다.\n인덱스 파일에는 key와 value로 무엇이 저장되는가. unique 인덱스와 _id 인덱스는 무엇이 다른가 쿼리 하나가 실행 계획을 얻기까지 어떤 단계를 거치는가 후보 계획끼리의 경쟁(trial)은 언제 끝나고, 점수는 어떻게 매기는가 plan cache 엔트리는 언제 만들어지고, 언제 쓰이고, 언제 버려지는가 ESR 규칙과 커버링 쿼리는 explain에서 어떻게 드러나는가 8.0에서 classic 엔진과 SBE 중 무엇이 쓰이는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 이 글의 실습은 standalone mongod 하나로 했습니다.\n인덱스는 KeyString을 key로 하는 WiredTiger 테이블 인덱스를 만들면 MongoDB는 WiredTiger 테이블을 하나 만들고, key와 value를 모두 바이트열(key_format=u, value_format=u)로 둡니다(wiredtiger_index.cpp). key에 들어가는 바이트열이 KeyString입니다. 인덱스 키의 BSON 값을 타입 순서와 값 순서가 그대로 바이트 비교 순서가 되도록 인코딩한 것이라, WiredTiger는 BSON을 모른 채 memcmp만으로 B-tree를 정렬할 수 있습니다. 값마다 첫 바이트가 타입(CType)을 나타냅니다(key_string.cpp). MinKey(10) \u0026lt; null(20) \u0026lt; 숫자(30번대) \u0026lt; 문자열(60) \u0026lt; 객체(70) \u0026lt; \u0026hellip; \u0026lt; MaxKey(240) 순서라서 서로 다른 타입이 섞여도 BSON 비교 규칙대로 정렬됩니다.\n인덱스 종류에 따라 key와 value의 모양이 다릅니다. 테이블 메타데이터의 app_metadata=(formatVersion=N)에 형식이 적힙니다(generateAppMetadataString(), 번호는 wiredtiger_index.h).\n인덱스 formatVersion key value 일반 인덱스 8 KeyString(인덱스 키) + RecordId 비어 있음(필요할 때만 TypeBits) unique 인덱스(_id 제외) 14 KeyString(인덱스 키) + RecordId 비어 있음(필요할 때만 TypeBits) _id 인덱스 8 KeyString(_id)만 RecordId(+ TypeBits) 일반 인덱스는 같은 키 값이 여러 도큐먼트에 있을 수 있으므로 key 뒤에 RecordId(컬렉션 테이블의 key, 1편)를 붙여 항목마다 key를 유일하게 만듭니다(WiredTigerIndexStandard::_insert()). unique 인덱스도 지금 형식(formatVersion 14)에서는 같은 모양입니다. 예전 형식(11, 13)은 key에 RecordId를 붙이지 않았습니다. 중복 검사는 넣기 전에 RecordId를 뗀 접두어로 기존 key가 있는지 찾아서 합니다(WiredTigerIndexUnique::_insert(), _keyExists()). key를 서로 다르게 두면 같은 값을 잠깐 동시에 가진 두 항목(예: 한 도큐먼트가 값을 놓고 다른 도큐먼트가 그 값을 가져가는 경우)이 WiredTiger key 하나를 두고 쓰기 충돌을 일으키지 않습니다. _id 인덱스만은 여전히 RecordId를 value에 둡니다(WiredTigerIdIndex::_insert()).\nRecordId는 KeyString 끝에 붙어도 뒤에서부터 길이를 알 수 있게 인코딩합니다. 첫 바이트의 위 3비트와 마지막 바이트의 아래 3비트에 \u0026ldquo;사이에 낀 바이트 수\u0026quot;를 적고 나머지 비트에 값을 넣습니다(_appendRecordIdLong()).\nwt로 본 인덱스 파일 도큐먼트 세 개에 일반 인덱스 k_1과 unique 인덱스 u_1을 만들고, 각 인덱스의 테이블 이름을 확인합니다.\ntest\u003e db.kdemo.insertMany([{_id: 1, k: 5, u: \u0026#34;a\u0026#34;}, {_id: 2, k: \u0026#34;x\u0026#34;, u: \u0026#34;b\u0026#34;}, {_id: 3, k: 5, u: \u0026#34;c\u0026#34;}]).insertedIds { \u0026#39;0\u0026#39;: 1, \u0026#39;1\u0026#39;: 2, \u0026#39;2\u0026#39;: 3 } test\u003e db.kdemo.createIndex({k: 1}) k_1 test\u003e db.kdemo.createIndex({u: 1}, {unique: true}) u_1 test\u003e db.kdemo.stats().wiredTiger.uri statistics:table:collection-7-13461233652411929628 test\u003e Object.entries(db.kdemo.stats({indexDetails: true}).indexDetails).map(([name, d]) =\u0026gt; [name, d.uri]) [ [ \u0026#39;_id_\u0026#39;, \u0026#39;statistics:table:index-8-13461233652411929628\u0026#39; ], [ \u0026#39;k_1\u0026#39;, \u0026#39;statistics:table:index-9-13461233652411929628\u0026#39; ], [ \u0026#39;u_1\u0026#39;, \u0026#39;statistics:table:index-11-13461233652411929628\u0026#39; ] ] test\u003e db.adminCommand({fsync: 1}).ok 1 mongod를 정상 종료하고 wt dump -x로 컬렉션과 인덱스 세 개를 16진수로 봅니다. key 줄과 value 줄이 번갈아 나오고, value가 비어 있으면 빈 줄입니다.\n$ mongosh --quiet --eval \u0026#39;Object.entries(db.kdemo.stats({indexDetails: true}).indexDetails).map(([n, d]) =\u0026gt; n + \u0026#34; \u0026#34; + d.uri.replace(\u0026#34;statistics:\u0026#34;, \u0026#34;\u0026#34;)).join(\u0026#34;\\n\u0026#34;)\u0026#39; \u0026gt; /tmp/idx.txt $ mongosh --quiet --eval \u0026#39;db.kdemo.stats().wiredTiger.uri.replace(\u0026#34;statistics:\u0026#34;, \u0026#34;\u0026#34;)\u0026#39; \u0026gt; /tmp/coll.txt $ mongod --dbpath /data/db --shutdown | grep -v \u0026#39;^{\u0026#39; $ wt -h /data/db dump -x $(cat /tmp/coll.txt) | sed -n \u0026#39;/^Data/,$p\u0026#39; $ while read name uri; do echo \u0026#34;== $name ($uri)\u0026#34;; wt -h /data/db list -v $uri | tr \u0026#39;,\u0026#39; \u0026#39;\\n\u0026#39; | grep -E \u0026#39;^app_metadata|^(key|value)_format\u0026#39;; wt -h /data/db dump -x $uri | sed -n \u0026#39;/^Data/,$p\u0026#39; | tail -n +2; done \u0026lt; /tmp/idx.txt Killing process with pid: 33 Data 81 1e000000105f69640001000000106b000500000002750002000000610000 82 20000000105f69640002000000026b0002000000780002750002000000620000 83 1e000000105f69640003000000106b000500000002750002000000630000 == _id_ (table:index-8-13461233652411929628) app_metadata=(formatVersion=8) key_format=u value_format=u 2b0204 0008 2b0404 0010 2b0604 0018 == k_1 (table:index-9-13461233652411929628) app_metadata=(formatVersion=8) key_format=u value_format=u 2b0a040008 2b0a040018 3c7800040010 == u_1 (table:index-11-13461233652411929628) app_metadata=(formatVersion=14) key_format=u value_format=u 3c6100040008 3c6200040010 3c6300040018 컬렉션 테이블의 key 81, 82, 83은 RecordId 1, 2, 3입니다(정수 key를 WiredTiger 방식으로 패킹한 값, 3편). value는 BSON 도큐먼트입니다. _id_ 인덱스의 첫 항목은 key 2b 02 04, value 00 08입니다. 0x2b(43)는 \u0026ldquo;1바이트로 표현되는 양의 정수\u0026rdquo; 타입(kNumericPositive1ByteInt), 0x02는 값 1을 왼쪽으로 한 비트 민 것(맨 아래 비트는 소수부가 있는지 표시, _appendInteger()), 0x04는 끝 표시(kEnd)입니다. RecordId 1은 value 00 08로, 사이 바이트 0개(첫 바이트 위 3비트와 마지막 바이트 아래 3비트가 0)에 값 1이 0x08(1 \u0026laquo; 3)로 들어갔습니다. k_1은 key가 2b 0a 04 + 00 08입니다. 0x0a는 5 \u0026laquo; 1이고, 뒤에 RecordId 1이 붙었습니다. k: 5인 도큐먼트가 둘(RecordId 1과 3)이라 앞부분이 같고 RecordId(0008, 0018)만 다른 key 두 개가 됩니다. 문자열 \u0026quot;x\u0026quot;는 3c(문자열 타입 60) + 78(\u0026lsquo;x\u0026rsquo;) + 00(문자열 끝) + 04이고 RecordId 2(0010)가 붙었습니다. 숫자 key가 문자열 key보다 앞에 정렬된 것도 보입니다. value는 모두 비어 있습니다. unique 인덱스 u_1은 formatVersion=14이지만 key 모양은 k_1과 같이 KeyString + RecordId입니다. _id_만 RecordId가 value 쪽에 있습니다. 인덱스 항목에는 키 값과 RecordId만 있으므로, 인덱스에 없는 필드가 필요하면 RecordId로 컬렉션 테이블을 한 번 더 찾아야 합니다. 이것이 explain의 FETCH 단계이고, 이 단계를 없앤 것이 커버링 쿼리입니다.\n쿼리 하나가 실행 계획을 얻기까지 find 명령 하나가 classic 엔진에서 실행 계획을 얻는 과정은 PrepareExecutionHelper::prepare()에 순서대로 있습니다.\nCanonicalQuery: 필터, 정렬, projection을 파싱하고 정규화합니다(CanonicalQuery::make(), 정규화는 canonical_query.cpp). 여기서 상수 값을 뺀 쿼리 모양(shape) 이 정해집니다. {a: 70000, b: 0}과 {a: 0, b: 30000}은 같은 shape입니다. _id 동등 조건 하나뿐인 쿼리처럼 특별한 경우는 계획 경쟁 없이 바로 실행합니다. plan cache 조회: shape와 사용 가능한 인덱스로 만든 planCacheKey로 캐시를 찾습니다(get_executor.cpp). active 엔트리가 있을 때만 씁니다(getCacheEntryIfActive()). QueryPlanner::plan: 캐시에서 못 찾으면 쓸 수 있는 인덱스마다 후보 계획(QuerySolution)을 열거합니다(QueryPlanner::plan(), 인덱스 조합 열거는 PlanEnumerator). 후보는 최대 64개입니다(internalQueryPlannerMaxIndexedSolutions). 후보가 하나면 바로 실행하고, 여럿이면 MultiPlanStage가 모두를 조금씩 실행해 승자를 고른 뒤 plan cache에 기록합니다. active 캐시 엔트리가 있으면 그 계획을 쓰되 예상 works의 10배 안에 끝나는지 지켜보고, 없으면 후보를 열거해 trial로 경쟁시킨 뒤 승자를 캐시에 기록합니다. 새 창에서 크게 보기 후보 계획 경쟁: trial과 점수 MultiPlanStage는 후보들의 work()를 한 번씩 번갈아 부릅니다(MultiPlanStage::pickBestPlan(), 루프는 multi_plan.cpp). work() 한 번은 인덱스 key 하나 읽기, 도큐먼트 하나 가져오기처럼 작은 일 한 단위이고, 이것이 explain의 works입니다. trial은 다음 중 하나가 되면 끝납니다.\n어느 후보가 결과 101개를 내면(internalQueryPlanEvaluationMaxResults). limit이 더 작으면 그 값(getTrialPeriodNumToReturn()) 어느 후보가 EOF(결과를 다 냄)에 닿으면 후보마다 work()를 10000번과 컬렉션 도큐먼트 수의 30% 중 큰 값만큼 부르면(getTrialPeriodMaxWorks(), 기본값은 query_knobs.idl) 그다음 후보마다 점수를 매깁니다(PlanScorer::calculateScore(), pickBestPlan()).\nscore = 1 (baseScore) + advanced / works (productivity, classic) + ε (FETCH가 없으면) + ε (blocking SORT가 없으면) + ε (index intersection이 아니면) + 1 (trial 안에 EOF에 닿았으면) ε = min(1 / (10 × advanced), 0.0001) productivity는 root 단계가 결과를 낸 횟수(advanced)를 works로 나눈 값입니다(DefaultPlanScorer). \u0026ldquo;일한 만큼 결과를 냈는가\u0026quot;라는 뜻입니다. 세 가지 ε 보너스는 productivity가 같을 때 순서를 가르는 작은 값이고(kBonusEpsilon), EOF 보너스 1은 trial 안에 일을 끝낸 계획을 확실히 앞세웁니다(plan_ranker_util.h, plan_ranker_util.h). 그래도 동점이면 읽은 도큐먼트 수가 적은 계획, 인덱스 접두어를 더 많이 쓴 계획에 보너스를 더 줍니다(plan_ranker_util.h).\nexplain(\u0026ldquo;allPlansExecution\u0026rdquo;)으로 본 trial a와 b에 각각 인덱스가 있는 10만 건짜리 컬렉션을 만듭니다. 앞 절반(_id 0~49999)은 a가 0이고 b는 _id와 같고, 뒤 절반은 b가 0이고 a가 _id와 같습니다. 즉 a: 0과 b: 0은 각각 5만 건쯤이고, 나머지 값은 한 건씩입니다.\ntest\u003e db.ev.insertMany(Array.from({length: 100000}, (_, i) =\u0026gt; ({_id: i, a: i \u0026lt; 50000 ? 0 : i, b: i \u0026gt;= 50000 ? 0 : i}))).acknowledged true test\u003e db.ev.createIndex({a: 1}) a_1 test\u003e db.ev.createIndex({b: 1}) b_1 test\u003e db.ev.countDocuments({a: 0}) 50000 test\u003e db.ev.countDocuments({b: 0}) 50001 {a: 70000, b: 0}의 explain을 봅니다. a: 70000은 한 건이고 b: 0은 5만 건입니다.\ntest\u003e e = db.ev.find({a: 70000, b: 0}).explain(\u0026#34;allPlansExecution\u0026#34;) { explainVersion: \u0026#39;1\u0026#39;, queryPlanner: { namespace: \u0026#39;test.ev\u0026#39;, parsedQuery: { \u0026#39;$and\u0026#39;: [ { a: { \u0026#39;$eq\u0026#39;: 70000 } }, { b: { \u0026#39;$eq\u0026#39;: 0 } } ] }, indexFilterSet: false, queryHash: \u0026#39;CB8DF112\u0026#39;, planCacheShapeHash: \u0026#39;CB8DF112\u0026#39;, planCacheKey: \u0026#39;CF147625\u0026#39;, ... test\u003e ({planCacheShapeHash: e.queryPlanner.planCacheShapeHash, planCacheKey: e.queryPlanner.planCacheKey, rejectedPlans: e.queryPlanner.rejectedPlans.length}) { planCacheShapeHash: \u0026#39;CB8DF112\u0026#39;, planCacheKey: \u0026#39;CF147625\u0026#39;, rejectedPlans: 2 } test\u003e e.queryPlanner.winningPlan { isCached: false, stage: \u0026#39;FETCH\u0026#39;, filter: { b: { \u0026#39;$eq\u0026#39;: 0 } }, inputStage: { stage: \u0026#39;IXSCAN\u0026#39;, keyPattern: { a: 1 }, indexName: \u0026#39;a_1\u0026#39;, isMultiKey: false, multiKeyPaths: { a: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: \u0026#39;forward\u0026#39;, indexBounds: { a: [ \u0026#39;[70000, 70000]\u0026#39; ] } } } explainVersion: '1'은 classic 엔진으로 실행했다는 뜻입니다(뒤에서 봅니다). planCacheShapeHash는 쿼리 shape의 해시이고, planCacheKey는 shape에 \u0026ldquo;지금 쓸 수 있는 인덱스\u0026quot;까지 더한 해시로 plan cache를 찾는 key입니다. queryHash는 8.0에서 planCacheShapeHash로 이름이 바뀌면서 호환을 위해 남아 있는 같은 값입니다. 승자는 a_1을 [70000, 70000] 범위로 읽고(IXSCAN), 도큐먼트를 가져와(FETCH) b: 0을 걸러 내는 계획입니다. 탈락한 후보(rejectedPlans)는 둘입니다. 후보별 trial 결과를 간추립니다. 8.0의 explain에는 후보마다 score도 나옵니다.\ntest\u003e ix = (s) =\u0026gt; s.indexName ? s.stage + \u0026#34; \u0026#34; + s.indexName : s.stage + (s.inputStage ? \u0026#34; \u0026gt; \u0026#34; + ix(s.inputStage) : \u0026#34; \u0026gt; [\u0026#34; + s.inputStages.map(ix).join(\u0026#34;, \u0026#34;) + \u0026#34;]\u0026#34;) [Function: ix] test\u003e e.executionStats.allPlansExecution.map(p =\u0026gt; ({plan: ix(p.executionStages), score: p.score, works: p.executionStages.works, advanced: p.executionStages.advanced, isEOF: p.executionStages.isEOF, totalKeysExamined: p.totalKeysExamined, totalDocsExamined: p.totalDocsExamined})) [ { plan: \u0026#39;FETCH \u0026gt; IXSCAN a_1\u0026#39;, score: 2.5002, works: 2, advanced: 1, isEOF: 1, totalKeysExamined: 1, totalDocsExamined: 1 }, { plan: \u0026#39;FETCH \u0026gt; IXSCAN b_1\u0026#39;, score: 1.0002, works: 2, advanced: 0, isEOF: 0, totalKeysExamined: 2, totalDocsExamined: 2 }, { plan: \u0026#39;FETCH \u0026gt; AND_SORTED \u0026gt; [IXSCAN a_1, IXSCAN b_1]\u0026#39;, score: 1.0001, works: 2, advanced: 0, isEOF: 0, totalKeysExamined: 2, totalDocsExamined: 0 } ] 후보는 a_1만 쓰는 계획, b_1만 쓰는 계획, 두 인덱스를 함께 읽어 RecordId가 겹치는 것만 남기는 index intersection(AND_SORTED) 계획 셋입니다. a_1 계획은 두 번째 work()에서 결과 하나를 내고 EOF에 닿았습니다. trial은 여기서 끝났으므로 세 후보 모두 works: 2입니다. 점수를 공식대로 풀면 a_1 계획은 1 + 1/2 + ε(SORT 없음) + ε(intersection 아님) + 1(EOF) = 2.5002입니다. FETCH가 있어서 FETCH 보너스는 없습니다. b_1 계획은 결과를 못 냈으므로 1 + 0 + 2ε = 1.0002이고, intersection 계획은 SORT 보너스만 받아 1.0001입니다. b_1 계획은 b: 0인 5만 건을 다 읽어야 끝날 계획이었지만, trial은 2번의 work()만에 그것을 가려냈습니다. 통계 없이 \u0026ldquo;실제로 조금 돌려 보는\u0026rdquo; 방식의 장점입니다. plan cache: inactive로 시작해 active가 된다 trial에서 이긴 계획은 plan cache에 기록됩니다. 엔트리는 컬렉션마다 따로 있고 메모리에만 있습니다(mongod를 재시작하면 비워짐). 크기는 기본으로 서버 메모리의 5%까지입니다(planCacheSize). 엔트리에는 계획과 함께 승자가 trial에서 쓴 works가 적힙니다. 이 값이 뒤의 replan 기준이 됩니다.\n새 엔트리는 곧바로 쓰이지 않습니다. 엔트리 상태는 getNewEntryState()가 정합니다.\n상황 결과 엔트리가 없음 inactive 엔트리를 만들고 works를 기록 inactive 엔트리가 있고, 이번 trial의 works ≤ 기록된 works active로 바꿈 inactive 엔트리가 있고, 이번 works \u0026gt; 기록된 works inactive로 두고 기록된 works를 2배로 키움(internalQueryCacheWorksGrowthCoefficient) active 엔트리가 있음 이번 works가 더 작거나 같을 때만 교체 inactive 엔트리는 조회 때 없는 것으로 취급되므로(getCacheEntryIfActive()), 같은 shape의 쿼리가 한 번 더 와서 trial을 다시 하고, 그때도 기록보다 적게 일해야 active가 됩니다. 한 번 운 좋게 싸게 끝난 값 때문에 나쁜 계획이 캐시에 박히는 것을 줄이려는 장치입니다.\n같은 쿼리를 두 번 실행하면 $planCacheStats로 엔트리를 보는 함수 pc()를 만들고, serverStatus의 plan cache 카운터도 함께 봅니다.\ntest\u003e pc = () =\u0026gt; db.ev.aggregate([{$planCacheStats: {}}, {$project: {_id: 0, planCacheShapeHash: 1, planCacheKey: 1, isActive: 1, works: 1, createdFromQuery: 1, index: \u0026#34;$cachedPlan.inputStage.indexName\u0026#34;}}]).toArray() [Function: pc] test\u003e db.serverStatus().metrics.query.planCache.classic { hits: Long(\u0026#39;0\u0026#39;), misses: Long(\u0026#39;2\u0026#39;), replanned: Long(\u0026#39;0\u0026#39;), skipped: Long(\u0026#39;6\u0026#39;) } test\u003e db.ev.find({a: 70000, b: 0}).toArray() [ { _id: 70000, a: 70000, b: 0 } ] test\u003e pc() [ { planCacheShapeHash: \u0026#39;CB8DF112\u0026#39;, planCacheKey: \u0026#39;CF147625\u0026#39;, isActive: false, works: Long(\u0026#39;2\u0026#39;), createdFromQuery: { query: { a: 70000, b: 0 }, sort: {}, projection: {} }, index: \u0026#39;a_1\u0026#39; } ] test\u003e db.ev.find({a: 70000, b: 0}).toArray() [ { _id: 70000, a: 70000, b: 0 } ] test\u003e pc() [ { planCacheShapeHash: \u0026#39;CB8DF112\u0026#39;, planCacheKey: \u0026#39;CF147625\u0026#39;, isActive: true, works: Long(\u0026#39;2\u0026#39;), createdFromQuery: { query: { a: 70000, b: 0 }, sort: {}, projection: {} }, index: \u0026#39;a_1\u0026#39; } ] test\u003e db.ev.find({a: 60000, b: 0}).toArray() [ { _id: 60000, a: 60000, b: 0 } ] test\u003e db.serverStatus().metrics.query.planCache.classic { hits: Long(\u0026#39;1\u0026#39;), misses: Long(\u0026#39;4\u0026#39;), replanned: Long(\u0026#39;0\u0026#39;), skipped: Long(\u0026#39;6\u0026#39;) } test\u003e db.ev.find({a: 60000, b: 0}).explain().queryPlanner.winningPlan.isCached true 첫 실행 뒤 엔트리가 생겼지만 isActive: false, works: 2입니다. 두 번째 실행도 trial을 다시 했고, 이번 works(2)가 기록값(2) 이하라 isActive: true가 되었습니다. 두 실행 모두 캐시를 쓰지 못했으므로 misses가 2에서 4로 늘었습니다. 값만 바꾼 {a: 60000, b: 0}은 같은 shape이라 active 엔트리를 그대로 썼습니다(hits 1). explain에서도 isCached: true가 보입니다. replanning: 캐시된 계획이 예상보다 10배 넘게 일하면 active 엔트리를 쓰는 쿼리는 trial 없이 바로 캐시된 계획을 실행하지만, 무조건 믿지는 않습니다. CachedPlanStage가 처음 일부를 실행하면서 works를 세고, 결과 101개(또는 limit)나 EOF에 닿기 전에 기록된 works × 10을 넘기면 엔트리를 inactive로 돌리고 처음부터 다시 계획합니다(cached_plan.cpp, 비활성화는 CachedPlanStage::replan()). 10은 internalQueryCacheEvictionRatio의 기본값입니다.\n데이터 분포가 다른 값으로 같은 shape을 실행하면 지금 캐시에는 a_1 계획이 works: 2로 active입니다. 같은 shape이지만 이번에는 a: 0(5만 건)이고 b: 30000(한 건)인 쿼리를 보냅니다. replan 과정을 로그로 보려고 query 컴포넌트의 로그 수준을 1로, 모든 쿼리를 slow query 로그에 남기도록 slowms를 0으로 잠깐 바꿨습니다.\ntest\u003e db.adminCommand({getParameter: 1, internalQueryCacheEvictionRatio: 1}).internalQueryCacheEvictionRatio 10 test\u003e db.setLogLevel(1, \u0026#34;query\u0026#34;).was.query.verbosity -1 test\u003e db.setProfilingLevel(0, {slowms: 0}).slowms 100 test\u003e db.ev.find({a: 0, b: 30000}).toArray() [ { _id: 30000, a: 0, b: 30000 } ] test\u003e db.setLogLevel(0, \u0026#34;query\u0026#34;).was.query.verbosity 1 test\u003e db.setProfilingLevel(0, {slowms: 100}).slowms 0 test\u003e pc() [ { planCacheShapeHash: \u0026#39;CB8DF112\u0026#39;, planCacheKey: \u0026#39;CF147625\u0026#39;, isActive: true, works: Long(\u0026#39;2\u0026#39;), createdFromQuery: { query: { a: 0, b: 30000 }, sort: {}, projection: {} }, index: \u0026#39;b_1\u0026#39; } ] test\u003e db.serverStatus().metrics.query.planCache.classic { hits: Long(\u0026#39;2\u0026#39;), misses: Long(\u0026#39;4\u0026#39;), replanned: Long(\u0026#39;1\u0026#39;), skipped: Long(\u0026#39;8\u0026#39;) } $ jq -c \u0026#39;select(.id == 20580) | {msg, attr: {maxWorksBeforeReplan: .attr.maxWorksBeforeReplan, decisionWorks: .attr.decisionWorks, planSummary: .attr.planSummary}}\u0026#39; /data/mongod.log $ jq -c \u0026#39;select(.msg == \u0026#34;Slow query\u0026#34; and .attr.ns == \u0026#34;test.ev\u0026#34; and .attr.replanned) | {planSummary: .attr.planSummary, replanned: .attr.replanned, replanReason: .attr.replanReason, keysExamined: .attr.keysExamined, docsExamined: .attr.docsExamined, planCacheShapeHash: .attr.planCacheShapeHash}\u0026#39; /data/mongod.log {\u0026#34;msg\u0026#34;:\u0026#34;Evicting cache entry and replanning query\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;maxWorksBeforeReplan\u0026#34;:20,\u0026#34;decisionWorks\u0026#34;:2,\u0026#34;planSummary\u0026#34;:\u0026#34;IXSCAN { a: 1 }\u0026#34;}} {\u0026#34;planSummary\u0026#34;:\u0026#34;IXSCAN { b: 1 }\u0026#34;,\u0026#34;replanned\u0026#34;:true,\u0026#34;replanReason\u0026#34;:\u0026#34;cached plan was less efficient than expected: expected trial execution to take 2 works but it took at least 20 works\u0026#34;,\u0026#34;keysExamined\u0026#34;:1,\u0026#34;docsExamined\u0026#34;:1,\u0026#34;planCacheShapeHash\u0026#34;:\u0026#34;CB8DF112\u0026#34;} 캐시를 찾았으므로 hits가 1 늘었고, 캐시된 a_1 계획이 a: 0인 5만 건을 읽기 시작했습니다. 기록된 works가 2라 한도는 20입니다(maxWorksBeforeReplan: 20, decisionWorks: 2). 20번 일해도 결과가 나오지 않자 Evicting cache entry and replanning query를 남기고 다시 계획했습니다. 다시 한 trial에서는 b_1이 이겼고, 엔트리가 b_1 계획으로 바뀌었습니다. createdFromQuery도 이번 쿼리입니다. replan할 때 옛 엔트리를 inactive로 돌리고, 새 trial 결과(works 2)가 기록값(2) 이하라 곧바로 active가 되었습니다. slow query 로그에 replanned: true와 이유(cached plan was less efficient than expected: expected trial execution to take 2 works but it took at least 20 works, cached_plan.cpp)가 남고, planSummary는 최종으로 실행한 IXSCAN { b: 1 }입니다. keysExamined, docsExamined는 최종 계획의 값이라 1씩입니다. replanned 카운터가 1이 되었습니다. replan은 쿼리마다 캐시를 되돌리는 장치입니다. 위의 두 쿼리가 번갈아 들어오면 엔트리가 a_1과 b_1 사이를 계속 오가며 매번 replan 비용을 치를 수 있습니다. 또 한도는 \u0026ldquo;기록된 works의 10배\u0026quot;라서, 기록된 works가 큰 엔트리에서는 나쁜 계획이 그만큼 오래 실행된 뒤에야 replan됩니다.\nplan cache가 비워지는 때 plan cache 엔트리는 다음 때 사라집니다.\nplanCacheClear 명령(또는 db.collection.getPlanCache().clear()) 인덱스를 새로 만들거나(multi_index_block.cpp) 지울 때(index_catalog_impl.cpp, updatePlanCacheIndexEntries()). 인덱스가 바뀌면 후보 자체가 달라지기 때문입니다 인덱스가 처음으로 multikey가 될 때(clearQueryCacheForSetMultikey()) mongod 재시작, 그리고 크기 한도를 넘을 때 오래 안 쓴 엔트리부터 planCacheClear와 인덱스 추가 test\u003e pc = () =\u0026gt; db.ev.aggregate([{$planCacheStats: {}}, {$project: {_id: 0, isActive: 1, works: 1, index: \u0026#34;$cachedPlan.inputStage.indexName\u0026#34;}}]).toArray() [Function: pc] test\u003e pc() [ { isActive: true, works: Long(\u0026#39;2\u0026#39;), index: \u0026#39;b_1\u0026#39; } ] test\u003e db.runCommand({planCacheClear: \u0026#34;ev\u0026#34;}).ok 1 test\u003e pc() [] test\u003e db.ev.find({a: 70000, b: 0}).toArray().length 1 test\u003e pc() [ { isActive: false, works: Long(\u0026#39;2\u0026#39;), index: \u0026#39;a_1\u0026#39; } ] test\u003e db.ev.createIndex({a: 1, b: 1}) a_1_b_1 test\u003e pc() [] test\u003e db.ev.find({a: 70000, b: 0}).explain().queryPlanner.winningPlan.inputStage.indexName a_1_b_1 planCacheClear로 엔트리가 없어졌고, 다음 실행이 다시 inactive 엔트리를 만들었습니다. 인덱스 {a: 1, b: 1}을 만들자 그 엔트리도 사라졌습니다. 새 인덱스로 두 조건을 모두 인덱스 범위로 처리할 수 있게 되어, 이제는 a_1_b_1이 승자입니다. ESR: Equality, Sort, Range 복합 인덱스의 필드 순서는 동등 조건(Equality) → 정렬(Sort) → 범위 조건(Range) 으로 두라는 것이 ESR 규칙입니다. 이유는 인덱스 범위(indexBounds)가 어떻게 만들어지는지를 보면 알 수 있습니다. 인덱스는 앞 필드부터 정렬되어 있으므로, 앞 필드가 동등 조건으로 한 값에 고정되면 그 안에서 다음 필드 순서대로 key가 나옵니다. 그런데 범위 조건 필드가 정렬 필드보다 앞에 있으면, 범위 안의 여러 값마다 정렬 필드가 따로 정렬되어 있어서 전체로는 정렬 순서가 아닙니다. 그러면 결과를 모아 따로 정렬하는 SORT 단계(blocking sort)가 필요합니다.\n같은 쿼리, 필드 순서만 다른 두 인덱스 20만 건의 orders에 status(4가지 값), amount(0~999), ts(증가값)를 넣고, 필드 순서만 다른 인덱스 둘을 만듭니다. 쿼리는 status 동등, amount 범위, ts 역순 정렬, 10건입니다.\ntest\u003e db.orders.insertMany(Array.from({length: 200000}, (_, i) =\u0026gt; ({_id: i, status: [\u0026#34;new\u0026#34;, \u0026#34;paid\u0026#34;, \u0026#34;shipped\u0026#34;, \u0026#34;done\u0026#34;][i % 4], amount: (i * 7) % 1000, ts: i}))).acknowledged true test\u003e db.orders.createIndex({status: 1, amount: 1, ts: 1}, {name: \u0026#34;esr_bad\u0026#34;}) esr_bad test\u003e db.orders.createIndex({status: 1, ts: 1, amount: 1}, {name: \u0026#34;esr_good\u0026#34;}) esr_good test\u003e db.orders.countDocuments({status: \u0026#34;paid\u0026#34;, amount: {$gte: 900}}) 5000 test\u003e st = (s) =\u0026gt; s.stage + (s.inputStage ? \u0026#34; \u0026gt; \u0026#34; + st(s.inputStage) : \u0026#34;\u0026#34;) [Function: st] test\u003e q = () =\u0026gt; db.orders.find({status: \u0026#34;paid\u0026#34;, amount: {$gte: 900}}).sort({ts: -1}).limit(10) [Function: q] test\u003e bad = q().hint(\u0026#34;esr_bad\u0026#34;).explain(\u0026#34;executionStats\u0026#34;); st(bad.executionStats.executionStages) FETCH \u0026gt; SORT \u0026gt; IXSCAN test\u003e ({nReturned: bad.executionStats.nReturned, totalKeysExamined: bad.executionStats.totalKeysExamined, totalDocsExamined: bad.executionStats.totalDocsExamined}) { nReturned: 10, totalKeysExamined: 5000, totalDocsExamined: 10 } test\u003e bad.queryPlanner.winningPlan.inputStage.inputStage.indexBounds { status: [ \u0026#39;[\u0026#34;paid\u0026#34;, \u0026#34;paid\u0026#34;]\u0026#39; ], amount: [ \u0026#39;[900, inf.0]\u0026#39; ], ts: [ \u0026#39;[MinKey, MaxKey]\u0026#39; ] } test\u003e good = q().hint(\u0026#34;esr_good\u0026#34;).explain(\u0026#34;executionStats\u0026#34;); st(good.executionStats.executionStages) LIMIT \u0026gt; FETCH \u0026gt; IXSCAN test\u003e ({nReturned: good.executionStats.nReturned, totalKeysExamined: good.executionStats.totalKeysExamined, totalDocsExamined: good.executionStats.totalDocsExamined}) { nReturned: 10, totalKeysExamined: 74, totalDocsExamined: 10 } test\u003e good.queryPlanner.winningPlan.inputStage.inputStage.indexBounds { status: [ \u0026#39;[\u0026#34;paid\u0026#34;, \u0026#34;paid\u0026#34;]\u0026#39; ], ts: [ \u0026#39;[MaxKey, MinKey]\u0026#39; ], amount: [ \u0026#39;[inf.0, 900]\u0026#39; ] } esr_bad(E, R, S 순서)는 status: \u0026quot;paid\u0026quot;와 amount \u0026gt;= 900 범위를 인덱스로 좁혀 key 5000개를 모두 읽고, ts로 정렬하는 SORT 단계에서 상위 10개를 골라 그 10건만 가져왔습니다(FETCH가 SORT 위에 있습니다). 결과가 10건이어도 조건에 맞는 key를 전부 읽어야 정렬할 수 있습니다. esr_good(E, S, R 순서)은 status: \u0026quot;paid\u0026quot; 안에서 ts 역순([MaxKey, MinKey])으로 key를 읽으면서 amount 조건은 key에서 바로 걸렀습니다. 이미 정렬된 순서라 SORT가 없고, 조건에 맞는 key 10개를 찾자마자 멈춰 key 74개만 읽었습니다. esr_good의 amount 범위가 [inf.0, 900]처럼 뒤집혀 보이는 것은 인덱스를 역방향으로 읽기 때문입니다. 범위 필드가 정렬 필드 뒤에 있어 경계로 쓰이지 못하고, key마다 검사하는 조건이 됩니다. 그래서 limit이 없고 조건에 맞는 key가 드물다면 esr_good이 더 많은 key를 읽을 수도 있습니다. ESR은 \u0026ldquo;정렬을 인덱스로 해결하는 것이 대개 더 싸다\u0026quot;는 경험칙이고, 결과 수와 limit에 따라 결과가 달라질 수 있습니다. hint 없이 플래너에게 맡기면 두 인덱스가 후보로 경쟁합니다.\ntest\u003e auto = q().explain(\u0026#34;allPlansExecution\u0026#34;); st(auto.queryPlanner.winningPlan) LIMIT \u0026gt; FETCH \u0026gt; IXSCAN test\u003e auto.executionStats.allPlansExecution.map(p =\u0026gt; ({plan: st(p.executionStages), score: p.score, works: p.executionStages.works, advanced: p.executionStages.advanced, isEOF: p.executionStages.isEOF, totalKeysExamined: p.totalKeysExamined})) [ { plan: \u0026#39;LIMIT \u0026gt; FETCH \u0026gt; IXSCAN\u0026#39;, score: 2.135335135135135, works: 74, advanced: 10, isEOF: 1, totalKeysExamined: 74 }, { plan: \u0026#39;FETCH \u0026gt; SORT \u0026gt; IXSCAN\u0026#39;, score: 1.0001, works: 74, advanced: 0, isEOF: 0, totalKeysExamined: 74 } ] test\u003e db.serverStatus().metrics.operation.scanAndOrder Long(\u0026#39;0\u0026#39;) test\u003e q().hint(\u0026#34;esr_bad\u0026#34;).toArray().length 10 test\u003e db.serverStatus().metrics.operation.scanAndOrder Long(\u0026#39;1\u0026#39;) esr_good 계획이 74번 일해 10건을 내고 limit에 닿아 EOF가 되었습니다. 점수는 1 + 10/74 + 2ε + 1 = 2.1353입니다. 같은 74번 동안 esr_bad 계획은 SORT가 입력을 다 받기 전에는 아무것도 내놓지 못하므로 advanced: 0이었습니다. blocking 단계가 있는 계획은 trial에서 불리합니다. 이 쿼리처럼 정렬과 limit이 있는 경우에는 그것이 맞는 판단입니다. blocking SORT로 실행한 쿼리가 끝날 때마다 metrics.operation.scanAndOrder가 1씩 늡니다. esr_bad로 한 번 실행하자 0에서 1이 되었습니다. 인덱스로 정렬하지 못하는 쿼리가 얼마나 도는지 보는 지표입니다. blocking SORT가 쓰는 메모리가 100MB(internalQueryMaxBlockingSortMemoryUsageBytes)를 넘으면 디스크의 임시 파일을 쓰고, allowDiskUse: false면 오류로 끝납니다. 커버링 쿼리: FETCH가 없는 계획 필요한 필드가 모두 인덱스 key 안에 있으면 컬렉션 테이블을 찾아갈 필요가 없습니다. 이런 쿼리를 커버링 쿼리라고 하고, explain에 FETCH 대신 PROJECTION_COVERED가 나옵니다. _id는 따로 빼지 않으면 결과에 포함되므로, _id가 인덱스에 없다면 projection에서 _id: 0으로 빼야 커버됩니다.\n_id를 빼면 커버된다 test\u003e st = (s) =\u0026gt; s.stage + (s.inputStage ? \u0026#34; \u0026gt; \u0026#34; + st(s.inputStage) : \u0026#34;\u0026#34;) [Function: st] test\u003e c1 = db.orders.find({status: \u0026#34;paid\u0026#34;, ts: {$gte: 199000}}, {_id: 0, status: 1, ts: 1}).hint(\u0026#34;esr_good\u0026#34;).explain(\u0026#34;executionStats\u0026#34;); st(c1.executionStats.executionStages) PROJECTION_COVERED \u0026gt; IXSCAN test\u003e ({nReturned: c1.executionStats.nReturned, totalKeysExamined: c1.executionStats.totalKeysExamined, totalDocsExamined: c1.executionStats.totalDocsExamined}) { nReturned: 250, totalKeysExamined: 250, totalDocsExamined: 0 } test\u003e c2 = db.orders.find({status: \u0026#34;paid\u0026#34;, ts: {$gte: 199000}}, {status: 1, ts: 1}).hint(\u0026#34;esr_good\u0026#34;).explain(\u0026#34;executionStats\u0026#34;); st(c2.executionStats.executionStages) PROJECTION_SIMPLE \u0026gt; FETCH \u0026gt; IXSCAN test\u003e ({nReturned: c2.executionStats.nReturned, totalKeysExamined: c2.executionStats.totalKeysExamined, totalDocsExamined: c2.executionStats.totalDocsExamined}) { nReturned: 250, totalKeysExamined: 250, totalDocsExamined: 250 } _id: 0을 준 쿼리는 PROJECTION_COVERED \u0026gt; IXSCAN이고 totalDocsExamined: 0입니다. 250건을 돌려주면서 도큐먼트는 하나도 읽지 않았습니다. _id를 빼지 않은 쿼리는 FETCH가 끼어 도큐먼트 250개를 읽었습니다. 인덱스 key에는 _id가 없기 때문입니다(앞의 wt 실습에서 본 것처럼 key에는 인덱스 필드와 RecordId뿐입니다). multikey 인덱스 배열 필드에 인덱스를 만들면 배열 원소마다 인덱스 항목이 하나씩 생깁니다. 이런 인덱스를 multikey 인덱스라고 하고, 어떤 경로가 배열이었는지를 인덱스 메타데이터에 기록합니다. multikey 경로에서는 플래너가 쓸 수 있는 범위 결합 방식이 제한되고(한 도큐먼트가 여러 key를 가지므로), 커버링도 되지 않습니다.\nisMultiKey와 multiKeyPaths test\u003e db.posts.insertMany([{_id: 1, tags: [\u0026#34;db\u0026#34;, \u0026#34;mongo\u0026#34;]}, {_id: 2, tags: [\u0026#34;db\u0026#34;, \u0026#34;pg\u0026#34;]}, {_id: 3, tags: \u0026#34;misc\u0026#34;}]).acknowledged true test\u003e db.posts.createIndex({tags: 1}) tags_1 test\u003e m = db.posts.find({tags: \u0026#34;db\u0026#34;}).explain(\u0026#34;executionStats\u0026#34;); m.executionStats.executionStages.stage FETCH test\u003e ({isMultiKey: m.queryPlanner.winningPlan.inputStage.isMultiKey, multiKeyPaths: m.queryPlanner.winningPlan.inputStage.multiKeyPaths, keysExamined: m.executionStats.totalKeysExamined, nReturned: m.executionStats.nReturned}) { isMultiKey: true, multiKeyPaths: { tags: [ \u0026#39;tags\u0026#39; ] }, keysExamined: 2, nReturned: 2 } test\u003e db.posts.stats({indexDetails: true}).indexSizes { _id_: 4096, tags_1: 20480 } 도큐먼트 두 개에 tags가 배열이라 isMultiKey: true이고, multiKeyPaths에 배열이었던 경로 tags가 적혔습니다. {tags: \u0026quot;db\u0026quot;}는 \u0026quot;db\u0026quot; key 두 개를 읽어 두 도큐먼트를 찾았습니다. 도큐먼트 3개에 원소는 5개이므로 tags_1에는 항목이 5개 있습니다. 인덱스가 처음으로 multikey가 되는 순간 그 컬렉션의 plan cache도 비워집니다(앞 절). classic 엔진과 SBE MongoDB에는 실행 엔진이 둘 있습니다. 앞에서 본 PlanStage 트리(IXSCAN, FETCH, SORT\u0026hellip;)를 work()로 한 단계씩 돌리는 classic 엔진과, 슬롯 기반으로 컴파일한 실행 트리를 쓰는 SBE(Slot-Based Execution) 엔진입니다. 8.0.32의 기본 설정(internalQueryFrameworkControl: trySbeRestricted, query_knobs.idl)에서는 집계 파이프라인 앞부분의 $group, $lookup 등을 find 계층으로 내려보낼 수 있는 쿼리만 SBE로 실행하고, 그 밖의 find는 classic으로 실행합니다(shouldUseRegularSbe()). 어느 쪽이 쓰였는지는 explain의 explainVersion으로 알 수 있습니다.\nexplainVersion 1과 2 test\u003e f = db.orders.find({status: \u0026#34;paid\u0026#34;}).explain(); f.ok 1 test\u003e ({explainVersion: f.explainVersion, winningPlanKeys: Object.keys(f.queryPlanner.winningPlan)}) { explainVersion: \u0026#39;1\u0026#39;, winningPlanKeys: [ \u0026#39;isCached\u0026#39;, \u0026#39;stage\u0026#39;, \u0026#39;inputStage\u0026#39; ] } test\u003e g = db.orders.explain().aggregate([{$match: {status: \u0026#34;paid\u0026#34;}}, {$group: {_id: \u0026#34;$amount\u0026#34;, n: {$sum: 1}}}]); g.ok 1 test\u003e ({explainVersion: g.explainVersion, winningPlanKeys: Object.keys(g.queryPlanner.winningPlan), queryPlanStages: g.queryPlanner.winningPlan.queryPlan.stage + \u0026#34; \u0026gt; \u0026#34; + g.queryPlanner.winningPlan.queryPlan.inputStage.stage}) { explainVersion: \u0026#39;2\u0026#39;, winningPlanKeys: [ \u0026#39;isCached\u0026#39;, \u0026#39;queryPlan\u0026#39;, \u0026#39;slotBasedPlan\u0026#39; ], queryPlanStages: \u0026#39;GROUP \u0026gt; PROJECTION_COVERED\u0026#39; } test\u003e db.adminCommand({getParameter: 1, internalQueryFrameworkControl: 1}).internalQueryFrameworkControl trySbeRestricted test\u003e db.orders.aggregate([{$match: {status: \u0026#34;paid\u0026#34;}}, {$group: {_id: \u0026#34;$amount\u0026#34;, n: {$sum: 1}}}]).toArray().length 250 test\u003e db.serverStatus().metrics.query.planCache { totalQueryShapes: Long(\u0026#39;1\u0026#39;), totalSizeEstimateBytes: Long(\u0026#39;2875\u0026#39;), classic: { hits: Long(\u0026#39;2\u0026#39;), misses: Long(\u0026#39;7\u0026#39;), replanned: Long(\u0026#39;1\u0026#39;), skipped: Long(\u0026#39;24\u0026#39;) }, sbe: { hits: Long(\u0026#39;0\u0026#39;), misses: Long(\u0026#39;0\u0026#39;), replanned: Long(\u0026#39;0\u0026#39;), skipped: Long(\u0026#39;0\u0026#39;) } } 단순한 find는 explainVersion: '1'(classic)이고, winningPlan이 바로 stage/inputStage 트리입니다. $group이 있는 집계는 explainVersion: '2'(SBE)이고, winningPlan 아래에 계획의 모양을 보여 주는 queryPlan(GROUP \u0026gt; PROJECTION_COVERED)과 실제 SBE 실행 트리인 slotBasedPlan이 따로 있습니다. $group이 find 계층으로 내려가 GROUP 단계가 되었고, status와 amount만 쓰므로 인덱스만으로 커버되었습니다(PROJECTION_COVERED). SBE로 실행했지만 sbe 쪽 plan cache 카운터는 모두 0입니다. 8.0에서 SBE 전용 plan cache는 featureFlagSbeFull이 켜져 있을 때만 쓰이고(classic_plan_cache.cpp), 기본 설정에서는 SBE로 실행하는 쿼리도 계획 선택은 classic의 multi-planner와 classic plan cache를 씁니다. explain을 읽을 때는 explainVersion부터 보고, 2라면 queryPlanner.winningPlan.queryPlan을 보면 됩니다. 운영에서는 이렇게 나타납니다 갑자기 느려진 쿼리: 계획이 바뀌었는지부터 본다 코드도 데이터 양도 그대로인데 특정 쿼리가 갑자기 느려졌다면, plan cache에 다른 계획이 들어갔을 가능성을 먼저 봅니다. replan 실습처럼 같은 shape의 쿼리라도 값에 따라 좋은 인덱스가 다르면, 어느 값으로 먼저 trial을 했느냐에 따라 캐시에 들어가는 계획이 달라집니다. 확인 순서는 이렇습니다.\nslow query 로그의 planSummary로 실제로 쓴 인덱스를 봅니다. 평소와 다른 인덱스이거나 COLLSCAN이면 계획이 바뀐 것입니다. keysExamined, docsExamined가 nreturned보다 훨씬 크면 비효율적인 계획입니다. replanned: true와 replanReason이 있으면 replan이 일어난 쿼리입니다. 같은 로그의 planCacheShapeHash(queryHash)로 $planCacheStats에서 엔트리를 찾아 cachedPlan, works, isActive, createdFromQuery를 봅니다. createdFromQuery는 그 엔트리를 만든 쿼리의 실제 값이라, 어떤 값 때문에 이 계획이 캐시되었는지 알려 줍니다. explain(\u0026quot;allPlansExecution\u0026quot;)로 후보별 score, works, advanced를 비교합니다. serverStatus().metrics.query.planCache.classic.replanned가 계속 늘면 계획이 자주 뒤바뀌고 있는 것입니다. 당장의 조치는 planCacheClear로 해당 컬렉션의 캐시를 비우는 것이지만, 같은 일이 다시 생길 수 있습니다. 근본적으로는 두 조건을 함께 쓰는 복합 인덱스처럼 값에 덜 민감한 인덱스를 만들거나(앞의 {a: 1, b: 1}처럼), 쿼리에 hint를 줍니다. 애플리케이션 코드를 바꿀 수 없다면 8.0부터는 setQuerySettings로 쿼리 shape에 쓸 인덱스를 서버에서 지정할 수 있습니다(기존의 index filter, planCacheSetFilter는 8.0에서 deprecated). hint를 준 쿼리는 계획 경쟁을 하지 않으므로 classic plan cache에 기록되지도 않습니다(classic_plan_cache.cpp).\nexplain은 이 순서로 읽는다 explainVersion: 1이면 classic, 2면 SBE. 2라면 winningPlan.queryPlan을 봅니다. winningPlan의 단계: COLLSCAN인지, IXSCAN의 indexName과 indexBounds가 기대한 범위인지, SORT가 있는지, FETCH 뒤에 filter가 남아 있는지. executionStats의 nReturned, totalKeysExamined, totalDocsExamined: 셋이 비슷할수록 좋습니다. totalKeysExamined가 nReturned보다 훨씬 크면 인덱스 범위가 넓은 것이고, totalDocsExamined가 크면 FETCH 뒤에서 많이 버리는 것입니다. 후보가 여럿이었다면 allPlansExecution의 score와 works, rejectedPlans. 인덱스는 많을수록 좋지 않다 인덱스 하나는 WiredTiger 테이블 하나입니다. 도큐먼트를 넣거나 인덱스 필드를 고칠 때마다 모든 관련 인덱스 테이블에 key를 넣고 지워야 하고, 각 테이블이 캐시와 디스크를 차지합니다. multikey 인덱스는 배열 원소 수만큼 항목이 생깁니다. 인덱스가 많으면 플래너가 경쟁시킬 후보도 늘어 trial 비용과 잘못 고를 가능성도 커집니다. $indexStats로 인덱스별 사용 횟수(accesses.ops)를 보고, 오랫동안 쓰이지 않은 인덱스는 먼저 숨겨서(hideIndex) 영향을 확인한 뒤 지우는 방법을 씁니다.\n정리 인덱스는 key가 KeyString(타입 순서까지 바이트 비교로 정렬되는 인코딩)인 WiredTiger 테이블입니다. 일반 인덱스와 unique 인덱스는 key 끝에 RecordId를 붙이고, _id 인덱스만 RecordId를 value에 둡니다. 인덱스에 없는 필드가 필요하면 RecordId로 컬렉션을 한 번 더 읽습니다(FETCH). 쿼리는 CanonicalQuery로 정규화되어 shape가 정해지고, active plan cache 엔트리가 있으면 그 계획을, 없으면 QueryPlanner가 열거한 후보를 실제로 조금씩 실행해 고릅니다. trial은 결과 101개, EOF, 또는 후보마다 max(10000, 도큐먼트 수의 30%)번의 work()에서 끝나고, 점수는 1 + advanced/works + 작은 보너스 + EOF 보너스 1입니다. plan cache 엔트리는 inactive로 시작해, 같은 shape의 다음 trial에서 기록된 works 이하로 끝나면 active가 됩니다. 캐시된 계획이 기록된 works의 10배를 넘기면 replan합니다. 인덱스를 만들거나 지우면 캐시가 비워집니다. ESR 순서의 복합 인덱스는 blocking SORT 없이 정렬된 순서로 읽고 limit에서 일찍 멈출 수 있습니다. 필요한 필드가 모두 인덱스에 있으면 FETCH 없는 커버링 쿼리가 됩니다. 8.0 기본 설정에서 SBE는 $group, $lookup 등을 내려보낼 수 있는 집계에만 쓰이고, 그 밖의 find는 classic입니다. explainVersion으로 구분합니다. 다음 글에서는 단일 서버를 벗어나, 쓰기가 oplog를 통해 레플리카셋의 다른 멤버로 전달되는 과정을 살펴봅니다.\n참고 자료 소스 코드 (r8.0.32, 커밋 8f1f561 기준)\nsrc/mongo/db/storage/wiredtiger/wiredtiger_index.cpp: 인덱스 테이블 형식, 항목 삽입 src/mongo/db/storage/key_string.cpp: KeyString 인코딩 src/mongo/db/query/get_executor.cpp: 실행 계획을 얻는 과정, 엔진 선택 src/mongo/db/query/query_planner.cpp: 후보 계획 열거 src/mongo/db/exec/multi_plan.cpp, src/mongo/db/query/plan_ranker.h, plan_ranker_util.h: trial과 점수 src/mongo/db/query/plan_cache.h, src/mongo/db/exec/cached_plan.cpp: plan cache 엔트리 상태, replan src/mongo/db/query/query_knobs.idl: 쿼리 관련 파라미터 기본값 MongoDB 8.0 공식 문서\nQuery Plans Explain Results The ESR (Equality, Sort, Range) Guideline $planCacheStats setQuerySettings ","permalink":"https://jinlog.dev/posts/mongodb/04-index-and-query-planner/","summary":"인덱스는 디스크에 어떻게 저장되고, MongoDB는 여러 실행 계획 중 하나를 어떻게 고르는가","title":"MongoDB 인터널 4: 인덱스 구조와 쿼리 플래너의 계획 선택 방식"},{"content":"개요 UPDATE가 많은 테이블이 계속 커집니다. autovacuum은 분명히 돌고 있고 로그도 남는데 n_dead_tup이 줄지 않습니다. 이럴 때 VACUUM은 일을 안 하는 것이 아니라 못 하는 것입니다. 어딘가에 오래된 트랜잭션이 있어서, 그 트랜잭션이 아직 볼 수도 있는 옛 버전을 지울 수 없기 때문입니다.\nVACUUM이 지울 수 있는 경계를 xmin horizon이라고 부릅니다. 지금 살아 있는 트랜잭션과 스냅샷 가운데 가장 오래된 것보다 먼저 지워진 행만 지울 수 있습니다(인터널 4편, 5편). 이 경계를 붙잡는 것이 무엇인지 찾는 것이 이 글의 주제입니다.\n이 글에서 답할 질문은 다음과 같습니다.\nVACUUM이 dead tuple을 지우지 못하고 있다는 것은 어디서 보이는가 xmin horizon을 붙잡는 것은 무엇이 있고, 한 번에 어떻게 찾는가 세션을 모두 끊었는데도 VACUUM이 지우지 못한다면 무엇을 봐야 하는가 이런 트랜잭션이 생기지 않게 하려면 어떤 설정을 거는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n먼저 확인할 것 VACUUM이 지우지 못했다는 표시 실습에서는 트랜잭션 세 개를 열어 둔 채(아래에서 하나씩 봅니다) 1만 행짜리 테이블을 다섯 번 통째로 UPDATE했습니다. VACUUM 결과를 일정하게 보려고 이 테이블의 autovacuum은 끄고 직접 VACUUM합니다.\npostgres=# UPDATE t SET v = v + 1; UPDATE 10000 ... postgres=# UPDATE t SET v = v + 1; UPDATE 10000 postgres=# SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_table_size(\u0026#39;t\u0026#39;)) AS size postgres-# FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_live_tup | n_dead_tup | size ------------+------------+--------- 10000 | 50000 | 2160 kB (1 row) postgres=# VACUUM (VERBOSE) t; INFO: vacuuming \u0026#34;postgres.public.t\u0026#34; VACUUM INFO: finished vacuuming \u0026#34;postgres.public.t\u0026#34;: index scans: 0 pages: 0 removed, 266 remain, 266 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 60000 remain, 50000 are dead but not yet removable removable cutoff: 757, which was 7 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 587 hits, 0 reads, 0 dirtied WAL usage: 1 records, 0 full page images, 307 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s postgres=# SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_table_size(\u0026#39;t\u0026#39;)) AS size postgres-# FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_live_tup | n_dead_tup | size ------------+------------+--------- 10000 | 50000 | 2160 kB (1 row) 핵심은 두 줄입니다.\ntuples: 0 removed, 60000 remain, 50000 are dead but not yet removable: dead tuple 5만 개가 있는데 하나도 지우지 못했습니다. removable cutoff: 757, which was 7 XIDs old when operation ended: 트랜잭션 ID 757보다 먼저 지워진 행만 지울 수 있다는 뜻입니다. 이 값이 xmin horizon입니다. autovacuum도 같은 두 줄을 로그에 남깁니다(1편의 log_autovacuum_min_duration). dead but not yet removable이 계속 크게 찍히고 removable cutoff의 XIDs old가 계속 커진다면, 무언가가 horizon을 붙잡고 있는 것입니다. 이 실습은 몇 초 동안이라 7이지만, 운영에서 이 값이 수백만, 수억이 되면 6편의 wraparound 문제로 이어집니다.\nhorizon을 붙잡는 것을 한 번에 찾기 xmin horizon을 붙잡을 수 있는 것은 크게 세 곳에 있습니다.\n어디서 보는가 무엇이 붙잡는가 pg_stat_activity 세션의 트랜잭션 ID(backend_xid)나 스냅샷(backend_xmin) pg_prepared_xacts 2단계 커밋의 첫 단계(PREPARE TRANSACTION)만 마친 트랜잭션 pg_replication_slots replication slot의 xmin(standby의 hot_standby_feedback), catalog_xmin(논리 복제) 세 곳을 한 번에 보는 쿼리입니다. 나이(age)가 많은 순서로 정렬했습니다.\npostgres=# SELECT \u0026#39;session\u0026#39; AS kind, pid::text AS id, state, postgres-# age(backend_xid) AS xid_age, age(backend_xmin) AS xmin_age, xact_start postgres-# FROM pg_stat_activity postgres-# WHERE (backend_xid IS NOT NULL OR backend_xmin IS NOT NULL) AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# UNION ALL postgres-# SELECT \u0026#39;prepared\u0026#39;, gid, NULL, age(transaction), NULL, prepared postgres-# FROM pg_prepared_xacts postgres-# UNION ALL postgres-# SELECT \u0026#39;slot\u0026#39;, slot_name, NULL, age(xmin), age(catalog_xmin), NULL postgres-# FROM pg_replication_slots postgres-# ORDER BY 4 DESC NULLS LAST, 5 DESC NULLS LAST; kind | id | state | xid_age | xmin_age | xact_start ----------+----------+---------------------+---------+----------+------------------------------- prepared | batch-42 | | 7 | | 2026-09-26 10:07:14.95094+00 session | 305 | idle in transaction | 6 | | 2026-09-26 10:07:23.77488+00 session | 224 | idle in transaction | | 7 | 2026-09-26 10:07:19.093974+00 (3 rows) 세 개가 나왔습니다. prepared transaction 하나와 세션 둘입니다. slot은 이 실습에 없습니다. 하나씩 봅니다.\n원인별 진단 세션: 스냅샷을 쥐거나, 트랜잭션 ID를 쥐거나 두 세션은 이렇게 만들었습니다.\nA=# SET application_name = \u0026#39;report\u0026#39;; SET A=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN A=# SELECT count(*) FROM t; count ------- 10000 (1 row) B=# SET application_name = \u0026#39;worker\u0026#39;; SET B=# BEGIN; BEGIN B=# UPDATE jobs SET state = \u0026#39;running\u0026#39; WHERE id = 1; UPDATE 1 pg_stat_activity에서 두 세션의 차이가 보입니다.\npostgres=# SELECT pid, application_name AS app, state, backend_xid, backend_xmin, postgres-# age(backend_xmin) AS xmin_age, now() - xact_start AS xact_age postgres-# FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# ORDER BY xact_start; pid | app | state | backend_xid | backend_xmin | xmin_age | xact_age -----+--------+---------------------+-------------+--------------+----------+----------------- 224 | report | idle in transaction | | 757 | 7 | 00:00:06.806246 305 | worker | idle in transaction | 758 | | | 00:00:02.12534 (2 rows) report(REPEATABLE READ)는 읽기만 했으므로 트랜잭션 ID(backend_xid)가 없습니다. 대신 트랜잭션이 끝날 때까지 같은 스냅샷을 써야 하므로, 스냅샷의 xmin(backend_xmin 757)을 붙잡고 있습니다. 보고서나 덤프처럼 오래 읽는 작업이 이런 모양입니다. worker는 행을 고쳤으므로 트랜잭션 ID 758을 받았고, 커밋이나 롤백을 할 때까지 이 ID가 살아 있습니다. 둘 다 idle in transaction입니다. 아무 일도 하지 않으면서 horizon을 붙잡고 있습니다. active 상태로 몇 시간째 도는 쿼리도 같은 방식으로 backend_xmin을 붙잡습니다.\nprepared transaction: 세션이 없는데 남아 있다 두 세션을 끊습니다.\npostgres=# SELECT pid, application_name, pg_terminate_backend(pid) postgres-# FROM pg_stat_activity WHERE application_name IN (\u0026#39;report\u0026#39;, \u0026#39;worker\u0026#39;); pid | application_name | pg_terminate_backend -----+------------------+---------------------- 224 | report | t 305 | worker | t (2 rows) postgres=# VACUUM (VERBOSE) t; INFO: vacuuming \u0026#34;postgres.public.t\u0026#34; INFO: finished vacuuming \u0026#34;postgres.public.t\u0026#34;: index scans: 0 pages: 0 removed, 266 remain, 266 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 60000 remain, 50000 are dead but not yet removable removable cutoff: 757, which was 7 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 578 hits, 0 reads, 0 dirtied WAL usage: 0 records, 0 full page images, 0 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s VACUUM 여전히 5만 개를 지우지 못했습니다. removable cutoff도 757 그대로입니다. 세션은 하나도 남아 있지 않습니다.\npostgres=# SELECT count(*) AS sessions FROM pg_stat_activity postgres-# WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid(); sessions ---------- 0 (1 row) pg_stat_activity만 보고 있으면 여기서 막힙니다. 앞의 쿼리를 다시 돌리면 남은 것이 보입니다.\npostgres=# SELECT \u0026#39;session\u0026#39; AS kind, pid::text AS id, state, postgres-# age(backend_xid) AS xid_age, age(backend_xmin) AS xmin_age, xact_start postgres-# FROM pg_stat_activity postgres-# WHERE (backend_xid IS NOT NULL OR backend_xmin IS NOT NULL) AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# UNION ALL postgres-# SELECT \u0026#39;prepared\u0026#39;, gid, NULL, age(transaction), NULL, prepared postgres-# FROM pg_prepared_xacts postgres-# UNION ALL postgres-# SELECT \u0026#39;slot\u0026#39;, slot_name, NULL, age(xmin), age(catalog_xmin), NULL postgres-# FROM pg_replication_slots postgres-# ORDER BY 4 DESC NULLS LAST, 5 DESC NULLS LAST; kind | id | state | xid_age | xmin_age | xact_start ----------+----------+-------+---------+----------+------------------------------ prepared | batch-42 | | 7 | | 2026-09-26 10:07:14.95094+00 (1 row) postgres=# SELECT gid, prepared, owner, database, age(transaction) AS xid_age FROM pg_prepared_xacts; gid | prepared | owner | database | xid_age ----------+------------------------------+----------+----------+--------- batch-42 | 2026-09-26 10:07:14.95094+00 | postgres | postgres | 7 (1 row) batch-42는 이렇게 만들었습니다. 트랜잭션 안에서 행을 고치고 PREPARE TRANSACTION까지 한 뒤 세션을 닫았습니다.\nC=# BEGIN; BEGIN C=# UPDATE jobs SET state = \u0026#39;running\u0026#39; WHERE id = 3; UPDATE 1 C=# PREPARE TRANSACTION \u0026#39;batch-42\u0026#39;; PREPARE TRANSACTION PREPARE TRANSACTION은 2단계 커밋(two-phase commit)의 첫 단계입니다. 트랜잭션을 세션에서 떼어 디스크에 기록해 두고, 나중에 어느 세션에서든 COMMIT PREPARED나 ROLLBACK PREPARED로 마무리하게 합니다(PREPARE TRANSACTION). 여러 DB에 걸친 트랜잭션을 조율하는 트랜잭션 관리자가 쓰는 기능인데, 관리자가 죽거나 버그로 두 번째 단계를 보내지 않으면 이렇게 주인 없는 트랜잭션이 남습니다.\n이 트랜잭션은 서버를 재시작해도 사라지지 않습니다.\npostgres=# SELECT gid, prepared, age(transaction) AS xid_age FROM pg_prepared_xacts; gid | prepared | xid_age ----------+------------------------------+--------- batch-42 | 2026-09-26 10:07:14.95094+00 | 7 (1 row) postgres=# SELECT locktype, relation::regclass, mode, granted, virtualtransaction postgres-# FROM pg_locks WHERE virtualtransaction LIKE \u0026#39;-1/%\u0026#39;; locktype | relation | mode | granted | virtualtransaction ---------------+-----------+------------------+---------+-------------------- relation | jobs_pkey | RowExclusiveLock | t | -1/757 transactionid | | ExclusiveLock | t | -1/757 relation | jobs | RowExclusiveLock | t | -1/757 (3 rows) 재시작 뒤에도 남아 있고, 고친 행의 락도 그대로 쥐고 있습니다. virtualtransaction이 -1/로 시작하는 락이 prepared transaction의 락입니다. 이 트랜잭션이 고친 jobs의 3번 행을 다른 세션이 고치려 하면 2편처럼 기다리게 되는데, 기다리게 만든 세션은 어디에도 없습니다.\nmax_prepared_transactions의 기본값은 0이라서, 이 기능을 쓰려고 일부러 켠 서버에서만 생기는 문제입니다. 실습에서도 10으로 켜고 재시작했습니다.\nreplication slot과 standby 이 실습에는 없지만 세 번째 후보입니다. standby가 hot_standby_feedback = on으로 붙어 있으면, standby에서 오래 도는 쿼리의 xmin이 primary의 slot xmin으로 전달되어 primary의 VACUUM을 막습니다. 논리 복제 slot은 catalog_xmin으로 시스템 카탈로그의 정리를 막습니다. 구독자가 사라진 slot이 남아 있으면 horizon이 계속 붙잡힙니다. 이 경우는 8편에서 standby와 함께 다룹니다(인터널 9편).\n조치 찾은 원인에 따라 다음과 같이 합니다.\n원인 조치 주의 세션 pg_terminate_backend(pid) 그 트랜잭션은 롤백됩니다(2편) prepared transaction ROLLBACK PREPARED 'gid' 또는 COMMIT PREPARED 'gid' 어느 쪽이 맞는지는 그 트랜잭션을 만든 시스템이 알고 있습니다 replication slot 쓰지 않는 slot이면 pg_drop_replication_slot('이름') 그 slot을 쓰는 standby나 구독자는 다시 구성해야 합니다 prepared transaction은 커밋할지 롤백할지를 DBA가 마음대로 정하면 안 됩니다. 다른 DB에서는 같은 트랜잭션이 이미 커밋되었을 수 있습니다. gid(여기서는 batch-42)와 prepared 시각, owner, database를 가지고 트랜잭션 관리자 쪽에서 결과를 확인한 뒤 맞는 쪽으로 마무리합니다. 이 실습에서는 롤백합니다.\npostgres=# ROLLBACK PREPARED \u0026#39;batch-42\u0026#39;; ROLLBACK PREPARED postgres=# VACUUM (VERBOSE) t; INFO: vacuuming \u0026#34;postgres.public.t\u0026#34; INFO: finished vacuuming \u0026#34;postgres.public.t\u0026#34;: index scans: 1 pages: 0 removed, 266 remain, 266 scanned (100.00% of total), 0 eagerly scanned tuples: 50000 removed, 10000 remain, 0 are dead but not yet removable removable cutoff: 764, which was 0 XIDs old when operation ended new relfrozenxid: 763, which is 10 XIDs ahead of previous value frozen: 1 pages from table (0.38% of total) had 172 tuples frozen visibility map: 266 pages set all-visible, 222 pages set all-frozen (0 were all-visible) index scan needed: 222 pages from table (83.46% of total) had 50000 dead item identifiers removed index \u0026#34;t_pkey\u0026#34;: pages: 111 in total, 0 newly deleted, 0 currently deleted, 0 reusable avg read rate: 1356.368 MB/s, avg write rate: 1342.983 MB/s buffer usage: 318 hits, 608 reads, 602 dirtied WAL usage: 824 records, 380 full page images, 953157 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s VACUUM postgres=# SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_table_size(\u0026#39;t\u0026#39;)) AS size postgres-# FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_live_tup | n_dead_tup | size ------------+------------+--------- 10000 | 0 | 2160 kB (1 row) 이제 50000 removed, 0 are dead but not yet removable입니다. removable cutoff도 764로 올라갔습니다.\n그런데 테이블 크기는 2160 kB 그대로입니다. VACUUM은 dead tuple이 차지하던 공간을 새 행이 다시 쓸 수 있게 할 뿐, 파일을 줄이지는 않습니다. horizon이 오래 붙잡혀 있던 동안 부풀어 오른 테이블을 되돌리는 방법은 5편에서 다룹니다.\n재발 방지 타임아웃 PostgreSQL에는 이런 트랜잭션을 자동으로 끊는 설정이 있지만, 모두 기본으로 꺼져 있습니다.\npostgres=# SELECT name, setting, unit FROM pg_settings postgres-# WHERE name IN (\u0026#39;idle_in_transaction_session_timeout\u0026#39;, \u0026#39;transaction_timeout\u0026#39;, \u0026#39;statement_timeout\u0026#39;, \u0026#39;idle_session_timeout\u0026#39;) postgres-# ORDER BY name; name | setting | unit -------------------------------------+---------+------ idle_in_transaction_session_timeout | 0 | ms idle_session_timeout | 0 | ms statement_timeout | 0 | ms transaction_timeout | 0 | ms (4 rows) idle_in_transaction_session_timeout은 트랜잭션 안에서 아무것도 하지 않고 기다린 시간이 이 값을 넘으면 세션을 끊습니다.\nD=# SET idle_in_transaction_session_timeout = \u0026#39;3s\u0026#39;; SET D=# BEGIN; BEGIN D=# SELECT count(*) FROM t; count ------- 10000 (1 row) D=# SELECT 1; FATAL: terminating connection due to idle-in-transaction timeout server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost 트랜잭션을 연 채 3초 넘게 가만히 있자 세션이 끊겼습니다. 클라이언트는 다음 명령을 보낼 때에야 알게 됩니다.\ntransaction_timeout(PostgreSQL 17부터)은 트랜잭션이 시작된 뒤의 전체 시간을 봅니다. idle이든 쿼리 실행 중이든 상관없습니다.\nE=# SET transaction_timeout = \u0026#39;3s\u0026#39;; SET E=# BEGIN; BEGIN E=# SELECT pg_sleep(1); pg_sleep ---------- (1 row) E=# SELECT pg_sleep(5); FATAL: terminating connection due to transaction timeout server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost 1초짜리 쿼리는 통과했지만, 트랜잭션 시작부터 3초가 지나자 5초짜리 쿼리를 실행하던 도중에 세션이 끊겼습니다. 두 경우 모두 서버 로그에 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;idle-in-transaction timeout|transaction timeout\u0026#39; 2026-09-26 10:07:35.956 UTC [544] postgres@postgres/forgetful FATAL: terminating connection due to idle-in-transaction timeout 2026-09-26 10:07:46.483 UTC [677] postgres@postgres/slowbatch FATAL: terminating connection due to transaction timeout 설정 무엇을 재는가 넘으면 statement_timeout 문장 하나의 실행 시간 그 문장만 취소 idle_in_transaction_session_timeout 트랜잭션 안에서 idle로 있는 시간 세션 종료 transaction_timeout 트랜잭션 전체 시간 세션 종료 idle_session_timeout 트랜잭션 밖에서 idle로 있는 시간 세션 종료 statement_timeout은 idle 상태의 트랜잭션을 잡지 못합니다. 이 글의 report, worker 같은 세션은 실행 중인 문장이 없기 때문입니다. idle_session_timeout은 트랜잭션 밖의 idle 세션을 끊으므로 horizon과는 관계가 없고, 커넥션 풀이 유지하는 연결을 끊어 버릴 수 있어 조심해서 씁니다.\n서버 전체에 걸기보다는 애플리케이션 계정에 겁니다. 백업이나 관리 작업 계정까지 끊기면 곤란하기 때문입니다.\nALTER ROLE app SET idle_in_transaction_session_timeout = \u0026#39;60s\u0026#39;; ALTER ROLE app SET transaction_timeout = \u0026#39;10min\u0026#39;; 값은 그 애플리케이션의 가장 긴 정상 트랜잭션보다 넉넉하게 잡습니다. 새로 접속하는 세션부터 적용됩니다.\n모니터링 앞의 \u0026ldquo;한 번에 찾기\u0026rdquo; 쿼리에서 가장 큰 나이를 주기적으로 수집하고 알람을 겁니다.\n세션의 age(backend_xmin), age(backend_xid), now() - xact_start pg_prepared_xacts의 행 수와 prepared 시각. 2단계 커밋을 쓰지 않는다면 max_prepared_transactions = 0으로 두고, 쓴다면 몇 분 넘게 남아 있는 행이 있으면 알람을 보냅니다. pg_replication_slots의 age(xmin), age(catalog_xmin), 그리고 active = false인 slot autovacuum 로그의 dead but not yet removable과 removable cutoff ... XIDs old도 같은 신호입니다.\n정리 VACUUM 결과의 dead but not yet removable과 removable cutoff ... XIDs old가 커지면 무언가가 xmin horizon을 붙잡고 있습니다. 붙잡는 것은 세 곳에 있습니다. pg_stat_activity(세션), pg_prepared_xacts(prepared transaction), pg_replication_slots(slot). 세 곳을 한 번에 봅니다. REPEATABLE READ로 읽기만 한 세션은 backend_xmin을, 행을 고친 세션은 backend_xid를 쥡니다. 둘 다 idle in transaction으로 보입니다. prepared transaction은 세션이 없어도, 재시작해도 남고 락도 쥐고 있습니다. 세션을 다 끊어도 VACUUM이 지우지 못하면 pg_prepared_xacts를 봅니다. 커밋할지 롤백할지는 트랜잭션 관리자 쪽에서 확인합니다. idle_in_transaction_session_timeout과 transaction_timeout을 애플리케이션 계정에 걸어 둡니다. 둘 다 기본으로 꺼져 있습니다. 참고 자료 Routine Vacuuming pg_stat_activity, pg_prepared_xacts, pg_replication_slots PREPARE TRANSACTION, ROLLBACK PREPARED Client Connection Defaults: statement_timeout, idle_in_transaction_session_timeout, transaction_timeout, idle_session_timeout PostgreSQL 인터널 4편 MVCC, 5편 VACUUM, 9편 스트리밍 복제 ","permalink":"https://jinlog.dev/posts/postgresql-ops/04-long-transactions/","summary":"VACUUM이 돌아도 dead tuple이 줄지 않을 때, 무엇이 붙잡고 있는지 찾고 어떻게 막는가","title":"PostgreSQL 운영 4: 긴 트랜잭션과 idle in transaction"},{"content":"개요 두 사람이 같은 은행 계좌를 동시에 본다고 해 봅시다. 한 사람이 잔액을 고치는 중이면, 다른 사람은 고치기 전 값을 봐야 할까요, 고친 뒤 값을 봐야 할까요? 그리고 고치는 동안 읽는 사람을 기다리게 해야 할까요?\nPostgreSQL은 이 문제를 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)로 풉니다. 행을 제자리에서 고치지 않고 새 버전을 하나 더 만듭니다. 그리고 트랜잭션마다 \u0026ldquo;어느 시점까지 커밋된 것을 볼지\u0026quot;를 정한 스냅샷으로, 여러 버전 가운데 자기에게 보이는 버전 하나를 고릅니다. 그래서 읽는 쪽은 쓰는 쪽을 기다리지 않고, 쓰는 쪽도 읽는 쪽을 기다리지 않습니다.\n이 글에서 답할 질문은 다음과 같습니다.\nUPDATE, DELETE를 하면 페이지에서는 실제로 무슨 일이 일어나는가 스냅샷은 무엇이고, \u0026ldquo;보인다\u0026quot;는 것은 정확히 어떻게 판단하는가 READ COMMITTED와 REPEATABLE READ는 무엇이 다른가 두 트랜잭션이 같은 행을 동시에 고치면 어떻게 되는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n트랜잭션 ID와 행의 xmin, xmax 트랜잭션이 처음으로 xid가 필요해질 때(데이터를 바꾸거나, FOR UPDATE로 행을 잠그거나, pg_current_xact_id()를 부를 때) PostgreSQL은 32비트 트랜잭션 ID(xid)를 하나 발급합니다(GetNewTransactionId()). 그 전까지, 그리고 읽기만 하는 트랜잭션은 xid 없이 가상 ID만 있습니다. xid는 1씩 커지므로, 번호가 작을수록 먼저 xid를 받은(처음으로 쓰기를 한) 트랜잭션입니다. 트랜잭션을 시작한 순서와는 다를 수 있습니다(문서).\n3편에서 본 튜플 헤더에는 이 xid가 두 개 적혀 있습니다.\n필드 뜻 SQL로 볼 때 t_xmin 이 버전을 만든 트랜잭션 숨은 열 xmin t_xmax 이 버전을 지웠거나(UPDATE, DELETE) 잠근(FOR UPDATE) 트랜잭션. 없으면 0 숨은 열 xmax 모든 행에는 xmin과 xmax가 있다 서버를 띄우고, 페이지 내용을 들여다볼 pageinspect 확장과 실습용 테이블 acct를 만듭니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION pageinspect; postgres=# CREATE TABLE acct (id int PRIMARY KEY, balance int); postgres=# INSERT INTO acct VALUES (1, 100), (2, 100); 숨은 열까지 조회한 뒤, 한 트랜잭션 안에서 행을 두 개 더 넣습니다.\npostgres=# SELECT xmin, xmax, cmin, ctid, * FROM acct; xmin | xmax | cmin | ctid | id | balance ------+------+------+-------+----+--------- 754 | 0 | 0 | (0,1) | 1 | 100 754 | 0 | 0 | (0,2) | 2 | 100 (2 rows) postgres=# BEGIN; BEGIN postgres=# SELECT pg_current_xact_id() AS my_xid; my_xid -------- 755 (1 row) postgres=# INSERT INTO acct VALUES (3, 100); INSERT 0 1 postgres=# INSERT INTO acct VALUES (4, 100); INSERT 0 1 postgres=# SELECT xmin, cmin, ctid, * FROM acct WHERE id IN (3, 4); xmin | cmin | ctid | id | balance ------+------+-------+----+--------- 755 | 0 | (0,3) | 3 | 100 755 | 1 | (0,4) | 4 | 100 (2 rows) postgres=# COMMIT; COMMIT 처음 넣은 두 행은 xmin = 754입니다. 트랜잭션 754가 만들었다는 뜻입니다. 트랜잭션 755 안에서 INSERT를 두 번 하자 두 행 모두 xmin = 755이고, cmin이 0과 1로 다릅니다. 같은 트랜잭션 안에서 데이터를 바꾼 명령 가운데 몇 번째가 만들었는지입니다(앞의 SELECT처럼 읽기만 한 명령은 번호를 쓰지 않습니다). 명령마다 페이지에서 일어나는 일을 정리하면 이렇습니다.\n명령 페이지에서 일어나는 일 INSERT 새 튜플을 쓰고 xmin = 내 xid DELETE 기존 튜플의 xmax = 내 xid. 튜플은 그대로 남음 UPDATE 기존 튜플의 xmax = 내 xid, t_ctid가 새 버전을 가리킴. 새 튜플을 쓰고 xmin = 내 xid ROLLBACK 아무것도 되돌리지 않음. 내 xid가 \u0026ldquo;롤백됨\u0026quot;으로 기록될 뿐 마지막 줄이 중요합니다. PostgreSQL의 ROLLBACK은 페이지를 되돌리지 않습니다. 대신 각 트랜잭션이 커밋되었는지 롤백되었는지를 따로 기록해 두고, 튜플을 읽을 때마다 그 기록을 봅니다. 이 기록이 $PGDATA/pg_xact 디렉터리입니다. 트랜잭션 하나당 2비트로 네 가지 상태(진행 중, 커밋, 롤백, 하위 트랜잭션 커밋)를 적으므로, 8kB 페이지 하나에 트랜잭션 32768개의 상태가 들어갑니다.\n지워지거나 옛 버전이 된 튜플(dead tuple)은 아무도 볼 일이 없어진 뒤 VACUUM이 정리합니다. 5편에서 다룹니다.\nUPDATE는 새 버전을 만든다 postgres=# BEGIN; BEGIN postgres=# SELECT pg_current_xact_id() AS my_xid; my_xid -------- 756 (1 row) postgres=# UPDATE acct SET balance = 150 WHERE id = 1; UPDATE 1 postgres=# COMMIT; COMMIT postgres=# SELECT xmin, xmax, ctid, * FROM acct WHERE id = 1; xmin | xmax | ctid | id | balance ------+------+-------+----+--------- 756 | 0 | (0,5) | 1 | 150 (1 row) postgres=# SELECT lp, t_xmin, t_xmax, t_ctid, raw_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)), postgres-# LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2); lp | t_xmin | t_xmax | t_ctid | raw_flags ----+--------+--------+--------+---------------------------------------------------------------------- 1 | 754 | 756 | (0,5) | {HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED} 2 | 754 | 0 | (0,2) | {HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID} 3 | 755 | 0 | (0,3) | {HEAP_XMAX_INVALID} 4 | 755 | 0 | (0,4) | {HEAP_XMAX_INVALID} 5 | 756 | 0 | (0,5) | {HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE} (5 rows) id = 1의 잔액을 150으로 바꾸자, SQL로는 행이 하나지만 페이지에는 두 버전이 있습니다.\nlp 1(옛 버전): t_xmax = 756, t_ctid = (0,5). 트랜잭션 756이 지웠고, 새 버전은 5번에 있다는 뜻입니다. lp 5(새 버전): t_xmin = 756. HEAP_UPDATED는 UPDATE로 생긴 버전이라는 표시입니다. 옛 버전의 HEAP_HOT_UPDATED와 새 버전의 HEAP_ONLY_TUPLE은 이 UPDATE가 HOT(Heap-Only Tuple) 업데이트였다는 뜻입니다. 새 버전이 같은 페이지에 들어갔고 인덱스 열(id)이 바뀌지 않아서, 인덱스를 고치지 않고 버전 체인만 이었습니다. 5편에서 자세히 다룹니다. 운영에서는: UPDATE가 많은 테이블은 커진다 위 실습처럼 UPDATE는 행을 하나 더 만듭니다. 같은 행을 자주 바꾸는 테이블(재고, 카운터, 세션 상태 등)은 dead tuple이 빠르게 쌓이고, VACUUM이 따라가지 못하면 테이블과 인덱스가 커집니다(bloat). 이것이 PostgreSQL MVCC의 대가이고, VACUUM이 반드시 필요한 이유입니다.\nDELETE와 ROLLBACK도 튜플을 지우지 않는다 postgres=# DELETE FROM acct WHERE id = 4; DELETE 1 postgres=# BEGIN; BEGIN postgres=# SELECT pg_current_xact_id() AS rollback_xid \\gset postgres=# INSERT INTO acct VALUES (5, 100); INSERT 0 1 postgres=# ROLLBACK; ROLLBACK postgres=# SELECT * FROM acct ORDER BY id; id | balance ----+--------- 1 | 150 2 | 100 3 | 100 (3 rows) postgres=# SELECT lp, t_xmin, t_xmax, t_ctid, t_data postgres-# FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)); lp | t_xmin | t_xmax | t_ctid | t_data ----+--------+--------+--------+-------------------- 1 | 754 | 756 | (0,5) | \\x0100000064000000 2 | 754 | 0 | (0,2) | \\x0200000064000000 3 | 755 | 0 | (0,3) | \\x0300000064000000 4 | 755 | 757 | (0,4) | \\x0400000064000000 5 | 756 | 0 | (0,5) | \\x0100000096000000 6 | 758 | 0 | (0,6) | \\x0500000064000000 (6 rows) postgres=# SELECT :\u0026#39;rollback_xid\u0026#39; AS xid, pg_xact_status(:\u0026#39;rollback_xid\u0026#39;::xid8) AS status; xid | status -----+--------- 758 | aborted (1 row) DELETE한 id = 4(lp 4)는 페이지에 그대로 있고 t_xmax = 757만 적혔습니다. INSERT 후 ROLLBACK한 id = 5(lp 6, t_xmin = 758)도 페이지에 남아 있습니다. SQL로 조회하면 둘 다 보이지 않습니다. lp 4는 지운 트랜잭션 757이 커밋되었기 때문이고, lp 6은 만든 트랜잭션 758이 롤백되었기(pg_xact_status(758) = aborted) 때문입니다. 결국 이 페이지에는 튜플 6개가 있지만 보이는 행은 3개입니다. 나머지 3개(lp 1, 4, 6)가 dead tuple입니다. 커밋 여부는 pg_xact에 2비트로 적힌다 $ ls -l $PGDATA/pg_xact total 8 -rw------- 1 postgres postgres 8192 Sep 26 10:07 0000 postgres=# SELECT x AS xid, pg_xact_status(x::text::xid8) AS status FROM generate_series(754, 758) x; xid | status -----+----------- 754 | committed 755 | committed 756 | committed 757 | committed 758 | aborted (5 rows) pg_xact 파일 0000 하나가 8kB로, 지금까지의 트랜잭션 상태가 모두 여기 들어 있습니다. 754-757은 커밋, 758은 롤백입니다.\n스냅샷: \u0026ldquo;어느 시점까지를 볼 것인가\u0026rdquo; 스냅샷은 \u0026ldquo;지금 이 순간 어떤 트랜잭션이 끝났고 어떤 트랜잭션이 아직 진행 중인가\u0026quot;를 찍어 둔 것입니다(SnapshotData). 핵심 필드는 세 개이고, pg_current_snapshot()은 이를 xmin:xmax:xip목록 형식으로 보여 줍니다.\n필드 뜻 xmin 이보다 작은 xid는 모두 끝났음 (커밋이든 롤백이든) xmax 스냅샷을 찍을 때까지 끝난 xid 가운데 가장 큰 값 + 1(procarray.c). 이 값 이상인 xid는 아직 끝나지 않은 것으로 취급 xip xmin 이상 xmax 미만 가운데 스냅샷을 찍을 때 진행 중이던 xid 목록 스냅샷은 공유 메모리의 실행 중 트랜잭션 목록(ProcArray)을 훑어서 만듭니다(GetSnapshotData()). 자기 자신의 xid는 xip에 넣지 않습니다(procarray.c). 자기가 한 일은 스냅샷이 아니라 \u0026ldquo;나 자신인가\u0026quot;를 따로 검사해서 판단하기 때문입니다. 어떤 xid가 \u0026ldquo;내 스냅샷 기준으로 진행 중인가\u0026quot;는 XidInMVCCSnapshot()이 판단합니다. xmin보다 작으면 끝난 것, xmax 이상이면 진행 중, 그 사이면 xip에 있는지 봅니다.\n여기서 핵심은 진행 중으로 취급된 트랜잭션이 한 일은 보이지 않는다는 것입니다. 스냅샷을 찍은 뒤에 그 트랜잭션이 커밋해도, 이 스냅샷을 쓰는 동안은 여전히 보이지 않습니다.\n스냅샷: 아직 커밋 안 된 변경은 보이지 않는다 psql 세션 두 개를 열어 두고 번갈아 명령을 보냅니다. A=#와 B=#가 두 세션의 프롬프트이고, postgres=#는 잠깐 따로 접속한 세션입니다.\n세션 A가 트랜잭션을 열고 id = 2를 999로 바꿉니다. 그 사이 다른 세션에서 트랜잭션 하나를 커밋합니다(자동 커밋, xid 760).\nA=# BEGIN; BEGIN A=# SELECT pg_current_xact_id() AS a_xid; a_xid ------- 759 (1 row) A=# UPDATE acct SET balance = 999 WHERE id = 2; UPDATE 1 postgres=# INSERT INTO acct VALUES (7, 100) RETURNING xmin AS other_xid; other_xid ----------- 760 (1 row) INSERT 0 1 A=# SELECT pg_current_snapshot() AS a_snapshot, balance FROM acct WHERE id = 2; a_snapshot | balance ------------+--------- 759:761: | 999 (1 row) 이 상태에서 세션 B가 READ COMMITTED 트랜잭션을 열고 같은 행을 읽습니다.\nB=# BEGIN; BEGIN B=# SELECT pg_current_snapshot() AS b_snapshot, balance FROM acct WHERE id = 2; b_snapshot | balance -------------+--------- 759:761:759 | 100 (1 row) B의 스냅샷은 759:761:759입니다.\nxmin = 759: 759보다 작은 xid는 모두 끝났습니다. xmax = 761: 마지막으로 끝난 트랜잭션이 760이라 761입니다. 761 이상은 끝나지 않은 것으로 봅니다. xip = 759: 그 사이의 759(세션 A)는 아직 진행 중입니다. 760은 이미 커밋해서 목록에 없습니다. id = 2의 새 버전은 xmin = 759인데 759가 진행 중이므로 보이지 않고, 옛 버전(xmax = 759)은 지운 트랜잭션이 진행 중이므로 아직 보입니다. 그래서 B는 100을 봅니다. A는 자기가 만든 버전이라 999를 봅니다. A의 스냅샷 759:761:에 759가 없는 것은 자기 xid를 xip에 넣지 않기 때문입니다.\nA=# COMMIT; COMMIT B=# SELECT pg_current_snapshot() AS b_snapshot, balance FROM acct WHERE id = 2; b_snapshot | balance ------------+--------- 761:761: | 999 (1 row) B=# COMMIT; COMMIT A가 커밋한 뒤 B가 같은 트랜잭션 안에서 다시 읽자, 스냅샷이 761:761:(진행 중 없음)로 바뀌었고 999가 보입니다. READ COMMITTED는 명령마다 스냅샷을 새로 찍기 때문입니다. 아래 그림은 이 과정을 순서대로 그린 것입니다.\nB의 첫 스냅샷에는 A(759)가 진행 중으로 들어 있어 옛 버전을 보고, A가 커밋한 뒤의 새 스냅샷에서는 새 버전을 봅니다. 새 창에서 크게 보기 가시성 판단: 이 튜플은 나에게 보이는가 튜플을 읽을 때마다 HeapTupleSatisfiesMVCC()가 내 스냅샷으로 그 튜플이 보이는지 판단합니다. 단순하게 줄이면 이렇습니다.\nxmin으로 \u0026#39;이미 태어났는가\u0026#39;를, xmax로 \u0026#39;아직 살아 있는가\u0026#39;를 봅니다. 새 창에서 크게 보기 만든 트랜잭션(xmin)이 내 스냅샷 기준으로 진행 중이면 안 보입니다(heapam_visibility.c). 만든 트랜잭션이 롤백되었으면 안 보입니다. 커밋되었는지는 튜플의 hint bit를 먼저 보고, 없으면 pg_xact를 찾아봅니다(TransactionIdDidCommit()). 지운 트랜잭션(xmax)이 없거나, 잠금만 했으면 보입니다(heapam_visibility.c). 지운 트랜잭션이 내 스냅샷 기준으로 진행 중이거나 롤백되었으면 아직 보입니다. 커밋되었으면 안 보입니다. 그림에서 뺀 경우가 하나 있습니다. xmin이나 xmax가 나 자신이면 스냅샷 대신 명령 번호(cmin, cmax)로 판단합니다. 같은 트랜잭션 안에서 방금 넣은 행은 다음 명령부터 보이고 같은 명령 안에서는 보이지 않습니다. 반대로 내가 지운 행은 지우기 전에 시작한 명령에는 계속 보입니다(heapam_visibility.c).\npg_xact를 매번 찾아보면 느리므로, 보통은 커밋 여부를 처음 확인한 쪽이 결과를 튜플의 t_infomask에 hint bit로 적어 둡니다(SetHintBits()). 다음부터는 튜플만 보고 바로 판단할 수 있습니다. 2편에서 SELECT만 했는데 페이지가 dirty가 된 이유가 이것입니다. 비동기 커밋 직후처럼 커밋 WAL이 아직 디스크에 없으면 적지 않고 넘어가는 예외도 있습니다.\nhint bit: 커밋 결과를 처음 확인한 쪽이 적어 둔다 postgres=# INSERT INTO acct VALUES (6, 100); INSERT 0 1 postgres=# SELECT lp, t_xmin, raw_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)), postgres-# LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) postgres-# WHERE t_data = (SELECT t_data FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)) ORDER BY lp DESC LIMIT 1); lp | t_xmin | raw_flags ----+--------+--------------------- 13 | 767 | {HEAP_XMAX_INVALID} (1 row) postgres=# SELECT * FROM acct WHERE id = 6; id | balance ----+--------- 6 | 100 (1 row) postgres=# SELECT lp, t_xmin, raw_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)), postgres-# LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) postgres-# WHERE t_data = (SELECT t_data FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)) ORDER BY lp DESC LIMIT 1); lp | t_xmin | raw_flags ----+--------+----------------------------------------- 13 | 767 | {HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID} (1 row) INSERT 직후의 새 튜플에는 HEAP_XMAX_INVALID만 있습니다. SELECT로 한 번 읽자 HEAP_XMIN_COMMITTED가 생겼습니다. 이 SELECT가 pg_xact에서 767의 커밋을 확인하고 적어 둔 hint bit입니다. 다음에 이 튜플을 읽는 쪽은 pg_xact를 찾아보지 않아도 됩니다.\n격리 수준은 \u0026ldquo;스냅샷을 언제 찍느냐\u0026quot;의 차이 PostgreSQL의 격리 수준 차이는 대부분 스냅샷을 얼마나 자주 새로 찍느냐로 설명됩니다(GetTransactionSnapshot()).\n격리 수준 스냅샷 결과 READ COMMITTED (기본값) 명령마다 새로 찍음 같은 트랜잭션 안에서도 명령마다 그 사이 커밋된 변경이 보임 REPEATABLE READ 트랜잭션의 첫 명령에서 한 번 찍고 끝까지 씀 트랜잭션 내내 같은 데이터가 보임 SERIALIZABLE REPEATABLE READ처럼 한 번 찍고, 직렬로 실행한 것과 결과가 다를 수 있는 패턴을 감지해 오류를 냄 READ COMMITTED와 REPEATABLE READ 세션 B가 REPEATABLE READ로 트랜잭션을 시작해 잔액 합계를 봅니다. 그 사이 세션 A(자동 커밋)가 id = 3에 1000을 더합니다.\nB=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN B=# SELECT pg_current_snapshot() AS b_snapshot, sum(balance) FROM acct; b_snapshot | sum ------------+------ 761:761: | 1349 (1 row) A=# UPDATE acct SET balance = balance + 1000 WHERE id = 3; UPDATE 1 B=# SELECT pg_current_snapshot() AS b_snapshot, sum(balance) FROM acct; b_snapshot | sum ------------+------ 761:761: | 1349 (1 row) B=# COMMIT; COMMIT B=# SELECT pg_current_snapshot() AS b_snapshot, sum(balance) FROM acct; b_snapshot | sum ------------+------ 762:762: | 2349 (1 row) A가 1000을 더해 커밋했는데도, B의 트랜잭션 안에서는 합계가 계속 1349이고 스냅샷도 761:761: 그대로입니다. 첫 명령에서 찍은 스냅샷을 트랜잭션 끝까지 쓰기 때문입니다. B가 트랜잭션을 끝내고 새로 조회하자 새 스냅샷(762:762:)으로 2349가 보입니다.\n운영에서는: 긴 트랜잭션이 dead tuple 정리를 막는다 dead tuple은 아무도 볼 가능성이 없어진 뒤에야 VACUUM이 지울 수 있습니다. 위 실습의 세션 B처럼 REPEATABLE READ 트랜잭션 하나가 오래 열려 있으면, 그 스냅샷이 볼 수도 있는 옛 버전은 모두 남아야 합니다. READ COMMITTED라도 트랜잭션 안에서 쓰기를 한 번 하고 커밋하지 않은 채 멈춘 세션(idle in transaction)이 있으면 같은 문제가 생깁니다. 그 세션의 xid가 끝나지 않았으니, 그 뒤에 생긴 dead tuple은 \u0026ldquo;아직 누군가 볼 수 있는\u0026rdquo; 것으로 남습니다. 오래된 트랜잭션은 다음처럼 찾습니다.\nSELECT pid, state, xact_start, backend_xid, backend_xmin, age(backend_xmin) AS xmin_age, left(query, 40) FROM pg_stat_activity WHERE (backend_xmin IS NOT NULL OR backend_xid IS NOT NULL) AND pid \u0026lt;\u0026gt; pg_backend_pid() ORDER BY xact_start; idle_in_transaction_session_timeout으로 이런 세션을 자동으로 끊을 수 있습니다. VACUUM이 무엇을 기준으로 지울 수 있는지 판단하는지는 5편에서 자세히 봅니다.\n같은 행을 동시에 고치면 읽기는 스냅샷 덕분에 서로 막지 않지만, 같은 행을 두 트랜잭션이 동시에 고치는 것은 막아야 합니다. A가 행을 고치고 아직 커밋하지 않았는데 B도 같은 행을 고치려 하면, B는 그 행의 xmax에 적힌 A의 xid를 보고 A가 끝날 때까지 기다립니다(XactLockTableWait()). xid를 발급받은 트랜잭션은 그 순간 자기 xid에 대한 잠금을 잡고(xact.c), 끝날 때 풉니다. B는 A의 xid 잠금을 기다리는 것입니다.\nA가 커밋한 뒤 B가 어떻게 하는지는 격리 수준에 따라 다릅니다(ExecUpdate()).\nREAD COMMITTED: A가 만든 새 버전을 다시 읽어 WHERE 조건을 다시 확인하고(EvalPlanQual), 여전히 맞으면 새 버전 위에 자기 UPDATE를 합니다. 조건이 더는 맞지 않으면 그 행은 건너뜁니다. REPEATABLE READ: B의 스냅샷에서는 A의 변경이 보이지 않는데, 그 위에 덮어쓰면 A의 변경을 잃게 됩니다. 그래서 could not serialize access due to concurrent update 오류를 냅니다. 애플리케이션이 트랜잭션을 처음부터 다시 시도해야 합니다. A가 커밋하지 않고 롤백했다면 B는 오류 없이 진행합니다. 같은 행을 동시에 고치면: READ COMMITTED 세션 A가 id = 1에 10을 더하고 커밋하지 않은 채로, 세션 B가 같은 행에 1을 더하려 합니다.\nA=# BEGIN; BEGIN A=# UPDATE acct SET balance = balance + 10 WHERE id = 1 RETURNING balance; balance --------- 160 (1 row) UPDATE 1 B=# BEGIN; BEGIN B=# UPDATE acct SET balance = balance + 1 WHERE id = 1 RETURNING balance; B의 UPDATE가 끝나지 않고 멈췄습니다. 다른 세션에서 상태를 봅니다.\npostgres=# SELECT pid, state, wait_event_type, wait_event, left(query, 60) AS query postgres-# FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; AND pid \u0026lt;\u0026gt; pg_backend_pid() ORDER BY pid; pid | state | wait_event_type | wait_event | query -----+---------------------+-----------------+---------------+-------------------------------------------------------------- 97 | idle in transaction | Client | ClientRead | UPDATE acct SET balance = balance + 10 WHERE id = 1 RETURNIN 112 | active | Lock | transactionid | UPDATE acct SET balance = balance + 1 WHERE id = 1 RETURNING (2 rows) postgres=# SELECT locktype, transactionid, mode, granted, pid FROM pg_locks WHERE locktype = \u0026#39;transactionid\u0026#39; ORDER BY granted DESC, pid; locktype | transactionid | mode | granted | pid ---------------+---------------+---------------+---------+----- transactionid | 762 | ExclusiveLock | t | 97 transactionid | 763 | ExclusiveLock | t | 112 transactionid | 762 | ShareLock | f | 112 (3 rows) B(pid 112)는 wait_event = transactionid, 즉 다른 트랜잭션이 끝나기를 기다리고 있습니다. pg_locks를 보면 A(pid 97)는 자기 xid 762에 대한 ExclusiveLock을 갖고 있고, B는 762에 대한 ShareLock을 요청했지만 받지 못한(granted = f) 상태입니다. 모든 트랜잭션이 자기 xid를 잠가 두기 때문에, 그 잠금을 요청하는 것이 곧 \u0026ldquo;그 트랜잭션이 끝날 때까지 기다리겠다\u0026quot;는 뜻이 됩니다. A=# COMMIT; COMMIT A가 커밋하자 세션 B에서 멈춰 있던 UPDATE가 끝났습니다.\nbalance --------- 161 (1 row) UPDATE 1 B=# COMMIT; COMMIT 결과는 161입니다. B는 A가 만든 새 버전(160)을 다시 읽어 조건을 확인한 뒤 그 위에 1을 더했습니다. 두 변경이 모두 반영되었습니다.\n운영에서는: 락 대기는 트랜잭션 ID 대기로 보인다 위 실습의 세션 B처럼 행 잠금을 기다리는 세션은 pg_stat_activity에서 wait_event_type = Lock, wait_event = transactionid로 보입니다. 누가 막고 있는지는 pg_blocking_pids()로 바로 찾을 수 있습니다.\nSELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event, left(query, 40) FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) \u0026gt; 0; 막고 있는 쪽이 idle in transaction이라면, 애플리케이션이 트랜잭션을 열어 둔 채 다른 일(외부 API 호출 등)을 하고 있을 가능성이 큽니다.\n같은 행을 동시에 고치면: REPEATABLE READ 같은 상황에서 B를 REPEATABLE READ로 바꿉니다.\nA=# BEGIN; BEGIN A=# UPDATE acct SET balance = balance + 10 WHERE id = 1 RETURNING balance; balance --------- 171 (1 row) UPDATE 1 B=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN B=# SELECT balance FROM acct WHERE id = 1; balance --------- 161 (1 row) B=# UPDATE acct SET balance = balance + 1 WHERE id = 1 RETURNING balance; A=# COMMIT; COMMIT B의 UPDATE는 이번에도 멈춰 기다리다가, A가 커밋하자 끝났습니다.\nERROR: could not serialize access due to concurrent update B=# ROLLBACK; ROLLBACK B의 스냅샷에서 id = 1은 161입니다. A가 171로 바꾸고 커밋하자, B의 UPDATE는 기다리다가 could not serialize access due to concurrent update 오류로 끝났습니다. B가 161을 기준으로 1을 더해 162를 쓰면 A의 변경이 사라지기 때문입니다. 이 트랜잭션은 롤백하고 처음부터 다시 해야 합니다.\n운영에서는: REPEATABLE READ를 쓴다면 재시도가 필수 REPEATABLE READ와 SERIALIZABLE에서는 위 실습의 직렬화 오류(SQLSTATE 40001)가 정상 동작의 일부입니다. 이 격리 수준을 쓰는 애플리케이션은 40001을 받으면 트랜잭션 전체를 처음부터 다시 실행하도록 만들어야 합니다. 오류가 난 문장만 다시 실행하는 것으로는 안 됩니다.\nSELECT FOR UPDATE도 xmax에 적힌다 postgres=# BEGIN; BEGIN postgres=# SELECT pg_current_xact_id() AS locker_xid; locker_xid ------------ 766 (1 row) postgres=# SELECT * FROM acct WHERE id = 3 FOR UPDATE; id | balance ----+--------- 3 | 1100 (1 row) postgres=# SELECT xmin, xmax, * FROM acct WHERE id = 3; xmin | xmax | id | balance ------+------+----+--------- 761 | 766 | 3 | 1100 (1 row) postgres=# SELECT lp, t_xmax, raw_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;acct\u0026#39;, 0)), postgres-# LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) postgres-# WHERE t_ctid = (SELECT ctid FROM acct WHERE id = 3); lp | t_xmax | raw_flags ----+--------+-------------------------------------------------------------------------------------------------------------- 3 | 761 | {HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED,HEAP_HOT_UPDATED} 9 | 766 | {HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_UPDATED,HEAP_KEYS_UPDATED,HEAP_ONLY_TUPLE} (2 rows) postgres=# COMMIT; COMMIT FOR UPDATE로 행을 잠그기만 했는데 xmax가 766(잠근 트랜잭션)이 되었습니다. PostgreSQL에는 행 잠금을 담는 별도 표가 없고, 잠금도 튜플의 xmax에 적습니다. 대신 HEAP_XMAX_LOCK_ONLY로 \u0026ldquo;지운 것이 아니라 잠그기만 했다\u0026quot;고 표시합니다. 가시성 판단 3단계에서 \u0026ldquo;잠금만 했으면 보인다\u0026quot;가 이 경우입니다. 여러 트랜잭션이 같은 행을 함께 잠그면(FOR SHARE 등) xmax 자리에 여러 xid를 묶은 MultiXact ID가 들어갑니다. HEAP_KEYS_UPDATED는 여기서 FOR UPDATE와 한 단계 약한 FOR NO KEY UPDATE를 구분하는 표시로, 가장 강한 잠금인 FOR UPDATE일 때 켭니다(heapam.c). 조회 결과에 나온 lp 3은 REPEATABLE READ 실습에서 세션 A가 한 UPDATE로 옛 버전이 된 튜플입니다.\n정리 PostgreSQL은 행을 제자리에서 고치지 않습니다. INSERT는 xmin을, DELETE는 xmax를 적고, UPDATE는 옛 버전에 xmax를 적고 새 버전을 만듭니다. ROLLBACK은 페이지를 되돌리지 않고 pg_xact에 \u0026ldquo;롤백됨\u0026quot;을 적습니다. 스냅샷(xmin:xmax:xip)은 \u0026ldquo;어떤 트랜잭션이 끝났는가\u0026quot;를 찍어 둔 것이고, 스냅샷 기준으로 진행 중인 트랜잭션의 변경은 보이지 않습니다. 튜플이 보이는지는 xmin(태어났는가)과 xmax(아직 살아 있는가)를 스냅샷과 pg_xact로 판단하고, 결과를 hint bit로 적어 둡니다. READ COMMITTED는 명령마다, REPEATABLE READ는 트랜잭션에 한 번 스냅샷을 찍습니다. 같은 행을 동시에 고치면 뒤의 트랜잭션이 앞 트랜잭션의 xid 잠금을 기다립니다. READ COMMITTED는 새 버전 위에서 다시 시도하고 REPEATABLE READ는 직렬화 오류를 냅니다. 행 잠금(FOR UPDATE)도 xmax에 적힙니다. 다음 글에서는 이렇게 쌓인 dead tuple을 정리하는 VACUUM과 autovacuum, 그리고 그 과정에서 쓰이는 FSM과 Visibility Map을 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/access/heap/heapam_visibility.c: 가시성 판단, hint bit src/include/utils/snapshot.h: SnapshotData src/backend/utils/time/snapmgr.c: 트랜잭션 스냅샷, XidInMVCCSnapshot src/backend/storage/ipc/procarray.c: GetSnapshotData src/backend/access/transam/clog.c: pg_xact src/backend/executor/nodeModifyTable.c: 동시 UPDATE 처리 PostgreSQL 18 공식 문서\nConcurrency Control Transaction Isolation Explicit Locking System Columns ","permalink":"https://jinlog.dev/posts/postgresql/04-mvcc/","summary":"PostgreSQL은 어떻게 읽기와 쓰기가 서로를 막지 않게 하는가","title":"PostgreSQL 인터널 4: MVCC"},{"content":"개요 1편부터 4편까지는 mongod 한 대 안에서 일어나는 일이었습니다. 운영에서 mongod를 한 대만 두는 일은 거의 없습니다. 보통 같은 데이터를 가진 mongod 여러 대를 레플리카셋(replica set)으로 묶고, 그중 쓰기를 받는 한 대를 primary, 나머지를 secondary라고 부릅니다.\nprimary는 데이터를 바꿀 때마다 \u0026ldquo;무엇을 바꿨는지\u0026quot;를 local.oplog.rs라는 컬렉션에 한 줄씩 남기고, secondary는 그 줄을 가져와 자기 데이터에 똑같이 적용합니다. 이 컬렉션이 oplog(operation log)입니다. PostgreSQL의 스트리밍 복제(PostgreSQL 인터널 9편)가 페이지를 어떻게 고쳤는지를 담은 WAL을 그대로 보내는 물리 복제라면, MongoDB의 oplog는 \u0026ldquo;이 _id의 도큐먼트를 이렇게 바꿨다\u0026quot;를 담은 논리적인 기록입니다. 그래서 secondary는 primary와 같은 도큐먼트를 갖지만, 파일의 모양까지 같지는 않습니다.\n이 글에서 답할 질문은 다음과 같습니다.\noplog는 어디에, 어떤 모양으로 저장되고, 기본 크기는 어떻게 정해지는가 도큐먼트 변경과 oplog 기록은 어떻게 한 번에 커밋되는가 $inc 같은 연산자 update는 왜 결과 값으로 바뀌어 기록되는가 secondary는 oplog를 어떤 단계를 거쳐 가져오고 적용하는가 복제 지연은 어디서 보이고, w: 3 같은 쓰기는 무엇을 기다리는가 oplog window 밖으로 밀려난 secondary는 어떻게 되는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 레플리카셋은 컨테이너 3대(m05-1, m05-2, m05-3)로 만들었습니다.\n레플리카셋 만들기 레플리카셋 멤버는 모두 같은 --replSet 이름으로 시작하고, 그중 한 대에서 rs.initiate()로 멤버 목록을 알려 주면 셋이 만들어집니다. 누가 primary가 될지는 선출로 정해지는데(6편), 이 글에서는 m05-1이 늘 primary가 되도록 priority: 2를 주었습니다(나머지는 기본값 1).\n3노드 레플리카셋 세 컨테이너에서 같은 옵션으로 mongod를 띄웁니다.\n$ mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork --replSet rs0 | grep -E \u0026#39;forked|ERROR\u0026#39; forked process: 33 m05-1에서 셋을 만들고 멤버 상태를 봅니다.\ntest\u003e rs.initiate({_id: \u0026#34;rs0\u0026#34;, members: [{_id: 0, host: \u0026#34;m05-1:27017\u0026#34;, priority: 2}, {_id: 1, host: \u0026#34;m05-2:27017\u0026#34;}, {_id: 2, host: \u0026#34;m05-3:27017\u0026#34;}]}) { ok: 1, ... } rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, syncSourceHost: m.syncSourceHost})) [ { name: \u0026#39;m05-1:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, syncSourceHost: \u0026#39;\u0026#39; }, { name: \u0026#39;m05-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, syncSourceHost: \u0026#39;\u0026#39; }, { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, syncSourceHost: \u0026#39;\u0026#39; } ] rs.initiate() 전에는 프롬프트가 test\u0026gt;였다가, 셋이 만들어지고 m05-1이 primary가 되자 rs0 [direct: primary] test\u0026gt;로 바뀌었습니다. 나머지 둘은 SECONDARY입니다. syncSourceHost(각 멤버가 어디서 oplog를 받아 오는지)가 아직 비어 있는 것은, primary가 다른 멤버의 상태를 2초마다 오가는 heartbeat로 알게 되는데 그 정보가 아직 도착하지 않았기 때문입니다. 잠시 뒤에 보면 두 secondary 모두 m05-1:27017에서 받아 오고 있습니다(뒤에서 확인).\nm05-2의 로그에서 secondary가 되기까지의 과정을 추립니다.\n$ jq -c \u0026#39;select(.msg|test(\u0026#34;^Initial sync (done|status)|Initial Sync Attempt|Sync source candidate chosen|Starting replication (fetcher|writer|applier)\u0026#34;))|{msg,attr:(.attr|{syncSource,durationMillis}|with_entries(select(.value!=null)))}\u0026#39; /data/mongod.log {\u0026#34;msg\u0026#34;:\u0026#34;Sync source candidate chosen\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;syncSource\u0026#34;:\u0026#34;m05-3:27017\u0026#34;}} {\u0026#34;msg\u0026#34;:\u0026#34;Initial sync status and statistics\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;msg\u0026#34;:\u0026#34;Initial sync done\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;msg\u0026#34;:\u0026#34;Starting replication fetcher thread\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;msg\u0026#34;:\u0026#34;Starting replication writer thread\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;msg\u0026#34;:\u0026#34;Starting replication applier thread\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;msg\u0026#34;:\u0026#34;Sync source candidate chosen\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;syncSource\u0026#34;:\u0026#34;m05-1:27017\u0026#34;}} 새 멤버는 먼저 initial sync로 데이터 전체를 복사합니다(뒤에서 다룹니다). 이때 m05-2는 같이 막 시작한 m05-3을 복사 원본으로 골랐습니다. 빈 셋이라 누구에게서 받아도 결과는 같습니다. initial sync가 끝나면 평상시 복제를 맡는 세 스레드, fetcher, writer, applier를 띄우고, 이번에는 primary인 m05-1을 sync source로 골랐습니다. 세 스레드가 하는 일이 이 글의 중심입니다. oplog는 ts 순서의 capped 컬렉션이다 oplog는 local 데이터베이스의 oplog.rs 컬렉션입니다. local은 복제되지 않는 데이터베이스라, 멤버마다 자기 oplog를 따로 갖습니다. oplog에는 두 가지 특징이 있습니다.\ncapped 컬렉션입니다. 정한 크기를 넘으면 가장 오래된 엔트리부터 지웁니다. 그래서 oplog가 담고 있는 기간, 즉 oplog window는 쓰기 양에 따라 달라집니다. WiredTiger 테이블의 key가 엔트리의 ts(timestamp)입니다. 보통 컬렉션은 mongod가 매기는 증가 번호(RecordId)를 key로 쓰는데, oplog는 엔트리의 ts를 그대로 RecordId로 씁니다(keyForOptime()). B-tree(1편)가 key 순서로 정렬되어 있으니 oplog는 늘 ts 순서이고, \u0026ldquo;이 ts부터 읽어라\u0026quot;를 인덱스 없이 key 탐색으로 처리합니다. 그래서 oplog에는 _id 인덱스도 없습니다. 기본 크기는 getNewOplogSizeBytes()가 정합니다.\n조건 크기 --oplogSize(replication.oplogSizeMB)를 준 경우 그 값. 0보다 크기만 하면 된다(mongod_options.cpp) 디스크 스토리지(보통의 경우) dbPath가 있는 파일시스템 여유 공간의 5%, 최소 990MB, 최대 50GB(oplog.cpp) in-memory 스토리지 물리 메모리의 5%, 최소 50MB macOS 빌드 192MB 고정 크기는 oplog를 처음 만들 때 한 번 계산합니다. 디스크가 나중에 커지거나 줄어도 저절로 바뀌지 않고, 이미 oplog가 있는데 다른 --oplogSize로 시작하면 오류로 멈춥니다(createOplog()). 운영 중에 바꾸려면 replSetResizeOplog 명령을 씁니다.\n기본 oplog 크기 primary의 로그에서 oplog를 만들 때의 메시지와, 지금 파일시스템의 여유 공간을 봅니다.\n$ jq -c \u0026#39;select(.msg|test(\u0026#34;Creating replication oplog|Oplog size is being rounded\u0026#34;))|{msg,attr}\u0026#39; /data/mongod.log $ df -B1 --output=avail /data/db {\u0026#34;msg\u0026#34;:\u0026#34;Creating replication oplog\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oplogSizeMB\u0026#34;:32730}} Avail 686415142912 rs0 [direct: primary] test\u003e rs.printReplicationInfo() actual oplog size \u0026#39;32730.94140625 MB\u0026#39; --- configured oplog size \u0026#39;32730.94140625 MB\u0026#39; --- log length start to end \u0026#39;10 secs (0 hrs)\u0026#39; ... rs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).getCollectionInfos({name: \u0026#34;oplog.rs\u0026#34;})[0].options { capped: true, size: Long(\u0026#39;34320879616\u0026#39;), autoIndexId: false } rs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.stats().wiredTiger.creationString.match(/key_format=\\w+|oplogKeyExtractionVersion=\\d/g) [ \u0026#39;oplogKeyExtractionVersion=1\u0026#39;, \u0026#39;key_format=q\u0026#39; ] rs0 [direct: primary] test\u003e db.adminCommand({replSetResizeOplog: 1, size: 100}) MongoServerError[BadValue]: BSON field \u0026#39;size\u0026#39; value must be \u0026gt;= 990, actual value \u0026#39;100\u0026#39; 여유 공간이 약 686GB이고, 그 5%인 약 34.3GB가 oplog 크기가 되었습니다(size: 34320879616, 32730MB). 50GB 상한에 걸리지 않았습니다. 실습 컨테이너의 디스크가 커서 생긴 숫자라, 여유 공간이 다른 서버에서는 다른 값이 나옵니다. log length start to end가 oplog window입니다. 셋을 만든 지 10초라 아직 10초 분량만 있습니다. 크기(34GB)와 window(시간)는 별개이고, 운영에서 봐야 하는 것은 window 쪽입니다. 컬렉션 옵션은 capped: true, autoIndexId: false(_id 인덱스 없음)입니다. WiredTiger 테이블은 key_format=q(64비트 정수 key, 여기에 ts가 들어감)로 만들어졌고, oplogKeyExtractionVersion=1이 이 테이블이 oplog라는 표시입니다(wiredtiger_record_store.cpp). replSetResizeOplog는 990MB보다 작게 줄일 수 없습니다(resize_oplog.idl). 시작 옵션 --oplogSize는 이런 하한이 없어서, 뒤의 실습에서는 --oplogSize 1로 1MB짜리 oplog를 만들어 씁니다. oplog 엔트리의 모양 엔트리 하나는 BSON 도큐먼트 하나입니다. 자주 보는 필드는 다음과 같습니다.\n필드 뜻 op 종류. i(insert), u(update), d(delete), c(명령: 컬렉션 생성, applyOps 등), n(no-op) (oplog.cpp) ns, ui 대상 네임스페이스(db.collection)와 컬렉션 UUID. 적용할 때는 UUID로 컬렉션을 찾는다 o 내용. insert는 도큐먼트 전체, update는 변경분, delete는 _id o2 update에서 바꿀 도큐먼트를 찾는 조건(_id). insert에도 도큐먼트 key가 들어간다 ts 이 엔트리의 timestamp. oplog 안에서 유일하고 순서를 정한다 t 이 엔트리를 쓴 primary의 term(6편) v oplog 엔트리 형식 버전(2) wall primary의 벽시계 시각. 복제 지연을 계산할 때 쓴다 lsid, txnNumber, stmtId, prevOpTime 세션과 트랜잭션 번호. retryable write와 트랜잭션에서만 붙는다 insert, update, delete를 하나씩 acct 컬렉션에 도큐먼트 하나를 넣고, $inc로 한 번, $push와 $set으로 한 번 고친 뒤 지웁니다. 그리고 oplog에서 test 데이터베이스의 엔트리를 들어간 순서대로 봅니다.\nrs0 [direct: primary] test\u003e db.acct.insertOne({_id: 1, owner: \u0026#34;kim\u0026#34;, balance: 100, tags: [\u0026#34;new\u0026#34;]}) { acknowledged: true, insertedId: 1 } rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 1}, {$inc: {balance: 10}}) rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 1}, {$push: {tags: \u0026#34;vip\u0026#34;}, $set: {owner: \u0026#34;lee\u0026#34;}}) rs0 [direct: primary] test\u003e db.acct.deleteOne({_id: 1}) { acknowledged: true, deletedCount: 1 } rs0 [direct: primary] test\u003e var oplog = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs rs0 [direct: primary] test\u003e oplog.find({ns: /^test\\./}).sort({$natural: 1}).toArray() [ { op: \u0026#39;c\u0026#39;, ns: \u0026#39;test.$cmd\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { create: \u0026#39;acct\u0026#39;, idIndex: { v: 2, key: { _id: 1 }, name: \u0026#39;_id_\u0026#39; } }, ts: Timestamp({ t: 1790515993, i: 1 }), t: Long(\u0026#39;1\u0026#39;), v: Long(\u0026#39;2\u0026#39;), wall: ISODate(\u0026#39;2026-09-27T13:33:13.959Z\u0026#39;) }, { lsid: { id: UUID(\u0026#39;b118290d-8a6a-4978-98bd-e837a248813e\u0026#39;), uid: Binary.createFromBase64(\u0026#39;47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\u0026#39;, 0) }, txnNumber: Long(\u0026#39;1\u0026#39;), op: \u0026#39;i\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { _id: 1, owner: \u0026#39;kim\u0026#39;, balance: 100, tags: [ \u0026#39;new\u0026#39; ] }, o2: { _id: 1 }, stmtId: 0, ts: Timestamp({ t: 1790515993, i: 2 }), t: Long(\u0026#39;1\u0026#39;), v: Long(\u0026#39;2\u0026#39;), wall: ISODate(\u0026#39;2026-09-27T13:33:13.959Z\u0026#39;), prevOpTime: { ts: Timestamp({ t: 0, i: 0 }), t: Long(\u0026#39;-1\u0026#39;) } }, { ... txnNumber: Long(\u0026#39;2\u0026#39;), op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { balance: 110 } } }, o2: { _id: 1 }, stmtId: 0, ts: Timestamp({ t: 1790515993, i: 3 }), ... }, { ... txnNumber: Long(\u0026#39;3\u0026#39;), op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { owner: \u0026#39;lee\u0026#39; }, stags: { a: true, u1: \u0026#39;vip\u0026#39; } } }, o2: { _id: 1 }, stmtId: 0, ts: Timestamp({ t: 1790515993, i: 4 }), ... }, { ... txnNumber: Long(\u0026#39;4\u0026#39;), op: \u0026#39;d\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { _id: 1 }, stmtId: 0, ts: Timestamp({ t: 1790515993, i: 5 }), ... } ] 첫 insert가 컬렉션을 암묵적으로 만들었으므로, 그 앞에 op: 'c', o: { create: 'acct', ... } 엔트리가 먼저 있습니다. 명령 엔트리의 ns는 test.$cmd입니다. 이 엔트리의 ui가 새 컬렉션의 UUID이고, 뒤의 엔트리는 모두 같은 ui를 가리킵니다. ts는 {t: 초, i: 그 초 안의 순번}입니다. 같은 1790515993초 안에서 i가 1부터 5까지 1씩 늘었습니다. t: Long('1')은 셋을 만든 뒤 첫 term이라는 뜻입니다. insert의 o는 도큐먼트 전체, delete의 o는 _id뿐입니다. 지우는 쪽은 무엇을 지울지만 알면 됩니다. update의 o에는 명령에 쓴 $inc나 $push가 없습니다. 대신 $v: 2와 diff가 있습니다. 바로 다음 절에서 봅니다. 모든 CRUD 엔트리에 lsid, txnNumber, stmtId가 붙었습니다. mongosh는 기본으로 retryable write를 켜서 쓰기마다 세션 ID와 증가하는 트랜잭션 번호를 보내고, 서버는 이 번호를 oplog에 남겨 둡니다. 연결이 끊겨 드라이버가 같은 쓰기를 다시 보내도, 새 primary가 자기 oplog(와 config.transactions)를 보고 \u0026ldquo;이미 한 쓰기\u0026quot;라는 것을 알아 두 번 실행하지 않습니다(6편의 retryable write와 이어지는 내용입니다). 쓰기와 oplog 기록은 같은 트랜잭션으로 커밋된다 도큐먼트를 바꾸고 oplog에 한 줄 쓰는 일이 따로 커밋된다면, 그 사이에 mongod가 죽었을 때 \u0026ldquo;데이터는 바뀌었는데 oplog에는 없는\u0026rdquo; 상태가 생기고, 그 변경은 secondary에 영영 가지 않습니다. MongoDB는 두 쓰기를 같은 WiredTiger 트랜잭션에 넣어 이런 틈을 없앱니다.\ninsert, update, delete 경로는 컬렉션과 인덱스를 고친 뒤 같은 WriteUnitOfWork(mongod가 WiredTiger 트랜잭션을 감싼 단위) 안에서 OpObserver를 부릅니다(OpObserverImpl::onInserts(), onUpdate(), onDelete()). OpObserver는 logOp()로 oplog 엔트리를 씁니다. 여기서 여는 WriteUnitOfWork는 바깥 것 안에 중첩되므로 따로 커밋되지 않고 바깥 트랜잭션과 함께 커밋됩니다(oplog.cpp). 엔트리의 ts는 LocalOplogInfo::getNextOpTimes()가 mutex 안에서 cluster time을 한 칸 올려 예약합니다. 그래서 ts는 겹치지 않고 늘 증가합니다. 이 값은 oplog의 RecordId가 되고, 같은 값이 WiredTiger 트랜잭션의 commit timestamp로도 설정됩니다(wiredtiger_record_store.cpp). 결과적으로 도큐먼트의 새 버전과 oplog 엔트리는 같은 timestamp로 함께 보이거나, 함께 없습니다. 3편에서 본 timestamp 기반 스냅샷이 여기서 쓰입니다. \u0026ldquo;timestamp T 시점의 데이터\u0026quot;와 \u0026ldquo;T까지의 oplog\u0026quot;가 늘 짝이 맞으므로, secondary는 T까지 적용했다고 말할 수 있고, 장애 복구(2편)도 체크포인트 timestamp 뒤의 oplog만 다시 적용하면 됩니다.\n연산자 update는 결과 값으로 기록된다 앞의 출력에서 $inc: {balance: 10}은 oplog에 diff: { u: { balance: 110 } }, 즉 \u0026ldquo;balance를 110으로 설정\u0026quot;으로 기록되었습니다. $push: {tags: \u0026quot;vip\u0026quot;}와 $set은 diff: { u: { owner: 'lee' }, stags: { a: true, u1: 'vip' } }로 바뀌었습니다. u는 필드 값 설정, s로 시작하는 stags는 tags 필드 안쪽의 변경(sub-diff), 그 안의 a: true는 배열이라는 표시, u1은 \u0026ldquo;1번 원소를 'vip'로\u0026quot;입니다(document_diff_serialization.h). $v: 2는 이런 delta 형식이라는 표시로, 4.7에서 도입되었고 5.1부터 모든 update가 이 형식으로 기록됩니다(update_oplog_entry_version.h). update를 실행하면서 무엇이 바뀌었는지를 V2LogBuilder가 모아 이 형식으로 만듭니다.\n왜 $inc를 그대로 적지 않을까요? oplog 엔트리는 여러 번 적용되어도 결과가 같아야(멱등, idempotent) 하기 때문입니다. secondary가 같은 엔트리를 다시 적용하는 일은 드물지 않습니다.\ninitial sync는 데이터를 복사하는 동안 쌓인 oplog를 복사가 끝난 뒤 적용합니다. 복사한 도큐먼트에 이미 반영된 변경을 한 번 더 적용하게 됩니다. 장애 뒤 재시작하면 마지막 체크포인트 이후의 oplog를 다시 적용하는데(2편), 어디까지 이미 반영됐는지를 엔트리 하나 단위로 알 수는 없습니다. $inc: 10을 두 번 적용하면 120이 되지만, \u0026ldquo;110으로 설정\u0026quot;은 몇 번 적용해도 110입니다. 비슷한 이유로 secondary는 insert할 도큐먼트가 이미 있으면 오류로 멈추지 않고 그 _id에 대한 update(upsert)로 바꿔 적용합니다(applyOperation_inlock()).\n같은 엔트리를 두 번 적용해도 결과는 같다 applyOps는 oplog 엔트리 모양의 연산을 그대로 적용하는 관리 명령입니다(일반 애플리케이션이 쓸 명령은 아니고, 여기서는 확인용으로만 씁니다). $inc로 만든 oplog 엔트리의 o를 꺼내 두 번 적용하고, 마지막에 같은 $inc를 명령으로 한 번 더 실행해 비교합니다.\nrs0 [direct: primary] test\u003e db.acct.insertOne({_id: 2, balance: 100}) { acknowledged: true, insertedId: 2 } rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 2}, {$inc: {balance: 10}}) rs0 [direct: primary] test\u003e var e = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find({ns: \u0026#34;test.acct\u0026#34;, op: \u0026#34;u\u0026#34;, \u0026#34;o2._id\u0026#34;: 2}).sort({$natural: -1}).limit(1).next() rs0 [direct: primary] test\u003e e.o { \u0026#39;$v\u0026#39;: 2, diff: { u: { balance: 110 } } } rs0 [direct: primary] test\u003e db.adminCommand({applyOps: [{op: \u0026#34;u\u0026#34;, ns: \u0026#34;test.acct\u0026#34;, o2: {_id: 2}, o: e.o}]}) { applied: 1, results: [ true ], ok: 1, ... } rs0 [direct: primary] test\u003e db.adminCommand({applyOps: [{op: \u0026#34;u\u0026#34;, ns: \u0026#34;test.acct\u0026#34;, o2: {_id: 2}, o: e.o}]}) { applied: 1, results: [ true ], ok: 1, ... } rs0 [direct: primary] test\u003e db.acct.findOne({_id: 2}) { _id: 2, balance: 110 } rs0 [direct: primary] test\u003e db.acct.updateOne({_id: 2}, {$inc: {balance: 10}}) rs0 [direct: primary] test\u003e db.acct.findOne({_id: 2}) { _id: 2, balance: 120 } oplog 엔트리를 두 번 적용한 뒤에도 잔액은 110 그대로입니다. 같은 $inc를 명령으로 다시 실행하자 120이 되었습니다. 명령은 \u0026ldquo;현재 값에서 10 더하기\u0026quot;이고, oplog 엔트리는 \u0026ldquo;그 결과가 110\u0026quot;이라는 사실입니다.\n여러 도큐먼트를 바꾸는 명령과 트랜잭션 명령 하나가 도큐먼트 여러 개를 바꿀 때 oplog에 몇 줄이 남는지는 명령마다 다릅니다.\ninsertMany, updateMany, deleteMany, 트랜잭션 도큐먼트 3개를 insertMany로 넣고, updateMany로 고치고, deleteMany로 지웁니다. 그리고 트랜잭션 하나로 insert와 update를 함께 합니다. 시작 전의 마지막 ts를 start에 적어 두고 그 뒤의 엔트리만 봅니다.\nrs0 [direct: primary] test\u003e var start = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find().sort({$natural: -1}).limit(1).next().ts rs0 [direct: primary] test\u003e db.acct.insertMany([{_id: 11, g: 1}, {_id: 12, g: 1}, {_id: 13, g: 1}]) { acknowledged: true, insertedIds: { \u0026#39;0\u0026#39;: 11, \u0026#39;1\u0026#39;: 12, \u0026#39;2\u0026#39;: 13 } } rs0 [direct: primary] test\u003e db.acct.updateMany({g: 1}, {$set: {g: 2}}) rs0 [direct: primary] test\u003e db.acct.deleteMany({g: 2}) { acknowledged: true, deletedCount: 3 } rs0 [direct: primary] test\u003e var s = db.getMongo().startSession() rs0 [direct: primary] test\u003e s.startTransaction() rs0 [direct: primary] test\u003e s.getDatabase(\u0026#34;test\u0026#34;).acct.insertOne({_id: 21, balance: 50}) { acknowledged: true, insertedId: 21 } rs0 [direct: primary] test\u003e s.getDatabase(\u0026#34;test\u0026#34;).acct.updateOne({_id: 2}, {$inc: {balance: -50}}) rs0 [direct: primary] test\u003e s.commitTransaction() rs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find({$or: [{ns: \u0026#34;test.acct\u0026#34;}, {\u0026#34;o.applyOps.ns\u0026#34;: \u0026#34;test.acct\u0026#34;}], ts: {$gt: start}}, {op: 1, ns: 1, o: 1, o2: 1, txnNumber: 1, stmtId: 1}).sort({$natural: 1}).toArray() [ { txnNumber: Long(\u0026#39;1\u0026#39;), op: \u0026#39;c\u0026#39;, ns: \u0026#39;admin.$cmd\u0026#39;, o: { applyOps: [ { op: \u0026#39;i\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { _id: 11, g: 1 }, o2: { _id: 11 }, stmtId: 0 }, ... ] } }, { op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { g: 2 } } }, o2: { _id: 11 } }, { op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { g: 2 } } }, o2: { _id: 12 } }, { op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { g: 2 } } }, o2: { _id: 13 } }, { op: \u0026#39;c\u0026#39;, ns: \u0026#39;admin.$cmd\u0026#39;, o: { applyOps: [ { op: \u0026#39;d\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { _id: 13 } }, ... ] } }, { txnNumber: Long(\u0026#39;2\u0026#39;), op: \u0026#39;c\u0026#39;, ns: \u0026#39;admin.$cmd\u0026#39;, o: { applyOps: [ { op: \u0026#39;i\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { _id: 21, balance: 50 }, o2: { _id: 21 } }, { op: \u0026#39;u\u0026#39;, ns: \u0026#39;test.acct\u0026#39;, ui: UUID(\u0026#39;d975692c-f17f-4b5c-92a5-c412735d92d7\u0026#39;), o: { \u0026#39;$v\u0026#39;: 2, diff: { u: { balance: 70 } } }, o2: { _id: 2 } } ] } } ] 명령 oplog에 남은 것 근거 insertMany (3건) applyOps 엔트리 1개 안에 insert 3개 8.0에서 켜진 featureFlagReplicateVectoredInsertsTransactionally. 트랜잭션이 아닌 여러 건 insert를 한 WUOW로 묶어 applyOps로 기록(write_ops_exec.cpp, 플래그) updateMany (3건) u 엔트리 3개, 도큐먼트마다 하나 묶는 경로가 없다 deleteMany (3건) applyOps 엔트리 1개 안에 delete 3개 batched delete. batchUserMultiDeletes(기본 true)가 켜져 있고 트랜잭션, retryable write가 아니면 여러 delete를 묶는다(delete_request.idl, planner_interface.cpp) 트랜잭션 커밋할 때 applyOps 엔트리 1개에 트랜잭션 안의 모든 연산 트랜잭션이 16MB를 넘으면 여러 엔트리로 나뉘어 prevOpTime으로 이어진다 applyOps로 묶인 연산은 secondary에서도 한 번에 적용됩니다. 트랜잭션 엔트리 안의 update도 $inc: -50이 아니라 결과 값 balance: 70입니다(앞 실습에서 120이었던 잔액에서 50을 뺐습니다). updateMany 엔트리에는 txnNumber가 없습니다. 여러 도큐먼트를 바꾸는 update는 retryable write 대상이 아니기 때문입니다. 트랜잭션 엔트리의 txnNumber: Long('2')는 이 mongosh 세션에서 insertMany(1) 다음 두 번째로 쓴 트랜잭션 번호입니다. 운영에서는: updateMany 한 번이 oplog를 채운다 updateMany({}, {$set: {flag: true}})처럼 도큐먼트 1000만 건을 고치는 명령은 명령 하나지만 oplog에는 1000만 줄이 남습니다. 그만큼 oplog window가 한꺼번에 줄어들고, secondary는 그 1000만 줄을 모두 받아 적용해야 하므로 복제 지연이 생깁니다. 대량 변경은 _id 범위로 나눠 천천히 실행하고, 실행하는 동안 oplog window와 복제 지연을 지켜봅니다. deleteMany는 묶여서 기록되지만, 지우는 도큐먼트 수만큼의 연산이 들어 있는 것은 같습니다.\nsecondary가 oplog를 따라가는 과정 secondary는 oplog를 받아서 바로 컬렉션에 적용하지 않고 몇 단계를 거칩니다. 8.0에서 이 구조가 한 번 바뀌었습니다. 예전에는 applier가 \u0026ldquo;자기 oplog에 쓰기\u0026quot;와 \u0026ldquo;컬렉션에 적용\u0026quot;을 한 배치로 같이 했는데, 8.0에서는 oplog를 쓰는 OplogWriter가 따로 떨어져 나와 applier보다 먼저 달립니다(featureFlagReduceMajorityWriteLatency, 기본 켜짐이고 FCV와 무관, repl_server_parameters.idl). 앞의 로그에서 본 Starting replication writer thread가 이 스레드입니다(startSteadyStateReplication()).\nprimary에서 도큐먼트 변경과 같은 트랜잭션으로 기록된 oplog 엔트리를, secondary의 OplogFetcher가 가져오고 OplogWriter가 자기 oplog에 쓴 뒤 OplogApplier가 컬렉션에 적용합니다. 각 단계의 위치는 Reporter가 replSetUpdatePosition으로 보고합니다. 새 창에서 크게 보기 sync source 고르기: 누구에게서 받을지를 TopologyCoordinator::chooseNewSyncSource()가 정합니다. 자기보다 앞서 있고, primary보다 maxSyncSourceLagSecs(기본 30초, repl_server_parameters.idl) 넘게 뒤처지지 않은 멤버 가운데 ping이 가장 짧은 멤버를 고릅니다(_chooseNearbySyncSource()). secondary가 다른 secondary에게서 받는 것을 chaining이라고 하고 기본으로 허용됩니다(settings.chainingAllowed). 다만 8.0.32에서는 두 후보의 ping 차이가 changeSyncSourceThresholdMillis 안이면 같은 데이터센터로 보고 primary를 고릅니다(topology_coordinator.cpp). 한 데이터센터 안에서는 대개 primary에서 직접 받고, chaining은 멀리 떨어진 데이터센터의 secondary가 가까운 secondary에게서 받을 때 주로 생깁니다. OplogFetcher: sync source의 local.oplog.rs에 {ts: {$gte: 마지막으로 받은 ts}}로 find를 보냅니다(_makeFindCmdRequest()). tailable, awaitData 커서라 끝에 닿아도 닫히지 않고 새 엔트리를 기다리며, 기다리는 시간은 electionTimeoutMillis의 절반(기본 5초)입니다(calculateAwaitDataTimeout()). oplogFetcherUsesExhaust(기본 true, repl_server_parameters.idl)라 exhaust 커서로 열어서, sync source가 getMore 요청을 기다리지 않고 새 배치를 계속 밀어 보냅니다. 첫 배치의 첫 엔트리는 내가 마지막으로 받은 엔트리와 같아야 합니다. 다르면 두 oplog가 갈라졌다는 뜻이라 rollback(8편)으로 가거나, sync source의 oplog가 이미 그 지점을 지웠으면 \u0026ldquo;too stale\u0026quot;이 됩니다(_checkRemoteOplogStart(), _checkTooStaleToSyncFromSource()). write buffer → OplogWriter: 받은 엔트리는 메모리의 write buffer(최대 256MB, replication_coordinator_external_state_impl.cpp)에 쌓이고, OplogWriter가 배치로 꺼내 자기 local.oplog.rs에 씁니다. 쓴 뒤 lastWritten을 올리고 저널 flush를 요청하며, flush가 끝나면 lastDurable이 올라갑니다(OplogWriterImpl::_run(), finalizeOplogBatch()). 그다음 같은 엔트리를 apply buffer(최대 100MB 또는 1만 개)로 넘깁니다. apply buffer → OplogApplier: applier는 엔트리를 배치로 모읍니다. 배치 하나는 최대 replBatchLimitOperations(5000개), replBatchLimitBytes(100MB)이고(repl_server_parameters.idl), 컬렉션 생성 같은 명령 엔트리는 혼자 한 배치가 됩니다(_getBatchActionForEntry()). 배치 안의 엔트리는 네임스페이스와 _id의 해시로 writer thread에 나눠 병렬로 적용합니다(getOplogEntryHash()). 같은 도큐먼트에 대한 연산은 늘 같은 스레드로 가서 순서가 지켜지고, 다른 도큐먼트는 동시에 적용됩니다. capped 컬렉션은 넣은 순서를 지켜야 하므로 _id 없이 네임스페이스로만 나눕니다(processCrudOp()). writer thread 수는 replWriterThreadCount(기본 16)와 CPU 코어 수의 2배 가운데 작은 값입니다(getThreadCountForReplWorkerPool()). 배치가 모두 끝나면 lastApplied를 배치의 마지막 엔트리로 올립니다(_applyOplogBatch()). 배치 중간 상태는 다른 쪽에 보이지 않고, 배치 단위로 한꺼번에 보입니다. 위치 보고: 세 위치는 replSetUpdatePosition 명령으로 sync source에 보고합니다. 이 명령에는 자기 위치뿐 아니라 자기에게서 받아 가는 멤버들의 위치도 함께 실리므로(prepareReplSetUpdatePositionCommand()), chaining을 해도 primary는 모든 멤버의 위치를 압니다. 위치 뜻 쓰는 곳 lastWritten 자기 oplog에 쓴 곳까지 j: false인 majority 쓰기 lastDurable 자기 oplog가 저널까지 flush된 곳까지 majority commit point(기본) lastApplied 컬렉션에 적용까지 끝난 곳까지 읽기에 보이는 곳, rs.status()의 optime primary는 투표권이 있는 멤버들의 lastDurable(writeConcernMajorityJournalDefault를 끈 셋이면 lastWritten)을 정렬해 과반이 도달한 위치를 majority commit point로 삼습니다(updateLastCommittedOpTimeAndWallTime()). 8.0에서 OplogWriter를 따로 둔 이유가 여기 있습니다. secondary가 컬렉션에 적용하기 전이라도 oplog가 디스크에 닿기만 하면 majority 쓰기를 확인해 줄 수 있어서, w: \u0026quot;majority\u0026quot; 쓰기가 기다리는 시간이 줄어듭니다. 이 위치들이 읽기와 쓰기에 무엇을 보장하는지는 8편에서 다룹니다.\nsecondary의 파이프라인 지표 m05-3에 접속해 멤버 상태와 serverStatus().metrics.repl을 봅니다.\nrs0 [direct: secondary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, syncSourceHost: m.syncSourceHost})) [ { name: \u0026#39;m05-1:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, syncSourceHost: \u0026#39;\u0026#39; }, { name: \u0026#39;m05-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, syncSourceHost: \u0026#39;m05-1:27017\u0026#39; }, { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, syncSourceHost: \u0026#39;m05-1:27017\u0026#39; } ] rs0 [direct: secondary] test\u003e var r = db.serverStatus().metrics.repl rs0 [direct: secondary] test\u003e r.buffer { write: { count: Long(\u0026#39;0\u0026#39;), sizeBytes: Long(\u0026#39;0\u0026#39;), maxSizeBytes: Long(\u0026#39;268435456\u0026#39;) }, apply: { count: Long(\u0026#39;0\u0026#39;), sizeBytes: Long(\u0026#39;0\u0026#39;), maxSizeBytes: Long(\u0026#39;104857600\u0026#39;), maxCount: Long(\u0026#39;10000\u0026#39;) } } rs0 [direct: secondary] test\u003e r.network { bytes: Long(\u0026#39;7667\u0026#39;), getmores: { num: 27, totalMillis: 2781, numEmptyBatches: Long(\u0026#39;13\u0026#39;) }, ... oplogFetcherHighestFetchedOptime: { ts: Timestamp({ t: 1790515995, i: 6 }), t: Long(\u0026#39;1\u0026#39;) }, oplogFetcherLagSeconds: Long(\u0026#39;0\u0026#39;), ... replSetUpdatePosition: { num: Long(\u0026#39;53\u0026#39;) } } rs0 [direct: secondary] test\u003e r.apply { attemptsToBecomeSecondary: Long(\u0026#39;1\u0026#39;), batchSize: Long(\u0026#39;29\u0026#39;), batches: { num: 26, totalMillis: 30 }, ops: Long(\u0026#39;43\u0026#39;) } rs0 [direct: secondary] test\u003e r.write { batchSize: Long(\u0026#39;29\u0026#39;), batches: { num: 13, totalMillis: 0 } } 이제 두 secondary 모두 syncSourceHost: 'm05-1:27017', primary에서 직접 받고 있습니다. buffer.write와 buffer.apply가 앞의 3, 4단계의 두 버퍼입니다. 최대 크기가 소스의 256MB(268435456)와 100MB, 1만 개 그대로입니다. 둘 다 count: 0이라 밀린 엔트리가 없습니다. 복제가 밀리면 이 count와 sizeBytes가 먼저 올라갑니다. network는 OplogFetcher의 지표입니다. getMore 27번 가운데 13번이 빈 배치였습니다. 새 쓰기가 없어 awaitData 시간만큼 기다렸다가 빈손으로 돌아온 것입니다. replSetUpdatePosition.num은 위치를 보고한 횟수입니다. write.batchSize와 apply.batchSize는 배치로 처리한 엔트리의 누적 개수이고(둘 다 29), batches.num은 배치 수입니다. 같은 엔트리를 writer는 13번, applier는 26번에 나눠 처리했습니다. 두 단계가 따로 배치를 만든다는 것이 보입니다. apply.ops는 적용한 개별 연산 수로, applyOps 안의 연산처럼 엔트리 하나에서 여러 연산이 나오기도 해서 엔트리 수와 같지 않습니다. 복제 지연 secondary가 따라오지 못하면 primary와 secondary의 위치에 차이가 생깁니다. 이것이 복제 지연(replication lag)입니다. 흔한 원인은 secondary의 디스크나 CPU가 primary보다 약한 경우, secondary에서 무거운 읽기가 돌아 적용이 늦어지는 경우, 네트워크가 느린 경우, 그리고 앞에서 본 대량 쓰기입니다.\n지연을 일부러 만들기 위해 m05-3의 mongod를 SIGSTOP으로 멈춥니다. 프로세스는 살아 있지만 아무 일도 하지 않으므로, 느려서 따라오지 못하는 secondary와 비슷한 상태가 됩니다.\nsecondary 하나를 멈추면 $ kill -STOP 17 $ ps -o pid,stat,cmd -p 17 $ date -u +%T.%3N PID STAT CMD 17 Tl mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork --replSet rs0 13:33:15.865 프로세스 상태가 T(stopped)입니다. primary에서 도큐먼트 1000개를 넣고, 3초 쉬고, 하나를 더 넣은 뒤 지연을 봅니다.\nrs0 [direct: primary] test\u003e new Date() ISODate(\u0026#39;2026-09-27T13:33:16.172Z\u0026#39;) rs0 [direct: primary] test\u003e for (let i = 0; i \u0026lt; 1000; i++) db.lag.insertOne({i: i, pad: \u0026#34;x\u0026#34;.repeat(200)}) rs0 [direct: primary] test\u003e sleep(3000) rs0 [direct: primary] test\u003e db.lag.insertOne({i: \u0026#34;last\u0026#34;}) rs0 [direct: primary] test\u003e rs.printSecondaryReplicationInfo() source: m05-2:27017 { syncedTo: \u0026#39;Sun Sep 27 2026 13:33:16 GMT+0000 (Coordinated Universal Time)\u0026#39;, replLag: \u0026#39;3 secs (0 hrs) behind the primary \u0026#39; } --- source: m05-3:27017 { syncedTo: \u0026#39;Sun Sep 27 2026 13:33:15 GMT+0000 (Coordinated Universal Time)\u0026#39;, replLag: \u0026#39;4 secs (0 hrs) behind the primary \u0026#39; } rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, optimeDate: m.optimeDate, lastAppliedWallTime: m.lastAppliedWallTime})) [ { name: \u0026#39;m05-1:27017\u0026#39;, optimeDate: ISODate(\u0026#39;2026-09-27T13:33:19.000Z\u0026#39;), lastAppliedWallTime: ISODate(\u0026#39;2026-09-27T13:33:19.949Z\u0026#39;) }, { name: \u0026#39;m05-2:27017\u0026#39;, optimeDate: ISODate(\u0026#39;2026-09-27T13:33:16.000Z\u0026#39;), lastAppliedWallTime: ISODate(\u0026#39;2026-09-27T13:33:19.949Z\u0026#39;) }, { name: \u0026#39;m05-3:27017\u0026#39;, optimeDate: ISODate(\u0026#39;2026-09-27T13:33:15.000Z\u0026#39;), lastAppliedWallTime: ISODate(\u0026#39;2026-09-27T13:33:15.088Z\u0026#39;) } ] rs.printSecondaryReplicationInfo()에 따르면 멈춘 m05-3는 4초, 멀쩡한 m05-2도 3초 뒤처져 있습니다. 그런데 m05-2가 정말 뒤처졌을까요?\nrs.status()의 optimeDate는 heartbeat로 받은 위치입니다(topology_coordinator.cpp). heartbeat는 2초마다 오가므로 최대 몇 초 전의 값이고, 게다가 초 단위로 잘립니다. rs.printSecondaryReplicationInfo()는 이 값으로 지연을 계산합니다. lastAppliedWallTime은 replSetUpdatePosition으로 받은 위치의 벽시계 시각입니다. m05-2는 13:33:19.949로 primary와 같습니다. m05-2는 이미 마지막 엔트리까지 적용했고, printSecondaryReplicationInfo()의 \u0026ldquo;3 secs\u0026quot;는 heartbeat가 늦게 알려 준 값이었습니다. m05-3는 두 값이 모두 13:33:15에 멈춰 있습니다. 멈춘 시각(13:33:15.865) 직전입니다. 정말로 뒤처진 멤버입니다. 이 상태에서 쓰기 확인 수준(write concern)을 바꿔 가며 씁니다.\nrs0 [direct: primary] test\u003e db.lag.insertOne({i: \u0026#34;w3\u0026#34;}, {writeConcern: {w: 3, wtimeout: 3000}}) Uncaught: MongoWriteConcernError[WriteConcernFailed]: waiting for replication timed out Additional information: { wtimeout: true, writeConcern: { w: 3, wtimeout: 3000, provenance: \u0026#39;clientSupplied\u0026#39; } } Result: { n: 1, ... writeConcernError: { code: 64, codeName: \u0026#39;WriteConcernFailed\u0026#39;, errmsg: \u0026#39;waiting for replication timed out\u0026#39;, ... } rs0 [direct: primary] test\u003e db.lag.insertOne({i: \u0026#34;wmaj\u0026#34;}, {writeConcern: {w: \u0026#34;majority\u0026#34;, wtimeout: 3000}}) { acknowledged: true, insertedId: ObjectId(\u0026#39;6ab91b236e3ef956ff05513f\u0026#39;) } w: 3는 세 멤버 모두가 이 쓰기를 받았다고 보고할 때까지 기다리라는 뜻입니다. m05-3가 멈춰 있으니 3초(wtimeout) 뒤 waiting for replication timed out 오류가 났습니다. 그런데 결과의 n: 1이 말하듯 쓰기 자체는 primary에서 이미 끝났습니다. write concern 오류는 \u0026ldquo;원하는 만큼 복제됐는지 확인하지 못했다\u0026quot;는 뜻이지 \u0026ldquo;쓰기가 취소됐다\u0026quot;는 뜻이 아닙니다. 이 점에서 PostgreSQL 동기 복제를 기다리다 취소했을 때와 같습니다. w: \u0026quot;majority\u0026quot;는 세 멤버 가운데 두 멤버면 되므로 m05-2만으로 바로 끝났습니다. 멤버 하나가 멈춰도 majority 쓰기는 계속됩니다. 10초를 더 기다린 뒤 멤버 상태를 봅니다.\nrs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, health: m.health, optimeDate: m.optimeDate, lastHeartbeatMessage: m.lastHeartbeatMessage})) [ ... { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0, optimeDate: ISODate(\u0026#39;1970-01-01T00:00:00.000Z\u0026#39;), lastHeartbeatMessage: \u0026#39;no response within election timeout period\u0026#39; } ] heartbeat에 electionTimeoutMillis(10초) 동안 응답하지 않자, primary는 m05-3를 health: 0, (not reachable/healthy)로 표시했습니다. 이때부터는 위치도 모른다는 뜻으로 optimeDate가 1970년(0)이 됩니다. 멈춘 것이 primary였다면 이 시점에 선출이 시작됩니다(6편).\n다시 움직이면 따라잡는다 $ kill -CONT 17 $ ps -o pid,stat,cmd -p 17 PID STAT CMD 17 Rl mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork --replSet rs0 5초 뒤:\nrs0 [direct: primary] test\u003e rs.printSecondaryReplicationInfo() ... source: m05-3:27017 { syncedTo: \u0026#39;Sun Sep 27 2026 13:33:23 GMT+0000 (Coordinated Universal Time)\u0026#39;, replLag: \u0026#39;0 secs (0 hrs) behind the primary \u0026#39; } m05-3에서:\nrs0 [direct: secondary] test\u003e db.lag.countDocuments() 1003 rs0 [direct: secondary] test\u003e var r = db.serverStatus().metrics.repl rs0 [direct: secondary] test\u003e r.apply { attemptsToBecomeSecondary: Long(\u0026#39;1\u0026#39;), batchSize: Long(\u0026#39;1033\u0026#39;), batches: { num: 30, totalMillis: 45 }, ops: Long(\u0026#39;2050\u0026#39;) } 5초 만에 지연이 0초가 되었고, 멈춘 동안 들어간 1003건(1000 + last, w3, wmaj)이 모두 있습니다. 멈추기 전 지표와 비교하면 batchSize가 29에서 1033으로 1004 늘었는데 batches.num은 26에서 30으로 4번 늘었을 뿐입니다. 밀린 엔트리 1000여 개를 배치 몇 번에 몰아서 적용했습니다. 평소에는 새 엔트리가 조금씩 와서 배치가 작지만, 밀리면 배치가 커져 따라잡는 속도가 빨라집니다. primary는 oplog에 남아 있는 한 secondary가 얼마나 멈춰 있었든 그 지점부터 이어서 보내 줍니다. PostgreSQL처럼 replication slot을 만들 필요는 없습니다. 대신 oplog에 남아 있는 동안만이라는 조건이 붙습니다.\n운영에서는: 복제 지연을 볼 때 rs.printSecondaryReplicationInfo()와 rs.status()의 optimeDate는 heartbeat 기준이라 몇 초의 오차가 있습니다. 위 실습처럼 멀쩡한 secondary도 2~3초 뒤처져 보일 수 있으므로, 몇 초 수준의 지연 알람은 오탐이 됩니다. 초 단위보다 정밀하게 보려면 lastAppliedWallTime이나 secondary 쪽의 metrics.repl을 봅니다. 지연이 생기면 어느 단계가 막혔는지 나눠 봅니다. secondary의 metrics.repl.buffer.write.count가 크면 OplogWriter(자기 oplog 쓰기, 저널 flush)가, buffer.apply.count가 크면 applier(컬렉션과 인덱스 적용)가 따라가지 못하는 것입니다. 버퍼가 비어 있는데도 뒤처져 있다면 받아 오는 쪽(네트워크, sync source)을 봅니다. w에 숫자를 쓰면 멤버 하나만 멈춰도 쓰기가 wtimeout까지 기다립니다. wtimeout을 주지 않으면 끝없이 기다립니다. 보통은 w: \u0026quot;majority\u0026quot;를 씁니다. oplog window oplog는 capped 컬렉션이라 정한 크기를 넘으면 오래된 엔트리를 지웁니다. 지우는 일은 OplogCapMaintainerThread가 따로 맡습니다(oplog_cap_maintainer_thread.cpp). 엔트리를 하나씩 지우지 않고, oplog를 truncate marker라는 구간(기본 크기의 1/10 정도, 최소 10개, oplog_truncate_marker_parameters.idl)으로 나눠 두었다가 가장 오래된 구간을 통째로 잘라 냅니다(reclaimOplog()).\n그런데 크기를 넘었다고 언제나 자를 수 있는 것은 아닙니다. 잘라도 되는 한계(pinned oplog)가 있고, 가장 오래된 구간이 이 한계를 넘으면 자르지 않습니다(_hasExcessMarkers(), getPinnedOplog()).\n마지막 stable 체크포인트가 필요로 하는 oplog: 장애 복구는 체크포인트부터 oplog를 다시 적용하므로(2편), 그 체크포인트 이후의 oplog는 지울 수 없습니다. 이 값은 체크포인트를 끝낼 때마다 갱신됩니다(wiredtiger_kv_engine.cpp). stable timestamp: majority commit point를 넘지 않는 선에서 정해지는 timestamp입니다(3편). rollback은 여기서부터 oplog를 다시 적용하므로 그 뒤를 지우지 않습니다(getOplogNeededForRollback()). 과반이 받지 못한 엔트리는 지워지지 않는다는 뜻입니다. 백업 커서가 열려 있으면 그 시점, oplogMinRetentionHours를 정했으면 그 시간. 그래서 oplog는 설정한 크기를 잠깐씩 넘을 수 있습니다. 반대로 이 보호는 과반까지만입니다. 과반에 들지 못한 뒤처진 secondary를 위해 oplog를 남겨 두지는 않습니다.\n작은 oplog로 확인하기 레플리카셋을 --oplogSize 1(1MB)로 새로 만들고, 1KB짜리 도큐먼트 3000개를 넣습니다.\n$ mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork --replSet rs0 --oplogSize 1 | grep -E \u0026#39;forked|ERROR\u0026#39; forked process: 33 rs0 [direct: primary] test\u003e rs.printReplicationInfo() actual oplog size \u0026#39;1 MB\u0026#39; --- configured oplog size \u0026#39;1 MB\u0026#39; ... rs0 [direct: primary] test\u003e for (let i = 0; i \u0026lt; 3000; i++) db.big.insertOne({i: i, pad: \u0026#34;x\u0026#34;.repeat(1000)}) rs0 [direct: primary] test\u003e var st = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.stats(); ({size: st.size, maxSize: st.maxSize, count: st.count}) { size: 3969425, maxSize: 1048576, count: 3017 } 설정은 1MB(maxSize: 1048576)인데 oplog는 약 3.8MB, 엔트리 3017개입니다. 아직 한 줄도 지우지 않았습니다. 셋을 만든 뒤 아직 주기 체크포인트(기본 60초, 2편)가 한 번도 돌지 않아, 셋을 만들 때 찍은 체크포인트 이후의 oplog가 모두 \u0026ldquo;복구에 필요한 oplog\u0026quot;로 묶여 있기 때문입니다. 마지막 stable 체크포인트(lastStableRecoveryTimestamp)가 지금 oplog 끝을 지날 때까지 기다렸다가 1000개를 더 넣습니다.\nrs0 [direct: primary] test\u003e var t0 = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find().sort({$natural: -1}).limit(1).next().ts rs0 [direct: primary] test\u003e while (rs.status().lastStableRecoveryTimestamp.t \u0026lt;= t0.t) sleep(1000) rs0 [direct: primary] test\u003e for (let i = 0; i \u0026lt; 1000; i++) db.big.insertOne({i: i, pad: \u0026#34;x\u0026#34;.repeat(1000)}) rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e var st = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.stats(); ({size: st.size, maxSize: st.maxSize, count: st.count}) { size: 1379155, maxSize: 1048576, count: 1046 } rs0 [direct: primary] test\u003e rs.printReplicationInfo() actual oplog size \u0026#39;1.3152647018432617 MB\u0026#39; --- configured oplog size \u0026#39;1 MB\u0026#39; --- log length start to end \u0026#39;47 secs (0.01 hrs)\u0026#39; --- oplog first event time \u0026#39;Sun Sep 27 2026 13:33:56 GMT+0000 (Coordinated Universal Time)\u0026#39; --- oplog last event time \u0026#39;Sun Sep 27 2026 13:34:43 GMT+0000 (Coordinated Universal Time)\u0026#39; ... $ jq -c \u0026#39;select(.msg==\u0026#34;WiredTiger record store oplog truncation finished\u0026#34;)|{t:.t.\u0026#34;$date\u0026#34;,attr}\u0026#39; /data/mongod.log | tail -2 {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:34:42.977+00:00\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;pinnedOplogTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516072,\u0026#34;i\u0026#34;:1}},\u0026#34;numRecords\u0026#34;:723,\u0026#34;dataSize\u0026#34;:952149,\u0026#34;durationMillis\u0026#34;:0}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:34:43.043+00:00\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;pinnedOplogTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516072,\u0026#34;i\u0026#34;:1}},\u0026#34;numRecords\u0026#34;:723,\u0026#34;dataSize\u0026#34;:952149,\u0026#34;durationMillis\u0026#34;:0}} 체크포인트가 지나간 뒤 새 쓰기로 truncate marker가 생기자, 그제야 잘라 내기가 일어났습니다(oplog truncation finished). pinnedOplogTimestamp는 이때 잘라도 되는 한계였고, 잘라 낸 뒤 남은 엔트리가 723개, 약 0.9MB입니다. 그 뒤에 들어간 엔트리까지 합쳐 지금은 1046개, 1.3MB입니다. window는 47초가 되었습니다. 이 oplog는 지금 쓰기 속도로 47초 분량만 담고 있습니다. 47초 넘게 뒤처진 secondary는 이 primary에서 이어 받을 수 없다는 뜻입니다. window 밖으로 밀려난 secondary m05-3를 다시 멈추고, 이번에는 window를 넘길 만큼 씁니다. primary와 m05-2 두 멤버 모두 체크포인트가 지나간 뒤 한 번 더 써서, 두 멤버의 oplog가 모두 잘리게 합니다. 멤버마다 oplog를 따로 자르기 때문에, 한쪽이라도 필요한 엔트리를 갖고 있으면 m05-3는 그쪽에서 받아 따라잡을 수 있습니다.\n$ kill -STOP 17 $ date -u +%T.%3N 13:34:45.690 rs0 [direct: primary] test\u003e for (let i = 0; i \u0026lt; 3000; i++) db.big.insertOne({i: i, pad: \u0026#34;x\u0026#34;.repeat(1000)}) rs0 [direct: primary] test\u003e var t0 = db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find().sort({$natural: -1}).limit(1).next().ts rs0 [direct: primary] test\u003e var m2 = new Mongo(\u0026#34;m05-2:27017\u0026#34;) rs0 [direct: primary] test\u003e while (rs.status().lastStableRecoveryTimestamp.t \u0026lt;= t0.t || m2.getDB(\u0026#34;admin\u0026#34;).runCommand({replSetGetStatus: 1}).lastStableRecoveryTimestamp.t \u0026lt;= t0.t) sleep(1000) rs0 [direct: primary] test\u003e for (let i = 0; i \u0026lt; 1000; i++) db.big.insertOne({i: i, pad: \u0026#34;x\u0026#34;.repeat(1000)}) rs0 [direct: primary] test\u003e sleep(2000) rs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#34;local\u0026#34;).oplog.rs.find().sort({$natural: 1}).limit(1).next().ts Timestamp({ t: 1790516088, i: 64 }) rs0 [direct: primary] test\u003e m2.getDB(\u0026#34;local\u0026#34;).oplog.rs.find().sort({$natural: 1}).limit(1).next().ts Timestamp({ t: 1790516088, i: 64 }) 두 멤버의 oplog 첫 엔트리가 모두 1790516088초(13:34:48)가 되었습니다. m05-3를 다시 움직이고, m05-3가 RECOVERING이 될 때까지 기다립니다.\n$ kill -CONT 17 rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, optimeDate: m.optimeDate})) [ ... { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;RECOVERING\u0026#39;, optimeDate: ISODate(\u0026#39;2026-09-27T13:34:47.000Z\u0026#39;) } ] $ jq -c \u0026#39;select(.msg|test(\u0026#34;too stale|Too stale\u0026#34;))|{t:.t.\u0026#34;$date\u0026#34;,s,msg,attr}\u0026#39; /data/mongod.log {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:46.952+00:00\u0026#34;,\u0026#34;s\u0026#34;:\u0026#34;W\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Oplog fetcher discovered we are too stale to sync from sync source. Denylisting sync source\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;syncSource\u0026#34;:\u0026#34;m05-1:27017\u0026#34;,\u0026#34;denylistDurationMillis\u0026#34;:60000}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:46.953+00:00\u0026#34;,\u0026#34;s\u0026#34;:\u0026#34;I\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;We are too stale to use candidate as a sync source. Denylisting this sync source because our last fetched timestamp is before their earliest timestamp\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;candidate\u0026#34;:\u0026#34;m05-2:27017\u0026#34;,\u0026#34;lastOpTimeFetchedTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516087,\u0026#34;i\u0026#34;:299}},\u0026#34;remoteEarliestOpTimeTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516088,\u0026#34;i\u0026#34;:64}},\u0026#34;denylistDurationMinutes\u0026#34;:1,\u0026#34;denylistUntil\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T13:36:46.953Z\u0026#34;}}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:46.953+00:00\u0026#34;,\u0026#34;s\u0026#34;:\u0026#34;E\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Too stale to catch up. Entering maintenance mode. See http://dochub.mongodb.org/core/resyncingaverystalereplicasetmember\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;lastOpTimeFetched\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516087,\u0026#34;i\u0026#34;:299}},\u0026#34;t\u0026#34;:1},\u0026#34;earliestOpTimeSeen\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790516088,\u0026#34;i\u0026#34;:64}},\u0026#34;t\u0026#34;:1}}} rs0 [direct: other] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, infoMessage: m.infoMessage})) [ { name: \u0026#39;m05-1:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, infoMessage: \u0026#39;\u0026#39; }, { name: \u0026#39;m05-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, infoMessage: \u0026#39;\u0026#39; }, { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;RECOVERING\u0026#39;, infoMessage: \u0026#39;Could not find member to sync from\u0026#39; } ] m05-3의 로그가 세 단계를 그대로 보여 줍니다.\nOplogFetcher가 sync source m05-1에 이어 받기를 요청했는데, 첫 엔트리가 자기가 마지막으로 받은 엔트리와 달랐고 m05-1의 가장 오래된 엔트리가 그보다 뒤였습니다. discovered we are too stale로 m05-1을 60초 동안 후보에서 뺍니다(denylist). 다른 후보 m05-2를 봤지만, 마지막으로 받은 엔트리 1790516087, 299가 m05-2의 가장 오래된 엔트리 1790516088, 64보다 앞이라 역시 뺍니다. 받을 수 있는 멤버가 하나도 없자 **Too stale to catch up. Entering maintenance mode.**를 오류(\u0026quot;s\u0026quot;:\u0026quot;E\u0026quot;)로 남기고 maintenance mode를 켜 RECOVERING 상태로 들어갑니다(bgsync.cpp). 프롬프트의 [direct: other]가 이 상태입니다. RECOVERING 멤버는 읽기를 받지 않고, sync source를 찾지 못했다는 Could not find member to sync from을 계속 띄웁니다. 로그의 lastOpTimeFetched는 1790516087초(13:34:47)인데, m05-3를 멈춘 것은 13:34:45.690였습니다. 멈춘 뒤 1초 넘게 더 받은 셈입니다. OplogFetcher가 exhaust 커서를 쓰기 때문으로 보입니다. sync source는 getMore를 기다리지 않고 배치를 계속 보내므로, 멈춘 프로세스가 읽지 않은 배치가 소켓 버퍼에 쌓여 있다가 다시 움직였을 때 읽힌 것입니다. 이렇게 받은 엔트리도 그 뒤의 엔트리가 이미 잘려 나간 뒤라 소용이 없었습니다.\nRECOVERING은 저절로 풀리지 않습니다. 필요한 엔트리가 어느 멤버에도 없으니 기다려도 따라잡을 방법이 없습니다.\ninitial sync로 다시 만든다 이런 멤버는 데이터를 처음부터 다시 받는 initial sync로 되살립니다. 보통은 mongod를 멈추고 dbPath를 비운 뒤 다시 시작합니다. 여기서는 빈 디렉터리 /data/db2로 시작했습니다.\n$ mongod --dbpath /data/db --shutdown | tail -1 $ mkdir /data/db2 $ mongod --dbpath /data/db2 --logpath /data/mongod2.log --bind_ip_all --fork --replSet rs0 --oplogSize 1 | grep -E \u0026#39;forked|ERROR\u0026#39; Killing process with pid: 17 forked process: 230 $ jq -c \u0026#39;select(.msg|test(\u0026#34;^Starting initial sync attempt|^Setting begin applying|^Finished cloning data|^Initial sync done\u0026#34;))|{t:.t.\u0026#34;$date\u0026#34;,msg,attr}\u0026#39; /data/mongod2.log {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:52.950+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting initial sync attempt\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;initialSyncAttempt\u0026#34;:1,\u0026#34;initialSyncMaxAttempts\u0026#34;:10}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:53.038+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Finished cloning data. Beginning oplog replay\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;databaseClonerFinishStatus\u0026#34;:\u0026#34;OK\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:35:53.041+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Initial sync done\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;durationSeconds\u0026#34;:0}} rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, optimeDate: m.optimeDate})) [ ... { name: \u0026#39;m05-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, optimeDate: ISODate(\u0026#39;2026-09-27T13:35:44.000Z\u0026#39;) } ] initial sync는 다음 순서로 진행합니다(initial_syncer.cpp).\nsync source를 고르고, 그 멤버의 oplog 끝 위치를 적어 둡니다. 모든 데이터베이스와 컬렉션, 인덱스를 복사합니다(Finished cloning data). 복사하는 동안에도 쓰기는 계속되므로, 그동안의 oplog도 함께 받아 둡니다. 복사가 끝나면 받아 둔 oplog를 적용합니다(Beginning oplog replay). 복사한 도큐먼트에는 이미 반영된 변경이 있을 수 있는데, oplog가 멱등하므로 다시 적용해도 괜찮습니다. 실습 데이터가 작아 1초도 걸리지 않았지만(durationSeconds: 0), 실제로는 데이터 크기에 비례해 몇 시간씩 걸립니다. 그리고 복사하는 동안 쌓이는 oplog가 sync source의 oplog window 안에 있어야 하므로, window가 복사 시간보다 짧으면 initial sync도 실패합니다.\n운영에서는 이렇게 나타납니다 oplog window가 곧 장애 허용 시간입니다. secondary를 정비하거나 장애로 잃었을 때, oplog window 안에 돌아오면 이어서 따라잡고, 넘기면 initial sync를 해야 합니다. rs.printReplicationInfo()의 log length start to end를 주기적으로 모니터링하고, 정비 작업에 필요한 시간(보통 수 시간)보다 넉넉한지 확인합니다. 쓰기가 가장 많은 시간대에 window가 가장 짧아진다는 점, 그리고 대량 updateMany 한 번이 window를 한꺼번에 줄인다는 점을 기억해 둡니다. window가 모자라면 replSetResizeOplog로 크기를 늘리거나 oplogMinRetentionHours로 최소 보존 시간을 정합니다. 둘 다 멤버마다 따로 설정해야 합니다(local은 복제되지 않습니다).\n복제 지연은 heartbeat 오차를 감안하고 봅니다. 앞의 실습처럼 heartbeat 기준 값은 2~3초 뒤처져 보일 수 있습니다. 알람 임계값은 수십 초 이상으로 잡고, 지연이 커지면 secondary의 metrics.repl.buffer로 막힌 단계를 나눕니다. secondary에서 읽기(readPreference: secondary)를 받는다면 지연만큼 오래된 데이터를 읽는다는 것도 애플리케이션이 알아야 합니다.\nw 숫자 지정과 wtimeout. w: 3처럼 멤버 수를 적으면 멤버 하나의 장애가 곧 쓰기 대기로 이어집니다. wtimeout으로 끝난 쓰기는 취소된 것이 아니라 primary에 이미 적용되어 있습니다. 재시도 로직을 만들 때 이 점을 반영해야 합니다.\n인덱스 빌드도 oplog로 복제됩니다. primary에서 인덱스를 만들면 startIndexBuild와 commitIndexBuild 엔트리가 oplog에 남고(OpObserverImpl::onStartIndexBuild(), onCommitIndexBuild()), secondary도 startIndexBuild를 받으면 자기 인덱스를 따로 만들기 시작합니다. 큰 컬렉션의 인덱스 빌드는 모든 멤버의 CPU와 I/O를 함께 씁니다. 이 글에서는 실측하지 않았습니다.\ninitial sync가 필요한 경우. window 밖으로 밀려나 RECOVERING이 된 멤버, 디스크를 교체했거나 데이터가 깨진 멤버, 새로 추가하는 멤버입니다. initial sync는 sync source에 전체 읽기 부하를 주고, 끝날 때까지 그 멤버는 과반 계산에 도움이 되지 않습니다. 데이터가 크면 파일시스템 스냅샷이나 백업으로 dbPath를 채운 뒤 oplog로 따라잡게 하는 방법이 더 빠릅니다.\nPostgreSQL 스트리밍 복제와 비교하면 다음과 같습니다.\nPostgreSQL 스트리밍 복제 MongoDB 레플리카셋 보내는 것 WAL(페이지 변경, 물리) oplog 엔트리(도큐먼트 변경, 논리) 기록 보존 max_wal_size, replication slot이 붙잡음 oplog 크기(capped). 과반이 받지 못한 부분만 보호 뒤처진 복제본 slot이 있으면 WAL을 무한정 붙잡아 primary 디스크를 채움 window 밖으로 밀려나면 RECOVERING, initial sync 필요 적용 startup 프로세스 하나가 순서대로 writer thread 여러 개가 _id 해시로 병렬 복제본의 쓰기 불가 불가(primary만 쓰기) PostgreSQL은 standby를 위해 primary의 디스크를 희생할 수 있고(slot), MongoDB는 기본으로 primary를 지키고 뒤처진 secondary를 포기합니다. 어느 쪽이든 \u0026ldquo;복제본이 얼마나 오래 떨어져 있어도 되는가\u0026quot;를 정해 두는 것이 운영자의 일입니다.\n정리 oplog는 local.oplog.rs capped 컬렉션이고, WiredTiger 테이블의 key가 엔트리의 ts라 늘 시간 순서입니다. 기본 크기는 디스크 여유 공간의 5%(990MB~50GB)이며 oplog를 처음 만들 때 정해집니다. 도큐먼트 변경과 oplog 엔트리는 OpObserver를 거쳐 같은 WiredTiger 트랜잭션, 같은 timestamp로 커밋됩니다. oplog 엔트리는 멱등해야 하므로, $inc나 $push는 결과 값을 담은 $v: 2 delta로 기록됩니다. insertMany와 deleteMany는 applyOps 하나로 묶이고, updateMany는 도큐먼트마다 한 줄입니다. secondary는 OplogFetcher로 받아 OplogWriter가 자기 oplog에 쓰고(lastWritten, lastDurable) OplogApplier가 _id 해시로 병렬 적용합니다(lastApplied). 8.0에서는 oplog가 디스크에 닿기만 하면 majority 쓰기를 확인해 줄 수 있습니다. 복제 지연은 heartbeat 기준 optimeDate로 보면 몇 초의 오차가 있습니다. w: 3처럼 멤버 수를 적은 쓰기는 멤버 하나만 멈춰도 waiting for replication timed out이 나지만, 쓰기 자체는 이미 끝나 있습니다. oplog는 마지막 체크포인트와 majority commit point 이전만 잘라 냅니다. window 밖으로 밀려난 secondary는 Too stale to catch up으로 RECOVERING이 되고, initial sync로 다시 만들어야 합니다. 다음 글에서는 primary가 사라졌을 때 남은 멤버들이 새 primary를 뽑는 선출 과정을 살펴봅니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/db/repl/oplog.cpp: oplog 생성과 크기, logOp, oplog 엔트리 적용 src/mongo/db/op_observer/op_observer_impl.cpp: 쓰기마다 oplog 엔트리를 만드는 OpObserver src/mongo/db/update/v2_log_builder.h, document_diff_serialization.h: $v: 2 delta 형식 src/mongo/db/repl/oplog_fetcher.cpp: OplogFetcher, too stale 판단 src/mongo/db/repl/oplog_writer_impl.cpp: 8.0의 OplogWriter src/mongo/db/repl/oplog_applier_impl.cpp, oplog_applier_utils.cpp: 배치 적용과 writer thread 분배 src/mongo/db/repl/topology_coordinator.cpp: sync source 선택, 위치 보고, majority commit point src/mongo/db/repl/bgsync.cpp: too stale일 때 RECOVERING으로 전환 src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp: oplog 테이블, truncate marker, oplog 잘라 내기 MongoDB 8.0 공식 문서\nReplica Set Oplog Replica Set Data Synchronization Resync a Member of a Self-Managed Replica Set ","permalink":"https://jinlog.dev/posts/mongodb/05-oplog-and-replication/","summary":"secondary는 primary의 변경을 어떻게 따라가고, oplog window 밖으로 밀려나면 어떻게 되는가","title":"MongoDB 인터널 5: oplog와 레플리카셋 복제"},{"content":"개요 행 수는 거의 그대로인데 테이블 파일이 몇 배로 커지고, 같은 쿼리가 읽는 페이지 수가 늘어납니다. PostgreSQL에서 UPDATE와 DELETE는 옛 버전을 남기고(인터널 4편), 그 자리는 VACUUM이 비워야 다시 쓸 수 있습니다(인터널 5편). 비우는 속도가 쌓이는 속도를 따라가지 못하거나, 4편처럼 비우지 못하는 동안 파일은 커지고, 한 번 커진 파일은 VACUUM으로 줄지 않습니다. 이렇게 부풀어 오른 상태를 bloat라고 부릅니다.\n이 글에서 답할 질문은 다음과 같습니다.\n테이블과 인덱스가 얼마나 부풀었는지 어떻게 재는가 VACUUM을 했는데 왜 파일이 줄지 않는가 부푼 인덱스와 테이블은 어떻게 줄이고, 그 대가는 무엇인가 autovacuum이 제때 돌게 하려면 무엇을 조정하는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n먼저 확인할 것 크기와 dead tuple 수 100만 행짜리 items 테이블을 만들고 status 컬럼에 인덱스를 걸었습니다. 부푸는 과정을 보려고 이 테이블의 autovacuum은 꺼 둡니다.\npostgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 237 MB | 21 MB | 6312 kB (1 row) 여기에 테이블 전체를 세 번 UPDATE합니다. 배치 작업이 상태 값을 차례로 바꾸는 상황입니다.\npostgres=# UPDATE items SET status = \u0026#39;paid\u0026#39;; UPDATE 1000000 postgres=# UPDATE items SET status = \u0026#39;shipped\u0026#39;; UPDATE 1000000 postgres=# UPDATE items SET status = \u0026#39;done\u0026#39;; UPDATE 1000000 postgres=# SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = \u0026#39;items\u0026#39;; n_live_tup | n_dead_tup ------------+------------ 1000000 | 3000000 (1 row) postgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 947 MB | 43 MB | 25 MB (1 row) 행은 여전히 100만 개인데 테이블은 237 MB에서 947 MB로 4배가 되었습니다. 기본 키 인덱스도 21 MB에서 43 MB로 커졌습니다. status는 인덱스가 걸린 컬럼이라 이 UPDATE는 HOT가 될 수 없고, 새 버전마다 모든 인덱스에 새 항목이 들어가기 때문입니다(인터널 3편).\npg_stat_user_tables의 n_dead_tup은 추정값이라 빠르게 볼 수 있지만, 실제로 공간이 얼마나 비어 있는지는 알려 주지 않습니다.\npgstattuple로 정확히 재기 contrib 확장 pgstattuple은 테이블을 실제로 읽어 살아 있는 튜플, dead tuple, 빈 공간의 비율을 알려 줍니다.\npostgres=# SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent::numeric, 1) AS dead_pct, postgres-# round(free_percent::numeric, 1) AS free_pct, pg_size_pretty(table_len) AS table_len postgres-# FROM pgstattuple(\u0026#39;items\u0026#39;); tuple_count | dead_tuple_count | dead_pct | free_pct | table_len -------------+------------------+----------+----------+----------- 1000000 | 1000000 | 24.2 | 49.8 | 947 MB (1 row) dead_pct 24.2%: dead tuple이 차지하는 공간 free_pct 49.8%: 이미 비어 있어 새 행이 쓸 수 있는 공간 n_dead_tup은 300만인데 pgstattuple이 센 dead tuple은 100만입니다. 나머지 200만은 UPDATE가 페이지를 읽는 도중 pruning으로 이미 튜플 공간을 비우고 line pointer만 남긴 것입니다(인터널 5편). 그 공간이 free_pct에 들어 있습니다.\npgstattuple은 테이블 전체를 읽으므로 큰 테이블에서는 오래 걸리고 I/O를 일으킵니다. 대략적인 값으로 충분하면 Visibility Map을 이용해 일부만 읽는 pgstattuple_approx를 씁니다(pgstattuple).\n인덱스는 pgstatindex로 봅니다.\npostgres=# SELECT \u0026#39;items_status_idx\u0026#39; AS index, leaf_pages, round(avg_leaf_density::numeric, 1) AS avg_leaf_density, postgres-# pg_size_pretty(index_size) AS size postgres-# FROM pgstatindex(\u0026#39;items_status_idx\u0026#39;); index | leaf_pages | avg_leaf_density | size ------------------+------------+------------------+--------- items_status_idx | 783 | 96.1 | 6312 kB (1 row) 막 만든 인덱스라 리프 페이지가 96.1% 차 있습니다.\n원인별 진단 VACUUM은 공간을 비우지만 파일을 줄이지 않는다 VACUUM을 돌립니다.\npostgres=# VACUUM (VERBOSE) items; INFO: vacuuming \u0026#34;postgres.public.items\u0026#34; INFO: launched 1 parallel vacuum worker for index vacuuming (planned: 1) INFO: finished vacuuming \u0026#34;postgres.public.items\u0026#34;: index scans: 1 pages: 0 removed, 121212 remain, 121212 scanned (100.00% of total), 0 eagerly scanned tuples: 1000000 removed, 1000000 remain, 0 are dead but not yet removable removable cutoff: 772, which was 0 XIDs old when operation ended new relfrozenxid: 771, which is 15 XIDs ahead of previous value frozen: 2 pages from table (0.00% of total) had 33 tuples frozen visibility map: 121212 pages set all-visible, 90909 pages set all-frozen (0 were all-visible) index scan needed: 90910 pages from table (75.00% of total) had 3000000 dead item identifiers removed index \u0026#34;items_pkey\u0026#34;: pages: 5486 in total, 0 newly deleted, 0 currently deleted, 0 reusable index \u0026#34;items_status_idx\u0026#34;: pages: 3151 in total, 2358 newly deleted, 2358 currently deleted, 0 reusable avg read rate: 2486.773 MB/s, avg write rate: 1741.955 MB/s buffer usage: 156210 hits, 204743 reads, 143420 dirtied WAL usage: 254976 records, 129073 full page images, 306909696 bytes, 15297 buffers full system usage: CPU: user: 0.37 s, system: 0.15 s, elapsed: 0.64 s ... VACUUM 1000000 removed로 dead tuple을 모두 지웠고, 인덱스에서도 dead 항목 300만 개(3000000 dead item identifiers removed)를 지웠습니다. 그런데 크기는 그대로입니다.\npostgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 947 MB | 43 MB | 25 MB (1 row) postgres=# SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent::numeric, 1) AS dead_pct, postgres-# round(free_percent::numeric, 1) AS free_pct, pg_size_pretty(table_len) AS table_len postgres-# FROM pgstattuple(\u0026#39;items\u0026#39;); tuple_count | dead_tuple_count | dead_pct | free_pct | table_len -------------+------------------+----------+----------+----------- 1000000 | 0 | 0.0 | 75.1 | 947 MB (1 row) 947 MB 그대로이고, 그 가운데 75.1%가 빈 공간입니다. VACUUM은 테이블 끝부분에 연속으로 빈 페이지가 충분히 있을 때만 파일을 잘라 내고, 중간의 빈 공간은 새 행이 다시 쓰도록 표시만 합니다(인터널 5편). 실제로 50만 행을 새로 넣어도 테이블은 커지지 않습니다.\npostgres=# INSERT INTO items SELECT g, \u0026#39;new\u0026#39;, repeat(\u0026#39;x\u0026#39;, 200) FROM generate_series(1000001, 1500000) g; INSERT 0 500000 postgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 947 MB | 54 MB | 28 MB (1 row) postgres=# SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent::numeric, 1) AS dead_pct, postgres-# round(free_percent::numeric, 1) AS free_pct, pg_size_pretty(table_len) AS table_len postgres-# FROM pgstattuple(\u0026#39;items\u0026#39;); tuple_count | dead_tuple_count | dead_pct | free_pct | table_len -------------+------------------+----------+----------+----------- 1500000 | 0 | 0.0 | 62.8 | 947 MB (1 row) free_pct가 75.1%에서 62.8%로 줄었을 뿐 테이블은 947 MB 그대로입니다. 그러니 bloat 자체가 곧 장애는 아닙니다. 빈 공간이 재사용되고 있다면 파일이 더 커지지는 않습니다. 문제가 되는 것은 다음과 같은 경우입니다.\n디스크가 모자라거나(6편), 백업과 복제로 옮기는 양이 부담이 될 때 순차 스캔이 빈 페이지까지 읽어야 해서 느려질 때 빈 공간이 계속 늘어나 파일이 계속 커질 때. 이것은 VACUUM이 제때 돌지 못하거나 4편처럼 지우지 못하고 있다는 뜻입니다. 인덱스는 따로 부푼다 VACUUM 뒤의 status 인덱스를 봅니다.\npostgres=# SELECT \u0026#39;items_status_idx\u0026#39; AS index, leaf_pages, round(avg_leaf_density::numeric, 1) AS avg_leaf_density, postgres-# pg_size_pretty(index_size) AS size postgres-# FROM pgstatindex(\u0026#39;items_status_idx\u0026#39;); index | leaf_pages | avg_leaf_density | size ------------------+------------+------------------+------- items_status_idx | 785 | 95.9 | 25 MB (1 row) 리프 밀도는 95.9%로 좋아 보이지만 크기는 25 MB입니다. 리프 페이지 785개면 6 MB 남짓입니다. VACUUM 출력의 index \u0026quot;items_status_idx\u0026quot;: pages: 3151 in total, 2358 newly deleted가 설명해 줍니다. 완전히 빈 페이지 2358개를 인덱스 구조에서 떼어 냈지만(재사용 대기), 파일에서는 그대로 공간을 차지합니다. pgstatindex의 avg_leaf_density는 남은 리프 페이지만 보므로, 인덱스 bloat는 밀도와 함께 크기(size)를 봐야 합니다.\nitems_pkey는 0 newly deleted입니다. 기본 키 값은 행마다 달라서 페이지가 완전히 비는 일이 드물고, 대신 페이지마다 조금씩 빈 자리가 남습니다. 이런 인덱스는 VACUUM이 페이지를 떼어 내지도 못합니다.\n조치 인덱스: REINDEX CONCURRENTLY 부푼 인덱스는 새로 만들어 바꾸는 것이 가장 확실합니다.\npostgres=# REINDEX INDEX CONCURRENTLY items_status_idx; REINDEX postgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 947 MB | 54 MB | 10176 kB (1 row) postgres=# SELECT \u0026#39;items_status_idx\u0026#39; AS index, leaf_pages, round(avg_leaf_density::numeric, 1) AS avg_leaf_density, postgres-# pg_size_pretty(index_size) AS size postgres-# FROM pgstatindex(\u0026#39;items_status_idx\u0026#39;); index | leaf_pages | avg_leaf_density | size ------------------+------------+------------------+---------- items_status_idx | 1263 | 90.0 | 10176 kB (1 row) status 인덱스가 28 MB에서 10 MB로 줄었습니다(그 사이 50만 행이 늘어서 처음의 6 MB보다 큽니다). CONCURRENTLY를 붙이면 새 인덱스를 옆에 만든 뒤 바꿔 끼우므로, 그동안 테이블의 읽기와 쓰기를 막지 않습니다. 대신 일반 REINDEX보다 오래 걸리고, 새 인덱스를 만드는 동안 디스크를 그만큼 더 씁니다. 도중에 실패하면 _ccnew가 붙은 INVALID 인덱스가 남으니 확인하고 지워야 합니다(REINDEX).\n테이블: VACUUM FULL과 그 대가 테이블 파일 자체를 줄이려면 VACUUM FULL로 테이블을 새 파일에 다시 씁니다.\nA=# VACUUM FULL items; VACUUM Time: 627.885 ms postgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;items\u0026#39;)) AS table_size, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_pkey\u0026#39;)) AS pkey, postgres-# pg_size_pretty(pg_relation_size(\u0026#39;items_status_idx\u0026#39;)) AS status_idx; table_size | pkey | status_idx ------------+-------+------------ 237 MB | 21 MB | 6792 kB (1 row) postgres=# SELECT tuple_count, dead_tuple_count, round(dead_tuple_percent::numeric, 1) AS dead_pct, postgres-# round(free_percent::numeric, 1) AS free_pct, pg_size_pretty(table_len) AS table_len postgres-# FROM pgstattuple(\u0026#39;items\u0026#39;); tuple_count | dead_tuple_count | dead_pct | free_pct | table_len -------------+------------------+----------+----------+----------- 1000000 | 0 | 0.0 | 1.4 | 237 MB (1 row) 947 MB가 237 MB로 돌아왔고 인덱스도 함께 새로 만들어졌습니다. 이 테이블은 0.6초 만에 끝났지만, 문제는 그동안 무슨 일이 생기는가입니다. 919 MB짜리 테이블로 다시 해 봅니다.\nA=# VACUUM FULL big; B=# SELECT count(*) FROM big WHERE id = 1; postgres=# SELECT pid, application_name AS app, state, wait_event_type, wait_event, left(query, 40) AS query postgres-# FROM pg_stat_activity WHERE application_name IN (\u0026#39;maint\u0026#39;, \u0026#39;app\u0026#39;) ORDER BY pid; pid | app | state | wait_event_type | wait_event | query -----+-------+--------+-----------------+------------+---------------------------------------- 295 | maint | active | LWLock | WALWrite | VACUUM FULL big; 355 | app | active | Lock | relation | SELECT count(*) FROM big WHERE id = 1; (2 rows) postgres=# SELECT pid, command, phase, heap_tuples_scanned, heap_tuples_written postgres-# FROM pg_stat_progress_cluster; pid | command | phase | heap_tuples_scanned | heap_tuples_written -----+-------------+-------------------+---------------------+--------------------- 295 | VACUUM FULL | seq scanning heap | 1431808 | 1431808 (1 row) postgres=# SELECT l.pid, a.application_name AS app, l.mode, l.granted postgres-# FROM pg_locks l JOIN pg_stat_activity a USING (pid) postgres-# WHERE l.locktype = \u0026#39;relation\u0026#39; AND l.relation = \u0026#39;big\u0026#39;::regclass ORDER BY l.granted DESC; pid | app | mode | granted -----+-------+---------------------+--------- 295 | maint | AccessExclusiveLock | t 355 | app | AccessShareLock | f (2 rows) VACUUM FULL은 테이블에 AccessExclusiveLock을 겁니다. id 하나를 찾는 SELECT조차 Lock/relation을 기다립니다. pg_stat_progress_cluster로 어디까지 진행했는지 볼 수 있습니다.\n# 세션 A: 앞 명령의 결과를 기다림 VACUUM Time: 3951.015 ms (00:03.951) # 세션 B: 앞 명령의 결과를 기다림 count ------- 1 (1 row) Time: 3479.863 ms (00:03.480) VACUUM FULL은 3.95초, 그동안 기다린 SELECT는 3.48초가 걸렸습니다. 테이블이 수십 GB라면 이 시간이 수십 분이 되고, 2편의 DDL 락 큐처럼 뒤에 오는 모든 쿼리가 줄을 섭니다. 또 새 파일을 다 쓸 때까지 옛 파일을 지우지 않으므로 테이블 크기만큼의 여유 공간이 필요합니다. 디스크가 모자라서 줄이려던 것이라면 VACUUM FULL 자체가 실패할 수 있습니다.\n정리하면 다음과 같습니다.\n방법 줄어드는 것 락 추가 공간 VACUUM 파일은 그대로, 빈 공간을 재사용 가능하게 읽기·쓰기를 막지 않음 없음 REINDEX CONCURRENTLY 인덱스 읽기·쓰기를 막지 않음 인덱스 크기만큼 VACUUM FULL 테이블과 인덱스 AccessExclusiveLock, 읽기도 막음 테이블+인덱스 크기만큼 VACUUM FULL은 점검 시간에 하거나, 서비스를 멈출 수 없다면 코어 밖의 도구(예: 트리거와 복사로 테이블을 다시 쓰는 도구)를 검토합니다. 이 연재는 코어 기능만 다루므로 여기서는 소개하지 않습니다. 어느 쪽이든 다시 부풀지 않게 하는 것이 먼저입니다.\n재발 방지: autovacuum이 제때 돌게 한다 autovacuum은 dead tuple 수가 기준을 넘으면 테이블을 VACUUM합니다. PostgreSQL 18의 기본값은 이렇습니다.\npostgres=# SELECT name, setting FROM pg_settings postgres-# WHERE name IN (\u0026#39;autovacuum_vacuum_threshold\u0026#39;, \u0026#39;autovacuum_vacuum_scale_factor\u0026#39;, postgres-# \u0026#39;autovacuum_vacuum_max_threshold\u0026#39;, \u0026#39;autovacuum_vacuum_insert_threshold\u0026#39;, postgres-# \u0026#39;autovacuum_vacuum_insert_scale_factor\u0026#39;) postgres-# ORDER BY name; name | setting ---------------------------------------+----------- autovacuum_vacuum_insert_scale_factor | 0.2 autovacuum_vacuum_insert_threshold | 1000 autovacuum_vacuum_max_threshold | 100000000 autovacuum_vacuum_scale_factor | 0.2 autovacuum_vacuum_threshold | 50 (5 rows) 기준은 autovacuum_vacuum_threshold(50) + autovacuum_vacuum_scale_factor(0.2) × 행 수이고, PostgreSQL 18부터는 이 값이 autovacuum_vacuum_max_threshold(1억)를 넘지 않습니다(Automatic Vacuuming). INSERT만 많은 테이블은 autovacuum_vacuum_insert_* 기준으로 따로 판단합니다.\n100만 행 테이블이면 20만 개 넘게 쌓여야 돕니다. 15만 행을 UPDATE해 봅니다. 실습에서는 autovacuum_naptime을 5초로 줄여 두었습니다.\npostgres=# CREATE TABLE events (id int PRIMARY KEY, state text); CREATE TABLE postgres=# INSERT INTO events SELECT g, \u0026#39;new\u0026#39; FROM generate_series(1, 1000000) g; INSERT 0 1000000 postgres=# VACUUM ANALYZE events; VACUUM postgres=# UPDATE events SET state = \u0026#39;seen\u0026#39; WHERE id \u0026lt;= 150000; UPDATE 150000 postgres=# SELECT s.relname, s.n_live_tup, s.n_dead_tup, postgres-# 50 + 0.2 * c.reltuples AS vacuum_threshold, s.last_autovacuum postgres-# FROM pg_stat_user_tables s JOIN pg_class c ON c.oid = s.relid postgres-# WHERE s.relname = \u0026#39;events\u0026#39;; relname | n_live_tup | n_dead_tup | vacuum_threshold | last_autovacuum ---------+------------+------------+------------------+----------------- events | 1000000 | 150000 | 200050 | (1 row) postgres=# SELECT relname, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE relname = \u0026#39;events\u0026#39;; relname | n_dead_tup | last_autovacuum | autovacuum_count ---------+------------+-----------------+------------------ events | 150000 | | 0 (1 row) 15초가 지나도 돌지 않았습니다. 테이블의 15%가 dead tuple인데도 기준(20만 50)에 못 미치기 때문입니다. 행이 1억 개인 테이블이라면 2천만 개가 쌓여야 합니다. 큰 테이블일수록 기준이 커지므로 테이블 단위로 scale factor를 낮춥니다.\npostgres=# ALTER TABLE events SET (autovacuum_vacuum_scale_factor = 0.05); ALTER TABLE postgres=# SELECT relname, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE relname = \u0026#39;events\u0026#39;; relname | n_dead_tup | last_autovacuum | autovacuum_count ---------+------------+-------------------------------+------------------ events | 0 | 2026-09-26 10:19:50.168624+00 | 1 (1 row) 5%(5만 50)로 낮추자 몇 초 안에 autovacuum이 돌았고 로그에도 남았습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 4 -E \u0026#39;automatic vacuum of table \u0026#34;postgres.public.events\u0026#34;\u0026#39; 2026-09-26 10:19:50.168 UTC [646] LOG: automatic vacuum of table \u0026#34;postgres.public.events\u0026#34;: index scans: 1 pages: 0 removed, 5236 remain, 1476 scanned (28.19% of total), 0 eagerly scanned tuples: 150000 removed, 868345 remain, 0 are dead but not yet removable removable cutoff: 789, which was 0 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen 그 밖에 확인할 것은 다음과 같습니다.\nautovacuum이 돌지만 느린 경우: autovacuum은 I/O를 조절하느라 일정량을 읽고 쉽니다(autovacuum_vacuum_cost_limit, autovacuum_vacuum_cost_delay). 큰 테이블 몇 개가 계속 dead tuple을 쌓는다면 이 값이나 autovacuum_max_workers를 조정합니다. pg_stat_progress_vacuum으로 진행 상황을 봅니다. 돌아도 지우지 못하는 경우: 로그의 dead but not yet removable이 크면 4편의 원인부터 해결합니다. UPDATE 패턴: 자주 바뀌는 컬럼에 꼭 필요하지 않은 인덱스가 걸려 있으면 HOT가 막혀 모든 인덱스가 함께 부풉니다. fillfactor로 페이지에 여유를 두면 HOT가 일어날 자리가 생깁니다(인터널 3편). 감시할 값: pg_stat_user_tables의 n_dead_tup과 last_autovacuum, 테이블·인덱스 크기의 추세, 큰 테이블은 주기적인 pgstattuple_approx.\n정리 UPDATE와 DELETE는 옛 버전을 남기고, 비우는 속도가 따라가지 못하면 테이블과 인덱스가 부풉니다. 인덱스가 걸린 컬럼을 바꾸는 UPDATE는 모든 인덱스를 함께 부풀립니다. pgstattuple로 dead tuple과 빈 공간의 비율을, pgstatindex로 인덱스 밀도를 봅니다. 인덱스는 밀도와 함께 크기를 봐야 합니다. VACUUM은 공간을 비워 재사용하게 할 뿐 파일을 줄이지 않습니다. 빈 공간이 재사용되고 있다면 bloat 자체는 급한 문제가 아닙니다. 인덱스는 REINDEX CONCURRENTLY로 서비스를 막지 않고 줄입니다. VACUUM FULL은 테이블을 줄이지만 AccessExclusiveLock으로 읽기까지 막고, 테이블 크기만큼의 여유 공간이 필요합니다. autovacuum 기준은 행 수의 20%라서 큰 테이블은 dead tuple이 많이 쌓인 뒤에야 돕니다. 테이블 단위로 autovacuum_vacuum_scale_factor를 낮춥니다. 참고 자료 Routine Vacuuming pgstattuple REINDEX, VACUUM Progress Reporting: pg_stat_progress_vacuum, pg_stat_progress_cluster PostgreSQL 인터널 3편 데이터 저장 구조, 4편 MVCC, 5편 VACUUM ","permalink":"https://jinlog.dev/posts/postgresql-ops/05-table-bloat/","summary":"행 수는 그대로인데 테이블과 인덱스가 커질 때, 얼마나 부풀었는지 재고 어떻게 줄이는가","title":"PostgreSQL 운영 5: 테이블과 인덱스가 계속 커진다"},{"content":"개요 4편에서 PostgreSQL이 행을 제자리에서 고치지 않고 새 버전을 만든다는 것을 봤습니다. 그러면 옛 버전(dead tuple)은 언제 사라질까요? 자동으로 사라지지 않습니다. 누군가 치워야 하고, 그 일을 하는 것이 VACUUM입니다.\n이 글에서 답할 질문은 다음과 같습니다.\nVACUUM은 정확히 무엇을 지우고, 몇 단계로 일하는가 VACUUM을 해도 테이블 파일이 줄어들지 않는 이유는 무엇인가 Free Space Map과 Visibility Map은 무엇에 쓰이는가 dead tuple이 쌓였는데도 VACUUM이 지우지 못하는 경우는 언제인가 autovacuum은 언제 도는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\nVACUUM이 하는 일 VACUUM(정확히는 VACUUM FULL이 아닌 일반 VACUUM)은 읽기와 쓰기를 막지 않고 다른 쿼리와 동시에 돌면서 다음 일을 합니다. 다만 SHARE UPDATE EXCLUSIVE 락을 잡으므로, 같은 테이블의 DDL이나 다른 VACUUM과는 동시에 돌지 않습니다.\ndead tuple 공간 회수: 아무도 볼 수 없게 된 옛 버전을 지우고 그 자리를 새 행이 쓸 수 있게 합니다. Visibility Map 갱신: \u0026ldquo;이 페이지의 모든 행은 모두에게 보인다\u0026quot;를 표시합니다. Free Space Map 갱신: 페이지마다 빈 공간이 얼마나 있는지 기록합니다. freeze: 오래된 트랜잭션 ID를 \u0026ldquo;얼려서\u0026rdquo; 번호가 한 바퀴 돌아도 문제가 없게 합니다(6편). 통계 갱신: pg_class의 페이지 수, 행 수 추정값을 고칩니다. 세 단계: 힙 스캔, 인덱스 정리, 힙 정리 vacuumlazy.c 맨 위 주석이 이 과정을 세 단계로 설명합니다.\n1단계에서 힙을 훑으며 지울 주소를 모으고, 2단계에서 인덱스를 먼저 정리한 뒤, 3단계에서 힙의 자리를 비웁니다. 새 창에서 크게 보기 1단계, 힙 스캔(lazy_scan_heap()): 테이블을 앞에서부터 읽습니다. Visibility Map에서 \u0026ldquo;모두 보임\u0026quot;으로 표시된 페이지는 대체로 건너뜁니다(aggressive VACUUM, 아래의 eager scanning, 짧은 구간은 읽는 예외가 있습니다). 읽은 페이지마다 dead tuple을 지우고(pruning), 그 line pointer를 LP_DEAD로 바꾼 뒤 주소(TID)를 메모리에 모읍니다. 모을 수 있는 양은 maintenance_work_mem까지이고, autovacuum은 autovacuum_work_mem(기본값 -1이면 maintenance_work_mem)까지입니다. PG17부터는 이 TID를 radix tree 기반의 TidStore에 담아 예전보다 훨씬 적은 메모리로 많은 TID를 모읍니다. 2단계, 인덱스 정리: 모은 TID를 가리키는 인덱스 항목을 모든 인덱스에서 지웁니다. 3단계, 힙 정리(lazy_vacuum_heap_rel()): 힙 페이지를 다시 방문해 LP_DEAD를 LP_UNUSED로 바꿉니다. 이제 그 line pointer를 새 행이 쓸 수 있습니다. 2단계가 3단계보다 먼저인 이유가 있습니다. 인덱스가 아직 그 TID를 가리키는데 line pointer를 먼저 재사용하면, 인덱스가 전혀 다른 새 행을 가리키게 됩니다. 그래서 인덱스를 먼저 비우고 나서야 힙의 자리를 돌려줍니다. TID를 담을 메모리가 가득 차면 1단계를 잠시 멈추고 2, 3단계를 한 번 돌린 뒤 이어서 스캔합니다.\nUPDATE를 하면 dead tuple이 쌓인다 페이지와 지도를 들여다보는 데는 contrib 확장 pageinspect, pg_visibility, pg_freespacemap을 씁니다. 실습 테이블은 autovacuum이 끼어들지 않도록 autovacuum_enabled = off로 만듭니다. 통계(pg_stat_user_tables)는 약간 늦게 반영되므로, 변경 뒤에는 2초 기다렸다가 조회합니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION pageinspect; postgres=# CREATE EXTENSION pg_visibility; postgres=# CREATE EXTENSION pg_freespacemap; postgres=# CREATE TABLE t (id int PRIMARY KEY, v int, pad text) WITH (autovacuum_enabled = off); postgres=# INSERT INTO t SELECT g, 0, repeat(\u0026#39;x\u0026#39;, 100) FROM generate_series(1, 20000) g; $ sleep 2 postgres=# VACUUM (ANALYZE) t; postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size, pg_relation_size(\u0026#39;t\u0026#39;) / 8192 AS pages; size | pages ---------+------- 2760 kB | 345 (1 row) postgres=# UPDATE t SET v = v + 1; UPDATE 20000 postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size, pg_relation_size(\u0026#39;t\u0026#39;) / 8192 AS pages; size | pages ---------+------- 5520 kB | 690 (1 row) $ sleep 2 postgres=# SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_live_tup | n_dead_tup ------------+------------ 20000 | 20000 (1 row) 2만 행 전체를 한 번 UPDATE하자 테이블이 345페이지에서 690페이지로 정확히 두 배가 되었습니다. 새 버전 2만 개가 새로 쓰였고, 옛 버전 2만 개(n_dead_tup)는 그대로 남았기 때문입니다.\nVACUUM이 dead tuple을 정리한다 postgres=# VACUUM (VERBOSE, PROCESS_TOAST false) t; INFO: vacuuming \u0026#34;postgres.public.t\u0026#34; INFO: finished vacuuming \u0026#34;postgres.public.t\u0026#34;: index scans: 1 pages: 0 removed, 690 remain, 690 scanned (100.00% of total), 0 eagerly scanned tuples: 20000 removed, 20000 remain, 0 are dead but not yet removable removable cutoff: 759, which was 0 XIDs old when operation ended new relfrozenxid: 758, which is 2 XIDs ahead of previous value frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 690 pages set all-visible, 344 pages set all-frozen (0 were all-visible) index scan needed: 345 pages from table (50.00% of total) had 20000 dead item identifiers removed index \u0026#34;t_pkey\u0026#34;: pages: 112 in total, 0 newly deleted, 0 currently deleted, 0 reusable avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 1898 hits, 0 reads, 0 dirtied WAL usage: 1492 records, 0 full page images, 257943 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s VACUUM $ sleep 2 postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size, pg_relation_size(\u0026#39;t\u0026#39;) / 8192 AS pages; size | pages ---------+------- 5520 kB | 690 (1 row) postgres=# SELECT n_live_tup, n_dead_tup, vacuum_count FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_live_tup | n_dead_tup | vacuum_count ------------+------------+-------------- 20000 | 0 | 2 (1 row) postgres=# SELECT count(*) AS pages, pg_size_pretty(sum(avail)) AS free_space FROM pg_freespace(\u0026#39;t\u0026#39;); pages | free_space -------+------------ 690 | 2761 kB (1 row) VACUUM VERBOSE 출력을 한 줄씩 읽어 보겠습니다.\n출력 뜻 index scans: 1 인덱스 정리(2단계)를 한 번 했음 tuples: 20000 removed dead tuple 2만 개를 지움 removable cutoff: 759 이 xid보다 먼저 지워진 튜플만 지울 수 있음. 지금은 다른 트랜잭션이 없어 가장 최신 값 visibility map: 690 pages set all-visible, 344 pages set all-frozen 모든 페이지를 all-visible로 표시함. 옛 버전이 있던 앞쪽 345페이지 가운데 완전히 비워진 344페이지는 얼릴 튜플이 없으니 all-frozen까지 켜졌음. 나머지 1페이지에는 UPDATE의 새 버전 일부가 함께 들어가 있어서 제외 index scan needed: ... had 20000 dead item identifiers removed 힙 345페이지의 LP_DEAD line pointer 2만 개. 이들을 가리키는 인덱스 항목을 지운 뒤 LP_UNUSED로 바꿈 WAL usage: 1492 records VACUUM도 WAL을 남김 그런데 VACUUM 뒤에도 테이블 크기는 690페이지 그대로입니다. 지운 자리는 FSM에 빈 공간(2761kB, 테이블의 절반)으로 기록되었을 뿐, 파일은 줄지 않았습니다.\n비운 공간은 다시 쓴다 postgres=# INSERT INTO t SELECT g, 0, repeat(\u0026#39;x\u0026#39;, 100) FROM generate_series(20001, 30000) g; INSERT 0 10000 postgres=# SELECT pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size, pg_relation_size(\u0026#39;t\u0026#39;) / 8192 AS pages; size | pages ---------+------- 5520 kB | 690 (1 row) 1만 행을 새로 넣었는데 테이블은 690페이지에서 늘지 않았습니다. INSERT가 FSM에서 빈 공간이 있는 페이지를 찾아 그 자리에 넣었기 때문입니다. VACUUM의 목적은 파일을 줄이는 것이 아니라 공간을 재사용할 수 있게 만드는 것입니다.\nFree Space Map과 Visibility Map VACUUM이 관리하는 지도 두 개가 있습니다. 둘 다 테이블 옆의 별도 fork 파일입니다(3편).\n지도 파일 한 페이지당 쓰는 곳 Free Space Map (FSM) _fsm 1바이트 (freespace/README) INSERT와 UPDATE가 새 행을 넣을 페이지를 찾을 때 Visibility Map (VM) _vm 2비트 (visibilitymapdefs.h) VACUUM이 건너뛸 페이지를 고를 때, index-only scan이 힙을 읽지 않아도 될지 판단할 때 VM의 두 비트는 다음과 같습니다.\nall-visible: 이 페이지의 모든 튜플이 모든 트랜잭션에게 보입니다. dead tuple도, 커밋 안 된 튜플도 없습니다. all-frozen: 이 페이지의 모든 튜플이 얼려져 있습니다(6편). wraparound를 막는 VACUUM이 이 페이지를 건너뛸 수 있습니다. 페이지의 튜플이 하나라도 바뀌면 그 페이지의 비트는 바로 지워지고, 다음 VACUUM이 다시 켭니다. index-only scan은 인덱스에 필요한 열이 다 있어도, 행이 보이는지 확인하려면 원래 힙을 읽어야 합니다. 그런데 VM이 all-visible이면 그 확인을 건너뛸 수 있습니다.\nVisibility Map과 index-only scan id \u0026lt;= 5000인 행을 바꿔 그 페이지들의 VM 비트를 지운 뒤, 인덱스만 읽으면 되는 쿼리를 실행합니다.\npostgres=# SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_tup_hot_upd --------------- 0 (1 row) postgres=# UPDATE t SET v = v + 1 WHERE id \u0026lt;= 5000; UPDATE 5000 postgres=# SELECT pg_sleep(2); pg_sleep ---------- (1 row) postgres=# SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = \u0026#39;t\u0026#39;; n_tup_hot_upd --------------- 10 (1 row) postgres=# SELECT * FROM pg_visibility_map_summary(\u0026#39;t\u0026#39;); all_visible | all_frozen -------------+------------ 342 | 84 (1 row) postgres=# SET enable_seqscan = off; SET postgres=# SET enable_bitmapscan = off; SET postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(id) FROM t WHERE id \u0026lt;= 5000; QUERY PLAN ----------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) -\u0026gt; Index Only Scan using t_pkey on t (actual rows=5000.00 loops=1) Index Cond: (id \u0026lt;= 5000) Heap Fetches: 9990 Index Searches: 1 (5 rows) postgres=# VACUUM t; VACUUM postgres=# SELECT * FROM pg_visibility_map_summary(\u0026#39;t\u0026#39;); all_visible | all_frozen -------------+------------ 690 | 170 (1 row) postgres=# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(id) FROM t WHERE id \u0026lt;= 5000; QUERY PLAN ----------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) -\u0026gt; Index Only Scan using t_pkey on t (actual rows=5000.00 loops=1) Index Cond: (id \u0026lt;= 5000) Heap Fetches: 0 Index Searches: 1 (5 rows) VACUUM 전: all-visible 페이지가 342개뿐이라 Heap Fetches: 9990입니다. 인덱스에 있는 id만 필요한데도, 행이 보이는지 확인하려고 힙을 9990번 읽었습니다. 5000행인데 9990번인 이유는 인덱스에 옛 버전 항목 5000개와 새 버전 항목 4990개가 모두 있었기 때문입니다. 10건은 같은 페이지 안의 HOT 업데이트(n_tup_hot_upd가 0에서 10으로 늘어남)라서 인덱스 항목이 새로 생기지 않았습니다. VACUUM 후: 690페이지가 모두 all-visible이 되자 Heap Fetches: 0입니다. 힙을 전혀 읽지 않았습니다. line pointer의 일생 3편에서 본 line pointer의 네 가지 상태가 여기서 모두 쓰입니다.\n살아 있던 튜플은 dead tuple이 되고, pruning이 LP_DEAD로, VACUUM이 LP_UNUSED로 바꾼 뒤 다시 쓰입니다. 새 창에서 크게 보기 \u0026ldquo;지워졌지만 옛 스냅샷엔 보임\u0026rdquo; 단계가 중요합니다. DELETE나 UPDATE가 커밋되어도, 그보다 먼저 시작한 스냅샷은 여전히 옛 버전을 볼 수 있습니다(4편). VACUUM은 지금 살아 있는 모든 스냅샷 가운데 가장 오래된 것보다 먼저 지워진 튜플만 지울 수 있습니다. 이 경계를 VACUUM 출력에서는 removable cutoff라고 부르고 소스에서는 GetOldestNonRemovableTransactionId()가 계산합니다. 오래 열린 트랜잭션 하나가 테이블 전체의 정리를 막을 수 있는 이유입니다.\n긴 트랜잭션이 있으면 지우지 못한다 세션 A가 REPEATABLE READ 트랜잭션을 열어 둔 상태에서, 다른 세션 B가 5000행을 지우고 VACUUM합니다. 프롬프트의 A, B가 각 세션입니다.\nA=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN A=*# SELECT count(*) FROM t; count ------- 30000 (1 row) A=*# SELECT pg_current_snapshot(); pg_current_snapshot --------------------- 766:766: (1 row) B=# DELETE FROM t WHERE id \u0026gt; 25000; DELETE 5000 $ psql -X -c \u0026#34;VACUUM (VERBOSE, PROCESS_TOAST false) t\u0026#34; 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;tuples:|removable cutoff\u0026#39; tuples: 0 removed, 26842 remain, 5000 are dead but not yet removable removable cutoff: 766, which was 1 XIDs old when operation ended B=# SELECT pid, state, backend_xmin, now() - xact_start \u0026gt; interval \u0026#39;0\u0026#39; AS in_xact FROM pg_stat_activity WHERE backend_xmin IS NOT NULL AND pid \u0026lt;\u0026gt; pg_backend_pid(); pid | state | backend_xmin | in_xact -----+---------------------+--------------+--------- 115 | idle in transaction | 766 | t (1 row) tuples: 0 removed, ... 5000 are dead but not yet removable. 5000개가 dead지만 지울 수 없다는 뜻입니다(remain은 건너뛴 페이지의 행 수를 추정해 더한 값이라 실제 행 수와 조금 다릅니다). 세션 A의 스냅샷(backend_xmin = 766)은 DELETE 전의 데이터를 볼 수 있어야 하므로, VACUUM의 removable cutoff도 766에 묶였습니다.\nA=*# COMMIT; COMMIT $ psql -X -c \u0026#34;VACUUM (VERBOSE, PROCESS_TOAST false) t\u0026#34; 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;tuples:|removable cutoff\u0026#39; tuples: 5000 removed, 19106 remain, 0 are dead but not yet removable removable cutoff: 767, which was 0 XIDs old when operation ended A가 커밋하자 removable cutoff가 767로 넘어가고, 같은 VACUUM이 이번에는 5000개를 모두 지웠습니다.\n운영에서는: dead but not yet removable VACUUM 로그에 are dead but not yet removable이 크게 찍히면, 무언가가 removable cutoff를 붙잡고 있다는 뜻입니다. 흔한 원인은 다음과 같습니다.\n오래 열린 트랜잭션, 특히 idle in transaction 세션: pg_stat_activity의 backend_xmin이 오래된 세션을 찾습니다. 사용하지 않는 replication slot: 물리 슬롯은 hot_standby_feedback을 쓸 때 xmin이 모든 테이블의 정리를 붙잡고, 논리 슬롯은 catalog_xmin이 시스템 카탈로그의 정리를 붙잡습니다(9편). standby의 hot_standby_feedback = on과 standby에서 도는 긴 쿼리(9편). 준비만 해 두고 끝내지 않은 prepared transaction(pg_prepared_xacts). 이 경우 VACUUM을 아무리 돌려도 dead tuple이 줄지 않으므로, 원인을 먼저 없애야 합니다.\npruning과 HOT: VACUUM 없이도 조금씩 치운다 VACUUM만 dead tuple을 지우는 것은 아닙니다. 일반 쿼리가 페이지를 읽다가, 그 페이지에 지울 수 있는 옛 버전이 있다는 표시(pd_prune_xid)가 있고 빈 공간이 부족해 보이면(fillfactor 목표 또는 페이지의 10% 미만) 그 자리에서 페이지 안의 dead tuple을 정리합니다. 이를 pruning이라고 합니다(heap_page_prune_opt()). pruning은 튜플 공간을 비우고, 인덱스가 가리키는 line pointer는 LP_DEAD로 남겨 둡니다. 인덱스는 건드리지 않으므로 line pointer를 완전히 돌려주는 일(LP_UNUSED)은 VACUUM이 해야 합니다.\nHOT(Heap-Only Tuple) 업데이트는 여기서 한 걸음 더 나갑니다. UPDATE가 인덱스 열을 바꾸지 않고 새 버전이 같은 페이지에 들어가면, 인덱스에 새 항목을 만들지 않고 옛 버전에서 새 버전으로 체인만 잇습니다. 인덱스는 체인의 첫 줄만 가리킵니다. 나중에 옛 버전들을 정리할 때 첫 줄은 LP_REDIRECT로 남아 살아 있는 버전을 가리키고, 중간 버전은 인덱스가 가리키지 않으므로 바로 LP_UNUSED가 됩니다. 인덱스를 고치지 않으니 UPDATE도, VACUUM도 가벼워집니다. 3편에서 본 fillfactor로 페이지에 여유를 남기는 것도 HOT가 일어날 자리를 확보하기 위해서입니다.\nHOT 업데이트와 pruning 인덱스 열(id)이 아닌 v만 세 번 바꿉니다.\npostgres=# CREATE TABLE hot (id int PRIMARY KEY, v int) WITH (autovacuum_enabled = off); CREATE TABLE postgres=# INSERT INTO hot VALUES (1, 0); INSERT 0 1 postgres=# UPDATE hot SET v = 1 WHERE id = 1; UPDATE 1 postgres=# UPDATE hot SET v = 2 WHERE id = 1; UPDATE 1 postgres=# UPDATE hot SET v = 3 WHERE id = 1; UPDATE 1 postgres=# SELECT lp, lp_flags, lp_off, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page(\u0026#39;hot\u0026#39;, 0)); lp | lp_flags | lp_off | t_xmin | t_xmax | t_ctid ----+----------+--------+--------+--------+-------- 1 | 1 | 8160 | 762 | 763 | (0,2) 2 | 1 | 8128 | 763 | 764 | (0,3) 3 | 1 | 8096 | 764 | 765 | (0,4) 4 | 1 | 8064 | 765 | 0 | (0,4) (4 rows) $ sleep 2 postgres=# SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = \u0026#39;hot\u0026#39;; n_tup_upd | n_tup_hot_upd -----------+--------------- 3 | 3 (1 row) postgres=# VACUUM hot; VACUUM postgres=# SELECT lp, lp_flags, lp_off, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page(\u0026#39;hot\u0026#39;, 0)); lp | lp_flags | lp_off | t_xmin | t_xmax | t_ctid ----+----------+--------+--------+--------+-------- 1 | 2 | 4 | | | 2 | 0 | 0 | | | 3 | 0 | 0 | | | 4 | 1 | 8160 | 765 | 0 | (0,4) (4 rows) UPDATE 세 번이 모두 HOT였습니다(n_tup_hot_upd = 3). 같은 페이지에 버전 네 개가 t_ctid로 이어진 체인(lp 1 → 2 → 3 → 4)이 생겼고, 인덱스는 lp 1만 가리킵니다. VACUUM 뒤 lp 1은 lp_flags = 2(LP_REDIRECT)가 되었습니다. lp_off = 4는 이 경우 바이트 위치가 아니라 넘겨줄 line pointer 번호, 즉 lp 4입니다. 인덱스가 lp 1을 찾아오면 lp 4의 살아 있는 버전으로 넘겨줍니다. 중간 버전 lp 2, 3은 LP_UNUSED(0)가 되어 바로 다시 쓸 수 있습니다. 살아 있는 버전 lp 4는 페이지 끝(8160)으로 옮겨졌습니다. 페이지 안 빈 공간을 한데 모은(정리한) 결과입니다. 운영에서는: HOT를 살리는 테이블 설계 UPDATE가 많은 테이블이라면 HOT 비율(n_tup_hot_upd / n_tup_upd)을 확인해 볼 만합니다. HOT가 안 되는 이유는 보통 두 가지입니다. 자주 바뀌는 열에 인덱스가 걸려 있거나, 페이지에 새 버전을 넣을 공간이 없어서입니다. 쓰지 않는 인덱스를 지우고, 자주 바뀌는 테이블은 fillfactor를 90 이하로 낮춰 두면 HOT 비율이 올라갑니다.\n테이블 파일은 끝부분만 줄어든다 세 단계가 끝나면, 테이블 끝부분에 완전히 빈 페이지가 충분히 많을 때(1000페이지 이상 또는 테이블의 1/16 이상, REL_TRUNCATE_MINIMUM) 그만큼 파일을 잘라 냅니다. 이때는 잠깐 ACCESS EXCLUSIVE 락이 필요한데, 바로 잡을 수 없거나 기다리는 쿼리가 생기면 포기합니다(lazy_truncate_heap()). 이 동작은 vacuum_truncate 설정(PG18에서 전역 설정으로 추가)이나 VACUUM (TRUNCATE false)로 끌 수 있습니다. 중간에 있는 빈 페이지는 잘라 내지 않습니다. 일반 VACUUM으로 테이블 파일이 좀처럼 줄지 않는 이유가 이것입니다. 비운 공간은 앞에서 본 것처럼 새 행이 다시 씁니다.\n테이블 끝의 빈 페이지는 잘라 낸다 postgres=# CREATE TABLE tail (id int, pad text) WITH (autovacuum_enabled = off); postgres=# INSERT INTO tail SELECT g, repeat(\u0026#39;x\u0026#39;, 100) FROM generate_series(1, 20000) g; postgres=# SELECT pg_relation_size(\u0026#39;tail\u0026#39;) / 8192 AS pages; pages ------- 345 (1 row) postgres=# DELETE FROM tail WHERE id \u0026gt; 10000; $ psql -X -c \u0026#34;VACUUM (VERBOSE, PROCESS_TOAST false) tail\u0026#34; 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;pages:|tuples:\u0026#39; pages: 172 removed, 173 remain, 345 scanned (100.00% of total), 0 eagerly scanned tuples: 10000 removed, 10000 remain, 0 are dead but not yet removable postgres=# SELECT pg_relation_size(\u0026#39;tail\u0026#39;) / 8192 AS pages; pages ------- 173 (1 row) 앞쪽 1만 행은 두고 뒤쪽 1만 행을 지웠더니, 뒤쪽 172페이지가 통째로 비었습니다. VACUUM이 이를 잘라 내 파일이 345페이지에서 173페이지로 줄었습니다(pages: 172 removed). 옛 버전이 앞쪽 페이지에 있던 앞의 VACUUM과 달리, 빈 페이지가 파일 끝에 몰려 있었기 때문입니다.\nVACUUM FULL은 테이블을 새로 쓴다 postgres=# DELETE FROM t WHERE id % 2 = 0; DELETE 12500 postgres=# VACUUM t; VACUUM postgres=# SELECT pg_relation_filenode(\u0026#39;t\u0026#39;) AS filenode, pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size; filenode | size ----------+--------- 16442 | 5520 kB (1 row) postgres=# VACUUM FULL t; VACUUM postgres=# SELECT pg_relation_filenode(\u0026#39;t\u0026#39;) AS filenode, pg_size_pretty(pg_relation_size(\u0026#39;t\u0026#39;)) AS size; filenode | size ----------+--------- 16461 | 1728 kB (1 row) 절반을 지우고 일반 VACUUM을 해도 5520kB 그대로였지만, VACUUM FULL 뒤에는 1728kB로 줄었습니다. 파일 번호(filenode)가 16442에서 16461로 바뀐 것에서 보듯, VACUUM FULL은 아직 지울 수 없는 행까지 포함해 필요한 행만 새 파일에 다시 쓰고 옛 파일을 버립니다(rebuild_relation()). 그동안 테이블에 ACCESS EXCLUSIVE 락을 잡으므로 읽기까지 모두 막힙니다. 또 새 파일을 다 쓸 때까지 옛 파일도 남아 있으므로 테이블 크기만큼의 여유 디스크가 필요합니다. 운영 중인 큰 테이블에는 신중하게 써야 합니다.\n운영에서는: 테이블이 줄지 않는다(bloat) 위에서 본 것처럼 일반 VACUUM은 파일을 거의 줄이지 않습니다. dead tuple이 많이 쌓인 뒤에 VACUUM하면 그만큼의 빈 공간이 파일 안에 남는데, 이것을 bloat라고 부릅니다. 빈 공간은 새 행이 다시 쓰므로 그 자체로 문제는 아니지만, 테이블을 끝까지 읽는 쿼리(순차 스캔)는 빈 페이지까지 모두 읽어야 해서 느려집니다. bloat가 너무 커지면 다음 방법으로 파일을 줄입니다.\nVACUUM FULL: 확실하지만 테이블 전체를 막습니다. pg_repack 같은 확장: 시작과 끝에만 짧게 락을 잡고 온라인으로 다시 씁니다. 기본 키나 UNIQUE 인덱스가 필요합니다. 가장 좋은 방법은 bloat가 커지기 전에 autovacuum이 자주, 제때 돌게 하는 것입니다.\nautovacuum VACUUM을 사람이 매번 돌릴 수는 없으므로, 1편에서 본 autovacuum launcher가 주기적으로(autovacuum_naptime, 기본 1분) 테이블을 살피고 필요하면 worker를 띄웁니다. 테이블이 VACUUM 대상이 되는 조건은 다음과 같습니다(autovacuum.c).\ndead tuple 수 \u0026gt; autovacuum_vacuum_threshold(50) + autovacuum_vacuum_scale_factor(0.2) × 행 수\n단, 이 값은 autovacuum_vacuum_max_threshold(기본 1억)를 넘지 않습니다. 이 상한은 PG18에서 새로 생겼습니다. 예전에는 10억 행 테이블이면 dead tuple이 2억 개 쌓여야 autovacuum이 돌았는데, 이제 1억 개에서 돕니다.\nINSERT만 있는 테이블도 대상이 됩니다(freeze와 VM 갱신이 필요하기 때문입니다).\n마지막 VACUUM 이후 INSERT 수 \u0026gt; autovacuum_vacuum_insert_threshold(1000) + autovacuum_vacuum_insert_scale_factor(0.2) × 행 수 × 아직 freeze 안 된 페이지 비율\n마지막 항(freeze 안 된 비율)도 PG18에서 추가되었습니다. 이 비율을 곱하면 기준값이 낮아집니다. 대부분 이미 얼려진 큰 테이블이라면 행 수 전체가 아니라 아직 얼리지 않은 부분만 기준으로 삼으므로, INSERT 기반 VACUUM이 예전보다 자주, 제때 돌게 됩니다. 이미 얼려진 페이지는 VM 덕분에 어차피 건너뜁니다.\nautovacuum은 언제 도는가 autovacuum_naptime을 1초로 줄이고, 1만 행 테이블에서 dead tuple을 기준값 아래와 위로 만들어 봅니다.\npostgres=# ALTER SYSTEM SET autovacuum_naptime = \u0026#39;1s\u0026#39;; postgres=# ALTER SYSTEM SET log_autovacuum_min_duration = 0; postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# CREATE TABLE av (id int PRIMARY KEY, v int); postgres=# INSERT INTO av SELECT g, 0 FROM generate_series(1, 10000) g; $ sleep 5 postgres=# SELECT relname, reltuples FROM pg_class WHERE relname = \u0026#39;av\u0026#39;; relname | reltuples ---------+----------- av | 10000 (1 row) postgres=# SELECT current_setting(\u0026#39;autovacuum_vacuum_threshold\u0026#39;)::int postgres-# + current_setting(\u0026#39;autovacuum_vacuum_scale_factor\u0026#39;)::float * reltuples AS vacuum_threshold postgres-# FROM pg_class WHERE relname = \u0026#39;av\u0026#39;; vacuum_threshold ------------------ 2050 (1 row) postgres=# SELECT n_dead_tup, autovacuum_count, last_autovacuum IS NOT NULL AS vacuumed FROM pg_stat_user_tables WHERE relname = \u0026#39;av\u0026#39;; n_dead_tup | autovacuum_count | vacuumed ------------+------------------+---------- 0 | 1 | t (1 row) postgres=# UPDATE av SET v = 1 WHERE id \u0026lt;= 1500; UPDATE 1500 $ sleep 4 postgres=# SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname = \u0026#39;av\u0026#39;; n_dead_tup | autovacuum_count ------------+------------------ 1500 | 1 (1 row) postgres=# UPDATE av SET v = 2 WHERE id \u0026lt;= 1500; $ sleep 4 postgres=# SELECT n_dead_tup, autovacuum_count FROM pg_stat_user_tables WHERE relname = \u0026#39;av\u0026#39;; n_dead_tup | autovacuum_count ------------+------------------ 0 | 2 (1 row) $ grep -A8 \u0026#39;automatic vacuum of table \u0026#34;postgres.public.av\u0026#34;\u0026#39; /home/postgres/server.log | grep -E \u0026#39;automatic vacuum|tuples:|index scan\u0026#39; 2026-09-26 10:08:59.784 UTC [271] LOG: automatic vacuum of table \u0026#34;postgres.public.av\u0026#34;: index scans: 0 tuples: 0 removed, 10000 remain, 0 are dead but not yet removable index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed 2026-09-26 10:09:09.096 UTC [297] LOG: automatic vacuum of table \u0026#34;postgres.public.av\u0026#34;: index scans: 1 tuples: 1500 removed, 8893 remain, 0 are dead but not yet removable index scan needed: 14 pages from table (24.14% of total) had 3000 dead item identifiers removed 이 테이블의 VACUUM 기준값은 50 + 0.2 × 10000 = 2050입니다. 1만 행을 넣은 직후 autovacuum이 이미 한 번 돌았습니다(autovacuum_count = 1). 로그의 첫 번째 tuples: 0 removed 기록입니다. 이때는 아직 행 수 추정값이 없어서 0으로 취급하므로(autovacuum.c), INSERT 기준값이 1000이었고 1만 행이 이를 넘었습니다. 1500행을 바꿔 dead tuple이 1500개(기준 2050 미만)일 때는 4초를 기다려도 돌지 않았습니다. 1500행을 한 번 더 바꿔 누적 3000개가 되자 autovacuum이 돌았습니다(autovacuum_count = 2). 두 번째 기록을 보면 tuples: 1500 removed인데 3000 dead item identifiers removed입니다. 두 번째 UPDATE는 바꿀 행을 인덱스로 찾아 읽었는데, 이때 첫 UPDATE의 옛 버전이 있던 페이지를 읽으면서 pruning해(heapam_handler.c) 1500개를 LP_DEAD로 만들어 두었습니다. VACUUM은 나머지 1500개만 직접 지웠고, 인덱스 항목은 둘 다 지워야 하므로 3000개입니다.\n운영에서는: 큰 테이블은 autovacuum이 늦게 돈다 기준값이 50 + 0.2 × 행 수이므로, 1억 행 테이블은 dead tuple이 2000만 개 쌓여야 autovacuum이 돕니다. PG18의 autovacuum_vacuum_max_threshold(1억)로도 이 정도 테이블에는 효과가 없습니다. 자주 바뀌는 큰 테이블에는 테이블 단위로 기준을 낮춥니다.\nALTER TABLE big_orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 10000); autovacuum이 도는지, 얼마나 자주 도는지는 pg_stat_user_tables의 last_autovacuum, autovacuum_count, n_dead_tup으로 확인하고, log_autovacuum_min_duration을 켜 두면 위 실습처럼 실행 기록이 로그에 남습니다.\nPG18의 그 밖의 VACUUM 변화 autovacuum 기준 외에 PG18에서 바뀐 점은 다음과 같습니다.\neager scanning(vacuumlazy.c): 일반 VACUUM도 all-visible이지만 아직 얼리지 않은 페이지를 조금씩 미리 읽어 freeze합니다. 나중의 대규모 freeze 작업(6편)을 나눠 하려는 것입니다. vacuum_max_eager_freeze_failure_rate(기본 0.03)로 조절하고, VACUUM VERBOSE 출력의 eagerly scanned가 그 페이지 수입니다. autovacuum_worker_slots(기본 16): autovacuum worker 자리를 미리 잡아 두는 값입니다. 이제 autovacuum_max_workers는 재시작 없이 이 범위 안에서 바꿀 수 있습니다. 정리 VACUUM은 힙 스캔(pruning, TID 수집) → 인덱스 정리 → 힙 정리(LP_DEAD → LP_UNUSED) 순서로 일하고, 이 순서는 인덱스가 재사용된 자리를 잘못 가리키지 않게 하려는 것입니다. 일반 VACUUM은 공간을 재사용 가능하게 만들 뿐, 파일 끝의 빈 페이지가 아니면 파일을 줄이지 않습니다. 줄이려면 VACUUM FULL(전체 락)이나 pg_repack이 필요합니다. VACUUM은 가장 오래된 스냅샷(removable cutoff)보다 먼저 지워진 튜플만 지울 수 있어서, 긴 트랜잭션 하나가 정리를 막습니다. 쿼리도 페이지가 차면 그 자리에서 pruning하고, HOT 업데이트는 인덱스를 건드리지 않고 같은 페이지 안에서 버전을 잇습니다. FSM은 빈 공간을, VM은 all-visible, all-frozen을 페이지마다 기록합니다. VM 덕분에 VACUUM은 페이지를 건너뛰고, index-only scan은 힙을 읽지 않습니다. autovacuum은 50 + 0.2 × 행 수를 넘는 dead tuple, 또는 INSERT 기준을 넘으면 돕니다. PG18에서 상한(autovacuum_vacuum_max_threshold)과 freeze 비율 반영, eager scanning이 추가되었습니다. 다음 글에서는 VACUUM의 또 다른 임무인 freeze와 이를 게을리하면 생기는 트랜잭션 ID wraparound를 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/access/heap/vacuumlazy.c: VACUUM 단계, eager scanning, truncate src/backend/access/heap/pruneheap.c: pruning과 HOT 체인 정리 src/backend/access/heap/README.HOT: HOT 설계 src/backend/storage/freespace/README: FSM 구조 src/backend/access/heap/visibilitymap.c: Visibility Map src/backend/postmaster/autovacuum.c: autovacuum 대상 판단 PostgreSQL 18 공식 문서\nRoutine Vacuuming VACUUM Automatic Vacuuming 설정 Heap-Only Tuples (HOT) Visibility Map, Free Space Map ","permalink":"https://jinlog.dev/posts/postgresql/05-vacuum/","summary":"dead tuple은 누가 언제 어떻게 정리하는가","title":"PostgreSQL 인터널 5: VACUUM과 Autovacuum"},{"content":"개요 5편에서 쓰기는 모두 primary 한 대가 받고, secondary는 primary의 oplog를 따라간다는 것을 봤습니다. 그렇다면 primary가 죽으면 어떻게 될까요? 남은 멤버들은 primary가 사라졌다는 것을 스스로 알아차리고, 그중 한 대를 새 primary로 뽑습니다. 이것이 선출(election)입니다. 사람이 개입하지 않아도 되는 자동 failover가 레플리카셋을 쓰는 가장 큰 이유입니다.\nMongoDB의 선출은 분산 합의 알고리즘 Raft의 선출 방식을 따릅니다(레플리카셋 설정의 protocolVersion: 1). PostgreSQL은 이런 선출을 서버 안에 두지 않아서 Patroni 같은 외부 도구가 맡는데, MongoDB는 mongod 안에 들어 있습니다.\n이 글에서 답할 질문은 다음과 같습니다.\nprimary가 사라졌다는 것은 누가, 언제 알아차리는가 선거는 어떤 순서로 진행되고, term은 무엇을 막는가 멤버는 누구에게 표를 주고, 누구에게는 주지 않는가 rs.stepDown()과 priority는 선출을 어떻게 바꾸는가 과반을 잃은 primary는 어떻게 되는가 failover 동안 애플리케이션의 쓰기는 어떻게 되는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 레플리카셋은 컨테이너 3대(m06-1, m06-2, m06-3)로 만들었고, 쓰기를 보내는 클라이언트는 별도 컨테이너(m06-c)에서 돌렸습니다.\nterm과 과반 선출을 이해하는 데 필요한 개념은 두 가지입니다.\nterm: 선거를 할 때마다 1씩 커지는 번호입니다. 한 term에는 primary가 많아야 한 대입니다. 멤버는 자기보다 큰 term을 보면 곧바로 그 term으로 올라가고, primary였다면 물러납니다. 5편에서 본 oplog 엔트리의 t 필드가 이 term입니다. 같은 ts라도 term이 다르면 다른 primary가 쓴 엔트리라는 것을 알 수 있습니다. 과반(majority): 투표권이 있는 멤버(votes: 1) 수의 절반보다 많은 수입니다. 3대면 2, 5대면 3입니다. 선거에서 이기려면 과반의 표가 필요하고, primary도 과반과 연락이 닿아야 primary로 남습니다. 한 term에 멤버마다 한 표씩이므로 두 후보가 같은 term에 동시에 과반을 얻을 수 없습니다. 선출에 관련된 설정은 레플리카셋 설정(rs.conf().settings)에 있습니다(repl_set_config.idl).\n설정 기본값 뜻 heartbeatIntervalMillis 2000 다른 멤버에게 heartbeat를 보내는 간격 heartbeatTimeoutSecs 10 heartbeat 하나를 재시도하며 기다리는 최대 시간 electionTimeoutMillis 10000 primary 소식이 이만큼 끊기면 선거를 시작 catchUpTimeoutMillis -1 (무제한) 새 primary가 다른 멤버를 따라잡으며 기다리는 최대 시간 catchUpTakeoverDelayMillis 30000 새 primary보다 앞선 secondary가 catchup takeover를 하기까지 기다리는 시간 멤버마다 설정하는 값으로는 priority(기본 1, 0이면 primary가 될 수 없음)와 votes(1 또는 0)가 있습니다.\n선출 설정과 term priority를 모두 1로 둔 3노드 레플리카셋을 만들고 설정과 상태를 봅니다.\ntest\u003e rs.initiate({_id: \u0026#34;rs0\u0026#34;, members: [{_id: 0, host: \u0026#34;m06-1:27017\u0026#34;}, {_id: 1, host: \u0026#34;m06-2:27017\u0026#34;}, {_id: 2, host: \u0026#34;m06-3:27017\u0026#34;}]}) { ok: 1, ... } rs0 [direct: primary] test\u003e rs.conf().protocolVersion Long(\u0026#39;1\u0026#39;) rs0 [direct: primary] test\u003e var s = rs.conf().settings; ({heartbeatIntervalMillis: s.heartbeatIntervalMillis, heartbeatTimeoutSecs: s.heartbeatTimeoutSecs, electionTimeoutMillis: s.electionTimeoutMillis, catchUpTimeoutMillis: s.catchUpTimeoutMillis, catchUpTakeoverDelayMillis: s.catchUpTakeoverDelayMillis}) { heartbeatIntervalMillis: 2000, heartbeatTimeoutSecs: 10, electionTimeoutMillis: 10000, catchUpTimeoutMillis: -1, catchUpTakeoverDelayMillis: 30000 } rs0 [direct: primary] test\u003e rs.conf().members.map(m =\u0026gt; ({host: m.host, priority: m.priority, votes: m.votes})) [ { host: \u0026#39;m06-1:27017\u0026#39;, priority: 1, votes: 1 }, { host: \u0026#39;m06-2:27017\u0026#39;, priority: 1, votes: 1 }, { host: \u0026#39;m06-3:27017\u0026#39;, priority: 1, votes: 1 } ] rs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, electionDate: m.electionDate}))}) { term: Long(\u0026#39;1\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, electionDate: ISODate(\u0026#39;2026-09-27T13:57:51.000Z\u0026#39;) }, ... ] } rs0 [direct: primary] test\u003e rs.status().electionCandidateMetrics { lastElectionReason: \u0026#39;electionTimeout\u0026#39;, lastElectionDate: ISODate(\u0026#39;2026-09-27T13:57:51.910Z\u0026#39;), electionTerm: Long(\u0026#39;1\u0026#39;), lastCommittedOpTimeAtElection: { ts: Timestamp({ t: 1790517460, i: 1 }), t: Long(\u0026#39;-1\u0026#39;) }, lastSeenWrittenOpTimeAtElection: { ts: Timestamp({ t: 1790517460, i: 1 }), t: Long(\u0026#39;-1\u0026#39;) }, lastSeenOpTimeAtElection: { ts: Timestamp({ t: 1790517460, i: 1 }), t: Long(\u0026#39;-1\u0026#39;) }, numVotesNeeded: 2, priorityAtElection: 1, electionTimeoutMillis: Long(\u0026#39;10000\u0026#39;), numCatchUpOps: Long(\u0026#39;0\u0026#39;), newTermStartDate: ISODate(\u0026#39;2026-09-27T13:57:51.935Z\u0026#39;), wMajorityWriteAvailabilityDate: ISODate(\u0026#39;2026-09-27T13:57:52.428Z\u0026#39;) } 설정은 표의 기본값 그대로입니다. 셋을 만든 직후 m06-1이 첫 선거에서 이겨 term 1의 primary가 되었습니다. 셋을 만든 시점(Timestamp({ t: 1790517460 ... }), 13:57:40)에는 primary가 없었으니, election timeout이 지난 13:57:51에 선거가 시작되었고 이유도 electionTimeout입니다. electionCandidateMetrics는 이 멤버가 후보로서 치른 마지막 선거의 기록입니다(_startRealElection()). 이기는 데 표가 2개 필요했고(numVotesNeeded), 새 term의 첫 엔트리를 13:57:51.935에 썼고(newTermStartDate), 그 엔트리가 과반에 복제되어 w: \u0026quot;majority\u0026quot; 쓰기가 가능해진 것은 0.5초 뒤인 13:57:52.428입니다(wMajorityWriteAvailabilityDate). lastElectionReason이 가질 수 있는 값은 electionTimeout, priorityTakeover, stepUpRequest, stepUpRequestSkipDryRun, catchupTakeover, singleNodePromptElection입니다(replication_metrics.idl). 이 글에서 앞의 셋을 차례로 만나게 됩니다. 장애 감지: heartbeat와 election timeout 모든 멤버는 다른 모든 멤버에게 2초마다 heartbeat를 보냅니다. 응답에는 상대의 상태(PRIMARY, SECONDARY 등), term, oplog 위치가 들어 있습니다. heartbeat가 실패하면 그 heartbeat를 시작한 지 heartbeatTimeoutSecs(10초)가 지나지 않은 동안 곧바로 다시 보내고, 재시도 횟수(kMaxHeartbeatRetries, 2번)를 다 쓰면 Heartbeat failed after max retries를 남깁니다(topology_coordinator.cpp). 상대 프로세스가 죽어 연결이 곧바로 거절되면 이 실패가 1~2초 안에 나오고, 네트워크가 끊겨 응답이 오지 않으면 10초 제한까지 기다린 뒤에 나옵니다.\n선거를 시작하는 것은 heartbeat 실패 자체가 아니라 election timeout 타이머입니다. secondary는 primary로부터 소식(heartbeat 응답, 복제 배치)을 들을 때마다 타이머를 electionTimeoutMillis 뒤로 다시 맞춥니다(_cancelAndRescheduleElectionTimeout_inlock()). primary가 죽으면 소식이 끊기고 타이머가 만료되어 선거가 시작됩니다. 이때 만료 시각에 electionTimeoutMillis의 최대 15%까지 무작위 시간을 더합니다(scheduleAtWithJitter, 비율은 replElectionTimeoutOffsetLimitFraction 기본 0.15, repl_server_parameters.idl). 모든 secondary가 같은 순간에 후보로 나서 표가 갈리는 일을 줄이기 위해서입니다. 기본값이면 primary 소식이 끊긴 뒤 10~11.5초 사이에 선거가 시작됩니다.\nprimary 쪽에서도 같은 감시가 돌아갑니다. primary는 electionTimeoutMillis 동안 소식이 없는 멤버를 down으로 표시하고, 그 결과 과반과 연락이 닿지 않으면 스스로 물러납니다(checkMemberTimeouts()). 다른 쪽에서 새 primary가 뽑힐 즈음 옛 primary도 물러나므로, 두 primary가 동시에 쓰기를 받는 시간이 길어지지 않습니다. 뒤의 네트워크 분할 실습에서 이 동작을 봅니다.\n선거 절차: dry run, term, 투표 타이머가 만료된 secondary는 _startElectSelfIfEligibleV1()에서 선거를 시작합니다.\nprimary 소식이 끊기면 election timeout 타이머가 만료되고, 후보는 dry run으로 이길 수 있는지 먼저 확인한 뒤 term을 올려 진짜 투표를 요청합니다. 과반의 표를 얻으면 catch-up과 drain을 거쳐 쓰기를 받기 시작합니다. 새 창에서 크게 보기 후보가 될 수 있는지 확인: 다음 가운데 하나라도 해당하면 선거에 나서지 않습니다(_getMyUnelectableReason()). 데이터가 없음, 과반이 살아 있는 것으로 보이지 않음(CannotSeeMajority), arbiter임, priority가 0, stepdown 뒤 대기 기간 중, SECONDARY 상태가 아님. dry run: term을 올리지 않은 채 \u0026ldquo;지금 선거를 하면 표를 주겠는가\u0026quot;를 replSetRequestVotes(dryRun: true)로 묻습니다(ElectionState::start()). 과반이 \u0026ldquo;예\u0026quot;라고 할 때만 다음 단계로 갑니다. Raft 논문에서 pre-vote라고 부르는 단계입니다. 이 단계가 없으면, 네트워크에서 잠깐 떨어졌던 멤버가 혼자 선거를 거듭하며 term을 계속 올리다가, 돌아왔을 때 그 큰 term 때문에 멀쩡한 primary를 물러나게 만들 수 있습니다. 진짜 선거: term을 1 올리고, 자기에게 한 표를 준 뒤, 그 사실(LastVote)을 local.replset.election 컬렉션에 기록하고 나서 투표를 요청합니다(_startRealElection(), _writeLastVoteForMyElection()). 투표 기록을 디스크에 남기는 것은, 투표한 뒤 재시작한 멤버가 같은 term에 다른 후보에게 또 표를 주지 않게 하기 위해서입니다. 과반을 얻으면 Election succeeded, assuming primary role을 남기고 primary로 전환합니다(replication_coordinator_impl_elect_v1.cpp). 전환 과정은 뒤에서 봅니다. 투표 요청을 받은 멤버는 processReplSetRequestVotes()에서 아래 순서로 검사해, 걸리는 것이 하나라도 있으면 거절합니다.\n거절 조건 이유 후보의 설정(config version, term)이 내 것보다 오래됨 오래된 멤버 목록으로 과반을 세면 안 된다 후보의 term이 내 term보다 작음 이미 지나간 선거다 레플리카셋 이름이 다름 후보의 lastWritten optime이 내 것보다 뒤처짐 내가 가진 엔트리를 후보가 갖고 있지 않다 (진짜 선거에서) 이 term에 이미 다른 후보에게 투표함 한 term에 한 표 내가 arbiter인데, priority가 같거나 높은 정상 primary가 보임 arbiter 때문에 primary가 오락가락하는 것을 막는다 네 번째 조건이 중요합니다. 과반의 멤버에게 복제된 엔트리(majority commit된 쓰기)는, 과반 가운데 적어도 한 멤버가 그 엔트리를 갖고 있으므로 그 엔트리가 없는 후보는 그 멤버의 표를 얻지 못합니다. 그래서 새 primary는 늘 majority commit된 쓰기를 모두 갖고 있습니다. 8.0에서는 이 비교에 lastWritten(자기 oplog에 쓴 위치, 5편)을 씁니다. 반대로 과반에 닿지 못한 쓰기는 새 primary에 없을 수 있고, 그 쓰기는 옛 primary가 돌아올 때 rollback됩니다(8편).\nprimary를 kill -9로 죽이면 뒤의 stepDown 실습을 거쳐 지금 primary는 m06-2입니다. 클라이언트 컨테이너에서 쓰기를 0.1초마다 보내는 스크립트를 두 벌 띄워 둡니다. 하나는 드라이버 기본값(retryable write 켜짐), 하나는 retryWrites=false이고, 0.3초 넘게 걸리거나 실패한 쓰기만 출력합니다.\n$ nohup mongosh \u0026#34;mongodb://m06-1:27017,m06-2:27017,m06-3:27017/test?replicaSet=rs0\u0026#34; --quiet --eval \u0026#39;var C = \u0026#34;retry\u0026#34;\u0026#39; --file /tmp/w.js \u0026gt; /tmp/retry.log 2\u0026gt;\u0026amp;1 \u0026lt; /dev/null \u0026amp; $ nohup mongosh \u0026#34;mongodb://m06-1:27017,m06-2:27017,m06-3:27017/test?replicaSet=rs0\u0026amp;retryWrites=false\u0026#34; --quiet --eval \u0026#39;var C = \u0026#34;noretry\u0026#34;\u0026#39; --file /tmp/w.js \u0026gt; /tmp/noretry.log 2\u0026gt;\u0026amp;1 \u0026lt; /dev/null \u0026amp; /tmp/w.js는 이렇습니다.\nlet i = 0; const end = Date.now() + 40000; while (Date.now() \u0026lt; end) { const t = Date.now(); try { db.getCollection(C).insertOne({i: i}); const ms = Date.now() - t; if (ms \u0026gt; 300) print(new Date().toISOString().slice(11, 23), \u0026#34;i=\u0026#34; + i, \u0026#34;ok after\u0026#34;, ms, \u0026#34;ms\u0026#34;); } catch (e) { print(new Date().toISOString().slice(11, 23), \u0026#34;i=\u0026#34; + i, \u0026#34;error after\u0026#34;, Date.now() - t, \u0026#34;ms:\u0026#34;, e.codeName || e.name, \u0026#34;-\u0026#34;, e.message); } i++; sleep(100); } print(\u0026#34;done\u0026#34;, i, \u0026#34;inserts,\u0026#34;, db.getCollection(C).countDocuments(), \u0026#34;docs\u0026#34;); 3초 뒤 primary m06-2의 mongod를 SIGKILL로 죽입니다.\n$ kill -9 17 $ date -u +%T.%3N 13:58:02.135 남은 두 멤버의 상태를 새 primary m06-3에서 봅니다.\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, health: m.health}))}) { term: Long(\u0026#39;3\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, health: 1 }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, health: 1 } ] } rs0 [direct: primary] test\u003e rs.status().electionCandidateMetrics { lastElectionReason: \u0026#39;electionTimeout\u0026#39;, lastElectionDate: ISODate(\u0026#39;2026-09-27T13:58:12.997Z\u0026#39;), electionTerm: Long(\u0026#39;3\u0026#39;), ... numVotesNeeded: 2, priorityAtElection: 1, electionTimeoutMillis: Long(\u0026#39;10000\u0026#39;), numCatchUpOps: Long(\u0026#39;0\u0026#39;), newTermStartDate: ISODate(\u0026#39;2026-09-27T13:58:13.008Z\u0026#39;), wMajorityWriteAvailabilityDate: ISODate(\u0026#39;2026-09-27T13:58:13.016Z\u0026#39;) } 선출 과정은 로그로 봅니다. 선출 관련 메시지만 짧게 뽑는 jq 필터를 파일로 만들어 두었습니다.\n$ cat \u0026gt; /tmp/elog.jq \u0026lt;\u0026lt;\u0026#39;JQ\u0026#39; \u003e select(.msg | test(\u0026#34;Starting an election|dry run|Dry election|Election succeeded|catch-up mode|Transition to primary complete|priority takeover|Stepping down from primary|see a majority|replSetStepUp|Handing off election|kill user operations|Replica set state transition\u0026#34;)) \u003e | {t: .t.\u0026#34;$date\u0026#34;[11:23], msg, attr: (.attr | {oldState, newState, term, newTerm, when, target} | with_entries(select(.value != null)))} \u003e JQ 후보 m06-3의 로그입니다.\n$ jq -c \u0026#39;select(.msg==\u0026#34;Heartbeat failed after max retries\u0026#34; and .attr.target==\u0026#34;m06-2:27017\u0026#34;)|{t:.t.\u0026#34;$date\u0026#34;[11:23],msg,attr:{target:.attr.target,error:.attr.error.errmsg}}\u0026#39; /data/mongod.log | head -1 $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -10 {\u0026#34;t\u0026#34;:\u0026#34;13:58:03.793\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Heartbeat failed after max retries\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;target\u0026#34;:\u0026#34;m06-2:27017\u0026#34;,\u0026#34;error\u0026#34;:\u0026#34;Error connecting to m06-2:27017 (172.21.0.3:27017) :: caused by :: onInvoke :: caused by :: Connection refused\u0026#34;}} ... {\u0026#34;t\u0026#34;:\u0026#34;13:58:12.995\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting an election, since we\u0026#39;ve seen no PRIMARY in election timeout period\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:12.995\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Conducting a dry run election to see if we could be elected\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:12.997\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Dry election run succeeded, running for election\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;newTerm\u0026#34;:3}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.004\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Election succeeded, assuming primary role\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:3}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.004\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;SECONDARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;PRIMARY\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.004\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Entering primary catch-up mode\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.006\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Exited primary catch-up mode\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.007\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:13.008\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to primary complete; database writes are now permitted\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:3}} 투표한 m06-1의 로그와 electionParticipantMetrics입니다.\n$ jq -c \u0026#39;select(.msg==\u0026#34;Responding to vote request\u0026#34;)|{t:.t.\u0026#34;$date\u0026#34;,msg,attr:(.attr|{request,response})}\u0026#39; /data/mongod.log | tail -2 {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:58:12.996+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Responding to vote request\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;request\u0026#34;:\u0026#34;{ replSetRequestVotes: 1, setName: \\\u0026#34;rs0\\\u0026#34;, dryRun: true, term: 2, candidateIndex: 2, configVersion: 1, configTerm: 2, lastWrittenOpTime: { ts: Timestamp(1790517482, 2), t: 2 }, lastAppliedOpTime: { ts: Timestamp(1790517482, 2), t: 2 } }\u0026#34;,\u0026#34;response\u0026#34;:\u0026#34;{ term: 2, voteGranted: true, reason: \\\u0026#34;\\\u0026#34; }\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:58:13.003+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Responding to vote request\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;request\u0026#34;:\u0026#34;{ replSetRequestVotes: 1, setName: \\\u0026#34;rs0\\\u0026#34;, dryRun: false, term: 3, candidateIndex: 2, configVersion: 1, configTerm: 2, lastWrittenOpTime: { ts: Timestamp(1790517482, 2), t: 2 }, lastAppliedOpTime: { ts: Timestamp(1790517482, 2), t: 2 } }\u0026#34;,\u0026#34;response\u0026#34;:\u0026#34;{ term: 3, voteGranted: true, reason: \\\u0026#34;\\\u0026#34; }\u0026#34;}} rs0 [direct: secondary] test\u003e rs.status().electionParticipantMetrics { votedForCandidate: true, electionTerm: Long(\u0026#39;3\u0026#39;), lastVoteDate: ISODate(\u0026#39;2026-09-27T13:58:13.003Z\u0026#39;), electionCandidateMemberId: 2, voteReason: \u0026#39;\u0026#39;, lastWrittenOpTimeAtElection: { ts: Timestamp({ t: 1790517482, i: 2 }), t: Long(\u0026#39;2\u0026#39;) }, maxWrittenOpTimeInSet: { ts: Timestamp({ t: 1790517482, i: 2 }), t: Long(\u0026#39;2\u0026#39;) }, ... priorityAtElection: 1, newTermStartDate: ISODate(\u0026#39;2026-09-27T13:58:13.008Z\u0026#39;), newTermAppliedDate: ISODate(\u0026#39;2026-09-27T13:58:13.015Z\u0026#39;) } 시간 순서로 정리하면 다음과 같습니다.\n시각 일 13:58:02.135 primary m06-2 강제 종료 13:58:03.793 m06-3의 heartbeat가 Connection refused로 실패 13:58:12.995 election timeout 만료, 선거 시작과 dry run (종료 뒤 10.86초) 13:58:12.996 m06-1이 dry run(term 2)에 찬성 13:58:12.997 dry run 통과, term 3으로 진짜 선거 13:58:13.003 m06-1이 term 3에서 찬성 13:58:13.004 과반(자기 표 + m06-1) 획득, PRIMARY로 전환 13:58:13.008 새 term의 첫 엔트리 기록, 쓰기 허용 heartbeat는 종료 1.7초 뒤에 이미 실패했지만, 선거는 그보다 9초 뒤에 시작되었습니다. 선거를 여는 것은 heartbeat 실패가 아니라 election timeout이기 때문입니다. 마지막으로 primary 소식을 들은 때부터 10초에 무작위 오프셋을 더한 시각입니다. dry run은 dryRun: true, term: 2, 즉 지금 term 그대로 표를 물었고, 진짜 선거는 dryRun: false, term: 3입니다. 투표자는 두 요청 모두에 voteGranted: true를 주었습니다. 후보의 lastWrittenOpTime이 투표자의 것(lastWrittenOpTimeAtElection)과 같아 뒤처지지 않았기 때문입니다. 표 계산은 밀리초 단위로 끝났습니다. failover 시간의 대부분은 감지, 즉 election timeout을 기다리는 시간입니다. 그동안 드라이버는 기다렸다 $ cat /tmp/retry.log $ cat /tmp/noretry.log 13:58:13.016 i=28 ok after 10812 ms done 271 inserts, 271 docs 13:58:13.016 i=28 ok after 10815 ms done 271 inserts, 271 docs 두 클라이언트 모두 28번째 쓰기가 10.8초 걸렸고, 오류는 없었습니다. 13:58:13.016에 끝났으니, 새 primary가 쓰기를 허용한(13:58:13.008) 직후입니다. retryable write를 끈 쪽도 오류가 나지 않은 것은, 이 쓰기가 보내지기 전에 primary가 사라졌기 때문입니다. kill -9로 프로세스가 죽으면 운영체제가 연결을 바로 끊으므로, 드라이버는 primary가 사라졌음을 곧바로 알고 다음 쓰기를 보낼 primary가 생길 때까지 기다렸습니다(드라이버의 server selection 대기). retryable write가 차이를 만드는 것은 쓰기를 보낸 뒤 응답을 받기 전에 문제가 생긴 경우입니다. 네트워크 분할 실습에서 그 경우를 봅니다.\n새 primary의 catch-up과 drain 선거에서 이겼다고 바로 쓰기를 받지는 않습니다. 앞의 로그에서 Election succeeded와 Transition to primary complete 사이에 두 단계가 있었습니다.\ncatch-up: 새 primary는 heartbeat로 알게 된 다른 멤버의 위치 가운데 자기보다 앞선 것이 있으면, 그 위치까지 oplog를 받아 옵니다(CatchupState::start_inlock()). 과반에 닿지 못했지만 어떤 secondary에는 있는 엔트리를 살려서, 옛 primary가 돌아왔을 때 rollback될 양을 줄이기 위해서입니다. 기다리는 한도가 catchUpTimeoutMillis(기본 무제한)입니다. 실습에서는 모두 같은 위치였으므로 Entering에서 Exited까지 2ms였고 numCatchUpOps: 0입니다. 새 primary가 끝내 따라잡지 못하는 동안 더 앞선 secondary가 있으면, 그 secondary가 catchUpTakeoverDelayMillis(30초) 뒤에 catchup takeover 선거를 엽니다(topology_coordinator.cpp, _amIFreshEnoughForCatchupTakeover()). drain: secondary일 때 받아 두고 아직 적용하지 않은 oplog(apply buffer, 5편)를 모두 적용합니다. 그다음 {msg: \u0026quot;new primary\u0026quot;}를 담은 no-op 엔트리를 새 term으로 oplog에 쓰고(onTransitionToPrimary()), 쓰기를 허용합니다(Transition to primary complete; database writes are now permitted). electionCandidateMetrics.newTermStartDate가 이 엔트리를 쓴 시각이고, secondary 쪽 electionParticipantMetrics.newTermAppliedDate는 그 엔트리를 적용한 시각입니다. rs.stepDown() 계획된 작업(버전 업그레이드, 서버 점검)으로 primary를 바꿀 때는 죽이지 않고 rs.stepDown()(replSetStepDown 명령)을 씁니다. ReplicationCoordinatorImpl::stepDown()은 이렇게 진행합니다.\n복제 상태 전환 락(RSTL)을 exclusive로 잡고 새 쓰기를 막습니다. 진행 중인 쓰기는 중단시킵니다(Starting to kill user operations). 과반의 멤버가 자기 마지막 위치까지 왔고, 그중 선출될 수 있는 secondary가 하나라도 따라왔는지 봅니다(isSafeToStepDown()). 아니면 락을 잠시 풀어 secondary가 oplog를 읽어 가게 하고, secondaryCatchUpPeriodSecs(기본 10초, repl_set_commands.cpp)까지 기다립니다. 그래도 안 되면 No electable secondaries caught up 오류로 stepdown을 포기합니다(tryToStartStepDown()). 따라온 secondary가 있을 때만 물러나므로, stepdown으로는 majority commit된 쓰기를 잃지 않습니다. SECONDARY로 내려가고, stepDownSecs(기본 60초) 동안은 선거에 나서지 않습니다. election handoff: 따라온 secondary 가운데 priority가 가장 높은 멤버에게 replSetStepUp(skipDryRun: true)을 보내, election timeout을 기다리지 않고 바로 선거를 열게 합니다(_performElectionHandoff(), chooseElectionHandoffCandidate(), enableElectionHandoff 기본 true). 옛 primary가 방금 물러나며 \u0026ldquo;따라온 것\u0026quot;을 확인했으므로 dry run을 건너뜁니다. rs.stepDown()으로 넘기기 primary m06-1에서 한 건 쓰고 물러납니다.\nrs0 [direct: primary] test\u003e db.t.insertOne({before: \u0026#34;stepDown\u0026#34;}) { acknowledged: true, insertedId: ObjectId(\u0026#39;6ab920e160932de5a5fdfe24\u0026#39;) } rs0 [direct: primary] test\u003e rs.stepDown() { ok: 1, ... } rs0 [direct: secondary] test\u003e db.t.insertOne({after: \u0026#34;stepDown\u0026#34;}) Uncaught: MongoServerSelectionError: not primary Caused by: MongoServerError[NotWritablePrimary]: not primary 새 primary m06-2에서:\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr}))}) { term: Long(\u0026#39;2\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39; }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; } ] } rs0 [direct: primary] test\u003e var m = rs.status().electionCandidateMetrics; ({lastElectionReason: m.lastElectionReason, electionTerm: m.electionTerm, numVotesNeeded: m.numVotesNeeded, priorPrimaryMemberId: m.priorPrimaryMemberId, numCatchUpOps: m.numCatchUpOps}) { lastElectionReason: \u0026#39;stepUpRequestSkipDryRun\u0026#39;, electionTerm: Long(\u0026#39;2\u0026#39;), numVotesNeeded: 2, priorPrimaryMemberId: 0, numCatchUpOps: Long(\u0026#39;0\u0026#39;) } 옛 primary m06-1의 로그와 새 primary m06-2의 로그입니다.\n$ jq -c -f /tmp/elog.jq /data/mongod.log | tail -3 {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.768\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.769\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;PRIMARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;SECONDARY\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.769\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Handing off election\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;target\u0026#34;:\u0026#34;m06-2:27017\u0026#34;}} $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -9 {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.769\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Received replSetStepUp request\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.769\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting an election due to step up request\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.769\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Skipping dry run and running for election\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;newTerm\u0026#34;:2}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.770\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Election succeeded, assuming primary role\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:2}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.770\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;SECONDARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;PRIMARY\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.770\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Entering primary catch-up mode\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.770\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Exited primary catch-up mode\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.770\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:57:53.771\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to primary complete; database writes are now permitted\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:2}} stepdown 직후 같은 연결로 쓰기를 시도하자 not primary(NotWritablePrimary) 오류가 났습니다. mongosh가 이 멤버에 직접 접속해 있어서 다른 멤버로 옮겨 가지 않았기 때문입니다. 프롬프트도 [direct: secondary]로 바뀌었습니다. m06-1이 사용자 작업을 끊고 SECONDARY로 내려간 뒤 m06-2에게 선거를 넘겼습니다(Handing off election). m06-2는 replSetStepUp을 받아 dry run 없이(Skipping dry run) term 2 선거를 열어 이겼습니다. 선출 이유가 stepUpRequestSkipDryRun, 이전 primary가 member id 0(m06-1)입니다. 옛 primary가 물러나기 시작한 13:57:53.768부터 새 primary가 쓰기를 허용한 13:57:53.771까지 3ms입니다. election timeout을 기다린 kill -9의 경우와 비교하면 차이가 분명합니다. priority와 takeover priority는 \u0026ldquo;누가 primary가 되면 좋은가\u0026quot;를 정합니다. priority 0인 멤버는 primary가 될 수 없고(표는 줄 수 있음), votes: 0인 멤버는 표를 주지 않습니다.\npriority가 높은 secondary가 heartbeat로 자기보다 priority가 낮은 primary를 보면 priority takeover를 예약합니다(topology_coordinator.cpp). 예약 시각은 (priority 순위 + 1) × electionTimeoutMillis에 무작위 오프셋을 더한 뒤입니다(getPriorityTakeoverDelay(), 예약). 가장 높은 priority(순위 0)면 10초 남짓, 두 번째면 20초 남짓입니다. 여러 멤버의 priority가 primary보다 높을 때 가장 높은 멤버가 먼저 나서게 하는 장치입니다. 그리고 primary의 최신 위치에서 priorityTakeoverFreshnessWindowSeconds(기본 2초) 안으로 따라와 있어야 실제로 선거에 나섭니다(_amIFreshEnoughForPriorityTakeover()). 뒤처진 멤버가 primary를 빼앗아 쓰기를 잃는 일을 막기 위해서입니다. takeover 선거에서 이긴 새 primary의 term이 옛 primary의 term보다 크므로, 옛 primary는 그 term을 보는 순간 물러납니다.\npriority를 올리면 kill -9로 죽였던 m06-2를 다시 켜면 SECONDARY로 돌아옵니다. 그다음 primary m06-3에서 m06-2의 priority를 3으로 올립니다.\n$ mongod --dbpath /data/db --logpath /data/mongod.log --bind_ip_all --fork --replSet rs0 | grep -E \u0026#39;forked|ERROR\u0026#39; forked process: 483 rs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr}))}) { term: Long(\u0026#39;3\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39; } ] } rs0 [direct: primary] test\u003e var c = rs.conf(); c.members[1].priority = 3; rs.reconfig(c).ok 1 잠시 뒤 m06-2에서:\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr}))}) { term: Long(\u0026#39;4\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39; }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; } ] } rs0 [direct: primary] test\u003e var m = rs.status().electionCandidateMetrics; ({lastElectionReason: m.lastElectionReason, electionTerm: m.electionTerm, priorityAtElection: m.priorityAtElection}) { lastElectionReason: \u0026#39;priorityTakeover\u0026#39;, electionTerm: Long(\u0026#39;4\u0026#39;), priorityAtElection: 3 } $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -10 {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.791\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Canceling priority takeover callback\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.791\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting an election for a priority takeover\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.791\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Conducting a dry run election to see if we could be elected\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.792\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Dry election run succeeded, running for election\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;newTerm\u0026#34;:4}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.796\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Election succeeded, assuming primary role\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:4}} ... {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.801\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to primary complete; database writes are now permitted\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:4}} 옛 primary m06-3에서:\n$ jq -c -f /tmp/elog.jq /data/mongod.log | tail -3 {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.795\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Stepping down from primary, because a new term has begun\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:4}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.795\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:58:57.795\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;PRIMARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;SECONDARY\u0026#34;}} 아무 장애도 없는데 m06-2가 Starting an election for a priority takeover로 선거를 열어 term 4의 primary가 되었습니다(lastElectionReason: 'priorityTakeover', priorityAtElection: 3). priority takeover도 dry run을 거칩니다. 옛 primary m06-3는 새 term 4를 보고 Stepping down from primary, because a new term has begun으로 물러났습니다. term이 \u0026ldquo;누가 최신 primary인가\u0026quot;를 정하는 장치로 쓰인 장면입니다. 네트워크 분할 primary가 죽지 않고 네트워크에서만 떨어지면 어떻게 될까요? 떨어진 primary는 살아 있으니 쓰기를 받으려 하고, 나머지 쪽은 primary 소식이 끊겼으니 새 primary를 뽑습니다. 잠깐이지만 primary가 둘이 될 수 있습니다. MongoDB는 두 장치로 이 시간을 짧게 만듭니다.\n떨어진 primary는 electionTimeout 동안 과반의 소식을 듣지 못하면 스스로 물러납니다(앞에서 본 checkMemberTimeouts()). 나머지 쪽이 새 term으로 primary를 뽑으므로, 떨어졌던 primary가 돌아오면 더 큰 term을 보고 물러납니다. 두 장치가 모두 electionTimeout을 기준으로 움직이므로 겹치는 시간은 길지 않지만 0은 아닙니다. 그 사이 옛 primary가 받은 w: 1 쓰기는 과반에 닿지 못하고, 돌아왔을 때 rollback됩니다. rollback과 이를 막는 w: \u0026quot;majority\u0026quot;는 8편에서 다룹니다.\nprimary를 네트워크에서 떼어 내면 앞의 두 클라이언트 스크립트를 다시 띄워 두고, primary m06-2의 컨테이너를 레플리카셋 네트워크에서 떼어 냈습니다(13:59:02.458). 15초 뒤 떨어진 m06-2에서:\nrs0 [direct: secondary] test\u003e var st = rs.status(); ({term: st.term, myState: st.myState, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, health: m.health}))}) { term: Long(\u0026#39;4\u0026#39;), myState: 2, members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, health: 1 }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 } ] } rs0 [direct: secondary] test\u003e db.t.insertOne({from: \u0026#34;isolated\u0026#34;}) MongoServerError[NotWritablePrimary]: not primary $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -4 {\u0026#34;t\u0026#34;:\u0026#34;13:59:12.308\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Can\u0026#39;t see a majority of the set, relinquishing primary\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:12.308\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Stepping down from primary in response to heartbeat\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:12.309\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:12.310\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;PRIMARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;SECONDARY\u0026#34;}} 나머지 쪽 m06-3에서:\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, health: m.health}))}) { term: Long(\u0026#39;5\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, health: 1 }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39;, health: 1 } ] } rs0 [direct: primary] test\u003e rs.status().electionCandidateMetrics.lastElectionReason electionTimeout 떨어진 m06-2는 떼어 낸 지 9.85초 뒤(13:59:12.308) **Can't see a majority of the set, relinquishing primary**를 남기고 SECONDARY로 내려갔습니다. 두 멤버가 모두 (not reachable/healthy)이니 자기 한 표로는 과반이 되지 않습니다. 이제 이 멤버에 직접 쓰려 하면 not primary입니다. term은 4 그대로입니다. 과반이 보이지 않으니 선거에도 나서지 않습니다. 나머지 두 멤버는 서로 과반(2)을 이루므로, election timeout 뒤 m06-3가 term 5의 primary가 되었습니다. 13:59:19.221에 m06-2를 네트워크에 다시 붙였습니다.\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr}))}) { term: Long(\u0026#39;5\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39; } ] } 잠시 뒤 m06-2에서:\nrs0 [direct: primary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr}))}) { term: Long(\u0026#39;6\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;PRIMARY\u0026#39; }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39; } ] } rs0 [direct: primary] test\u003e rs.status().electionCandidateMetrics.lastElectionReason priorityTakeover $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -12 {\u0026#34;t\u0026#34;:\u0026#34;13:59:12.310\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;PRIMARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;SECONDARY\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:19.378\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Scheduling priority takeover\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;when\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T13:59:30.602Z\u0026#34;}}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.602\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Canceling priority takeover callback\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.602\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting an election for a priority takeover\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.602\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Conducting a dry run election to see if we could be elected\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.603\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Dry election run succeeded, running for election\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;newTerm\u0026#34;:6}} {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.607\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Election succeeded, assuming primary role\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:6}} ... {\u0026#34;t\u0026#34;:\u0026#34;13:59:30.612\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to primary complete; database writes are now permitted\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;term\u0026#34;:6}} 다시 붙은 m06-2는 먼저 term 5의 SECONDARY로 합류했습니다. 그런데 m06-2의 priority는 앞에서 3으로 올려 두었습니다. 다시 붙은 지 0.16초 만에(13:59:19.378) heartbeat로 priority 1인 primary m06-3를 보고 priority takeover를 예약했습니다. 예약 시각 13:59:30.602는 11.2초 뒤로, priority 순위 0에 해당하는 10초에 무작위 오프셋 1.2초가 더해진 값입니다. 그 시각에 선거를 열어 term 6의 primary로 돌아왔습니다. 떨어져 있는 동안 m06-2에는 새로 들어간 쓰기가 없었으므로(아래 클라이언트의 쓰기도 도착하지 않았습니다) 이번에는 rollback이 일어나지 않았습니다. 분할 중에 보낸 쓰기 $ cat /tmp/retry2.log $ cat /tmp/noretry2.log 13:59:29.029 i=27 ok after 26652 ms done 124 inserts, 124 docs 13:59:29.021 i=27 error after 26645 ms: NotWritablePrimary - not primary done 124 inserts, 123 docs 이번에는 두 클라이언트의 결과가 달랐습니다.\n두 클라이언트 모두 27번째 쓰기가 26.6초 동안 끝나지 않았습니다. 이 쓰기를 시작한 13:59:02.37 무렵은 네트워크를 떼어 낸 순간과 겹칩니다. kill -9와 달리 네트워크 분할에서는 연결이 끊겼다는 신호가 오지 않으므로, 드라이버는 이미 보낸 요청의 응답을 계속 기다렸습니다. 응답은 네트워크를 다시 붙이고 10초쯤 지난 13:59:29에 왔습니다. 그사이 m06-2는 SECONDARY가 되었으므로 not primary로 거절했습니다. 분할 동안 전달되지 못한 요청이 재연결 뒤 TCP 재전송으로 뒤늦게 도착한 것으로 보입니다. retryWrites=false 클라이언트는 이 NotWritablePrimary 오류를 그대로 받았고, 124번 가운데 123건만 저장되었습니다. 기본 설정 클라이언트는 같은 오류를 받자 드라이버가 새 primary로 한 번 더 보내 성공했고(ok after 26652 ms), 124건이 모두 저장되었습니다. retryable write는 쓰기마다 세션 ID와 트랜잭션 번호를 붙여 보내므로(5편의 lsid, txnNumber), 첫 시도가 사실은 적용되었더라도 두 번째 시도에서 서버가 \u0026ldquo;이미 한 쓰기\u0026quot;를 알아보고 다시 실행하지 않습니다. 그래서 드라이버가 안심하고 한 번 재시도할 수 있습니다. 여러 도큐먼트를 바꾸는 updateMany, deleteMany는 retryable write가 아니므로 이 보호를 받지 못합니다.\n과반이 없으면 primary도 없다 두 멤버를 끄면 primary m06-2만 남기고 나머지 두 멤버를 정상 종료합니다.\n$ mongod --dbpath /data/db --shutdown | tail -1 Killing process with pid: 33 15초 뒤 m06-2에서:\nrs0 [direct: secondary] test\u003e var st = rs.status(); ({term: st.term, members: st.members.map(m =\u0026gt; ({name: m.name, stateStr: m.stateStr, health: m.health}))}) { term: Long(\u0026#39;6\u0026#39;), members: [ { name: \u0026#39;m06-1:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 }, { name: \u0026#39;m06-2:27017\u0026#39;, stateStr: \u0026#39;SECONDARY\u0026#39;, health: 1 }, { name: \u0026#39;m06-3:27017\u0026#39;, stateStr: \u0026#39;(not reachable/healthy)\u0026#39;, health: 0 } ] } rs0 [direct: secondary] test\u003e db.t.insertOne({x: \u0026#34;no majority\u0026#34;}) MongoServerError[NotWritablePrimary]: not primary rs0 [direct: secondary] test\u003e db.t.countDocuments() 1 $ jq -c -f /tmp/elog.jq /data/mongod.log | tail -4 $ jq -c \u0026#39;select(.msg==\u0026#34;Not starting an election, since we are not electable\u0026#34;)|{t:.t.\u0026#34;$date\u0026#34;,msg,attr}\u0026#39; /data/mongod.log | tail -1 {\u0026#34;t\u0026#34;:\u0026#34;14:00:25.649\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Can\u0026#39;t see a majority of the set, relinquishing primary\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;14:00:25.649\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Stepping down from primary in response to heartbeat\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;14:00:25.650\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting to kill user operations\u0026#34;,\u0026#34;attr\u0026#34;:{}} {\u0026#34;t\u0026#34;:\u0026#34;14:00:25.650\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Replica set state transition\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;oldState\u0026#34;:\u0026#34;PRIMARY\u0026#34;,\u0026#34;newState\u0026#34;:\u0026#34;SECONDARY\u0026#34;}} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T14:00:36.427+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Not starting an election, since we are not electable\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;reason\u0026#34;:\u0026#34;Not standing for election because I cannot see a majority (mask 0x1)\u0026#34;}} 멤버가 멀쩡히 살아 있어도, 과반과 연락이 닿지 않으면 primary는 물러납니다(Can't see a majority of the set). 이후 election timeout이 돌아와도 **Not standing for election because I cannot see a majority**로 선거에 나서지 않습니다(_getMyUnelectableReason(), 과반 판단은 _aMajoritySeemsToBeUp()). 그래서 쓰기는 not primary로 거절되지만, 읽기(countDocuments())는 SECONDARY에서도 됩니다. 과반을 잃은 레플리카셋은 읽기 전용이 됩니다. 한 대만 남은 쪽이 primary를 계속 하면 안 되는 이유는 네트워크 분할과 같습니다. 남은 한 대는 \u0026ldquo;다른 둘이 죽었는지, 나만 떨어졌는지\u0026rdquo; 구별할 수 없습니다. 다른 둘이 살아서 새 primary를 뽑았을 수도 있으므로, 과반이 보이지 않으면 물러나는 것이 안전한 쪽입니다. 운영에서는 이렇게 나타납니다 failover 동안 쓰기가 멈추는 시간. kill -9 실습에서 쓰기가 10.8초 멈췄고, 거의 전부가 election timeout을 기다린 시간이었습니다. 계획된 작업에서는 primary를 그냥 끄지 말고 먼저 rs.stepDown()을 해야 하는 이유입니다. stepDown 실습에서 primary가 바뀌는 데 3ms가 걸렸습니다. 서버를 재시작하는 순서는 secondary부터 하나씩, 마지막에 primary를 stepDown한 뒤 재시작입니다.\nretryable write는 켜 두고, 그래도 오류 처리는 합니다. 최근 드라이버는 기본으로 retryWrites=true입니다. 분할 실습처럼 응답을 못 받은 쓰기를 한 번 재시도해 주지만 딱 한 번이고, updateMany, deleteMany는 대상이 아닙니다. 재시도해도 새 primary가 아직 없으면 오류가 납니다. 애플리케이션은 NotWritablePrimary나 네트워크 오류를 \u0026ldquo;잠시 뒤 다시\u0026quot;로 처리할 수 있어야 합니다. failover 중에 쓰기가 멈춰 있는 시간(실습에서 10~27초)이 애플리케이션의 타임아웃보다 길면, 사용자에게는 오류로 보입니다.\nelectionTimeoutMillis 조정은 트레이드오프입니다. 줄이면 장애 감지와 failover가 빨라지지만, 네트워크가 잠깐 흔들리거나 primary가 GC, 디스크 지연으로 몇 초 멈춘 것만으로도 선거가 열립니다. 불필요한 선거는 쓰기를 짧게 끊고 진행 중인 작업을 중단시키며(Starting to kill user operations), w: 1 쓰기의 rollback 위험도 만듭니다. 늘리면 반대입니다. 기본값 10초를 바꾸려면 네트워크 지연과 서버의 멈춤 시간을 측정한 근거가 있어야 합니다.\n멤버 수는 홀수로. 과반은 투표 멤버 수의 절반 초과이므로, 4대면 과반이 3이라 3대와 마찬가지로 한 대만 잃을 수 있습니다. 데이터 센터 두 곳에 2대씩 나누면 어느 쪽도 과반이 아니어서, 두 곳 사이의 네트워크가 끊기면 양쪽 모두 primary가 없습니다. 세 번째 위치에 멤버 하나(가능하면 데이터를 가진 멤버, 여의치 않으면 arbiter)를 둡니다. arbiter는 투표만 하고 데이터가 없습니다. primary, secondary, arbiter 구성(PSA)에서 secondary가 죽으면 선출은 되지만, majority commit에 필요한 수는 데이터를 가진 투표 멤버 수로 제한되고(_calculateMajorities()) primary 혼자로는 채워지지 않아 w: \u0026quot;majority\u0026quot; 쓰기가 끝나지 않습니다(5편의 commit point). 이 글에서는 arbiter 구성을 실측하지 않았습니다.\n데이터 센터 배치와 priority. 평소에는 주 데이터 센터의 멤버가 primary가 되도록 그쪽 멤버의 priority를 높이고, 재해 복구용 원격 멤버는 낮은 priority(또는 0)로 둡니다. priority 실습처럼 priority가 높은 멤버는 돌아오는 즉시(약 10초 뒤) primary를 되찾습니다. 장애에서 막 복구된 멤버가 곧바로 primary가 되는 것이 부담스럽다면 이 동작도 고려해야 합니다.\n정리 MongoDB의 선출은 Raft 방식(protocolVersion: 1)입니다. term마다 primary는 많아야 하나이고, 선거에서 이기려면 투표 멤버 과반의 표가 필요합니다. 멤버는 2초마다 heartbeat를 주고받습니다. secondary는 primary 소식이 electionTimeoutMillis(10초)와 최대 15%의 무작위 시간 동안 끊기면 선거를 엽니다. 실습에서 primary를 죽인 뒤 선거가 시작되기까지 10.86초, 표를 모아 쓰기를 허용하기까지는 13ms가 더 걸렸습니다. 후보는 먼저 term을 올리지 않은 dry run으로 이길 수 있는지 확인하고, term을 올려 자기 표를 기록한 뒤 진짜 투표를 요청합니다. 투표자는 lastWritten이 자기보다 뒤처진 후보나 이미 표를 준 term의 다른 후보에게는 표를 주지 않습니다. 그래서 새 primary는 majority commit된 쓰기를 모두 갖습니다. 새 primary는 catch-up과 drain을 거쳐 new primary 엔트리를 쓴 뒤 쓰기를 받습니다. rs.stepDown()은 따라온 secondary가 있을 때만 물러나고, election handoff로 dry run 없이 곧바로 선거를 넘깁니다(stepUpRequestSkipDryRun, 실습에서 3ms). priority가 높은 secondary는 priority takeover로 primary를 가져갑니다. 과반과 연락이 끊긴 primary는 **Can't see a majority of the set**으로 스스로 물러나고, 과반이 없는 쪽에는 primary가 없습니다. 쓰기는 not primary로 거절됩니다. 응답을 받지 못한 쓰기는 retryable write가 한 번 재시도해 줍니다. 다음 글에서는 레플리카셋 여러 개를 묶어 데이터를 나눠 담는 샤딩 구조를 살펴봅니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/db/repl/replication_coordinator_impl_elect_v1.cpp: dry run과 진짜 선거, LastVote 기록 src/mongo/db/repl/replication_coordinator_impl_heartbeat.cpp: heartbeat 처리, election timeout, takeover 예약 src/mongo/db/repl/topology_coordinator.cpp: 투표 규칙, 선출 자격, takeover 판단, stepdown 조건, 과반을 잃었을 때의 stepdown src/mongo/db/repl/replication_coordinator_impl.cpp: stepDown, election handoff, catch-up과 drain src/mongo/db/repl/repl_set_config.idl: 선출 관련 설정의 기본값 src/mongo/db/repl/replication_metrics.idl: electionCandidateMetrics, electionParticipantMetrics MongoDB 8.0 공식 문서\nReplica Set Elections Retryable Writes rs.stepDown() 논문\nDiego Ongaro, John Ousterhout, In Search of an Understandable Consensus Algorithm (Raft) ","permalink":"https://jinlog.dev/posts/mongodb/06-election/","summary":"primary가 죽으면 누가, 어떻게 새 primary가 되고, 그동안 쓰기는 어떻게 되는가","title":"MongoDB 인터널 6: Primary 선출 과정"},{"content":"개요 디스크 사용률 알람이 울립니다. 데이터가 갑자기 늘어난 것도 아닌데 여유 공간이 빠르게 줄어듭니다. PostgreSQL 서버의 디스크를 채우는 것은 테이블만이 아닙니다. WAL(pg_wal), 임시 파일, 서버 로그, 그리고 5편의 bloat가 있습니다. 이 가운데 가장 위험한 것은 WAL입니다. 지워져야 할 WAL이 지워지지 않으면 디스크가 가득 차고, WAL을 쓰지 못하면 서버가 멈춥니다.\n이 글에서 답할 질문은 다음과 같습니다.\n디스크를 무엇이 차지하고 있는지 어떻게 나눠 보는가 WAL이 지워지지 않는 원인(아카이브 실패, 쓰지 않는 replication slot)은 어떻게 보이는가 디스크가 가득 차면 PostgreSQL은 어떻게 되고, 어떻게 되살리는가 임시 파일이 디스크를 채우는 것은 어떻게 막는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습에서는 데이터 디렉터리를 600MB로 크기를 제한한 파일 시스템(/pgfs)에 두고, 서버 로그와 WAL 아카이브는 그 밖(/var/lib/pgsql/log, /var/lib/pgsql/archive)에 둡니다. 로그를 데이터와 같은 디스크에 두면 디스크가 찼을 때 원인을 알려 줄 로그마저 쓰지 못하기 때문입니다. 운영에서도 로그는 데이터와 다른 디스크에 두는 편이 안전합니다.\n$ df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 39M 562M 7% /pgfs 먼저 확인할 것 디스크 사용량을 DB 파일과 WAL로 나눠 봅니다. pg_ls_waldir()는 pg_wal의 파일 목록과 크기를 돌려줍니다.\nSELECT pg_size_pretty(pg_database_size(\u0026#39;postgres\u0026#39;)) AS db, (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS pg_wal, (SELECT count(*) FROM pg_ls_waldir()) AS wal_files; pg_wal이 비정상적으로 크다면 다음 두 가지를 먼저 확인합니다.\n아카이브가 실패하고 있는가: pg_stat_archiver의 failed_count, last_failed_wal WAL을 붙잡는 replication slot이 있는가: pg_replication_slots의 active, restart_lsn, wal_status 평소 WAL은 체크포인트 때마다 필요 없어진 파일이 재활용되거나 지워지므로 대략 max_wal_size(기본 1GB) 근처에서 머뭅니다(인터널 7편, 8편). 이보다 한참 크다면 누군가 붙잡고 있는 것입니다.\n원인별 진단 아카이브 실패: 보내지 못한 WAL은 지울 수 없다 archive_mode = on이면 WAL 파일은 archive_command로 다른 곳에 복사된 뒤에야 지우거나 재활용할 수 있습니다. 아카이브 대상(백업 서버, 마운트한 NFS 등)이 망가진 상황을 항상 실패하는 명령(false)으로 흉내 냅니다.\npostgres=# ALTER SYSTEM SET archive_mode = on; ALTER SYSTEM # 아카이브 대상이 망가진 상황을 흉내 낸다: 명령이 항상 실패한다 postgres=# ALTER SYSTEM SET archive_command = \u0026#39;false\u0026#39;; ALTER SYSTEM WAL을 만드는 작업을 하고 체크포인트를 돌립니다.\n$ pgbench -i -s 10 -q postgres 2\u0026gt;\u0026amp;1 | tail -n 1 done in 0.42 s (drop tables 0.00 s, create tables 0.00 s, client-side generate 0.29 s, vacuum 0.03 s, primary keys 0.09 s). postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_size_pretty(pg_database_size(\u0026#39;postgres\u0026#39;)) AS db, postgres-# (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS pg_wal, postgres-# (SELECT count(*) FROM pg_ls_waldir()) AS wal_files; db | pg_wal | wal_files --------+--------+----------- 157 MB | 144 MB | 9 (1 row) postgres=# SELECT archived_count, failed_count, last_failed_wal, last_failed_time postgres-# FROM pg_stat_archiver; archived_count | failed_count | last_failed_wal | last_failed_time ----------------+--------------+--------------------------+------------------------------- 0 | 1 | 000000010000000000000001 | 2026-09-26 10:23:01.860649+00 (1 row) failed_count가 올라가고 archived_count는 0입니다. 보내지 못한 WAL 파일은 pg_wal/archive_status 아래 .ready 표시가 붙은 채 남습니다.\n$ ls $PGDATA/pg_wal/archive_status | head -n 3 $ ls $PGDATA/pg_wal/archive_status | grep -c \u0026#39;\\.ready$\u0026#39; 000000010000000000000001.ready 000000010000000000000002.ready 000000010000000000000003.ready 8 서버 로그에는 실패할 때마다 이렇게 남습니다.\n$ tail -n 600 \u0026#34;$(ls -t /var/lib/pgsql/log/*.log | head -1)\u0026#34; | grep -A 1 -E \u0026#39;archive command failed\u0026#39; | tail -n 2 2026-09-26 10:23:01.860 UTC [106] LOG: archive command failed with exit code 1 2026-09-26 10:23:01.860 UTC [106] DETAIL: The failed archive command was: false 아카이브는 실패해도 쓰기는 계속되므로, 대상이 몇 시간 동안 망가져 있으면 그동안의 WAL이 모두 pg_wal에 쌓입니다. 아카이브 대상을 고치면 밀린 파일을 차례로 보냅니다.\npostgres=# ALTER SYSTEM SET archive_command = \u0026#39;test ! -f /var/lib/pgsql/archive/%f \u0026amp;\u0026amp; cp %p /var/lib/pgsql/archive/%f\u0026#39;; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# SELECT archived_count, failed_count, last_archived_wal FROM pg_stat_archiver; archived_count | failed_count | last_archived_wal ----------------+--------------+-------------------------- 8 | 1 | 000000010000000000000008 (1 row) 밀려 있던 8개가 모두 아카이브되었습니다. 이제 다음 체크포인트부터 이 파일들을 재활용할 수 있습니다. 이 실습에서는 체크포인트 로그에 8 recycled로 남았습니다.\n2026-09-26 10:23:07.849 UTC [101] LOG: checkpoint complete: wrote 0 buffers (0.0%), wrote 0 SLRU buffers; 0 WAL file(s) added, 0 removed, 8 recycled; write=0.001 s, sync=0.001 s, total=0.001 s; sync files=0, longest=0.000 s, average=0.000 s; distance=0 kB, estimate=113474 kB; lsn=0/927BE18, redo lsn=0/927BDC0 재활용된 파일은 앞으로 쓸 WAL 파일로 이름만 바뀌어 남으므로 pg_wal의 크기가 바로 줄지는 않지만, 더 늘지 않습니다.\n쓰지 않는 replication slot: WAL을 끝없이 붙잡는다 replication slot은 \u0026ldquo;이 standby가 아직 받지 않은 WAL을 지우지 말라\u0026quot;는 약속입니다(인터널 9편). standby를 없애고 slot을 지우지 않았거나, standby가 오래 끊겨 있으면 slot은 그 시점 이후의 WAL을 모두 붙잡습니다. 아무도 쓰지 않는 slot을 하나 만들어 봅니다.\npostgres=# SELECT pg_create_physical_replication_slot(\u0026#39;standby1\u0026#39;, true); pg_create_physical_replication_slot ------------------------------------- (standby1,0/927BDC0) (1 row) postgres=# SELECT slot_name, active, restart_lsn, wal_status, safe_wal_size FROM pg_replication_slots; slot_name | active | restart_lsn | wal_status | safe_wal_size -----------+--------+-------------+------------+--------------- standby1 | f | 0/927BDC0 | reserved | (1 row) postgres=# SHOW max_slot_wal_keep_size; max_slot_wal_keep_size ------------------------ -1 (1 row) active = f이고, max_slot_wal_keep_size는 기본값 -1(제한 없음)입니다. 부하를 20초 줍니다.\n$ pgbench -n -c 4 -T 20 postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;processed|tps\u0026#39; number of transactions actually processed: 251116 tps = 12556.039820 (without initial connection time) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_size_pretty(pg_database_size(\u0026#39;postgres\u0026#39;)) AS db, postgres-# (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS pg_wal, postgres-# (SELECT count(*) FROM pg_ls_waldir()) AS wal_files; db | pg_wal | wal_files --------+--------+----------- 172 MB | 256 MB | 16 (1 row) postgres=# SELECT slot_name, active, restart_lsn, postgres-# pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained, postgres-# wal_status postgres-# FROM pg_replication_slots; slot_name | active | restart_lsn | retained | wal_status -----------+--------+-------------+----------+------------ standby1 | f | 0/927BDC0 | 249 MB | reserved (1 row) retained 249 MB가 slot이 붙잡고 있는 WAL의 양입니다. pg_wal은 256 MB로, 부하를 준 만큼 그대로 쌓였습니다. 체크포인트를 돌려도 이 WAL은 지워지지 않습니다. active = f인데 restart_lsn이 오래전에 멈춘 slot이 디스크를 채우는 대표적인 원인입니다.\n$ df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 444M 157M 74% /pgfs 디스크가 가득 차면 같은 상태로 부하를 계속 주면 어떻게 되는지 봅니다. 그 전에 비상용 여유 공간으로 50 MB짜리 파일을 하나 만들어 둡니다. 이 파일의 쓰임새는 아래에서 봅니다.\n$ dd if=/dev/zero of=/pgfs/ballast bs=1M count=50 status=none \u0026amp;\u0026amp; ls -lh /pgfs/ballast -rw-r--r-- 1 postgres postgres 50M Sep 26 10:23 /pgfs/ballast $ timeout 300 pgbench -n -c 4 -T 280 postgres 2\u0026gt;\u0026amp;1 | tail -n 6 number of transactions actually processed: 19799 number of failed transactions: 0 (0.000%) latency average = 0.323 ms initial connection time = 3.756 ms tps = 12382.834023 (without initial connection time) pgbench: error: Run was aborted; the above results are incomplete. [exit=0] $ df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 591M 9.8M 99% /pgfs pgbench는 1.6초 만에 중단되었고 디스크는 99%입니다. 서버 로그를 봅니다.\n2026-09-26 10:23:30.180 UTC [316] postgres@postgres/pgbench PANIC: could not write to file \u0026#34;pg_wal/xlogtemp.316\u0026#34;: No space left on device 2026-09-26 10:23:30.182 UTC [96] LOG: client backend (PID 316) was terminated by signal 6: Aborted 2026-09-26 10:23:30.182 UTC [96] LOG: terminating any other active server processes 2026-09-26 10:23:30.182 UTC [106] FATAL: archive command was terminated by signal 3: Quit 2026-09-26 10:23:30.187 UTC [96] LOG: all server processes terminated; reinitializing 2026-09-26 10:23:30.204 UTC [328] LOG: database system was interrupted; last known up at 2026-09-26 10:23:28 UTC 2026-09-26 10:23:30.206 UTC [328] LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 10:23:30.206 UTC [328] LOG: redo starts at 0/18B93B20 2026-09-26 10:23:30.292 UTC [328] LOG: redo done at 0/1EFFF3D0 system usage: CPU: user: 0.05 s, system: 0.03 s, elapsed: 0.08 s 2026-09-26 10:23:30.294 UTC [328] FATAL: could not write to file \u0026#34;pg_wal/xlogtemp.328\u0026#34;: No space left on device 2026-09-26 10:23:30.298 UTC [96] LOG: terminating any other active server processes 2026-09-26 10:23:30.308 UTC [96] LOG: database system is shut down 차례로 읽으면 이렇습니다.\nbackend가 새 WAL 파일(xlogtemp.316)을 만들다 공간이 없어 PANIC으로 죽었습니다. WAL을 쓰지 못하면 커밋을 보장할 수 없으므로, PostgreSQL은 에러로 넘기지 않고 서버 전체를 멈춥니다. postmaster가 다른 프로세스를 모두 끝내고 재시작해 장애 복구를 시작합니다(인터널 8편). WAL 재생(redo)은 끝났지만, 복구를 마무리하려고 WAL을 쓰다가 다시 공간이 없어 실패합니다. 서버가 내려간 채로 멈춥니다. $ pg_ctl -D $PGDATA status pg_ctl: no server running [exit=3] 디스크를 비우지 않으면 서버는 다시 뜨지 않습니다. 그리고 서버가 멈춰 있으니 slot을 지우는 SQL도 실행할 수 없습니다. 여기서 흔히 저지르는 실수가 pg_wal 안의 파일을 손으로 지우는 것입니다. 어느 파일이 복구에 필요한지는 PostgreSQL만 알고 있고, 필요한 WAL을 지우면 데이터베이스를 잃습니다. pg_wal은 절대 직접 지우지 않습니다.\n조치 서버가 멈췄을 때: 공간을 만들고, 띄우고, 원인을 없앤다 미리 만들어 둔 비상용 파일이 여기서 쓰입니다. 지우면 곧바로 50 MB가 생깁니다.\n$ rm /pgfs/ballast $ df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 541M 60M 91% /pgfs [exit=0] $ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w start waiting for server to start.... done server started [exit=0] $ tail -n 600 \u0026#34;$(ls -t /var/lib/pgsql/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;redo|ready to accept\u0026#39; | tail -n 4 2026-09-26 10:23:30.531 UTC [380] LOG: redo starts at 0/18B93B20 2026-09-26 10:23:30.617 UTC [380] LOG: redo done at 0/1EFFF3D0 system usage: CPU: user: 0.04 s, system: 0.04 s, elapsed: 0.08 s 2026-09-26 10:23:30.643 UTC [378] LOG: checkpoint complete: wrote 11979 buffers (73.1%), wrote 4 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.022 s, sync=0.001 s, total=0.022 s; sync files=11, longest=0.001 s, average=0.000 s; distance=102833 kB, estimate=102833 kB; lsn=0/1F000058, redo lsn=0/1F000058 2026-09-26 10:23:30.644 UTC [373] LOG: database system is ready to accept connections 복구를 마치고 접속을 받기 시작했습니다. 이제 SQL로 원인을 없앱니다.\npostgres=# SELECT slot_name, active, wal_status FROM pg_replication_slots; slot_name | active | wal_status -----------+--------+------------ standby1 | f | reserved (1 row) postgres=# SELECT pg_drop_replication_slot(\u0026#39;standby1\u0026#39;); pg_drop_replication_slot -------------------------- (1 row) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_size_pretty(pg_database_size(\u0026#39;postgres\u0026#39;)) AS db, postgres-# (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS pg_wal, postgres-# (SELECT count(*) FROM pg_ls_waldir()) AS wal_files; db | pg_wal | wal_files --------+--------+----------- 173 MB | 224 MB | 14 (1 row) $ df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 413M 188M 69% /pgfs slot을 지우고 체크포인트를 돌리자 사용량이 541 MB에서 413 MB로 줄었습니다. 비상용 파일은 다시 만들어 둡니다.\n비상용 파일이 없다면 같은 디스크에서 지워도 되는 다른 파일(오래된 로그, 덤프 파일 등)을 찾거나, 볼륨을 늘려야 합니다. 클라우드 볼륨이나 LVM처럼 온라인으로 늘릴 수 있는 환경이라면 그것이 가장 빠릅니다.\n원인별 근본 조치 원인 확인 조치 아카이브 실패 pg_stat_archiver.failed_count, 로그의 archive command failed 아카이브 대상 복구. 밀린 WAL은 자동으로 보냄 쓰지 않는 slot pg_replication_slots의 active = f, restart_lsn 필요 없는 slot은 pg_drop_replication_slot() slot의 무한 보존 max_slot_wal_keep_size = -1 상한을 걸어 두면 초과한 slot은 wal_status = lost로 무효화되고 WAL은 지워짐 max_slot_wal_keep_size를 걸면 slot이 붙잡을 수 있는 WAL에 상한이 생깁니다. 상한을 넘은 slot의 standby는 다시 구성해야 하지만, primary의 디스크가 차서 서비스 전체가 멈추는 것보다는 낫습니다(max_slot_wal_keep_size). pg_replication_slots의 safe_wal_size가 무효화까지 남은 여유를 알려 줍니다.\n임시 파일: 쿼리만 실패한다 큰 정렬과 해시가 work_mem을 넘으면 임시 파일을 씁니다(1편). 임시 파일로 디스크가 차면 어떻게 되는지 봅니다. 여유 공간을 30 MB만 남기고 큰 정렬을 돌립니다.\n$ avail=$(df --output=avail -m /pgfs | tail -n 1); dd if=/dev/zero of=/pgfs/filler bs=1M count=$((avail - 30)) status=none; df -h /pgfs Filesystem Size Used Avail Use% Mounted on tmpfs 600M 571M 30M 96% /pgfs postgres=# SET max_parallel_workers_per_gather = 0; postgres-# SELECT count(*) FROM (SELECT * FROM pgbench_accounts ORDER BY filler, aid OFFSET 0) s; SET ERROR: could not write to file \u0026#34;base/pgsql_tmp/pgsql_tmp459.0\u0026#34;: No space left on device postgres=# SELECT 1 AS still_alive; still_alive ------------- 1 (1 row) WAL과 달리 쿼리 하나만 에러로 끝나고 서버는 멀쩡합니다. 임시 파일은 그 쿼리만 쓰는 것이라 지우고 에러를 내면 그만이기 때문입니다. 하지만 그 순간 디스크가 가득 차 있으므로, 같은 때 WAL을 써야 하는 다른 세션이 있었다면 앞의 PANIC으로 이어질 수 있습니다.\ntemp_file_limit을 걸면 쿼리 하나가 쓸 수 있는 임시 파일 크기에 상한이 생깁니다.\npostgres=# SET temp_file_limit = \u0026#39;20MB\u0026#39;; postgres-# SELECT count(*) FROM (SELECT * FROM pgbench_accounts ORDER BY filler, aid OFFSET 0) s; ERROR: temporary file size exceeds \u0026#34;temp_file_limit\u0026#34; (20480kB) CONTEXT: parallel worker SET 20 MB를 넘자 병렬 worker에서 에러가 났습니다. 기본값은 제한 없음(-1)입니다. 애플리케이션 계정에 걸어 두면 잘못 짠 쿼리 하나가 디스크를 채우는 일을 막을 수 있습니다.\n재발 방지 디스크 사용률 알람을 두 단계로 겁니다. 경고(예: 80%)에서 원인을 찾고, 위험(예: 90%)에서 바로 조치합니다. 이 실습처럼 WAL은 몇 초 만에 수백 MB가 쌓일 수 있습니다. pg_wal 크기와 WAL 파일 수를 따로 감시합니다. max_wal_size보다 한참 크면 아카이브와 slot부터 봅니다. pg_stat_archiver.failed_count 증가를 알람으로 겁니다. pg_replication_slots에서 active = f인 slot과, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)이 큰 slot을 감시합니다. max_slot_wal_keep_size로 상한을 겁니다. temp_file_limit을 애플리케이션 계정에 겁니다. 임시 파일은 log_temp_files로 기록합니다. 비상용 여유 공간을 데이터 디스크에 만들어 둡니다. 서버가 멈췄을 때 가장 빨리 공간을 만드는 방법입니다. 로그는 데이터와 다른 디스크에 둡니다. PGDG 패키지의 기본 로그 위치는 데이터 디렉터리 안(log/)입니다(1편). 정리 디스크는 DB 파일, WAL, 임시 파일, 로그가 나눠 씁니다. pg_database_size()와 pg_ls_waldir()로 나눠 봅니다. 아카이브가 실패하면 보내지 못한 WAL이 .ready로 쌓이고, 로그에 archive command failed가 남습니다. 대상을 고치면 밀린 WAL을 보냅니다. 쓰지 않는 replication slot은 WAL을 끝없이 붙잡습니다. max_slot_wal_keep_size로 상한을 겁니다. WAL을 쓸 공간이 없으면 PANIC: could not write to file \u0026quot;pg_wal/xlogtemp...\u0026quot;: No space left on device로 서버가 멈추고, 복구도 WAL을 써야 하므로 공간을 만들기 전에는 다시 뜨지 않습니다. pg_wal을 직접 지우지 않습니다. 임시 파일로 디스크가 차면 그 쿼리만 실패합니다. temp_file_limit로 상한을 겁니다. 참고 자료 Write Ahead Log 설정: archive_mode, archive_command, max_wal_size WAL Configuration Continuous Archiving pg_replication_slots, max_slot_wal_keep_size pg_stat_archiver temp_file_limit PostgreSQL 인터널 7편 WAL, 8편 체크포인트와 장애 복구, 9편 스트리밍 복제 ","permalink":"https://jinlog.dev/posts/postgresql-ops/06-disk-full/","summary":"데이터는 그대로인데 디스크가 찬다면 무엇이 차지하고 있고, 가득 차면 어떻게 되며, 어떻게 되살리는가","title":"PostgreSQL 운영 6: 디스크가 찬다"},{"content":"개요 4편에서 PostgreSQL이 행마다 \u0026ldquo;만든 트랜잭션 ID(xmin)\u0026ldquo;를 적어 두고, 그 번호를 비교해 행이 보이는지 판단한다는 것을 봤습니다. 그런데 이 번호는 32비트입니다. 약 42억 개를 쓰고 나면 다시 처음으로 돌아옵니다. 초당 1만 건의 쓰기 트랜잭션이 도는 시스템이라면 약 2.5일이면 21억 개, 즉 비교에 쓸 수 있는 범위의 절반을 씁니다.\n번호가 한 바퀴 돌면, 아주 오래전에 커밋된 행이 갑자기 \u0026ldquo;미래의 트랜잭션이 만든 행\u0026quot;으로 보여 사라질 수 있습니다. 이것이 트랜잭션 ID wraparound입니다. PostgreSQL은 이를 막으려고 오래된 행을 freeze(동결)하고, 그래도 위험하면 단계적으로 경고를 내다가 마지막에는 새 트랜잭션을 거부합니다. 이 글에서는 그 전 과정을 실제로 재현합니다.\n이 글에서 답할 질문은 다음과 같습니다.\n32비트 번호로 어떻게 \u0026ldquo;앞, 뒤\u0026quot;를 비교하는가 freeze는 튜플에 무엇을 하는가 age()는 무엇을 재고, 어떤 값에서 무슨 일이 일어나는가 쓰기가 멈추면 어떻게 복구하는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n원형으로 비교하는 트랜잭션 ID xid는 32비트 부호 없는 정수입니다. 0, 1, 2는 특별한 값이고(transam.h), 보통 트랜잭션은 3부터 번호를 받습니다.\n값 이름 뜻 0 InvalidTransactionId 없음 1 BootstrapTransactionId initdb가 쓰는 번호 2 FrozenTransactionId \u0026ldquo;어떤 트랜잭션보다도 먼저\u0026rdquo; (예전 방식의 freeze) 3 이상 일반 xid 1씩 증가하다 2³²에서 다시 3으로 두 xid 중 어느 쪽이 먼저인지는 단순한 크기 비교가 아니라 차이를 부호 있는 32비트로 보고 판단합니다(TransactionIdPrecedes()).\ndiff = (int32) (id1 - id2); return (diff \u0026lt; 0); xid를 시계 문자판처럼 원 위에 놓고, 어느 xid에서 보든 앞쪽 약 21억 개는 과거, 뒤쪽 약 21억 개는 미래로 보는 것입니다. 번호가 한 바퀴 돌아도 비교는 계속됩니다. 문제는 21억 개보다 더 오래된 xid입니다. 그런 xid는 원의 반대편, 즉 \u0026ldquo;미래\u0026quot;로 보입니다. 행의 xmin이 미래면 그 행은 아직 만들어지지 않은 것으로 판단되어 보이지 않습니다. 데이터가 사라진 것처럼 보이는 것입니다.\nfreeze: \u0026ldquo;이 행은 누구보다도 먼저 만들어졌다\u0026rdquo; 해결책은 충분히 오래된 행에 \u0026ldquo;이 행은 모든 트랜잭션보다 먼저 만들어졌다\u0026ldquo;고 표시해서 xmin 비교를 아예 하지 않게 하는 것입니다. 이것이 freeze입니다. 9.4 이전에는 xmin 자체를 FrozenTransactionId(2)로 바꿨지만, 9.4부터는 xmin 값은 그대로 두고 t_infomask에 HEAP_XMIN_FROZEN(HEAP_XMIN_COMMITTED와 HEAP_XMIN_INVALID를 함께 켠 조합)을 적습니다(아래 실습). 원래 xmin이 남아 있으면 장애 분석에 도움이 되기 때문입니다.\nfreeze는 VACUUM이 합니다(heap_prepare_freeze_tuple()). VACUUM이 all-frozen이 아닌 페이지를 빠짐없이 스캔했을 때, 테이블에 남은 가장 오래된 얼리지 않은 xid를 pg_class.relfrozenxid에 적습니다. \u0026ldquo;이보다 오래된 xid는 이 테이블에 더는 없다\u0026quot;는 경계입니다. 데이터베이스 단위로는 모든 테이블의 relfrozenxid 가운데 가장 오래된 값이 pg_database.datfrozenxid입니다.\nage(xid)는 현재 xid에서 그 xid까지의 거리입니다. age(relfrozenxid)는 \u0026ldquo;이 테이블에서 아직 얼려지지 않았을 수 있는 가장 오래된 xid가 몇 트랜잭션 전인가\u0026quot;이고, 이 값이 21억에 가까워질수록 위험합니다.\n트랜잭션 ID와 age autovacuum이 빨리 반응하도록 autovacuum_naptime을 1초로 줄여 서버를 띄우고, 실습에 쓸 확장 xid_wraparound와 pageinspect를 설치한 뒤 막 만든 클러스터의 xid와 age를 봅니다.\n$ cat \u0026gt;\u0026gt; $PGDATA/postgresql.conf \u0026lt;\u0026lt;\u0026#39;CONF\u0026#39; \u003e autovacuum_naptime = 1s \u003e log_autovacuum_min_duration = 0 \u003e log_line_prefix = \u0026#39;%m [%p] %b \u0026#39; \u003e CONF $ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION xid_wraparound; postgres=# CREATE EXTENSION pageinspect; postgres=# SELECT pg_current_xact_id() AS next_xid; next_xid ---------- 754 (1 row) postgres=# SELECT datname, datfrozenxid, age(datfrozenxid) FROM pg_database ORDER BY datname; datname | datfrozenxid | age -----------+--------------+----- postgres | 744 | 11 template0 | 744 | 11 template1 | 744 | 11 (3 rows) $ pg_controldata $PGDATA | grep -E \u0026#34;NextXID|oldestXID\u0026#34; Latest checkpoint\u0026#39;s NextXID: 0:752 Latest checkpoint\u0026#39;s oldestXID: 744 Latest checkpoint\u0026#39;s oldestXID\u0026#39;s DB: 1 막 만든 클러스터라 다음 xid는 754이고, 모든 DB의 datfrozenxid가 744, age는 11입니다. pg_controldata의 oldestXID도 744입니다. 서버가 기억하는 \u0026ldquo;가장 오래된 얼리지 않은 xid\u0026quot;입니다.\nfreeze: 튜플에 \u0026ldquo;아주 오래전에 커밋됨\u0026rdquo; 표시하기 postgres=# CREATE TABLE f (id int) WITH (autovacuum_enabled = off); CREATE TABLE postgres=# INSERT INTO f SELECT generate_series(1, 3); INSERT 0 3 postgres=# SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = \u0026#39;f\u0026#39;; relfrozenxid | age --------------+----- 755 | 2 (1 row) postgres=# SELECT lp, t_xmin, raw_flags, combined_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;f\u0026#39;, 0)), LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2); lp | t_xmin | raw_flags | combined_flags ----+--------+---------------------+---------------- 1 | 756 | {HEAP_XMAX_INVALID} | {} 2 | 756 | {HEAP_XMAX_INVALID} | {} 3 | 756 | {HEAP_XMAX_INVALID} | {} (3 rows) postgres=# VACUUM (FREEZE) f; VACUUM postgres=# SELECT lp, t_xmin, raw_flags, combined_flags postgres-# FROM heap_page_items(get_raw_page(\u0026#39;f\u0026#39;, 0)), LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2); lp | t_xmin | raw_flags | combined_flags ----+--------+-----------------------------------------------------------+-------------------- 1 | 756 | {HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID} | {HEAP_XMIN_FROZEN} 2 | 756 | {HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID} | {HEAP_XMIN_FROZEN} 3 | 756 | {HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID} | {HEAP_XMIN_FROZEN} (3 rows) postgres=# SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = \u0026#39;f\u0026#39;; relfrozenxid | age --------------+----- 757 | 0 (1 row) postgres=# SELECT xmin, * FROM f; xmin | id ------+---- 756 | 1 756 | 2 756 | 3 (3 rows) freeze 전: 세 행 모두 t_xmin = 756이고 HEAP_XMAX_INVALID만 있습니다. VACUUM (FREEZE) 뒤: t_xmin은 756 그대로인데, HEAP_XMIN_COMMITTED와 HEAP_XMIN_INVALID가 함께 켜져 combined_flags가 HEAP_XMIN_FROZEN이 되었습니다. 원래는 동시에 켜질 수 없는 두 비트(커밋됨, 무효)의 조합을 \u0026ldquo;얼려짐\u0026quot;이라는 뜻으로 씁니다. relfrozenxid가 757로 올라가 age가 0이 되었습니다. SQL로 본 xmin은 여전히 756입니다. xid를 쓰면 age가 늘어난다 실제 서비스에서 xid 21억 개를 쓰려면 며칠에서 몇 달이 걸립니다. 여기서는 PostgreSQL 소스에 포함된 테스트 모듈 xid_wraparound의 consume_xids()로 xid를 몇 초 만에 소모합니다. 이 함수는 최상위 xid를 받은 트랜잭션 안에서 xid를 하위 트랜잭션용으로 소모하므로, 소모하는 동안에는 그 트랜잭션 자체가 오래 열린 트랜잭션처럼 동작합니다.\npostgres=# CREATE TABLE noav (id int) WITH (autovacuum_enabled = off); postgres=# INSERT INTO noav VALUES (1); postgres=# SELECT consume_xids(150000000); NOTICE: consumed 10000050 / 150000000 XIDs, latest 0:10000809 NOTICE: consumed 20000859 / 150000000 XIDs, latest 0:20001618 NOTICE: consumed 30001668 / 150000000 XIDs, latest 0:30002427 NOTICE: consumed 40002477 / 150000000 XIDs, latest 0:40003236 NOTICE: consumed 50003209 / 150000000 XIDs, latest 0:50003968 NOTICE: consumed 60003276 / 150000000 XIDs, latest 0:60004035 NOTICE: consumed 70003977 / 150000000 XIDs, latest 0:70004736 NOTICE: consumed 80004075 / 150000000 XIDs, latest 0:80004834 NOTICE: consumed 90004745 / 150000000 XIDs, latest 0:90005504 NOTICE: consumed 100004874 / 150000000 XIDs, latest 0:100005633 NOTICE: consumed 110005513 / 150000000 XIDs, latest 0:110006272 NOTICE: consumed 120005673 / 150000000 XIDs, latest 0:120006432 NOTICE: consumed 130006281 / 150000000 XIDs, latest 0:130007040 NOTICE: consumed 140006472 / 150000000 XIDs, latest 0:140007231 consume_xids -------------- 150000759 (1 row) postgres=# SELECT relname, age(relfrozenxid) FROM pg_class WHERE relname IN (\u0026#39;f\u0026#39;, \u0026#39;noav\u0026#39;) ORDER BY 1; relname | age ---------+----------- f | 150000003 noav | 150000003 (2 rows) xid 1.5억 개를 쓰자 두 테이블의 age가 1.5억이 되었습니다. 테이블 안의 행은 하나도 바뀌지 않았는데 age만 올라갔습니다. age는 테이블이 얼마나 바뀌었는지가 아니라, 마지막 freeze 이후 클러스터 전체가 xid를 얼마나 썼는지를 잽니다.\nage에 따라 차례로 작동하는 방어선 age가 커질수록 VACUUM이 더 적극적으로 얼리고, 끝내 막히면 경고를 내다가 새 xid 발급을 거부합니다. 새 창에서 크게 보기 기준 기본값 일어나는 일 vacuum_freeze_min_age 5000만 VACUUM이 스캔한 페이지에서 이보다 오래된 xid를 얼림(일반 VACUUM은 all-visible 페이지를 건너뛸 수 있음) vacuum_freeze_table_age 1.5억 이보다 오래되면 aggressive VACUUM: VM에서 all-visible이어도 all-frozen이 아닌 페이지는 모두 읽음(vacuum.c) autovacuum_freeze_max_age 2억 이보다 오래되면 테이블 단위로 autovacuum을 꺼 둔 테이블도(autovacuum.c), 서버 전체에서 autovacuum = off여도(varsup.c에서 launcher를 깨움) 강제로 VACUUM vacuum_failsafe_age 16억 VACUUM이 인덱스 정리, 테이블 끝 잘라 내기, 비용 지연(cost delay), ring buffer 같은 \u0026ldquo;당장 필요 없는 일\u0026quot;을 건너뛰고 freeze만 서두름(lazy_check_wraparound_failsafe()) 한계 − 4000만 (고정) 새 xid를 줄 때마다 WARNING 한계 − 300만 (고정) 새 xid 발급 거부. 쓰기 불가, 읽기만 가능 여기서 \u0026ldquo;한계\u0026quot;는 가장 오래된 datfrozenxid + 2³¹ − 1(MaxTransactionId \u0026gt;\u0026gt; 1, 약 21억)이고, 경고와 정지 지점도 여기서 계산합니다(SetTransactionIdLimit()). 새 xid를 줄 때마다 이 값과 비교해 경고하거나 거부하는 곳이 GetNewTransactionId()입니다. VACUUM 자체는 새 xid가 필요 없어서 정지 상태에서도 돕니다. 마지막 300만 개는 관리자가 단일 사용자 모드에서 필요 없는 테이블을 TRUNCATE하거나 DROP해서 VACUUM할 양을 줄일 수 있게 남겨 둔 여유입니다(문서).\nautovacuum을 꺼도 wraparound 방지 VACUUM은 돈다 앞에서 만든 두 테이블 f와 noav는 모두 테이블 단위로 autovacuum_enabled = off를 주고 만들었습니다. 6000만 개를 더 써서 age가 2억(autovacuum_freeze_max_age)을 넘게 해 봅니다.\npostgres=# SELECT consume_xids(60000000); NOTICE: consumed 10000718 / 60000000 XIDs, latest 0:160001478 NOTICE: consumed 20001527 / 60000000 XIDs, latest 0:170002287 NOTICE: consumed 30002056 / 60000000 XIDs, latest 0:180002816 NOTICE: consumed 40002326 / 60000000 XIDs, latest 0:190003086 NOTICE: consumed 50002824 / 60000000 XIDs, latest 0:200003584 consume_xids -------------- 210000760 (1 row) $ sleep 8 postgres=# SELECT relname, age(relfrozenxid) FROM pg_class WHERE relname IN (\u0026#39;f\u0026#39;, \u0026#39;noav\u0026#39;) ORDER BY 1; relname | age ---------+---------- f | 60000011 noav | 60000011 (2 rows) $ grep -E \u0026#39;to prevent wraparound of table \u0026#34;postgres.public.(f|noav)\u0026#34;\u0026#39; /home/postgres/server.log | cut -c1-160 2026-09-26 10:09:14.546 UTC [86] autovacuum worker LOG: automatic aggressive vacuum to prevent wraparound of table \u0026#34;postgres.public.f\u0026#34;: index scans: 0 2026-09-26 10:09:14.547 UTC [86] autovacuum worker LOG: automatic aggressive vacuum to prevent wraparound of table \u0026#34;postgres.public.noav\u0026#34;: index scans: 0 로그에 automatic aggressive vacuum to prevent wraparound of table이 찍혔습니다. autovacuum을 끈 테이블인데도 VACUUM이 돌았습니다. age가 2억을 넘으면 autovacuum_enabled 설정과 상관없이 강제로 돕니다. 그 뒤 age는 6000만으로 줄었습니다. 0이 아닌 이유는 이 VACUUM이 도는 동안 xid를 소모하던 트랜잭션이 열려 있어서 그보다 뒤로는 얼릴 수 없었기 때문입니다. 다음 xid 210000771에서 60000011을 빼면 150000760으로, 위에서 6000만 개 소모를 시작한 트랜잭션의 xid와 같습니다.\n운영에서는: age를 모니터링한다 wraparound는 갑자기 오지 않습니다. 몇 주, 몇 달에 걸쳐 age가 올라가는 것이 보입니다. 다음 두 쿼리를 모니터링에 넣어 두면 됩니다.\n-- DB별 SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC; -- 테이블별 (age가 큰 순) SELECT c.oid::regclass AS table_name, age(c.relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(c.oid)) AS size FROM pg_class c WHERE c.relkind IN (\u0026#39;r\u0026#39;, \u0026#39;m\u0026#39;, \u0026#39;t\u0026#39;) ORDER BY 2 DESC LIMIT 10; age가 autovacuum_freeze_max_age(2억)를 넘는 것 자체는 정상입니다. 강제 VACUUM이 곧 처리합니다. 2억을 한참 넘었는데 줄지 않는다면 무언가가 freeze를 막고 있다는 신호입니다. 알람은 보통 5억, 10억 같은 단계로 겁니다.\n운영에서는: \u0026ldquo;to prevent wraparound\u0026rdquo; VACUUM을 취소하지 말 것 pg_stat_activity에서 autovacuum: VACUUM public.orders (to prevent wraparound) 같은 세션을 보면, 부하가 걸린다고 취소하고 싶어집니다. 하지만 이 VACUUM은 취소해도 곧 다시 시작되고, 그사이 age는 계속 오릅니다. 게다가 일반 autovacuum과 달리 이 VACUUM은 다른 세션과 락이 충돌해도 자동으로 취소되지 않으므로(proc.c), DDL이 이 VACUUM 뒤에서 기다리는 일이 생깁니다. 이 VACUUM이 오래 걸리지 않게 하려면 평소 VACUUM이 제때 돌아 freeze할 양을 미리 줄여 두는 것이 가장 좋습니다. 5편에서 본 PG18의 eager scanning도 이 부담을 나누려는 기능입니다.\n운영에서는: MultiXact에도 같은 한계가 있다 4편에서 본 MultiXact ID(여러 트랜잭션이 같은 행을 함께 잠글 때 쓰는 ID)도 32비트이고, 같은 구조의 wraparound 방어(autovacuum_multixact_freeze_max_age, mxid_age())가 있습니다. SELECT ... FOR SHARE나 외래 키 검사를 많이 쓰는 시스템이라면 mxid_age(datminmxid)도 함께 모니터링해야 합니다.\nfreeze가 막히는 경우 VACUUM은 5편에서 본 removable cutoff보다 오래된 행만 얼릴 수 있습니다. 누군가 오래된 스냅샷이나 xid를 들고 있으면, 그보다 뒤의 행은 \u0026ldquo;아직 누군가에게는 진행 중일 수 있는 트랜잭션이 만든 행\u0026quot;이라 얼릴 수 없습니다. 그래서 wraparound는 대부분 VACUUM이 게을러서가 아니라, VACUUM이 일을 할 수 없게 막혀서 일어납니다. 흔한 원인은 5편에서 본 것과 같습니다. 오래 열린 트랜잭션, 버려진 replication slot, 끝내지 않은 prepared transaction입니다.\n오래된 트랜잭션이 freeze를 막으면 세션 A(프롬프트 A=#)가 트랜잭션을 열고 xid를 받은 채 아무것도 하지 않습니다. 그리고 vacuumdb --all로 얼릴 수 있는 만큼 모두 얼려 둔 뒤, 한계값을 계산합니다.\nA=# BEGIN; BEGIN A=# SELECT pg_current_xact_id() AS old_xid; old_xid ----------- 210000772 (1 row) $ vacuumdb --all --quiet $ sleep 3 postgres=# SELECT datname, datfrozenxid, age(datfrozenxid) FROM pg_database ORDER BY datname; datname | datfrozenxid | age -----------+--------------+----- postgres | 210000772 | 1 template0 | 210000772 | 1 template1 | 210000772 | 1 (3 rows) postgres=# SELECT min(datfrozenxid::text::bigint) AS oldest_datfrozenxid, postgres-# min(datfrozenxid::text::bigint) + 2147483647 AS wrap_limit, postgres-# min(datfrozenxid::text::bigint) + 2147483647 - 40000000 AS warn_limit, postgres-# min(datfrozenxid::text::bigint) + 2147483647 - 3000000 AS stop_limit postgres-# FROM pg_database; oldest_datfrozenxid | wrap_limit | warn_limit | stop_limit ---------------------+------------+------------+------------ 210000772 | 2357484419 | 2317484419 | 2354484419 (1 row) 모든 DB의 datfrozenxid가 세션 A의 xid(210000772)에서 멈췄습니다. VACUUM을 해도 A보다 뒤로는 얼릴 수 없기 때문입니다. 이 값에서 계산한 한계는 다음과 같습니다.\n지점 xid wraparound 한계 (210000772 + 2³¹ − 1) 2357484419 경고 시작 (한계 − 4000만) 2317484419 쓰기 정지 (한계 − 300만) 2354484419 경고 단계: 한계까지 4000만 개 남았을 때 경고 지점을 1000개 넘을 때까지 xid를 쓴 뒤, 평범한 INSERT를 합니다.\n$ W=$(psql -X -At -c \u0026#34;SELECT min(datfrozenxid::text::bigint) + 2147483647 - 40000000 + 1000 FROM pg_database\u0026#34;) $ psql -X -q -c \u0026#34;SELECT consume_xids_until(\u0026#39;$W\u0026#39;::xid8)\u0026#34; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 postgres=# INSERT INTO noav VALUES (2); WARNING: database \u0026#34;postgres\u0026#34; must be vacuumed within 39998999 transactions HINT: To avoid transaction ID assignment failures, execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. INSERT 0 1 INSERT는 성공했지만 WARNING이 붙었습니다. \u0026ldquo;postgres DB를 39998999 트랜잭션 안에 VACUUM하라\u0026quot;는 뜻입니다. 경고 지점(4000만 전)을 약 1000개 지난 뒤라 남은 수가 4000만보다 조금 적습니다. HINT는 DB 전체 VACUUM과 함께 오래된 prepared transaction과 버려진 replication slot을 확인하라고 알려 줍니다. 이 단계부터 새 xid를 받는 모든 트랜잭션에 이 경고가 붙습니다.\n정지 단계: 한계까지 300만 개 남았을 때 $ S=$(psql -X -At -c \u0026#34;SELECT min(datfrozenxid::text::bigint) + 2147483647 - 3000000 + 1000 FROM pg_database\u0026#34;) $ psql -X -q -c \u0026#34;SELECT consume_xids_until(\u0026#39;$S\u0026#39;::xid8)\u0026#34; 2\u0026gt;\u0026amp;1 | tail -3 ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database \u0026#34;postgres\u0026#34; HINT: Execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. postgres=# INSERT INTO noav VALUES (3); ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database \u0026#34;postgres\u0026#34; HINT: Execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. postgres=# SELECT count(*) FROM noav; count ------- 2 (1 row) 정지 지점에 이르자 xid 소모가 멈췄고, 이어서 INSERT도 ERROR로 거부되었습니다. database is not accepting commands that assign new transaction IDs. 새 xid가 필요한 명령, 즉 쓰기는 모두 거부됩니다. 반면 SELECT count(*)는 xid가 필요 없어서 정상적으로 동작합니다. 서비스로 보면 읽기 전용 장애입니다.\n쓰기가 멈춘 뒤의 복구 예전 버전에서는 쓰기가 멈추면 서버를 내리고 단일 사용자 모드로 VACUUM해야 했지만, 지금은 그럴 필요가 없고 오히려 피해야 한다고 문서가 안내합니다. 정지 상태에서도 VACUUM은 새 xid 없이 돌 수 있으므로, 원인을 없앤 뒤 평소 모드에서 VACUUM을 돌리면 됩니다. 위 ERROR의 HINT에도 \u0026ldquo;Execute a database-wide VACUUM\u0026quot;이라고 나옵니다.\n원인을 없애고 VACUUM으로 복구 세션 A를 끝내면 freeze를 막던 것이 사라집니다.\nA=# ROLLBACK; ROLLBACK $ sleep 15 postgres=# SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY datname; datname | age -----------+---------- postgres | 36998999 template0 | 36998997 template1 | 36998997 (3 rows) postgres=# INSERT INTO noav VALUES (4); INSERT 0 1 $ grep -cE \u0026#39;to prevent wraparound\u0026#39; /home/postgres/server.log 3153 $ grep -E \u0026#39;bypassing nonessential maintenance\u0026#39; /home/postgres/server.log | head -2 | cut -c1-200 2026-09-26 10:09:33.958 UTC [226] autovacuum worker WARNING: bypassing nonessential maintenance of table \u0026#34;postgres.public.f\u0026#34; as a failsafe after 0 index scans 2026-09-26 10:09:33.958 UTC [226] autovacuum worker WARNING: bypassing nonessential maintenance of table \u0026#34;postgres.pg_catalog.pg_statistic\u0026#34; as a failsafe after 0 index scans A를 롤백하고 15초 뒤, autovacuum이 모든 DB를 얼려 age가 2억 아래(약 3700만)로 내려왔고, INSERT가 다시 성공했습니다. 0이 아니라 3700만인 이유는 postgres DB에서는 경고 단계에서 넣은 행(xid 약 23.17억)이 vacuum_freeze_min_age(5000만)보다 젊어서 얼리지 않았고, 그 xid가 relfrozenxid로 남았기 때문입니다. 로그의 3153줄은 서버를 시작한 뒤 쌓인 wraparound 방지 VACUUM 기록 전체입니다. A가 freeze를 막는 동안 autovacuum이 1초마다 같은 테이블들을 계속 시도했기 때문에 이렇게 많습니다. bypassing nonessential maintenance ... as a failsafe는 age가 16억(vacuum_failsafe_age)을 넘은 뒤부터 VACUUM이 failsafe로 전환해 필수가 아닌 일을 건너뛰었다는 기록입니다(로그에서 가장 이른 두 줄).\n아래 그림은 세션 A가 xid를 붙잡은 때부터 복구까지의 과정을 순서대로 정리한 것입니다.\n세션 A가 붙잡은 xid에서 datfrozenxid가 멈추고, xid가 한계에 다가가 경고와 정지가 일어난 뒤, A를 끝내자 VACUUM이 복구합니다. 새 창에서 크게 보기 운영에서는: 경고가 뜨면 WARNING이 보이면 이미 한계까지 4000만 개 남은 상태입니다. 초당 1000 트랜잭션이면 약 10시간 뒤에 쓰기가 멈춥니다(정지 지점까지 3700만 개). 순서대로 확인합니다.\n오래된 트랜잭션: pg_stat_activity에서 backend_xid, backend_xmin의 age가 큰 세션을 찾아 끝냅니다. prepared transaction: pg_prepared_xacts에 오래된 것이 있으면 COMMIT PREPARED 또는 ROLLBACK PREPARED합니다. replication slot: pg_replication_slots에서 xmin, catalog_xmin이 오래된 비활성 슬롯을 지웁니다(9편). 그다음 superuser로 VACUUM (VERBOSE)를 돌립니다. 가장 age가 큰 테이블부터 해도 됩니다. 이때 FREEZE나 FULL 옵션은 필요한 것보다 많은 일을 하므로 쓰지 않습니다(문서). 원인을 없애지 않고 VACUUM만 돌리면, 위 실습처럼 VACUUM이 끝나도 datfrozenxid가 움직이지 않습니다.\n정리 xid는 32비트이고 원형으로 비교하므로, 어느 xid에서 보든 약 21억 개 앞까지만 \u0026ldquo;과거\u0026quot;로 판단할 수 있습니다. 그보다 오래된 xmin은 미래로 보여 행이 사라진 것처럼 됩니다. freeze는 충분히 오래된 행에 HEAP_XMIN_FROZEN을 적어 xmin 비교에서 빼는 것입니다. xmin 값은 남습니다. relfrozenxid와 datfrozenxid는 \u0026ldquo;이보다 오래된 행은 모두 얼려졌다\u0026quot;는 경계이고, age()로 그 거리를 봅니다. age에 따라 aggressive VACUUM(1.5억), 강제 autovacuum(2억, autovacuum을 꺼도 실행), failsafe(16억), 경고(한계 − 4000만), 쓰기 정지(한계 − 300만)가 차례로 작동합니다. wraparound는 보통 오래된 트랜잭션, prepared transaction, replication slot이 freeze를 막아서 생깁니다. 원인을 없앤 뒤 VACUUM하면 평소 모드에서 복구됩니다. 다음 글에서는 지금까지 여러 번 등장한 WAL을 본격적으로 살펴봅니다. LSN과 WAL 레코드의 구조, full page writes입니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/access/transam/transam.c: xid 비교 src/backend/access/transam/varsup.c: xid 발급, 경고와 정지 한계 src/backend/commands/vacuum.c: freeze 기준, aggressive 판단 src/backend/access/heap/heapam.c: 튜플 freeze src/test/modules/xid_wraparound: 실습에 쓴 xid 소모 모듈 PostgreSQL 18 공식 문서\nPreventing Transaction ID Wraparound Failures Transactions and Identifiers Vacuuming 설정 ","permalink":"https://jinlog.dev/posts/postgresql/06-xid-wraparound/","summary":"32비트 트랜잭션 ID가 한 바퀴 돌면 무슨 일이 생기고, PostgreSQL은 어떻게 막는가","title":"PostgreSQL 인터널 6: 트랜잭션 ID wraparound"},{"content":"개요 5편과 6편의 레플리카셋은 같은 데이터를 여러 멤버가 똑같이 가집니다. 멤버를 늘리면 장애에 강해지고 읽기를 나눌 수 있지만, 쓰기와 데이터 크기는 여전히 primary 한 대의 몫입니다. 한 대가 감당할 수 있는 것보다 데이터가 커지면 데이터 자체를 나눠 여러 레플리카셋에 흩어야 합니다. 이것이 샤딩이고, 나눈 조각을 각각 맡는 레플리카셋이 샤드입니다.\n데이터를 나누면 곧바로 새 질문들이 생깁니다. 어떤 도큐먼트가 어느 샤드에 있는지는 누가 기억하는가? 쿼리는 어느 샤드로 보내야 하는가? 한 샤드에 데이터가 몰리면 어떻게 옮기는가? 옮기는 동안의 쓰기와 읽기는 어떻게 되는가? 이 글은 이 질문들을 소스와 실측으로 따라갑니다.\n이 글에서 답할 질문은 다음과 같습니다.\nmongos, config server, 샤드는 각각 무엇을 가지고 무엇을 하는가 shard key와 chunk는 무엇이고, range 샤딩과 hashed 샤딩은 무엇이 다른가 쿼리는 어떻게 한 샤드로 가거나(targeted) 모든 샤드로 가는가(scatter-gather) balancer는 무엇을 기준으로 chunk를 옮기고, 옮기는 과정(chunk migration)은 어떤 단계를 거치는가 mongos가 가진 정보가 낡았을 때(stale config) 쿼리는 어떻게 되는가 옮긴 뒤 원본 샤드에 남는 도큐먼트(orphan)는 어떻게 되는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 컨테이너 4대로 config server(1노드 레플리카셋 cfg), 샤드 2개(각각 1노드 레플리카셋 sh1, sh2), mongos 2개(한 컨테이너에 포트 27017, 27018)를 띄웠습니다.\n샤드 클러스터의 세 구성 요소 애플리케이션은 mongos에만 접속합니다. mongos는 config server의 메타데이터를 캐시해 쿼리를 보낼 샤드를 정하고, balancer는 config server primary에서 돌며 샤드 사이의 chunk 이동을 지시합니다. 새 창에서 크게 보기 mongos: 라우터입니다. 데이터를 저장하지 않고, 애플리케이션의 요청을 받아 어느 샤드로 보낼지 정한 뒤 결과를 모아 돌려줍니다. 판단에 쓰는 것은 config server에서 가져와 메모리에 캐시한 routing table입니다(CatalogCache, CatalogCache::getCollectionRoutingInfo()). mongos는 상태가 없어서 여러 개를 띄워 부하를 나누고, 하나가 죽어도 다른 mongos로 접속하면 됩니다. config server: 클러스터의 메타데이터를 가진 레플리카셋입니다(CSRS, Config Server Replica Set). --configsvr로 띄우며, 어떤 샤드가 있고(config.shards), 어떤 컬렉션이 어떤 키로 샤딩되었으며(config.collections), 키의 어느 범위가 어느 샤드에 있는지(config.chunks)를 config 데이터베이스에 저장합니다. balancer도 config server의 primary에서 돕니다. 샤드: 실제 데이터를 가진 레플리카셋입니다. --shardsvr로 띄우고, 자기가 맡은 범위의 도큐먼트만 가집니다. 각 샤드도 자기 컬렉션의 메타데이터(어느 범위를 자기가 가졌는지)를 캐시해 두고, mongos가 보낸 요청이 최신 정보에 기반한 것인지 확인합니다. 클러스터 만들기와 config 데이터베이스 config server와 두 샤드를 각각 1노드 레플리카셋으로 띄우고 rs.initiate한 뒤, mongos를 띄웁니다. mongos에는 config server의 주소만 줍니다.\n$ mongos --configdb cfg/m07-cfg:27017 --bind_ip_all --port 27017 --logpath /data/mongos.log --fork | grep -E \u0026#39;forked|ERROR\u0026#39; $ mongos --configdb cfg/m07-cfg:27017 --bind_ip_all --port 27018 --logpath /data/mongos-b.log --fork | grep -E \u0026#39;forked|ERROR\u0026#39; forked process: 35 forked process: 76 두 번째 mongos(27018)는 뒤에서 stale config를 보려고 띄웠습니다. 이제 첫 mongos에 접속해 샤드를 등록합니다. 프롬프트의 [direct: mongos]는 mongos에 접속했다는 표시입니다.\n[direct: mongos] test\u003e sh.addShard(\u0026#39;sh1/m07-sh1:27017\u0026#39;).shardAdded sh1 [direct: mongos] test\u003e sh.addShard(\u0026#39;sh2/m07-sh2:27017\u0026#39;).shardAdded sh2 [direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).shards.find() [ { _id: \u0026#39;sh1\u0026#39;, host: \u0026#39;sh1/m07-sh1:27017\u0026#39;, state: 1, topologyTime: Timestamp({ t: 1790514860, i: 10 }), replSetConfigVersion: Long(\u0026#39;-1\u0026#39;) }, { _id: \u0026#39;sh2\u0026#39;, host: \u0026#39;sh2/m07-sh2:27017\u0026#39;, state: 1, topologyTime: Timestamp({ t: 1790514860, i: 28 }), replSetConfigVersion: Long(\u0026#39;-1\u0026#39;) } ] [direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).mongos.find({}, {_id: 1, mongoVersion: 1}) [ { _id: \u0026#39;m07-mongos:27017\u0026#39;, mongoVersion: \u0026#39;8.0.32\u0026#39; }, { _id: \u0026#39;m07-mongos:27018\u0026#39;, mongoVersion: \u0026#39;8.0.32\u0026#39; } ] 샤드는 레플리카셋 이름/멤버 주소 형식으로 등록되고, 레플리카셋 이름이 샤드 ID(sh1, sh2)가 됩니다. mongos는 이 주소로 샤드의 레플리카셋 전체를 찾아가므로 샤드의 primary가 바뀌어도 따라갑니다. config.mongos에는 떠 있는 mongos가 스스로 등록되어 있습니다. mongos는 데이터를 갖지 않지만 자기 존재는 config server에 알립니다. config 데이터베이스에서 이 글에 나오는 컬렉션은 다음과 같습니다.\n컬렉션 내용 config.shards 샤드 목록과 주소 config.databases 데이터베이스마다 primary shard(샤딩하지 않은 컬렉션이 기본으로 놓이는 샤드) config.collections 샤딩된(또는 추적되는) 컬렉션의 shard key, UUID config.chunks chunk마다 범위(min, max), 소유 샤드, 버전(lastmod) config.settings chunk 크기, balancer 설정 config.changelog shardCollection, chunk 이동 같은 메타데이터 변경 기록 shard key와 chunk 컬렉션을 샤딩할 때 shard key를 하나 정합니다. shard key는 도큐먼트의 필드(또는 여러 필드)이고, 클러스터는 이 키의 값 공간을 연속된 구간으로 잘라 샤드에 나눠 줍니다. 그 구간 하나가 chunk입니다. chunk는 [min, max) 반열린 구간이고, 전체 chunk를 이어 붙이면 MinKey부터 MaxKey까지 빈틈없이 덮습니다. 어떤 도큐먼트가 어느 샤드에 있는지는 \u0026ldquo;그 도큐먼트의 shard key 값이 어느 chunk에 속하는가\u0026quot;로 정해집니다.\nchunk는 메타데이터의 단위이지 저장의 단위가 아닙니다. 샤드 안에서 한 컬렉션은 chunk와 상관없이 WiredTiger 테이블 하나(1편)이고, chunk는 config server의 config.chunks에 있는 도큐먼트 한 줄일 뿐입니다. 그래서 chunk를 \u0026ldquo;옮긴다\u0026quot;는 것은 그 범위의 도큐먼트를 다른 샤드의 테이블로 복사하고, config.chunks의 소유 샤드를 바꾸는 일입니다.\nrange shard key로 샤딩하기 shop 데이터베이스의 primary shard를 sh1로 정하고, orders 컬렉션을 customerId로 샤딩합니다.\n[direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).settings.find() [direct: mongos] test\u003e db.adminCommand({enableSharding: \u0026#39;shop\u0026#39;, primaryShard: \u0026#39;sh1\u0026#39;}).ok 1 [direct: mongos] test\u003e sh.shardCollection(\u0026#39;shop.orders\u0026#39;, {customerId: 1}).collectionsharded shop.orders [direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.orders\u0026#39;}, {key: 1, unique: 1, uuid: 1, timestamp: 1}) { _id: \u0026#39;shop.orders\u0026#39;, timestamp: Timestamp({ t: 1790514860, i: 64 }), uuid: UUID(\u0026#39;a25230b3-f5d2-467e-955e-8f77b5680469\u0026#39;), key: { customerId: 1 }, unique: false } [direct: mongos] test\u003e const ou = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.orders\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: ou}, {_id: 0, min: 1, max: 1, shard: 1, lastmod: 1}) [ { min: { customerId: MinKey() }, max: { customerId: MaxKey() }, shard: \u0026#39;sh1\u0026#39;, lastmod: Timestamp({ t: 1, i: 0 }) } ] config.settings가 비어 있습니다. 설정하지 않은 값은 기본값을 씁니다. 8.0의 기본 chunk 크기는 128MB입니다(balancer_configuration.cpp). config.collections에 shard key { customerId: 1 }(오름차순 range 키)와 컬렉션 UUID가 기록되었습니다. config.chunks는 이름이 아니라 이 UUID로 컬렉션을 가리킵니다. 빈 컬렉션을 range 키로 샤딩하면 MinKey부터 MaxKey까지 전체를 덮는 chunk 하나가 primary shard sh1에 생깁니다(SingleChunkOnPrimarySplitPolicy). 첫 chunk를 어떻게 만들지 고르는 곳을 보면(create_collection_coordinator.cpp), 빈 컬렉션을 미리 나누는 경우는 hashed 키와 zone뿐이고, 나머지는 데이터가 이미 있든 없든 chunk 하나로 시작합니다. 나누는 일은 뒤에서 볼 balancer가 합니다. lastmod: Timestamp({ t: 1, i: 0 })은 chunk 버전 1|0입니다. 이 버전은 shard version에서 다시 봅니다. range와 hashed shard key에는 두 방식이 있습니다.\n방식 chunk가 나누는 것 장점 단점 range ({k: 1}) 키 값 자체의 범위 범위 쿼리가 소수의 샤드로 감 단조 증가 키면 새 쓰기가 한 샤드로 몰림 hashed ({k: 'hashed'}) 키 값의 64비트 해시의 범위 쓰기가 샤드에 고르게 퍼짐 범위 쿼리는 모든 샤드로 감 hashed 키로 빈 컬렉션을 샤딩하면, 해시 공간을 샤드 수만큼 미리 나눠 샤드마다 chunk를 하나씩 줍니다(initial_split_policy.cpp). 해시 값은 데이터를 보지 않아도 고르게 퍼질 것을 알기 때문입니다(8.0 이전 FCV에서는 샤드마다 2개).\nhashed shard key: 빈 컬렉션도 샤드마다 chunk [direct: mongos] test\u003e sh.shardCollection(\u0026#39;shop.users\u0026#39;, {userId: \u0026#39;hashed\u0026#39;}).collectionsharded shop.users [direct: mongos] test\u003e const uu = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.users\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: uu}, {_id: 0, min: 1, max: 1, shard: 1}) [ { min: { userId: MinKey() }, max: { userId: Long(\u0026#39;0\u0026#39;) }, shard: \u0026#39;sh2\u0026#39; }, { min: { userId: Long(\u0026#39;0\u0026#39;) }, max: { userId: MaxKey() }, shard: \u0026#39;sh1\u0026#39; } ] [direct: mongos] test\u003e use shop switched to db shop [direct: mongos] shop\u003e for (let b = 0; b \u0026lt; 10; b++) { const docs = []; for (let i = 0; i \u0026lt; 2000; i++) { docs.push({userId: b * 2000 + i, name: \u0026#39;u\u0026#39; + (b * 2000 + i)}); } db.users.insertMany(docs); } \u0026#39;inserted\u0026#39; inserted [direct: mongos] shop\u003e db.users.getShardDistribution() Shard sh1 at sh1/m07-sh1:27017 { data: \u0026#39;485KiB\u0026#39;, docs: 9860, chunks: 1, ... } --- Shard sh2 at sh2/m07-sh2:27017 { data: \u0026#39;499KiB\u0026#39;, docs: 10140, chunks: 1, ... } ... chunk 경계가 Long('0')입니다. 키 값이 아니라 해시 값(부호 있는 64비트 정수)의 공간을 음수 절반(sh2)과 양수 절반(sh1)으로 나눴습니다. userId를 0부터 19999까지 순서대로 넣었는데도 9860건과 10140건으로 거의 반씩 나뉘었습니다. 연속된 값이라도 해시하면 흩어지기 때문입니다. 쿼리 라우팅: targeted와 scatter-gather mongos는 쿼리 조건에서 shard key에 대한 조건을 뽑아, routing table에서 그 값이나 범위가 걸리는 chunk를 찾고, 그 chunk들을 가진 샤드에만 쿼리를 보냅니다. 조건에 shard key가 없으면 어느 샤드에 있을지 알 수 없으므로 모든 샤드에 보내고 결과를 합칩니다(scatter-gather, broadcast). explain의 최상위 stage 이름이 이 결정을 보여 줍니다(ClusterExplain::getStageNameForReadOp()).\nstage 뜻 SINGLE_SHARD 샤드 하나에만 보냄 SHARD_MERGE 여러 샤드에 보내고 결과를 합침 SHARD_MERGE_SORT 여러 샤드에 보내고 정렬을 유지하며 합침 아래 실습은 orders에 8만 건을 넣고 balancer가 chunk를 나눠 옮긴 뒤의 상태에서 한 것입니다. 이때 customerId가 37840보다 작은 범위는 sh2에, 나머지는 sh1에 있었습니다.\nshard key로 찾으면 한 샤드, 아니면 모든 샤드 [direct: mongos] shop\u003e function route(e) { const p = e.queryPlanner.winningPlan; return {stage: p.stage, shards: p.shards.map(s =\u0026gt; s.shardName)}; } [Function: route] [direct: mongos] shop\u003e route(db.orders.find({customerId: 12345}).explain()) { stage: \u0026#39;SINGLE_SHARD\u0026#39;, shards: [ \u0026#39;sh2\u0026#39; ] } [direct: mongos] shop\u003e route(db.orders.find({status: \u0026#39;cancelled\u0026#39;, amount: 5}).explain()) { stage: \u0026#39;SHARD_MERGE\u0026#39;, shards: [ \u0026#39;sh1\u0026#39;, \u0026#39;sh2\u0026#39; ] } [direct: mongos] shop\u003e route(db.orders.find({customerId: {$gte: 100, $lt: 200}}).explain()) { stage: \u0026#39;SINGLE_SHARD\u0026#39;, shards: [ \u0026#39;sh2\u0026#39; ] } [direct: mongos] shop\u003e route(db.orders.find({customerId: {$gte: 0, $lt: 80000}}).explain()) { stage: \u0026#39;SHARD_MERGE\u0026#39;, shards: [ \u0026#39;sh1\u0026#39;, \u0026#39;sh2\u0026#39; ] } shard key 값 하나(customerId: 12345)는 chunk 하나에 속하므로 SINGLE_SHARD로 sh2에만 갑니다. shard key가 없는 조건(status, amount)은 모든 샤드로 갑니다. range 키에서는 범위 조건도 그 범위가 걸치는 chunk의 샤드로만 갑니다. 100199는 sh2 한 곳, 079999는 두 샤드 모두입니다. hashed 컬렉션에서는 같은 범위 조건이라도 달라집니다.\n[direct: mongos] shop\u003e route(db.users.find({userId: 777}).explain()) { stage: \u0026#39;SINGLE_SHARD\u0026#39;, shards: [ \u0026#39;sh2\u0026#39; ] } [direct: mongos] shop\u003e route(db.users.find({userId: {$gte: 100, $lt: 110}}).explain()) { stage: \u0026#39;SHARD_MERGE\u0026#39;, shards: [ \u0026#39;sh1\u0026#39;, \u0026#39;sh2\u0026#39; ] } userId: 777 같은 동등 조건은 해시해서 chunk를 찾으면 되니 한 샤드로 가지만, 100 \u0026lt;= userId \u0026lt; 110처럼 10개짜리 좁은 범위도 해시 값으로는 어디든 흩어져 있을 수 있어 모든 샤드로 갑니다.\nscatter-gather의 비용 [direct: mongos] shop\u003e const x = db.orders.find({status: \u0026#39;cancelled\u0026#39;, amount: 5}).explain(\u0026#39;executionStats\u0026#39;).executionStats; ({nReturned: x.nReturned, totalDocsExamined: x.totalDocsExamined, shards: x.executionStages.shards.map(s =\u0026gt; ({shard: s.shardName, nReturned: s.nReturned, docsExamined: s.totalDocsExamined}))}) { nReturned: 8, totalDocsExamined: 117840, shards: [ { shard: \u0026#39;sh2\u0026#39;, nReturned: 4, docsExamined: 37840 }, { shard: \u0026#39;sh1\u0026#39;, nReturned: 4, docsExamined: 80000 } ] } [direct: mongos] shop\u003e const y = db.orders.find({customerId: 12345}).explain(\u0026#39;executionStats\u0026#39;).executionStats; ({nReturned: y.nReturned, totalDocsExamined: y.totalDocsExamined, shards: y.executionStages.shards.map(s =\u0026gt; ({shard: s.shardName, nReturned: s.nReturned, docsExamined: s.totalDocsExamined}))}) { nReturned: 1, totalDocsExamined: 1, shards: [ { shard: \u0026#39;sh2\u0026#39;, nReturned: 1, docsExamined: 1 } ] } status 쿼리는 8건을 돌려주려고 두 샤드가 각자 컬렉션 전체를 훑었습니다(인덱스가 없으므로 4편의 COLLSCAN). 합계 totalDocsExamined가 117840으로, 컬렉션의 8만 건보다 많습니다. sh1이 80000건을 읽은 것은, 이미 sh2로 옮겨 간 37840건이 orphan으로 sh1에 아직 남아 있어서입니다. 샤드는 이 도큐먼트를 읽기는 하지만, 자기 소유가 아니므로 결과에서는 걸러 냅니다(SHARDING_FILTER stage, shard_filter.cpp). customerId 쿼리는 한 샤드에서 1건만 읽었습니다(_id가 아니어도 shard key에는 인덱스가 반드시 있습니다). scatter-gather는 샤드 수만큼 일을 늘립니다. 샤드가 10개면 결과가 몇 건이든 10개 샤드가 모두 쿼리를 실행하고, mongos는 가장 느린 샤드의 응답까지 기다립니다.\nbalancer: 데이터 크기를 맞춘다 8.0에는 쓰기 도중 chunk를 크기에 맞춰 자르는 과정이 없습니다. chunk 하나가 아무리 커져도 그대로 있다가, balancer가 샤드 사이의 데이터 크기 차이를 보고 옮길 때 잘립니다.\nbalancer는 config server primary에서 도는 스레드입니다(Balancer::_mainThread()). 한 라운드가 끝나면 기본 10초 쉬고(balancer.cpp) 다음 라운드를 돕니다. 라운드마다 샤딩된 컬렉션별로 샤드의 데이터 크기를 모으고, BalancerPolicy::_singleZoneBalanceBasedOnDataSize()로 옮길지 정합니다.\n가장 많이 가진 샤드(from)와 가장 적게 가진 샤드(to)를 고릅니다. from이 이상적인 크기(전체 / 샤드 수)보다 크고 to가 그보다 작을 때만(balancer_policy.cpp), 두 샤드의 차이가 chunk 크기의 3배 이상이면 옮깁니다(balancer_policy.cpp). 기본 chunk 크기 128MB에서는 384MB입니다. from의 chunk 가운데 jumbo가 아닌 것의 min만 정해 moveRange를 요청합니다. max는 비워 둡니다(balancer_policy.cpp). 원본 샤드가 min부터 chunk 크기만큼 되는 지점을 찾아 max로 삼습니다(computeOtherBound()). 옮길 때 chunk가 잘리는 것이 이 단계입니다. 샤드별 데이터 크기는 샤드가 보고하는 값이고, 컬렉션 크기에서 orphan 도큐먼트를 뺀 값입니다(shardsvr_get_stats_for_balancing_command.cpp).\nchunk 크기를 1MB로 줄이고 데이터를 넣으면 실습에서는 기본 128MB로는 이동을 보기 어려워 config.settings의 chunk 크기를 1MB로 줄였습니다. 그리고 orders에 도큐먼트 8만 건을 customerId 0부터 순서대로 넣었습니다.\n[direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).settings.updateOne({_id: \u0026#39;chunksize\u0026#39;}, {$set: {value: 1}}, {upsert: true}).acknowledged true [direct: mongos] test\u003e use shop switched to db shop [direct: mongos] shop\u003e const pad = \u0026#39;x\u0026#39;.repeat(200); for (let b = 0; b \u0026lt; 40; b++) { const docs = []; for (let i = 0; i \u0026lt; 2000; i++) { const c = b * 2000 + i; docs.push({customerId: c, status: c % 10 === 0 ? \u0026#39;cancelled\u0026#39; : \u0026#39;paid\u0026#39;, amount: c % 997, pad: pad}); } db.orders.insertMany(docs); } \u0026#39;inserted\u0026#39; inserted [direct: mongos] shop\u003e db.orders.countDocuments() 80000 [direct: mongos] shop\u003e sh.balancerCollectionStatus(\u0026#39;shop.orders\u0026#39;) { chunkSize: 1, balancerCompliant: false, firstComplianceViolation: \u0026#39;chunksImbalance\u0026#39;, ... } 넣은 직후 balancerCollectionStatus는 balancerCompliant: false, 이유는 chunksImbalance입니다. 모든 데이터가 sh1의 chunk 하나에 있으니 당연합니다. 약 15초 뒤 balancerCompliant: true가 되었습니다. 그때의 상태입니다.\n[direct: mongos] shop\u003e db.orders.aggregate([{$collStats: {storageStats: {}}}, {$project: {_id: 0, shard: 1, count: \u0026#39;$storageStats.count\u0026#39;, numOrphanDocs: \u0026#39;$storageStats.numOrphanDocs\u0026#39;}}, {$sort: {shard: 1}}]) [ { shard: \u0026#39;sh1\u0026#39;, count: 80000, numOrphanDocs: 37840 }, { shard: \u0026#39;sh2\u0026#39;, count: 37840, numOrphanDocs: 0 } ] [direct: mongos] shop\u003e const ou = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.orders\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.aggregate([{$match: {uuid: ou}}, {$group: {_id: \u0026#39;$shard\u0026#39;, chunks: {$sum: 1}}}, {$sort: {_id: 1}}]) [ { _id: \u0026#39;sh1\u0026#39;, chunks: 1 }, { _id: \u0026#39;sh2\u0026#39;, chunks: 10 } ] [direct: mongos] shop\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: ou}, {_id: 0, min: 1, max: 1, shard: 1}).sort({min: 1}).limit(4) [ { min: { customerId: MinKey() }, max: { customerId: 3784 }, shard: \u0026#39;sh2\u0026#39; }, { min: { customerId: 3784 }, max: { customerId: 7568 }, shard: \u0026#39;sh2\u0026#39; }, { min: { customerId: 7568 }, max: { customerId: 11352 }, shard: \u0026#39;sh2\u0026#39; }, { min: { customerId: 11352 }, max: { customerId: 15136 }, shard: \u0026#39;sh2\u0026#39; } ] balancer가 sh1의 chunk 앞쪽부터 3784건(약 1MB)씩 10번 잘라 sh2로 옮겼습니다. sh2에는 1MB짜리 chunk 10개(37840건), sh1에는 나머지 42160건이 든 chunk 하나가 남았습니다. 아래 moveChunk.commit에서 보듯 한 번에 3784건, 1050063바이트를 옮겼으므로 도큐먼트 하나가 약 277바이트입니다. 이 값으로 계산하면 sh2가 약 10.5MB, sh1이 약 11.7MB(42160건)이고, 차이가 3MB(chunk 크기의 3배)보다 작아 balancer가 멈췄습니다. balancer는 \u0026ldquo;chunk 수\u0026quot;가 아니라 \u0026ldquo;데이터 크기\u0026quot;를 맞춘다는 것이 chunk 수 1 대 10에서 보입니다. sh1의 count가 아직 80000이고 그중 37840이 numOrphanDocs입니다. 옮긴 도큐먼트가 원본에서 아직 지워지지 않았습니다. 뒤에서 봅니다. [direct: mongos] shop\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).changelog.aggregate([{$match: {ns: \u0026#39;shop.orders\u0026#39;}}, {$group: {_id: \u0026#39;$what\u0026#39;, n: {$sum: 1}}}, {$sort: {_id: 1}}]) [ { _id: \u0026#39;moveChunk.commit\u0026#39;, n: 10 }, { _id: \u0026#39;moveChunk.from\u0026#39;, n: 10 }, { _id: \u0026#39;moveChunk.start\u0026#39;, n: 10 }, { _id: \u0026#39;moveChunk.to\u0026#39;, n: 10 }, { _id: \u0026#39;shardCollection.end\u0026#39;, n: 1 }, { _id: \u0026#39;shardCollection.start\u0026#39;, n: 1 } ] [direct: mongos] shop\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).changelog.find({ns: \u0026#39;shop.orders\u0026#39;, what: \u0026#39;moveChunk.commit\u0026#39;}, {_id: 0, time: 1, what: 1, details: 1}).sort({time: 1}).limit(1) [ { time: ISODate(\u0026#39;2026-09-27T13:14:25.547Z\u0026#39;), what: \u0026#39;moveChunk.commit\u0026#39;, details: { min: { customerId: MinKey() }, max: { customerId: 3784 }, from: \u0026#39;sh1\u0026#39;, to: \u0026#39;sh2\u0026#39;, counts: { cloned: Long(\u0026#39;3784\u0026#39;), clonedBytes: Long(\u0026#39;1050063\u0026#39;), catchup: Long(\u0026#39;0\u0026#39;), steady: Long(\u0026#39;0\u0026#39;) } ... ] config.changelog에는 이동마다 moveChunk.start, .to, .from, .commit이 하나씩, 모두 10번 남았습니다. 첫 이동은 3784건, 1050063바이트(1MB 남짓)를 복제했습니다. catchup과 steady가 0인 것은 복제하는 동안 그 범위에 새 쓰기가 없었다는 뜻입니다.\nbalancer가 config server에서 돈다는 것은 config server의 로그로도 보입니다.\n$ jq -r \u0026#39;select(.msg | test(\u0026#34;alanc\u0026#34;)) | .msg\u0026#39; /data/mongod.log | sort | uniq -c 1 Balancer command scheduler start requested 1 Balancer scheduler recovery complete. Switching to regular execution 1 Balancer scheduler thread started 1 Balancer worker thread initialised. Entering main loop. 1 CSRS balancer is starting 1 Performing balancer warning checks chunk migration: 옮기는 동안에도 쓰기는 계속된다 balancer든 사용자든 chunk 이동은 원본 샤드에 moveRange 명령을 보내는 것으로 시작하고, 원본 샤드가 절차를 이끕니다(shardsvr_move_range_command.cpp).\nMigrationSourceManager migrationSourceManager( opCtx, std::move(request), std::move(writeConcern), donorConnStr, recipientHost); migrationSourceManager.startClone(); migrationSourceManager.awaitToCatchUp(); migrationSourceManager.enterCriticalSection(); migrationSourceManager.commitChunkOnRecipient(); migrationSourceManager.commitChunkMetadataOnConfig(); 받는 샤드가 도큐먼트를 끌어가 복제하는 동안 원본은 쓰기를 계속 받습니다. 쓰기를 막는 것은 마지막 변경분을 넘기고 config server에 commit하는 짧은 critical section뿐이고, 원본에 남은 도큐먼트는 나중에 range deleter가 지웁니다. 새 창에서 크게 보기 범위 확정, 복제 시작 (startClone()): 옮길 범위를 정하고 받는 샤드에 복제를 시작하라고 알립니다. 이때부터 원본은 그 범위에 들어오는 쓰기를 따로 기록해 둡니다. 도큐먼트 복제: 받는 샤드가 원본에 _migrateClone을 보내 범위 안의 도큐먼트를 배치로 끌어가 자기 컬렉션에 넣습니다. 변경분 따라잡기 (awaitToCatchUp()): 복제하는 동안 생긴 변경을 받는 샤드가 _transferMods로 가져가 적용합니다. 남은 변경이 충분히 적어질 때까지 반복합니다. critical section (enterCriticalSection()): 원본이 그 컬렉션의 쓰기를 막고(읽기는 commit 직전까지 허용), 마지막 변경분을 넘깁니다. commit (commitChunkMetadataOnConfig()): config server가 config.chunks의 소유 샤드를 바꾸고, 옮긴 chunk의 버전을 major + 1로 올립니다(commitChunkMigration()). 이것으로 critical section이 끝나고, 막혔던 쓰기는 새 소유 샤드로 다시 라우팅됩니다. 정리: 원본에 남은 도큐먼트를 지우는 작업(range deletion)을 예약합니다. 쓰기가 막히는 시간은 4~5단계뿐입니다. 도큐먼트 복제는 쓰기와 동시에 진행됩니다.\n수동 moveRange와 migration 로그 balancer를 멈춘 상태에서 sh1의 마지막 chunk(customerId 37840 이상)를 sh2로 직접 옮깁니다. max는 주지 않고 waitForDelete: true로 원본 정리까지 기다리게 했습니다.\n[direct: mongos] test\u003e db.adminCommand({moveRange: \u0026#39;shop.orders\u0026#39;, min: {customerId: 37840}, toShard: \u0026#39;sh2\u0026#39;, waitForDelete: true}).ok 1 [direct: mongos] test\u003e const ou = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.orders\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: ou, \u0026#39;min.customerId\u0026#39;: {$gte: 37840}}, {_id: 0, min: 1, max: 1, shard: 1, lastmod: 1}).sort({min: 1}) [ { min: { customerId: 37840 }, max: { customerId: 41624 }, shard: \u0026#39;sh2\u0026#39;, lastmod: Timestamp({ t: 12, i: 0 }) }, { min: { customerId: 41624 }, max: { customerId: MaxKey() }, shard: \u0026#39;sh1\u0026#39;, lastmod: Timestamp({ t: 12, i: 1 }) } ] [direct: mongos] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).changelog.find({ns: \u0026#39;shop.orders\u0026#39;, what: \u0026#39;moveChunk.from\u0026#39;}, {_id: 0, what: 1, details: 1}).sort({time: -1}).limit(1) [ { what: \u0026#39;moveChunk.from\u0026#39;, details: { \u0026#39;step 1 of 6\u0026#39;: 0, \u0026#39;step 2 of 6\u0026#39;: 2, \u0026#39;step 3 of 6\u0026#39;: 9, \u0026#39;step 4 of 6\u0026#39;: 104, \u0026#39;step 5 of 6\u0026#39;: 15, \u0026#39;step 6 of 6\u0026#39;: 54, min: { customerId: 37840 }, max: { customerId: 41624 }, to: \u0026#39;sh2\u0026#39;, from: \u0026#39;sh1\u0026#39;, note: \u0026#39;success\u0026#39; } } ] [37840, MaxKey) chunk가 [37840, 41624)와 [41624, MaxKey)로 잘렸고, 앞쪽 3784건만 sh2로 갔습니다. max를 주지 않자 원본이 chunk 크기(1MB)로 경계를 정한 것입니다. 옮긴 chunk의 버전이 12|0, 원본에 남은 chunk가 12|1입니다. 그 전 최대 버전이 11|x(balancer 이동 10번)였으므로 major가 하나 올라갔습니다. moveChunk.from의 단계별 시간(ms)은 원본 샤드가 잰 것입니다. step 4(복제와 따라잡기, awaitToCatchUp()까지)가 104ms로 가장 길고, 쓰기를 막는 critical section과 받는 샤드의 commit(step 5)은 15ms였습니다. step 6(config commit과 waitForDelete 대기)이 54ms입니다. 원본 샤드(sh1)의 로그입니다.\n$ jq -c \u0026#39;select(.c == \u0026#34;MIGRATE\u0026#34; or .c == \u0026#34;RANGEDEL\u0026#34;) | {t: .t.\u0026#34;$date\u0026#34;, c, msg}\u0026#39; /data/mongod.log | tail -12 ... {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.613+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting chunk migration donation\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.730+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Migration successfully entered critical section\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.750+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Migration succeeded and updated collection placement version\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.750+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Exiting commit critical section\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.750+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Finished critical section\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.750+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;MigrationCoordinator delivering decision to self and to recipient\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.761+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Waiting for migration cleanup after chunk commit\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.802+00:00\u0026#34;,\u0026#34;c\u0026#34;:\u0026#34;MIGRATE\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Unregistering donate chunk\u0026#34;} $ jq -c \u0026#39;select(.msg == \u0026#34;Migration finished\u0026#34;) | .attr\u0026#39; /data/mongod.log | tail -1 {\u0026#34;migrationId\u0026#34;:\u0026#34;1b6d9bd6-a654-4816-b64c-98d0be8f1eed\u0026#34;,\u0026#34;totalTimeMillis\u0026#34;:187,\u0026#34;docsCloned\u0026#34;:3784,\u0026#34;bytesCloned\u0026#34;:1050063,\u0026#34;cloneTime\u0026#34;:111} 받는 샤드(sh2)의 로그입니다.\n$ jq -c \u0026#39;select(.c == \u0026#34;MIGRATE\u0026#34;) | {t: .t.\u0026#34;$date\u0026#34;, msg}\u0026#39; /data/mongod.log | tail -5 {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.730+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Chunk data replicated successfully.\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.744+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Migration commit succeeded flushing to secondaries\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.746+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Entered migration recipient critical section\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.751+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Exited migration recipient critical section\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:55.755+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;clearReceiveChunk\u0026#34;} 원본은 .613에 시작해 .730에 critical section에 들어갔고, .750에 config commit과 placement version 갱신까지 끝냈습니다. critical section은 약 20ms였습니다. 그 사이 받는 샤드는 .730에 복제를 마치고(Chunk data replicated successfully.), 자기 쪽 critical section을 .746~.751에 거쳤습니다. Waiting for migration cleanup after chunk commit은 waitForDelete: true 때문에 원본의 도큐먼트 삭제를 기다린 것입니다. 약 40ms 뒤 끝났습니다. Migration finished의 요약: 전체 187ms, 3784건, 1050063바이트, 복제에 111ms. shard version과 stale config mongos는 routing table을 캐시해 두고 쓰므로, 다른 mongos나 balancer가 chunk를 옮기면 그 캐시가 낡습니다. MongoDB는 캐시를 매번 확인하는 대신 버전으로 낡은 것을 알아챕니다.\nchunk마다 버전(lastmod, major|minor)이 있고, 샤드의 placement version은 그 샤드가 가진 chunk 중 가장 높은 버전입니다. 이동하면 major가, 분할과 병합이면 minor가 오릅니다. mongos는 샤드에 요청을 보낼 때 자기 routing table 기준의 그 샤드 버전(shard version)을 붙입니다. 샤드는 받은 버전을 자기가 아는 버전과 비교해(_getMetadataWithVersionCheckAt()), 맞지 않으면 StaleConfig 오류를 돌려줍니다. migration의 critical section 중에도 같은 오류로 요청을 잠시 돌려보냅니다(collection_sharding_runtime.cpp). mongos는 StaleConfig를 받으면 그 컬렉션의 캐시를 무효화하고(CollectionRouterCommon::_onException()), config server에서 새 routing table을 받아 요청을 다시 보냅니다. 재시도는 최대 10번입니다(catalog_cache.h). 애플리케이션은 이 과정을 보지 못합니다. 응답이 조금 늦어질 뿐입니다.\n옛 routing table을 가진 mongos 두 번째 mongos(B, 포트 27018)는 앞의 moveRange 전에 orders를 한 번 조회해 routing table을 캐시해 두었습니다. 이 mongos의 프롬프트 앞에 B를 붙입니다.\nB [direct: mongos] shop\u003e db.orders.countDocuments({customerId: {$gte: 37840, $lt: 37940}}) 100 B [direct: mongos] shop\u003e db.adminCommand({getShardVersion: \u0026#39;shop.orders\u0026#39;}).version Timestamp({ t: 11, i: 1 }) 그 뒤 첫 mongos로 [37840, 41624)를 sh2로 옮겼습니다(앞 절의 moveRange). 이제 B에서 같은 범위를 다시 조회합니다.\nB [direct: mongos] test\u003e db.adminCommand({getShardVersion: \u0026#39;shop.orders\u0026#39;}).version Timestamp({ t: 11, i: 1 }) B [direct: mongos] shop\u003e db.orders.countDocuments({customerId: {$gte: 37840, $lt: 37940}}) 100 B [direct: mongos] shop\u003e db.adminCommand({getShardVersion: \u0026#39;shop.orders\u0026#39;}).version Timestamp({ t: 12, i: 1 }) # sh1 countStaleConfigErrors: Long(\u0026#39;1\u0026#39;) -\u0026gt; Long(\u0026#39;2\u0026#39;) # sh2 countStaleConfigErrors: Long(\u0026#39;0\u0026#39;) -\u0026gt; Long(\u0026#39;0\u0026#39;) $ jq -c \u0026#39;select(.msg == \u0026#34;Refreshed cached collection\u0026#34; and .attr.namespace == \u0026#34;shop.orders\u0026#34;) | {t: .t.\u0026#34;$date\u0026#34;, msg, newVersion: (.attr.newVersion | capture(\u0026#34;v: (?\u0026lt;v\u0026gt;Timestamp\\\\([0-9, ]+\\\\))\u0026#34;).v), durationMillis: .attr.durationMillis}\u0026#39; /data/mongos-b.log {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:44.177+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Refreshed cached collection\u0026#34;,\u0026#34;newVersion\u0026#34;:\u0026#34;Timestamp(11, 1)\u0026#34;,\u0026#34;durationMillis\u0026#34;:0} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:56.974+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Refreshed cached collection\u0026#34;,\u0026#34;newVersion\u0026#34;:\u0026#34;Timestamp(12, 1)\u0026#34;,\u0026#34;durationMillis\u0026#34;:0} 이동 뒤에도 B의 캐시는 11|1에 머물러 있었습니다. mongos는 다른 곳에서 일어난 이동을 스스로 알아채지 않습니다. 조회 결과는 정확히 100건이었습니다. B는 옛 routing table대로 이 범위를 sh1에 보냈고, sh1은 버전이 맞지 않아 StaleConfig로 돌려보냈습니다. sh1의 serverStatus().shardingStatistics.countStaleConfigErrors가 1 늘었습니다(sh2는 그대로). B의 로그에는 routing table을 두 번 받은 기록이 있습니다. 첫 조회 때 11|1, StaleConfig를 받은 뒤 12|1입니다. 새 버전으로 sh2에 다시 보낸 결과가 100건이었습니다. orphan과 range deleter chunk를 옮긴 뒤 원본 샤드에 남은 도큐먼트를 orphan이라고 합니다. 원본은 commit 직후 바로 지우지 않고, 삭제 작업을 config.rangeDeletions에 기록해 두었다가 orphanCleanupDelaySecs(기본 900초, sharding_runtime_d_params.idl) 뒤에 range deleter가 지웁니다(range_deleter_service.cpp). 이동 직전에 시작된 쿼리가 원본에서 아직 그 도큐먼트를 읽고 있을 수 있기 때문입니다. waitForDelete를 주면 이 지연 없이 곧바로 지웁니다(migration_coordinator.cpp).\norphan은 mongos를 거친 쿼리에는 보이지 않습니다. 샤드가 자기 소유 범위가 아닌 도큐먼트를 결과에서 거르기 때문입니다. 샤드에 직접 접속하면 보입니다.\nbalancer 이동 뒤의 원본 샤드 balancer가 10번 옮긴 직후 sh1에 직접 접속해 봤습니다(프롬프트의 sh1 [direct: primary]).\nsh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).rangeDeletions.find({}, {_id: 0, nss: 1, range: 1, whenToClean: 1, numOrphanDocs: 1}).limit(3) [ { nss: \u0026#39;shop.orders\u0026#39;, range: { min: { customerId: MinKey() }, max: { customerId: 3784 } }, whenToClean: \u0026#39;delayed\u0026#39;, numOrphanDocs: Long(\u0026#39;3784\u0026#39;) }, ... ] sh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).rangeDeletions.countDocuments() 10 sh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;shop\u0026#39;).orders.countDocuments() 80000 sh1 [direct: primary] test\u003e db.adminCommand({getParameter: 1, orphanCleanupDelaySecs: 1}).orphanCleanupDelaySecs 900 [direct: mongos] shop\u003e db.orders.countDocuments() 80000 이동 10번마다 삭제 작업이 하나씩, whenToClean: 'delayed'로 예약되어 있습니다. numOrphanDocs가 범위마다 3784입니다. sh1에 직접 세면 80000건입니다. 이미 sh2로 넘어간 37840건이 여기 그대로 있습니다. mongos로 세면 전체가 정확히 80000건입니다. orphan은 걸러졌습니다. waitForDelete로 옮긴 범위는 바로 지워진다 앞의 수동 moveRange(waitForDelete: true) 뒤에 다시 sh1에 직접 접속해 봤습니다.\nsh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;shop\u0026#39;).orders.countDocuments({customerId: {$gte: 37840, $lt: 41624}}) 0 sh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;shop\u0026#39;).orders.countDocuments({customerId: {$lt: 3784}}) 3784 sh1 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).rangeDeletions.countDocuments() 10 waitForDelete로 옮긴 [37840, 41624)는 sh1에서 이미 0건이지만, balancer가 옮긴 [MinKey, 3784)는 3784건이 아직 남아 있습니다. rangeDeletions도 balancer 이동분 10개가 그대로입니다. 900초가 지나야 지워집니다.\norphan이 남은 범위는 되돌려 보낼 수 없다 orphan이 남아 있는 동안 그 범위를 원래 샤드로 되돌려 보내면 어떻게 될까요? balancer가 sh2로 옮긴 [MinKey, 3784)를 곧바로 sh1로 옮겨 봤습니다.\n[direct: mongos] test\u003e db.adminCommand({moveRange: \u0026#39;shop.orders\u0026#39;, min: {customerId: MinKey}, toShard: \u0026#39;sh1\u0026#39;}) MongoServerError[OperationFailed]: Command request failed on source shard. :: caused by :: Data transfer error: migrate failed: ExceededTimeLimit: Migration failed because the orphans cleanup routine didn\u0026#39;t clear yet a portion of the range being migrated that was previously owned by the recipient shard. 받는 쪽이 될 sh1의 로그입니다.\n$ jq -c \u0026#39;select(.c == \u0026#34;MIGRATE\u0026#34;) | {t: .t.\u0026#34;$date\u0026#34;, msg}\u0026#39; /data/mongod.log | tail -4 {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:44.681+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Migration paused because the requested range overlaps with a range already scheduled for deletion\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:44.681+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Waiting for deletion of orphans\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:54.682+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Error during migration\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T13:14:54.682+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;clearReceiveChunk\u0026#34;} sh1에는 그 범위의 orphan이 아직 있고 삭제가 예약되어 있습니다. 받는 샤드는 같은 범위의 orphan이 지워질 때까지 이동을 멈추고 기다리는데(migration_destination_manager.cpp), 그 대기의 상한 receiveChunkWaitForRangeDeleterTimeoutMS(기본 10초, sharding_runtime_d_params.idl)가 지나자 이동이 실패했습니다. 로그의 .681과 .682가 정확히 10초 차이입니다. 옛 orphan과 새로 받을 도큐먼트가 섞이는 것을 막으려는 동작입니다.\nshard key를 잘못 고르면: 단조 증가 키 customerId처럼 계속 커지기만 하는 값(자동 증가 번호, 시각, ObjectId)을 range shard key로 쓰면, 새 도큐먼트는 모두 가장 큰 chunk(MaxKey까지 덮는 chunk)로 들어갑니다. 그 chunk는 한 샤드에만 있습니다.\n새 도큐먼트가 모두 한 샤드로 balancer를 멈추고, customerId 80000부터 99999까지 2만 건을 더 넣었습니다.\n[direct: mongos] shop\u003e const ou = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.orders\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: ou, \u0026#39;max.customerId\u0026#39;: MaxKey}, {_id: 0, min: 1, max: 1, shard: 1}) [ { min: { customerId: 37840 }, max: { customerId: MaxKey() }, shard: \u0026#39;sh1\u0026#39; } ] [direct: mongos] shop\u003e sh.stopBalancer().ok 1 [direct: mongos] shop\u003e const pad = \u0026#39;x\u0026#39;.repeat(200); for (let b = 40; b \u0026lt; 50; b++) { const docs = []; for (let i = 0; i \u0026lt; 2000; i++) { const c = b * 2000 + i; docs.push({customerId: c, status: \u0026#39;paid\u0026#39;, amount: c % 997, pad: pad}); } db.orders.insertMany(docs); } \u0026#39;inserted\u0026#39; inserted [direct: mongos] shop\u003e const z = db.orders.find({customerId: {$gte: 80000}}).explain(\u0026#39;executionStats\u0026#39;).executionStats; ({nReturned: z.nReturned, shards: z.executionStages.shards.map(s =\u0026gt; ({shard: s.shardName, nReturned: s.nReturned}))}) { nReturned: 20000, shards: [ { shard: \u0026#39;sh1\u0026#39;, nReturned: 20000 } ] } 새로 넣은 2만 건이 모두 sh1에 있습니다. 샤드가 몇 개든 쓰기는 이 한 샤드가 다 받고, balancer가 나중에 나눠 옮깁니다. 즉 쓰기를 한 번 받고, 옮기면서 한 번 더 복제합니다. 그리고 앞 절의 moveRange 결과에서 보듯, 이 2만 건이 더해져 약 5만 8천 건이 된 [41624, MaxKey)는 chunk 크기(1MB)를 한참 넘었는데도 chunk 하나로 남아 있었습니다. 쓰기가 chunk를 자르지 않는다는 것이 여기서도 보입니다.\n8.0: 샤딩하지 않은 컬렉션도 옮길 수 있다 샤드 클러스터에서 샤딩하지 않은 컬렉션은 데이터베이스의 primary shard에 놓입니다. 8.0에는 이런 컬렉션을 다른 샤드로 옮기는 moveCollection이 생겼습니다(cluster_move_collection_cmd.cpp). 내부적으로는 컬렉션 전체를 새 샤드에 다시 만드는 resharding 기능을 씁니다. 같은 기반으로 shard key를 바꾸는 reshardCollection, 샤딩을 해제하는 unshardCollection도 있습니다.\nmoveCollection resharding 작업에는 최소 소요 시간(reshardingMinimumOperationDurationMillis, 기본 5분, resharding_server_parameters.idl)이 있어서, 실습에서는 config server에서 이 값을 0으로 낮췄습니다.\ncfg [direct: primary] test\u003e db.adminCommand({setParameter: 1, reshardingMinimumOperationDurationMillis: 0}).ok 1 [direct: mongos] shop\u003e db.logs.insertMany([{m: \u0026#39;a\u0026#39;}, {m: \u0026#39;b\u0026#39;}, {m: \u0026#39;c\u0026#39;}]).acknowledged true [direct: mongos] shop\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.logs\u0026#39;}) null [direct: mongos] shop\u003e db.adminCommand({moveCollection: \u0026#39;shop.logs\u0026#39;, toShard: \u0026#39;sh2\u0026#39;}).ok 1 [direct: mongos] shop\u003e db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.logs\u0026#39;}, {_id: 1, key: 1, unsplittable: 1}) { _id: \u0026#39;shop.logs\u0026#39;, key: { _id: 1 }, unsplittable: true } [direct: mongos] shop\u003e const lu = db.getSiblingDB(\u0026#39;config\u0026#39;).collections.findOne({_id: \u0026#39;shop.logs\u0026#39;}).uuid; db.getSiblingDB(\u0026#39;config\u0026#39;).chunks.find({uuid: lu}, {_id: 0, min: 1, max: 1, shard: 1}) [ { min: { _id: MinKey() }, max: { _id: MaxKey() }, shard: \u0026#39;sh2\u0026#39; } ] [direct: mongos] shop\u003e db.logs.countDocuments() 3 옮기기 전 shop.logs는 config.collections에 없었습니다. primary shard에 있는 샤딩하지 않은 컬렉션은 config server가 따로 추적하지 않습니다. 옮긴 뒤에는 unsplittable: true, 키 { _id: 1 }로 등록되었고, MinKey~MaxKey chunk 하나가 sh2에 있습니다. \u0026ldquo;chunk 하나로 고정된 컬렉션\u0026quot;으로 추적해 primary shard가 아닌 샤드에 둘 수 있게 된 것입니다. 운영에서는 이렇게 나타납니다 shard key는 쿼리와 쓰기 분포를 함께 보고 고른다 shard key는 모든 쿼리 라우팅과 데이터 분포를 정합니다. 가장 흔한 실수는 위 실습처럼 단조 증가 값을 range 키로 쓰는 것입니다. 쓰기가 한 샤드에 몰리는 hot shard가 생기고, balancer는 그 샤드에서 계속 데이터를 퍼 나르느라 바쁩니다. 반대로 hashed 키는 쓰기를 고르게 퍼뜨리지만 범위 쿼리를 모두 scatter-gather로 만듭니다. 자주 쓰는 쿼리가 어떤 조건으로 찾는지, 새 쓰기의 키 값이 어떻게 분포하는지를 함께 보고, 필요하면 {tenantId: 1, createdAt: 1}처럼 분산을 주는 앞 필드와 범위 쿼리용 뒤 필드를 묶은 복합 키를 씁니다. 8.0에서는 잘못 고른 키를 reshardCollection으로 바꿀 수 있지만, 컬렉션 전체를 다시 쓰는 무거운 작업입니다.\nscatter-gather 쿼리는 샤드를 늘릴수록 비싸진다 shard key 조건이 없는 쿼리는 모든 샤드에서 실행됩니다. 위 실습처럼 8건을 찾으려고 모든 샤드가 전체를 읽을 수도 있고, 샤드를 늘리면 이런 쿼리의 총 비용은 오히려 늘어납니다. 느린 쿼리 로그와 explain에서 SHARD_MERGE인 쿼리와 그 shards 목록을 확인하고, 자주 쓰는 쿼리라면 shard key를 조건에 넣거나 각 샤드에서 쓸 인덱스를 만듭니다.\nbalancer window와 migration 부하 chunk 이동은 복제와 삭제를 하는 만큼 원본과 받는 샤드 모두에 I/O를 더하고, critical section 동안 그 컬렉션의 쓰기를 잠깐 막습니다. 부하가 큰 시간대를 피하려면 config.settings의 balancer 설정에 activeWindow(balancer가 도는 시간대)를 정할 수 있습니다(balancer_configuration.cpp). 백업처럼 메타데이터가 바뀌면 안 되는 작업 중에는 sh.stopBalancer()로 멈춥니다. 이동 이력은 config.changelog의 moveChunk.*에, 각 이동의 단계별 시간은 moveChunk.from의 step N of 6에 남으니, 이동이 느리거나 실패할 때 먼저 봅니다.\njumbo chunk 같은 shard key 값을 가진 도큐먼트는 반드시 한 chunk에 있어야 합니다. 그래서 값 하나에 도큐먼트가 몰려 chunk 크기를 넘으면 그 chunk는 잘 수도, 옮길 수도 없는 jumbo chunk가 됩니다. balancer는 jumbo chunk를 건너뛰므로(balancer_policy.cpp) 그 샤드는 계속 무거운 채로 남습니다. 카디널리티가 낮은 필드(상태, 국가 코드 등)만으로 shard key를 만들면 생깁니다. config.chunks에 jumbo: true가 있는지 확인하고, 근본적으로는 카디널리티를 높이는 필드를 더한 키로 바꿔야 합니다.\norphan이 보이는 곳 orphan은 mongos를 거치면 보이지 않지만, 샤드에 직접 접속한 조회, 샤드별 count나 $collStats의 count, 샤드 단위 백업에는 들어 있습니다. 위 실습에서 sh1에 직접 세면 8만 건이었던 것처럼, 이동 직후 샤드별 크기를 합치면 실제보다 큽니다. 또 이동 직후 같은 범위를 되돌리는 이동은 orphan이 지워질 때까지(기본 15분) 실패합니다. read concern available은 샤드의 orphan 필터링을 건너뛰므로 orphan이 결과에 섞일 수 있습니다(8편, collection_sharding_runtime.cpp).\n정리 샤드 클러스터는 라우터 mongos, 메타데이터를 가진 config server 레플리카셋, 데이터를 나눠 가진 샤드 레플리카셋으로 이루어집니다. mongos는 config server의 routing table을 캐시해 요청을 보낼 샤드를 정합니다. shard key의 값 공간을 [min, max) 구간인 chunk로 나누고, chunk마다 소유 샤드를 config.chunks에 적습니다. range 키는 값 그대로, hashed 키는 해시 값으로 나눕니다. shard key 조건이 있으면 해당 chunk의 샤드로만(SINGLE_SHARD), 없으면 모든 샤드로(SHARD_MERGE) 쿼리가 갑니다. 8.0의 balancer는 config server primary에서 돌며 샤드 사이 데이터 크기 차이가 chunk 크기의 3배를 넘으면 moveRange를 요청하고, 원본 샤드가 chunk 크기만큼 잘라 옮깁니다. 쓰기가 chunk를 자르지는 않습니다. chunk migration은 복제, 따라잡기, 짧은 critical section, config commit(major 버전 + 1) 순으로 진행되고, 쓰기는 critical section 동안만 막힙니다. 낡은 routing table로 보낸 요청은 샤드가 StaleConfig로 돌려보내고, mongos가 캐시를 새로 받아 다시 보냅니다. 옮긴 뒤 원본에 남는 orphan은 기본 15분 뒤 range deleter가 지웁니다. 그동안 직접 조회와 되돌리는 이동에 영향을 줍니다. 다음 글에서는 레플리카셋과 샤드 위에서 클라이언트가 받는 보장, read concern과 write concern이 실제로 무엇을 기다리고 무엇을 읽는지를 살펴봅니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/s/catalog_cache.cpp: mongos의 routing table 캐시 src/mongo/s/router_role.cpp: StaleConfig를 받은 뒤의 재시도 src/mongo/s/commands/cluster_explain.cpp: SINGLE_SHARD, SHARD_MERGE src/mongo/db/s/balancer/balancer.cpp, balancer_policy.cpp: balancer 라운드와 이동 기준 src/mongo/db/s/migration_source_manager.cpp: 원본 샤드의 migration 단계 src/mongo/db/s/migration_destination_manager.cpp: 받는 샤드의 migration src/mongo/db/s/config/sharding_catalog_manager_chunk_operations.cpp: chunk 이동 commit과 버전 src/mongo/db/s/collection_sharding_runtime.cpp: 샤드의 shard version 확인 src/mongo/db/s/range_deleter_service.cpp: orphan 삭제 src/mongo/db/s/config/initial_split_policy.cpp: shardCollection 때의 첫 chunk MongoDB 8.0 공식 문서\nSharding Shard Keys Sharded Cluster Balancer moveCollection ","permalink":"https://jinlog.dev/posts/mongodb/07-sharding/","summary":"MongoDB는 데이터를 여러 샤드에 어떻게 나누고, 쿼리를 어떻게 보내며, 데이터를 어떻게 옮기는가","title":"MongoDB 인터널 7: 샤딩 구조"},{"content":"개요 애플리케이션의 에러 로그나 서버 로그에 이런 경고가 찍히기 시작합니다.\nWARNING: database \u0026#34;template1\u0026#34; must be vacuumed within 3146471 transactions HINT: To avoid transaction ID assignment failures, execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. 이 경고를 무시하면 어느 순간 모든 쓰기가 멈춥니다. PostgreSQL의 트랜잭션 ID는 32비트라서 약 21억 개마다 한 바퀴를 돌고, 오래된 행을 \u0026ldquo;얼리지\u0026rdquo;(freeze) 않은 채 한 바퀴를 돌면 과거의 행이 미래의 것으로 보이게 됩니다. 그런 일을 막으려고 PostgreSQL은 한계에 가까워지면 경고를 보내고, 끝내 새 트랜잭션 ID를 내주지 않습니다. 원리는 인터널 6편에서 다뤘고, 여기서는 이 경고를 받았을 때 무엇을 보고 어떻게 되살리는지를 다룹니다.\n이 글에서 답할 질문은 다음과 같습니다.\n지금 한계까지 얼마나 남았는지 어떻게 보는가 경고 단계와 쓰기 거부 단계에서는 각각 무엇이 되고 무엇이 안 되는가 freeze가 진행되지 못하는 원인은 어떻게 찾는가 쓰기가 거부되었을 때 어떤 순서로 되살리는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습 방법: 트랜잭션 ID 20억 개를 실제로 쓰는 대신, 서버를 멈추고 pg_resetwal -x로 다음 트랜잭션 ID를 한계 근처로 옮겼습니다. 실습을 위한 조작이며, 운영 서버에서 pg_resetwal을 쓰면 데이터를 잃을 수 있습니다. 그 뒤의 경고, 에러, 복구 과정은 모두 실제 동작입니다.\n먼저 확인할 것 한계까지 얼마나 남았는가 DB별로 가장 오래된, 얼리지 않은 트랜잭션 ID가 pg_database.datfrozenxid이고, 그 나이가 age(datfrozenxid)입니다. 21억 4748만(2³¹ − 1)에서 이 나이를 빼면 한계까지 남은 양입니다.\npostgres=# SELECT datname, datfrozenxid, age(datfrozenxid) AS xid_age, postgres-# 2147483647 - age(datfrozenxid) AS until_wraparound postgres-# FROM pg_database ORDER BY age(datfrozenxid) DESC; datname | datfrozenxid | xid_age | until_wraparound -----------+--------------+------------+------------------ postgres | 744 | 2144337177 | 3146470 template1 | 744 | 2144337177 | 3146470 template0 | 744 | 2144337177 | 3146470 (3 rows) 같은 조회를 정상일 때 하면 이렇습니다.\npostgres=# SELECT datname, datfrozenxid, age(datfrozenxid) AS xid_age, postgres-# 2147483647 - age(datfrozenxid) AS until_wraparound postgres-# FROM pg_database ORDER BY age(datfrozenxid) DESC; datname | datfrozenxid | xid_age | until_wraparound -----------+--------------+---------+------------------ postgres | 744 | 12 | 2147483635 template1 | 744 | 12 | 2147483635 template0 | 744 | 12 | 2147483635 (3 rows) 나이에 따라 PostgreSQL이 하는 일이 달라집니다.\n나이 일어나는 일 관련 설정 1억 5천만 일반 VACUUM이 테이블 전체를 훑는 aggressive VACUUM으로 바뀜 vacuum_freeze_table_age 2억 autovacuum이 꺼져 있어도 wraparound 방지 VACUUM을 강제로 시작 autovacuum_freeze_max_age 16억 VACUUM이 failsafe 모드로 전환(인덱스 정리를 건너뛰고 속도 제한 해제) vacuum_failsafe_age 한계까지 4천만 트랜잭션 ID를 받을 때마다 must be vacuumed within 경고 고정 한계까지 300만 새 트랜잭션 ID 발급 거부 고정 설정값은 PostgreSQL 18의 기본값입니다.\npostgres=# SELECT name, setting FROM pg_settings postgres-# WHERE name IN (\u0026#39;autovacuum_freeze_max_age\u0026#39;, \u0026#39;vacuum_freeze_table_age\u0026#39;, \u0026#39;vacuum_failsafe_age\u0026#39;) postgres-# ORDER BY name; name | setting ---------------------------+------------ autovacuum_freeze_max_age | 200000000 vacuum_failsafe_age | 1600000000 vacuum_freeze_table_age | 150000000 (3 rows) 평소에 age(datfrozenxid)가 2억 근처에서 오르내리는 것은 정상입니다. autovacuum이 2억에서 freeze하고 나이를 되돌리기 때문입니다. 2억을 한참 넘어 계속 올라간다면 freeze가 진행되지 못하고 있다는 뜻입니다.\n무엇이 freeze를 막는가 VACUUM은 4편의 xmin horizon보다 오래된 행만 얼릴 수 있습니다. 그러니 나이가 계속 오른다면 먼저 4편의 쿼리로 horizon을 붙잡는 것을 찾습니다.\npostgres=# SELECT \u0026#39;session\u0026#39; AS kind, pid::text AS id, age(backend_xid) AS xid_age, age(backend_xmin) AS xmin_age postgres-# FROM pg_stat_activity WHERE (backend_xid IS NOT NULL OR backend_xmin IS NOT NULL) AND pid \u0026lt;\u0026gt; pg_backend_pid() postgres-# UNION ALL postgres-# SELECT \u0026#39;prepared\u0026#39;, gid, age(transaction), NULL FROM pg_prepared_xacts postgres-# UNION ALL postgres-# SELECT \u0026#39;slot\u0026#39;, slot_name, age(xmin), age(catalog_xmin) FROM pg_replication_slots postgres-# ORDER BY 3 DESC NULLS LAST; kind | id | xid_age | xmin_age ----------+----------+------------+---------- prepared | batch-77 | 2144483638 | (1 row) 이 실습에서는 21억 트랜잭션 전에 만들어진 prepared transaction batch-77이 남아 있었습니다. 운영에서도 몇 달 전 트랜잭션 관리자가 남기고 간 prepared transaction, 몇 주째 붙잡힌 replication slot, 며칠씩 열린 세션이 흔한 원인입니다. 그 밖에 autovacuum이 계속 취소되거나(락 충돌), 너무 느려서 큰 테이블을 끝내지 못하는 경우도 있습니다.\n테이블 단위로는 relfrozenxid의 나이를 봅니다.\npostgres=# SELECT c.oid::regclass AS table_name, age(c.relfrozenxid) AS xid_age postgres-# FROM pg_class c WHERE c.relkind IN (\u0026#39;r\u0026#39;, \u0026#39;t\u0026#39;, \u0026#39;m\u0026#39;) postgres-# ORDER BY age(c.relfrozenxid) DESC LIMIT 3; table_name | xid_age ------------------------+------------ pg_statistic | 2144483649 pg_type | 2144483649 pg_toast.pg_toast_1255 | 2144483649 (3 rows) 단계별 증상 경고 단계: 쓰기는 되지만 경고가 쏟아진다 한계까지 4천만이 남지 않으면, 트랜잭션 ID를 받는 모든 명령에 경고가 붙습니다.\npostgres=# INSERT INTO orders (amount) VALUES (100); WARNING: database \u0026#34;template1\u0026#34; must be vacuumed within 3146471 transactions HINT: To avoid transaction ID assignment failures, execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. INSERT 0 1 INSERT는 성공합니다. 경고는 클라이언트와 서버 로그에 함께 남습니다. 서버 로그에는 DB 이름 대신 OID로 찍히기도 합니다.\n$ tail -n 3000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;must be vacuumed within\u0026#39; | head -n 1 2026-09-26 10:29:50.599 UTC [227] WARNING: database with OID 1 must be vacuumed within 3146471 transactions HINT가 해야 할 일을 정확히 알려 줍니다. 그 DB 전체를 VACUUM하고, 오래된 prepared transaction과 slot을 정리하라는 것입니다. 이 단계에서 해결해야 합니다. 남은 여유는 트랜잭션 수로 셈하므로, 초당 수천 건을 처리하는 서버라면 몇 시간 만에 다음 단계로 넘어갑니다.\n쓰기 거부: 새 트랜잭션 ID가 나오지 않는다 트랜잭션 ID를 쓰는 부하를 계속 주었습니다.\n$ echo \u0026#39;SELECT pg_current_xact_id();\u0026#39; \u0026gt; /tmp/xid.sql $ pgbench -n -c 4 -t 40000 -f /tmp/xid.sql postgres 2\u0026gt;\u0026amp;1 | grep -v \u0026#39;must be vacuumed\\|To avoid\\|You might\u0026#39; | tail -n 4 latency average = 0.062 ms initial connection time = 6.072 ms tps = 64132.910544 (without initial connection time) pgbench: error: Run was aborted; the above results are incomplete. pgbench가 도중에 중단되었습니다. 이제 쓰기는 모두 거부됩니다.\npostgres=# INSERT INTO orders (amount) VALUES (200); ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database \u0026#34;template1\u0026#34; HINT: Execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. postgres=# SELECT count(*) FROM orders; count ------- 1 (1 row) postgres=# SELECT datname, datfrozenxid, age(datfrozenxid) AS xid_age, postgres-# 2147483647 - age(datfrozenxid) AS until_wraparound postgres-# FROM pg_database ORDER BY age(datfrozenxid) DESC; datname | datfrozenxid | xid_age | until_wraparound -----------+--------------+------------+------------------ postgres | 744 | 2144483649 | 2999998 template1 | 744 | 2144483649 | 2999998 template0 | 744 | 2144483649 | 2999998 (3 rows) until_wraparound가 정확히 300만 남은 곳에서 멈췄습니다. 트랜잭션 ID가 필요 없는 읽기는 계속 됩니다. 새 트랜잭션 ID가 필요한 INSERT, UPDATE, DELETE, DDL이 모두 이 에러로 실패하므로 서비스 입장에서는 장애입니다.\n조치 1. freeze를 막는 원인을 없앤다 VACUUM부터 돌리고 싶어지지만, 원인이 남아 있으면 VACUUM이 끝나도 나이가 내려가지 않습니다. 이 실습에서는 prepared transaction을 롤백합니다. 4편에서 말했듯 커밋할지 롤백할지는 트랜잭션 관리자 쪽에서 확인한 뒤 정합니다. ROLLBACK PREPARED는 새 트랜잭션 ID가 필요 없으므로 쓰기가 거부된 상태에서도 실행됩니다.\npostgres=# ROLLBACK PREPARED \u0026#39;batch-77\u0026#39;; ROLLBACK PREPARED 세션이라면 pg_terminate_backend(), slot이라면 pg_drop_replication_slot()으로 없앱니다.\n2. 에러 메시지에 나온 DB를 VACUUM한다 VACUUM도 새 트랜잭션 ID가 필요 없으므로 이 상태에서 실행할 수 있습니다. 먼저 테이블 하나를 돌려 봅니다.\npostgres=# VACUUM (VERBOSE) orders; INFO: aggressively vacuuming \u0026#34;postgres.public.orders\u0026#34; WARNING: bypassing nonessential maintenance of table \u0026#34;postgres.public.orders\u0026#34; as a failsafe after 0 index scans DETAIL: The table\u0026#39;s relfrozenxid or relminmxid is too far in the past. HINT: Consider increasing configuration parameter \u0026#34;maintenance_work_mem\u0026#34; or \u0026#34;autovacuum_work_mem\u0026#34;. You might also need to consider other ways for VACUUM to keep up with the allocation of transaction IDs. INFO: finished vacuuming \u0026#34;postgres.public.orders\u0026#34;: index scans: 0 pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 1 remain, 0 are dead but not yet removable removable cutoff: 2144484393, which was 0 XIDs old when operation ended new relfrozenxid: 2144337920, which is 2144337168 XIDs ahead of previous value frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 1 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan bypassed by failsafe: 0 pages from table (0.00% of total) have 0 dead item identifiers avg read rate: 0.000 MB/s, avg write rate: 104.866 MB/s buffer usage: 56 hits, 0 reads, 4 dirtied WAL usage: 5 records, 4 full page images, 33237 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s VACUUM WARNING: database \u0026#34;postgres\u0026#34; must be vacuumed within 2999998 transactions HINT: To avoid XID assignment failures, execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. bypassing nonessential maintenance ... as a failsafe는 나이가 vacuum_failsafe_age(16억)를 넘어 VACUUM이 failsafe 모드로 들어갔다는 뜻입니다. freeze를 최대한 빨리 끝내려고 인덱스 정리 같은 부수적인 일을 건너뛰고, 속도 제한(cost delay)도 풉니다. 경고처럼 보이지만 지금 상황에서는 바라던 동작입니다. 건너뛴 정리는 나중에 평소의 VACUUM이 합니다.\n테이블 하나를 얼려도 DB의 datfrozenxid는 모든 테이블 중 가장 오래된 값이라 그대로입니다. DB 전체를 VACUUM합니다.\n$ vacuumdb --all \u0026gt; /tmp/vacuumdb.log 2\u0026gt;\u0026amp;1 $ grep -E \u0026#39;^vacuumdb:\u0026#39; /tmp/vacuumdb.log $ grep -m 1 -A 3 \u0026#39;bypassing nonessential\u0026#39; /tmp/vacuumdb.log $ grep -c \u0026#39;bypassing nonessential\u0026#39; /tmp/vacuumdb.log vacuumdb: vacuuming database \u0026#34;postgres\u0026#34; vacuumdb: vacuuming database \u0026#34;template1\u0026#34; WARNING: bypassing nonessential maintenance of table \u0026#34;postgres.pg_catalog.pg_proc\u0026#34; as a failsafe after 0 index scans DETAIL: The table\u0026#39;s relfrozenxid or relminmxid is too far in the past. HINT: Consider increasing configuration parameter \u0026#34;maintenance_work_mem\u0026#34; or \u0026#34;autovacuum_work_mem\u0026#34;. You might also need to consider other ways for VACUUM to keep up with the allocation of transaction IDs. 216 vacuumdb --all은 접속할 수 있는 모든 DB를 VACUUM합니다. 시스템 카탈로그까지 216개 테이블에서 failsafe가 켜졌습니다.\npostgres=# SELECT datname, datfrozenxid, age(datfrozenxid) AS xid_age, postgres-# 2147483647 - age(datfrozenxid) AS until_wraparound postgres-# FROM pg_database ORDER BY age(datfrozenxid) DESC; datname | datfrozenxid | xid_age | until_wraparound -----------+--------------+------------+------------------ template0 | 744 | 2144483649 | 2999998 postgres | 2144337920 | 146473 | 2147337174 template1 | 2144484393 | 0 | 2147483647 (3 rows) postgres=# INSERT INTO orders (amount) VALUES (250); ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database \u0026#34;template0\u0026#34; HINT: Execute a database-wide VACUUM in that database. You might also need to commit or roll back old prepared transactions, or drop stale replication slots. postgres와 template1은 되살아났지만 여전히 쓰기가 거부됩니다. 이번에는 template0이 이유입니다. 새 트랜잭션 ID 발급 한계는 모든 DB 가운데 가장 오래된 datfrozenxid로 정해지기 때문입니다.\n3. template0은 autovacuum이 처리한다 template0은 접속을 받지 않는 DB(datallowconn = false)라서 vacuumdb --all이 건너뜁니다. 쓰기가 거부된 상태에서는 접속을 허용하도록 바꾸는 ALTER DATABASE도 새 트랜잭션 ID가 필요해서 실행할 수 없습니다. 이런 DB는 autovacuum이 wraparound 방지 VACUUM으로 처리합니다.\n$ tail -n 3000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;automatic aggressive vacuum to prevent wraparound of table \u0026#34;template0\u0026#39; | head -n 2 2026-09-26 10:30:26.142 UTC [387] LOG: automatic aggressive vacuum to prevent wraparound of table \u0026#34;template0.pg_catalog.pg_statistic\u0026#34;: index scans: 0 2026-09-26 10:30:26.143 UTC [387] LOG: automatic aggressive vacuum to prevent wraparound of table \u0026#34;template0.pg_catalog.pg_type\u0026#34;: index scans: 0 postgres=# SELECT datname, datfrozenxid, age(datfrozenxid) AS xid_age, postgres-# 2147483647 - age(datfrozenxid) AS until_wraparound postgres-# FROM pg_database ORDER BY age(datfrozenxid) DESC; datname | datfrozenxid | xid_age | until_wraparound -----------+--------------+---------+------------------ postgres | 2144337920 | 146473 | 2147337174 template1 | 2144484393 | 0 | 2147483647 template0 | 2144484393 | 0 | 2147483647 (3 rows) postgres=# INSERT INTO orders (amount) VALUES (300); INSERT 0 1 postgres=# SELECT count(*) FROM orders; count ------- 2 (1 row) 26초 만에 autovacuum이 template0을 처리했고 쓰기가 돌아왔습니다. autovacuum은 wraparound 방지 VACUUM을 DB 하나씩 차례로 하므로, DB가 많고 크면 기다리는 시간이 길어집니다. 쓰기 거부 상태에서 할 수 있는 일은 원인을 없애고, 접속 가능한 DB를 직접 VACUUM하고, 나머지는 autovacuum에 맡기는 것입니다. autovacuum을 꺼 두었더라도 wraparound 방지 VACUUM은 돕니다.\n예전 버전의 문서에는 서버를 단일 사용자 모드로 띄워 VACUUM하라는 안내가 있었지만, 지금 문서는 대부분의 경우 필요 없고 가능한 한 피하라고 말합니다(Preventing Transaction ID Wraparound Failures). 이 실습도 서버를 멈추지 않고 되살렸습니다.\n재발 방지 age(datfrozenxid) 감시: DB별로 수집하고, 예를 들어 5억에서 경고, 10억에서 위험 알람을 겁니다. 평소 2억 근처를 오가는지, 계속 오르는지를 추세로 봅니다. 테이블 단위로는 age(relfrozenxid)가 큰 순서로 봅니다. horizon 감시: 4편의 쿼리로 오래된 세션, prepared transaction, slot을 감시합니다. wraparound 장애의 대부분은 여기서 시작합니다. autovacuum이 wraparound 방지 VACUUM을 끝낼 수 있게: 큰 테이블의 wraparound 방지 VACUUM이 락 충돌로 계속 취소되거나 너무 느리지 않은지 autovacuum 로그(automatic aggressive vacuum to prevent wraparound)로 확인합니다. 경고를 알람으로: 서버 로그의 must be vacuumed within은 즉시 대응할 알람으로 걸어 둡니다. 경고 단계에서 해결하면 쓰기 거부까지 가지 않습니다. 정리 age(datfrozenxid)로 한계까지 남은 양을 봅니다. 2억 근처는 정상이고, 계속 오르면 freeze가 막힌 것입니다. 한계까지 4천만이 남으면 must be vacuumed within 경고가, 300만이 남으면 database is not accepting commands that assign new transaction IDs 에러와 함께 쓰기 거부가 시작됩니다. 읽기는 계속 됩니다. freeze를 막는 원인(오래된 세션, prepared transaction, slot)을 먼저 없앱니다. 에러에 나온 DB를 VACUUM합니다. failsafe 경고는 VACUUM이 서두르고 있다는 뜻입니다. vacuumdb --all은 template0을 건너뜁니다. template0은 autovacuum이 처리하고, 가장 오래된 DB가 처리되어야 쓰기가 돌아옵니다. 참고 자료 Preventing Transaction ID Wraparound Failures vacuumdb pg_resetwal PostgreSQL 인터널 6편 트랜잭션 ID wraparound ","permalink":"https://jinlog.dev/posts/postgresql-ops/07-wraparound-warning/","summary":"must be vacuumed within 경고가 보이거나 쓰기가 거부될 때, 무엇을 확인하고 어떤 순서로 되살리는가","title":"PostgreSQL 운영 7: wraparound 경고가 떴다"},{"content":"개요 지금까지 여러 편에서 WAL이 등장했습니다. dirty 페이지를 내보내기 전에 WAL을 먼저 쓴다(2편), 체크섬이 켜져 있으면 hint bit도 WAL을 남긴다(3편), VACUUM도 WAL을 쓴다(5편). 이번 글에서 WAL을 본격적으로 들여다봅니다.\nWAL(Write-Ahead Log)은 \u0026ldquo;데이터 파일을 바꾸기 전에, 무엇을 바꿀지를 먼저 로그에 적는다\u0026quot;는 규칙이자 그 로그입니다. 커밋할 때 데이터 파일은 그대로 두고 WAL만 디스크에 확실히 써 두면, 서버가 갑자기 죽어도 WAL을 다시 재생해서 커밋된 변경을 모두 되살릴 수 있습니다. 데이터 파일은 나중에 여유 있게 쓰면 됩니다.\n이 글에서 답할 질문은 다음과 같습니다.\nLSN은 무엇이고, WAL 파일 이름은 어떻게 정해지는가 UPDATE 한 번은 어떤 WAL 레코드를 남기는가 full page write는 왜 필요하고, 얼마나 큰가 커밋할 때 정확히 무엇을 기다리는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\nLSN: WAL 안의 위치 WAL은 클러스터가 만들어진(initdb) 이래 끝없이 이어지는 하나의 바이트 흐름으로 볼 수 있습니다. 그 흐름 안의 위치(바이트 오프셋)가 LSN(Log Sequence Number)입니다. 64비트 정수이고(XLogRecPtr), 0/17E0A58처럼 상위 32비트와 하위 32비트를 16진수로 나눠 씁니다. LSN은 계속 커지기만 하므로, 두 LSN을 빼면 그 사이에 쓴 WAL의 바이트 수가 됩니다.\nLSN은 여러 곳에 쓰입니다.\n페이지 LSN: 모든 데이터 페이지의 헤더(pd_lsn, 3편)에는 그 페이지를 마지막으로 바꾼 WAL 레코드의 끝 위치가 적혀 있습니다. dirty 페이지를 디스크에 쓰기 전에 그 LSN까지 WAL이 디스크에 있는지 확인합니다. 복제와 복구의 진행 위치: standby가 어디까지 받았는지, 복구가 어디까지 재생했는지도 LSN으로 표시합니다(8편, 9편). WAL 위치는 세 가지로 나눠 볼 수 있습니다.\n함수 뜻 pg_current_wal_insert_lsn() WAL buffers에 넣은 곳까지 pg_current_wal_lsn() 운영체제에 write()로 넘긴 곳까지(디스크 도달은 미보장) pg_current_wal_flush_lsn() fsync까지 끝나 확실히 저장된 곳까지 WAL 파일(세그먼트) WAL은 $PGDATA/pg_wal 아래에 16MB짜리 파일(세그먼트)로 나뉘어 저장됩니다. 파일 이름은 24자리 16진수로, 타임라인 ID 8자리, LSN 상위 32비트 8자리, 그리고 그 4GB 구간 안의 세그먼트 순번 8자리(16MB 세그먼트면 00~FF)입니다(XLogFileName()). LSN을 보면 몇 번 파일의 몇 번째 바이트인지 바로 계산할 수 있고, pg_walfile_name()이 그 계산을 해 줍니다. 타임라인은 복구나 standby 승격 때 WAL의 \u0026ldquo;갈래\u0026quot;를 나누는 번호입니다(8편).\nLSN과 WAL 세그먼트 파일 서버를 띄우고, 이 글 내내 쓸 acct 테이블을 만든 뒤 CHECKPOINT를 해 둡니다. WAL 레코드를 보는 데는 contrib 확장 pg_walinspect와 명령줄 도구 pg_waldump를 씁니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION pg_walinspect; postgres=# CREATE TABLE acct (id int PRIMARY KEY, balance int, memo text); postgres=# INSERT INTO acct SELECT g, 100, \u0026#39;init\u0026#39; FROM generate_series(1, 1000) g; postgres=# CHECKPOINT; postgres=# SELECT pg_current_wal_insert_lsn() AS insert_lsn, pg_current_wal_lsn() AS write_lsn, pg_current_wal_flush_lsn() AS flush_lsn; insert_lsn | write_lsn | flush_lsn ------------+-----------+----------- 0/17E0A58 | 0/17E0A58 | 0/17E0A58 (1 row) postgres=# SELECT pg_walfile_name(pg_current_wal_insert_lsn()) AS segment_file, postgres-# pg_walfile_name_offset(pg_current_wal_insert_lsn()) AS file_and_offset; segment_file | file_and_offset --------------------------+------------------------------------ 000000010000000000000001 | (000000010000000000000001,8260184) (1 row) postgres=# SHOW wal_segment_size; wal_segment_size ------------------ 16MB (1 row) $ ls -l $PGDATA/pg_wal | head -5 total 16392 -rw------- 1 postgres postgres 16777216 Sep 26 10:09 000000010000000000000001 drwx------ 2 postgres postgres 4096 Sep 26 10:09 archive_status drwx------ 2 postgres postgres 4096 Sep 26 10:09 summaries 지금 LSN은 0/17E0A58이고, 쉬고 있는 서버라 insert, write, flush 위치가 모두 같습니다. 0x17E0A58 = 25037400는 16MB(16777216) 세그먼트 하나를 넘은 위치라 두 번째 파일, 이름으로는 000000010000000000000001의 8260184번째 바이트입니다(타임라인 1, 세그먼트 번호 1). LSN 0을 \u0026ldquo;무효\u0026quot;라는 뜻으로 쓰기 위해 initdb가 첫 세그먼트(...0000)를 건너뛰므로, WAL은 처음부터 ...0001에서 시작합니다(xlogdefs.h).\n다 쓴 세그먼트는 체크포인트 뒤에 필요가 없어지면, 지우는 대신 앞으로 쓸 번호의 이름으로 바꿔 재활용합니다. 파일을 새로 만드는 비용을 아끼려는 것입니다.\nWAL 세그먼트는 재활용된다 postgres=# SHOW min_wal_size; min_wal_size -------------- 80MB (1 row) postgres=# SHOW max_wal_size; max_wal_size -------------- 1GB (1 row) postgres=# SELECT count(*) AS segments, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir(); segments | total ----------+------- 4 | 64 MB (1 row) postgres=# CREATE TABLE bulk AS SELECT g AS id, repeat(\u0026#39;w\u0026#39;, 200) AS pad FROM generate_series(1, 400000) g; postgres=# SELECT count(*) AS segments, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir(); segments | total ----------+-------- 10 | 160 MB (1 row) postgres=# SELECT pg_walfile_name(pg_current_wal_insert_lsn()) AS current_segment; 00000001000000000000000A $ ls $PGDATA/pg_wal | grep -v -e archive_status -e summaries | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; echo 000000010000000000000001 000000010000000000000002 000000010000000000000003 000000010000000000000004 000000010000000000000005 000000010000000000000006 000000010000000000000007 000000010000000000000008 000000010000000000000009 00000001000000000000000A postgres=# CHECKPOINT; postgres=# CHECKPOINT; postgres=# SELECT count(*) AS segments, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir(); segments | total ----------+-------- 10 | 160 MB (1 row) $ ls $PGDATA/pg_wal | grep -v -e archive_status -e summaries | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; echo 00000001000000000000000A 00000001000000000000000B 00000001000000000000000C 00000001000000000000000D 00000001000000000000000E 00000001000000000000000F 000000010000000000000010 000000010000000000000011 000000010000000000000012 000000010000000000000013 대량 INSERT로 WAL이 늘자 세그먼트가 4개에서 10개(160MB)가 되었습니다. 체크포인트 뒤 파일 수는 그대로 10개인데, 이름이 …01-…0A에서 …0A-…13으로 바뀌었습니다. 다 쓴 …01-…09를 지우지 않고 앞으로 쓸 …0B-…13으로 이름을 바꿔 준비해 둔 것입니다. 이렇게 남겨 두는 양은 min_wal_size(80MB)와 최근 WAL 사용량으로 정하고, 체크포인트 사이의 WAL은 대략 max_wal_size(1GB)를 넘지 않도록 체크포인트를 앞당깁니다(8편). 운영에서는: WAL 디렉터리가 가득 차면 서버가 멈춘다 WAL을 쓸 자리가 없으면 PostgreSQL은 PANIC으로 멈춥니다. 보통은 체크포인트가 오래된 세그먼트를 재활용하므로 max_wal_size(soft limit) 이하로 유지되지만, 아카이빙이 실패하거나(archive_command), replication slot이 오래된 WAL을 붙잡거나(9편), wal_keep_size가 크면 지울 수 없는 WAL이 계속 쌓입니다. 커밋된 트랜잭션을 잃지는 않지만 공간을 비울 때까지 서버를 다시 켤 수 없습니다(Continuous Archiving). pg_wal 디렉터리의 크기와 pg_stat_archiver의 failed_count를 모니터링해야 합니다.\nWAL 레코드의 구조 변경 하나는 WAL 레코드 하나 이상이 됩니다. 레코드의 전체 배치는 xlogrecord.h에 정의되어 있습니다.\n아래 full page write 실습에서 볼 UPDATE 레코드. 고정 헤더 뒤에 건드린 블록마다 블록 헤더가 오고, 헤더가 모두 끝난 뒤 블록 데이터와 main data가 이어집니다. 새 창에서 크게 보기 고정 헤더(XLogRecord, 24바이트): 레코드 전체 길이, 트랜잭션 ID, 바로 앞 레코드의 위치(xl_prev), 어떤 종류의 레코드인지(xl_rmid, xl_info), 레코드 전체의 CRC. 블록 헤더(XLogRecordBlockHeader): 이 레코드가 건드린 페이지마다 하나씩. 어느 파일의 몇 번 블록인지, 페이지 이미지가 붙어 있는지를 적습니다. 이미지 헤더(XLogRecordBlockImageHeader): 페이지 전체 이미지가 붙어 있을 때, 그 길이와 hole(페이지 가운데 빈 공간, 즉 pd_lower와 pd_upper 사이)의 위치. 블록 데이터와 main data: 실제 변경 내용. \u0026ldquo;몇 번 line pointer에 이런 튜플을 넣어라\u0026rdquo; 같은 정보입니다. 블록에 페이지 이미지가 붙으면 이미지가 이미 새 내용을 담고 있으므로 그 블록의 데이터는 보통 생략합니다(xloginsert.c). 어떤 종류의 레코드인지는 resource manager(rmgr)로 나뉩니다(rmgrlist.h). 테이블 변경은 Heap, Heap2, 인덱스는 Btree, 커밋은 Transaction, 체크포인트나 페이지 이미지는 XLOG 식입니다. 장애 복구 때는 rmgr마다 정해진 재생(redo) 함수가 레코드를 해석해 페이지에 다시 적용합니다.\nINSERT 하나가 남기는 WAL 레코드 행 하나를 두 번 넣고, 각각 WAL을 얼마나 남겼는지 봅니다.\npostgres=# SELECT pg_current_wal_insert_lsn() AS s1 \\gset postgres=# INSERT INTO acct VALUES (1001, 100, \u0026#39;hello\u0026#39;); INSERT 0 1 postgres=# SELECT pg_current_wal_insert_lsn() AS e1 \\gset postgres=# INSERT INTO acct VALUES (1002, 100, \u0026#39;world\u0026#39;); INSERT 0 1 postgres=# SELECT pg_current_wal_insert_lsn() AS e2 \\gset postgres=# SELECT \u0026#39;first\u0026#39; AS insert, pg_wal_lsn_diff(:\u0026#39;e1\u0026#39;, :\u0026#39;s1\u0026#39;) AS wal_bytes postgres-# UNION ALL SELECT \u0026#39;second\u0026#39;, pg_wal_lsn_diff(:\u0026#39;e2\u0026#39;, :\u0026#39;e1\u0026#39;); insert | wal_bytes --------+----------- first | 8968 second | 176 (2 rows) postgres=# SELECT start_lsn, xid, resource_manager AS rmgr, record_type, record_length AS len, fpi_length AS fpi, description postgres-# FROM pg_get_wal_records_info(:\u0026#39;s1\u0026#39;, :\u0026#39;e2\u0026#39;); start_lsn | xid | rmgr | record_type | len | fpi | description -----------+-----+-------------+-------------+------+------+------------------------------- 0/17E0A58 | 755 | Heap | INSERT | 3422 | 3368 | off: 76, flags: 0x00 0/17E17B8 | 755 | Btree | INSERT_LEAF | 5473 | 5420 | off: 269 0/17E2D38 | 755 | Transaction | COMMIT | 34 | 0 | 2026-09-26 10:09:58.565017+00 0/17E2D60 | 756 | Heap | INSERT | 69 | 0 | off: 77, flags: 0x00 0/17E2DA8 | 756 | Btree | INSERT_LEAF | 64 | 0 | off: 270 0/17E2DE8 | 756 | Transaction | COMMIT | 34 | 0 | 2026-09-26 10:09:58.565349+00 (6 rows) 같은 INSERT인데 첫 번째는 8968바이트, 두 번째는 176바이트입니다.\n첫 번째 INSERT는 앞에서 테이블을 만들고 한 CHECKPOINT 뒤로 이 테이블 페이지와 인덱스 페이지를 처음 고친 것이라, 두 레코드 모두 페이지 이미지를 달고 있습니다(fpi 3368, 5420). 레코드 세 개는 힙에 튜플 넣기(Heap INSERT), 인덱스에 항목 넣기(Btree INSERT_LEAF), 커밋(Transaction COMMIT)입니다. 두 번째 INSERT는 같은 페이지를 다시 고친 것이라 이미지가 없고 레코드 크기가 69, 64, 34바이트뿐입니다. 레코드 길이를 더한 값(3422 + 5473 + 34)보다 LSN 차이(8968)가 조금 큰 것은, 레코드가 8바이트 단위로 정렬되고 WAL 페이지(8kB)마다 페이지 헤더가 끼기 때문입니다. 같은 레코드를 pg_waldump로도 볼 수 있습니다. 행 하나를 더 넣고, 그 INSERT 전후의 insert LSN인 0/17EA970부터 0/17EAA20까지를 읽습니다.\npostgres=# INSERT INTO acct VALUES (1003, 100, \u0026#39;waldump\u0026#39;); $ pg_waldump -p $PGDATA/pg_wal -s 0/17EA970 -e 0/17EAA20 rmgr: Heap len (rec/tot): 71/ 71, tx: 757, lsn: 0/017EA970, prev 0/017E9E80, desc: INSERT off: 78, flags: 0x00, blkref #0: rel 1663/5/16391 blk 5 rmgr: Btree len (rec/tot): 64/ 64, tx: 757, lsn: 0/017EA9B8, prev 0/017EA970, desc: INSERT_LEAF off: 271, blkref #0: rel 1663/5/16397 blk 4 rmgr: Transaction len (rec/tot): 34/ 34, tx: 757, lsn: 0/017EA9F8, prev 0/017EA9B8, desc: COMMIT 2026-09-26 10:09:58.617179 UTC 각 줄은 레코드 하나입니다. len (rec/tot)은 레코드 길이, tx는 xid, lsn은 위치, prev는 바로 앞 레코드의 위치이고, blkref #0: rel 1663/5/16391 blk 5는 테이블스페이스 1663, DB 5, 파일 16391의 5번 블록을 건드렸다는 뜻입니다. prev를 따라가면 레코드가 한 줄로 이어집니다.\n어떤 종류의 WAL이 쌓였나 initdb 이후 쌓인 WAL 전체를 resource manager별로 집계합니다. 뒤의 pgbench 실습까지 마친 뒤의 집계입니다.\npostgres=# SELECT \u0026#34;resource_manager/record_type\u0026#34; AS rmgr, count, round(count_percentage::numeric, 1) AS count_pct, postgres-# pg_size_pretty(combined_size) AS bytes, round(combined_size_percentage::numeric, 1) AS bytes_pct, postgres-# round(fpi_size_percentage::numeric, 1) AS fpi_pct postgres-# FROM pg_get_wal_stats(\u0026#39;0/1000000\u0026#39;, pg_current_wal_lsn()) postgres-# WHERE count \u0026gt; 0 ORDER BY combined_size DESC; rmgr | count | count_pct | bytes | bytes_pct | fpi_pct -------------+--------+-----------+------------+-----------+--------- Heap | 400882 | 49.1 | 24 MB | 46.7 | 14.6 Btree | 215549 | 26.4 | 16 MB | 32.2 | 13.2 Transaction | 196029 | 24.0 | 6694 kB | 13.0 | 0.0 Heap2 | 2814 | 0.3 | 2090 kB | 4.1 | 31.9 XLOG | 393 | 0.0 | 2060 kB | 4.0 | 40.3 Standby | 449 | 0.1 | 25 kB | 0.0 | 0.0 Storage | 28 | 0.0 | 1176 bytes | 0.0 | 0.0 CLOG | 6 | 0.0 | 204 bytes | 0.0 | 0.0 Database | 2 | 0.0 | 84 bytes | 0.0 | 0.0 (9 rows) pgbench의 INSERT가 대부분이라 Heap(튜플), Btree(인덱스), Transaction(커밋) 순서입니다. XLOG는 레코드 수가 393개로 적지만 바이트의 4.0%를 차지합니다. fpi_pct는 그 rmgr 안의 비율이 아니라 전체 페이지 이미지 바이트 중 그 rmgr 몫인데(pg_walinspect.c), 전체 이미지의 40.3%가 XLOG 레코드(주로 hint bit 때문에 생기는 FPI_FOR_HINT)에서 나왔습니다. 쓰기를 하지 않았는데 생긴 이미지가 가장 큰 몫이라는 뜻입니다. 맨 아래 Database 2건은 initdb가 데이터베이스를 만들 때 남긴 기록입니다. WAL이 예상보다 많이 쌓인다면 이렇게 종류별로 나눠 보는 것이 원인을 찾는 첫걸음입니다.\nfull page write: 페이지 전체를 남기는 이유 디스크는 8kB 페이지를 한 번에 쓰지 못할 수 있습니다. 운영체제와 디스크의 쓰기 단위(보통 4kB나 512바이트)가 더 작기 때문입니다. 페이지를 쓰는 도중 전원이 나가면 앞 절반은 새 내용, 뒤 절반은 옛 내용인 깨진 페이지(torn page)가 남을 수 있습니다. 이 페이지에 \u0026ldquo;3번 line pointer에 튜플 추가\u0026rdquo; 같은 작은 WAL 레코드를 적용해 봐야 페이지 자체가 망가져 있으니 소용이 없습니다.\n그래서 PostgreSQL은 체크포인트 뒤에 어떤 페이지를 처음 고칠 때, 그 페이지 전체를 WAL에 함께 남깁니다. 이것이 full page write(FPW), 또는 full page image(FPI)입니다. 복구할 때는 이 이미지로 페이지를 통째로 되살린 뒤, 그 뒤의 레코드를 차례로 적용합니다. 판단 기준은 간단합니다. 페이지의 LSN이 마지막 체크포인트의 REDO 위치보다 작거나 같으면, 즉 체크포인트 뒤로 한 번도 안 바뀌었으면 이미지를 붙입니다(xloginsert.c).\nfull page write: 체크포인트 뒤 첫 수정 CHECKPOINT 직후 id = 1을 바꾸고, 이어서 같은 페이지의 id = 2를 바꿉니다.\npostgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_current_wal_insert_lsn() AS s1 \\gset postgres=# UPDATE acct SET balance = balance + 1 WHERE id = 1; UPDATE 1 postgres=# SELECT pg_current_wal_insert_lsn() AS e1 \\gset postgres=# UPDATE acct SET balance = balance + 1 WHERE id = 2; UPDATE 1 postgres=# SELECT pg_current_wal_insert_lsn() AS e2 \\gset postgres=# SELECT \u0026#39;first update after checkpoint\u0026#39; AS which, resource_manager AS rmgr, record_type, record_length AS len, fpi_length AS fpi, block_ref postgres-# FROM pg_get_wal_records_info(:\u0026#39;s1\u0026#39;, :\u0026#39;e1\u0026#39;) postgres-# UNION ALL postgres-# SELECT \u0026#39;second update (same page)\u0026#39;, resource_manager, record_type, record_length, fpi_length, block_ref postgres-# FROM pg_get_wal_records_info(:\u0026#39;e1\u0026#39;, :\u0026#39;e2\u0026#39;); which | rmgr | record_type | len | fpi | block_ref -------------------------------+-------------+-----------------+------+------+-------------------------------------------------------------------------------------------------------------------------------- first update after checkpoint | XLOG | FPI_FOR_HINT | 8213 | 8164 | blkref #0: rel 1663/5/16391 fork main blk 0 (FPW); hole: offset: 764, length: 28 first update after checkpoint | Heap | LOCK | 54 | 0 | blkref #0: rel 1663/5/16391 fork main blk 0 first update after checkpoint | Heap | UPDATE | 3573 | 3500 | blkref #0: rel 1663/5/16391 fork main blk 5 (FPW); hole: offset: 340, length: 4692 blkref #1: rel 1663/5/16391 fork main blk 0 first update after checkpoint | Btree | INSERT_LEAF | 7453 | 7400 | blkref #0: rel 1663/5/16397 fork main blk 1 (FPW); hole: offset: 1496, length: 792 first update after checkpoint | Transaction | COMMIT | 34 | 0 | second update (same page) | Heap2 | PRUNE_ON_ACCESS | 56 | 0 | blkref #0: rel 1663/5/16391 fork main blk 0 second update (same page) | Heap | HOT_UPDATE | 71 | 0 | blkref #0: rel 1663/5/16391 fork main blk 0 second update (same page) | Transaction | COMMIT | 34 | 0 | (8 rows) 체크포인트 뒤의 첫 UPDATE가 남긴 레코드입니다.\n레코드 크기 무엇인가 XLOG FPI_FOR_HINT 8213 UPDATE할 행을 찾으며 blk 0의 hint bit를 적었고, 체크섬이 켜져 있어 blk 0 전체 이미지를 남김 Heap LOCK 54 blk 0에 자리가 없어 다른 페이지를 찾는 동안 페이지 락을 잠시 풀어야 하므로, 그사이 옛 버전을 임시로 잠갔다고 기록 Heap UPDATE 3573 새 버전이 들어갈 blk 5의 이미지(FPW, 3500바이트, hole 4692바이트 제외). 이미지가 새 튜플을 이미 담고 있어 튜플 데이터는 생략되고 main data만 붙음. 옛 버전이 있는 blk 0(blkref #1)은 바로 앞 FPI_FOR_HINT가 이미 이미지를 남겼으므로 참조만 함 Btree INSERT_LEAF 7453 새 버전을 가리킬 인덱스 항목. 인덱스 페이지도 체크포인트 뒤 처음이라 이미지 포함 이 UPDATE는 blk 0에 자리가 없어 새 버전을 blk 5에 넣었고, 그래서 HOT가 아닌 일반 UPDATE가 되어 인덱스 항목도 새로 만들었습니다(5편). 행 하나를 바꿨는데 WAL은 약 19kB가 생겼습니다.\n반면 두 번째 UPDATE는 페이지 정리(PRUNE_ON_ACCESS) 56바이트와 HOT_UPDATE 71바이트뿐입니다. 첫 UPDATE가 커밋되어 id = 1의 옛 버전은 누구에게도 보이지 않게 되었고, 이번에 blk 0을 읽으면서 그 옛 버전을 치웠습니다. 그렇게 생긴 자리에 새 버전이 들어가 같은 페이지 안의 HOT 업데이트가 되었으므로 인덱스는 건드리지 않았습니다. blk 0은 체크포인트 뒤에 이미 한 번 이미지를 남긴 페이지라 이번에는 이미지도 필요 없습니다.\n이미지를 줄이는 방법 이미지를 남길 때 가운데 빈 공간(hole)은 빼고 저장해서 크기를 줄입니다. wal_compression을 켜면 이미지를 압축합니다(pglz, 그리고 빌드 옵션에 따라 lz4, zstd). full_page_writes와 wal_compression이 WAL 양에 주는 영향 설정마다 같은 조건을 만들기 위해 1000행짜리 테이블을 새로 만들고 VACUUM한 뒤, CHECKPOINT 직후 100행을 바꿉니다. 그 UPDATE가 남긴 WAL 범위만 pg_get_wal_records_info()로 집계해, 페이지 이미지(fpi_bytes)와 나머지(other_bytes)를 나눠 봅니다.\n$ cat \u0026gt; /home/postgres/fpw.sql \u0026lt;\u0026lt;\u0026#39;SQL\u0026#39; \u003e DROP TABLE IF EXISTS fpw_t; \u003e CREATE TABLE fpw_t (id int PRIMARY KEY, balance int, memo text); \u003e INSERT INTO fpw_t SELECT g, 100, \u0026#39;init\u0026#39; FROM generate_series(1, 1000) g; \u003e VACUUM fpw_t; \u003e CHECKPOINT; \u003e SELECT pg_current_wal_insert_lsn() AS s \\gset \u003e UPDATE fpw_t SET balance = balance + 1 WHERE id % 10 = 0; \u003e SELECT pg_current_wal_insert_lsn() AS e \\gset \u003e SELECT current_setting(\u0026#39;full_page_writes\u0026#39;) AS fpw, current_setting(\u0026#39;wal_compression\u0026#39;) AS compression, \u003e count(*) AS records, count(*) FILTER (WHERE fpi_length \u0026gt; 0) AS with_fpi, \u003e sum(fpi_length) AS fpi_bytes, sum(record_length - fpi_length) AS other_bytes, \u003e pg_wal_lsn_diff(:\u0026#39;e\u0026#39;, :\u0026#39;s\u0026#39;) AS wal_bytes \u003e FROM pg_get_wal_records_info(:\u0026#39;s\u0026#39;, :\u0026#39;e\u0026#39;); \u003e SQL postgres=# \\i /home/postgres/fpw.sql psql:/home/postgres/fpw.sql:1: NOTICE: table \u0026#34;fpw_t\u0026#34; does not exist, skipping fpw | compression | records | with_fpi | fpi_bytes | other_bytes | wal_bytes -----+-------------+---------+----------+-----------+-------------+----------- on | off | 285 | 9 | 72600 | 19485 | 92648 (1 row) postgres=# ALTER SYSTEM SET wal_compression = \u0026#39;pglz\u0026#39;; postgres=# SELECT pg_reload_conf(); postgres=# \\i /home/postgres/fpw.sql fpw | compression | records | with_fpi | fpi_bytes | other_bytes | wal_bytes -----+-------------+---------+----------+-----------+-------------+----------- on | pglz | 285 | 9 | 23864 | 19503 | 43800 (1 row) postgres=# ALTER SYSTEM SET wal_compression = \u0026#39;off\u0026#39;; postgres=# ALTER SYSTEM SET full_page_writes = \u0026#39;off\u0026#39;; postgres=# SELECT pg_reload_conf(); postgres=# \\i /home/postgres/fpw.sql fpw | compression | records | with_fpi | fpi_bytes | other_bytes | wal_bytes -----+-------------+---------+----------+-----------+-------------+----------- off | off | 285 | 0 | 0 | 19502 | 19840 (1 row) postgres=# ALTER SYSTEM RESET full_page_writes; postgres=# ALTER SYSTEM RESET wal_compression; postgres=# SELECT pg_reload_conf(); 설정 WAL 레코드 이미지가 붙은 레코드 이미지 바이트 나머지 바이트 WAL 크기 기본값 (full_page_writes = on) 285 9 72600 19485 92648 wal_compression = pglz 285 9 23864 19503 43800 full_page_writes = off 285 0 0 19502 19840 세 번 모두 레코드 수와 이미지를 뺀 나머지 크기(약 19.5kB)는 같고, 달라진 것은 페이지 이미지뿐입니다. 이미지가 붙은 레코드는 9개뿐인데 WAL의 78%(72600 / 92648)를 차지합니다. pglz로 압축하면 이미지가 1/3로 줄어 WAL 전체가 절반 아래가 되고, 끄면 이미지가 사라져 약 1/5이 됩니다. 다만 full_page_writes = off는 torn page가 생겨도 복구할 수 없게 만드므로, 디스크가 원자적으로 8kB를 쓴다고 보장되는 환경(일부 파일 시스템이나 스토리지)이 아니면 쓰면 안 됩니다. 이 실습에서도 확인만 하고 바로 되돌렸습니다.\n운영에서는: 체크포인트 직후 WAL이 치솟는다 체크포인트 뒤 첫 수정과 위 실습에서 본 것처럼, 체크포인트 직후에는 모든 페이지가 \u0026ldquo;처음 고치는 페이지\u0026quot;라 페이지 이미지가 쏟아집니다. 쓰기가 많은 시스템에서 체크포인트마다 WAL 생성량이 톱니 모양으로 치솟는 것이 이 때문입니다. 체크포인트 간격(checkpoint_timeout, max_wal_size)을 늘리면 이미지가 덜 생기고, wal_compression을 켜면 크기가 줄어듭니다. 대신 체크포인트 간격이 길수록 장애 복구 시간은 길어집니다(8편).\nhint bit만 바꿔도 이미지가 남는다 체크섬이 켜져 있거나 wal_log_hints = on이면, 데이터를 바꾸지 않고 hint bit만 바꿔도 체크포인트 뒤 첫 변경이면 이미지를 남깁니다(XLogSaveBufferForHint()). hint bit만 바뀐 페이지가 깨지면 체크섬이 맞지 않게 되기 때문입니다.\n체크섬이 켜져 있으면 SELECT도 WAL을 남긴다 새 행 200개를 넣고 CHECKPOINT한 뒤, 읽기만 합니다.\npostgres=# SHOW data_checksums; data_checksums ---------------- on (1 row) postgres=# INSERT INTO acct SELECT g, 100, \u0026#39;new\u0026#39; FROM generate_series(2001, 2200) g; INSERT 0 200 postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_current_wal_insert_lsn() AS s \\gset postgres=# SELECT count(*) FROM acct WHERE id \u0026gt; 2000; count ------- 200 (1 row) postgres=# SELECT pg_current_wal_insert_lsn() AS e \\gset postgres=# SELECT pg_wal_lsn_diff(:\u0026#39;e\u0026#39;, :\u0026#39;s\u0026#39;) AS wal_bytes_by_select, postgres-# pg_wal_lsn_diff(:\u0026#39;e\u0026#39;, pg_current_wal_lsn()) AS not_yet_written; wal_bytes_by_select | not_yet_written ---------------------+----------------- 53728 | 53728 (1 row) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT resource_manager AS rmgr, record_type, count(*), sum(fpi_length) AS fpi_bytes postgres-# FROM pg_get_wal_records_info(:\u0026#39;s\u0026#39;, :\u0026#39;e\u0026#39;) GROUP BY 1, 2; rmgr | record_type | count | fpi_bytes -------+-----------------+-------+----------- Heap2 | PRUNE_ON_ACCESS | 1 | 0 XLOG | FPI_FOR_HINT | 7 | 53148 (2 rows) postgres=# SELECT record_type, fpi_length AS fpi, block_ref FROM pg_get_wal_records_info(:\u0026#39;s\u0026#39;, :\u0026#39;e\u0026#39;) WHERE fpi_length \u0026gt; 0; record_type | fpi | block_ref --------------+------+------------------------------------------------------------------------------------ FPI_FOR_HINT | 8168 | blkref #0: rel 1663/5/16391 fork main blk 0 (FPW); hole: offset: 768, length: 24 FPI_FOR_HINT | 8164 | blkref #0: rel 1663/5/16391 fork main blk 1 (FPW); hole: offset: 764, length: 28 FPI_FOR_HINT | 8164 | blkref #0: rel 1663/5/16391 fork main blk 2 (FPW); hole: offset: 764, length: 28 FPI_FOR_HINT | 8164 | blkref #0: rel 1663/5/16391 fork main blk 3 (FPW); hole: offset: 764, length: 28 FPI_FOR_HINT | 8164 | blkref #0: rel 1663/5/16391 fork main blk 4 (FPW); hole: offset: 764, length: 28 FPI_FOR_HINT | 8164 | blkref #0: rel 1663/5/16391 fork main blk 5 (FPW); hole: offset: 764, length: 28 FPI_FOR_HINT | 4160 | blkref #0: rel 1663/5/16391 fork main blk 6 (FPW); hole: offset: 400, length: 4032 (7 rows) postgres=# SELECT (ctid::text::point)[0]::int AS blk, count(*) AS new_rows FROM acct WHERE id \u0026gt; 2000 GROUP BY 1 ORDER BY 1; blk | new_rows -----+---------- 5 | 106 6 | 94 (2 rows) postgres=# EXPLAIN (COSTS OFF) SELECT count(*) FROM acct WHERE id \u0026gt; 2000; QUERY PLAN ----------------------------- Aggregate -\u0026gt; Seq Scan on acct Filter: (id \u0026gt; 2000) (3 rows) SELECT 한 번이 WAL을 53728바이트 남겼습니다. 그 대부분은 FPI_FOR_HINT 일곱 개(53148바이트)입니다. 어느 페이지인지는 block_ref에 나옵니다. 조건이 id \u0026gt; 2000이지만 마지막 EXPLAIN에서 보듯 인덱스 대신 테이블 전체(blk 06)를 순차 스캔(Seq Scan)했고, 그러면서 아직 hint bit가 없던 튜플들에 hint bit를 적었습니다(4편). 새 행이 있는 blk 5, 6만이 아니라, 앞선 실습에서 넣고 바꾼 행이 있는 blk 04도 포함됩니다. PG18은 체크섬이 기본으로 켜져 있어(3편) hint bit만 바뀐 페이지도 체크포인트 뒤 첫 변경이면 이미지를 남깁니다. PRUNE_ON_ACCESS 한 개는 앞선 실습의 UPDATE가 남긴 옛 버전을 이 SELECT가 페이지를 읽으면서 정리한 기록입니다(5편).\nnot_yet_written이 53728이라는 점도 눈여겨볼 만합니다. 이 WAL은 SELECT가 끝난 시점에 WAL buffers에만 있었고 아직 운영체제에 넘어가지도 않았습니다. SELECT는 xid가 없어 커밋할 때 flush할 필요가 없기 때문입니다. 그래서 이 글의 실습은 WAL 양을 모두 pg_current_wal_lsn()(쓴 위치)이 아니라 pg_current_wal_insert_lsn()(넣은 위치)으로 잽니다.\n운영에서는: 읽기만 하는데 WAL이 생긴다 대량 적재 직후나 복구 직후에 SELECT만 도는데 WAL이 계속 생긴다면, 위 실습에서 본 hint bit 이미지(FPI_FOR_HINT)일 가능성이 큽니다. PG18에서 새로 만든 클러스터는 체크섬이 기본으로 켜져 있으므로 이 현상이 전보다 흔할 수 있습니다. 대량 적재 뒤 VACUUM을 한 번 돌려 두면 hint bit와 페이지 정리를 한꺼번에 끝낼 수 있습니다. standby가 있다면 이 WAL이 그대로 복제 트래픽이 된다는 점도 기억해 둘 만합니다.\n커밋: WAL이 디스크에 닿을 때까지 기다린다 변경은 메모리에서 끝나고, 커밋은 WAL이 디스크에 닿을 때까지만 기다립니다. 데이터 페이지는 나중에 WAL보다 뒤에 씁니다. 새 창에서 크게 보기 backend가 페이지를 고치면서 WAL 레코드를 만들어 XLogInsert()로 WAL buffers에 넣습니다. 여러 backend가 동시에 넣을 수 있도록, 먼저 WAL 안의 자리를 예약하고(ReserveXLogInsertLocation()) 그 자리에 복사합니다. 복사는 WAL 삽입 락 8개로 나눠 병렬로 합니다. 페이지 헤더의 LSN을 방금 넣은 레코드의 끝 위치로 올립니다. COMMIT하면 커밋 레코드를 넣고, XLogFlush()로 그 위치까지 WAL을 디스크에 쓰고 fsync합니다(RecordTransactionCommit()). 이게 끝나야 클라이언트에 \u0026ldquo;커밋 완료\u0026quot;를 돌려줍니다. 데이터 페이지는 아직 메모리에만 있습니다. 나중에 checkpointer나 bgwriter가 dirty 페이지를 쓸 때, 그 페이지 LSN까지 WAL이 디스크에 있는지 확인하고(없으면 먼저 flush) 씁니다(2편). synchronous_commit = off로 두면 3단계의 XLogFlush()를 아예 건너뛰고 바로 \u0026ldquo;커밋 완료\u0026quot;를 돌려줍니다. WAL을 쓰고 fsync하는 일은 walwriter가 곧 합니다. (동기 복제를 쓰면 커밋은 standby의 응답도 기다리는데, 이것은 9편에서 다룹니다.) 문서에 따르면 위험 구간은 최대 wal_writer_delay(기본 200ms)의 세 배입니다(Asynchronous Commit). 빠르지만 그 짧은 사이에 서버가 죽으면 이미 커밋 완료를 받은 트랜잭션이 사라질 수 있습니다. 데이터가 깨지지는 않고, 마지막 몇 개의 커밋만 없던 일이 됩니다.\nsynchronous_commit에 따른 처리량 차이 행 하나를 넣는 트랜잭션을 5초 동안 반복하는 pgbench를 synchronous_commit on, off로 돌립니다.\n$ cat \u0026gt; /home/postgres/one.sql \u0026lt;\u0026lt;\u0026#39;SQL\u0026#39; \u003e INSERT INTO acct VALUES (100000 + random() * 1000000000, 1, \u0026#39;x\u0026#39;) ON CONFLICT DO NOTHING; \u003e SQL postgres=# SHOW synchronous_commit; synchronous_commit -------------------- on (1 row) $ pgbench -n -c 1 -T 5 -f /home/postgres/one.sql postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#34;number of transactions actually processed|latency average|tps\u0026#34; number of transactions actually processed: 56673 latency average = 0.088 ms tps = 11330.980885 (without initial connection time) $ PGOPTIONS=\u0026#39;-c synchronous_commit=off\u0026#39; pgbench -n -c 1 -T 5 -f /home/postgres/one.sql postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#34;number of transactions actually processed|latency average|tps\u0026#34; number of transactions actually processed: 141476 latency average = 0.035 ms tps = 28298.239231 (without initial connection time) synchronous_commit = off에서 처리량이 약 2.5배(11330 → 28298 tps)가 되었습니다. 커밋마다 WAL을 디스크에 fsync하고 기다리던 시간이 빠졌기 때문입니다. 이 실습 환경은 가상 디스크라 fsync가 비교적 빠른 편이고, 실제 서버의 차이는 디스크에 따라 훨씬 클 수도 작을 수도 있습니다.\n운영에서는: synchronous_commit은 트랜잭션 단위로 고를 수 있다 synchronous_commit은 세션이나 트랜잭션 단위로 바꿀 수 있습니다. 로그, 통계 수집처럼 마지막 몇 건을 잃어도 괜찮은 쓰기에만 SET LOCAL synchronous_commit = off를 쓰면, 중요한 트랜잭션의 안전성은 그대로 두고 전체 처리량을 높일 수 있습니다. 반대로 fsync = off는 커밋 손실이 아니라 데이터 손상을 일으킬 수 있으므로 운영 환경에서는 절대 쓰면 안 됩니다.\n정리 WAL은 데이터 파일보다 먼저 쓰는 변경 기록이고, LSN은 그 안의 위치입니다. WAL은 16MB 세그먼트 파일로 나뉘고, 파일 이름은 타임라인과 세그먼트 번호입니다. WAL 레코드는 고정 헤더 → 블록 헤더들 → 블록 데이터 → main data 구조이고, resource manager별로 종류가 나뉩니다. 체크포인트 뒤 페이지를 처음 고칠 때는 torn page에 대비해 페이지 전체 이미지(FPW)를 함께 남기고, 이것이 WAL의 대부분을 차지할 수 있습니다. 체크섬이 켜져 있으면 hint bit 변경도 이미지를 남깁니다. 커밋은 커밋 레코드까지 WAL이 디스크에 flush될 때까지 기다립니다. synchronous_commit = off는 이 대기를 없애는 대신 마지막 커밋 몇 건을 잃을 수 있습니다. 다 쓴 세그먼트는 지우지 않고 이름을 바꿔 재활용합니다. 다음 글에서는 WAL이 실제로 쓰이는 순간, 체크포인트와 장애 복구 과정을 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/include/access/xlogrecord.h: WAL 레코드 구조 src/backend/access/transam/xloginsert.c: 레코드 조립, full page image 판단 src/backend/access/transam/xlog.c: WAL 삽입, flush src/backend/access/transam/README: WAL 설계 설명 src/include/access/rmgrlist.h: resource manager 목록 PostgreSQL 18 공식 문서\nWrite-Ahead Logging (WAL) WAL Internals Asynchronous Commit WAL 설정 pg_walinspect, pg_waldump ","permalink":"https://jinlog.dev/posts/postgresql/07-wal/","summary":"모든 변경은 왜 먼저 WAL에 적히고, WAL 레코드에는 무엇이 들어 있는가","title":"PostgreSQL 인터널 7: WAL"},{"content":"개요 5편에서 secondary는 primary의 oplog를 가져와 적용하며 따라가고, 6편에서 primary가 사라지면 남은 멤버가 새 primary를 뽑는 것을 봤습니다. 복제는 비동기라서, 어느 순간이든 primary에만 있고 secondary에는 아직 없는 쓰기가 있습니다. 그 순간 primary가 바뀌면 그 쓰기는 어떻게 될까요? 그리고 클라이언트는 자기 쓰기가 그런 상태인지 아닌지를 어떻게 알 수 있을까요?\nMongoDB는 이 질문을 write concern과 read concern으로 클라이언트에게 넘깁니다. write concern은 \u0026ldquo;쓰기 응답을 언제 돌려받을지\u0026rdquo;, read concern은 \u0026ldquo;읽을 때 어느 시점까지 확정된 데이터를 볼지\u0026quot;를 요청마다 정하는 옵션입니다. 둘 다 서버가 데이터를 다르게 쓰거나 복제하게 만드는 옵션이 아닙니다. 무엇을 기다린 뒤에 응답하고, 어느 스냅샷에서 읽을지만 바꿉니다. 이 글은 그 \u0026ldquo;기다림\u0026quot;과 \u0026ldquo;스냅샷\u0026quot;이 소스에서 무엇인지, 그리고 그 보장을 벗어난 쓰기가 실제로 사라지는 모습(rollback)을 차례로 봅니다.\n이 글에서 답할 질문은 다음과 같습니다.\nw, j, wtimeout은 쓰기 응답 전에 각각 무엇을 기다리게 하는가 \u0026ldquo;과반에 복제되었다\u0026quot;는 판단(majority commit point)은 어디서, 어떻게 전진하는가 8.0의 기본 write concern은 무엇이고, arbiter가 있으면 왜 달라지는가 read concern local, majority, snapshot, linearizable은 각각 어느 시점의 데이터를 읽는가 causal consistency 세션은 secondary 읽기에서 무엇을 보장하는가 w:1로 인정받은 쓰기가 rollback되면 데이터는 어디로 가는가 기준 버전: MongoDB 8.0.32. 소스 링크는 모두 r8.0.32 태그(커밋 8f1f561)에 고정했고, 실습 출력은 공식 RPM을 Rocky Linux 9.8 컨테이너에 설치해 실행한 결과입니다. 컨테이너 3대(m08-1, m08-2, m08-3)로 레플리카셋 rs0을 만들었고, 마지막에 arbiter용 컨테이너 1대를 더했습니다.\nwrite concern: 쓰기 응답을 언제 보내는가 write concern은 세 가지 필드로 이루어집니다.\n필드 뜻 w 몇 멤버에 반영된 뒤 응답할지. 숫자(1, 3), \u0026quot;majority\u0026quot;, 또는 태그로 정의한 이름 j 저널(2편)에 기록된 뒤를 기준으로 할지 wtimeout 기다리는 시간의 상한(ms). 0이면 끝없이 기다림 쓰기 명령은 먼저 로컬에서 끝까지 실행됩니다. 도큐먼트 변경과 oplog 엔트리는 WiredTiger 트랜잭션 하나로 커밋되고(5편), 그 oplog 엔트리의 위치가 이 쓰기의 optime(timestamp + term)입니다. 명령이 끝난 뒤, 응답을 보내기 직전에 waitForWriteConcern()이 불립니다(mongod에서의 호출은 service_entry_point_mongod.cpp). 여기서 하는 일은 두 단계입니다.\n디스크 대기: j:true이면 저널 flush를 기다립니다(write_concern.cpp). 다른 멤버까지 기다리는 write concern이면 flush를 걸어만 두고 넘어갑니다. 복제 대기가 각 멤버의 durable optime(저널까지 기록한 위치)을 따로 추적하기 때문입니다. 복제 대기: w:1처럼 다른 멤버를 기다릴 필요가 없으면 여기서 바로 응답합니다(write_concern.cpp). 그렇지 않으면 ReplicationCoordinatorImpl::awaitReplication()에 이 optime을 기다리는 waiter를 걸고 잠듭니다. 로컬 커밋은 먼저 끝납니다. 응답은 secondary의 위치 보고로 과반이 그 optime에 닿았다고 판단한 뒤에 나갑니다. 대기 중에 wtimeout이 지나면 쓰기는 남겨 둔 채 writeConcernError만 붙여 응답합니다. 새 창에서 크게 보기 secondary는 oplog를 가져가 쓰고 적용할 때마다 자기 위치(written, applied, durable optime)를 primary에 보고합니다(replSetUpdatePosition). 보고가 올 때마다 primary는 대기 중인 waiter를 확인하고, 조건을 만족한 waiter를 깨웁니다. 조건 판단은 _doneWaitingForReplication_inlock()이 합니다.\n숫자 w: 멤버를 하나씩 보며 그 optime에 닿은 멤버 수를 셉니다(haveNumNodesReachedOpTime()). arbiter는 세지 않습니다(topology_coordinator.cpp). j:true면 durable optime, 아니면 written optime을 비교합니다. \u0026quot;majority\u0026quot;: 멤버를 세지 않고, primary의 committed snapshot이 그 optime 이상이 되었는지를 봅니다(replication_coordinator_impl.cpp). committed snapshot이 무엇인지는 다음 절에서 봅니다. wtimeout이 먼저 지나면 대기가 WriteConcernFailed와 waiting for replication timed out으로 끝납니다(replication_coordinator_impl.cpp). 이 경로 어디에도 이미 커밋된 로컬 쓰기를 되돌리는 코드는 없습니다. write concern은 응답 시점만 늦출 뿐, 쓰기 자체를 조건부로 만들지 않습니다. 이 점은 뒤에서 실측으로 확인합니다.\nj를 지정하지 않은 \u0026quot;majority\u0026quot;는 레플리카셋 설정의 writeConcernMajorityJournalDefault(기본 true)를 따라 저널 기준으로 판단합니다(_populateUnsetWriteConcernOptionsSyncMode()).\n실습용 3노드 레플리카셋 멤버 3개짜리 레플리카셋을 만듭니다. 실습에서 특정 멤버를 primary로 두기 위해 m08-1에 priority 2를 줬습니다. 그리고 electionTimeoutMillis를 기본 10초에서 30초로 늘렸습니다. 뒤에서 secondary 두 개를 15초쯤 멈추는데, 기본값이면 그 사이 primary가 과반을 볼 수 없다며 스스로 물러나기 때문입니다(6편).\ntest\u003e rs.initiate({_id: \u0026#39;rs0\u0026#39;, settings: {electionTimeoutMillis: 30000}, members: [{_id: 0, host: \u0026#39;m08-1:27017\u0026#39;, priority: 2}, {_id: 1, host: \u0026#39;m08-2:27017\u0026#39;}, {_id: 2, host: \u0026#39;m08-3:27017\u0026#39;}]}) { ok: 1, ... } rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; m.name + \u0026#39; \u0026#39; + m.stateStr) [ \u0026#39;m08-1:27017 PRIMARY\u0026#39;, \u0026#39;m08-2:27017 SECONDARY\u0026#39;, \u0026#39;m08-3:27017 SECONDARY\u0026#39; ] 기본 read/write concern 클라이언트가 아무것도 지정하지 않으면 어떤 값이 쓰이는지는 getDefaultRWConcern으로 봅니다.\nrs0 [direct: primary] test\u003e db.adminCommand({getDefaultRWConcern: 1}) { defaultReadConcern: { level: \u0026#39;local\u0026#39; }, defaultWriteConcern: { w: \u0026#39;majority\u0026#39;, wtimeout: 0 }, defaultWriteConcernSource: \u0026#39;implicit\u0026#39;, defaultReadConcernSource: \u0026#39;implicit\u0026#39;, ... } rs0 [direct: primary] test\u003e rs.conf().writeConcernMajorityJournalDefault true rs0 [direct: primary] test\u003e rs.conf().settings.electionTimeoutMillis 30000 기본 write concern은 { w: 'majority', wtimeout: 0 }, 기본 read concern은 local입니다. Source: 'implicit'는 누가 정한 값이 아니라 서버가 레플리카셋 구성을 보고 계산한 값이라는 뜻입니다. 이 계산은 arbiter를 다룰 때 다시 봅니다. wtimeout: 0은 끝없이 기다린다는 뜻입니다. 과반이 응답하지 않으면 기본 설정의 쓰기는 응답하지 않습니다. 이것도 뒤에서 확인합니다. write concern을 붙인 쓰기의 응답 rs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;majority-1\u0026#39;, v: 1}, {writeConcern: {w: \u0026#39;majority\u0026#39;}}) { acknowledged: true, insertedId: \u0026#39;majority-1\u0026#39; } rs0 [direct: primary] test\u003e db.runCommand({insert: \u0026#39;t\u0026#39;, documents: [{_id: \u0026#39;majority-2\u0026#39;, v: 2}], writeConcern: {w: \u0026#39;majority\u0026#39;, wtimeout: 5000}}) { n: 1, electionId: ObjectId(\u0026#39;7fffffff0000000000000001\u0026#39;), opTime: { ts: Timestamp({ t: 1790513570, i: 1 }), t: Long(\u0026#39;1\u0026#39;) }, ok: 1, \u0026#39;$clusterTime\u0026#39;: { clusterTime: Timestamp({ t: 1790513570, i: 1 }), ... }, operationTime: Timestamp({ t: 1790513570, i: 1 }) } insertOne은 드라이버가 응답을 요약해 보여 주고, runCommand는 서버 응답을 그대로 보여 줍니다. opTime이 이 쓰기의 oplog 위치이고, t: Long('1')은 term 1(첫 primary의 임기)이라는 뜻입니다. electionId도 term을 담고 있습니다(...0001). operationTime과 $clusterTime은 causal consistency에서 씁니다.\nw:1, j:true, w:\u0026ldquo;majority\u0026quot;의 지연 한 세션에서 각 write concern으로 300건씩 순서대로 넣고 전체 시간을 쟀습니다. 다른 실습이 같은 호스트에서 돌고 있었으므로 절대값에는 의미가 없고, 같은 실행 안에서의 비교로만 봐야 합니다.\nrs0 [direct: primary] test\u003e function bench(wc, n) { const t0 = Date.now(); for (let i = 0; i \u0026lt; n; i++) { db.bench.insertOne({i: i}, {writeConcern: wc}); } return (Date.now() - t0) + \u0026#39; ms / \u0026#39; + n; } [Function: bench] rs0 [direct: primary] test\u003e bench({w: 1}, 300) 207 ms / 300 rs0 [direct: primary] test\u003e bench({w: 1, j: true}, 300) 216 ms / 300 rs0 [direct: primary] test\u003e bench({w: \u0026#39;majority\u0026#39;}, 300) 246 ms / 300 rs0 [direct: primary] test\u003e bench({w: 3}, 300) 235 ms / 300 w:1이 가장 빠르고 w:\u0026quot;majority\u0026quot;가 가장 느리지만, 차이는 건당 0.1ms 안팎입니다. 세 컨테이너가 한 호스트의 가상 네트워크에 있어서 왕복 시간이 거의 없기 때문입니다. 실제 서버 사이에서는 w:\u0026quot;majority\u0026quot;의 지연이 \u0026ldquo;가장 가까운 secondary까지의 왕복 + 그 secondary의 저널 기록\u0026quot;만큼 늘어납니다. 3노드에서 과반은 primary 자신을 포함한 2개이므로, 느린 secondary 하나는 기다리지 않습니다.\nmajority commit point: 과반이 가진 위치 \u0026quot;majority\u0026quot;를 판단하는 기준은 primary의 majority commit point(lastCommittedOpTime)입니다. \u0026ldquo;voting 멤버의 과반이 이 optime까지 가지고 있다\u0026quot;는 위치이고, primary가 secondary의 보고를 받을 때마다 TopologyCoordinator::updateLastCommittedOpTimeAndWallTime()으로 다시 계산합니다.\nvoting 멤버마다 위치를 하나씩 모읍니다. writeConcernMajorityJournalDefault가 true면 durable optime, 아니면 written optime입니다. 오름차순으로 정렬하고, 뒤에서 writeMajority번째 값을 고릅니다(topology_coordinator.cpp). 3노드에서 writeMajority는 2이므로 두 번째로 앞선 멤버의 위치가 commit point입니다. primary는 보통 가장 앞서 있으니, 가장 빠른 secondary의 위치와 같아집니다. writeMajority는 min(과반 투표 수, 데이터를 가진 voting 멤버 수)입니다(ReplSetConfig::_calculateMajorities()). arbiter는 투표는 하지만 데이터가 없어서 이 값을 줄입니다. commit point가 전진하면 primary는 이것을 스토리지로 내려보냅니다(_setStableTimestampForStorage()).\nstable optime은 commit point와 \u0026ldquo;이보다 앞에는 더 커밋될 쓰기가 없는 위치\u0026rdquo;(oplog hole이 없는 위치) 가운데 작은 쪽입니다(_recalculateStableOpTime()). 즉 stable optime은 majority commit point를 넘지 않습니다. 이 stable optime이 committed snapshot이 되고(_updateCommittedSnapshot()), WiredTiger의 stable timestamp로도 설정됩니다(replication_coordinator_impl.cpp). 2편의 체크포인트가 기록하는 시점과 3편의 timestamp가 여기서 이어집니다. committed snapshot이 바뀌면 그것을 기다리던 write concern waiter와 read concern 대기를 깨웁니다. 정리하면 w:\u0026quot;majority\u0026quot; 쓰기의 응답은 \u0026ldquo;이 쓰기가 committed snapshot에 들어갔다\u0026rdquo;, 즉 \u0026ldquo;과반의 저널에 들어가 되돌려질 수 없는 위치를 넘었다\u0026quot;는 뜻입니다.\nreplSetGetStatus로 보는 위치들 rs0 [direct: primary] test\u003e const o = db.adminCommand({replSetGetStatus: 1}).optimes; ({lastCommittedOpTime: o.lastCommittedOpTime, readConcernMajorityOpTime: o.readConcernMajorityOpTime, appliedOpTime: o.appliedOpTime, writtenOpTime: o.writtenOpTime, durableOpTime: o.durableOpTime}) { lastCommittedOpTime: { ts: Timestamp({ t: 1790513571, i: 1150 }), t: Long(\u0026#39;1\u0026#39;) }, readConcernMajorityOpTime: { ts: Timestamp({ t: 1790513571, i: 1150 }), t: Long(\u0026#39;1\u0026#39;) }, appliedOpTime: { ts: Timestamp({ t: 1790513571, i: 1150 }), t: Long(\u0026#39;1\u0026#39;) }, writtenOpTime: { ts: Timestamp({ t: 1790513571, i: 1150 }), t: Long(\u0026#39;1\u0026#39;) }, durableOpTime: { ts: Timestamp({ t: 1790513571, i: 1150 }), t: Long(\u0026#39;1\u0026#39;) } } 필드 뜻 lastCommittedOpTime majority commit point readConcernMajorityOpTime committed snapshot. read concern majority가 읽는 시점 writtenOpTime 이 멤버가 oplog에 쓴 위치 appliedOpTime 이 멤버가 적용까지 끝낸 위치 durableOpTime 이 멤버가 저널까지 기록한 위치 바로 앞에서 넣은 1200건 뒤로 쓰기가 없어서 다섯 위치가 모두 같습니다. secondary가 모두 따라와 있으면 commit point는 primary의 위치와 거의 붙어 다닙니다. 이 둘이 벌어지는 모습은 secondary를 멈춰 보면 볼 수 있습니다.\nsecondary 둘을 멈추면: w:1은 성공, w:\u0026ldquo;majority\u0026quot;는 wtimeout primary에 mongosh 세션 A를 열어 둔 채로, m08-2와 m08-3의 mongod에 SIGSTOP을 보냅니다. 프로세스는 살아 있지만 아무 일도 하지 않으므로 heartbeat에도, oplog 요청에도 응답하지 않습니다.\n$ kill -STOP 18 $ ps -o pid,stat,comm -p 18 PID STAT COMMAND 18 Tl mongod (m08-2의 mongod PID가 18, m08-3은 17이고 m08-3에도 같은 명령을 보냈습니다. STAT의 T가 멈춘 상태입니다.)\n세션 A에서 w:1, w:\u0026quot;majority\u0026quot;(wtimeout 2초), 그리고 write concern을 지정하지 않은 쓰기(maxTimeMS 2초)를 차례로 보냅니다.\nA rs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;w1-stalled\u0026#39;, v: 10}, {writeConcern: {w: 1}}) { acknowledged: true, insertedId: \u0026#39;w1-stalled\u0026#39; } A rs0 [direct: primary] test\u003e db.runCommand({insert: \u0026#39;t\u0026#39;, documents: [{_id: \u0026#39;maj-stalled\u0026#39;, v: 11}], writeConcern: {w: \u0026#39;majority\u0026#39;, wtimeout: 2000}}) Uncaught: MongoWriteConcernError[WriteConcernFailed]: waiting for replication timed out Additional information: { wtimeout: true, writeConcern: { w: \u0026#39;majority\u0026#39;, wtimeout: 2000, provenance: \u0026#39;clientSupplied\u0026#39; } } Result: { n: 1, electionId: ObjectId(\u0026#39;7fffffff0000000000000001\u0026#39;), opTime: { ts: Timestamp({ t: 1790513576, i: 1 }), t: 1 }, writeConcernError: { code: 64, codeName: \u0026#39;WriteConcernFailed\u0026#39;, errmsg: \u0026#39;waiting for replication timed out\u0026#39;, errInfo: { wtimeout: true, writeConcern: { w: \u0026#39;majority\u0026#39;, wtimeout: 2000, provenance: \u0026#39;clientSupplied\u0026#39; } } }, ok: 1, ... } A rs0 [direct: primary] test\u003e db.runCommand({insert: \u0026#39;t\u0026#39;, documents: [{_id: \u0026#39;default-stalled\u0026#39;, v: 12}], maxTimeMS: 2000}) Uncaught: MongoWriteConcernError[MaxTimeMSExpired]: operation exceeded time limit Additional information: { writeConcern: { w: \u0026#39;majority\u0026#39;, wtimeout: 0, provenance: \u0026#39;implicitDefault\u0026#39; } } Result: { n: 1, ... writeConcernError: { code: 50, codeName: \u0026#39;MaxTimeMSExpired\u0026#39;, errmsg: \u0026#39;operation exceeded time limit\u0026#39;, ... ok: 1, ... } w:1은 secondary와 상관없이 곧바로 성공했습니다. primary의 로컬 커밋만 기다리기 때문입니다. w:\u0026quot;majority\u0026quot;, wtimeout: 2000은 2초 뒤 writeConcernError(code: 64, WriteConcernFailed, waiting for replication timed out)로 끝났습니다. 그런데 같은 응답에 n: 1, ok: 1, opTime이 있습니다. 명령은 성공했고 도큐먼트는 들어갔으며, 기다림만 실패했다는 뜻입니다. mongosh는 이 응답을 오류로 던지지만 서버 응답 자체는 ok: 1입니다. write concern을 지정하지 않은 쓰기는 provenance: 'implicitDefault', 즉 기본값 { w: 'majority', wtimeout: 0 }으로 기다렸습니다. wtimeout이 0이라 스스로는 끝나지 않고, maxTimeMS(2초)가 지나서야 MaxTimeMSExpired로 끝났습니다. 이것도 n: 1입니다. maxTimeMS가 없었다면 이 쓰기는 secondary가 돌아올 때까지 응답하지 않았을 것입니다. 대기 시간 초과를 WriteConcernFailed로 바꾸는 것은 wtimeout이 opCtx의 deadline보다 먼저 올 때뿐입니다(replication_coordinator_impl.cpp). 같은 세션에서 read concern만 바꿔 읽고, 위치를 확인합니다.\nA rs0 [direct: primary] test\u003e db.t.find({v: {$gte: 10}}).readConcern(\u0026#39;local\u0026#39;).toArray() [ { _id: \u0026#39;w1-stalled\u0026#39;, v: 10 }, { _id: \u0026#39;maj-stalled\u0026#39;, v: 11 }, { _id: \u0026#39;default-stalled\u0026#39;, v: 12 } ] A rs0 [direct: primary] test\u003e db.t.find({v: {$gte: 10}}).readConcern(\u0026#39;majority\u0026#39;).toArray() [] A rs0 [direct: primary] test\u003e const s = db.adminCommand({replSetGetStatus: 1}); ({lastCommittedOpTime: s.optimes.lastCommittedOpTime.ts, appliedOpTime: s.optimes.appliedOpTime.ts, members: s.members.map(m =\u0026gt; m.name + \u0026#39; \u0026#39; + m.stateStr + \u0026#39; \u0026#39; + (m.health === 1 ? \u0026#39;up\u0026#39; : \u0026#39;down\u0026#39;))}) { lastCommittedOpTime: Timestamp({ t: 1790513571, i: 1150 }), appliedOpTime: Timestamp({ t: 1790513580, i: 1 }), members: [ \u0026#39;m08-1:27017 PRIMARY up\u0026#39;, \u0026#39;m08-2:27017 (not reachable/healthy) down\u0026#39;, \u0026#39;m08-3:27017 (not reachable/healthy) down\u0026#39; ] } local로 읽으면 세 도큐먼트가 모두 보입니다. primary에는 분명히 들어가 있습니다. majority로 읽으면 하나도 보이지 않습니다. lastCommittedOpTime이 secondary를 멈추기 전의 위치(1790513571, i: 1150, 앞에서 본 값과 같음)에 멈춰 있고, primary의 appliedOpTime만 1790513580까지 나갔습니다. majority 읽기는 commit point의 스냅샷을 읽으므로, 그 뒤에 들어온 세 도큐먼트는 아직 없는 것으로 보입니다. 두 secondary는 heartbeat에 응답하지 않아 (not reachable/healthy)로 보입니다. secondary를 깨우고(kill -CONT) 3초 뒤 다시 읽습니다.\nrs0 [direct: primary] test\u003e db.t.find({v: {$gte: 10}}).readConcern(\u0026#39;majority\u0026#39;).toArray() [ { _id: \u0026#39;w1-stalled\u0026#39;, v: 10 }, { _id: \u0026#39;maj-stalled\u0026#39;, v: 11 }, { _id: \u0026#39;default-stalled\u0026#39;, v: 12 } ] rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; m.name + \u0026#39; \u0026#39; + m.stateStr) [ \u0026#39;m08-1:27017 PRIMARY\u0026#39;, \u0026#39;m08-2:27017 SECONDARY\u0026#39;, \u0026#39;m08-3:27017 SECONDARY\u0026#39; ] secondary가 밀린 oplog를 받아 가자 commit point가 전진했고, 세 도큐먼트가 모두 majority 읽기에 보입니다. WriteConcernFailed를 받은 maj-stalled와 MaxTimeMSExpired를 받은 default-stalled도 그대로 남아 있습니다.\n운영에서는: writeConcernError는 \u0026ldquo;실패\u0026quot;가 아니라 \u0026ldquo;모름\u0026quot;이다 wtimeout이나 maxTimeMS로 끝난 쓰기는 들어갔을 수도, 나중에 rollback될 수도 있는 상태입니다. 위 실습처럼 secondary가 돌아오면 남고, 뒤에서 볼 것처럼 그 전에 primary가 바뀌면 사라집니다. 그래서 writeConcernError를 받았을 때 같은 쓰기를 그냥 다시 보내면 중복이 생길 수 있습니다. 재시도하는 쓰기는 고유 _id나 upsert처럼 두 번 실행해도 결과가 같게 만들거나, 드라이버의 retryable writes(세션의 txnNumber로 같은 쓰기를 한 번만 적용)에 맡겨야 합니다.\n기본 write concern과 arbiter 기본 write concern은 레플리카셋 구성으로 정해집니다(ReadWriteConcernDefaults). 운영자가 setDefaultRWConcern으로 정한 값(cluster-wide write concern, CWWC)이 있으면 그것을 쓰고(Source: 'global'), 없으면 다음 규칙으로 계산합니다('implicit').\nbool ReplSetConfig::isImplicitDefaultWriteConcernMajority() const { auto arbiters = _totalVotingMembers - _writableVotingMembersCount; return arbiters == 0 || _writableVotingMembersCount \u0026gt; _majorityVoteCount; } isImplicitDefaultWriteConcernMajority()의 뜻은 이렇습니다. voting arbiter가 없으면 w:\u0026quot;majority\u0026quot;입니다. arbiter가 있으면, 데이터를 가진 voting 멤버 수가 과반 투표 수보다 클 때만 w:\u0026quot;majority\u0026quot;이고 아니면 w:1입니다. 소스 주석이 이유를 말해 줍니다. arbiter 덕에 primary는 뽑혀 있는데 majority 쓰기는 끝나지 않는 상황을 막기 위해서입니다.\n구성 voting / 데이터 voting / 과반 기본 write concern PSS (3노드) 3 / 3 / 2 w:\u0026quot;majority\u0026quot; PSA 3 / 2 / 2 w:1 PSSA 4 / 3 / 3 w:1 PSSSA 5 / 4 / 3 w:\u0026quot;majority\u0026quot; PSA에서 secondary 하나가 죽으면 primary와 arbiter로 과반 투표는 되지만, 데이터를 가진 멤버는 primary뿐이라 majority 쓰기가 끝날 수 없습니다. 그래서 이런 구성의 기본값은 w:1이 됩니다.\narbiter를 더하면 reconfig가 거부된다 실습 마지막(아래 rollback 실습이 끝나 m08-3이 primary인 상태)에 arbiter를 하나 더해 PSS를 PSSA로 바꿔 봤습니다.\nrs0 [direct: primary] test\u003e rs.addArb(\u0026#39;m08-4:27017\u0026#39;) MongoServerError[NewReplicaSetConfigurationIncompatible]: Reconfig attempted to install a config that would change the implicit default write concern. Use the setDefaultRWConcern command to set a cluster-wide write concern and try the reconfig again. rs0 [direct: primary] test\u003e db.adminCommand({setDefaultRWConcern: 1, defaultWriteConcern: {w: \u0026#39;majority\u0026#39;}, writeConcern: {w: \u0026#39;majority\u0026#39;}}) { defaultReadConcern: { level: \u0026#39;local\u0026#39; }, defaultWriteConcern: { w: \u0026#39;majority\u0026#39;, wtimeout: 0 }, updateOpTime: Timestamp({ t: 1790513642, i: 1 }), ... defaultWriteConcernSource: \u0026#39;global\u0026#39;, defaultReadConcernSource: \u0026#39;implicit\u0026#39;, ... } rs0 [direct: primary] test\u003e rs.addArb(\u0026#39;m08-4:27017\u0026#39;).ok 1 PSSA의 기본값은 표처럼 w:1입니다. reconfig가 implicit default를 w:\u0026quot;majority\u0026quot;에서 w:1로 바꾸게 되자 서버가 NewReplicaSetConfigurationIncompatible로 거부했습니다(replication_coordinator_impl.cpp). 구성 변경 하나로 애플리케이션의 쓰기 보장이 조용히 약해지는 일을 막는 장치입니다. setDefaultRWConcern으로 CWWC를 명시하자 defaultWriteConcernSource가 'global'이 되었고, 같은 rs.addArb가 성공했습니다. 이제 기본값은 구성과 상관없이 w:\u0026quot;majority\u0026quot;입니다. 단, 이 PSSA 구성에서 writeMajority는 min(3, 3) = 3이므로 w:\u0026quot;majority\u0026quot;가 데이터를 가진 세 멤버를 모두 기다립니다. secondary 하나만 멈춰도 기본 설정의 쓰기가 멈춥니다. arbiter를 더할 때는 이 대가를 알고 결정해야 합니다.\nread concern: 어느 스냅샷에서 읽는가 read concern은 읽기가 어느 시점의 스냅샷을 볼지 정합니다. 3편에서 본 WiredTiger의 timestamp 읽기(read_timestamp)를 어떤 값으로 여느냐의 차이입니다. 결정은 waitForReadConcernImpl()에서 합니다.\nlevel 읽는 시점 되돌려질 수 있는가 local (기본값) 이 멤버의 최신 데이터 예 (rollback되면 사라짐) available local과 같음. 샤드에서 orphan 도큐먼트 필터링을 하지 않음 예 majority committed snapshot 아니오 snapshot 지정한 atClusterTime, 없으면 committed snapshot 아니오 linearizable 최신 데이터를 읽은 뒤, 과반 확인까지 기다림 (primary만) 아니오 majority: 읽기의 read source를 kMajorityCommitted로 정하고(read_concern_mongod.cpp), WiredTiger 트랜잭션을 committed snapshot의 timestamp로 엽니다(beginTransactionOnCommittedSnapshot()). 과반 확인을 기다리는 것이 아니라, 이미 과반이 확인된 과거 시점을 읽는 것입니다. 그래서 앞 실습에서 secondary가 멈췄을 때도 오류 없이 곧바로 옛 데이터(빈 결과)를 돌려줬습니다. available: 레플리카셋에서는 local과 같습니다. 샤드에서는 컬렉션을 샤딩되지 않은 것처럼 다뤄 shard version 확인과 orphan 필터링을 건너뜁니다(collection_sharding_runtime.cpp). orphan은 7편에서 다룹니다. snapshot: 트랜잭션 밖에서는 atClusterTime으로 준 시점, 없으면 committed snapshot의 시점을 읽습니다(read_concern_mongod.cpp). 여러 번 읽어도 같은 시점을 보게 하려고 씁니다. linearizable: 아래에서 따로 봅니다. local은 secondary에서 읽으면 그 secondary가 적용한 곳까지를 봅니다. 복제가 늦으면 옛 데이터를 보고, primary가 rollback되면 방금 본 데이터가 사라질 수도 있습니다.\nsnapshot: 과거 시점으로 읽기 rs0 [direct: primary] test\u003e const t1 = db.runCommand({insert: \u0026#39;snap\u0026#39;, documents: [{_id: 1, v: \u0026#39;old\u0026#39;}]}).operationTime; t1 Timestamp({ t: 1790513570, i: 4 }) rs0 [direct: primary] test\u003e db.snap.updateOne({_id: 1}, {$set: {v: \u0026#39;new\u0026#39;}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] test\u003e db.snap.find() [ { _id: 1, v: \u0026#39;new\u0026#39; } ] rs0 [direct: primary] test\u003e db.runCommand({find: \u0026#39;snap\u0026#39;, filter: {_id: 1}, readConcern: {level: \u0026#39;snapshot\u0026#39;, atClusterTime: t1}}).cursor.firstBatch [ { _id: 1, v: \u0026#39;old\u0026#39; } ] rs0 [direct: primary] test\u003e db.adminCommand({getParameter: 1, minSnapshotHistoryWindowInSeconds: 1}).minSnapshotHistoryWindowInSeconds 300 insert의 operationTime(t1)을 기억해 두고 값을 new로 바꾼 뒤, atClusterTime: t1로 읽자 바꾸기 전 값 old가 나왔습니다. WiredTiger가 도큐먼트의 옛 버전을 timestamp와 함께 보관하고 있어서 가능합니다(3편). 얼마나 먼 과거까지 읽을 수 있는지는 minSnapshotHistoryWindowInSeconds(기본 300초, snapshot_window_options.idl)가 정합니다. 서버는 committed snapshot보다 이 시간만큼 앞선 버전까지 지우지 않고 남깁니다.\nlinearizable: 읽은 뒤에 과반에 물어본다 majority 읽기는 되돌려지지 않는 데이터를 보장하지만, 가장 최신이라는 보장은 없습니다. 특히 네트워크에서 고립된 옛 primary는 자기가 아직 primary라고 믿는 동안 옛 데이터를 돌려줄 수 있습니다. linearizable은 이것을 막습니다. primary에서만 되고, 읽기를 끝낸 뒤 oplog에 noop 엔트리({msg: 'linearizable read'})를 하나 쓰고 그 엔트리가 과반에 복제될 때까지 기다립니다(waitForLinearizableReadConcernImpl()). 과반이 받아 줬다면 읽는 순간에 이 멤버가 진짜 primary였다는 뜻입니다. 이 대기에는 따로 시간 제한이 없어서(service_entry_point_mongod.cpp), maxTimeMS와 함께 쓰라고 문서가 권합니다.\nsecondary가 멈춘 동안의 linearizable 읽기 앞의 실습에서 secondary 둘이 멈춘 동안, 같은 세션 A에서 linearizable 읽기를 했습니다.\nA rs0 [direct: primary] test\u003e db.runCommand({find: \u0026#39;t\u0026#39;, filter: {_id: \u0026#39;majority-1\u0026#39;}, readConcern: {level: \u0026#39;linearizable\u0026#39;}, maxTimeMS: 2000}) MongoServerError[MaxTimeMSExpired]: operation exceeded time limit secondary를 깨운 뒤 oplog를 보고, 같은 읽기를 다시 합니다.\nrs0 [direct: primary] test\u003e db.getSiblingDB(\u0026#39;local\u0026#39;).oplog.rs.find({op: \u0026#39;n\u0026#39;, \u0026#39;o.msg\u0026#39;: \u0026#39;linearizable read\u0026#39;}, {ts: 1, op: 1, o: 1}).toArray() [ { op: \u0026#39;n\u0026#39;, o: { msg: \u0026#39;linearizable read\u0026#39; }, ts: Timestamp({ t: 1790513588, i: 1 }) } ] rs0 [direct: primary] test\u003e db.runCommand({find: \u0026#39;t\u0026#39;, filter: {_id: \u0026#39;majority-1\u0026#39;}, readConcern: {level: \u0026#39;linearizable\u0026#39;}, maxTimeMS: 2000}).cursor.firstBatch [ { _id: \u0026#39;majority-1\u0026#39;, v: 1 } ] 도큐먼트는 이미 읽었지만 과반 확인이 오지 않아 2초 뒤 MaxTimeMSExpired로 끝났습니다. 결과는 돌려주지 않았습니다. oplog에는 그 읽기가 남긴 noop 엔트리(op: 'n')가 secondary를 멈춘 동안의 timestamp(1790513588)로 남아 있습니다. linearizable 읽기는 매번 oplog 쓰기 한 번과 과반 왕복 한 번을 치릅니다. secondary가 돌아온 뒤에는 같은 읽기가 곧바로 성공합니다. causal consistency: 내가 쓴 것을 secondary에서 읽기 primary에 쓰고 곧바로 secondary에서 읽으면, secondary가 아직 그 쓰기를 적용하지 않았을 수 있습니다. causal consistency는 \u0026ldquo;내가 앞서 쓰거나 읽은 것보다 이전 상태는 보지 않는다\u0026quot;는 보장이고, 두 가지 시간으로 구현합니다.\n$clusterTime: 클러스터 전체의 논리 시계입니다. 모든 응답에 붙어 오고, 클라이언트는 받은 것 중 가장 큰 값을 다음 요청에 붙입니다. operationTime: 이 명령이 만든(쓰기) 또는 본(읽기) 데이터의 시점입니다. 쓰기면 그 oplog 엔트리의 timestamp, 읽기면 read concern에 따라 committed snapshot이나 마지막 적용 위치입니다(computeOperationTime()). causal consistency 세션을 쓰면 드라이버가 세션의 마지막 operationTime을 기억했다가, 다음 읽기에 readConcern: {afterClusterTime: \u0026lt;그 시점\u0026gt;}을 붙입니다. 이 읽기를 받은 멤버는 자기가 그 시점까지 따라올 때까지 기다렸다가 읽습니다(_waitUntilClusterTimeForRead()). level이 majority면 committed snapshot이 그 시점에 닿기를 기다리고(waitUntilMajorityOpTime()), local이면 적용을 끝낸 위치(applied optime)가 닿기를 기다립니다. 샤드 클러스터에서는 멤버의 oplog가 그 clusterTime보다 뒤처져 있으면 primary에 noop을 쓰게 해 시계를 밀어 주는데, 샤딩이 아닌 레플리카셋에서는 이 단계를 건너뜁니다(makeNoopWriteIfNeeded()).\n적용이 멈춘 secondary에서 afterClusterTime으로 읽기 지연을 만들기 위해 m08-3에서 db.fsyncLock()을 겁니다. fsyncLock은 백업용 명령으로, 전역 공유 잠금을 잡아 쓰기를 모두 막습니다(fsync.cpp). 읽기는 되지만 oplog 적용도 쓰기라서 멈춥니다. primary와 m08-2로 과반이 되므로 majority 쓰기는 계속 됩니다.\nrs0 [direct: secondary] test\u003e db.fsyncLock() { info: \u0026#39;now locked against writes, use db.fsyncUnlock() to unlock\u0026#39;, lockCount: Long(\u0026#39;1\u0026#39;), ... } primary에서 w:\u0026quot;majority\u0026quot;로 씁니다.\nrs0 [direct: primary] test\u003e const r = db.runCommand({insert: \u0026#39;t\u0026#39;, documents: [{_id: \u0026#39;causal-1\u0026#39;, v: 20}], writeConcern: {w: \u0026#39;majority\u0026#39;}}); r { n: 1, electionId: ObjectId(\u0026#39;7fffffff0000000000000001\u0026#39;), opTime: { ts: Timestamp({ t: 1790513597, i: 1 }), t: Long(\u0026#39;1\u0026#39;) }, ok: 1, ... operationTime: Timestamp({ t: 1790513597, i: 1 }) } 이 쓰기의 operationTime은 Timestamp({ t: 1790513597, i: 1 })입니다. 이제 m08-3에 접속해서 읽습니다.\nrs0 [direct: secondary] test\u003e db.t.find({_id: \u0026#39;causal-1\u0026#39;}).readConcern(\u0026#39;local\u0026#39;).toArray() [] rs0 [direct: secondary] test\u003e db.t.find({_id: \u0026#39;causal-1\u0026#39;}).readConcern(\u0026#39;majority\u0026#39;).toArray() [] rs0 [direct: secondary] test\u003e db.runCommand({find: \u0026#39;t\u0026#39;, filter: {_id: \u0026#39;causal-1\u0026#39;}, readConcern: {level: \u0026#39;majority\u0026#39;, afterClusterTime: Timestamp({t: 1790513597, i: 1})}, maxTimeMS: 3000}) MongoServerError[MaxTimeMSExpired]: Error waiting for snapshot not less than { ts: Timestamp(1790513597, 1), t: -1 }, current relevant optime is { ts: Timestamp(1790513596, 1), t: 1 }. :: caused by :: operation exceeded time limit local과 majority는 둘 다 빈 결과를 곧바로 돌려줍니다. 이미 과반에 커밋된 쓰기인데도 이 secondary에는 아직 없습니다. majority 읽기라도 그 멤버의 committed snapshot을 읽을 뿐, 클러스터의 최신 commit point를 보장하지 않습니다. 이것이 secondary의 stale read입니다. afterClusterTime을 붙이자 곧바로 돌려주지 않고 기다리다가 3초 뒤 MaxTimeMSExpired로 끝났습니다. 메시지가 기다린 내용을 그대로 말해 줍니다. 요청한 시점 Timestamp(1790513597, 1) 이상의 스냅샷을 기다렸는데, 이 멤버의 committed snapshot은 fsyncLock을 건 시점인 Timestamp(1790513596, 1)에 머물러 있습니다(replication_coordinator_impl.cpp). t: -1은 clusterTime에는 term이 없어서 붙은 값입니다. 이번에는 m08-3의 세션 B에서 maxTimeMS 없이 같은 읽기를 보내고, 다른 접속에서 잠금을 풉니다.\nB rs0 [direct: secondary] test\u003e db.runCommand({find: \u0026#39;t\u0026#39;, filter: {_id: \u0026#39;causal-1\u0026#39;}, readConcern: {level: \u0026#39;majority\u0026#39;, afterClusterTime: Timestamp({t: 1790513597, i: 1})}}).cursor.firstBatch rs0 [direct: secondary] test\u003e db.fsyncUnlock() { info: \u0026#39;fsyncUnlock completed\u0026#39;, lockCount: Long(\u0026#39;0\u0026#39;), ... } 잠금을 풀자 세션 B에 결과가 돌아왔습니다.\n[ { _id: \u0026#39;causal-1\u0026#39;, v: 20 } ] 세션 B의 읽기는 3초 동안 응답 없이 기다리다가, m08-3이 oplog를 적용해 committed snapshot이 그 시점을 넘자마자 방금 쓴 도큐먼트를 돌려줬습니다. 옛 데이터를 돌려주는 대신 기다리는 것, 이것이 causal consistency가 secondary 읽기에 주는 보장입니다.\n드라이버는 afterClusterTime을 알아서 붙인다 실제 애플리케이션은 afterClusterTime을 직접 쓰지 않습니다. 레플리카셋 전체에 접속한 mongosh에서 causal consistency 세션을 열고, 쓰고, secondary에서 읽었습니다. 두 secondary는 받은 명령을 모두 로그에 남기도록 slowms를 -1로 두었습니다.\nrs0 [direct: primary] test\u003e const s = db.getMongo().startSession({causalConsistency: true}); const sdb = s.getDatabase(\u0026#39;test\u0026#39;) rs0 [direct: primary] test\u003e sdb.t.insertOne({_id: \u0026#39;causal-2\u0026#39;, v: 21}) { acknowledged: true, insertedId: \u0026#39;causal-2\u0026#39; } rs0 [direct: primary] test\u003e s.getOperationTime() Timestamp({ t: 1790513609, i: 1 }) rs0 [direct: primary] test\u003e sdb.t.find({_id: \u0026#39;causal-2\u0026#39;}).readPref(\u0026#39;secondary\u0026#39;).readConcern(\u0026#39;majority\u0026#39;).toArray() [ { _id: \u0026#39;causal-2\u0026#39;, v: 21 } ] 읽기를 받은 secondary(m08-2)의 로그에서 그 find 명령을 봅니다.\n$ jq -c \u0026#39;select(.msg == \u0026#34;Slow query\u0026#34; and .attr.command.find == \u0026#34;t\u0026#34; and .attr.command.filter._id == \u0026#34;causal-2\u0026#34;) | {readConcern: .attr.command.readConcern, readPreference: .attr.command[\u0026#34;$readPreference\u0026#34;]}\u0026#39; /data/mongod.log {\u0026#34;readConcern\u0026#34;:{\u0026#34;level\u0026#34;:\u0026#34;majority\u0026#34;,\u0026#34;afterClusterTime\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513609,\u0026#34;i\u0026#34;:1}}},\u0026#34;readPreference\u0026#34;:{\u0026#34;mode\u0026#34;:\u0026#34;secondary\u0026#34;}} 코드에서는 readConcern('majority')만 지정했는데, 서버가 받은 명령에는 afterClusterTime이 insert의 operationTime(1790513609, i: 1)과 같은 값으로 붙어 있습니다. 세션이 마지막 operationTime을 기억했다가 드라이버가 붙여 준 것입니다. 같은 세션 안에서만 보장되므로, 요청마다 새 세션을 쓰거나 세션 없이 읽으면 이 보장은 없습니다. 또 쓰기와 읽기가 모두 majority일 때만 장애가 나도 이 순서가 깨지지 않습니다. w:1 쓰기는 rollback될 수 있기 때문입니다.\nrollback: w:1로 인정받은 쓰기가 사라지는 과정 w:1 쓰기는 primary에만 있어도 성공 응답을 받습니다. 그 쓰기가 과반에 복제되기 전에 primary가 바뀌면, 새 primary의 oplog에는 그 쓰기가 없고 다른 쓰기가 이어집니다. 옛 primary가 돌아오면 두 oplog가 갈라진 것을 발견하고, 자기에게만 있는 부분을 되돌려야 새 primary를 따라갈 수 있습니다. 이것이 rollback입니다.\n8.0의 WiredTiger 환경에서 rollback은 Recover To Timestamp 방식입니다(RollbackImpl::runRollback()). oplog 엔트리를 하나씩 거꾸로 되돌리는 것이 아니라, 스토리지 전체를 과반이 확인한 시점(stable timestamp)으로 한 번에 되돌린 뒤 거기서부터 다시 따라갑니다.\n갈라짐을 감지하면 공통 지점을 찾고, 지워질 도큐먼트를 rollback 파일에 남긴 뒤 WiredTiger를 stable timestamp로 되돌립니다. 공통 지점 뒤의 oplog를 잘라 내고 다시 적용한 다음 SECONDARY로 돌아갑니다. 새 창에서 크게 보기 갈라짐 감지: oplog fetcher가 sync source에서 가져온 첫 엔트리가 자기 마지막 엔트리와 이어지지 않으면 OplogStartMissing으로 멈추고(oplog_fetcher.cpp), 복제 스레드가 rollback을 시작합니다(bgsync.cpp). ROLLBACK 상태, 공통 지점: 멤버 상태를 ROLLBACK으로 바꾸고, 자기 oplog와 sync source의 oplog를 뒤에서부터 비교해 마지막으로 같았던 엔트리(common point)를 찾습니다. 공통 지점 뒤가 너무 길면(rollbackTimeLimitSecs, 기본 1일, rollback_impl.idl) 되돌리기를 거부합니다(_checkAgainstTimeLimit()). rollback 파일: 공통 지점 뒤에 insert나 update된 도큐먼트(rollback이 지우거나 옛 버전으로 바꿀 도큐먼트)의 현재 버전을 \u0026lt;dbPath\u0026gt;/rollback/\u0026lt;컬렉션 UUID\u0026gt;/removed.\u0026lt;시각\u0026gt;.bson에 씁니다(_writeRollbackFileForNamespace()). createRollbackDataFiles(기본 true, rollback_impl.idl)로 끌 수 있습니다. stable timestamp로 복구: WiredTiger의 rollback_to_stable을 불러 모든 테이블을 stable timestamp 시점으로 되돌립니다(WiredTigerKVEngine::recoverToStableTimestamp()). stable timestamp는 majority commit point를 넘지 않으므로(앞 절), 과반에 커밋된 쓰기는 이 단계에서 사라지지 않습니다. oplog 정리, 재적용: 공통 지점 뒤의 oplog를 잘라 내도록 표시하고(rollback_impl.cpp), stable timestamp부터 공통 지점까지의 oplog를 다시 적용합니다(rollback_impl.cpp). 이 과정은 2편의 재시작 복구와 같은 코드입니다. 그 뒤 SECONDARY로 돌아가 새 primary의 oplog를 따라갑니다. primary를 떼어 내고 w:1로 쓰기 먼저 m08-1의 priority를 1로 되돌려(재연결 뒤 priority 때문에 다시 primary가 되는 것을 막으려고) 세 멤버를 같게 하고, majority로 도큐먼트 하나를 넣어 둡니다.\nrs0 [direct: primary] test\u003e const cfg = rs.conf(); cfg.members[0].priority = 1; rs.reconfig(cfg).ok 1 rs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;before-partition\u0026#39;, v: 30}, {writeConcern: {w: \u0026#39;majority\u0026#39;}}) { acknowledged: true, insertedId: \u0026#39;before-partition\u0026#39; } 그리고 m08-1을 네트워크에서 떼어 냈습니다. m08-1은 다른 멤버와 통신할 수 없지만, 그 안에서 접속한 mongosh는 계속 씁니다. m08-1은 아직 자기가 primary라고 알고 있습니다.\nrs0 [direct: primary] test\u003e db.hello().isWritablePrimary true rs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;lost-w1\u0026#39;, v: 31}, {writeConcern: {w: 1}}) { acknowledged: true, insertedId: \u0026#39;lost-w1\u0026#39; } rs0 [direct: primary] test\u003e db.t.updateOne({_id: \u0026#39;before-partition\u0026#39;}, {$set: {v: 300}}, {writeConcern: {w: 1}}) { acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } rs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;lost-majority-timeout\u0026#39;, v: 32}, {writeConcern: {w: \u0026#39;majority\u0026#39;, wtimeout: 2000}}) Uncaught: MongoWriteConcernError[WriteConcernFailed]: waiting for replication timed out ... rs0 [direct: primary] test\u003e db.t.find({v: {$gte: 30}}).toArray() [ { _id: \u0026#39;before-partition\u0026#39;, v: 300 }, { _id: \u0026#39;lost-w1\u0026#39;, v: 31 }, { _id: \u0026#39;lost-majority-timeout\u0026#39;, v: 32 } ] w:1 insert와 update는 성공 응답을 받았습니다. 클라이언트 입장에서 이 두 쓰기는 끝난 일입니다. w:\u0026quot;majority\u0026quot;로 보낸 세 번째 쓰기는 wtimeout을 받았지만, 앞에서 본 것처럼 도큐먼트는 들어가 있습니다.\n나머지 둘이 새 primary를 뽑는다 electionTimeoutMillis(30초)가 지나자 m08-2와 m08-3이 선거를 해 m08-3이 primary가 되었습니다. 새 primary에서는 다른 쓰기를 합니다.\nrs0 [direct: primary] test\u003e db.t.insertOne({_id: \u0026#39;after-failover\u0026#39;, v: 40}, {writeConcern: {w: \u0026#39;majority\u0026#39;}}) { acknowledged: true, insertedId: \u0026#39;after-failover\u0026#39; } rs0 [direct: primary] test\u003e db.t.find({v: {$gte: 30}}).toArray() [ { _id: \u0026#39;before-partition\u0026#39;, v: 30 }, { _id: \u0026#39;after-failover\u0026#39;, v: 40 } ] rs0 [direct: primary] test\u003e rs.status().members.map(m =\u0026gt; m.name + \u0026#39; \u0026#39; + m.stateStr) [ \u0026#39;m08-1:27017 (not reachable/healthy)\u0026#39;, \u0026#39;m08-2:27017 SECONDARY\u0026#39;, \u0026#39;m08-3:27017 PRIMARY\u0026#39; ] 새 primary의 데이터에는 m08-1이 고립된 뒤 한 쓰기가 하나도 없습니다. before-partition은 30이고, lost-w1과 lost-majority-timeout은 없습니다. 같은 시각 m08-1도 과반을 볼 수 없다며 스스로 물러났습니다(checkMemberTimeouts()).\n$ jq -c \u0026#39;select(.msg | test(\u0026#34;relinquishing primary|Stepping down from primary|Can.t see a majority\u0026#34;)) | {t: .t.\u0026#34;$date\u0026#34;, msg}\u0026#39; /data/mongod.log {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:00.859+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Can\u0026#39;t see a majority of the set, relinquishing primary\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:00.859+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Stepping down from primary in response to heartbeat\u0026#34;} 고립 직후의 쓰기(아래 rollback 요약의 firstOpWallClockTimeAfterCommonPoint, 12:53:31)부터 약 30초 뒤입니다. 이 30초 동안 m08-1에는 primary가 둘인 것처럼 보이는 창이 있었고, 그 사이의 w:1 쓰기가 모두 성공 응답을 받았습니다.\n옛 primary를 다시 붙이면: ROLLBACK을 거쳐 SECONDARY m08-1을 네트워크에 다시 붙이자, 몇 초 안에 SECONDARY가 되었습니다. m08-1의 로그에서 rollback 관련 메시지만 뽑습니다.\n$ jq -c \u0026#39;select(.c == \u0026#34;ROLLBACK\u0026#34; or (.msg | test(\u0026#34;Transition to ROLLBACK|Starting rollback|rollback\u0026#34;))) | {t: .t.\u0026#34;$date\u0026#34;, msg}\u0026#39; /data/mongod.log ... {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.881+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Starting rollback due to fetcher error\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.881+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Scheduling rollback\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.881+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to ROLLBACK\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.881+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Finding common point\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.884+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Rollback common point\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.884+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Incremented the rollback ID\u0026#34;} ... {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.884+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Finding record store counts\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.884+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Preparing to write deleted documents to a rollback file\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:03.986+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Rolling back to the stable timestamp\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.007+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Rolling back to the stable timestamp completed by storage engine\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.014+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Operations reverted by rollback\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.018+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Marking to truncate all oplog entries with timestamps greater than common point\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.019+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Not updating committed snapshot because we are in rollback\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.019+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Triggering the rollback op observer\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.019+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Rollback complete\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.019+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Rollback summary\u0026#34;} {\u0026#34;t\u0026#34;:\u0026#34;2026-09-27T12:54:04.019+00:00\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;Transition to SECONDARY\u0026#34;} 위 그림의 단계가 순서대로 나옵니다. 전체가 약 140ms 걸렸습니다. 되돌릴 쓰기가 몇 개뿐이었기 때문입니다. rollback을 시작한 이유와 주요 단계의 내용을 봅니다.\n$ jq -c \u0026#39;select(.msg == \u0026#34;Starting rollback due to fetcher error\u0026#34;) | .attr\u0026#39; /data/mongod.log {\u0026#34;error\u0026#34;:\u0026#34;OplogStartMissing: the sync source\u0026#39;s oplog and our oplog have diverged, going into rollback. our last optime fetched: { ts: Timestamp(1790513637, 1), t: 1 }. optime of first document in batch: { ts: Timestamp(1790513641, 2), t: 2 }. sync source\u0026#39;s first optime: { ts: Timestamp(1790513535, 1), t: -1 }\u0026#34;,\u0026#34;lastCommittedOpTime\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513610,\u0026#34;i\u0026#34;:2}},\u0026#34;t\u0026#34;:1}} $ jq -c \u0026#39;select(.msg == \u0026#34;Rollback common point\u0026#34; or .msg == \u0026#34;Operations reverted by rollback\u0026#34; or .msg == \u0026#34;Preparing to write deleted documents to a rollback file\u0026#34;) | {msg, attr}\u0026#39; /data/mongod.log {\u0026#34;msg\u0026#34;:\u0026#34;Rollback common point\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;commonPointOpTime\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513610,\u0026#34;i\u0026#34;:2}},\u0026#34;t\u0026#34;:1}}} {\u0026#34;msg\u0026#34;:\u0026#34;Preparing to write deleted documents to a rollback file\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;namespace\u0026#34;:\u0026#34;test.t\u0026#34;,\u0026#34;uuid\u0026#34;:\u0026#34;e16a3931-c0ef-487c-a251-1a236d215b48\u0026#34;,\u0026#34;file\u0026#34;:\u0026#34;/data/db/rollback/e16a3931-c0ef-487c-a251-1a236d215b48/removed.2026-09-27T12-54-03.0.bson\u0026#34;}} {\u0026#34;msg\u0026#34;:\u0026#34;Operations reverted by rollback\u0026#34;,\u0026#34;attr\u0026#34;:{\u0026#34;insert\u0026#34;:2,\u0026#34;update\u0026#34;:1,\u0026#34;delete\u0026#34;:0,\u0026#34;insertGlobalIndexKey\u0026#34;:0,\u0026#34;deleteGlobalIndexKey\u0026#34;:0}} 갈라짐의 증거는 term입니다. m08-1의 마지막 엔트리는 term 1(t: 1)인데, sync source가 보낸 첫 엔트리는 term 2(t: 2)입니다. 새 primary가 term 2에서 쓴 oplog가 m08-1의 마지막 엔트리에서 이어지지 않으므로 oplog가 갈라졌다고 판단했습니다. 공통 지점은 Timestamp(1790513610, 2), term 1입니다. 고립 전 마지막으로 과반에 복제된 쓰기(before-partition)입니다. lastCommittedOpTime도 같은 값이라, m08-1이 알고 있던 commit point까지는 그대로 남습니다. rollback 파일은 test.t 컬렉션의 UUID 디렉터리에 하나 생겼고, 되돌린 연산은 insert 2개와 update 1개입니다. $ jq -c \u0026#39;select(.msg == \u0026#34;Rollback summary\u0026#34;) | .attr | {startTime, endTime, syncSource, rbid, lastOptimeRolledBack, commonPoint, lastWallClockTimeRolledBack, firstOpWallClockTimeAfterCommonPoint, truncateTimestamp, stableTimestamp, rollbackDataFileDirectory, rollbackCommandCounts, totalEntriesRolledBackIncludingNoops}\u0026#39; /data/mongod.log {\u0026#34;startTime\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T12:54:03.881Z\u0026#34;},\u0026#34;endTime\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T12:54:04.019Z\u0026#34;},\u0026#34;syncSource\u0026#34;:\u0026#34;m08-2:27017\u0026#34;,\u0026#34;rbid\u0026#34;:2,\u0026#34;lastOptimeRolledBack\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513637,\u0026#34;i\u0026#34;:1}},\u0026#34;t\u0026#34;:1},\u0026#34;commonPoint\u0026#34;:{\u0026#34;ts\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513610,\u0026#34;i\u0026#34;:2}},\u0026#34;t\u0026#34;:1},\u0026#34;lastWallClockTimeRolledBack\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T12:53:57.392Z\u0026#34;},\u0026#34;firstOpWallClockTimeAfterCommonPoint\u0026#34;:{\u0026#34;$date\u0026#34;:\u0026#34;2026-09-27T12:53:31.368Z\u0026#34;},\u0026#34;truncateTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513610,\u0026#34;i\u0026#34;:2}},\u0026#34;stableTimestamp\u0026#34;:{\u0026#34;$timestamp\u0026#34;:{\u0026#34;t\u0026#34;:1790513610,\u0026#34;i\u0026#34;:2}},\u0026#34;rollbackDataFileDirectory\u0026#34;:\u0026#34;/data/db/rollback/e16a3931-c0ef-487c-a251-1a236d215b48\u0026#34;,\u0026#34;rollbackCommandCounts\u0026#34;:{\u0026#34;update\u0026#34;:1,\u0026#34;insert\u0026#34;:2},\u0026#34;totalEntriesRolledBackIncludingNoops\u0026#34;:5} stableTimestamp, commonPoint, truncateTimestamp가 모두 1790513610, 2입니다. 고립되기 직전에 commit point가 공통 지점까지 와 있었으므로 stable timestamp로 되돌리는 것만으로 공통 지점에 도착했고, 다시 적용할 oplog가 없었습니다. 되돌린 oplog 엔트리는 모두 5개(totalEntriesRolledBackIncludingNoops)이고 그중 명령은 3개입니다. 나머지 2개는 noop 엔트리입니다. primary는 쓰기가 없어도 10초마다 periodic noop을 oplog에 씁니다(noop_writer.cpp). 마지막으로 되돌린 엔트리의 시각(12:53:57)이 쓰기 뒤 약 26초이므로, 고립된 동안 쓴 noop으로 보입니다. rbid(rollback ID)가 2가 되었습니다. 이 멤버가 rollback을 한 번 했다는 표시로, sync source를 고를 때 등에 쓰입니다. syncSource는 새 primary m08-3이 아니라 m08-2입니다. rollback의 기준은 primary가 아니라 이 멤버가 따라가는 sync source입니다. rollback 파일과 살아남은 데이터 $ find /data/db/rollback -type f $ bsondump --quiet $(find /data/db/rollback -name \u0026#39;removed.*.bson\u0026#39;) /data/db/rollback/e16a3931-c0ef-487c-a251-1a236d215b48/removed.2026-09-27T12-54-03.0.bson {\u0026#34;_id\u0026#34;:\u0026#34;lost-w1\u0026#34;,\u0026#34;v\u0026#34;:{\u0026#34;$numberInt\u0026#34;:\u0026#34;31\u0026#34;}} {\u0026#34;_id\u0026#34;:\u0026#34;lost-majority-timeout\u0026#34;,\u0026#34;v\u0026#34;:{\u0026#34;$numberInt\u0026#34;:\u0026#34;32\u0026#34;}} {\u0026#34;_id\u0026#34;:\u0026#34;before-partition\u0026#34;,\u0026#34;v\u0026#34;:{\u0026#34;$numberInt\u0026#34;:\u0026#34;300\u0026#34;}} rs0 [direct: secondary] test\u003e rs.status().members.map(m =\u0026gt; m.name + \u0026#39; \u0026#39; + m.stateStr) [ \u0026#39;m08-1:27017 SECONDARY\u0026#39;, \u0026#39;m08-2:27017 SECONDARY\u0026#39;, \u0026#39;m08-3:27017 PRIMARY\u0026#39; ] rs0 [direct: secondary] test\u003e db.t.find({v: {$gte: 30}}).readPref(\u0026#39;secondaryPreferred\u0026#39;).toArray() [ { _id: \u0026#39;before-partition\u0026#39;, v: 30 }, { _id: \u0026#39;after-failover\u0026#39;, v: 40 } ] rollback 파일에는 되돌려진 도큐먼트가 되돌리기 직전의 모습으로 들어 있습니다. insert된 lost-w1, lost-majority-timeout뿐 아니라, update로 300이 되었던 before-partition도 v: 300으로 남았습니다. 파일은 \u0026ldquo;지워지기 전 버전\u0026quot;을 모으는 것이지 \u0026ldquo;되돌린 연산의 목록\u0026quot;이 아닙니다. m08-1에는 이제 새 primary와 같은 데이터가 있습니다. before-partition은 majority로 쓴 원래 값 30으로 돌아왔고, 새 primary에서 쓴 after-failover가 있습니다. 성공 응답을 받은 w:1 쓰기 두 개는 사라졌습니다. majority로 쓴 before-partition의 insert는 살아남았고, wtimeout을 받은 lost-majority-timeout은 사라졌습니다. 앞에서 secondary가 돌아왔을 때는 wtimeout 쓰기가 남았으니, wtimeout을 받은 쓰기의 운명은 그 뒤에 무슨 일이 일어나느냐에 달려 있습니다. 운영에서는 이렇게 나타납니다 w:1 쓰기는 장애 한 번에 사라질 수 있다 rollback 실습의 lost-w1은 클라이언트가 성공 응답을 받았는데도 사라졌습니다. primary가 네트워크에서 고립되거나, 쓰기 직후 죽고 secondary가 선출되면 같은 일이 일어납니다. 8.0에서 PSS 구성의 기본값은 w:\u0026quot;majority\u0026quot;이지만, 드라이버 연결 문자열(w=1)이나 코드에서 w:1을 지정한 애플리케이션은 여전히 많습니다. \u0026ldquo;빠르니까\u0026rdquo; 지정한 w:1이 무엇을 포기하는지는 위 실습이 보여 줍니다. PostgreSQL의 비동기 복제에서 failover 뒤 마지막 커밋이 standby에 없는 것과 같은 성격입니다(PostgreSQL 인터널 9편). 차이는 MongoDB는 옛 primary가 돌아오면 그 쓰기를 파일로 꺼내 둔다는 점입니다.\n기본 write concern은 멈출 수 있다 기본값 { w: 'majority', wtimeout: 0 }은 과반이 응답하지 않으면 끝나지 않습니다. 위 실습에서 maxTimeMS가 없었다면 default-stalled 쓰기는 secondary가 돌아올 때까지 응답하지 않았을 것입니다. secondary 둘이 동시에 느려지는 일(같은 스토리지 장애, 같은 시각의 백업)은 드물지 않습니다. 애플리케이션의 연결 풀이 이런 쓰기로 가득 차면 서비스 전체가 멈춘 것처럼 보입니다. 쓰기마다 wtimeout이나 maxTimeMS를 두고, 그 오류를 writeConcernError의 뜻대로 처리해야 합니다.\nsecondary 읽기는 옛 데이터를 돌려준다 readPreference secondary나 secondaryPreferred로 읽으면, 실습의 m08-3처럼 적용이 늦은 멤버가 local은 물론 majority에서도 방금 쓴 데이터를 돌려주지 않습니다. 오류가 아니라 빈 결과가 오므로 알아채기 어렵습니다. \u0026ldquo;방금 저장한 것이 안 보인다\u0026quot;는 문의가 secondary 읽기에서 자주 나오는 이유입니다. 같은 사용자의 쓰기 뒤 읽기가 중요하면 causal consistency 세션을 쓰고, 오래된 멤버를 아예 피하려면 readPreference의 maxStalenessSeconds를 씁니다. 지연이 얼마인지는 5편의 복제 지연 지표로 봅니다.\nmajority commit point가 멈추면 캐시가 찬다 commit point가 멈추면 stable timestamp도 멈춥니다. WiredTiger는 stable timestamp 이후의 변경을 체크포인트로 확정하지 못하고, 그 사이 도큐먼트의 옛 버전을 캐시와 history store에 계속 붙잡아야 합니다(3편). 가장 흔한 경우가 PSA 구성에서 secondary가 오래 내려간 상황입니다. primary와 arbiter로 쓰기는 계속 받지만(기본값이 w:1이므로) commit point는 전진하지 않고, primary의 캐시 사용량과 WiredTigerHS.wt가 계속 커집니다. replSetGetStatus의 optimes.lastCommittedOpTime이 appliedOpTime보다 한참 뒤처져 있으면 이 상태입니다. secondary를 빨리 되살리거나, 오래 걸리면 그 멤버를 votes: 0으로 바꿔 과반 계산에서 빼는 것이 대응입니다.\nrollback 파일은 누군가 처리해야 한다 rollback이 일어나면 \u0026lt;dbPath\u0026gt;/rollback/\u0026lt;UUID\u0026gt;/removed.*.bson이 생기고, 서버는 이 파일을 다시 쓰지 않습니다. 로그에서 Rollback summary나 Transition to ROLLBACK을 감시하고, 파일이 생기면 bsondump로 내용을 확인한 뒤 되살릴지 판단해야 합니다. 되살린다면 새 primary의 현재 데이터와 비교해야 합니다. 실습의 before-partition처럼 파일 속 버전(v: 300)과 현재 버전(v: 30)이 다르고, 그 사이 다른 쓰기가 있었을 수도 있기 때문입니다. rollback 파일이 쌓이는 것을 원하지 않는다고 createRollbackDataFiles를 끄는 것은 되살릴 방법을 버리는 것이므로 권하지 않습니다.\n정리 write concern은 로컬 커밋 뒤에 응답을 얼마나 늦출지 정합니다. w:\u0026quot;majority\u0026quot;는 committed snapshot이 그 쓰기의 optime을 넘을 때까지 기다리고, wtimeout이나 maxTimeMS가 먼저 오면 쓰기는 남긴 채 writeConcernError만 돌려줍니다. majority commit point는 voting 멤버의 위치를 정렬해 과반 번째 값을 고른 것이고, 이것이 committed snapshot과 WiredTiger stable timestamp가 됩니다. 8.0의 기본 write concern은 PSS에서 w:\u0026quot;majority\u0026quot;, arbiter 때문에 과반 쓰기가 막힐 수 있는 구성에서는 w:1입니다. 이 값을 바꾸는 reconfig는 CWWC 없이는 거부됩니다. read concern majority는 committed snapshot을 읽고, linearizable은 읽은 뒤 noop 쓰기로 과반을 확인하며, snapshot은 지정한 과거 시점을 읽습니다. causal consistency 세션은 operationTime을 기억해 다음 읽기에 afterClusterTime을 붙이고, 뒤처진 멤버는 그 시점까지 기다린 뒤 읽습니다. 과반에 복제되지 않은 쓰기는 primary가 바뀌면 rollback됩니다. 옛 primary는 stable timestamp로 되돌린 뒤 새 primary를 따라가고, 되돌린 도큐먼트는 rollback/ 디렉터리의 BSON 파일로 남깁니다. 이 글로 MongoDB 인터널 연재를 마칩니다. WiredTiger의 캐시와 체크포인트에서 시작해 스냅샷과 timestamp, 인덱스와 플래너, oplog와 선출, 샤딩을 거쳐, 마지막으로 그 모든 것 위에서 클라이언트가 받는 보장까지 왔습니다.\n참고 자료 소스 코드 (r8.0.32 커밋 8f1f561 기준)\nsrc/mongo/db/write_concern.cpp: waitForWriteConcern src/mongo/db/repl/replication_coordinator_impl.cpp: awaitReplication, committed snapshot, stable timestamp, read concern 대기 src/mongo/db/repl/topology_coordinator.cpp: majority commit point 계산 src/mongo/db/repl/repl_set_config.cpp: write majority, implicit default write concern src/mongo/db/read_write_concern_defaults.cpp: 기본 read/write concern src/mongo/db/read_concern_mongod.cpp: read concern별 read source, linearizable src/mongo/db/storage/wiredtiger/wiredtiger_snapshot_manager.cpp: committed snapshot으로 트랜잭션 열기 src/mongo/db/repl/rollback_impl.cpp: Recover To Timestamp rollback, rollback 파일 MongoDB 8.0 공식 문서\nWrite Concern Read Concern Causal Consistency and Read and Write Concerns Rollbacks During Replica Set Failover setDefaultRWConcern ","permalink":"https://jinlog.dev/posts/mongodb/08-read-write-concern/","summary":"쓰기 응답은 무엇을 기다린 뒤에 오고, 읽기는 어느 시점의 데이터를 보는가. 그리고 w:1로 인정받은 쓰기는 어떻게 사라지는가","title":"MongoDB 인터널 8: Read/Write Concern이 실제로 보장하는 것"},{"content":"개요 standby에서 읽는 화면에 방금 쓴 데이터가 안 보인다는 신고가 오거나, 복제 지연 알람이 울립니다. standby는 primary가 보낸 WAL을 받아(write), 디스크에 확정하고(flush), 재생(replay)해야 데이터가 보입니다(인터널 9편). 어느 단계에서 늦는지에 따라 원인과 조치가 다릅니다.\n이 글에서 답할 질문은 다음과 같습니다.\n지연이 어느 단계에서 생기는지 어떻게 나눠 보는가 replay_lag만 보면 안 되는 이유는 무엇인가 standby의 쿼리가 왜 재생을 멈추고, 왜 취소되는가 hot_standby_feedback을 켜면 무엇을 얻고 무엇을 잃는가 standby가 멈추면 primary에는 무슨 일이 생기는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습에서는 primary와 standby를 따로 띄우고, pg_basebackup으로 standby를 만들었습니다. -R은 standby 설정(standby.signal, primary_conninfo)을 써 주고, -C -S standby1은 primary에 replication slot을 만듭니다. 명령 출력에서 primary=#와 standby=#로 어느 서버인지 구분합니다.\n$ pg_basebackup -h pgprimary -D $PGDATA -R -X stream -C -S standby1 -c fast $ ls $PGDATA/standby.signal \u0026amp;\u0026amp; grep primary_conninfo $PGDATA/postgresql.auto.conf /var/lib/pgsql/18/data/standby.signal primary_conninfo = \u0026#39;user=postgres passfile=\u0026#39;\u0026#39;/var/lib/pgsql/.pgpass\u0026#39;\u0026#39; channel_binding=prefer host=pgprimary port=5432 sslmode=prefer sslnegotiation=postgres sslcompression=0 sslcertmode=allow sslsni=1 ssl_min_protocol_version=TLSv1.2 gssencmode=prefer krbsrvname=postgres gssdelegation=0 target_session_attrs=any load_balance_hosts=disable\u0026#39; standby에는 충돌 대기를 기록하는 log_recovery_conflict_waits를 켜고, 충돌 시 최대 대기 시간 max_standby_streaming_delay를 실습에 맞게 10초로 줄였습니다(기본 30초).\nstandby=# SELECT name, setting FROM pg_settings WHERE name IN (\u0026#39;hot_standby_feedback\u0026#39;, \u0026#39;max_standby_streaming_delay\u0026#39;, \u0026#39;log_recovery_conflict_waits\u0026#39;) ORDER BY name; name | setting -----------------------------+--------- hot_standby_feedback | off log_recovery_conflict_waits | on max_standby_streaming_delay | 10000 (3 rows) 먼저 확인할 것 primary에서: pg_stat_replication primary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -------------+-----------+-----------+------------+-----------------+-----------------+-----------------+------------ walreceiver | streaming | 0/4000000 | 0/4000000 | 00:00:00.000029 | 00:00:00.000029 | 00:00:00.000029 | 0 bytes (1 row) 컬럼 뜻 sent_lsn primary가 보낸 위치 write_lsn, flush_lsn standby가 받아 쓴 위치, 디스크에 확정한 위치 replay_lsn standby가 재생을 마친 위치. 여기까지가 standby에서 보임 write_lag, flush_lag, replay_lag 각 단계까지 걸린 시간 write_lag가 크면 네트워크나 standby의 수신이, flush_lag가 크면 standby의 디스크가, replay_lag만 크면 standby의 재생이 늦는 것입니다. 실제로 가장 흔한 것은 재생 지연이고, 이 글도 주로 그것을 다룹니다.\nstandby에서: 받은 것과 재생한 것 standby=# SELECT pg_last_wal_receive_lsn() AS received, pg_last_wal_replay_lsn() AS replayed, standby-# pg_size_pretty(pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())) AS gap, standby-# now() - pg_last_xact_replay_timestamp() AS since_last_replay; received | replayed | gap | since_last_replay -----------+-----------+---------+------------------- 0/4000000 | 0/4000000 | 0 bytes | (1 row) standby=# SELECT status, sender_host, written_lsn, flushed_lsn FROM pg_stat_wal_receiver; status | sender_host | written_lsn | flushed_lsn -----------+-------------+-------------+------------- streaming | pgprimary | | 0/4000000 (1 row) pg_last_wal_receive_lsn()과 pg_last_wal_replay_lsn()의 차이가 받았지만 아직 재생하지 못한 양입니다. pg_last_xact_replay_timestamp()는 마지막으로 재생한 트랜잭션이 primary에서 커밋된 시각입니다. 다만 primary에 쓰기가 없으면 재생할 것도 없으므로 이 값은 지연이 없어도 커집니다. 그래서 바이트 차이와 함께 봐야 합니다.\n원인별 진단 standby의 긴 쿼리가 재생을 멈춘다 standby에서 보고서 쿼리가 40초 동안 돌고 있습니다.\nR=# SET application_name = \u0026#39;report\u0026#39;; SET R=# \\timing on Timing is on. R=# SELECT count(*), pg_sleep(40) FROM acc; 그 사이 primary에서 같은 테이블의 행을 모두 고치고 VACUUM합니다.\nprimary=# UPDATE acc SET v = v + 1; UPDATE 100000 primary=# VACUUM acc; VACUUM primary의 VACUUM이 지운 옛 버전은 WAL을 통해 standby에서도 지워져야 합니다. 그런데 standby의 보고서 쿼리는 스냅샷을 쥐고 있어서 그 옛 버전을 아직 볼 수도 있습니다. standby는 이 WAL을 재생하지 못하고 기다립니다. 이것이 hot standby 충돌(recovery conflict)입니다. 4초 뒤의 모습입니다.\nprimary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -------------+-----------+-----------+------------+----------------+-----------------+-----------------+------------ walreceiver | streaming | 0/5B82970 | 0/5A829A0 | 00:00:00.00016 | 00:00:00.000472 | 00:00:00.206355 | 1024 kB (1 row) standby=# SELECT pg_last_wal_receive_lsn() AS received, pg_last_wal_replay_lsn() AS replayed, standby-# pg_size_pretty(pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())) AS gap, standby-# now() - pg_last_xact_replay_timestamp() AS since_last_replay; received | replayed | gap | since_last_replay -----------+-----------+---------+------------------- 0/5B82970 | 0/5A829A0 | 1024 kB | 00:00:04.236133 (1 row) standby에서는 받은 WAL 가운데 1024 kB를 재생하지 못했고, 마지막 재생이 4.2초 전입니다. 그런데 primary의 replay_lag는 0.2초입니다. lag 시간 값은 standby가 재생을 진행하며 보고할 때 갱신되므로, 재생이 멈춘 동안에는 실제보다 작게 보입니다. replay_lag만 보고 알람을 걸면 이런 순간을 놓칩니다. sent_lsn과 replay_lsn의 바이트 차이(replay_gap)를 같이 감시해야 합니다.\nstandby 로그에는 재생이 기다리기 시작했다는 기록이 남습니다(log_recovery_conflict_waits).\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;recovery still waiting|conflict\u0026#39; | tail -n 3 2026-09-26 10:34:48.936 UTC [34] LOG: recovery still waiting after 1034.610 ms: recovery conflict on snapshot max_standby_streaming_delay(10초)가 지나면 standby는 기다리기를 멈추고 쿼리를 취소합니다.\n# 세션 R: 앞 명령의 결과를 기다림 ERROR: canceling statement due to conflict with recovery DETAIL: User query might have needed to see row versions that must be removed. Time: 11643.173 ms (00:11.643) $ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;recovery conflict|canceling statement|finished waiting\u0026#39; | tail -n 4 2026-09-26 10:34:48.936 UTC [34] LOG: recovery still waiting after 1034.610 ms: recovery conflict on snapshot 2026-09-26 10:34:58.712 UTC [72] postgres@postgres/report ERROR: canceling statement due to conflict with recovery 2026-09-26 10:34:58.718 UTC [34] LOG: recovery finished waiting after 10816.743 ms: recovery conflict on snapshot 재생은 10.8초 기다린 뒤 다시 진행했고, 보고서 쿼리는 11.6초 만에 에러로 끝났습니다. 곧 지연도 풀립니다.\nprimary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -------------+-----------+-----------+------------+-----------------+-----------------+-----------------+------------ walreceiver | streaming | 0/5B829A8 | 0/5B829A8 | 00:00:00.000452 | 00:00:00.001044 | 00:00:01.961063 | 0 bytes (1 row) standby=# SELECT datname, confl_snapshot, confl_lock, confl_bufferpin, confl_deadlock FROM pg_stat_database_conflicts WHERE datname = \u0026#39;postgres\u0026#39;; datname | confl_snapshot | confl_lock | confl_bufferpin | confl_deadlock ----------+----------------+------------+-----------------+---------------- postgres | 1 | 0 | 0 | 0 (1 row) pg_stat_database_conflicts의 confl_snapshot이 이번 취소를 셉니다. 충돌은 원인별로 따로 셉니다. confl_snapshot은 이번처럼 VACUUM이 지운 행 때문에, confl_lock은 primary의 AccessExclusiveLock(DROP, TRUNCATE 등) 때문에 생긴 충돌입니다.\nstandby에서 긴 쿼리를 돌리는 한 둘 중 하나를 골라야 합니다. 재생을 기다리게 해서 지연을 감수하거나, 쿼리를 취소하거나. max_standby_streaming_delay가 그 경계입니다. 크게 잡으면 쿼리는 덜 취소되지만 지연이 그만큼 길어질 수 있고, -1이면 쿼리가 끝날 때까지 무한정 기다립니다.\nhot_standby_feedback: 취소 대신 primary가 대가를 치른다 세 번째 선택지가 hot_standby_feedback입니다. standby가 \u0026ldquo;나는 이 트랜잭션 ID 이후의 행을 아직 볼 수 있다\u0026quot;고 primary에 알려 주면, primary의 VACUUM이 그 행을 지우지 않으므로 충돌 자체가 생기지 않습니다.\nstandby=# ALTER SYSTEM SET hot_standby_feedback = on; ALTER SYSTEM standby=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) 같은 보고서 쿼리를 이번에는 20초 돌립니다.\nR=# SELECT count(*), pg_sleep(20) FROM acc; primary=# SELECT application_name AS app, backend_xmin FROM pg_stat_replication; app | backend_xmin -------------+-------------- walreceiver | (1 row) primary=# SELECT slot_name, active, xmin, age(xmin) AS xmin_age FROM pg_replication_slots; slot_name | active | xmin | xmin_age -----------+--------+------+---------- standby1 | t | 755 | 0 (1 row) slot을 쓰는 standby의 피드백은 pg_stat_replication.backend_xmin이 아니라 slot의 xmin에 기록됩니다. primary에서 같은 UPDATE와 VACUUM을 합니다.\nprimary=# UPDATE acc SET v = v + 1; UPDATE 100000 primary=# VACUUM (VERBOSE) acc; INFO: vacuuming \u0026#34;postgres.public.acc\u0026#34; INFO: finished vacuuming \u0026#34;postgres.public.acc\u0026#34;: index scans: 0 pages: 0 removed, 885 remain, 885 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 200000 remain, 100000 are dead but not yet removable removable cutoff: 755, which was 1 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 1825 hits, 0 reads, 0 dirtied WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s VACUUM primary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -------------+-----------+-----------+------------+-----------------+-----------------+-----------------+------------ walreceiver | streaming | 0/6F9E910 | 0/6F9E910 | 00:00:00.000104 | 00:00:00.000306 | 00:00:00.002368 | 0 bytes (1 row) 지연은 없습니다. 대신 primary의 VACUUM이 100000 are dead but not yet removable로 하나도 지우지 못했습니다. removable cutoff: 755는 slot의 xmin과 같습니다. standby의 쿼리가 primary의 xmin horizon을 붙잡은 것으로, 4편에서 \u0026ldquo;찾는 쿼리\u0026quot;에 slot을 넣은 이유가 이것입니다.\n# 세션 R: 앞 명령의 결과를 기다림 count | pg_sleep --------+---------- 100000 | (1 row) Time: 20028.378 ms (00:20.028) 보고서 쿼리는 취소되지 않고 20초를 다 채웠습니다.\n설정 standby 쿼리 재생 지연 primary max_standby_streaming_delay 작게 자주 취소 짧음 영향 없음 max_standby_streaming_delay 크게 덜 취소 길어질 수 있음 영향 없음 hot_standby_feedback = on 스냅샷 충돌로는 취소 안 됨 짧음 dead tuple 정리가 밀려 bloat, wraparound 위험 hot_standby_feedback을 켰다면 standby에서 몇 시간씩 도는 쿼리가 primary 전체의 VACUUM을 막을 수 있다는 것을 기억해야 합니다. standby 쪽에도 statement_timeout이나 4편의 타임아웃을 걸어 둡니다.\nstandby가 멈추면 slot이 WAL을 붙잡는다 standby를 멈춥니다.\n$ pg_ctl -D $PGDATA -w stop -m fast waiting for server to shut down.... done server stopped [exit=0] primary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -----+-------+----------+------------+-----------+-----------+------------+------------ (0 rows) pg_stat_replication에서 standby가 사라졌습니다. 이 뷰는 연결된 standby만 보여 주므로, 지연 알람을 이 뷰의 lag 값에만 걸면 standby가 끊겼을 때 오히려 조용해집니다. 그동안 primary에 쓰기가 계속되면 slot이 WAL을 붙잡습니다.\n$ pgbench -i -s 10 -q postgres 2\u0026gt;\u0026amp;1 | tail -n 1 done in 0.50 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 0.36 s, vacuum 0.03 s, primary keys 0.10 s). primary=# SELECT slot_name, active, restart_lsn, primary-# pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained primary-# FROM pg_replication_slots; slot_name | active | restart_lsn | retained -----------+--------+-------------+---------- standby1 | f | 0/6FEEFC8 | 123 MB (1 row) active = f, retained 123 MB입니다. 이 상태가 오래가면 6편처럼 primary의 디스크가 찹니다. standby를 다시 띄우면 붙잡혀 있던 WAL부터 받아 따라잡습니다.\n$ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w start waiting for server to start.... done server started [exit=0] primary=# SELECT application_name AS app, state, sent_lsn, replay_lsn, primary-# write_lag, flush_lag, replay_lag, primary-# pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_gap primary-# FROM pg_stat_replication; app | state | sent_lsn | replay_lsn | write_lag | flush_lag | replay_lag | replay_gap -------------+-----------+-----------+------------+-----------------+-----------------+-----------------+------------ walreceiver | streaming | 0/EB0ED18 | 0/EB0ED18 | 00:00:00.136627 | 00:00:00.138404 | 00:00:00.213931 | 0 bytes (1 row) 이 실습에서는 123 MB를 1초 안에 따라잡았습니다. 끊긴 시간이 길어 WAL이 수십 GB 쌓였다면 따라잡는 데도 그만큼 걸리고, 그동안 standby의 데이터는 오래된 상태입니다.\n조치 증상 확인 조치 write_lag, flush_lag가 큼 네트워크 대역폭, standby 디스크 I/O 네트워크·디스크 증설, WAL 양 줄이기(대량 작업 분산) replay_lag와 replay_gap이 큼, 충돌 대기 로그 standby 로그의 recovery still waiting, pg_stat_database_conflicts standby의 긴 쿼리 정리, max_standby_streaming_delay 조정 충돌 없이 재생만 느림 standby의 CPU, I/O standby 자원 확인. 재생은 프로세스 하나가 순서대로 하므로 쓰기가 많으면 따라가지 못할 수 있음 standby 쿼리가 자주 취소됨 canceling statement due to conflict with recovery 쿼리를 짧게, 지연 허용치 조정, 또는 hot_standby_feedback(primary 영향 감수) standby가 끊김 pg_stat_replication에 없음, slot active = f standby 복구. 오래 걸리면 max_slot_wal_keep_size로 primary 보호(6편) 재발 방지 바이트 지연을 감시합니다. primary에서 pg_wal_lsn_diff(sent_lsn, replay_lsn), standby에서 pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()). 시간 값(replay_lag)은 재생이 멈추면 늦게 반응합니다. 연결 여부를 따로 감시합니다. pg_stat_replication의 행 수, slot의 active, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn). 충돌을 기록합니다. standby에 log_recovery_conflict_waits = on, pg_stat_database_conflicts 수집. standby 쿼리에도 타임아웃을 겁니다. 특히 hot_standby_feedback = on이면 standby의 긴 쿼리가 primary를 부풀립니다. 보고서처럼 오래 도는 쿼리를 standby에 보낸다면, 그 standby는 지연 허용치를 크게 잡고 장애 조치(failover) 대상과 분리하는 것도 방법입니다. 정리 지연은 write, flush, replay 단계로 나눠 봅니다. 대부분은 replay 지연입니다. replay_lag는 재생이 멈춘 동안 실제보다 작게 보입니다. 바이트 차이(replay_gap)와 standby의 받은/재생한 위치를 같이 봅니다. standby의 긴 쿼리는 primary의 VACUUM이 지운 행을 볼 수 있으므로 재생을 멈춥니다. max_standby_streaming_delay가 지나면 쿼리가 canceling statement due to conflict with recovery로 취소됩니다. hot_standby_feedback = on이면 취소되지 않는 대신 primary의 VACUUM이 dead but not yet removable로 막힙니다. standby가 끊기면 pg_stat_replication에서 사라지고 slot이 WAL을 붙잡습니다. 연결 여부와 slot을 따로 감시합니다. 참고 자료 Log-Shipping Standby Servers Hot Standby: 쿼리 충돌 처리 pg_stat_replication, pg_stat_database_conflicts Replication 설정: max_standby_streaming_delay, hot_standby_feedback pg_basebackup PostgreSQL 인터널 9편 스트리밍 복제와 Replication Slot ","permalink":"https://jinlog.dev/posts/postgresql-ops/08-standby-lag/","summary":"standby의 데이터가 늦을 때 어디서 늦는지 나눠 보고, 쿼리 취소와 primary bloat 사이에서 무엇을 고르는가","title":"PostgreSQL 운영 8: standby가 뒤처진다"},{"content":"개요 7편에서 커밋은 WAL이 디스크에 닿을 때까지만 기다리고, 데이터 페이지는 메모리(shared buffers)에 dirty 상태로 남겨 둔다고 했습니다. 그렇다면 두 가지 질문이 생깁니다.\n데이터 페이지는 언제 디스크에 쓰이는가. 영원히 메모리에만 둘 수는 없습니다. 서버가 갑자기 죽으면 WAL을 어디서부터 다시 적용해야 하는가. 클러스터를 만든 뒤의 WAL을 전부 다시 적용할 수는 없습니다. 두 질문의 답이 체크포인트(checkpoint)입니다. 체크포인트는 \u0026ldquo;이 위치 이전의 변경은 모두 데이터 파일에 반영되었다\u0026quot;는 표시를 남기는 작업이고, 장애 복구는 마지막 체크포인트가 남긴 그 위치부터 WAL을 다시 적용합니다. 같은 원리를 백업에 적용하면, 원하는 시점까지만 되돌리는 PITR(Point-In-Time Recovery)이 됩니다.\n이 글에서 답할 질문은 다음과 같습니다.\n체크포인트는 무엇을 하고, 언제 일어나는가 체크포인트의 쓰기를 왜 나눠서 하는가 장애 뒤 재시작하면 복구는 어떤 순서로 진행되는가 PITR은 어떻게 원하는 시점에서 멈추는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n체크포인트가 하는 일 체크포인트는 checkpointer 프로세스(1편)가 CreateCheckPoint()로 수행합니다.\n먼저 REDO 위치를 WAL에 표시하고, 그 이전에 바뀐 페이지를 모두 디스크에 쓴 뒤, 완료 기록을 남기고 pg_control을 갱신합니다. 새 창에서 크게 보기 REDO 위치 표시: WAL에 CHECKPOINT_REDO 레코드를 넣습니다(xlog.c). 이 레코드의 시작 위치가 이번 체크포인트의 REDO 위치입니다. 이 순간부터 페이지를 처음 고치는 변경은 다시 페이지 이미지(FPI)를 남깁니다(7편). 쓰기: SLRU(CLOG, multixact 등 4편), 그리고 shared buffers의 dirty 페이지를 데이터 파일에 씁니다(CheckPointGuts()). 쓸 대상은 체크포인트를 시작한 순간 dirty였던 페이지입니다(BufferSync()에서 BM_CHECKPOINT_NEEDED 표시). 쓰는 동안 다른 세션이 계속 페이지를 바꿔도 괜찮습니다. 그 변경은 REDO 위치 뒤의 WAL에 있으므로 복구가 다시 적용해 줍니다. 파일 순서, 블록 순서로 정렬해서 쓰기 때문에 디스크 입장에서는 비교적 순차적인 쓰기가 됩니다(bufmgr.c). fsync: 쓴 파일들을 모두 fsync해서 실제로 디스크에 닿게 합니다(ProcessSyncRequests()). 완료 기록: CHECKPOINT_ONLINE 레코드를 넣고 flush합니다(xlog.c). 이 레코드 안에 1단계의 REDO 위치, 다음 xid, 다음 OID 같은 정보가 들어 있습니다. pg_control 갱신: $PGDATA/global/pg_control 파일에 \u0026ldquo;마지막 체크포인트 레코드는 여기\u0026quot;라고 적습니다(xlog.c). pg_control은 내용이 512바이트도 안 되는 작은 파일(파일 크기 8kB)로, 서버가 켜질 때 가장 먼저 읽는 곳입니다(pg_control.h). 옛 WAL 정리: 이제 REDO 위치 이전의 WAL은 장애 복구에 필요 없으므로, 그 세그먼트를 지우거나 이름을 바꿔 재활용합니다. 단 wal_keep_size, replication slot(9편)이 요구하는 몫과 아직 보관(archive)되지 않은 세그먼트는 남깁니다(RemoveOldXlogFiles(), 7편). REDO 레코드와 완료 레코드를 따로 두는 이유는, 체크포인트가 몇 분씩 걸리는 동안에도 다른 세션이 WAL을 계속 쓸 수 있게 하기 위해서입니다(xlog.c). 서버를 정상 종료할 때 하는 shutdown 체크포인트는 그동안 다른 WAL이 끼어들 수 없으므로 CHECKPOINT_SHUTDOWN 레코드 하나가 REDO 위치이자 완료 기록입니다.\npg_control: 서버가 기억하는 마지막 체크포인트 서버 로그에 프로세스 종류가 나오도록 log_line_prefix에 %b를 넣고 서버를 띄운 뒤, 20만 행짜리 테이블 acct를 만듭니다. log_checkpoints는 PG15부터 기본으로 켜져 있어 체크포인트마다 로그가 남습니다.\n$ cat \u0026gt;\u0026gt; $PGDATA/postgresql.conf \u0026lt;\u0026lt;\u0026#39;CONF\u0026#39; \u003e log_line_prefix = \u0026#39;%m [%p] %b \u0026#39; \u003e CONF $ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE EXTENSION pg_walinspect; postgres=# CREATE TABLE acct (id int PRIMARY KEY, balance int, pad text); postgres=# INSERT INTO acct SELECT g, 100, repeat(\u0026#39;p\u0026#39;, 200) FROM generate_series(1, 200000) g; $ pg_controldata $PGDATA | grep -E \u0026#34;cluster state|Latest checkpoint location|Latest checkpoint\u0026#39;s REDO location|Latest checkpoint\u0026#39;s REDO WAL file|Time of latest checkpoint\u0026#34; Database cluster state: in production Latest checkpoint location: 0/1755398 Latest checkpoint\u0026#39;s REDO location: 0/1755398 Latest checkpoint\u0026#39;s REDO WAL file: 000000010000000000000001 Time of latest checkpoint: Sat Sep 26 10:10:10 2026 postgres=# SHOW checkpoint_timeout; checkpoint_timeout -------------------- 5min (1 row) postgres=# SHOW max_wal_size; max_wal_size -------------- 1GB (1 row) postgres=# SHOW checkpoint_completion_target; checkpoint_completion_target ------------------------------ 0.9 (1 row) postgres=# SHOW log_checkpoints; log_checkpoints ----------------- on (1 row) pg_controldata는 pg_control 파일의 내용을 보여 줍니다. 지금 마지막 체크포인트는 initdb가 끝나며 한 shutdown 체크포인트라, 체크포인트 레코드 위치와 REDO 위치가 0/1755398로 같습니다. 기본값은 checkpoint_timeout 5분, max_wal_size 1GB, checkpoint_completion_target 0.9입니다.\n수동 CHECKPOINT 5만 행을 바꾼 뒤 CHECKPOINT를 실행합니다. 앞에서 20만 행을 넣은 뒤로 체크포인트가 한 번도 없었으므로, 그 INSERT로 dirty가 된 페이지도 아직 메모리에만 있습니다.\npostgres=# UPDATE acct SET balance = balance + 1 WHERE id \u0026lt;= 50000; postgres=# SELECT num_timed, num_requested, num_done, buffers_written FROM pg_stat_checkpointer; num_timed | num_requested | num_done | buffers_written -----------+---------------+----------+----------------- 0 | 0 | 0 | 0 (1 row) postgres=# SELECT pg_current_wal_insert_lsn() AS before_checkpoint; before_checkpoint ------------------- 0/6C8D540 (1 row) postgres=# CHECKPOINT; $ grep -E \u0026#34;checkpoint (starting|complete)\u0026#34; /home/postgres/server.log | tail -2 2026-09-26 10:10:11.273 UTC [39] checkpointer LOG: checkpoint starting: immediate force wait 2026-09-26 10:10:11.331 UTC [39] checkpointer LOG: checkpoint complete: wrote 8337 buffers (50.9%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 5 recycled; write=0.013 s, sync=0.028 s, total=0.059 s; sync files=52, longest=0.014 s, average=0.001 s; distance=87264 kB, estimate=87264 kB; lsn=0/6C8D598, redo lsn=0/6C8D540 $ pg_controldata $PGDATA | grep -E \u0026#34;Latest checkpoint location|Latest checkpoint\u0026#39;s REDO location\u0026#34; Latest checkpoint location: 0/6C8D598 Latest checkpoint\u0026#39;s REDO location: 0/6C8D540 postgres=# SELECT num_timed, num_requested, num_done, buffers_written FROM pg_stat_checkpointer; num_timed | num_requested | num_done | buffers_written -----------+---------------+----------+----------------- 0 | 1 | 1 | 8337 (1 row) 로그 두 줄이 체크포인트의 시작과 끝입니다.\nimmediate force wait: 쉬지 않고 쓰며(immediate), 지난 체크포인트 뒤 WAL 변화가 없어도 건너뛰지 않고(force), 요청한 쪽이 끝날 때까지 기다렸다(wait)는 뜻입니다. CHECKPOINT 명령이 이 세 플래그를 붙입니다. wrote 8337 buffers (50.9%): shared buffers 16384개(128MB) 가운데 dirty였던 8337개를 썼습니다. 대부분은 20만 행 INSERT로 생긴 페이지이고, 5만 행 UPDATE분이 더해졌습니다. pg_stat_checkpointer의 buffers_written도 0에서 8337이 되었습니다. write=0.013 s, sync=0.028 s: 쓰기와 fsync에 걸린 시간입니다. write 단계는 운영체제 페이지 캐시에 넘기는 것이라 금방 끝나고, 실제로 디스크에 닿게 하는 것은 sync 단계입니다. 5 recycled: REDO 이전의 WAL 세그먼트 5개를 재활용했습니다. distance=87264 kB: 이전 체크포인트의 REDO부터 이번 REDO까지의 WAL 양입니다. estimate는 이 값의 이동 평균으로, 다음 체크포인트까지 쓸 세그먼트를 얼마나 남겨 둘지 정하는 데 씁니다. CHECKPOINT 직전의 insert 위치 0/6C8D540이 그대로 새 REDO 위치가 되었고, 체크포인트 레코드는 그 뒤 0/6C8D598에 있습니다.\nWAL 안의 체크포인트 레코드 pg_control이 가리키는 REDO 위치 0/6C8D540부터 현재 insert 위치까지의 레코드를 WAL에서 직접 봅니다.\npostgres=# SELECT start_lsn, resource_manager AS rmgr, record_type, record_length AS len, left(description, 90) AS description FROM pg_get_wal_records_info(\u0026#39;0/6C8D540\u0026#39;, pg_current_wal_insert_lsn()); start_lsn | rmgr | record_type | len | description -----------+---------+-------------------+-----+-------------------------------------------------------------------------------------------- 0/6C8D540 | XLOG | CHECKPOINT_REDO | 30 | wal_level replica 0/6C8D560 | Standby | RUNNING_XACTS | 50 | nextXid 756 latestCompletedXid 755 oldestRunningXid 756 0/6C8D598 | XLOG | CHECKPOINT_ONLINE | 114 | redo 0/6C8D540; tli 1; prev tli 1; fpw true; wal_level replica; xid 0:756; oid 24576; mult (3 rows) REDO 위치 0/6C8D540에 CHECKPOINT_REDO가 있고, 완료 기록 CHECKPOINT_ONLINE은 0/6C8D598에 있습니다. 완료 기록의 설명에 있는 redo 0/6C8D540이 REDO 위치를 다시 가리킵니다. 둘 사이의 RUNNING_XACTS는 체크포인트가 완료 기록 직전에 standby를 위해 남기는 \u0026ldquo;지금 실행 중인 트랜잭션 목록\u0026quot;입니다(xlog.c, 9편).\n이번에는 다른 세션이 없어 레코드가 셋뿐이지만, 바쁜 서버라면 체크포인트가 페이지를 쓰는 동안 생긴 모든 변경 레코드가 REDO와 완료 기록 사이에 들어갑니다. 완료 기록에는 xid 0:756(다음에 줄 xid), oid 24576(다음에 줄 OID) 같은 값도 담겨 있어서 복구를 마친 서버가 번호를 이어서 쓸 수 있습니다.\n체크포인트는 언제 일어나는가 원인 로그의 표시 설명 시간 time 마지막 체크포인트 시작 뒤 checkpoint_timeout(기본 5분)이 지남. 그동안 WAL 변화가 없으면 건너뜀 WAL 양 wal REDO 위치 이후 WAL이 max_wal_size(기본 1GB)에서 계산한 양만큼 쌓임 명령 immediate force wait 등 CHECKPOINT 명령, pg_basebackup 시작, 정상 종료 등 복구 끝 end-of-recovery 장애 복구를 마친 직후 checkpointer는 주기적으로 깨어나 경과 시간을 보고 time 체크포인트를 시작합니다(checkpointer.c). wal 체크포인트는 WAL을 디스크에 쓰는 프로세스(주로 backend, walwriter)가 WAL 세그먼트 하나를 다 쓸 때마다 확인해서 요청합니다(xlog.c).\nmax_wal_size는 \u0026ldquo;WAL이 이만큼 쌓이면 체크포인트\u0026quot;라는 뜻이 아닙니다. 체크포인트가 진행되는 동안에도 WAL이 쌓이므로, PostgreSQL은 max_wal_size / (1 + checkpoint_completion_target)만큼 쌓이면 체크포인트를 시작합니다(CalculateCheckpointSegments()). 기본값(1GB, 0.9)이면 1024MB / 1.9 = 약 539MB, 세그먼트로는 33개이고, 실제로는 REDO 위치의 세그먼트에서 32개를 더 채운 순간(XLogCheckpointNeeded()), 대략 512MB쯤에서 요청됩니다.\nWAL이 많이 쌓여도 체크포인트가 일어난다 max_wal_size를 64MB로 줄이고, 20만 행 전체를 두 번 바꿉니다.\npostgres=# ALTER SYSTEM SET max_wal_size = \u0026#39;64MB\u0026#39;; postgres=# SELECT pg_reload_conf(); postgres=# SHOW max_wal_size; max_wal_size -------------- 64MB (1 row) postgres=# UPDATE acct SET balance = balance + 1; postgres=# UPDATE acct SET balance = balance + 1; $ sleep 2 $ grep -E \u0026#34;checkpoint starting|checkpoints are occurring too frequently\u0026#34; /home/postgres/server.log | tail -4 | cut -c1-140 2026-09-26 10:10:12.461 UTC [39] checkpointer LOG: checkpoints are occurring too frequently (0 seconds apart) 2026-09-26 10:10:12.461 UTC [39] checkpointer LOG: checkpoint starting: wal 2026-09-26 10:10:12.606 UTC [39] checkpointer LOG: checkpoints are occurring too frequently (0 seconds apart) 2026-09-26 10:10:12.606 UTC [39] checkpointer LOG: checkpoint starting: wal postgres=# SELECT num_timed, num_requested, num_done FROM pg_stat_checkpointer; num_timed | num_requested | num_done -----------+---------------+---------- 0 | 9 | 8 (1 row) postgres=# ALTER SYSTEM RESET max_wal_size; postgres=# SELECT pg_reload_conf(); UPDATE가 WAL을 많이 만드는 동안 wal 체크포인트가 연달아 일어났습니다. max_wal_size가 64MB면 64MB / 1.9 = 약 34MB라 세그먼트 2개로 내림되고, REDO 위치가 있는 세그먼트 다음 세그먼트 하나만 채워도 체크포인트가 요청됩니다. REDO 뒤 16~32MB마다 체크포인트를 하는 셈이라, 앞 체크포인트가 끝나자마자 다음이 시작되고 간격이 checkpoint_warning(30초)보다 짧아 occurring too frequently 경고가 붙었습니다.\nnum_requested 9는 앞에서 실행한 CHECKPOINT 명령 1번과 이번 wal 8번입니다. num_requested는 체크포인트를 시작할 때, num_done은 끝날 때 늘어나는데, 마지막 하나는 UPDATE가 끝나 WAL이 더 늘지 않자 시간 기준 일정에 맞춰 천천히 쓰는 중이라 num_done이 8입니다.\n운영에서는: checkpoints are occurring too frequently 이 경고는 wal 체크포인트가 checkpoint_warning(기본 30초)보다 짧은 간격으로 일어날 때 나옵니다(checkpointer.c). 대량 적재나 배치 UPDATE 때 자주 보입니다. 체크포인트가 잦으면 체크포인트 직후마다 페이지 이미지가 쏟아지고(7편), 쓰기를 나눌 시간도 없어 디스크 I/O가 튑니다. 보통은 로그의 힌트대로 max_wal_size를 늘려 해결합니다. pg_stat_checkpointer에서 num_requested가 num_timed보다 훨씬 많다면 같은 신호입니다.\n쓰기를 나눠서 한다: checkpoint_completion_target dirty 페이지 수천, 수만 개를 한꺼번에 쓰면 그동안 디스크가 포화되어 다른 쿼리가 느려집니다. 그래서 time, wal 체크포인트는 쓰기를 다음 체크포인트까지 남은 시간의 checkpoint_completion_target(기본 0.9) 비율에 걸쳐 나눠서 합니다. 페이지를 하나 쓸 때마다 진행률을 확인해서 예정보다 앞서 있으면 잠깐 쉽니다(CheckpointWriteDelay(), IsCheckpointOnSchedule()). 진행률은 시간 기준과 WAL 양 기준을 모두 보고 더 급한 쪽에 맞춥니다.\n반면 CHECKPOINT 명령이나 복구 끝의 체크포인트는 immediate 플래그가 붙어 쉬지 않고 최대한 빨리 씁니다.\n시간 기준 체크포인트는 쓰기를 나눠서 한다 checkpoint_timeout을 허용되는 최솟값인 30초로 줄이고, 전체 행을 바꾼 뒤 다음 time 체크포인트가 끝날 때까지 기다립니다.\npostgres=# ALTER SYSTEM SET checkpoint_timeout = \u0026#39;30s\u0026#39;; postgres=# SELECT pg_reload_conf(); postgres=# CHECKPOINT; postgres=# UPDATE acct SET balance = balance + 1; $ for i in $(seq 1 120); do \u003e grep -A1 \u0026#34;checkpoint starting: time\u0026#34; /home/postgres/server.log | grep -q \u0026#34;checkpoint complete\u0026#34; \u0026amp;\u0026amp; break \u003e sleep 1 \u003e done $ grep -A1 \u0026#34;checkpoint starting: time\u0026#34; /home/postgres/server.log | head -2 | cut -c1-230 2026-09-26 10:10:44.814 UTC [39] checkpointer LOG: checkpoint starting: time 2026-09-26 10:11:11.048 UTC [39] checkpointer LOG: checkpoint complete: wrote 6484 buffers (39.6%), wrote 1 SLRU buffers; 0 WAL file(s) added, 0 removed, 11 recycled; write=26.198 s, sync=0.025 s, total=26.235 s; sync files=6, lo postgres=# ALTER SYSTEM RESET checkpoint_timeout; postgres=# SELECT pg_reload_conf(); time 체크포인트가 10:10:44.814에 시작해 10:11:11.048에 끝났고, write=26.198 s입니다. checkpoint_timeout 30초 × checkpoint_completion_target 0.9 = 27초에 맞춰 6484개 페이지를 나눠 쓴 것입니다. 진행 시간은 초 단위로 자른 시작 시각(10:10:44)부터 재므로 27초 뒤인 10:11:11 무렵에 끝났고, 그래서 write가 27초보다 조금 짧게 나왔습니다. 수동 CHECKPOINT의 immediate 체크포인트가 8337개를 0.013초에 쓴 것과 비교하면, 비슷한 양을 일부러 천천히 썼다는 것을 알 수 있습니다. 기본값(5분)이라면 약 4분 30초에 걸쳐 씁니다.\n운영에서는: 체크포인트 동안 쿼리가 느려진다 checkpoint_completion_target을 낮게 두면 짧은 시간에 쓰기가 몰립니다. 기본값 0.9를 그대로 두는 편이 대부분 낫습니다. 또 로그의 sync= 시간이 길다면 운영체제 페이지 캐시에 쌓인 쓰기가 fsync 순간 한꺼번에 나가는 것이므로, 쓰기 자체를 줄이거나 디스크 성능을 봐야 합니다.\n장애 복구 과정 서버가 정상 종료하지 못하고 죽은 뒤(전원 차단, 커널 OOM killer, pg_ctl stop -m immediate 등) 다시 켜면 다음 순서로 복구합니다.\npg_control에서 마지막 체크포인트를 찾고, 그 REDO 위치부터 WAL 끝까지 다시 적용한 뒤, 체크포인트를 한 번 하고 접속을 받습니다. 새 창에서 크게 보기 postmaster가 startup 프로세스를 띄우고, startup은 StartupXLOG()를 실행합니다. pg_control의 상태가 정상 종료(shut down)가 아니라 in production이면, 정상 종료하지 못했다는 뜻입니다(xlog.c). pg_control이 가리키는 마지막 체크포인트 레코드를 읽어 REDO 위치를 얻습니다(xlogrecovery.c). REDO 위치부터 WAL 레코드를 차례로 읽어, 레코드 종류(rmgr)마다 정해진 재생 함수로 적용합니다(xlogrecovery.c). 더 읽을 수 있는 올바른 레코드가 없으면(길이가 0이거나 CRC가 맞지 않으면) 거기가 WAL의 끝이라고 보고 재생을 멈춥니다. 체크포인트를 한 번 해서(end-of-recovery) 복구한 결과를 디스크에 확정하고(PerformRecoveryXLogAction()), 접속을 받기 시작합니다. 장애 복구: 마지막 체크포인트부터 WAL을 다시 적용한다 합계를 확인하고 CHECKPOINT한 뒤, 10만 행에 1씩 더하고(합계 +100000) 행 하나(balance 777)를 넣습니다. 그리고 pg_ctl stop -m immediate로 서버를 체크포인트 없이 즉시 죽입니다. 전원이 나간 것과 비슷한 상황입니다.\npostgres=# SELECT sum(balance) AS total, count(*) FROM acct; total | count ----------+-------- 20650000 | 200000 (1 row) postgres=# CHECKPOINT; postgres=# UPDATE acct SET balance = balance + 1 WHERE id \u0026lt;= 100000; postgres=# INSERT INTO acct VALUES (300001, 777, \u0026#39;after checkpoint\u0026#39;); postgres=# SELECT pg_current_wal_insert_lsn() AS insert_lsn, pg_current_wal_flush_lsn() AS flush_lsn; insert_lsn | flush_lsn ------------+------------ 0/30DFA7C8 | 0/30DFA7C8 (1 row) $ pg_controldata $PGDATA | grep -E \u0026#34;Latest checkpoint\u0026#39;s REDO location\u0026#34; Latest checkpoint\u0026#39;s REDO location: 0/2CD4C480 $ pg_ctl -D $PGDATA stop -m immediate waiting for server to shut down.... done server stopped $ pg_controldata $PGDATA | grep -E \u0026#34;cluster state\u0026#34; Database cluster state: in production 죽기 직전 insert 위치와 flush 위치가 모두 0/30DFA7C8입니다. 마지막 INSERT가 커밋하면서 WAL을 이 위치까지 flush했기 때문입니다(7편). 마지막 체크포인트의 REDO 위치는 0/2CD4C480입니다. 죽인 뒤에도 pg_control의 상태는 in production으로 남았습니다. 정상 종료였다면 shutdown 체크포인트를 하고 상태를 shut down으로 바꿨을 것입니다.\n다시 켭니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started $ sed -n \u0026#39;/database system was interrupted/,/database system is ready/p\u0026#39; /home/postgres/server.log | tail -12 | cut -c1-220 2026-09-26 10:11:12.349 UTC [343] startup LOG: database system was interrupted; last known up at 2026-09-26 10:11:12 UTC 2026-09-26 10:11:12.372 UTC [343] startup LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 10:11:12.372 UTC [343] startup LOG: redo starts at 0/2CD4C480 2026-09-26 10:11:12.455 UTC [343] startup LOG: invalid record length at 0/30DFA7C8: expected at least 24, got 0 2026-09-26 10:11:12.455 UTC [343] startup LOG: redo done at 0/30DFA7A0 system usage: CPU: user: 0.05 s, system: 0.02 s, elapsed: 0.08 s 2026-09-26 10:11:12.456 UTC [341] checkpointer LOG: checkpoint starting: end-of-recovery immediate wait 2026-09-26 10:11:12.495 UTC [341] checkpointer LOG: checkpoint complete: wrote 6615 buffers (40.4%), wrote 3 SLRU buffers; 0 WAL file(s) added, 4 removed, 0 recycled; write=0.011 s, sync=0.015 s, total=0.039 s; sync fil 2026-09-26 10:11:12.497 UTC [337] postmaster LOG: database system is ready to accept connections postgres=# SELECT sum(balance) AS total, count(*) FROM acct; total | count ----------+-------- 20750777 | 200001 (1 row) postgres=# SELECT * FROM acct WHERE id = 300001; id | balance | pad --------+---------+------------------ 300001 | 777 | after checkpoint (1 row) $ pg_controldata $PGDATA | grep -E \u0026#34;cluster state\u0026#34; Database cluster state: in production startup 프로세스의 로그가 복구 과정을 그대로 보여 줍니다.\nwas interrupted, not properly shut down: pg_control의 상태를 보고 복구를 시작했습니다. redo starts at 0/2CD4C480: pg_control이 가리키는 REDO 위치부터 재생합니다. invalid record length at 0/30DFA7C8: 죽기 전 flush 위치와 정확히 같은 곳에서 더 읽을 레코드가 없어 멈췄습니다. redo done at 0/30DFA7A0은 마지막으로 적용한 레코드(마지막 INSERT의 커밋 레코드)의 시작 위치입니다. REDO부터 약 65MB의 WAL을 0.08초에 재생했고, 이어서 end-of-recovery 체크포인트를 한 뒤 접속을 받기 시작했습니다. 합계는 20650000에서 정확히 100000 + 777만큼 늘어난 20750777이고, 체크포인트 뒤에 넣은 행도 있습니다. 데이터 파일에는 아직 반영되지 않았던 변경이 WAL에서 모두 되살아났습니다.\n같은 변경을 두 번 적용하지 않는 장치: 페이지 LSN 복구 4단계에서 같은 변경을 두 번 적용하지 않는 장치가 페이지 LSN입니다. 레코드가 건드리는 페이지를 읽었을 때,\n레코드에 페이지 이미지가 있으면 그 이미지로 페이지를 통째로 덮습니다(xlogutils.c). torn page도 이렇게 되살아납니다. 이미지가 없으면 페이지 LSN과 레코드 LSN을 비교해서 페이지가 이미 이 레코드 이후의 상태라면 건너뜁니다(xlogutils.c). 체크포인트 도중이나 그 뒤에 이미 디스크에 쓰인 페이지가 여기에 해당합니다. 그래서 복구는 몇 번을 다시 해도 같은 결과가 나옵니다. 복구 도중 다시 죽어도 처음부터 다시 하면 됩니다.\n체크포인트 간격과 복구 시간 복구 시간은 REDO 위치 이후의 WAL 양에 비례합니다. 체크포인트를 자주 하면 복구는 빨라지지만 페이지 이미지와 쓰기가 늘고, 드물게 하면 반대입니다. checkpoint_timeout과 max_wal_size가 이 균형을 정합니다.\n복구 시간은 마지막 체크포인트 이후 WAL 양에 비례한다 체크포인트가 저절로 일어나지 않도록 max_wal_size를 4GB, checkpoint_timeout을 1시간으로 늘리고, 전체 행 UPDATE를 다섯 번 한 뒤 죽입니다.\npostgres=# ALTER SYSTEM SET max_wal_size = \u0026#39;4GB\u0026#39;; postgres=# ALTER SYSTEM SET checkpoint_timeout = \u0026#39;1h\u0026#39;; postgres=# SELECT pg_reload_conf(); postgres=# CHECKPOINT; postgres=# UPDATE acct SET balance = balance + 1; postgres=# UPDATE acct SET balance = balance + 1; postgres=# UPDATE acct SET balance = balance + 1; postgres=# UPDATE acct SET balance = balance + 1; postgres=# UPDATE acct SET balance = balance + 1; postgres=# SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(), (SELECT redo_lsn FROM pg_control_checkpoint()))) AS wal_since_redo; wal_since_redo ---------------- 453 MB (1 row) $ pg_ctl -D $PGDATA stop -m immediate waiting for server to shut down.... done server stopped $ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start..... done server started $ grep -E \u0026#34;redo starts|redo done\u0026#34; /home/postgres/server.log | tail -2 | cut -c1-200 2026-09-26 10:11:15.490 UTC [389] startup LOG: redo starts at 0/33D772A8 2026-09-26 10:11:16.327 UTC [389] startup LOG: redo done at 0/502C9C20 system usage: CPU: user: 0.74 s, system: 0.08 s, elapsed: 0.83 s REDO 이후 WAL 453MB를 재생하는 데 0.83초 걸렸습니다. 앞의 장애 복구(약 65MB, 0.08초)보다 WAL이 약 7배이고 시간은 약 10배입니다. 재생은 startup 프로세스 하나가 순서대로 합니다. 여기서는 테이블이 작아 페이지가 모두 메모리에 있으므로 빠르지만, 실제 서버에서는 재생할 페이지를 디스크에서 읽어야 하므로 훨씬 오래 걸릴 수 있습니다. 어느 쪽이든 복구 시간은 REDO 이후 WAL 양에 따라 늘어납니다.\n운영에서는: 재시작이 오래 걸린다 장애 뒤 재시작이 오래 걸린다면 REDO 위치 이후 WAL이 많이 쌓여 있었다는 뜻입니다. max_wal_size를 아주 크게, checkpoint_timeout을 아주 길게 잡았다면 복구에 필요한 WAL도 그만큼 늘어납니다. 복구 중에는 로그에 redo in progress, elapsed time: ... 진행 메시지가 주기적으로 나오므로(log_startup_progress_interval, 기본 10초) 얼마나 남았는지 가늠할 수 있습니다.\nPITR: 백업과 WAL로 원하는 시점까지 장애 복구는 \u0026ldquo;마지막 체크포인트 + 그 뒤의 WAL\u0026quot;로 데이터를 되살립니다. 같은 원리를 넓히면 이렇게 됩니다.\n베이스 백업: 데이터 디렉터리 전체의 복사본입니다(pg_basebackup). 서버가 도는 중에 복사하므로 파일마다 시점이 제각각인데, 괜찮습니다. 백업을 시작할 때 체크포인트를 하고 그 REDO 위치를 backup_label 파일에 적어 두기 때문에, 복원할 때 그 위치부터 WAL을 적용해 백업이 끝난 위치까지 재생하면 일관된 상태가 됩니다. 그래서 PITR로 멈출 지점은 백업이 끝난 시점보다 뒤여야 합니다(read_backup_label()). WAL 보관(archiving): archive_mode = on이면 archiver 프로세스가 다 쓴 세그먼트를 archive_command로 다른 곳에 복사합니다. 체크포인트가 옛 WAL을 지워도 보관본은 남습니다. 복원: 백업을 풀고 recovery.signal 파일을 만든 뒤 켜면, restore_command로 보관한 WAL을 하나씩 가져와 적용합니다. recovery_target_time, recovery_target_lsn, recovery_target_name 등으로 멈출 지점을 정하면 거기서 멈춥니다(recoveryStopsAfter()). 복원한 서버가 멈춘 지점부터 새 WAL을 쓰기 시작하면, 원래 서버가 그 뒤로 쓴 WAL과 번호가 겹칩니다. 그래서 복구를 끝낼 때 새 타임라인 ID를 골라(xlog.c) 이후 WAL 파일 이름의 앞 8자리를 바꾸고, 어디서 갈라졌는지를 .history 파일에 남깁니다(Timelines). standby를 승격할 때도 같은 일이 일어납니다(9편).\nPITR: 실수로 지운 테이블 되살리기 WAL 보관을 켜고 베이스 백업을 뜬 뒤, 행 하나를 더 넣고 복원 지점(restore point)을 만든 다음, 실수로 테이블을 지웠다고 해 봅니다.\n$ mkdir -p /home/postgres/archive postgres=# ALTER SYSTEM RESET ALL; postgres=# ALTER SYSTEM SET archive_mode = on; postgres=# ALTER SYSTEM SET archive_command = \u0026#39;cp %p /home/postgres/archive/%f\u0026#39;; $ pg_ctl -D $PGDATA -l /home/postgres/server.log restart \u0026gt; /dev/null $ pg_basebackup -D /home/postgres/backup -c fast \u0026amp;\u0026amp; echo \u0026#34;base backup ok\u0026#34; base backup ok $ ls /home/postgres/backup | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; echo PG_VERSION backup_label backup_manifest base global pg_commit_ts pg_dynshmem pg_hba.conf pg_ident.conf pg_logical pg_multixact pg_notify pg_replslot pg_serial pg_snapshots pg_stat pg_stat_tmp pg_subtrans pg_tblspc pg_twophase pg_wal pg_xact postgresql.auto.conf postgresql.conf $ cat /home/postgres/backup/backup_label START WAL LOCATION: 0/51000028 (file 000000010000000000000051) CHECKPOINT LOCATION: 0/51000080 BACKUP METHOD: streamed BACKUP FROM: primary START TIME: 2026-09-26 10:11:16 UTC LABEL: pg_basebackup base backup START TIMELINE: 1 postgres=# INSERT INTO acct VALUES (400001, 1, \u0026#39;before mistake\u0026#39;); postgres=# SELECT pg_create_restore_point(\u0026#39;before_drop\u0026#39;); pg_create_restore_point ------------------------- 0/52001CD0 (1 row) postgres=# DROP TABLE acct; postgres=# SELECT pg_switch_wal(); $ sleep 2 postgres=# SELECT archived_count, last_archived_wal, failed_count FROM pg_stat_archiver; archived_count | last_archived_wal | failed_count ----------------+--------------------------+-------------- 4 | 000000010000000000000052 | 0 (1 row) $ ls /home/postgres/archive 000000010000000000000050 000000010000000000000051 000000010000000000000051.00000028.backup 000000010000000000000052 pg_basebackup -c fast는 백업을 시작하며 체크포인트를 하고 데이터 디렉터리 전체를 복사했습니다. backup_label의 START WAL LOCATION: 0/51000028이 이 백업을 복원할 때 WAL 적용을 시작할 REDO 위치입니다. pg_create_restore_point('before_drop')는 WAL에 이름 붙은 표시 레코드를 남기고, 그 레코드의 끝 위치 0/52001CD0을 돌려줍니다. pg_switch_wal()로 현재 세그먼트를 마감해서 archiver가 보관하게 했습니다. 보관소에는 세그먼트 3개와, 백업 시작과 끝 위치를 적은 .backup 파일이 있습니다(archived_count 4). 원래 서버의 acct 테이블은 이제 없습니다. 백업 디렉터리를 복원용 서버(포트 5433)로 켜서, 복원 지점까지만 복구합니다. 프롬프트 restored=#는 복원한 서버(포트 5433), original=#는 원래 서버(포트 5432)입니다.\n$ cat \u0026gt;\u0026gt; /home/postgres/backup/postgresql.auto.conf \u0026lt;\u0026lt;\u0026#39;CONF\u0026#39; \u003e port = 5433 \u003e restore_command = \u0026#39;cp /home/postgres/archive/%f %p\u0026#39; \u003e recovery_target_name = \u0026#39;before_drop\u0026#39; \u003e recovery_target_action = \u0026#39;promote\u0026#39; \u003e CONF $ touch /home/postgres/backup/recovery.signal $ pg_ctl -D /home/postgres/backup -l /home/postgres/pitr.log start waiting for server to start.... done server started $ sleep 2 $ grep -E \u0026#34;starting point-in-time|redo starts|restored log file|recovery stopping|redo done|selected new timeline|archive recovery complete|ready to accept\u0026#34; /home/postgres/pitr.log | cut -c1-200 2026-09-26 10:11:19.871 UTC [456] startup LOG: restored log file \u0026#34;000000010000000000000051\u0026#34; from archive 2026-09-26 10:11:19.877 UTC [456] startup LOG: starting point-in-time recovery to \u0026#34;before_drop\u0026#34; 2026-09-26 10:11:19.881 UTC [456] startup LOG: redo starts at 0/51000028 2026-09-26 10:11:19.884 UTC [456] startup LOG: restored log file \u0026#34;000000010000000000000052\u0026#34; from archive 2026-09-26 10:11:19.893 UTC [450] postmaster LOG: database system is ready to accept read-only connections 2026-09-26 10:11:19.893 UTC [456] startup LOG: recovery stopping at restore point \u0026#34;before_drop\u0026#34;, time 2026-09-26 10:11:17.702584+00 2026-09-26 10:11:19.893 UTC [456] startup LOG: redo done at 0/52001C68 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.01 s 2026-09-26 10:11:19.897 UTC [456] startup LOG: restored log file \u0026#34;000000010000000000000052\u0026#34; from archive 2026-09-26 10:11:19.908 UTC [456] startup LOG: selected new timeline ID: 2 2026-09-26 10:11:19.925 UTC [456] startup LOG: archive recovery complete 2026-09-26 10:11:19.932 UTC [450] postmaster LOG: database system is ready to accept connections restored=# SELECT count(*), max(id) FROM acct; count | max --------+-------- 200002 | 400001 (1 row) original=# SELECT count(*) FROM acct; ERROR: relation \u0026#34;acct\u0026#34; does not exist LINE 1: SELECT count(*) FROM acct ^ $ ls /home/postgres/backup/pg_wal 000000010000000000000052 00000002.history 000000020000000000000052 000000020000000000000053 000000020000000000000054 000000020000000000000055 000000020000000000000056 000000020000000000000057 archive_status summaries $ cat /home/postgres/backup/pg_wal/00000002.history 1\t0/52001CD0\tat restore point \u0026#34;before_drop\u0026#34; restored log file ... from archive: restore_command로 보관소에서 세그먼트를 하나씩 가져왔습니다. redo starts at 0/51000028: backup_label의 시작 위치부터 재생합니다. 백업을 복원할 때는 pg_control이 아니라 backup_label이 기준입니다. ready to accept read-only connections: 백업이 일관된 상태에 도달한 뒤라, 복구 중에도 읽기 접속을 받을 수 있게 되었습니다(hot_standby 기본값 on). recovery stopping at restore point \u0026quot;before_drop\u0026quot;: 복원 지점 레코드를 만나 멈췄습니다. redo done at 0/52001C68은 마지막으로 적용한 레코드, 즉 복원 지점 레코드 자신의 시작 위치입니다. pg_create_restore_point()가 돌려준 0/52001CD0은 이 레코드의 끝이고, 타임라인도 여기서 갈라집니다. DROP TABLE은 그 뒤에 있으므로 적용되지 않았습니다. selected new timeline ID: 2: recovery_target_action = 'promote'라서 멈춘 뒤 바로 일반 서버로 승격했고(기본값 pause는 멈춘 채로 확인을 기다립니다), 여기서부터 새 타임라인입니다. 이후 WAL 파일 이름이 00000002로 시작하고, 00000002.history에는 \u0026ldquo;타임라인 1의 0/52001CD0, 복원 지점에서 갈라짐\u0026quot;이 적혀 있습니다. 복원한 서버에는 acct가 200002행, 마지막에 넣은 400001번 행까지 있고, 원래 서버에서는 여전히 테이블이 없습니다. 이 복원 서버에서 테이블을 pg_dump로 꺼내 원래 서버에 다시 넣는 식으로 실수를 되돌릴 수 있습니다.\n운영에서는: PITR은 연습해 둬야 한다 PITR에는 베이스 백업과, 그 백업 이후 끊김 없는 WAL 보관본이 모두 필요합니다. pg_stat_archiver의 failed_count를 모니터링하고 복원 절차는 실제로 한 번 해 봐야 합니다. 위 실습처럼 위험한 작업 전에 pg_create_restore_point()로 이름을 붙여 두면 멈출 지점을 찾기 쉽습니다.\n정리 체크포인트는 REDO 위치를 WAL에 표시하고, 그 전에 dirty였던 페이지를 모두 디스크에 쓴 뒤, 완료 기록과 pg_control을 남기는 작업입니다. 그 뒤로 REDO 이전의 WAL은 장애 복구에 필요 없어집니다. 체크포인트는 checkpoint_timeout이 지나거나, WAL이 max_wal_size에서 계산한 양만큼 쌓이거나, 명령으로 일어납니다. 앞의 두 경우는 쓰기를 checkpoint_completion_target에 맞춰 나눠서 합니다. 장애 복구는 pg_control에서 마지막 체크포인트를 찾아, 그 REDO 위치부터 디스크에 남아 있는 유효한 WAL의 끝까지 다시 적용합니다. 페이지 LSN 덕분에 이미 반영된 변경은 건너뜁니다. 복구 시간은 REDO 위치 이후 WAL 양에 비례합니다. PITR은 베이스 백업에서 출발해 보관한 WAL을 원하는 지점까지만 적용하고, 새 타임라인으로 갈라집니다. 다음 글에서는 WAL을 다른 서버로 계속 보내는 스트리밍 복제와 replication slot을 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/access/transam/xlog.c: CreateCheckPoint(), StartupXLOG() src/backend/postmaster/checkpointer.c: 체크포인트 시작 조건과 쓰기 분산 src/backend/storage/buffer/bufmgr.c: BufferSync() src/backend/access/transam/xlogrecovery.c: WAL 재생, 복구 목표 src/backend/access/transam/xlogutils.c: 재생 시 페이지 LSN 비교 PostgreSQL 18 공식 문서\nWAL Configuration 체크포인트 설정 Continuous Archiving and Point-in-Time Recovery (PITR) pg_stat_checkpointer pg_controldata, pg_basebackup ","permalink":"https://jinlog.dev/posts/postgresql/08-checkpoint-and-recovery/","summary":"장애가 나도 커밋한 데이터가 사라지지 않는 이유, 그리고 복구는 어디서부터 시작하는가","title":"PostgreSQL 인터널 8: 체크포인트와 장애 복구 과정"},{"content":"개요 애플리케이션 로그에 접속 에러가 쏟아집니다. 접속 실패는 원인에 따라 메시지가 분명하게 다르므로, 에러 메시지를 정확히 읽는 것이 가장 빠른 진단입니다. 이 글에서는 흔한 세 가지를 재현합니다. 슬롯이 가득 찬 경우, pg_hba.conf에 맞는 규칙이 없는 경우, 인증에 실패한 경우입니다. 그리고 슈퍼유저조차 들어갈 수 없는 상황에서 되살리는 방법과, 커넥션을 새로 맺는 것이 얼마나 비싼지를 봅니다.\n이 글에서 답할 질문은 다음과 같습니다.\n접속 에러 메시지는 각각 무엇을 뜻하는가 슬롯이 가득 찼을 때 누가 차지하고 있는지 어떻게 찾고, 들어갈 수조차 없을 때는 어떻게 하는가 인증 실패의 진짜 이유는 어디서 보는가 커넥션 풀은 왜 필요한가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습에서는 슬롯이 차는 모습을 쉽게 보려고 max_connections를 20으로 줄이고, 일반 역할이 쓰지 못하는 예약 슬롯을 두었습니다.\npostgres=# SELECT name, setting FROM pg_settings postgres-# WHERE name IN (\u0026#39;max_connections\u0026#39;, \u0026#39;superuser_reserved_connections\u0026#39;, \u0026#39;reserved_connections\u0026#39;, \u0026#39;log_connections\u0026#39;) postgres-# ORDER BY name; name | setting --------------------------------+------------------------------- log_connections | authorization,setup_durations max_connections | 20 reserved_connections | 2 superuser_reserved_connections | 3 (4 rows) postgres=# CREATE ROLE app LOGIN; CREATE ROLE postgres=# CREATE ROLE monitor LOGIN; CREATE ROLE postgres=# GRANT pg_use_reserved_connections TO monitor; GRANT ROLE 설정 뜻 max_connections 동시에 받을 수 있는 클라이언트 연결 수 superuser_reserved_connections 그 가운데 슈퍼유저만 쓸 수 있는 슬롯(기본 3) reserved_connections 그 가운데 pg_use_reserved_connections 역할을 가진 사용자만 쓸 수 있는 슬롯(PostgreSQL 16부터, 기본 0) 일반 역할은 20 − 3 − 2 = 15개까지 쓸 수 있습니다. 모니터링 계정(monitor)에 pg_use_reserved_connections를 주어, 애플리케이션이 슬롯을 다 써도 모니터링은 들어올 수 있게 했습니다. log_connections는 PostgreSQL 18부터 기록할 단계를 고를 수 있고, setup_durations는 접속에 걸린 시간을 남깁니다.\n먼저 확인할 것: 에러 메시지 메시지 뜻 볼 곳 remaining connection slots are reserved for roles with privileges of the \u0026quot;pg_use_reserved_connections\u0026quot; role 일반 역할이 쓸 슬롯이 다 참 pg_stat_activity remaining connection slots are reserved for roles with the SUPERUSER attribute 예약 슬롯까지 다 참 pg_stat_activity sorry, too many clients already 모든 슬롯이 다 참 OS의 프로세스 목록 no pg_hba.conf entry for host ... 접속 경로에 맞는 규칙이 없음 pg_hba_file_rules password authentication failed for user ... 인증 실패. 진짜 이유는 서버 로그의 DETAIL 서버 로그 원인별 진단 슬롯이 찬다 애플리케이션이 커넥션을 15개 열어 두고 쓰지 않는 상황입니다. 커넥션 풀 설정이 잘못되었거나 코드가 커넥션을 반납하지 않을 때 이렇게 됩니다.\npostgres=# SELECT usename, application_name AS app, state, count(*) postgres-# FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; postgres-# GROUP BY 1, 2, 3 ORDER BY 4 DESC; usename | app | state | count ----------+------+--------+------- app | psql | idle | 15 postgres | psql | active | 1 (2 rows) 이제 애플리케이션 계정은 들어올 수 없습니다.\n$ psql -X -U app -d postgres -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server on socket \u0026#34;/run/postgresql/.s.PGSQL.5432\u0026#34; failed: FATAL: remaining connection slots are reserved for roles with privileges of the \u0026#34;pg_use_reserved_connections\u0026#34; role [exit=2] 모니터링 계정은 예약 슬롯으로 들어옵니다.\n$ psql -X -U monitor -d postgres -c \u0026#39;SELECT 1\u0026#39; ?column? ---------- 1 (1 row) [exit=0] 모니터링 계정도 2개를 열어 두면 그 예약 슬롯도 찹니다.\n$ for i in 1 2; do nohup bash -c \u0026#39;sleep 900 | psql -X -U monitor -d postgres \u0026gt;/dev/null\u0026#39; \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp; done $ sleep 1 $ psql -X -U monitor -d postgres -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server on socket \u0026#34;/run/postgresql/.s.PGSQL.5432\u0026#34; failed: FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute [exit=2] postgres=# SELECT count(*) AS used, current_setting(\u0026#39;max_connections\u0026#39;) AS max FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39;; used | max ------+----- 18 | 20 (1 row) 마지막으로 슈퍼유저 슬롯 3개까지 누군가 차지하면, 슈퍼유저도 들어올 수 없습니다.\n$ for i in 1 2 3; do nohup bash -c \u0026#39;sleep 900 | psql -X -U postgres -d postgres \u0026gt;/dev/null\u0026#39; \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp; done $ sleep 1 $ psql -X -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server on socket \u0026#34;/run/postgresql/.s.PGSQL.5432\u0026#34; failed: FATAL: sorry, too many clients already [exit=2] 서버 로그에는 단계마다 거절이 남습니다. application_name은 접속이 끝나기 전이라 [unknown]으로 찍힙니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;too many clients|reserved for\u0026#39; | tail -n 3 2026-09-26 10:39:00.646 UTC [210] app@postgres/[unknown] FATAL: remaining connection slots are reserved for roles with privileges of the \u0026#34;pg_use_reserved_connections\u0026#34; role 2026-09-26 10:39:01.777 UTC [235] monitor@postgres/[unknown] FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute 2026-09-26 10:39:02.940 UTC [263] postgres@postgres/[unknown] FATAL: sorry, too many clients already [exit=0] sorry, too many clients already는 가장 나쁜 상태입니다. SQL로 원인을 볼 수도, 세션을 끊을 수도 없습니다. 이 메시지의 SQLSTATE는 53300입니다.\npg_hba.conf에 맞는 규칙이 없다 같은 서버에 TCP로 접속해 봅니다.\n$ hostname -i 172.17.0.4 [exit=0] $ psql -X -h $(hostname -i) -U app -d postgres -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server at \u0026#34;172.17.0.4\u0026#34;, port 5432 failed: FATAL: no pg_hba.conf entry for host \u0026#34;172.17.0.4\u0026#34;, user \u0026#34;app\u0026#34;, database \u0026#34;postgres\u0026#34;, no encryption [exit=2] 에러가 host, user, database, 암호화 여부를 모두 알려 줍니다. pg_hba.conf는 위에서부터 차례로 보며 첫 번째로 맞는 규칙을 쓰고, 맞는 규칙이 없으면 이 에러를 냅니다. 지금 적용된 규칙은 pg_hba_file_rules 뷰로 봅니다.\npostgres=# SELECT line_number, type, database, user_name, address, auth_method postgres-# FROM pg_hba_file_rules ORDER BY line_number; line_number | type | database | user_name | address | auth_method -------------+-------+---------------+-----------+-----------+------------- 117 | local | {all} | {all} | | trust 119 | host | {all} | {all} | 127.0.0.1 | trust 121 | host | {all} | {all} | ::1 | trust 124 | local | {replication} | {all} | | trust 125 | host | {replication} | {all} | 127.0.0.1 | trust 126 | host | {replication} | {all} | ::1 | trust (6 rows) 로컬 소켓과 127.0.0.1, ::1만 허용되어 있습니다(initdb 기본값인 trust). 172.17.0.4에서 오는 연결에 맞는 규칙이 없습니다. 규칙을 추가하고 설정을 다시 읽습니다. pg_hba.conf는 재시작 없이 pg_reload_conf()로 반영됩니다.\n$ echo \u0026#39;host all app samenet scram-sha-256\u0026#39; \u0026gt;\u0026gt; $PGDATA/pg_hba.conf postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# SELECT line_number, type, database, user_name, address, auth_method postgres-# FROM pg_hba_file_rules WHERE user_name @\u0026gt; \u0026#39;{app}\u0026#39;; line_number | type | database | user_name | address | auth_method -------------+------+----------+-----------+---------+--------------- 127 | host | {all} | {app} | samenet | scram-sha-256 (1 row) pg_hba_file_rules는 파일을 다시 읽어 보여 주므로 reload하기 전에 파일의 문법 오류를 확인하는 데도 씁니다. 잘못된 줄은 error 컬럼에 표시됩니다.\n인증 실패: 진짜 이유는 서버 로그에 이제 규칙은 맞지만 인증에 실패합니다.\n$ PGPASSWORD=wrong psql -X -h $(hostname -i) -U app -d postgres -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server at \u0026#34;172.17.0.4\u0026#34;, port 5432 failed: FATAL: password authentication failed for user \u0026#34;app\u0026#34; [exit=2] 클라이언트는 password authentication failed만 받습니다. 공격자에게 정보를 주지 않으려고 이유를 말하지 않기 때문입니다. 이유는 서버 로그의 DETAIL에 있습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 1 -E \u0026#39;password authentication failed\u0026#39; | tail -n 2 2026-09-26 10:39:12.974 UTC [483] app@postgres/[unknown] FATAL: password authentication failed for user \u0026#34;app\u0026#34; 2026-09-26 10:39:12.974 UTC [483] app@postgres/[unknown] DETAIL: User \u0026#34;app\u0026#34; has no password assigned. 비밀번호가 아예 설정되지 않은 계정이었습니다. 비밀번호를 설정한 뒤 틀린 비밀번호로 접속하면 DETAIL이 달라집니다.\n$ PGPASSWORD=wrong psql -X -h $(hostname -i) -U app -d postgres -c \u0026#39;SELECT 1\u0026#39; psql: error: connection to server at \u0026#34;172.17.0.4\u0026#34;, port 5432 failed: FATAL: password authentication failed for user \u0026#34;app\u0026#34; [exit=2] $ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 1 -E \u0026#39;password authentication failed\u0026#39; | tail -n 2 2026-09-26 10:39:13.154 UTC [516] app@postgres/[unknown] FATAL: password authentication failed for user \u0026#34;app\u0026#34; 2026-09-26 10:39:13.154 UTC [516] app@postgres/[unknown] DETAIL: Connection matched file \u0026#34;/var/lib/pgsql/18/data/pg_hba.conf\u0026#34; line 127: \u0026#34;host all app samenet scram-sha-256\u0026#34; [exit=0] 이번에는 어느 pg_hba.conf 규칙에 걸렸는지(127번째 줄)를 알려 줍니다. 규칙이 의도와 다른 줄에 걸리는 문제를 찾을 때 유용합니다. 맞는 비밀번호로는 들어옵니다.\n$ PGPASSWORD=\u0026#34;$(cat /var/lib/pgsql/app.pw)\u0026#34; psql -X -h $(hostname -i) -U app -d postgres -c \u0026#39;SELECT current_user, inet_server_addr()\u0026#39; current_user | inet_server_addr --------------+------------------ app | 172.17.0.4 (1 row) [exit=0] log_connections의 setup_durations로 접속 단계별 시간이 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;connection authenticated|connection authorized|connection ready\u0026#39; | tail -n 3 2026-09-26 10:39:13.091 UTC [507] postgres@postgres/psql LOG: connection ready: setup total=1.195 ms, fork=0.241 ms, authentication=0.099 ms 2026-09-26 10:39:13.274 UTC [538] app@postgres/[unknown] LOG: connection authorized: user=app database=postgres application_name=psql 2026-09-26 10:39:13.274 UTC [538] app@postgres/psql LOG: connection ready: setup total=4.464 ms, fork=0.236 ms, authentication=3.265 ms trust로 들어온 로컬 접속은 인증에 0.099 ms, scram-sha-256으로 들어온 TCP 접속은 3.265 ms가 걸렸습니다. SCRAM은 비밀번호 해시를 일부러 여러 번 반복 계산하기 때문에 느립니다. 커넥션 하나로는 작은 차이지만, 커넥션을 쉴 새 없이 새로 맺는 애플리케이션이라면 쌓입니다.\n조치 들어갈 수 없을 때: OS에서 idle 세션 하나를 끝낸다 슬롯이 모두 찼다면 DB 서버의 OS에서 backend 프로세스를 봅니다. 프로세스 제목에 사용자와 상태가 있습니다(인터널 1편).\n$ ps -u postgres -o pid,cmd | grep \u0026#39;postgres: app postgres \\[local\\] idle\u0026#39; | grep -v grep | head -n 3 179 postgres: app postgres [local] idle 180 postgres: app postgres [local] idle 181 postgres: app postgres [local] idle [exit=0] $ kill -TERM $(ps -u postgres -o pid,cmd | grep \u0026#39;postgres: app postgres \\[local\\] idle\u0026#39; | grep -v grep | head -n 1 | awk \u0026#39;{print $1}\u0026#39;) $ sleep 1 [exit=0] idle 상태인 애플리케이션 backend 하나에 SIGTERM을 보냈습니다. SIGTERM은 pg_terminate_backend()와 같은 신호라서 그 세션만 깔끔하게 끝납니다. kill -9(SIGKILL)는 절대 쓰지 않습니다. backend 하나가 비정상 종료하면 postmaster가 공유 메모리를 믿을 수 없다고 보고 모든 세션을 끊고 장애 복구를 합니다(인터널 1편). 슬롯 하나가 생겼으니 SQL로 들어가 누가 차지하고 있는지 봅니다.\npostgres=# SELECT usename, application_name AS app, state, count(*) postgres-# FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39; postgres-# GROUP BY 1, 2, 3 ORDER BY 4 DESC; usename | app | state | count ----------+------+--------+------- app | psql | idle | 14 postgres | psql | idle | 3 monitor | psql | idle | 2 postgres | psql | active | 1 (4 rows) postgres=# SELECT pid, usename, state, now() - state_change AS idle_for, backend_start postgres-# FROM pg_stat_activity WHERE usename = \u0026#39;app\u0026#39; ORDER BY backend_start LIMIT 3; pid | usename | state | idle_for | backend_start -----+---------+-------+-----------------+------------------------------- 181 | app | idle | 00:00:05.783947 | 2026-09-26 10:38:58.506671+00 180 | app | idle | 00:00:05.784385 | 2026-09-26 10:38:58.507287+00 182 | app | idle | 00:00:05.783392 | 2026-09-26 10:38:58.507389+00 (3 rows) 애플리케이션 계정의 idle 세션 14개가 대부분입니다. client_addr과 application_name으로 어느 서버의 어느 애플리케이션인지 확인하고, 그쪽 담당자와 함께 정리합니다. 급하면 오래 idle인 세션을 한꺼번에 끊습니다.\npostgres=# SELECT count(pg_terminate_backend(pid)) AS terminated postgres-# FROM pg_stat_activity postgres-# WHERE usename IN (\u0026#39;app\u0026#39;, \u0026#39;monitor\u0026#39;) AND state = \u0026#39;idle\u0026#39; AND state_change \u0026lt; now() - interval \u0026#39;5 seconds\u0026#39;; terminated ------------ 14 (1 row) postgres=# SELECT count(*) AS used FROM pg_stat_activity WHERE backend_type = \u0026#39;client backend\u0026#39;; used ------ 6 (1 row) 끊긴 세션을 쓰던 애플리케이션은 다음 요청에서 에러를 받으므로, 커넥션 풀이 끊긴 연결을 감지하고 다시 맺는지 확인해야 합니다.\nidle 세션을 자동으로 끊는다: idle_session_timeout 애플리케이션 계정에 idle_session_timeout을 걸면, 트랜잭션 밖에서 아무것도 하지 않는 세션을 서버가 끊습니다.\npostgres=# ALTER ROLE app SET idle_session_timeout = \u0026#39;3s\u0026#39;; ALTER ROLE # 세션 X 시작: psql -X -U app -d postgres X=# SELECT current_user; current_user -------------- app (1 row) X=# SELECT 1; FATAL: terminating connection due to idle-session timeout server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost $ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;idle-session timeout\u0026#39; | tail -n 1 2026-09-26 10:39:09.537 UTC [348] app@postgres/psql FATAL: terminating connection due to idle-session timeout 커넥션 풀은 일부러 idle 연결을 유지하므로, 이 값은 풀의 idle 타임아웃보다 길게 잡아야 합니다. 그렇지 않으면 풀이 들고 있는 연결을 서버가 계속 끊어 에러가 납니다. 풀이 없는 배치나 개발자 접속 계정에 거는 것이 안전합니다.\n예약 슬롯을 설계해 둔다 superuser_reserved_connections(기본 3)는 DBA가 들어올 자리입니다. 애플리케이션이나 모니터링을 슈퍼유저로 접속시키면 이 자리까지 차지하므로, 슈퍼유저 계정은 사람만 씁니다. reserved_connections와 pg_use_reserved_connections로 모니터링·운영 도구의 자리를 따로 둡니다. 장애 중에 모니터링이 끊기면 상황을 볼 수 없습니다. 커넥션은 비싸다: 풀을 쓴다 같은 조회를 커넥션을 유지한 채 할 때와, 매번 새로 맺을 때(pgbench -C)를 비교합니다.\n$ pgbench -n -S -c 4 -T 5 postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;number of transactions actually|average connection time|tps\u0026#39; number of transactions actually processed: 388940 tps = 77846.384789 (without initial connection time) $ pgbench -n -S -c 4 -T 5 -C postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;number of transactions actually|average connection time|tps\u0026#39; number of transactions actually processed: 5706 average connection time = 0.869 ms tps = 1141.107114 (including reconnection times) 초당 7만 7천 건과 1천 1백 건, 약 70배 차이입니다. PostgreSQL은 커넥션마다 backend 프로세스를 새로 띄우고(fork), 인증하고, 캐시를 새로 채웁니다(인터널 1편). 이 실습은 trust 인증의 로컬 접속이었으니, TLS와 SCRAM 인증까지 더해지는 실제 환경에서는 차이가 더 큽니다.\n그래서 애플리케이션은 커넥션 풀을 씁니다. max_connections를 무작정 늘리는 것은 해결책이 아닙니다. backend마다 메모리를 쓰고(11편), 동시에 일하는 프로세스가 CPU 코어 수를 훨씬 넘으면 오히려 전체 처리량이 떨어집니다. 애플리케이션 서버마다 풀 크기를 정하고, (애플리케이션 서버 수 × 풀 최대 크기) + 예약 슬롯이 max_connections를 넘지 않게 맞춥니다. 애플리케이션 서버가 수십 대라면 별도의 커넥션 풀러를 앞에 두는 구성도 흔한데, 이 연재는 코어 기능만 다루므로 여기서는 다루지 않습니다.\n재발 방지 슬롯 사용률 감시: pg_stat_activity의 client backend 수 ÷ max_connections. 사용자·애플리케이션·상태별로 나눠 수집하면 누수를 일찍 찾습니다. idle 세션 감시: state = 'idle'이고 state_change가 오래된 세션 수. 접속 실패 감시: 서버 로그의 too many clients, reserved for, no pg_hba.conf entry, password authentication failed 건수. 인증 실패가 갑자기 늘면 비밀번호 변경 누락이나 공격을 의심합니다. log_connections: PostgreSQL 18에서는 필요한 단계만 고를 수 있습니다. 접속이 아주 많은 서버라면 로그 양을 보고 정합니다. 예약 슬롯: 모니터링 계정에 pg_use_reserved_connections, 슈퍼유저는 사람만. 정리 접속 에러는 메시지가 원인을 말해 줍니다. 슬롯 부족은 reserved for ...에서 too many clients already로 단계가 올라가고, 경로 문제는 no pg_hba.conf entry, 인증 문제는 password authentication failed입니다. 인증 실패의 진짜 이유(비밀번호 없음, 어느 규칙에 걸렸는지)는 서버 로그의 DETAIL에만 있습니다. 슈퍼유저조차 들어갈 수 없으면 OS에서 idle backend 하나에 SIGTERM을 보내 자리를 만듭니다. kill -9는 쓰지 않습니다. 모니터링용 예약 슬롯(reserved_connections)을 두고, 슈퍼유저 계정은 사람만 씁니다. 커넥션을 매번 새로 맺으면 이 실습에서 처리량이 약 70배 떨어졌습니다. 커넥션 풀을 쓰고, 풀 크기의 합이 max_connections를 넘지 않게 합니다. 참고 자료 Connections and Authentication: max_connections, reserved_connections, superuser_reserved_connections The pg_hba.conf File, pg_hba_file_rules Password Authentication log_connections, idle_session_timeout Predefined Roles: pg_use_reserved_connections PostgreSQL 인터널 1편 프로세스 구조 ","permalink":"https://jinlog.dev/posts/postgresql-ops/09-connection-failures/","summary":"애플리케이션이 DB에 접속하지 못할 때, 에러 메시지로 원인을 가르고 들어갈 수 없는 상황에서도 되살리는 법","title":"PostgreSQL 운영 9: 접속이 안 된다"},{"content":"개요 8편에서 장애 복구는 \u0026ldquo;마지막 체크포인트부터 WAL을 다시 적용\u0026quot;하는 일이었고, PITR은 백업에 보관한 WAL을 적용하는 일이었습니다. 이 생각을 한 걸음 더 밀면 스트리밍 복제가 됩니다. 백업에서 출발한 서버가 복구를 끝내지 않고, primary가 새로 만드는 WAL을 네트워크로 계속 받아 영원히 복구하는 상태로 남는 것입니다. 이 서버가 standby입니다.\nstandby는 primary와 똑같은 WAL을 똑같이 재생하므로 데이터가 페이지 단위까지 같아집니다. 이것을 물리 복제(physical replication)라고 합니다. (테이블의 행 변경을 논리적으로 풀어서 보내는 논리 복제도 있지만, 이 글에서는 다루지 않습니다.)\n이 글에서 답할 질문은 다음과 같습니다.\nWAL은 어떤 프로세스를 거쳐 standby에 도착하고 재생되는가 복제 지연은 어디서 생기고, 어떻게 읽는가 동기 복제에서 커밋은 무엇을 기다리는가 standby의 읽기 쿼리는 왜 취소되기도 하는가 replication slot은 왜 필요하고, 왜 디스크를 가득 채우기도 하는가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n복제를 담당하는 세 프로세스 walsender가 primary의 WAL을 보내고, walreceiver가 받아 standby 디스크에 쓰고, startup이 재생합니다. 같은 연결로 standby가 처리한 위치를 거꾸로 보고합니다. slot이 붙잡은 WAL을 실제로 지우거나 남기는 것은 primary의 체크포인트입니다. 새 창에서 크게 보기 walsender (primary): standby가 복제 연결을 맺으면 postmaster가 띄우는 backend의 한 종류입니다(1편). 보낼 WAL이 아직 WAL buffers(공유 메모리)에 남아 있으면 거기서, 없으면 pg_wal 파일에서 읽어 보냅니다(버퍼에서 먼저 읽는 것은 PG17부터, XLogSendPhysical()). 보내는 것은 primary에서 flush까지 끝난 WAL뿐입니다(walsender.c). 아직 primary 디스크에도 없는 WAL을 standby가 먼저 갖는 일은 없습니다. walreceiver (standby): primary에 접속해서 WAL을 받아 standby의 pg_wal에 쓰고(XLogWalRcvWrite()) fsync합니다(XLogWalRcvFlush()). 그리고 어디까지 썼고(write), 디스크에 확정했고(flush), 재생했는지(apply)를 같은 연결로 primary에 보고합니다(XLogWalRcvSendReply()). startup (standby): 8편의 장애 복구를 하던 그 프로세스입니다. standby에서는 복구를 끝내지 않고, walreceiver가 fsync까지 끝낸 WAL을 이어서 재생합니다. standby는 standby.signal 파일이 있는 데이터 디렉터리로 시작하며, primary_conninfo에 적힌 primary로 접속합니다. pg_basebackup -R이 이 두 가지를 만들어 줍니다. hot_standby(기본 on)이면 재생하는 동안에도 읽기 전용 쿼리를 받습니다.\npg_basebackup -R로 standby 만들기 primary(포트 5432)와 standby(포트 5433) 두 클러스터를 한 서버에 띄웁니다. 프로세스 이름에 어느 쪽인지 나오도록 cluster_name을 붙였고, psql 프롬프트의 primary=#와 standby=#는 어느 서버에 접속해 실행했는지를 나타냅니다. 먼저 primary를 띄우고 테이블을 만듭니다.\n$ cat \u0026gt;\u0026gt; $PGDATA/postgresql.conf \u0026lt;\u0026lt;\u0026#39;CONF\u0026#39; \u003e log_line_prefix = \u0026#39;%m [%p] %b \u0026#39; \u003e cluster_name = \u0026#39;primary\u0026#39; \u003e CONF $ pg_ctl -D $PGDATA -l /home/postgres/primary.log start waiting for server to start.... done server started primary=# CREATE TABLE acct (id int PRIMARY KEY, balance int, pad text); primary=# INSERT INTO acct SELECT g, 100, repeat(\u0026#39;p\u0026#39;, 200) FROM generate_series(1, 200000) g; primary의 베이스 백업으로 standby를 만들고, 포트와 cluster_name을 바꿔 띄웁니다.\n$ pg_basebackup -D /home/postgres/standby -R -C -S standby1 -c fast \u0026amp;\u0026amp; echo \u0026#34;base backup ok\u0026#34; base backup ok $ ls /home/postgres/standby/standby.signal /home/postgres/standby/standby.signal $ cat /home/postgres/standby/postgresql.auto.conf # Do not edit this file manually! # It will be overwritten by the ALTER SYSTEM command. primary_conninfo = \u0026#39;user=postgres passfile=\u0026#39;\u0026#39;/home/postgres/.pgpass\u0026#39;\u0026#39; channel_binding=disable port=5432 sslmode=disable sslnegotiation=postgres sslcompression=0 sslcertmode=disable sslsni=1 ssl_min_protocol_version=TLSv1.2 gssencmode=disable krbsrvname=postgres gssdelegation=0 target_session_attrs=any load_balance_hosts=disable\u0026#39; primary_slot_name = \u0026#39;standby1\u0026#39; $ cat \u0026gt;\u0026gt; /home/postgres/standby/postgresql.auto.conf \u0026lt;\u0026lt;\u0026#39;CONF\u0026#39; \u003e port = 5433 \u003e cluster_name = \u0026#39;standby1\u0026#39; \u003e CONF $ pg_ctl -D /home/postgres/standby -l /home/postgres/standby.log start waiting for server to start.... done server started $ sleep 1 $ grep -E \u0026#34;entering standby mode|redo starts|consistent recovery state|ready to accept read-only|started streaming\u0026#34; /home/postgres/standby.log | cut -c1-160 2026-09-26 10:11:24.580 UTC [70] startup LOG: entering standby mode 2026-09-26 10:11:24.581 UTC [70] startup LOG: redo starts at 0/6000028 2026-09-26 10:11:24.582 UTC [70] startup LOG: consistent recovery state reached at 0/6000120 2026-09-26 10:11:24.582 UTC [64] postmaster LOG: database system is ready to accept read-only connections 2026-09-26 10:11:24.583 UTC [71] walreceiver LOG: started streaming WAL from primary at 0/7000000 on timeline 1 pg_basebackup -R -C -S standby1은 베이스 백업을 뜨면서(8편) primary에 standby1이라는 slot을 만들고(-C -S), standby로 시작하는 데 필요한 설정을 적어 줍니다(-R). standby.signal 파일이 있으면 서버는 복구를 끝내지 않고 standby로 남습니다. postgresql.auto.conf에는 primary 접속 정보(primary_conninfo)와 쓸 slot 이름(primary_slot_name)이 들어갔습니다. standby 로그를 보면 entering standby mode로 시작해 백업의 REDO 위치부터 재생하고(redo starts at 0/6000028), 백업이 끝난 위치에서 일관된 상태가 되자(consistent recovery state reached) 읽기 접속을 받기 시작합니다. 그리고 walreceiver가 0/7000000부터 스트리밍을 시작했습니다. primary와 standby에 뜨는 복제 프로세스 $ ps -eo pid,args | grep -E \u0026#34;postgres: (primary|standby1): (walsender|walreceiver|startup)\u0026#34; | grep -v grep 70 postgres: standby1: startup waiting for 000000010000000000000007 71 postgres: standby1: walreceiver 72 postgres: primary: walsender postgres [local] START_REPLICATION primary에는 walsender가, standby에는 walreceiver와 startup이 있습니다. walsender는 복제 명령 START_REPLICATION을 처리하는 중이고, startup은 다음 WAL 파일(...07)이 오기를 기다리고 있습니다.\n전송, 기록, 재생: 지연은 세 군데서 생긴다 primary의 walsender는 standby의 보고를 받아 pg_stat_replication에 보여 줍니다(ProcessStandbyReplyMessage()).\n컬럼 뜻 sent_lsn walsender가 보낸 곳까지 write_lsn standby가 운영체제에 write한 곳까지 flush_lsn standby가 fsync까지 끝낸 곳까지 replay_lsn standby가 재생한 곳까지 write_lag, flush_lag, replay_lag primary가 WAL을 flush한 뒤, standby가 그 위치를 write, flush, 재생했다는 보고를 받기까지 걸린 시간 primary의 현재 위치 - sent_lsn이 크면 walsender가 보내는 쪽이, sent_lsn - write_lsn이 크면 네트워크나 standby의 수신이, write_lsn - flush_lsn이 크면 standby의 fsync가, flush_lsn - replay_lsn이 크면 standby의 재생(startup)이 병목입니다. 이렇게 나눠 보면 \u0026ldquo;복제가 느리다\u0026quot;의 원인을 좁힐 수 있습니다. 비동기 standby에서 replay_lag는 \u0026ldquo;primary에서 커밋한 뒤 standby 쿼리에 보이기까지\u0026quot;의 시간에 가깝습니다(pg_stat_replication).\npg_stat_replication과 pg_stat_wal_receiver primary=# SELECT pid, application_name, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_state FROM pg_stat_replication \\gx -[ RECORD 1 ]----+---------------- pid | 72 application_name | standby1 state | streaming sent_lsn | 0/7000000 write_lsn | 0/7000000 flush_lsn | 0/7000000 replay_lsn | 0/7000000 write_lag | 00:00:00.000019 flush_lag | 00:00:00.000019 replay_lag | 00:00:00.000019 sync_state | async standby=# SELECT pid, status, receive_start_lsn, written_lsn, flushed_lsn, slot_name FROM pg_stat_wal_receiver \\gx -[ RECORD 1 ]-----+---------- pid | 71 status | streaming receive_start_lsn | 0/7000000 written_lsn | flushed_lsn | 0/7000000 slot_name | standby1 primary=# SELECT \u0026#39;primary: in_recovery=\u0026#39; || pg_is_in_recovery() \\g (format=unaligned tuples_only=on) primary: in_recovery=false standby=# SELECT \u0026#39;standby: in_recovery=\u0026#39; || pg_is_in_recovery() \\g (format=unaligned tuples_only=on) standby: in_recovery=true primary 쪽 pg_stat_replication에는 standby 하나가 streaming 상태, async(비동기)로 보입니다. 네 위치가 모두 0/7000000으로 같아 완전히 따라온 상태입니다. standby 쪽 pg_stat_wal_receiver는 같은 연결을 반대편에서 본 것이고, standby1 slot을 쓰고 있습니다. written_lsn이 비어 있는 것은 스트리밍을 시작한 뒤 아직 새로 받아 쓴 WAL이 없기 때문입니다. pg_is_in_recovery()가 standby에서 true입니다. standby는 계속 복구 중인 서버입니다. primary의 변경이 standby에 보인다 primary=# INSERT INTO acct VALUES (200001, 1, \u0026#39;from primary\u0026#39;); primary=# SELECT pg_current_wal_lsn() AS primary_lsn; primary_lsn ------------- 0/7002148 (1 row) standby=# SELECT pg_last_wal_receive_lsn() AS received, pg_last_wal_replay_lsn() AS replayed; received | replayed -----------+----------- 0/7002148 | 0/7002148 (1 row) standby=# SELECT * FROM acct WHERE id = 200001; id | balance | pad --------+---------+-------------- 200001 | 1 | from primary (1 row) standby=# INSERT INTO acct VALUES (200002, 1, \u0026#39;from standby\u0026#39;); ERROR: cannot execute INSERT in a read-only transaction primary에서 넣은 행이 1초 뒤 standby에 보이고, standby가 받은 위치와 재생한 위치가 primary의 위치 0/7002148과 같습니다. standby에 쓰려고 하면 read-only transaction 오류가 납니다.\n재생만 늦추면 replay_lsn만 뒤처진다 standby에 recovery_min_apply_delay = '5s'를 걸어, WAL은 받되 커밋 레코드의 재생을 5초 늦춥니다(recoveryApplyDelay()).\nstandby=# ALTER SYSTEM SET recovery_min_apply_delay = \u0026#39;5s\u0026#39;; standby=# SELECT pg_reload_conf(); primary=# INSERT INTO acct VALUES (200003, 1, \u0026#39;delayed\u0026#39;); primary=# SELECT pg_current_wal_lsn() AS primary_lsn, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag FROM pg_stat_replication \\gx -[ RECORD 1 ]---------------- primary_lsn | 0/70021F8 sent_lsn | 0/70021F8 write_lsn | 0/70021F8 flush_lsn | 0/70021F8 replay_lsn | 0/7002148 write_lag | 00:00:00.000275 flush_lag | 00:00:00.000586 replay_lag | 00:00:00.000586 standby=# SELECT count(*) AS delayed_row_visible FROM acct WHERE id = 200003; delayed_row_visible --------------------- 0 (1 row) 6초를 더 기다린 뒤 다시 확인합니다.\nstandby=# SELECT count(*) AS delayed_row_visible FROM acct WHERE id = 200003; delayed_row_visible --------------------- 1 (1 row) primary=# SELECT replay_lsn, replay_lag FROM pg_stat_replication \\gx -[ RECORD 1 ]--------------- replay_lsn | 0/70021F8 replay_lag | 00:00:05.007244 standby=# ALTER SYSTEM RESET recovery_min_apply_delay; standby=# SELECT pg_reload_conf(); INSERT 1초 뒤 primary에서 보면, sent_lsn, write_lsn, flush_lsn은 primary 위치 0/70021F8까지 왔는데 replay_lsn만 바로 앞에서 본 위치 0/7002148에 머물러 있습니다. WAL은 standby 디스크에 도착했지만 재생만 기다리는 중이라, standby에서는 새 행이 아직 보이지 않습니다(0).\nlag 값은 standby의 보고를 받은 순간에, 그 위치를 primary가 flush한 시각과 비교해 계산합니다(walsender.c). 이때의 replay_lag(0.000586초)가 flush_lag와 같은 것은, flush 보고를 받은 순간에 \u0026ldquo;아직 재생하지 않은 새 WAL을 지금 재생했다면\u0026quot;이라는 가정으로 계산한 값이기 때문입니다(LagTrackerRead()). 그 뒤 새 보고가 없어 값이 그대로 멈춰 있었습니다. 재생이 끝나자 startup이 곧바로 보고를 보내게 했고, replay_lag가 5.007244초로 바뀌었습니다. 늦춘 5초가 그대로 재생 지연으로 측정되었습니다. 참고로 replay_lsn은 primary가 마지막으로 받은 보고 기준이고, standby의 실제 재생 위치는 standby에서 pg_last_wal_replay_lsn()으로 봅니다.\n운영에서는: 복제 지연은 나눠서 본다 pg_stat_replication의 네 위치로 지연이 어디서 생기는지 먼저 나눕니다. replay_lsn만 뒤처진다면 standby의 재생이 느린 것이고, 원인은 standby의 I/O 부족이나 쿼리 충돌 대기인 경우가 많습니다. 재생은 startup 프로세스 하나가 순서대로 하므로(8편), primary에서 여러 세션이 병렬로 만든 WAL을 standby가 따라잡지 못할 수도 있습니다.\n동기 복제: 커밋이 standby를 기다린다 기본은 비동기 복제입니다. primary의 커밋은 자기 WAL만 flush하고 끝나며, standby는 조금 늦게 따라옵니다. primary가 그 사이에 죽고 standby를 승격하면, 마지막 몇 커밋이 standby에 없을 수 있습니다.\nsynchronous_standby_names에 standby 이름을 적으면 동기 복제가 됩니다.\n먼저 primary의 WAL을 flush하고, standby가 같은 위치까지 flush했다는 보고가 올 때까지 기다린 뒤에 커밋 완료를 돌려줍니다. 새 창에서 크게 보기 커밋 레코드를 primary에서 flush합니다. 여기까지는 비동기와 같고, 이 flush가 walsender도 깨웁니다. 이 순간 WAL과 커밋 로그에는 이미 커밋으로 기록되어 되돌릴 수 없습니다. 다만 대기가 끝나기 전까지 다른 세션에는 아직 보이지 않습니다(xact.c). backend는 동기 복제 대기열에 들어가 기다립니다(SyncRepWaitForLSN(), 호출은 xact.c). standby가 그 위치까지 처리했다고 보고하면 walsender가 대기열의 backend를 깨웁니다(walsender.c). 무엇을 기다릴지는 synchronous_commit이 정합니다(xact.h).\nsynchronous_commit standby에서 기다리는 것 remote_write write (운영체제까지) on (기본값) flush (디스크까지) remote_apply 재생까지. 커밋 직후 standby에서 읽어도 보임 local, off standby를 기다리지 않음 1단계에서 이미 로컬 커밋이 끝났다는 점이 중요합니다. 기다리던 중에 취소하면 커밋이 되돌려지는 것이 아니라, \u0026ldquo;로컬에서는 커밋되었지만 standby에는 아직 없을 수 있다\u0026quot;는 경고와 함께 대기만 끝납니다(syncrep.c). 또 standby가 하나뿐인데 그 standby가 멈추면 primary의 모든 커밋이 멈춥니다. 그래서 동기 복제는 보통 standby를 둘 이상 두고 ANY 1 (s1, s2)처럼 지정합니다.\nstandby가 멈추면 커밋이 끝나지 않는다 synchronous_standby_names = 'standby1'로 동기 복제를 켭니다. standby의 이름은 standby의 cluster_name에서 왔습니다(primary_conninfo에 application_name이 없으면 walreceiver가 cluster_name을 대신 씁니다).\nprimary=# ALTER SYSTEM SET synchronous_standby_names = \u0026#39;standby1\u0026#39;; primary=# SELECT pg_reload_conf(); primary=# SELECT application_name, sync_state FROM pg_stat_replication; application_name | sync_state ------------------+------------ standby1 | sync (1 row) primary=# \\timing on Timing is on. primary=# INSERT INTO acct VALUES (200004, 1, \u0026#39;sync ok\u0026#39;); INSERT 0 1 Time: 1.558 ms $ pg_ctl -D /home/postgres/standby stop -m fast waiting for server to shut down.... done server stopped standby가 살아 있을 때 INSERT는 1.558ms에 끝났습니다. 이제 standby를 멈춘 상태에서, primary에 접속한 세션 A가 INSERT를 합니다.\nA=# INSERT INTO acct VALUES (200005, 1, \u0026#39;sync wait\u0026#39;); 3초가 지나도 응답이 없습니다. 다른 세션에서 보면, 세션 A는 SyncRep 대기 이벤트에서 기다리고 있습니다. 이 backend를 취소합니다.\nprimary=# SELECT pid, state, wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event = \u0026#39;SyncRep\u0026#39;; pid | state | wait_event_type | wait_event | query -----+--------+-----------------+------------+--------------------------------------------------- 167 | active | IPC | SyncRep | INSERT INTO acct VALUES (200005, 1, \u0026#39;sync wait\u0026#39;); (1 row) primary=# SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE wait_event = \u0026#39;SyncRep\u0026#39;; pg_cancel_backend ------------------- t (1 row) 세션 A에는 다음 결과가 돌아왔습니다.\nWARNING: canceling wait for synchronous replication due to user request DETAIL: The transaction has already committed locally, but might not have been replicated to the standby. INSERT 0 1 primary와, 다시 켠 standby에서 그 행을 찾아봅니다.\nprimary=# SELECT * FROM acct WHERE id = 200005; id | balance | pad --------+---------+----------- 200005 | 1 | sync wait (1 row) $ pg_ctl -D /home/postgres/standby -l /home/postgres/standby.log start waiting for server to start.... done server started $ sleep 1 standby=# SELECT * FROM acct WHERE id = 200005; id | balance | pad --------+---------+----------- 200005 | 1 | sync wait (1 row) primary=# ALTER SYSTEM RESET synchronous_standby_names; primary=# SELECT pg_reload_conf(); 세션 A는 취소된 뒤 canceling wait for synchronous replication 경고와 함께 INSERT 0 1을 받았습니다. DETAIL이 말하듯 로컬에서는 이미 커밋되었고, standby에는 복제되지 않았을 수 있다는 뜻입니다. 실제로 primary에서 200005번 행이 보입니다. standby를 다시 켜자 walreceiver가 밀린 WAL을 받아, standby에서도 그 행이 보입니다. 동기 복제는 \u0026ldquo;standby에 없으면 커밋하지 않는다\u0026quot;가 아니라 \u0026ldquo;standby에 닿기 전에는 커밋 완료를 알리지 않는다\u0026quot;입니다. 대기를 취소하거나 그 사이 primary가 죽으면, 클라이언트는 결과를 모르지만 로컬 커밋은 남아 있을 수 있습니다.\nhot standby 충돌 standby의 읽기 쿼리는 자기 스냅샷(4편)으로 옛 행 버전을 보고 있을 수 있습니다. 그런데 primary에서 VACUUM(이나 페이지 정리, 5편)이 그 버전을 지우면, 그 정리 기록이 WAL로 standby에 옵니다. standby의 startup은 이 기록을 재생해야 하는데, 재생하면 쿼리가 보던 행이 사라집니다.\nstartup은 충돌하는 WAL을 받은 시각부터 max_standby_streaming_delay(기본 30초)가 지날 때까지 쿼리가 끝나기를 기다리고, 그래도 안 끝나면 쿼리를 취소하고 재생을 계속합니다(GetStandbyLimitTime(), ResolveRecoveryConflictWithSnapshot()). 재생을 무한정 멈출 수는 없기 때문입니다. hot_standby_feedback = on으로 두면 standby가 자기 쿼리의 xmin을 primary에 알려서 primary의 VACUUM이 그 버전을 지우지 않게 할 수 있습니다(ProcessStandbyHSFeedbackMessage()). 대신 standby의 긴 쿼리가 primary의 VACUUM을 막아 primary에 bloat가 생깁니다(5편).\nstandby 쿼리와 WAL 재생의 충돌 standby의 max_standby_streaming_delay를 3초로 줄입니다.\nstandby=# SHOW max_standby_streaming_delay; max_standby_streaming_delay ----------------------------- 30s (1 row) standby=# SHOW hot_standby_feedback; hot_standby_feedback ---------------------- off (1 row) standby=# ALTER SYSTEM SET max_standby_streaming_delay = \u0026#39;3s\u0026#39;; standby=# SELECT pg_reload_conf(); standby에 접속한 세션 B에서 20초 걸리는 쿼리를 돌리는 동안, primary에서 행을 지우고 VACUUM합니다.\nB=# SELECT count(*), pg_sleep(20) FROM acct; primary=# DELETE FROM acct WHERE id \u0026gt; 200000; primary=# VACUUM acct; 세션 B에는 다음 오류가 돌아왔습니다.\nERROR: canceling statement due to conflict with recovery DETAIL: User query might have needed to see row versions that must be removed. $ grep -E \u0026#34;conflict with recovery|recovery conflict\u0026#34; /home/postgres/standby.log | tail -3 | cut -c1-200 2026-09-26 10:11:49.202 UTC [271] client backend ERROR: canceling statement due to conflict with recovery standby=# SELECT datname, confl_snapshot FROM pg_stat_database_conflicts WHERE datname = \u0026#39;postgres\u0026#39;; datname | confl_snapshot ----------+---------------- postgres | 1 (1 row) standby=# ALTER SYSTEM RESET max_standby_streaming_delay; standby=# SELECT pg_reload_conf(); standby의 쿼리가 canceling statement due to conflict with recovery로 취소되었습니다. DETAIL은 \u0026ldquo;쿼리가 봐야 할 수도 있는 행 버전을 지워야 한다\u0026quot;는 뜻입니다. primary의 VACUUM이 지운 행 버전(앞에서 넣었다가 DELETE한 행)의 정리 기록을 재생하려다, 그 버전을 볼 수도 있는 쿼리의 스냅샷과 부딪혔습니다. startup은 max_standby_streaming_delay(3초)까지 기다린 뒤 쿼리를 취소하고 재생을 계속했고, standby의 pg_stat_database_conflicts.confl_snapshot이 1이 되었습니다. 기본값(30초)이었다면 이 20초짜리 쿼리는 취소되지 않았을 것입니다. 대신 쿼리가 끝날 때까지 재생이 멈춰 standby가 그만큼 뒤처졌을 것입니다.\n운영에서는: standby 쿼리가 자주 취소된다 canceling statement due to conflict with recovery가 자주 나오면 선택지는 세 가지입니다.\nmax_standby_streaming_delay를 늘립니다. 대신 쿼리가 도는 동안 재생이 멈춰 standby가 그만큼 뒤처집니다. hot_standby_feedback = on으로 둡니다. 대신 standby의 긴 쿼리가 primary의 VACUUM을 막습니다. 분석용 긴 쿼리는 재생 지연을 감수하는 별도 standby로 보냅니다. 어떤 종류의 충돌인지는 standby의 pg_stat_database_conflicts에서 나눠 볼 수 있습니다.\nreplication slot: WAL을 붙잡아 두는 약속 primary는 체크포인트 때 REDO 위치 이전의 WAL을 지웁니다(8편). standby가 잠시 끊겼다가 돌아왔는데 그동안 필요한 WAL이 지워졌다면, standby는 더 따라갈 수 없고 백업부터 다시 만들어야 합니다.\nreplication slot은 \u0026ldquo;이 standby가 아직 받지 못한 WAL은 지우지 말라\u0026quot;는 약속입니다. slot에는 standby가 flush했다고 보고한 위치가 restart_lsn으로 기록되고(PhysicalConfirmReceivedLocation()), 체크포인트는 모든 slot의 restart_lsn 가운데 가장 오래된 것 이후의 WAL을 남깁니다(KeepLogSeg()). slot은 standby가 끊겨 있어도 유지됩니다.\nslot의 상태는 pg_replication_slots.wal_status로 봅니다(GetWALAvailability()).\nwal_status 뜻 reserved 필요한 WAL이 max_wal_size 안에 있음 extended max_wal_size를 넘었지만 slot 때문에 남겨 두고 있음 unreserved 다음 체크포인트에 지워질 수 있음 lost 필요한 WAL이 이미 지워졌거나 slot이 무효화됨. 이 slot으로는 복제를 이어갈 수 없음 slot은 standby가 멈춰도 WAL을 붙잡는다 primary의 max_wal_size를 64MB로 줄이고, standby를 멈춘 채 전체 행 UPDATE를 세 번 한 뒤 체크포인트합니다.\nprimary=# ALTER SYSTEM SET max_wal_size = \u0026#39;64MB\u0026#39;; primary=# SELECT pg_reload_conf(); $ pg_ctl -D /home/postgres/standby stop -m fast waiting for server to shut down.... done server stopped primary=# SELECT slot_name, active, restart_lsn, wal_status, pg_size_pretty(safe_wal_size) AS safe_wal_size FROM pg_replication_slots; slot_name | active | restart_lsn | wal_status | safe_wal_size -----------+--------+-------------+------------+--------------- standby1 | f | 0/A0543F0 | reserved | (1 row) primary=# UPDATE acct SET balance = balance + 1; primary=# UPDATE acct SET balance = balance + 1; primary=# UPDATE acct SET balance = balance + 1; primary=# CHECKPOINT; primary=# SELECT slot_name, active, restart_lsn, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots; slot_name | active | restart_lsn | wal_status | retained -----------+--------+-------------+------------+---------- standby1 | f | 0/A0543F0 | extended | 480 MB (1 row) $ du -sh $PGDATA/pg_wal 497M\t/var/lib/postgresql/data/pg_wal standby를 멈춘 직후 slot은 active = f이고 wal_status는 reserved입니다. safe_wal_size가 비어 있는 것은 max_slot_wal_keep_size가 무제한(-1)이라 \u0026ldquo;지워지기까지 남은 양\u0026quot;이라는 것이 없기 때문입니다. UPDATE 세 번과 CHECKPOINT 뒤에도 restart_lsn은 standby가 멈춘 위치 0/A0543F0에 그대로이고, 그 뒤로 480MB의 WAL을 붙잡고 있습니다. max_wal_size(64MB)를 한참 넘었으므로 wal_status가 extended가 되었고, pg_wal 디렉터리는 497MB입니다. 체크포인트를 했는데도 WAL을 지우지 못한 것입니다. slot을 무효화하는 안전장치 문제는 standby가 영영 돌아오지 않을 때입니다. slot이 WAL을 계속 붙잡아 pg_wal이 끝없이 커지고, 결국 디스크가 가득 차면 primary가 멈춥니다(7편). 이를 막는 설정이 두 가지 있습니다.\nmax_slot_wal_keep_size: slot이 붙잡을 수 있는 WAL의 상한입니다(기본 -1, 무제한). 넘으면 체크포인트가 그 slot을 무효화(invalidate)하고 WAL을 지웁니다(보존 상한 계산은 xlog.c, 무효화는 xlog.c). idle_replication_slot_timeout (PG18 신규): 이 시간보다 오래 쓰이지 않은 slot을 체크포인트 때 무효화합니다(기본 0, 끔). WAL을 예약한(restart_lsn이 있는) slot만 대상입니다. max_slot_wal_keep_size를 넘으면 slot을 포기한다 primary=# ALTER SYSTEM SET max_slot_wal_keep_size = \u0026#39;128MB\u0026#39;; primary=# SELECT pg_reload_conf(); primary=# UPDATE acct SET balance = balance + 1; primary=# CHECKPOINT; primary=# SELECT slot_name, active, restart_lsn, wal_status, invalidation_reason FROM pg_replication_slots; slot_name | active | restart_lsn | wal_status | invalidation_reason -----------+--------+-------------+------------+--------------------- standby1 | f | | lost | wal_removed (1 row) $ grep -E \u0026#34;invalidating obsolete replication slot|exceeds the limit\u0026#34; /home/postgres/primary.log | cut -c1-200 2026-09-26 10:11:54.892 UTC [39] checkpointer LOG: invalidating obsolete replication slot \u0026#34;standby1\u0026#34; 2026-09-26 10:11:54.892 UTC [39] checkpointer DETAIL: The slot\u0026#39;s restart_lsn 0/A0543F0 exceeds the limit by 419085328 bytes. $ du -sh $PGDATA/pg_wal 65M\t/var/lib/postgresql/data/pg_wal $ pg_ctl -D /home/postgres/standby -l /home/postgres/standby.log start waiting for server to start.... done server started $ sleep 2 $ grep -E \u0026#34;could not start WAL streaming\u0026#34; /home/postgres/standby.log | tail -1 | cut -c1-200 2026-09-26 10:11:55.591 UTC [392] walreceiver FATAL: could not start WAL streaming: ERROR: can no longer access replication slot \u0026#34;standby1\u0026#34; max_slot_wal_keep_size를 128MB로 정하고 체크포인트하자, checkpointer가 invalidating obsolete replication slot \u0026quot;standby1\u0026quot;을 남기고 slot을 무효화했습니다. DETAIL은 restart_lsn이 상한을 419085328바이트(약 400MB) 넘었다는 뜻입니다. slot은 lost, 원인은 wal_removed가 되었고 restart_lsn은 비었습니다. pg_wal은 65MB로 줄었습니다. standby를 다시 켜도 walreceiver가 can no longer access replication slot으로 실패합니다. WAL 아카이브(8편)가 없다면 이 standby는 새 베이스 백업으로 다시 만들어야 합니다. primary의 디스크를 지키는 대신 standby를 포기한 것입니다. PG18의 idle_replication_slot_timeout 아무도 쓰지 않는 slot forgotten을 만들고, 1초 이상 쉰 slot을 무효화하도록 설정한 뒤 2초 뒤에 체크포인트합니다.\nprimary=# SELECT * FROM pg_create_physical_replication_slot(\u0026#39;forgotten\u0026#39;, true); slot_name | lsn -----------+------------ forgotten | 0/33639AA8 (1 row) primary=# ALTER SYSTEM SET idle_replication_slot_timeout = \u0026#39;1s\u0026#39;; primary=# SELECT pg_reload_conf(); primary=# CHECKPOINT; primary=# SELECT slot_name, active, inactive_since IS NOT NULL AS has_inactive_since, wal_status, invalidation_reason FROM pg_replication_slots ORDER BY slot_name; slot_name | active | has_inactive_since | wal_status | invalidation_reason -----------+--------+--------------------+------------+--------------------- forgotten | f | t | lost | idle_timeout standby1 | f | t | lost | wal_removed (2 rows) $ grep -E \u0026#34;invalidating obsolete replication slot \\\u0026#34;forgotten\\\u0026#34;\u0026#34; -A1 /home/postgres/primary.log | cut -c1-200 2026-09-26 10:11:59.786 UTC [39] checkpointer LOG: invalidating obsolete replication slot \u0026#34;forgotten\u0026#34; 2026-09-26 10:11:59.786 UTC [39] checkpointer DETAIL: The slot\u0026#39;s idle time of 2s exceeds the configured \u0026#34;idle_replication_slot_timeout\u0026#34; duration of 1s. 1초 넘게 쉰 forgotten slot을 체크포인트가 무효화했습니다. 원인은 idle_timeout이고, 로그에 쉰 시간(2s)과 설정값(1s)이 나옵니다. 이 검사는 체크포인트 때 합니다(xlog.c). 그래서 실제로 무효화되는 시점은 설정값보다 체크포인트 간격만큼 늦을 수 있습니다. 실습에서는 CHECKPOINT로 바로 확인했습니다. forgotten은 두 번째 인자 true로 만들 때 WAL을 바로 예약했기 때문에 검사 대상이 되었습니다. slot은 한 번 무효화되면 되살릴 수 없습니다. 운영에서는 며칠처럼 넉넉한 값을 씁니다. 운영에서는: 방치된 slot이 디스크를 가득 채운다 가장 흔한 복제 사고입니다. standby를 없애거나 논리 복제 구독을 지우면서 slot을 지우지 않으면, 앞에서 본 것처럼 pg_wal이 끝없이 커집니다. pg_replication_slots에서 active = false이면서 restart_lsn이 오래된 slot을 주기적으로 확인하고, 쓰지 않는 slot은 pg_drop_replication_slot()으로 지워야 합니다. 안전장치로 max_slot_wal_keep_size를 디스크 여유에 맞게 정해 두면, 최악의 경우 standby 하나를 다시 만드는 것으로 끝나고 primary는 멈추지 않습니다. PG18에서는 idle_replication_slot_timeout도 쓸 수 있습니다.\n정리 스트리밍 복제는 standby가 primary의 WAL을 계속 받아 재생하는 \u0026ldquo;끝나지 않는 복구\u0026quot;입니다. primary의 walsender가 보내고, standby의 walreceiver가 받아 쓰고, startup이 재생합니다. standby는 write, flush, apply 위치를 primary에 보고하고, pg_stat_replication의 네 위치와 lag 컬럼으로 지연이 전송, 기록, 재생 중 어디서 생기는지 볼 수 있습니다. 동기 복제에서 커밋은 로컬 flush 뒤 standby의 보고를 기다립니다. 기다리다 취소해도 로컬 커밋은 되돌아가지 않습니다. standby의 읽기 쿼리는 재생과 충돌하면 max_standby_streaming_delay 뒤에 취소됩니다. replication slot은 standby가 받지 못한 WAL을 지우지 않게 하지만, 방치하면 디스크를 채웁니다. max_slot_wal_keep_size와 PG18의 idle_replication_slot_timeout이 안전장치입니다. 다음 글에서는 시리즈의 마지막으로, SQL 한 줄이 결과가 되기까지의 쿼리 처리 과정과 통계 정보를 살펴봅니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/replication/walsender.c: WAL 전송, standby 보고 처리 src/backend/replication/walreceiver.c: WAL 수신, 위치 보고 src/backend/replication/syncrep.c: 동기 복제 대기 src/backend/replication/slot.c: replication slot, 무효화 src/backend/storage/ipc/standby.c: hot standby 충돌 처리 PostgreSQL 18 공식 문서\nStreaming Replication, Replication Slots Synchronous Replication Hot Standby: Handling Query Conflicts pg_stat_replication, pg_replication_slots max_slot_wal_keep_size, idle_replication_slot_timeout ","permalink":"https://jinlog.dev/posts/postgresql/09-streaming-replication/","summary":"standby는 primary의 변경을 어떻게 따라가고, replication slot은 왜 디스크를 채우는가","title":"PostgreSQL 인터널 9: 스트리밍 복제와 Replication Slot"},{"content":"개요 평소에는 괜찮다가 몇 분마다 응답 시간이 튀고, 그때마다 디스크 쓰기가 몰립니다. 이런 주기적인 패턴의 가장 흔한 원인은 체크포인트입니다. 체크포인트는 shared buffers의 dirty 페이지를 디스크에 내보내고, 그 뒤 처음 고치는 페이지마다 페이지 전체를 WAL에 기록하게 만듭니다(full page write)(인터널 7편, 8편). 체크포인트가 너무 잦거나 한꺼번에 몰리면 I/O와 WAL이 함께 튑니다.\n이 글에서 답할 질문은 다음과 같습니다.\n체크포인트가 너무 잦은지 어디서 보는가 max_wal_size가 작으면 실제로 무엇이 얼마나 나빠지는가 체크포인트가 시작될 때 처리량이 떨어지는 이유는 무엇인가 누가 dirty 페이지를 디스크에 쓰고 있는지 어떻게 보는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습 데이터는 pgbench scale 50(약 750 MB)이고, 기본 shared buffers(128 MB)보다 훨씬 큽니다. 체크포인트 관련 기본값은 이렇습니다.\npostgres=# SELECT name, setting, unit FROM pg_settings postgres-# WHERE name IN (\u0026#39;max_wal_size\u0026#39;, \u0026#39;min_wal_size\u0026#39;, \u0026#39;checkpoint_timeout\u0026#39;, \u0026#39;checkpoint_completion_target\u0026#39;, postgres-# \u0026#39;checkpoint_warning\u0026#39;, \u0026#39;full_page_writes\u0026#39;, \u0026#39;log_checkpoints\u0026#39;) postgres-# ORDER BY name; name | setting | unit ------------------------------+---------+------ checkpoint_completion_target | 0.9 | checkpoint_timeout | 300 | s checkpoint_warning | 30 | s full_page_writes | on | log_checkpoints | on | max_wal_size | 1024 | MB min_wal_size | 80 | MB (7 rows) 체크포인트는 두 가지 이유로 시작됩니다. checkpoint_timeout(5분)이 지나거나(time), 마지막 체크포인트 이후 쌓인 WAL이 max_wal_size(1 GB)에 가까워지거나(wal) 둘 중 먼저 오는 쪽입니다.\n먼저 확인할 것 서버 로그: 체크포인트가 왜, 얼마나 자주 시작되는가 log_checkpoints는 PostgreSQL 15부터 기본으로 켜져 있어서 체크포인트마다 두 줄이 남습니다.\n$ tail -n 2000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;checkpoint (starting|complete)\u0026#39; | tail -n 2 2026-09-26 10:42:33.963 UTC [31] LOG: checkpoint starting: wal checkpoint starting: 뒤의 이유가 핵심입니다. time이면 정상이고, wal이 자주 보이면 max_wal_size가 부하에 비해 작은 것입니다. 체크포인트 간격이 checkpoint_warning(30초)보다 짧으면 경고도 남습니다.\n$ tail -n 2000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -A 1 -E \u0026#39;checkpoints are occurring too frequently\u0026#39; | tail -n 2 2026-09-26 10:42:34.389 UTC [31] LOG: checkpoints are occurring too frequently (1 second apart) 2026-09-26 10:42:34.389 UTC [31] HINT: Consider increasing the configuration parameter \u0026#34;max_wal_size\u0026#34;. pg_stat_checkpointer 누적 통계는 pg_stat_checkpointer(PostgreSQL 17부터, 그 전에는 pg_stat_bgwriter에 있던 값)에 있습니다. num_timed는 시간으로, num_requested는 WAL 양이나 요청으로 시작된 체크포인트 수입니다. num_requested가 num_timed보다 훨씬 많다면 max_wal_size를 먼저 의심합니다.\n원인별 진단 max_wal_size가 작다: 체크포인트가 끝없이 돈다 max_wal_size를 64 MB로 줄이고, 통계를 초기화한 뒤 60초 동안 부하를 줍니다.\npostgres=# ALTER SYSTEM SET max_wal_size = \u0026#39;64MB\u0026#39;; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_stat_reset_shared(\u0026#39;checkpointer\u0026#39;), pg_stat_reset_shared(\u0026#39;wal\u0026#39;), postgres-# pg_stat_reset_shared(\u0026#39;io\u0026#39;), pg_stat_reset_shared(\u0026#39;bgwriter\u0026#39;), pg_stat_reset(); pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset ----------------------+----------------------+----------------------+----------------------+--------------- | | | | (1 row) $ pgbench -n -c 8 -j 2 -T 60 -P 5 postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;^progress|^tps|latency average\u0026#39; progress: 5.0 s, 8836.5 tps, lat 0.897 ms stddev 3.228, 0 failed progress: 10.0 s, 9037.0 tps, lat 0.882 ms stddev 2.919, 0 failed progress: 15.0 s, 9966.2 tps, lat 0.798 ms stddev 2.421, 0 failed progress: 20.0 s, 9954.2 tps, lat 0.797 ms stddev 3.076, 0 failed progress: 25.0 s, 9962.0 tps, lat 0.799 ms stddev 3.031, 0 failed progress: 30.0 s, 10037.0 tps, lat 0.792 ms stddev 2.533, 0 failed progress: 35.0 s, 10163.8 tps, lat 0.779 ms stddev 2.211, 0 failed progress: 40.0 s, 10394.2 tps, lat 0.768 ms stddev 2.291, 0 failed progress: 45.0 s, 10217.5 tps, lat 0.778 ms stddev 2.583, 0 failed progress: 50.0 s, 9803.4 tps, lat 0.811 ms stddev 3.227, 0 failed progress: 55.0 s, 10074.1 tps, lat 0.789 ms stddev 2.575, 0 failed progress: 60.0 s, 9511.6 tps, lat 0.835 ms stddev 2.630, 0 failed latency average = 0.809 ms tps = 9829.277229 (without initial connection time) postgres=# SELECT c.num_timed, c.num_requested, c.num_done, c.buffers_written, postgres-# round(c.write_time) AS write_ms, round(c.sync_time) AS sync_ms, postgres-# w.wal_fpi, pg_size_pretty(w.wal_bytes) AS wal, postgres-# d.xact_commit, pg_size_pretty(w.wal_bytes / d.xact_commit) AS wal_per_xact postgres-# FROM pg_stat_checkpointer c, pg_stat_wal w, pg_stat_database d postgres-# WHERE d.datname = \u0026#39;postgres\u0026#39;; num_timed | num_requested | num_done | buffers_written | write_ms | sync_ms | wal_fpi | wal | xact_commit | wal_per_xact -----------+---------------+----------+-----------------+----------+---------+---------+---------+-------------+----------------------------- 0 | 170 | 169 | 651127 | 41982 | 14690 | 679639 | 5432 MB | 589820 | 9656.1948424943203011 bytes (1 row) $ tail -n 2000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -c \u0026#39;checkpoint starting: wal\u0026#39; 171 [exit=0] 60초 동안 체크포인트가 170번, 거의 1초에 3번 돌았습니다. 모두 num_requested, 로그로는 wal입니다. 같은 부하를 max_wal_size 4 GB에서 다시 줍니다.\npostgres=# ALTER SYSTEM SET max_wal_size = \u0026#39;4GB\u0026#39;; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) postgres=# CHECKPOINT; CHECKPOINT postgres=# SELECT pg_stat_reset_shared(\u0026#39;checkpointer\u0026#39;), pg_stat_reset_shared(\u0026#39;wal\u0026#39;), postgres-# pg_stat_reset_shared(\u0026#39;io\u0026#39;), pg_stat_reset_shared(\u0026#39;bgwriter\u0026#39;), pg_stat_reset(); pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset_shared | pg_stat_reset ----------------------+----------------------+----------------------+----------------------+--------------- | | | | (1 row) $ pgbench -n -c 8 -j 2 -T 60 -P 5 postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;^progress|^tps|latency average\u0026#39; progress: 5.0 s, 13490.2 tps, lat 0.587 ms stddev 0.648, 0 failed progress: 10.0 s, 14140.8 tps, lat 0.561 ms stddev 0.339, 0 failed progress: 15.0 s, 13064.4 tps, lat 0.607 ms stddev 0.765, 0 failed progress: 20.0 s, 14205.0 tps, lat 0.558 ms stddev 0.248, 0 failed progress: 25.0 s, 14422.8 tps, lat 0.550 ms stddev 0.220, 0 failed progress: 30.0 s, 14013.1 tps, lat 0.566 ms stddev 0.260, 0 failed progress: 35.0 s, 14125.7 tps, lat 0.561 ms stddev 0.255, 0 failed progress: 40.0 s, 14227.3 tps, lat 0.557 ms stddev 0.197, 0 failed progress: 45.0 s, 13654.7 tps, lat 0.581 ms stddev 1.751, 0 failed progress: 50.0 s, 13773.3 tps, lat 0.576 ms stddev 0.204, 0 failed progress: 55.0 s, 14229.0 tps, lat 0.557 ms stddev 0.204, 0 failed progress: 60.0 s, 14313.4 tps, lat 0.554 ms stddev 0.192, 0 failed latency average = 0.567 ms tps = 13972.776666 (without initial connection time) postgres=# SELECT c.num_timed, c.num_requested, c.num_done, c.buffers_written, postgres-# round(c.write_time) AS write_ms, round(c.sync_time) AS sync_ms, postgres-# w.wal_fpi, pg_size_pretty(w.wal_bytes) AS wal, postgres-# d.xact_commit, pg_size_pretty(w.wal_bytes / d.xact_commit) AS wal_per_xact postgres-# FROM pg_stat_checkpointer c, pg_stat_wal w, pg_stat_database d postgres-# WHERE d.datname = \u0026#39;postgres\u0026#39;; num_timed | num_requested | num_done | buffers_written | write_ms | sync_ms | wal_fpi | wal | xact_commit | wal_per_xact -----------+---------------+----------+-----------------+----------+---------+---------+---------+-------------+----------------------------- 0 | 0 | 0 | 0 | 0 | 0 | 93556 | 1043 MB | 838330 | 1304.3643278899717295 bytes (1 row) 두 결과를 나란히 놓으면 이렇습니다.\nmax_wal_size 64 MB max_wal_size 4 GB 체크포인트 170번 0번 full page image(wal_fpi) 679,639 93,556 WAL 양 5432 MB 1043 MB 트랜잭션당 WAL 약 9.7 kB 약 1.3 kB 처리량 9,829 tps 13,972 tps 평균 지연 0.809 ms 0.567 ms 체크포인트가 잦으면 같은 일을 하는 데 WAL을 7배 넘게 씁니다. 체크포인트가 끝날 때마다 페이지를 다시 처음 고치는 것이 되어 full page image를 새로 쓰기 때문입니다. WAL이 늘면 WAL을 디스크에 쓰는 시간, 아카이브와 복제로 보내는 양, standby가 재생할 양이 모두 늘어납니다. 처리량은 42% 떨어졌습니다.\n체크포인트가 시작될 때 처리량이 떨어진다 잦지 않더라도 체크포인트는 주기적인 흔들림을 만듭니다. checkpoint_timeout을 30초로 줄여 시간 기준 체크포인트가 부하 도중에 돌게 합니다.\npostgres=# CHECKPOINT; CHECKPOINT postgres=# ALTER SYSTEM SET checkpoint_timeout = \u0026#39;30s\u0026#39;; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) $ pgbench -n -c 8 -j 2 -T 70 -P 5 postgres 2\u0026gt;\u0026amp;1 | grep -E \u0026#39;^progress|^tps\u0026#39; progress: 5.0 s, 14097.2 tps, lat 0.562 ms stddev 0.754, 0 failed progress: 10.0 s, 14490.4 tps, lat 0.547 ms stddev 0.087, 0 failed progress: 15.0 s, 13674.7 tps, lat 0.580 ms stddev 0.785, 0 failed progress: 20.0 s, 13957.3 tps, lat 0.568 ms stddev 0.133, 0 failed progress: 25.0 s, 14181.0 tps, lat 0.559 ms stddev 0.085, 0 failed progress: 30.0 s, 13995.2 tps, lat 0.567 ms stddev 0.131, 0 failed progress: 35.0 s, 11633.8 tps, lat 0.673 ms stddev 2.018, 0 failed progress: 40.0 s, 12508.8 tps, lat 0.643 ms stddev 1.638, 0 failed progress: 45.0 s, 10561.2 tps, lat 0.752 ms stddev 2.344, 0 failed progress: 50.0 s, 6617.0 tps, lat 1.204 ms stddev 8.370, 0 failed progress: 55.0 s, 5207.0 tps, lat 1.531 ms stddev 2.233, 0 failed progress: 60.0 s, 13100.9 tps, lat 0.605 ms stddev 0.907, 0 failed progress: 65.0 s, 11957.9 tps, lat 0.664 ms stddev 4.772, 0 failed progress: 70.0 s, 7108.1 tps, lat 1.120 ms stddev 1.828, 0 failed tps = 11650.093680 (without initial connection time) $ tail -n 2000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;checkpoint (starting|complete)\u0026#39; | tail -n 4 2026-09-26 10:43:35.566 UTC [31] LOG: checkpoint complete: wrote 8992 buffers (54.9%), wrote 58 SLRU buffers; 0 WAL file(s) added, 0 removed, 66 recycled; write=0.023 s, sync=0.262 s, total=0.414 s; sync files=25, longest=0.196 s, average=0.011 s; distance=1084112 kB, estimate=1084112 kB; lsn=1/BF2BAE30, redo lsn=1/BF2BADD8 2026-09-26 10:44:05.175 UTC [31] LOG: checkpoint starting: time 2026-09-26 10:44:32.495 UTC [31] LOG: checkpoint complete: wrote 213 buffers (1.3%), wrote 46 SLRU buffers; 0 WAL file(s) added, 0 removed, 52 recycled; write=26.840 s, sync=0.300 s, total=27.321 s; sync files=18, longest=0.251 s, average=0.017 s; distance=865433 kB, estimate=1062244 kB; lsn=2/2B003938, redo lsn=1/F3FE11D8 2026-09-26 10:44:35.506 UTC [31] LOG: checkpoint starting: time 처음 30초는 초당 1만 4천 건 안팎이다가, 체크포인트가 시작된 뒤(30초 무렵)부터 처리량이 떨어져 50~55초 구간에는 5천 건대까지 내려갔습니다. 체크포인트가 끝나자 회복했고, 60초 무렵 다음 체크포인트가 시작되자 다시 떨어졌습니다. 몇 분마다 튀는 응답 시간이 체크포인트 주기와 맞는지 로그의 checkpoint starting 시각과 비교해 보면 원인을 가를 수 있습니다.\n두 가지가 함께 일어납니다. 체크포인트가 시작되면 그 뒤 처음 고치는 페이지마다 full page image를 WAL에 쓰므로 WAL이 갑자기 늘어나고(앞 절에서 본 차이), 체크포인터는 dirty 페이지를 쓰고 마지막에 fsync합니다. 로그의 write=26.840 s는 체크포인터가 쓰기를 checkpoint_timeout × checkpoint_completion_target(30초 × 0.9 = 27초)에 걸쳐 나눠 썼다는 뜻입니다. 쓰기를 이렇게 펴 두는 것이 checkpoint_completion_target의 역할이고, 기본값 0.9면 대개 충분합니다.\n누가 페이지를 쓰는가 pg_stat_io로 relation 페이지를 누가 디스크에 썼는지 봅니다. 64 MB일 때입니다.\npostgres=# SELECT backend_type, writes, fsyncs postgres-# FROM pg_stat_io postgres-# WHERE object = \u0026#39;relation\u0026#39; AND context = \u0026#39;normal\u0026#39; AND writes \u0026gt; 0 postgres-# ORDER BY writes DESC; backend_type | writes | fsyncs -------------------+--------+-------- checkpointer | 651127 | 1173 background writer | 7871 | 0 (2 rows) 거의 모든 쓰기를 체크포인터가 했습니다. 4 GB일 때는 다릅니다.\npostgres=# SELECT backend_type, writes, fsyncs postgres-# FROM pg_stat_io postgres-# WHERE object = \u0026#39;relation\u0026#39; AND context = \u0026#39;normal\u0026#39; AND writes \u0026gt; 0 postgres-# ORDER BY writes DESC; backend_type | writes | fsyncs -------------------+--------+-------- client backend | 737700 | 0 background writer | 29730 | 0 autovacuum worker | 173 | 0 (3 rows) 체크포인트가 한 번도 돌지 않았으므로, shared buffers가 부족할 때마다 client backend가 직접 dirty 페이지를 내보냈습니다. 쿼리를 처리하던 backend가 쓰기까지 하니 그만큼 응답이 늦어집니다. 문서는 client backend의 쓰기가 많으면 shared buffers나 체크포인트 설정이 맞지 않을 수 있다고 설명합니다(pg_stat_io). 이 실습은 데이터(750 MB)에 비해 shared buffers(128 MB)가 작아서 생긴 일로, 인터널 2편과 1편에서 본 것과 같은 모습입니다. background writer가 미리 써 주는 양(bgwriter_lru_maxpages, bgwriter_lru_multiplier)을 늘리거나 shared buffers를 키우는 것이 방법입니다.\n조치 증상 확인 조치 checkpoint starting: wal이 잦음, too frequently 경고 로그, num_requested ≫ num_timed max_wal_size를 늘림 체크포인트 시작 무렵 처리량이 떨어짐 로그의 checkpoint starting 시각과 지연 그래프 비교 checkpoint_completion_target 확인(0.9), checkpoint_timeout을 늘려 빈도를 줄임, 디스크 성능 확인 client backend의 쓰기가 많음 pg_stat_io의 client backend / relation / normal writes shared buffers, background writer 설정 max_wal_size를 늘리는 대가도 알고 있어야 합니다.\n디스크: 평소 pg_wal이 그만큼 커질 수 있습니다. 4 GB로 둔 실습에서 60초 뒤 pg_wal은 1 GB를 넘었습니다. postgres=# SELECT pg_size_pretty(sum(size)) AS pg_wal FROM pg_ls_waldir(); pg_wal --------- 1072 MB (1 row) 장애 복구 시간: 장애가 나면 마지막 체크포인트 이후의 WAL을 재생해야 하므로, 체크포인트 간격이 길수록 복구가 오래 걸립니다(인터널 8편). 일반적인 방향은 평소에는 time으로 체크포인트가 돌 만큼 max_wal_size를 넉넉히 잡는 것입니다. 로그에서 wal 체크포인트가 드물게만 보이면 됩니다. checkpoint_timeout은 복구 시간 목표에 맞춰 정합니다.\n재발 방지 체크포인트 이유 비율: pg_stat_checkpointer의 num_timed와 num_requested를 수집합니다. 부하가 늘면서 num_requested가 늘기 시작하면 max_wal_size를 다시 봅니다. 체크포인트 소요 시간: 로그의 write=, sync=, total=. sync가 길면 디스크가 fsync를 버거워하는 것입니다. WAL 생성량: pg_stat_wal의 wal_bytes, wal_fpi 추세. full page image 비율이 갑자기 늘면 체크포인트가 잦아진 것입니다. 누가 쓰는가: pg_stat_io에서 client backend의 쓰기 비율. 지연 그래프와 체크포인트 시각을 겹쳐 봅니다. 주기적인 튐이 체크포인트와 맞는지 한눈에 보입니다. 정리 체크포인트는 time 또는 wal 이유로 시작됩니다. 로그의 checkpoint starting: wal이 잦고 checkpoints are occurring too frequently가 보이면 max_wal_size가 작은 것입니다. 이 실습에서 max_wal_size 64 MB는 60초에 체크포인트 170번, 트랜잭션당 WAL 7배 이상, 처리량 42% 감소로 이어졌습니다. 체크포인트마다 full page image를 새로 쓰기 때문입니다. 체크포인트가 시작되면 full page image와 체크포인터의 쓰기로 처리량이 주기적으로 떨어질 수 있습니다. 지연 그래프를 체크포인트 시각과 겹쳐 봅니다. pg_stat_io로 누가 페이지를 쓰는지 봅니다. client backend의 쓰기가 많으면 shared buffers와 background writer를 봅니다. max_wal_size는 평소 time 체크포인트가 되도록 넉넉히 잡되, 디스크와 복구 시간을 함께 고려합니다. 참고 자료 WAL Configuration Write Ahead Log 설정: max_wal_size, checkpoint_timeout, checkpoint_completion_target, checkpoint_warning pg_stat_checkpointer, pg_stat_wal, pg_stat_io Resource Consumption: background writer PostgreSQL 인터널 2편 메모리 구조, 7편 WAL, 8편 체크포인트와 장애 복구 ","permalink":"https://jinlog.dev/posts/postgresql-ops/10-checkpoint-io/","summary":"몇 분마다 응답 시간이 튀고 디스크 쓰기가 몰린다면, 체크포인트를 어떻게 확인하고 무엇을 조정하는가","title":"PostgreSQL 운영 10: 주기적으로 I/O가 튄다"},{"content":"개요 지금까지는 데이터가 어떻게 저장되고 지켜지는지를 봤습니다. 마지막 편에서는 반대 방향, 사용자가 보낸 SQL 한 줄이 어떻게 결과가 되는지를 봅니다.\nSQL은 \u0026ldquo;무엇을 원하는지\u0026quot;만 말하고 \u0026ldquo;어떻게 가져올지\u0026quot;는 말하지 않습니다. WHERE amount \u0026lt; 30을 만족하는 행을 찾으려면 테이블을 처음부터 끝까지 읽을 수도 있고, 인덱스를 쓸 수도 있습니다. 어느 쪽이 빠른지는 데이터에 따라 다르고, 이를 정하는 것이 플래너입니다. 플래너가 판단 근거로 쓰는 것이 통계 정보이고, 통계가 틀리면 계획도 틀립니다. 운영 중 \u0026ldquo;어제까지 빠르던 쿼리가 갑자기 느려졌다\u0026quot;의 상당수가 여기서 나옵니다.\n이 글에서 답할 질문은 다음과 같습니다.\nSQL은 어떤 단계를 거쳐 실행되는가 플래너는 여러 실행 방법 중 하나를 어떻게 고르는가 통계 정보에는 무엇이 들어 있고, 행 수는 어떻게 추정되는가 추정이 틀리는 대표적인 경우와 고치는 방법은 무엇인가 기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.\n다섯 단계 클라이언트가 보낸 SQL 문자열은 backend(1편) 안에서 다섯 단계를 거칩니다. 전체 흐름은 exec_simple_query()에 그대로 드러나 있습니다.\n문자열이 파스 트리, Query 트리, 계획 트리로 바뀌고, 실행기가 계획 트리를 돌며 결과 행을 만듭니다. 새 창에서 크게 보기 파서(parser): 문자열을 문법 규칙(gram.y)에 맞춰 파스 트리로 바꿉니다(raw_parser(), postgres.c의 pg_parse_query()를 거쳐 불립니다). 이 단계는 문법만 보고 카탈로그는 보지 않습니다. orders라는 테이블이 정말 있는지는 아직 모릅니다. 분석기(analyzer): 시스템 카탈로그를 보며 이름을 실제 객체로 바꿉니다. orders가 어떤 OID의 테이블인지, amount가 몇 번째 컬럼이고 타입이 무엇인지, \u0026lt;가 어떤 연산자 함수인지 정해서 Query 트리를 만듭니다(transformStmt()). 없는 테이블이나 컬럼은 여기서 오류가 납니다. 리라이터(rewriter): 규칙(rule)을 적용합니다. 가장 흔한 것은 뷰입니다. 뷰는 \u0026ldquo;이 이름을 이 SELECT로 바꿔라\u0026quot;라는 규칙으로 저장되어 있어, 리라이터가 뷰 이름을 그 정의(SELECT)의 서브쿼리로 바꿉니다(QueryRewrite(), ApplyRetrieveRule()). 플래너는 이 서브쿼리를 바깥 쿼리로 끌어올려(pull-up) 합치므로, 결국 뷰가 아니라 원래 테이블을 보고 계획을 세웁니다(뷰가 풀리는 모습). 2번과 3번은 pg_analyze_and_rewrite_fixedparams()에서 함께 불립니다. 플래너(planner): Query 트리를 실행할 수 있는 여러 방법(경로, path)을 만들고, 통계로 비용을 추정해 가장 싼 것을 계획 트리로 만듭니다(planner(), create_plan()). EXPLAIN이 보여 주는 것이 이 계획 트리입니다. 실행기(executor): 계획 트리를 실행합니다(standard_ExecutorRun()). 맨 위 노드에게 \u0026ldquo;행 하나 줘\u0026quot;라고 요청하면, 그 노드가 자기 아래 노드에게 다시 요청하는 식으로 행이 한 개씩 위로 올라옵니다(ExecProcNode()). 위에서 더 필요 없다고 하면 아래 노드도 거기서 멈춥니다. 다만 정렬(Sort), 해시(Hash), 해시 집계(HashAggregate) 노드는 첫 행을 내기 전에 아래 입력을 모두 읽어야 하고, Bitmap Index Scan은 행 대신 비트맵을 통째로 넘깁니다. EXPLAIN의 비용 앞쪽 숫자(startup cost)가 이렇게 첫 행을 내기까지의 비용입니다. 단계마다 걸린 시간 고객 1000명과 주문 10만 건을 만듭니다. 주문 상태(status)는 일부러 치우치게 했고(delivered 80%, shipped 15%, cancelled 4.9%, returned 0.1%), 금액(amount)은 0~999에 고르게 퍼지되 행의 물리적 순서와는 무관하게 흩어 놓았습니다. 도시(city)와 나라(country)는 도시가 정해지면 나라가 정해지는 관계입니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log start waiting for server to start.... done server started postgres=# CREATE TABLE customers (id int PRIMARY KEY, name text); postgres=# INSERT INTO customers SELECT g, \u0026#39;customer \u0026#39; || g FROM generate_series(1, 1000) g; postgres=# CREATE TABLE orders ( postgres-# id int PRIMARY KEY, customer_id int, status text, amount int, city text, country text); postgres=# INSERT INTO orders postgres-# SELECT g, g % 1000 + 1, postgres-# CASE WHEN g % 1000 \u0026lt; 800 THEN \u0026#39;delivered\u0026#39; WHEN g % 1000 \u0026lt; 950 THEN \u0026#39;shipped\u0026#39; postgres-# WHEN g % 1000 \u0026lt; 999 THEN \u0026#39;cancelled\u0026#39; ELSE \u0026#39;returned\u0026#39; END, postgres-# (g * 7919) % 1000, postgres-# (ARRAY[\u0026#39;Seoul\u0026#39;,\u0026#39;Busan\u0026#39;,\u0026#39;Tokyo\u0026#39;,\u0026#39;Osaka\u0026#39;,\u0026#39;Paris\u0026#39;,\u0026#39;Lyon\u0026#39;,\u0026#39;Berlin\u0026#39;,\u0026#39;Munich\u0026#39;,\u0026#39;Rome\u0026#39;,\u0026#39;Milan\u0026#39;])[g % 10 + 1], postgres-# (ARRAY[\u0026#39;KR\u0026#39;,\u0026#39;KR\u0026#39;,\u0026#39;JP\u0026#39;,\u0026#39;JP\u0026#39;,\u0026#39;FR\u0026#39;,\u0026#39;FR\u0026#39;,\u0026#39;DE\u0026#39;,\u0026#39;DE\u0026#39;,\u0026#39;IT\u0026#39;,\u0026#39;IT\u0026#39;])[g % 10 + 1] postgres-# FROM generate_series(1, 100000) g; postgres=# CREATE INDEX orders_customer_idx ON orders (customer_id); postgres=# CREATE INDEX orders_amount_idx ON orders (amount); postgres=# ANALYZE; log_parser_stats, log_planner_stats, log_executor_stats를 켜면 단계마다 걸린 시간이 로그로 나옵니다. client_min_messages = log로 두어 psql 화면에서 바로 봅니다.\n$ PGOPTIONS=\u0026#34;-c client_min_messages=log -c log_parser_stats=on -c log_planner_stats=on -c log_executor_stats=on\u0026#34; \\ \u003e psql -X -c \u0026#34;SELECT status, count(*) FROM orders WHERE amount \u0026lt; 100 GROUP BY status\u0026#34; 2\u0026gt;\u0026amp;1 | grep -E \u0026#34;^LOG:|elapsed|^ +status +\\||^-+\\+|^ [a-z]+ +\\|\u0026#34; LOG: PARSER STATISTICS !\t0.000017 s user, 0.000017 s system, 0.000033 s elapsed LOG: PARSE ANALYSIS STATISTICS !\t0.000115 s user, 0.000115 s system, 0.000230 s elapsed LOG: REWRITER STATISTICS !\t0.000003 s user, 0.000003 s system, 0.000005 s elapsed LOG: PLANNER STATISTICS !\t0.000099 s user, 0.000099 s system, 0.000198 s elapsed LOG: EXECUTOR STATISTICS !\t0.000000 s user, 0.001685 s system, 0.001686 s elapsed status | count -----------+------- returned | 100 cancelled | 400 shipped | 1400 delivered | 8100 PARSER → PARSE ANALYSIS → REWRITER → PLANNER → EXECUTOR 순서로, 위에서 본 다섯 단계가 그대로 나옵니다. 이 쿼리에서는(elapsed 기준) 파싱이 0.000033초, 분석이 0.000230초, 계획이 0.000198초였고, 실제로 행을 읽고 집계한 실행이 0.001686초로 가장 깁니다. 분석이 계획만큼 걸린 것은 새 연결의 첫 쿼리라 카탈로그 캐시를 채우는 시간이 들어갔기 때문입니다. 쿼리가 복잡해지면(조인이 많으면) 계획 시간도 크게 늘어납니다.\n오류가 나는 단계로 보는 파서와 분석기의 차이 postgres=# SELEC * FROM orders; ERROR: syntax error at or near \u0026#34;SELEC\u0026#34; LINE 1: SELEC * FROM orders ^ postgres=# SELECT * FROM order_typo; ERROR: relation \u0026#34;order_typo\u0026#34; does not exist LINE 1: SELECT * FROM order_typo ^ postgres=# SELECT nosuchcol FROM orders; ERROR: column \u0026#34;nosuchcol\u0026#34; does not exist LINE 1: SELECT nosuchcol FROM orders ^ SELEC는 문법에 맞지 않으므로 파서가 syntax error를 냅니다. SELECT * FROM order_typo는 문법은 맞습니다. 파서는 통과하고, 카탈로그에서 이름을 찾는 분석기가 relation ... does not exist를 냅니다. 없는 컬럼도 마찬가지입니다. 내부 트리: Query와 PlannedStmt debug_print_rewritten, debug_print_plan을 켜면 리라이터를 거친 Query 트리와 계획 트리 전체가 출력됩니다. 매우 길어서 트리에 나오는 노드 이름만 처음 나온 순서대로 뽑았습니다.\n$ psql -X -c \u0026#34;SET client_min_messages = log\u0026#34; -c \u0026#34;SET debug_print_rewritten = on\u0026#34; -c \u0026#34;SELECT status, count(*) FROM orders WHERE amount \u0026lt; 100 GROUP BY status\u0026#34; 2\u0026gt;\u0026amp;1 | grep -oE \u0026#34;\\{[A-Z_]+\u0026#34; | awk \u0026#39;!seen[$0]++\u0026#39; | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; echo {QUERY {RANGETBLENTRY {ALIAS {VAR {RTEPERMISSIONINFO {FROMEXPR {RANGETBLREF {OPEXPR {CONST {TARGETENTRY {AGGREF {SORTGROUPCLAUSE $ psql -X -c \u0026#34;SET client_min_messages = log\u0026#34; -c \u0026#34;SET debug_print_plan = on\u0026#34; -c \u0026#34;SELECT status, count(*) FROM orders WHERE amount \u0026lt; 100 GROUP BY status\u0026#34; 2\u0026gt;\u0026amp;1 | grep -oE \u0026#34;\\{[A-Z_]+\u0026#34; | awk \u0026#39;!seen[$0]++\u0026#39; | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; echo {PLANNEDSTMT {AGG {TARGETENTRY {VAR {AGGREF {BITMAPHEAPSCAN {BITMAPINDEXSCAN {OPEXPR {CONST {RANGETBLENTRY {ALIAS {RTEPERMISSIONINFO Query 트리(첫 줄)에는 SQL의 구성 요소가 그대로 들어 있습니다. RANGETBLENTRY는 FROM의 orders(그리고 PG18부터 생긴 GROUP BY용 항목), VAR는 컬럼 참조, OPEXPR과 CONST는 amount \u0026lt; 100, TARGETENTRY와 AGGREF는 SELECT 목록의 count(*), SORTGROUPCLAUSE는 GROUP BY입니다. \u0026ldquo;어떻게\u0026rdquo; 읽을지는 아직 들어 있지 않습니다. 계획 트리(둘째 줄)에는 PLANNEDSTMT 아래에 AGG(집계), BITMAPHEAPSCAN, BITMAPINDEXSCAN(읽는 방법)이 있습니다. 플래너가 \u0026ldquo;amount 인덱스로 비트맵을 만들어 읽고 집계한다\u0026quot;는 방법을 정한 것입니다. EXPLAIN은 이 트리를 사람이 읽기 좋게 보여 주는 명령입니다. 리라이터: 뷰는 원래 테이블로 풀린다 postgres=# CREATE VIEW big_orders AS SELECT id, customer_id, amount FROM orders WHERE amount \u0026gt;= 990; postgres=# EXPLAIN (COSTS OFF) SELECT * FROM big_orders WHERE customer_id = 7; QUERY PLAN --------------------------------------------------------- Bitmap Heap Scan on orders Recheck Cond: ((customer_id = 7) AND (amount \u0026gt;= 990)) -\u0026gt; BitmapAnd -\u0026gt; Bitmap Index Scan on orders_customer_idx Index Cond: (customer_id = 7) -\u0026gt; Bitmap Index Scan on orders_amount_idx Index Cond: (amount \u0026gt;= 990) (7 rows) 뷰 big_orders를 조회했는데 계획에는 뷰가 없고 orders만 나옵니다. 리라이터가 뷰를 그 정의의 서브쿼리로 바꾸고, 플래너가 그 서브쿼리를 바깥 쿼리로 끌어올려, 뷰 안의 조건(amount \u0026gt;= 990)과 바깥 조건(customer_id = 7)이 한 쿼리가 되었습니다. 플래너는 두 조건을 합쳐 두 인덱스의 비트맵을 AND(BitmapAnd)해서 읽는 계획을 세웠습니다.\n실행기는 필요한 만큼만 당겨 온다 EXPLAIN (ANALYZE)는 실제로 실행해서 rows=(추정) 옆에 actual rows=(실제)를 보여 줍니다. PG18에서는 EXPLAIN ANALYZE에 BUFFERS가 기본으로 붙는데(2편), 여기서는 출력을 짧게 하려고 BUFFERS OFF, TIMING OFF를 붙였습니다.\npostgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF, COSTS OFF) SELECT * FROM orders LIMIT 5; QUERY PLAN ----------------------------------------------------- Limit (actual rows=5.00 loops=1) -\u0026gt; Seq Scan on orders (actual rows=5.00 loops=1) Planning Time: 0.128 ms Execution Time: 0.014 ms (4 rows) 테이블은 10만 행이지만 Seq Scan의 actual rows가 5입니다. 실행기는 맨 위 Limit 노드가 아래 Seq Scan에게 행을 하나씩 달라고 하는 구조라, Limit이 5행을 받고 더 요청하지 않자 Seq Scan도 거기서 멈췄습니다(ExecLimit()).\n플래너: 경로를 만들고 비용을 비교한다 조건의 선택도를 컬럼 통계로 추정하고 튜플 수를 곱해 행 수를 구한 뒤, 경로 후보마다 비용을 계산해 가장 싼 것을 고릅니다. 새 창에서 크게 보기 테이블 하나를 읽는 방법만 해도 여러 가지입니다(set_plain_rel_pathlist()).\n스캔 방법 유리할 때 Seq Scan 테이블 전체를 처음부터 순서대로 읽음 많은 행을 가져올 때 Index Scan 인덱스에서 찾은 순서대로 테이블 페이지를 하나씩 읽음 아주 적은 행, 또는 인덱스 순서와 테이블 순서가 비슷할 때 Bitmap Scan 인덱스로 해당 페이지 목록(비트맵)을 먼저 만들고, 페이지 순서대로 읽음 그 중간, 또는 여러 인덱스를 AND/OR로 합칠 때(뷰 예시) Index Only Scan 인덱스만 읽고 테이블은 건너뜀(visibility map이 all-visible인 페이지, 5편) 필요한 컬럼이 모두 인덱스에 있을 때 비용 계산 플래너는 경로마다 비용(cost)을 계산합니다. 비용의 단위는 \u0026ldquo;페이지 하나를 순차로 읽는 비용 = 1\u0026rdquo;(seq_page_cost)이고, 나머지는 이를 기준으로 한 상대값입니다.\n설정 기본값 뜻 seq_page_cost 1 페이지 하나를 순차로 읽기 random_page_cost 4 페이지 하나를 임의 위치에서 읽기 cpu_tuple_cost 0.01 행 하나를 처리하기 cpu_operator_cost 0.0025 연산자(비교 등) 하나를 계산하기 예를 들어 Seq Scan의 비용은 페이지 수 × seq_page_cost + 행 수 × (cpu_tuple_cost + 조건마다 cpu_operator_cost)입니다(정확히는 조건마다 연산자 함수의 비용 계수 × cpu_operator_cost이고, =, \u0026lt; 같은 기본 연산자는 계수가 1, cost_seqscan()). 아래에서 이 식으로 계산한 값이 EXPLAIN의 숫자와 정확히 맞는 것을 확인합니다.\nSeq Scan의 비용을 직접 계산해 보기 postgres=# SHOW seq_page_cost; seq_page_cost --------------- 1 (1 row) postgres=# SHOW random_page_cost; random_page_cost ------------------ 4 (1 row) postgres=# SHOW cpu_tuple_cost; cpu_tuple_cost ---------------- 0.01 (1 row) postgres=# SHOW cpu_operator_cost; cpu_operator_cost ------------------- 0.0025 (1 row) postgres=# EXPLAIN SELECT * FROM orders; QUERY PLAN --------------------------------------------------------------- Seq Scan on orders (cost=0.00..1805.00 rows=100000 width=30) (1 row) postgres=# EXPLAIN SELECT * FROM orders WHERE status = \u0026#39;returned\u0026#39;; QUERY PLAN ------------------------------------------------------------ Seq Scan on orders (cost=0.00..2055.00 rows=123 width=30) Filter: (status = \u0026#39;returned\u0026#39;::text) (2 rows) postgres=# SELECT relpages * 1.0 + reltuples * 0.01 AS seqscan_cost, relpages * 1.0 + reltuples * 0.01 + reltuples * 0.0025 AS with_filter_cost FROM pg_class WHERE relname = \u0026#39;orders\u0026#39;; seqscan_cost | with_filter_cost --------------+------------------ 1805 | 2055 (1 row) EXPLAIN의 cost=0.00..1805.00에서 앞의 숫자는 첫 행을 내기까지의 비용, 뒤의 숫자는 마지막 행까지의 총비용입니다. 계산해 보면,\n조건 없음: 805페이지 × 1(seq_page_cost) + 100000행 × 0.01(cpu_tuple_cost) = 1805 조건 하나: 여기에 100000행 × 0.0025(cpu_operator_cost, status = 'returned' 비교 한 번) = 2055 마지막 쿼리로 계산한 값이 EXPLAIN과 정확히 같습니다. ANALYZE 직후라 pg_class의 값과 지금 테이블 크기가 같기 때문입니다(달라지는 경우는 뒤에서 봅니다). 비용은 이렇게 \u0026ldquo;몇 페이지를 읽고, 몇 행을 처리하는가\u0026quot;를 설정값으로 곱해 더한 것입니다. 조건이 붙어도 Seq Scan은 전체 행을 읽고 비교해야 하므로 총비용이 오히려 늘어납니다. 반면 rows=는 100000에서 123으로 줄었는데, 이것이 행 수 추정입니다.\n조인 순서와 방식 테이블이 둘 이상이면 조인 순서와 조인 방식도 골라야 합니다.\n조인 방법 유리할 때 Nested Loop 바깥 행 하나마다 안쪽을 찾음(보통 인덱스로) 바깥 행이 적을 때 Hash Join 작은 쪽으로 해시 테이블을 만들고 큰 쪽을 훑으며 찾음 큰 테이블끼리, 해시할 쪽이 work_mem에 들어갈 때 Merge Join 양쪽을 조인 키 순서로 정렬해 나란히 훑음 양쪽이 이미 정렬되어 있을 때 Hash Join과 Merge Join은 등호(=) 조인에서만 쓸 수 있고, a.x \u0026lt; b.y 같은 조인은 Nested Loop만 가능합니다.\nNested Loop, Hash Join, Merge Join postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF, COSTS OFF) SELECT c.name, o.amount FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.id = 42; QUERY PLAN ----------------------------------------------------------------------------------- Nested Loop (actual rows=100.00 loops=1) -\u0026gt; Index Scan using customers_pkey on customers c (actual rows=1.00 loops=1) Index Cond: (id = 42) Index Searches: 1 -\u0026gt; Bitmap Heap Scan on orders o (actual rows=100.00 loops=1) Recheck Cond: (customer_id = 42) Heap Blocks: exact=100 -\u0026gt; Bitmap Index Scan on orders_customer_idx (actual rows=100.00 loops=1) Index Cond: (customer_id = 42) Index Searches: 1 Planning Time: 0.210 ms Execution Time: 0.351 ms (12 rows) postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF, COSTS OFF) SELECT c.name, sum(o.amount) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name; QUERY PLAN ------------------------------------------------------------------------- HashAggregate (actual rows=1000.00 loops=1) Group Key: c.name Batches: 1 Memory Usage: 121kB -\u0026gt; Hash Join (actual rows=100000.00 loops=1) Hash Cond: (o.customer_id = c.id) -\u0026gt; Seq Scan on orders o (actual rows=100000.00 loops=1) -\u0026gt; Hash (actual rows=1000.00 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 59kB -\u0026gt; Seq Scan on customers c (actual rows=1000.00 loops=1) Planning Time: 0.252 ms Execution Time: 12.893 ms (11 rows) postgres=# SET enable_hashjoin = off; SET postgres=# SET enable_nestloop = off; SET postgres=# EXPLAIN (COSTS OFF) SELECT c.name, sum(o.amount) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name; QUERY PLAN -------------------------------------------------------------- HashAggregate Group Key: c.name -\u0026gt; Merge Join Merge Cond: (c.id = o.customer_id) -\u0026gt; Index Scan using customers_pkey on customers c -\u0026gt; Index Scan using orders_customer_idx on orders o (6 rows) 고객 한 명(c.id = 42)의 주문: 바깥쪽이 1행이라 Nested Loop입니다. 고객 1행마다(loops=1) 안쪽에서 인덱스로 주문을 찾습니다. 안쪽 조건이 customer_id = 42인 것은, 플래너가 c.id = 42와 o.customer_id = c.id에서 o.customer_id = 42를 스스로 끌어냈기 때문입니다. 모든 고객의 주문 합계: 10만 행 전체를 조인하므로 Hash Join입니다. 작은 customers(1000행)로 해시 테이블을 만들고(Hash, 59kB), orders를 한 번 훑으며 찾습니다. Hash Join과 Nested Loop를 끄면 Merge Join이 나옵니다. 두 인덱스(customers_pkey, orders_customer_idx)를 조인 키 순서로 읽어 나란히 맞춰 갑니다. 플래너가 처음에 이것을 고르지 않은 것은 이 경우 Hash Join보다 비싸다고 계산했기 때문입니다. 운영에서는: enable_* 설정은 진단용이다 위 실습에서 쓴 enable_hashjoin = off 같은 설정은 \u0026ldquo;그 방법을 절대 쓰지 말라\u0026quot;가 아닙니다. PG18의 플래너는 경로마다 꺼진 방법을 쓴 노드 수(disabled_nodes)를 세어, 그 수가 적은 계획을 먼저 고르고 그다음에 비용을 비교합니다(costsize.c). 다른 방법이 없으면 여전히 쓰이고, 이때 EXPLAIN에 Disabled: true가 표시됩니다. 다른 계획이 실제로 더 빠른지 확인하는 진단 도구로는 좋지만, 운영 설정으로 전역에 두면 데이터가 바뀌었을 때 더 나은 계획을 막습니다. 근본 원인인 추정 오류를 고치는 편이 낫습니다.\n통계 정보: 행 수 추정의 근거 비용 계산에서 가장 중요한 입력은 \u0026ldquo;이 조건을 만족하는 행이 몇 개인가\u0026quot;입니다. 1개라면 인덱스가, 절반이라면 Seq Scan이 낫습니다. 플래너는 이것을 두 가지 통계로 추정합니다.\n테이블 통계 (pg_class): 페이지 수 relpages, 행 수 reltuples. 계획할 때는 지금 실제 페이지 수를 보고, reltuples / relpages(페이지당 행 밀도)에 그 페이지 수를 곱해 행 수를 추정합니다(tableam.c). 컬럼 통계 (pg_statistic, 읽기 쉬운 뷰는 pg_stats): 컬럼마다 NULL 비율(null_frac), 서로 다른 값의 수(n_distinct), 가장 흔한 값과 그 비율(MCV, most_common_vals, most_common_freqs), 나머지 값의 분포를 같은 개수씩 나눈 히스토그램(histogram_bounds), 물리적 순서와 값 순서의 상관관계(correlation)가 들어 있습니다. 통계는 ANALYZE가 만듭니다. 테이블 전체가 아니라 표본을 읽습니다. 표본 크기는 300 × default_statistics_target(기본 100), 즉 30000행입니다(analyze.c).\npg_stats로 보는 통계 정보 postgres=# SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, correlation FROM pg_stats WHERE tablename = \u0026#39;orders\u0026#39; AND attname = \u0026#39;status\u0026#39; \\gx -[ RECORD 1 ]-----+------------------------------------------------- attname | status null_frac | 0 n_distinct | 4 most_common_vals | {delivered,shipped,cancelled,returned} most_common_freqs | {0.79906666,0.15003334,0.049666665,0.0012333334} correlation | 0.66688544 postgres=# SELECT attname, n_distinct, array_length(most_common_vals::text::int[], 1) AS mcv_count, (SELECT sum(f) FROM unnest(most_common_freqs) f) AS mcv_total_freq, array_length(histogram_bounds, 1) AS histogram_len, (histogram_bounds::text::int[])[1:6] AS histogram_head, correlation FROM pg_stats WHERE tablename = \u0026#39;orders\u0026#39; AND attname = \u0026#39;amount\u0026#39; \\gx -[ RECORD 1 ]--+------------------ attname | amount n_distinct | 1000 mcv_count | 19 mcv_total_freq | 0.02666667 histogram_len | 101 histogram_head | {0,9,19,29,39,48} correlation | -0.0008968399 postgres=# SELECT relname, relpages, reltuples FROM pg_class WHERE relname IN (\u0026#39;orders\u0026#39;, \u0026#39;customers\u0026#39;) ORDER BY relname; relname | relpages | reltuples -----------+----------+----------- customers | 7 | 1000 orders | 805 | 100000 (2 rows) status: 서로 다른 값이 4개(n_distinct)라 네 값이 모두 MCV에 들어 있고, 비율이 0.79906666, 0.15003334, 0.049666665, 0.0012333334입니다. 실제 비율(0.8, 0.15, 0.049, 0.001)과 조금씩 다른 것은 3만 행 표본으로 셌기 때문입니다. 비율은 표본에서 센 개수를 표본 크기로 나눈 값이라, returned의 0.0012333334는 표본 30000행 중 37행이었다는 뜻입니다. 표본은 매번 무작위로 뽑으므로 ANALYZE를 다시 하면 이 값들도 조금씩 달라집니다. amount: 서로 다른 값이 1000개이고 실제로는 모두 같은 비율(0.1%)이지만, 표본에서 우연히 조금 더 많이 나온 값 19개가 MCV로 남았습니다(mcv_count, 합계 비율 0.02666667, 값 하나에 평균 약 0.0014). ANALYZE는 표본 빈도가 평균보다 뚜렷하게 높은 값만 MCV로 남깁니다. 나머지 값의 분포는 히스토그램 경계값 101개(histogram_len)로 나타내고, 경계값 사이 100개 구간에는 MCV를 뺀 나머지 행이 같은 비율(1%)씩 들어갑니다. correlation이 0에 가까운 것은 amount 값의 순서와 행이 저장된 물리적 순서가 무관하다는 뜻입니다. 테이블 통계는 orders가 805페이지, 100000행입니다. 선택도와 행 수 추정 WHERE status = 'shipped'처럼 같다 조건이면 MCV에서 그 값의 비율을 찾고(var_eq_const()), WHERE amount \u0026lt; 30처럼 범위 조건이면 히스토그램의 몇 번째 구간까지인지로 비율을 구합니다(ineq_histogram_selectivity()). 이 비율이 선택도(selectivity)이고, 행 수 추정은 선택도 × 행 수입니다.\n추정과 실제: MCV와 히스토그램 postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE status = \u0026#39;shipped\u0026#39;; QUERY PLAN --------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2055.00 rows=15003 width=30) (actual rows=15000.00 loops=1) Filter: (status = \u0026#39;shipped\u0026#39;::text) Rows Removed by Filter: 85000 Planning Time: 0.149 ms Execution Time: 2.904 ms (5 rows) postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE status = \u0026#39;returned\u0026#39;; QUERY PLAN ----------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2055.00 rows=123 width=30) (actual rows=100.00 loops=1) Filter: (status = \u0026#39;returned\u0026#39;::text) Rows Removed by Filter: 99900 Planning Time: 0.125 ms Execution Time: 2.643 ms (5 rows) postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE amount \u0026lt; 30; QUERY PLAN ------------------------------------------------------------------------------------------------------------------ Bitmap Heap Scan on orders (cost=34.91..876.38 rows=2918 width=30) (actual rows=3000.00 loops=1) Recheck Cond: (amount \u0026lt; 30) Heap Blocks: exact=805 -\u0026gt; Bitmap Index Scan on orders_amount_idx (cost=0.00..34.18 rows=2918 width=0) (actual rows=3000.00 loops=1) Index Cond: (amount \u0026lt; 30) Index Searches: 1 Planning Time: 0.140 ms Execution Time: 0.709 ms (8 rows) 조건 추정 근거 추정 실제 status = 'shipped' MCV 비율 0.15003334 × 100000 15003 15000 status = 'returned' MCV 비율 0.0012333334 × 100000 123 100 amount \u0026lt; 30 히스토그램에서 30이 들어가는 위치(약 3.1%)에 MCV를 뺀 나머지 비율(1 − 0.02666667)을 곱함 2918 3000 추정은 통계 값을 그대로 곱한 것입니다. amount \u0026lt; 30을 풀어 보면, 30은 히스토그램의 넷째 구간(29~39)의 10% 지점이라 (3 + 0.1) / 100 = 0.031이고, \u0026lt;라서 30과 같은 값의 몫(MCV가 아닌 값 하나의 비율, 1 / (1000 − 19))을 빼면 0.02998, 여기에 히스토그램이 대표하는 비율(1 − 0.02666667)을 곱하면 0.02918, 즉 2918행입니다. 30 미만인 MCV 값이 있었다면 그 비율이 더해졌을 텐데, 계산이 EXPLAIN과 맞으므로 이번 표본에서는 없었던 셈입니다(scalarineqsel()). 표본 오차 때문에 조금씩 어긋나지만(returned는 100행을 123행으로 봤습니다), 이 정도 차이는 계획을 바꾸지 않습니다. 문제가 되는 것은 통계가 오래되었거나 컬럼끼리 관련이 있어서 몇 배, 몇십 배씩 틀릴 때입니다.\namount \u0026lt; 30은 Bitmap Heap Scan인데, Heap Blocks: exact=805로 테이블의 모든 페이지를 읽었습니다. amount가 물리적 순서와 무관하게 흩어져 있어(correlation 약 0) 3000행이 모든 페이지에 퍼져 있기 때문입니다. 모든 페이지를 읽을 것으로 보면 페이지 비용은 Seq Scan과 같은 seq_page_cost로 계산되고, 조건을 검사할 행이 3000개뿐이라 CPU 비용이 적습니다. 그래서 Seq Scan(2055)보다 싸다(876.38)고 계산했습니다.\n선택도에 따라 스캔 방식이 바뀐다 postgres=# EXPLAIN SELECT * FROM orders WHERE id = 42; QUERY PLAN --------------------------------------------------------------------------- Index Scan using orders_pkey on orders (cost=0.29..8.31 rows=1 width=30) Index Cond: (id = 42) (2 rows) postgres=# EXPLAIN SELECT * FROM orders WHERE amount \u0026lt; 30; QUERY PLAN ------------------------------------------------------------------------------------ Bitmap Heap Scan on orders (cost=34.91..876.38 rows=2918 width=30) Recheck Cond: (amount \u0026lt; 30) -\u0026gt; Bitmap Index Scan on orders_amount_idx (cost=0.00..34.18 rows=2918 width=0) Index Cond: (amount \u0026lt; 30) (4 rows) postgres=# EXPLAIN SELECT * FROM orders WHERE amount \u0026lt; 800; QUERY PLAN -------------------------------------------------------------- Seq Scan on orders (cost=0.00..2055.00 rows=80297 width=30) Filter: (amount \u0026lt; 800) (2 rows) id = 42: 1행으로 추정되어 Index Scan입니다. 인덱스에서 찾아 테이블 페이지 하나만 읽습니다(비용 8.31). amount \u0026lt; 30: 약 3000행이라 Bitmap Scan입니다. amount \u0026lt; 800: 약 8만 행이라 Seq Scan입니다. 테이블의 80%를 가져올 때는 인덱스를 거치는 것이 더 비쌉니다. 같은 테이블, 같은 인덱스라도 추정 행 수에 따라 계획이 달라집니다. 그래서 추정이 틀리면 계획도 틀립니다.\n운영에서는: 인덱스가 있는데 사용되지 않는다 위 실습처럼 조건이 테이블의 상당 부분을 가져오면 Seq Scan이 실제로 더 빠르고, 플래너도 그쪽을 고릅니다. 인덱스를 쓰지 않는 것이 이상하다면, 먼저 추정 행 수가 맞는지 봅니다. 추정이 맞는데도 Seq Scan이라면 대부분 올바른 선택입니다. SSD처럼 임의 읽기가 빠른 저장 장치에서는 random_page_cost를 기본값 4보다 낮추는(예: 1.1) 경우가 많습니다. 이 밖에 컬럼에 함수를 씌우거나(WHERE lower(email) = ...) 타입이 맞지 않으면 인덱스를 쓸 수 없는데, 이때는 표현식 인덱스를 만들거나 타입을 맞춥니다.\n통계가 오래되면 autovacuum이 변경량에 따라 자동으로 ANALYZE를 돌리지만(5편), 그 사이에 데이터 분포가 크게 바뀌면 통계는 옛 모습 그대로입니다.\n분포가 바뀐 뒤의 추정 autovacuum이 끼어들지 않게 이 테이블만 끄고, 주문 1만 건의 상태를 returned로 바꾼 뒤 바로 조회합니다.\npostgres=# ALTER TABLE orders SET (autovacuum_enabled = off); postgres=# UPDATE orders SET status = \u0026#39;returned\u0026#39; WHERE id % 10 = 0; postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE status = \u0026#39;returned\u0026#39;; QUERY PLAN ------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2266.89 rows=136 width=30) (actual rows=10100.00 loops=1) Filter: (status = \u0026#39;returned\u0026#39;::text) Rows Removed by Filter: 89900 Planning Time: 0.145 ms Execution Time: 3.653 ms (5 rows) postgres=# ANALYZE orders; postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE status = \u0026#39;returned\u0026#39;; QUERY PLAN --------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2138.00 rows=10140 width=29) (actual rows=10100.00 loops=1) Filter: (status = \u0026#39;returned\u0026#39;::text) Rows Removed by Filter: 89900 Planning Time: 0.127 ms Execution Time: 2.658 ms (5 rows) ANALYZE 전: 추정 136행, 실제 10100행으로 약 74배 차이입니다. 통계의 MCV는 여전히 \u0026ldquo;returned는 약 0.12%(0.0012333334)\u0026ldquo;라고 말하고 있기 때문입니다. 플래너는 UPDATE로 늘어난 페이지 수에 예전 밀도(페이지당 행 수)를 곱해 행 수를 약 11만으로 봤습니다(0.0012333334 × 약 110300 ≈ 136, 비용도 2055에서 2266.89로 늘었습니다). 실제로 살아 있는 행은 10만 그대로이고, 늘어난 페이지는 UPDATE가 남긴 옛 버전(4편) 때문입니다. 어느 쪽이든 값의 분포는 ANALYZE 때 그대로입니다. ANALYZE 뒤: 추정 10140행으로 실제와 거의 같아졌습니다. 이 쿼리는 어차피 Seq Scan이라 계획이 바뀌지 않았지만, 조인 안에서 이런 오차가 생기면 \u0026ldquo;136행이니 Nested Loop로 136번만 돌면 된다\u0026quot;는 계획이 실제로는 1만 번을 돌게 됩니다.\n조건이 여러 개일 때 조건이 여러 개면 기본적으로 서로 독립이라고 가정하고 곱합니다(clauselist_selectivity()). 이 가정이 틀리는 경우를 위해 확장 통계(CREATE STATISTICS)가 있습니다.\n서로 관련된 컬럼: 확장 통계 postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE city = \u0026#39;Seoul\u0026#39; AND country = \u0026#39;KR\u0026#39;; QUERY PLAN -------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2388.00 rows=2032 width=29) (actual rows=10000.00 loops=1) Filter: ((city = \u0026#39;Seoul\u0026#39;::text) AND (country = \u0026#39;KR\u0026#39;::text)) Rows Removed by Filter: 90000 Planning Time: 0.141 ms Execution Time: 3.725 ms (5 rows) postgres=# CREATE STATISTICS orders_city_country (dependencies) ON city, country FROM orders; postgres=# ANALYZE orders; postgres=# SELECT statistics_name, dependencies FROM pg_stats_ext WHERE statistics_name = \u0026#39;orders_city_country\u0026#39;; statistics_name | dependencies ---------------------+---------------------- orders_city_country | {\u0026#34;5 =\u0026gt; 6\u0026#34;: 1.000000} (1 row) postgres=# EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF) SELECT * FROM orders WHERE city = \u0026#39;Seoul\u0026#39; AND country = \u0026#39;KR\u0026#39;; QUERY PLAN --------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..2388.00 rows=10090 width=29) (actual rows=10000.00 loops=1) Filter: ((city = \u0026#39;Seoul\u0026#39;::text) AND (country = \u0026#39;KR\u0026#39;::text)) Rows Removed by Filter: 90000 Planning Time: 0.105 ms Execution Time: 3.537 ms (5 rows) 확장 통계 전: 추정 2032행, 실제 10000행입니다. 플래너는 city = 'Seoul'(10%)과 country = 'KR'(20%)이 독립이라고 보고 10% × 20% = 2%를 곱했습니다. 하지만 Seoul이면 반드시 KR이므로 실제는 10%입니다. CREATE STATISTICS ... (dependencies)와 ANALYZE 뒤, pg_stats_ext에 \u0026quot;5 =\u0026gt; 6\u0026quot;: 1.000000이 생겼습니다. 5번 컬럼(city)이 정해지면 6번 컬럼(country)이 100% 정해진다는 뜻입니다(dependencies_clauselist_selectivity()). 추정이 10090행으로 실제와 거의 같아졌습니다. 확장 통계는 자동으로 만들어지지 않습니다. 주소(시, 구), 상품(카테고리, 하위 카테고리)처럼 함께 조건에 쓰이면서 서로 관련된 컬럼이 있다면 직접 만들어 줘야 합니다. dependencies 말고도 컬럼 조합의 서로 다른 값 수(ndistinct), 조합별 MCV(mcv)를 만들 수 있습니다.\n운영에서는: 추정 행 수와 실제 행 수가 크게 다를 때 느린 쿼리를 볼 때는 먼저 EXPLAIN (ANALYZE)에서 노드마다 rows=(추정)와 actual rows=(실제)를 비교합니다. 몇 배 이상 차이 나는 노드가 있다면, 그 위의 계획은 잘못된 전제 위에 세워졌을 가능성이 큽니다. 예를 들어 앞의 예처럼 136행으로 추정한 곳이 실제로 1만 행이면, 플래너는 1만 번 반복될 Nested Loop를 싸다고 판단할 수 있습니다. 원인은 대개 셋 중 하나입니다.\n통계가 오래됨: 대량 적재나 일괄 UPDATE 직후. ANALYZE로 해결됩니다. 배치 작업 끝에 ANALYZE를 넣어 두는 것이 좋습니다. 표본이 작음: 값이 아주 많거나 분포가 복잡한 컬럼. ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000으로 그 컬럼의 MCV와 히스토그램 크기를 늘릴 수 있습니다. 표본 크기는 테이블 단위로 가장 큰 값을 따르므로, 그 테이블의 ANALYZE 표본도 300 × 1000 = 30만 행으로 커집니다. 컬럼 사이의 관계: 위 실습처럼 독립 가정이 틀린 경우. CREATE STATISTICS로 해결합니다. 정리 SQL은 파서(문법) → 분석기(이름을 실제 객체로) → 리라이터(뷰 펼치기) → 플래너(가장 싼 계획) → 실행기(행을 한 개씩 당기기)를 거칩니다. 플래너는 스캔 방법, 조인 순서와 방식의 후보마다 비용을 계산해 가장 싼 것을 고릅니다. 비용은 페이지 읽기와 행 처리 횟수로 계산하는 상대값입니다. 비용 계산의 핵심 입력은 추정 행 수이고, pg_class의 테이블 통계와 pg_stats의 컬럼 통계(MCV, 히스토그램)로 추정합니다. 통계는 ANALYZE가 표본으로 만듭니다. 조건이 여러 개면 독립이라고 가정하고 선택도를 곱합니다. 컬럼 사이에 관계가 있으면 확장 통계로 알려 줘야 합니다. 느린 쿼리를 보면 EXPLAIN (ANALYZE)에서 추정과 실제 행 수의 차이부터 찾습니다. 이것으로 PostgreSQL 인터널 시리즈를 마칩니다. 프로세스와 메모리에서 시작해, 데이터가 페이지에 저장되고(3편), 여러 버전으로 동시에 읽히고(4편), 정리되고(5편, 6편), WAL로 지켜지고(7편, 8편), 다른 서버로 복제되고(9편), 마지막으로 쿼리로 꺼내지는(10편) 과정을 소스 코드와 실제 실행 결과로 따라가 봤습니다.\n참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/tcop/postgres.c: exec_simple_query() src/backend/parser/: 파서, 분석기 src/backend/rewrite/rewriteHandler.c: 리라이터 src/backend/optimizer/README: 플래너 설계 설명 src/backend/optimizer/path/costsize.c: 비용 계산 src/backend/utils/adt/selfuncs.c: 선택도 추정 src/backend/commands/analyze.c: ANALYZE PostgreSQL 18 공식 문서\nThe Path of a Query Using EXPLAIN Statistics Used by the Planner, Row Estimation Examples Planner Cost Constants pg_stats ","permalink":"https://jinlog.dev/posts/postgresql/10-query-processing/","summary":"SQL 한 줄이 결과가 되기까지: 파서, 플래너, 실행기와 통계 정보","title":"PostgreSQL 인터널 10: 쿼리 처리 과정"},{"content":"개요 어느 순간 모든 애플리케이션의 DB 연결이 한꺼번에 끊기고, 몇 초 뒤 다시 접속됩니다. 서버 로그에는 terminated by signal 9: Killed가 남아 있습니다. 메모리가 모자라 운영체제가 PostgreSQL 프로세스 하나를 강제로 죽였고, 그 때문에 PostgreSQL이 모든 세션을 끊고 장애 복구를 한 것입니다. 원인은 대개 work_mem을 크게 잡은 쿼리 몇 개가 동시에 돈 것입니다.\n이 글에서 답할 질문은 다음과 같습니다.\nwork_mem은 정확히 무엇의 한도이고, 한 쿼리가 실제로 얼마나 쓸 수 있는가 실행 중인 세션이 메모리를 얼마나 쓰는지 어떻게 보는가 backend 하나가 OOM으로 죽으면 왜 모든 세션이 끊기는가 work_mem을 어떻게 잡아야 하는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습 환경과 실제 서버의 차이: 이 실습은 메모리를 768 MB로 제한한 컨테이너에서 돌았습니다. 메모리가 모자라면 서버 전체의 OOM killer가 아니라 컨테이너(cgroup)의 메모리 제한이 프로세스를 죽입니다. 누가 죽는지 고르는 방식과 로그는 다르지만, PostgreSQL 쪽에서 일어나는 일(backend가 SIGKILL로 죽고 전체가 재시작되는 것)은 같습니다.\n$ cat /sys/fs/cgroup/memory.max 805306368 실습 테이블은 300만 행, 서로 다른 값 300만 개인 k 컬럼으로 GROUP BY합니다. 결과를 일정하게 보려고 병렬 쿼리는 껐습니다.\npostgres=# SELECT name, setting, unit FROM pg_settings postgres-# WHERE name IN (\u0026#39;shared_buffers\u0026#39;, \u0026#39;work_mem\u0026#39;, \u0026#39;hash_mem_multiplier\u0026#39;, \u0026#39;maintenance_work_mem\u0026#39;, \u0026#39;max_connections\u0026#39;) postgres-# ORDER BY name; name | setting | unit ----------------------+---------+------ hash_mem_multiplier | 2 | maintenance_work_mem | 65536 | kB max_connections | 100 | shared_buffers | 16384 | 8kB work_mem | 4096 | kB (5 rows) postgres=# CREATE TABLE big AS SELECT g AS id, md5(g::text) AS k FROM generate_series(1, 3000000) g; SELECT 3000000 postgres=# VACUUM ANALYZE big; VACUUM postgres=# SELECT pg_size_pretty(pg_table_size(\u0026#39;big\u0026#39;)) AS big; big -------- 196 MB (1 row) 먼저 확인할 것 work_mem은 연산 하나의 한도다 work_mem은 쿼리 하나가 아니라 정렬이나 해시 연산 하나가 디스크로 넘치기 전에 쓸 수 있는 메모리입니다. 기본값 4 MB로 GROUP BY를 돌립니다.\npostgres=# SET work_mem = \u0026#39;4MB\u0026#39;; postgres-# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s; SET QUERY PLAN ---------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=2304 read=22720, temp read=18844 written=42391 -\u0026gt; HashAggregate (actual rows=3000000.00 loops=1) Group Key: big.k Batches: 65 Memory Usage: 8217kB Disk Usage: 191832kB Buffers: shared hit=2304 read=22720, temp read=18844 written=42391 -\u0026gt; Seq Scan on big (actual rows=3000000.00 loops=1) Buffers: shared hit=2304 read=22720 Planning: Buffers: shared hit=105 read=4 Planning Time: 0.195 ms Execution Time: 693.372 ms (12 rows) Batches: 65, Disk Usage: 191832kB는 메모리에 다 들어가지 않아 65번에 나눠 디스크를 쓰며 처리했다는 뜻입니다. 그런데 Memory Usage가 8217kB, 4 MB의 두 배입니다. 해시 연산은 work_mem × hash_mem_multiplier(기본 2)까지 쓸 수 있기 때문입니다(hash_mem_multiplier). work_mem을 1 GB로 올립니다.\npostgres=# SET work_mem = \u0026#39;1GB\u0026#39;; postgres-# EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s; SET QUERY PLAN -------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit=2398 read=22626 -\u0026gt; HashAggregate (actual rows=3000000.00 loops=1) Group Key: big.k Batches: 1 Memory Usage: 237593kB Buffers: shared hit=2398 read=22626 -\u0026gt; Seq Scan on big (actual rows=3000000.00 loops=1) Buffers: shared hit=2398 read=22626 Planning: Buffers: shared hit=109 Planning Time: 0.169 ms Execution Time: 656.771 ms (12 rows) 이번에는 Batches: 1, 232 MB(237593 kB)를 메모리에서 썼습니다. 한도를 올리면 필요한 만큼 씁니다. 이 실습에서는 임시 파일이 운영체제 캐시에 남아 있어서 두 경우의 실행 시간 차이가 거의 없었습니다. 실제 디스크를 거쳐야 한다면 디스크로 넘치는 쪽이 느려집니다.\n쿼리 하나가 쓸 수 있는 메모리는 이렇게 곱해집니다.\n쿼리 안의 정렬·해시 노드 수만큼(조인과 집계가 여럿이면 각각) 해시 노드는 hash_mem_multiplier배 병렬 쿼리면 worker 수만큼(각 worker가 따로 한도를 가짐) 그리고 이런 쿼리를 동시에 실행하는 세션 수만큼 실행 중인 세션의 메모리 보기 work_mem을 1 GB로 둔 세션이 같은 GROUP BY를 실행하는 동안 봅니다. 해시 테이블을 다 만든 뒤 출력 단계만 몇 초 걸리도록 조건을 붙였습니다.\nA=# SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k HAVING md5(md5(md5(md5(md5(md5(k)))))) IS NOT NULL) s; OS에서는 프로세스의 RSS를, 컨테이너 전체로는 cgroup의 사용량을 봅니다.\n$ ps -o pid,rss,cmd -p $(psql -XAtc \u0026#34;SELECT pid FROM pg_stat_activity WHERE application_name = \u0026#39;report\u0026#39;\u0026#34;) $ cat /sys/fs/cgroup/memory.current PID RSS CMD 132 134908 postgres: postgres postgres [local] SELECT 699432960 RSS 132 MB(134908 kB)에는 이 backend가 건드린 shared buffers도 들어 있어서 정확한 개인 메모리 사용량이 아닙니다(인터널 1편). cgroup 사용량(약 667 MB)에는 운영체제 캐시도 들어 있습니다. PostgreSQL 안에서 보려면 pg_log_backend_memory_contexts()로 그 backend의 메모리 컨텍스트를 서버 로그에 남기게 합니다(PostgreSQL 14부터).\npostgres=# SELECT pg_log_backend_memory_contexts(pid) FROM pg_stat_activity WHERE application_name = \u0026#39;report\u0026#39;; pg_log_backend_memory_contexts -------------------------------- t (1 row) # 세션 A: 앞 명령의 결과를 기다림 count --------- 3000000 (1 row) Time: 6000.451 ms (00:06.000) $ tail -n 1000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;Grand total|level: 1; ExecutorState|level: [0-9]+; HashAgg\u0026#39; | tail -n 4 2026-09-26 10:52:35.436 UTC [132] postgres@postgres/report LOG: level: 5; HashAgg table context: 58720144 total in 17 blocks; 7620496 free; 51099648 used 2026-09-26 10:52:35.436 UTC [132] postgres@postgres/report LOG: level: 5; HashAgg meta context: 67117104 total in 2 blocks; 4456 free (0 chunks); 67112648 used 2026-09-26 10:52:35.436 UTC [132] postgres@postgres/report LOG: Grand total: 127374888 bytes in 276 blocks; 8061392 free (305 chunks); 119313496 used 해시 집계가 쓰는 컨텍스트(HashAgg table context, HashAgg meta context)와 backend 전체의 합계(Grand total, 사용량 119313496바이트))가 남았습니다. 자기 세션이라면 pg_backend_memory_contexts 뷰로 바로 조회할 수 있습니다. 어느 세션이 메모리를 많이 쓰는지 의심될 때 이렇게 확인합니다.\n원인: 동시에 돌면 곱해진다 work_mem 1 GB로 같은 쿼리를 네 세션에서 동시에 돌립니다. 하나가 230 MB 안팎을 쓰므로 넷이면 컨테이너 한도(768 MB)를 넘습니다.\n$ cat /sys/fs/cgroup/memory.events low 0 high 0 max 15 oom 0 oom_kill 0 oom_group_kill 0 sock_throttled 0 [exit=0] postgres=# SELECT pg_postmaster_start_time(); pg_postmaster_start_time ------------------------------ 2026-09-26 10:52:22.35438+00 (1 row) $ for i in 1 2 3 4; do PGAPPNAME=report$i PGOPTIONS=\u0026#39;-c work_mem=1GB\u0026#39; nohup psql -X -c \u0026#34;SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s;\u0026#34; \u0026gt; /tmp/heavy_$i.out 2\u0026gt;\u0026amp;1 \u0026amp; done $ sleep 25 $ cat /tmp/heavy_1.out /tmp/heavy_2.out /tmp/heavy_3.out /tmp/heavy_4.out WARNING: terminating connection because of crash of another server process DETAIL: The postmaster has commanded this server process to roll back the current transaction and exit, because another server process exited abnormally and possibly corrupted shared memory. HINT: In a moment you should be able to reconnect to the database and repeat your command. server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost [exit=0] 네 세션 모두 연결이 끊겼습니다. 일부는 terminating connection because of crash of another server process라는 경고를 받았습니다. 자기 잘못이 아니라 다른 프로세스가 죽어서 끊긴다는 뜻입니다. cgroup은 OOM으로 프로세스를 죽인 횟수를 셉니다.\n$ cat /sys/fs/cgroup/memory.events low 0 high 0 max 1505 oom 13 oom_kill 3 oom_group_kill 0 sock_throttled 0 [exit=0] 서버 로그를 보면 순서가 분명합니다.\n$ tail -n 1000 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;terminated by signal|Failed process|terminating any other|all server processes terminated|not properly shut down|redo done|ready to accept\u0026#39; | tail -n 8 2026-09-26 10:52:22.358 UTC [26] LOG: database system is ready to accept connections 2026-09-26 10:52:44.770 UTC [26] LOG: client backend (PID 299) was terminated by signal 9: Killed 2026-09-26 10:52:44.770 UTC [26] DETAIL: Failed process was running: SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s; 2026-09-26 10:52:44.770 UTC [26] LOG: terminating any other active server processes 2026-09-26 10:52:44.777 UTC [26] LOG: all server processes terminated; reinitializing 2026-09-26 10:52:44.818 UTC [305] LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 10:52:45.628 UTC [305] LOG: redo done at 0/12C9CD40 system usage: CPU: user: 0.59 s, system: 0.20 s, elapsed: 0.80 s 2026-09-26 10:52:45.770 UTC [26] LOG: database system is ready to accept connections backend 하나(PID 299)가 signal 9: Killed로 죽었습니다. DETAIL에 그 backend가 실행하던 쿼리가 남습니다. postmaster는 다른 모든 프로세스를 끝냅니다. backend가 SIGKILL로 죽으면 공유 메모리를 어떤 상태로 남겼는지 알 수 없으므로, 공유 메모리를 쓰는 모든 프로세스를 믿을 수 없다고 보기 때문입니다(인터널 1편). 공유 메모리를 다시 만들고 장애 복구(WAL 재생)를 한 뒤 1초 안에 다시 접속을 받습니다(인터널 8편). postmaster 자체는 죽지 않았으므로 시작 시각은 그대로입니다.\npostgres=# SELECT pg_postmaster_start_time(), now(); pg_postmaster_start_time | now ------------------------------+------------------------------ 2026-09-26 10:52:22.35438+00 | 2026-09-26 10:53:09.60796+00 (1 row) 모니터링에서 \u0026ldquo;서버가 재시작되지 않았다\u0026quot;고 보이더라도, 모든 세션이 끊기고 진행 중이던 트랜잭션이 모두 롤백된 사건입니다. 로그의 terminated by signal, all server processes terminated; reinitializing을 알람으로 걸어 두어야 알 수 있습니다. 또 장애 복구를 하면 pg_stat_* 누적 통계가 초기화되므로, 이전 추세와 비교할 때 이 시점을 표시해 둬야 합니다.\n조치 work_mem을 동시 실행 수에 맞춰 잡는다 같은 네 쿼리를 work_mem 16 MB로 돌립니다.\n$ for i in 1 2 3 4; do PGAPPNAME=report$i PGOPTIONS=\u0026#39;-c work_mem=16MB\u0026#39; nohup psql -X -c \u0026#34;\\\\timing on\u0026#34; -c \u0026#34;SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s;\u0026#34; \u0026gt; /tmp/small_$i.out 2\u0026gt;\u0026amp;1 \u0026amp; done $ sleep 25 $ cat /tmp/small_1.out /tmp/small_2.out /tmp/small_3.out /tmp/small_4.out $ cat /sys/fs/cgroup/memory.events Timing is on. count --------- 3000000 (1 row) Time: 1215.095 ms (00:01.215) Timing is on. count --------- 3000000 (1 row) Time: 1256.357 ms (00:01.256) Timing is on. count --------- 3000000 (1 row) Time: 1253.183 ms (00:01.253) Timing is on. count --------- 3000000 (1 row) Time: 1232.279 ms (00:01.232) low 0 high 0 max 4855 oom 20 oom_kill 5 oom_group_kill 0 sock_throttled 0 [exit=0] postgres=# SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_bytes FROM pg_stat_database WHERE datname = \u0026#39;postgres\u0026#39;; datname | temp_files | temp_bytes ----------+------------+------------ postgres | 4 | 569 MB (1 row) 네 쿼리가 모두 1.2초 안팎에 끝났고, 대신 임시 파일 569 MB를 썼습니다. oom_kill은 늘지 않았습니다. 1단계의 EXPLAIN도 임시 파일을 썼는데 여기에는 4개만 남은 것은, 앞의 장애 복구로 누적 통계가 초기화되었기 때문입니다. 느려지는 쿼리 몇 개와, 모든 세션이 끊기는 장애를 맞바꾼 것입니다.\n16 MB 전에 64 MB로도 돌려 봤는데, 그때도 OOM이 났습니다.\n$ for i in 1 2 3 4; do PGAPPNAME=report$i PGOPTIONS=\u0026#39;-c work_mem=64MB\u0026#39; nohup psql -X -c \u0026#34;SELECT count(*) FROM (SELECT k, count(*) FROM big GROUP BY k) s;\u0026#34; \u0026gt; /tmp/mid_$i.out 2\u0026gt;\u0026amp;1 \u0026amp; done $ sleep 25 $ grep -h -E \u0026#39;count|server closed|crash of another\u0026#39; /tmp/mid_1.out /tmp/mid_2.out /tmp/mid_3.out /tmp/mid_4.out $ grep oom_kill /sys/fs/cgroup/memory.events WARNING: terminating connection because of crash of another server process server closed the connection unexpectedly WARNING: terminating connection because of crash of another server process server closed the connection unexpectedly server closed the connection unexpectedly server closed the connection unexpectedly oom_kill 5 [exit=0] oom_kill이 3에서 5로 늘었습니다. 해시 노드 한도가 64 MB × 2 = 128 MB이고, 넷이면 512 MB에 shared buffers 128 MB와 운영체제 몫이 더해져 768 MB에 닿기 때문입니다. hash_mem_multiplier를 빼고 계산하면 틀립니다.\nwork_mem을 정하는 방법은 이렇습니다.\n서버 메모리에서 shared buffers와 운영체제 몫(캐시 포함)을 뺀 나머지가 쿼리들이 쓸 수 있는 메모리입니다. 그것을 동시에 무거운 쿼리를 돌릴 수 있는 세션 수와 쿼리당 정렬·해시 노드 수로 나눕니다. max_connections 전부가 동시에 큰 쿼리를 돌리지는 않지만, 최악을 가정할수록 안전합니다. 전역 값은 작게 두고, 큰 메모리가 필요한 작업(배치, 보고서)은 그 계정이나 세션에서만 올립니다. ALTER ROLE report SET work_mem = \u0026#39;256MB\u0026#39;; -- 또는 작업 안에서만 SET LOCAL work_mem = \u0026#39;256MB\u0026#39;; maintenance_work_mem(VACUUM, CREATE INDEX)과 autovacuum worker 수도 같은 계산에 넣습니다.\nOOM killer가 postmaster를 고르지 않게 실제 서버에서 서버 전체의 OOM killer가 개입하면, 가장 메모리를 많이 쓰는 프로세스를 고릅니다. 운이 나쁘면 postmaster가 죽고, 그러면 재시작이 아니라 서버가 내려갑니다. PostgreSQL 문서는 두 가지를 권합니다(Linux Memory Overcommit).\nvm.overcommit_memory = 2: 커널이 메모리를 약속 이상으로 내주지 않게 해서, 메모리가 모자라면 OOM killer 대신 할당 요청이 실패하게 합니다. 이때 PostgreSQL은 그 쿼리만 out of memory 에러로 끝냅니다. 이 실습은 컨테이너라 커널 설정을 바꿀 수 없어 재현하지 못했습니다. postmaster의 OOM 점수 조정: postmaster의 oom_score_adj를 -1000으로 두어 OOM killer의 대상에서 빼고, backend는 기본값(0)으로 되돌립니다. PGDG 패키지의 systemd 서비스 파일이 이 설정을 담고 있습니다. $ grep -n -i -A 1 \u0026#39;oom\u0026#39; /usr/lib/systemd/system/postgresql-18.service 36:# Disable OOM kill on postgres main process 37:OOMScoreAdjust=-1000 38:Environment=PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj 39:Environment=PG_OOM_ADJUST_VALUE=0 systemd로 띄우면 적용되므로, pg_ctl로 직접 띄우는 스크립트를 쓰고 있다면 이 설정이 빠져 있지 않은지 확인합니다.\n재발 방지 로그 알람: terminated by signal 9, all server processes terminated; reinitializing. 이 두 줄은 모든 세션이 끊겼다는 뜻입니다. OS 쪽 기록: 서버라면 커널 로그(dmesg, journal)의 OOM killer 기록, 컨테이너라면 cgroup의 memory.events(oom_kill). 메모리 사용 추세: 서버의 가용 메모리, 큰 쿼리가 몰리는 시간대. 의심되는 세션은 pg_log_backend_memory_contexts(). 임시 파일: work_mem을 줄이면 임시 파일이 늘어납니다. log_temp_files와 pg_stat_database.temp_bytes로 보고, 디스크는 6편의 temp_file_limit로 보호합니다. 커넥션 수: 동시에 무거운 쿼리를 돌리는 세션 수를 커넥션 풀 크기로 제한합니다(9편). 정리 work_mem은 정렬·해시 연산 하나의 한도이고, 해시는 hash_mem_multiplier(기본 2)배까지 씁니다. 노드 수, 병렬 worker 수, 동시 세션 수만큼 곱해집니다. 실행 중인 세션의 메모리는 pg_log_backend_memory_contexts()로 서버 로그에 남겨 봅니다. RSS에는 shared buffers가 섞여 있습니다. backend 하나가 OOM으로 signal 9를 받으면 postmaster가 모든 세션을 끊고 장애 복구를 합니다. 다른 세션은 crash of another server process를 받고, 누적 통계는 초기화됩니다. work_mem은 전역으로 작게, 필요한 계정·세션에서만 크게 잡습니다. 계산할 때 hash_mem_multiplier를 빼먹지 않습니다. 실제 서버에서는 vm.overcommit_memory = 2와 postmaster의 OOM 점수 조정으로 OOM killer의 개입을 막습니다. 참고 자료 Resource Consumption: work_mem, hash_mem_multiplier, maintenance_work_mem Linux Memory Overcommit pg_log_backend_memory_contexts, pg_backend_memory_contexts PostgreSQL 인터널 1편 프로세스 구조, 2편 메모리 구조, 8편 체크포인트와 장애 복구 ","permalink":"https://jinlog.dev/posts/postgresql-ops/11-out-of-memory/","summary":"쿼리 몇 개 때문에 모든 세션이 끊겼다면, 메모리를 누가 얼마나 쓰는지 보고 한도를 어떻게 잡는가","title":"PostgreSQL 운영 11: 메모리가 모자란다"},{"content":"개요 특정 테이블을 읽을 때마다 invalid page in block 에러가 납니다. 다른 테이블은 멀쩡하고, 같은 테이블도 어떤 행은 읽힙니다. 디스크나 스토리지 계층의 문제로 데이터 파일의 일부가 망가진 것입니다. 이럴 때 가장 위험한 것은 서두르는 것입니다. 잘못된 조치 하나가 살릴 수 있던 데이터를 없애거나, 손상을 보이지 않게 덮어 버립니다.\n이 글에서 답할 질문은 다음과 같습니다.\n데이터 checksum은 손상을 어떻게 알려 주는가 망가진 것이 어느 테이블, 어느 블록인지 어떻게 전부 찾는가 인덱스만 망가졌을 때와 테이블 페이지가 망가졌을 때 각각 어떻게 하는가 ignore_checksum_failure와 zero_damaged_pages는 무엇을 하고, 무엇을 잃는가 기준 환경: PostgreSQL 18.6(PGDG RPM postgresql18-server-18.6-1PGDG.rhel9.8), Rocky Linux 9.8. 본문의 출력은 모두 이 환경에서 직접 재현한 결과입니다.\n실습 방법: 서버를 멈춘 상태에서 테이블 두 개(t, w)와 인덱스 하나(u_pkey)의 1번 블록 끝 30바이트를 X로 덮어써 손상을 만들었습니다. 실제 손상은 스토리지 장애, 펌웨어 버그, 잘못된 파일 복사 등에서 옵니다.\nPostgreSQL 18은 initdb 기본값으로 데이터 checksum을 켭니다. 페이지를 디스크에 쓸 때 checksum을 계산해 넣고, 읽을 때 다시 계산해 비교합니다(인터널 3편).\npostgres=# SHOW data_checksums; data_checksums ---------------- on (1 row) 손상시킬 블록 1에는 t의 id 59~115가 들어 있습니다.\npostgres=# SELECT (ctid::text::point)[0]::int AS block, count(*), min(id), max(id) postgres-# FROM t GROUP BY 1 ORDER BY 1 LIMIT 3; block | count | min | max -------+-------+-----+----- 0 | 58 | 1 | 58 1 | 57 | 59 | 115 2 | 55 | 116 | 170 (3 rows) postgres=# SELECT \u0026#39;t\u0026#39; AS rel, pg_relation_filepath(\u0026#39;t\u0026#39;) AS path postgres-# UNION ALL SELECT \u0026#39;w\u0026#39;, pg_relation_filepath(\u0026#39;w\u0026#39;) postgres-# UNION ALL SELECT \u0026#39;u_pkey\u0026#39;, pg_relation_filepath(\u0026#39;u_pkey\u0026#39;); rel | path --------+-------------- t | base/5/16394 w | base/5/16402 u_pkey | base/5/16416 (3 rows) 먼저 확인할 것 에러와 로그 postgres=# SELECT count(*) FROM t; ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; postgres=# SELECT * FROM t WHERE id = 1; id | v ----+------------------------------------------------------------------------------------------------------------ 1 | row-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (1 row) postgres=# SELECT * FROM t WHERE id = 100; ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; postgres=# SELECT * FROM u WHERE id = 10; ERROR: invalid page in block 1 of relation \u0026#34;base/5/16416\u0026#34; postgres=# SELECT count(*) FROM u; count ------- 1000 (1 row) 블록 1을 읽어야 하는 쿼리(전체 스캔, id 100)는 invalid page in block 1 of relation \u0026quot;base/5/16394\u0026quot;로 실패합니다. 블록 0에 있는 id 1은 인덱스로 찾아 정상으로 읽힙니다. 손상은 페이지 단위로 국한됩니다. u는 인덱스 페이지가 망가졌으므로 인덱스를 쓰는 조회는 실패하지만, 인덱스를 쓰지 않는 전체 스캔은 성공합니다. 서버 로그에는 checksum 불일치가 함께 남습니다.\n$ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;page verification failed|invalid page\u0026#39; | tail -n 4 2026-09-26 10:56:18.567 UTC [201] postgres@postgres/psql ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; 2026-09-26 10:56:18.615 UTC [208] postgres@postgres/psql LOG: page verification failed, calculated checksum 1188 but expected 7419 2026-09-26 10:56:18.615 UTC [208] postgres@postgres/psql LOG: invalid page in block 1 of relation \u0026#34;base/5/16416\u0026#34; 2026-09-26 10:56:18.615 UTC [208] postgres@postgres/psql ERROR: invalid page in block 1 of relation \u0026#34;base/5/16416\u0026#34; page verification failed, calculated checksum ... but expected ...가 checksum이 맞지 않았다는 뜻이고, 그 결과가 invalid page 에러입니다. 에러에 나오는 base/5/16394는 파일 경로라서, 어느 테이블인지는 pg_relation_filepath()나 pg_filenode_relation()으로 거꾸로 찾습니다. 누적 횟수는 pg_stat_database에 있습니다.\npostgres=# SELECT datname, checksum_failures, checksum_last_failure FROM pg_stat_database WHERE datname = \u0026#39;postgres\u0026#39;; datname | checksum_failures | checksum_last_failure ----------+-------------------+------------------------------- postgres | 3 | 2026-09-26 10:56:18.615407+00 (1 row) checksum_failures가 0이 아니면 그 서버의 스토리지를 의심해야 합니다. 알람으로 걸어 둘 값입니다.\n무엇부터 할 것인가 스토리지부터 봅니다. OS의 커널 로그(dmesg, journal)에 I/O 에러가 있는지, 디스크나 RAID 컨트롤러가 경고를 내고 있는지 확인합니다. 원인이 남아 있으면 손상은 계속 늘어납니다. 지금 상태를 보존합니다. 가능하면 데이터 디렉터리를 통째로 복사해 둡니다. 아래의 복구 조치 가운데 일부는 되돌릴 수 없습니다. 손상 범위를 전부 찾습니다. 에러가 난 테이블 하나만 망가졌다는 보장은 없습니다. 복구 방법을 고릅니다. 백업이나 standby가 있다면 그것이 가장 안전합니다. 손상 범위 파악 온라인: pg_amcheck pg_amcheck(PostgreSQL 14부터)는 contrib 확장 amcheck를 이용해 DB의 모든 테이블과 B-tree 인덱스를 검사합니다. 서버를 멈추지 않고 돌릴 수 있습니다.\n$ pg_amcheck -d postgres 2\u0026gt;\u0026amp;1 | head -n 20 btree index \u0026#34;postgres.public.u_pkey\u0026#34;: ERROR: invalid page in block 1 of relation \u0026#34;base/5/16416\u0026#34; heap table \u0026#34;postgres.public.t\u0026#34;: ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; heap table \u0026#34;postgres.public.w\u0026#34;: ERROR: invalid page in block 1 of relation \u0026#34;base/5/16402\u0026#34; [exit=0] 에러를 낸 t뿐 아니라 아직 아무도 읽지 않은 w의 손상까지 찾았습니다. 에러가 보고된 테이블 하나만 보고 판단하면 안 되는 이유입니다. amcheck는 checksum뿐 아니라 인덱스 순서가 맞는지, 튜플 헤더가 말이 되는지 같은 논리적인 손상도 검사합니다(amcheck). 전체를 읽으므로 큰 DB에서는 오래 걸리고 I/O를 일으킵니다.\n오프라인: pg_checksums 서버를 멈출 수 있다면 pg_checksums --check로 데이터 디렉터리의 모든 파일, 모든 블록의 checksum을 확인합니다.\n$ pg_ctl -D $PGDATA -w stop -m fast $ pg_checksums --check -D $PGDATA 2\u0026gt;\u0026amp;1 | tail -n 10 $ pg_ctl -D $PGDATA -l /var/lib/pgsql/startup.log -w start waiting for server to shut down.... done server stopped pg_checksums: error: checksum verification failed in file \u0026#34;/var/lib/pgsql/18/data/base/5/16416\u0026#34;, block 1: calculated checksum 4A4 but block contains 1CFB pg_checksums: error: checksum verification failed in file \u0026#34;/var/lib/pgsql/18/data/base/5/16394\u0026#34;, block 1: calculated checksum D55D but block contains 1E3C pg_checksums: error: checksum verification failed in file \u0026#34;/var/lib/pgsql/18/data/base/5/16402\u0026#34;, block 1: calculated checksum 7D3C but block contains 84DD Checksum operation completed Files scanned: 965 Blocks scanned: 2954 Bad checksums: 3 Data checksum version: 1 waiting for server to start.... done server started [exit=0] 세 블록을 모두 찾았고, 파일과 블록 번호까지 알려 줍니다. pg_amcheck와 달리 테이블뿐 아니라 모든 파일을 보지만, checksum만 확인하므로 논리적인 손상은 찾지 못합니다. checksum이 꺼진 클러스터에서는 쓸 수 없습니다.\n조치 인덱스만 망가졌다면: REINDEX 인덱스는 테이블에서 다시 만들 수 있으므로 가장 쉽습니다.\npostgres=# SELECT bt_index_check(\u0026#39;u_pkey\u0026#39;); ERROR: invalid page in block 1 of relation \u0026#34;base/5/16416\u0026#34; postgres=# REINDEX INDEX u_pkey; REINDEX postgres=# SELECT bt_index_check(\u0026#39;u_pkey\u0026#39;); bt_index_check ---------------- (1 row) postgres=# SELECT * FROM u WHERE id = 10; id | v ----+------ 10 | u-10 (1 row) bt_index_check()가 손상을 확인하고, REINDEX로 새로 만들자 검사도 조회도 정상이 되었습니다. 서비스 중이라면 REINDEX INDEX CONCURRENTLY를 씁니다(5편). 단, 테이블 쪽도 망가져 있다면 REINDEX가 테이블을 읽다가 실패하므로 테이블부터 해결해야 합니다.\n테이블 페이지가 망가졌다면: 백업부터 테이블 페이지는 다시 만들 원본이 없습니다. 가장 좋은 방법은 백업이나 standby에서 되살리는 것입니다.\nstandby가 있다면: standby는 primary의 파일을 복사한 것이 아니라 WAL을 재생해 만든 자기 파일을 가지고 있습니다. primary의 스토리지에서 생긴 손상이라면 standby의 같은 페이지는 멀쩡할 가능성이 큽니다. standby로 장애 조치(failover)하거나, standby에서 그 테이블을 덤프해 옮깁니다. 먼저 standby에서도 pg_amcheck로 확인합니다. 백업과 WAL 아카이브가 있다면: 다른 서버에 시점 복구(PITR)로 손상 전 시점을 되살리고, 필요한 테이블을 덤프해 옮깁니다(인터널 8편). 둘 다 없을 때 쓰는 마지막 수단이 아래 두 설정입니다. 둘 다 데이터를 잃거나 망가진 데이터를 받아들이는 방법이고, 슈퍼유저만 켤 수 있으며, 보존용 사본을 떠 둔 뒤에 씁니다.\nignore_checksum_failure: checksum을 무시하고 읽는다 checksum이 맞지 않아도 경고만 내고 페이지를 읽습니다. 망가진 페이지의 나머지 행을 꺼낼 수 있습니다.\npostgres=# SET ignore_checksum_failure = on; postgres-# SELECT count(*) FROM t; WARNING: ignoring checksum failure in block 1 of relation \u0026#34;base/5/16394\u0026#34; SET count ------- 1000 (1 row) postgres=# SET ignore_checksum_failure = on; postgres-# CREATE TABLE t_rescue AS SELECT * FROM t; SET SELECT 1000 1000행을 모두 읽어 t_rescue로 복사했습니다. 그런데 복사한 데이터를 보면 이렇습니다.\npostgres=# SELECT id, right(v, 35) AS tail FROM t_rescue WHERE id IN (58, 59, 60) ORDER BY id; id | tail ----+------------------------------------- 58 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 59 | XXXXXXXXXXXXXXXXXXXXXXXXXaaaaaaaaaa 60 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (3 rows) id 59의 값 끝이 X로 바뀌어 있습니다. checksum을 무시했으니 망가진 바이트도 그대로 읽힌 것입니다. 이 실습은 텍스트 값의 일부만 덮어썼기 때문에 이 정도로 끝났지만, 튜플 헤더나 길이 정보가 망가졌다면 엉뚱한 값을 읽거나 에러가 나거나 backend가 죽을 수도 있습니다. 문서도 이 설정이 서버를 죽이거나 손상을 퍼뜨리거나 숨길 수 있다고 경고합니다(ignore_checksum_failure). 구조한 데이터는 반드시 검증합니다. 망가진 블록에 있던 행(여기서는 id 59~115)을 골라 애플리케이션 쪽 기록이나 다른 원천과 대조합니다.\n이 설정은 디스크의 페이지를 고치지 않습니다. 재시작한 뒤 다시 읽으면 여전히 손상이 보입니다.\npostgres=# SELECT count(*) FROM t; ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; postgres=# SELECT id, right(v, 35) AS tail FROM t WHERE id = 59; ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; $ pg_amcheck -d postgres 2\u0026gt;\u0026amp;1 | head -n 10 heap table \u0026#34;postgres.public.t\u0026#34;: ERROR: invalid page in block 1 of relation \u0026#34;base/5/16394\u0026#34; heap table \u0026#34;postgres.public.w\u0026#34;: ERROR: invalid page in block 1 of relation \u0026#34;base/5/16402\u0026#34; [exit=0] zero_damaged_pages: 망가진 페이지를 버린다 checksum이 맞지 않는 페이지를 만나면 그 페이지를 0으로 채우고, 빈 페이지로 취급해 넘어갑니다. 아무도 건드리지 않은 w에 써 봅니다.\npostgres=# SET zero_damaged_pages = on; postgres-# SELECT count(*) FROM w; WARNING: invalid page in block 1 of relation \u0026#34;base/5/16402\u0026#34;; zeroing out page SET count ------- 943 (1 row) $ tail -n 400 \u0026#34;$(ls -t $PGDATA/log/*.log | head -1)\u0026#34; | grep -E \u0026#39;zeroing out page\u0026#39; | tail -n 1 2026-09-26 10:56:20.078 UTC [362] postgres@postgres/psql WARNING: invalid page in block 1 of relation \u0026#34;base/5/16402\u0026#34;; zeroing out page 1000행이던 테이블이 943행이 되었습니다. VACUUM 뒤에는 검사에도 걸리지 않습니다.\npostgres=# SELECT count(*) FROM w; count ------- 943 (1 row) postgres=# VACUUM w; VACUUM postgres=# SELECT * FROM verify_heapam(\u0026#39;w\u0026#39;); blkno | offnum | attnum | msg -------+--------+--------+----- (0 rows) postgres=# SELECT count(*) AS missing, min(g) AS first_id, max(g) AS last_id postgres-# FROM generate_series(1, 1000) g WHERE NOT EXISTS (SELECT 1 FROM w WHERE w.id = g); missing | first_id | last_id ---------+----------+--------- 57 | 59 | 115 (1 row) 블록 1에 있던 57행(id 59~115)이 사라졌습니다. 테이블은 정상처럼 보이지만 그 행들은 돌아오지 않습니다. 페이지를 0으로 채운 상태가 디스크에 쓰이면 되돌릴 방법도 없습니다(zero_damaged_pages). 그래서 쓰기 전에 보존용 사본을 떠 두고, 가능하면 먼저 ignore_checksum_failure로 살릴 수 있는 행을 꺼내 둡니다.\n방법 되살아나는 것 잃는 것 쓸 때 REINDEX 인덱스 전체 없음 인덱스만 망가졌을 때 standby·백업 손상 전 데이터 백업 이후 변경(PITR이면 최소) 테이블 페이지 손상, 가장 먼저 검토 ignore_checksum_failure 망가진 페이지의 행 대부분 망가진 값이 섞일 수 있음 백업이 없을 때 데이터를 꺼내는 용도 zero_damaged_pages 테이블을 다시 읽을 수 있게 됨 그 페이지의 모든 행 마지막 수단 어느 방법으로 살렸든, 조치 뒤에는 pg_amcheck를 다시 돌려 남은 손상이 없는지 확인하고, 원인이 된 스토리지 문제가 해결되었는지 확인합니다.\n재발 방지 checksum을 켜 둡니다. PostgreSQL 18은 기본으로 켜지만, 업그레이드로 넘어온 옛 클러스터는 꺼져 있을 수 있습니다. SHOW data_checksums로 확인하고, 꺼져 있다면 서버를 멈추고 pg_checksums --enable로 켤 수 있습니다. pg_stat_database.checksum_failures 알람: 0에서 바뀌면 즉시 조사합니다. 정기 검사: 부하가 적은 시간에 pg_amcheck를 돌리거나, 백업을 복원한 서버에서 검사합니다. 손상은 읽기 전까지 드러나지 않으므로, 오래 읽지 않는 데이터일수록 늦게 발견됩니다. 백업을 복원해 봅니다. 손상이 났을 때 가장 믿을 만한 방법은 백업이고, 복원해 본 적 없는 백업은 믿을 수 없습니다. 스토리지 설정: fsync = off나 쓰기 캐시를 보장하지 않는 스토리지 설정은 장애 때 손상의 원인이 됩니다. fsync와 full_page_writes는 끄지 않습니다(인터널 7편). 정리 checksum이 맞지 않으면 로그에 page verification failed, 쿼리에 invalid page in block N of relation ...가 나옵니다. 손상은 페이지 단위로 국한되어, 다른 페이지의 행은 읽힙니다. 에러가 난 테이블 하나만 보지 말고, pg_amcheck(온라인)나 pg_checksums --check(오프라인)로 전체 범위를 찾습니다. 인덱스는 REINDEX로 다시 만듭니다. 테이블 페이지는 standby나 백업에서 되살리는 것이 가장 먼저입니다. ignore_checksum_failure는 망가진 페이지를 읽게 해 주지만 망가진 값도 함께 읽힙니다. 구조한 데이터는 검증합니다. zero_damaged_pages는 그 페이지의 행을 모두 버립니다. 이 실습에서는 57행이 사라졌습니다. 보존용 사본을 떠 둔 뒤 마지막 수단으로 씁니다. 참고 자료 amcheck, pg_amcheck pg_checksums Data Checksums Developer Options: ignore_checksum_failure, zero_damaged_pages pg_stat_database PostgreSQL 인터널 3편 데이터 저장 구조, 7편 WAL, 8편 체크포인트와 장애 복구 ","permalink":"https://jinlog.dev/posts/postgresql-ops/12-data-corruption/","summary":"invalid page 에러가 뜰 때, 손상 범위를 어떻게 파악하고 무엇을 먼저 살리는가","title":"PostgreSQL 운영 12: 데이터 손상이 의심된다"},{"content":"개요 CREATE EXTENSION pg_stat_statements 한 줄이면 새 뷰와 함수가 생기고, shared_preload_libraries에 이름을 넣고 재시작하면 모든 쿼리의 통계가 쌓이기 시작합니다. 너무 간단해서 안에서 무슨 일이 일어나는지 생각해 볼 일이 별로 없습니다. 그러다 운영 중에 이런 일을 겪습니다.\nCREATE EXTENSION은 성공했는데 조회하면 must be loaded via \u0026quot;shared_preload_libraries\u0026quot; 오류가 난다. 패키지를 올렸는데 ALTER EXTENSION ... UPDATE가 could not find function 오류로 실패한다. extension 함수 하나를 불렀을 뿐인데 모든 접속이 끊기고 서버가 복구 모드로 들어간다. 이런 일은 모두 extension이 파일 몇 개와 카탈로그 행 몇 개, 그리고 서버 프로세스 안으로 들어온 C 코드로 이루어져 있기 때문에 생깁니다. 이 글은 다음 질문에 답합니다.\nextension은 무엇으로 이루어지고, CREATE EXTENSION은 정확히 무엇을 하는가 C로 짠 함수는 언제, 어떻게 서버 프로세스 안으로 들어오는가 왜 어떤 extension은 shared_preload_libraries에 넣고 재시작해야 하는가 라이브러리 파일을 바꾸면 이미 떠 있는 프로세스에는 무슨 일이 일어나는가 C extension의 동작은 공유 라이브러리, 심볼, dlopen, fork 같은 운영체제 개념 위에 서 있습니다. 이 개념들을 먼저 짧게 정리하고 시작합니다. 설명에는 직접 만든 작은 extension demo_ext를 씁니다.\n기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1(18.6 개발 버전). PostgreSQL 인터널 연재와 같은 커밋이고, 소스 링크는 모두 이 커밋에 고정했습니다. 실습은 Rocky Linux 9.8(aarch64) 컨테이너에서 이 소스를 gcc 11.5로 빌드해 실행한 결과입니다.\n먼저 결론부터 정리하면 이렇습니다.\n구성 요소 위치 하는 일 control 파일 (이름.control) share/extension/ 기본 버전, 라이브러리 경로, 권한 같은 메타데이터 SQL 스크립트 (이름--버전.sql, 이름--이전--다음.sql) share/extension/ 설치하거나 업데이트할 때 실행할 SQL 공유 라이브러리 (이름.so) $libdir (lib/) C 함수와 hook. 서버 프로세스 안으로 들어와 실행됨 pg_extension 행 카탈로그 이 DB에 어떤 extension이 몇 버전으로 설치되어 있는지 pg_depend 행 (deptype = 'e') 카탈로그 어떤 객체가 그 extension의 소속인지 CREATE EXTENSION은 앞의 두 파일을 읽어 카탈로그를 채우는 명령이고, .so는 그 과정이나 첫 함수 호출 때 dlopen으로 프로세스에 올라옵니다. 이 둘이 서로 다른 시점에 따로 움직인다는 것이 이 글 전체의 요점입니다.\n먼저 알아야 할 OS 배경지식 공유 라이브러리와 심볼 C 소스를 컴파일하면 기계어와 함께 심볼 표가 나옵니다. 심볼은 함수나 전역 변수의 이름입니다. 파일 안에 정의가 있는 심볼(defined)이 있고, 이름만 쓰고 정의는 다른 곳에서 찾아야 하는 심볼(undefined)이 있습니다.\n공유 라이브러리(Linux에서는 .so, shared object)는 실행 파일처럼 ELF 형식이지만 혼자 실행되지 않고 다른 프로세스에 끼워 넣어 쓰는 파일입니다. 어느 주소에 올라가도 동작하도록 위치 독립 코드(-fPIC)로 컴파일하고 -shared로 링크합니다. 이 파일을 실행 중에 프로세스로 불러오는 함수가 dlopen(), 불러온 라이브러리에서 이름으로 심볼 주소를 찾는 함수가 dlsym()입니다. dlopen()이 라이브러리를 올릴 때 동적 링커가 라이브러리의 undefined 심볼을 이미 프로세스에 있는 심볼과 연결합니다.\ndemo_ext를 PGXS(extension 빌드용 Makefile 틀, 뒤에서 설명)로 빌드할 때 나온 명령에서 이 옵션들을 볼 수 있습니다(경고 옵션은 줄였습니다).\n$ make gcc -Wall -Wmissing-prototypes -Wpointer-arith -Wdeclaration-after-statement -Werror=vla -Wendif-labels -Wmissing-format-attribute -Wimplicit-fallthrough=3 -Wcast-function-type -Wshadow=compatible-local -Wformat-security -fno-strict-aliasing -fwrapv -fexcess-precision=standard -Wno-format-truncation -Wno-stringop-truncation -O2 -fPIC -fvisibility=hidden -I. -I./ -I/usr/local/pgsql/include/server -I/usr/local/pgsql/include/internal -D_GNU_SOURCE -c -o demo_ext.o demo_ext.c gcc -Wall -Wmissing-prototypes -Wpointer-arith -Wdeclaration-after-statement -Werror=vla -Wendif-labels -Wmissing-format-attribute -Wimplicit-fallthrough=3 -Wcast-function-type -Wshadow=compatible-local -Wformat-security -fno-strict-aliasing -fwrapv -fexcess-precision=standard -Wno-format-truncation -Wno-stringop-truncation -O2 -fPIC -fvisibility=hidden demo_ext.o -L/usr/local/pgsql/lib -Wl,--as-needed -Wl,-rpath,\u0026#39;/usr/local/pgsql/lib\u0026#39;,--enable-new-dtags -fvisibility=hidden -shared -o demo_ext.so -fvisibility=hidden은 기본적으로 모든 심볼을 라이브러리 밖에서 안 보이게 숨기는 옵션입니다. 밖에서 찾아야 하는 심볼만 PostgreSQL 매크로(PG_FUNCTION_INFO_V1, PG_MODULE_MAGIC)가 PGDLLEXPORT로 내보냅니다(fmgr.h).\ndemo_ext.so의 심볼 표 nm -D는 동적 심볼 표를 보여 줍니다. T는 이 파일 안에 정의된 함수, U는 다른 곳에서 찾아야 하는 심볼입니다.\n$ file demo_ext.so demo_ext.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[sha1]=020d9238e238d042536254e28e669018b6045b7c, not stripped $ nm -D --defined-only demo_ext.so 0000000000000ed0 T Pg_magic_func 0000000000000ee0 T _PG_init 0000000000001030 T demo_add 0000000000001054 T demo_build 0000000000001100 T demo_crash 0000000000001070 T demo_local_count 0000000000001090 T demo_shared_count 0000000000001020 T pg_finfo_demo_add 0000000000001044 T pg_finfo_demo_build 00000000000010f0 T pg_finfo_demo_crash 0000000000001060 T pg_finfo_demo_local_count 0000000000001080 T pg_finfo_demo_shared_count $ nm -D --undefined-only demo_ext.so U DefineCustomBoolVariable U ExecutorEnd_hook U MarkGUCPrefixReserved U RequestAddinShmemSpace U ShmemInitStruct w _ITM_deregisterTMCloneTable w _ITM_registerTMCloneTable w __cxa_finalize@GLIBC_2.17 U __getauxval@GLIBC_2.17 w __gmon_start__ U cstring_to_text U errcode U errfinish U errmsg U errmsg_internal U errstart U errstart_cold U getpid@GLIBC_2.17 U process_shared_preload_libraries_in_progress U shmem_request_hook U shmem_startup_hook U standard_ExecutorEnd $ ldd demo_ext.so linux-vdso.so.1 (0x0000ffffbe987000) libc.so.6 =\u0026gt; /lib64/libc.so.6 (0x0000ffffbe771000) /lib/ld-linux-aarch64.so.1 (0x0000ffffbe940000) 정의된 심볼에는 SQL에서 부를 함수(demo_add 등)와 짝을 이루는 pg_finfo_demo_add, 그리고 Pg_magic_func, _PG_init이 있습니다. 이 세 종류가 PostgreSQL이 dlsym()으로 찾는 이름입니다. C 소스의 static 변수(local_queries 등)는 숨겨져서 보이지 않습니다. undefined 심볼의 대부분은 PostgreSQL 서버 자신의 함수와 전역 변수입니다. errstart, errmsg는 ereport()가, cstring_to_text는 문자열 반환이, ExecutorEnd_hook은 hook이 쓰는 심볼입니다. GLIBC 표시가 붙은 몇 개만 C 라이브러리에서 옵니다. 그런데 ldd로 의존 라이브러리를 보면 libc뿐이고 PostgreSQL은 없습니다. 이 심볼들은 어디서 찾는 걸까요. 서버 실행 파일이 심볼을 내보낸다 답은 postgres 실행 파일 자신입니다. 보통 실행 파일은 자기 함수 이름을 동적 심볼 표에 올리지 않지만 PostgreSQL은 서버를 링크할 때 -Wl,--export-dynamic을 붙여 모든 전역 심볼을 동적 심볼 표에 올립니다(configure.ac). 그래서 postgres 프로세스 안으로 올라온 .so는 서버 내부 함수를 제 것처럼 부를 수 있습니다.\npostgres 실행 파일의 동적 심볼 $ grep -E \u0026#34;^(LDFLAGS_EX_BE|CFLAGS_SL|CFLAGS_SL_MODULE|DLSUFFIX) \u0026#34; $(pg_config --pgxs | sed \u0026#34;s#makefiles/pgxs.mk#Makefile.global#\u0026#34;) CFLAGS_SL = -fPIC CFLAGS_SL_MODULE = -fvisibility=hidden LDFLAGS_EX_BE = -Wl,--export-dynamic DLSUFFIX = .so $ nm -D --defined-only /usr/local/pgsql/bin/postgres | wc -l 11192 $ nm -D --defined-only /usr/local/pgsql/bin/postgres | grep -wE \u0026#34;cstring_to_text|errstart|ExecutorEnd_hook|ShmemInitStruct|standard_ExecutorEnd|shmem_request_hook|process_shared_preload_libraries_in_progress\u0026#34; 0000000000d0bd78 B ExecutorEnd_hook 0000000000809b30 T ShmemInitStruct 00000000009461d4 T cstring_to_text 000000000096f660 T errstart 0000000000d26760 B process_shared_preload_libraries_in_progress 0000000000d26790 B shmem_request_hook 0000000000670870 T standard_ExecutorEnd postgres 실행 파일은 동적 심볼 11192개를 내보냅니다. demo_ext.so가 찾던 이름이 모두 여기 있습니다. T는 함수이고, B는 초기값 없는 전역 변수(.bss 영역)입니다. ExecutorEnd_hook이 함수가 아니라 변수라는 점을 기억해 두세요. hook은 이 변수에 함수 주소를 써넣는 방식으로 동작합니다.\n그렇다면 demo_ext.so를 PostgreSQL이 아닌 프로그램에 올리면 어떻게 될까요. dlopen()만 부르는 작은 C 프로그램을 만들어 봤습니다. 플래그는 PostgreSQL이 쓰는 것과 같은 RTLD_NOW | RTLD_GLOBAL입니다.\nPostgreSQL 밖에서 dlopen하면 $ cat dltest.c #include \u0026lt;dlfcn.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; int main(int argc, char **argv) { void\t*h = dlopen(argv[1], RTLD_NOW | RTLD_GLOBAL); if (h == NULL) { printf(\u0026#34;dlopen failed: %s\\n\u0026#34;, dlerror()); return 1; } printf(\u0026#34;dlopen ok\\n\u0026#34;); return 0; } $ gcc -o dltest dltest.c $ ./dltest ./demo_ext/demo_ext.so dlopen failed: ./demo_ext/demo_ext.so: undefined symbol: ExecutorEnd_hook $ ./dltest /usr/local/pgsql/lib/pg_stat_statements.so dlopen failed: /usr/local/pgsql/lib/pg_stat_statements.so: undefined symbol: post_parse_analyze_hook RTLD_NOW는 \u0026ldquo;올리는 순간 undefined 심볼을 모두 연결하라\u0026quot;는 뜻입니다. 이 프로그램에는 ExecutorEnd_hook이 없으니 올리는 단계에서 실패합니다. PostgreSQL의 extension 라이브러리는 postgres 프로세스 안에서만 살 수 있는 코드이고, 서버 내부의 함수와 전역 변수를 직접 만질 수 있습니다. 서버 코드와 같은 권한, 같은 주소 공간에서 돈다는 뜻이고, C 함수의 버그가 서버 전체에 영향을 주는 이유도 여기 있습니다.\ndlopen은 파일을 메모리에 매핑한다 dlopen()은 파일을 read()로 통째로 읽어 들이지 않습니다. mmap()으로 파일을 프로세스 주소 공간에 매핑합니다. 매핑된 영역은 /proc/\u0026lt;pid\u0026gt;/maps에 한 줄씩 나타나며 공유 라이브러리 하나는 보통 권한이 다른 네 영역으로 보입니다.\n권한 내용 r-xp 기계어 코드 (읽기, 실행) ---p 정렬을 맞추려고 비워 둔 틈 r--p 동적 링커가 주소를 채운 뒤 읽기 전용으로 바꾼 영역(RELRO) rw-p 전역 변수와 static 변수 끝의 p는 private, 즉 copy-on-write 매핑입니다. 여러 프로세스가 같은 .so를 매핑하면 처음에는 같은 물리 페이지를 공유하지만 한 프로세스가 rw-p 영역에 쓰는 순간 그 페이지만 해당 프로세스 전용으로 복사됩니다. 그래서 .so 안의 static 변수는 프로세스마다 따로 있습니다. 실제 매핑 모습은 CREATE EXTENSION 절에서 봅니다.\nfork와 프로세스별 메모리 1편에서 봤듯이 PostgreSQL은 접속마다 postmaster가 fork()로 backend를 만듭니다. fork()는 부모의 주소 공간을 그대로 복제하므로(copy-on-write) 부모가 이미 매핑해 둔 라이브러리와 거기 담긴 전역 변수 값이 자식에게 그대로 넘어갑니다. 반대로 fork() 뒤에 자식이 따로 dlopen()한 라이브러리는 그 자식에게만 있습니다.\n프로세스끼리 값을 함께 보려면 공유 메모리(2편)가 필요합니다. PostgreSQL의 공유 메모리는 postmaster가 시작할 때 크기를 정해 한 번 만들고, 이후 모든 자식이 물려받습니다. 크기를 나중에 늘릴 수 없으니 공유 메모리를 쓰려는 extension은 postmaster가 공유 메모리를 만들기 전에 올라와 있어야 하고, 그러려면 shared_preload_libraries가 필요합니다.\n용어 정리\n심볼(symbol): 함수나 전역 변수의 이름과 주소. 공유 라이브러리를 올릴 때 이름으로 서로 연결됩니다. dlopen() / dlsym(): 실행 중에 공유 라이브러리를 프로세스에 올리는 함수 / 올린 라이브러리에서 이름으로 심볼 주소를 찾는 함수. mmap(): 파일이나 메모리를 프로세스 주소 공간에 매핑하는 시스템 콜. dlopen()이 라이브러리를 올릴 때 씁니다. copy-on-write: 여러 프로세스가 같은 페이지를 공유하다가, 누군가 쓰는 순간 그 페이지만 복사해 주는 방식. fork()와 private 매핑이 이렇게 동작합니다. $libdir: PostgreSQL이 공유 라이브러리를 두는 디렉터리(pg_config --pkglibdir). 이 빌드에서는 /usr/local/pgsql/lib입니다. extension을 이루는 파일들 extension 하나는 control 파일, SQL 스크립트, (C로 짠 경우) 공유 라이브러리로 이루어집니다. SQL과 PL/pgSQL만으로 된 extension은 .so 없이 앞의 둘만 있습니다. 파일 위치는 pg_config로 확인합니다.\n$ pg_config --version --pkglibdir --sharedir --includedir-server --pgxs PostgreSQL 18.6 /usr/local/pgsql/lib /usr/local/pgsql/share /usr/local/pgsql/include/server /usr/local/pgsql/lib/pgxs/src/makefiles/pgxs.mk demo_ext의 파일은 다음과 같습니다. 처음에는 버전 1.0만 있습니다.\n$ cat demo_ext.control # demo_ext extension comment = \u0026#39;demo extension for extension internals\u0026#39; default_version = \u0026#39;1.0\u0026#39; module_pathname = \u0026#39;$libdir/demo_ext\u0026#39; relocatable = true $ cat demo_ext--1.0.sql \\echo Use \u0026#34;CREATE EXTENSION demo_ext\u0026#34; to load this file. \\quit CREATE FUNCTION demo_add(integer, integer) RETURNS integer AS \u0026#39;MODULE_PATHNAME\u0026#39;, \u0026#39;demo_add\u0026#39; LANGUAGE C STRICT IMMUTABLE; CREATE FUNCTION demo_build() RETURNS text AS \u0026#39;MODULE_PATHNAME\u0026#39;, \u0026#39;demo_build\u0026#39; LANGUAGE C STRICT; CREATE FUNCTION demo_local_count() RETURNS bigint AS \u0026#39;MODULE_PATHNAME\u0026#39;, \u0026#39;demo_local_count\u0026#39; LANGUAGE C STRICT; CREATE FUNCTION demo_shared_count() RETURNS bigint AS \u0026#39;MODULE_PATHNAME\u0026#39;, \u0026#39;demo_shared_count\u0026#39; LANGUAGE C STRICT; CREATE TABLE demo_note (id int PRIMARY KEY, note text); SELECT pg_catalog.pg_extension_config_dump(\u0026#39;demo_note\u0026#39;, \u0026#39;\u0026#39;); $ cat Makefile MODULES = demo_ext EXTENSION = demo_ext DATA = demo_ext--1.0.sql PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS) control 파일에서 자주 쓰는 항목은 다음과 같습니다(parse_extension_control_file()).\n항목 뜻 default_version 버전을 지정하지 않은 CREATE EXTENSION이 설치할 버전 module_pathname 스크립트의 MODULE_PATHNAME 글자를 이 값으로 바꿈 relocatable 설치 후 ALTER EXTENSION ... SET SCHEMA로 스키마를 옮길 수 있는지 schema 설치할 스키마를 고정할 때 requires 먼저 설치되어 있어야 하는 다른 extension superuser 설치에 superuser가 필요한지 (기본 true) trusted superuser가 아니어도 설치할 수 있는지 (권한 절) 스크립트 첫 줄의 \\echo ... \\quit은 누가 psql로 이 파일을 직접 실행하는 것을 막는 줄입니다. CREATE EXTENSION은 \\echo로 시작하는 줄을 지우고 실행합니다(extension.c).\nC 소스의 핵심은 다음과 같습니다(전체는 약 170줄입니다).\nPG_MODULE_MAGIC_EXT(.name = \u0026#34;demo_ext\u0026#34;, .version = DEMO_VERSION); static uint64 local_queries = 0; /* 프로세스마다 따로 있는 카운터 */ static DemoSharedState *demo_state = NULL; /* 공유 메모리의 카운터를 가리킴 */ static ExecutorEnd_hook_type prev_ExecutorEnd = NULL; void _PG_init(void) { elog(LOG, \u0026#34;demo_ext %s: _PG_init in pid %d (shared_preload_libraries: %s)\u0026#34;, DEMO_VERSION, (int) getpid(), process_shared_preload_libraries_in_progress ? \u0026#34;yes\u0026#34; : \u0026#34;no\u0026#34;); DefineCustomBoolVariable(\u0026#34;demo_ext.trace\u0026#34;, ..., \u0026amp;demo_trace, false, PGC_USERSET, ...); MarkGUCPrefixReserved(\u0026#34;demo_ext\u0026#34;); prev_ExecutorEnd = ExecutorEnd_hook; /* 원래 값을 저장하고 */ ExecutorEnd_hook = demo_ExecutorEnd; /* 내 함수로 바꾼다 */ /* 공유 메모리는 postmaster가 만들 때만 요청할 수 있다 */ if (process_shared_preload_libraries_in_progress) { prev_shmem_request_hook = shmem_request_hook; shmem_request_hook = demo_shmem_request; /* RequestAddinShmemSpace() */ prev_shmem_startup_hook = shmem_startup_hook; shmem_startup_hook = demo_shmem_startup; /* ShmemInitStruct(\u0026#34;demo_ext\u0026#34;) */ } } static void demo_ExecutorEnd(QueryDesc *queryDesc) { local_queries++; if (demo_state) pg_atomic_fetch_add_u64(\u0026amp;demo_state-\u0026gt;queries, 1); if (demo_trace) ereport(NOTICE, (errmsg(\u0026#34;demo_ext: %llu rows processed\u0026#34;, (unsigned long long) queryDesc-\u0026gt;estate-\u0026gt;es_processed))); if (prev_ExecutorEnd) prev_ExecutorEnd(queryDesc); else standard_ExecutorEnd(queryDesc); } PG_FUNCTION_INFO_V1(demo_add); Datum demo_add(PG_FUNCTION_ARGS) { PG_RETURN_INT32(PG_GETARG_INT32(0) + PG_GETARG_INT32(1)); } demo_build()는 이 .so가 몇 버전으로 빌드되었는지 문자열로 돌려주고, demo_local_count()와 demo_shared_count()는 두 카운터 값을 돌려줍니다. demo_shared_count()는 공유 메모리가 없으면 오류를 냅니다. demo_crash()는 일부러 NULL 포인터에 씁니다.\n설치는 root로 make install을 실행합니다. PGXS는 control 파일과 스크립트를 share/extension/에, .so를 $libdir에 복사할 뿐입니다. 여기까지는 DB에 아무 변화가 없습니다.\n$ make install /usr/bin/mkdir -p \u0026#39;/usr/local/pgsql/share/extension\u0026#39; /usr/bin/mkdir -p \u0026#39;/usr/local/pgsql/share/extension\u0026#39; /usr/bin/mkdir -p \u0026#39;/usr/local/pgsql/lib\u0026#39; /usr/bin/install -c -m 644 .//demo_ext.control \u0026#39;/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 644 .//demo_ext--1.0.sql \u0026#39;/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 755 demo_ext.so \u0026#39;/usr/local/pgsql/lib/\u0026#39; CREATE EXTENSION이 하는 일 control 파일과 스크립트를 읽어 카탈로그를 채우고, LANGUAGE C 함수를 만들 때 .so를 dlopen합니다. 모두 한 트랜잭션 안에서 일어납니다. 새 창에서 크게 보기 CREATE EXTENSION을 받은 backend는 다음 순서로 일합니다(CreateExtension(), CreateExtensionInternal()).\ncontrol 파일 찾기와 읽기: extension_control_path에 적힌 디렉터리에서 이름.control을 찾습니다. 기본값 $system은 share/extension/입니다(get_extension_control_directories()). 없으면 extension \u0026quot;...\u0026quot; is not available 오류입니다. 버전별 보조 control 파일(이름--버전.control)이 있으면 그것도 읽습니다. 설치 경로 정하기: 설치할 버전(지정이 없으면 default_version)의 스크립트가 있으면 그것을 쓰고, 없으면 있는 설치 스크립트에서 업데이트 스크립트를 이어 붙여 목표 버전까지 가는 가장 짧은 경로를 찾습니다(find_install_path()). pg_extension에 행 넣기: 스크립트를 실행하기 전에 먼저 extension 자신을 등록합니다(InsertExtensionTuple()). 스크립트가 만드는 객체가 가리킬 대상이 있어야 하기 때문입니다. 스크립트 읽고 바꾸기: 스크립트를 읽어 \\echo 줄을 지우고, @extschema@를 설치 스키마로, MODULE_PATHNAME을 control 파일의 module_pathname으로 바꿉니다(execute_extension_script(), 치환은 L1373-L1434). 실행하는 동안만 search_path를 설치 스키마로, client_min_messages를 warning으로, check_function_bodies를 off로 바꿔 둡니다. 스크립트 실행: creating_extension = true, CurrentExtensionObject = \u0026lt;extension OID\u0026gt;로 표시해 두고(L1319) 스크립트의 SQL 문을 하나씩 실행합니다(execute_sql_string()). 객체를 만드는 코드는 이 표시를 보고 새 객체에서 extension으로 가는 pg_depend 행(deptype = 'e')을 추가합니다(recordDependencyOnCurrentExtension()). \u0026ldquo;extension의 멤버\u0026quot;라는 말의 실체가 이 행입니다. C 함수 검증: LANGUAGE C 함수를 만들면 C 언어의 검증 함수 fmgr_c_validator()가 불립니다. 이 함수는 라이브러리를 실제로 올려서 심볼이 있는지 확인합니다. 4번에서 check_function_bodies를 꺼 뒀지만 C 함수 검증은 이 설정을 무시합니다(pg_proc.c L808). 이 검증을 거치면서 CREATE EXTENSION을 실행한 backend에는 .so가 올라오고 _PG_init()이 실행됩니다. 이 모든 일이 한 트랜잭션 안에서 일어납니다. 스크립트 중간에 오류가 나면 pg_extension 행과 만들던 객체가 모두 롤백됩니다. 다만 6번에서 프로세스에 올라온 .so는 롤백되지 않습니다. 라이브러리를 내리는 기능은 없습니다(dfmgr.c).\nstrace로 보는 CREATE EXTENSION 접속 A를 열고 그 backend에 strace를 붙인 상태에서 CREATE EXTENSION을 실행했습니다. 먼저 실행 전의 상태입니다. /proc/self/maps는 backend 자신의 매핑 목록이라 pg_read_file()로 읽으면 SQL에서 바로 볼 수 있습니다.\nA=# SELECT pg_backend_pid(); pg_backend_pid ---------------- 165 (1 row) A=# SELECT name, default_version, installed_version, comment FROM pg_available_extensions WHERE name IN (\u0026#39;demo_ext\u0026#39;, \u0026#39;pg_stat_statements\u0026#39;, \u0026#39;pgcrypto\u0026#39;) ORDER BY name; name | default_version | installed_version | comment --------------------+-----------------+-------------------+------------------------------------------------------------------------ demo_ext | 1.0 | | demo extension for extension internals pg_stat_statements | 1.12 | | track planning and execution statistics of all SQL statements executed (2 rows) A=# SELECT count(*) AS mapped FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext%\u0026#39;; mapped -------- 0 (1 row) A=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name -------------+---------+----------- (0 rows) pg_available_extensions는 control 파일만 읽어서 보여 주는 뷰입니다(이 빌드는 OpenSSL 없이 만들어 pgcrypto가 없습니다). demo_ext가 보이지만 installed_version은 비어 있고 backend에는 아직 .so가 매핑되어 있지 않습니다. pg_get_loaded_modules()는 PG18에서 새로 생긴 함수로, 이 프로세스에 올라온 라이브러리 목록을 보여 줍니다(extension.c).\n$ strace -p 165 -f -tt -e trace=openat,read,mmap,mprotect,close -o /tmp/create_ext.strace A=# CREATE EXTENSION demo_ext; CREATE EXTENSION strace 결과 67줄 중 앞부분과, 카탈로그 파일(base/5/...)을 여는 줄을 줄인 결과입니다.\n$ cat /tmp/create_ext.strace 165 11:01:47.364224 openat(AT_FDCWD, \u0026#34;/usr/local/pgsql/share/extension/demo_ext.control\u0026#34;, O_RDONLY) = 59 165 11:01:47.364503 read(59, \u0026#34;# demo_ext extension\\ncomment = \u0026#39;\u0026#34;..., 8192) = 152 165 11:01:47.364633 read(59, \u0026#34;\u0026#34;, 4096) = 0 165 11:01:47.364733 read(59, \u0026#34;\u0026#34;, 8192) = 0 165 11:01:47.364826 close(59) = 0 165 11:01:47.365033 openat(AT_FDCWD, \u0026#34;/usr/local/pgsql/share/extension/demo_ext--1.0.control\u0026#34;, O_RDONLY) = -1 ENOENT (No such file or directory) 165 11:01:47.365205 openat(AT_FDCWD, \u0026#34;base/5/2685\u0026#34;, O_RDWR|O_CLOEXEC) = 59 165 11:01:47.365644 openat(AT_FDCWD, \u0026#34;base/5/3079_fsm\u0026#34;, O_RDWR|O_CLOEXEC) = 60 165 11:01:47.366096 openat(AT_FDCWD, \u0026#34;base/5/3079_vm\u0026#34;, O_RDWR|O_CLOEXEC) = 61 165 11:01:47.366485 openat(AT_FDCWD, \u0026#34;base/5/2608_fsm\u0026#34;, O_RDWR|O_CLOEXEC) = 62 165 11:01:47.366951 openat(AT_FDCWD, \u0026#34;base/5/2608\u0026#34;, O_RDWR|O_CLOEXEC) = 63 ... 165 11:01:47.369823 openat(AT_FDCWD, \u0026#34;/usr/local/pgsql/share/extension/demo_ext--1.0.sql\u0026#34;, O_RDONLY) = 71 165 11:01:47.370011 read(71, \u0026#34;\\\\echo Use \\\u0026#34;CREATE EXTENSION demo\u0026#34;..., 4096) = 624 165 11:01:47.370102 close(71) = 0 ... 165 11:01:47.371182 openat(AT_FDCWD, \u0026#34;base/5/1255_fsm\u0026#34;, O_RDWR|O_CLOEXEC) = 74 165 11:01:47.371705 openat(AT_FDCWD, \u0026#34;base/5/1255_vm\u0026#34;, O_RDWR|O_CLOEXEC) = 75 165 11:01:47.372210 openat(AT_FDCWD, \u0026#34;base/5/2682\u0026#34;, O_RDWR|O_CLOEXEC) = 76 165 11:01:47.372780 openat(AT_FDCWD, \u0026#34;/usr/local/pgsql/lib/demo_ext.so\u0026#34;, O_RDONLY|O_CLOEXEC) = 77 165 11:01:47.372893 read(77, \u0026#34;\\177ELF\\2\\1\\1\\0\\0\\0\\0\\0\\0\\0\\0\\0\\3\\0\\267\\0\\1\\0\\0\\0\\200\\f\\0\\0\\0\\0\\0\\0\u0026#34;..., 832) = 832 165 11:01:47.373063 mmap(NULL, 131264, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 77, 0) = 0xffffaa30c000 165 11:01:47.373166 mprotect(0xffffaa30e000, 118784, PROT_NONE) = 0 165 11:01:47.373269 mmap(0xffffaa32b000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 77, 0xf000) = 0xffffaa32b000 165 11:01:47.373378 close(77) = 0 165 11:01:47.373498 mprotect(0xffffaa32b000, 4096, PROT_READ) = 0 165 11:01:47.374790 openat(AT_FDCWD, \u0026#34;base/5/16389\u0026#34;, O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = 77 ... 165 11:01:47.384251 openat(AT_FDCWD, \u0026#34;pg_wal/000000010000000000000001\u0026#34;, O_RDWR|O_CLOEXEC) = 91 base/5/\u0026lt;번호\u0026gt;는 postgres DB(OID 5)의 카탈로그 파일입니다. 번호를 카탈로그 이름으로 바꿔 보면 다음과 같습니다.\npostgres=# SELECT pg_relation_filenode(c.oid) AS filenode, c.relname FROM pg_class c WHERE pg_relation_filenode(c.oid) IN (3079, 1255, 2608, 1259, 1247) ORDER BY 1; filenode | relname ----------+-------------- 1247 | pg_type 1255 | pg_proc 1259 | pg_class 2608 | pg_depend 3079 | pg_extension (5 rows) strace의 순서가 위 목록과 그대로 맞습니다.\ndemo_ext.control을 열어 읽습니다(152바이트). 보조 control 파일 demo_ext--1.0.control은 없어서 ENOENT입니다. 스크립트보다 먼저 extension 행을 넣으므로 pg_extension(3079)과 pg_depend(2608)를 엽니다. demo_ext--1.0.sql을 읽습니다(624바이트). 첫 CREATE FUNCTION이 pg_proc(1255)에 행을 넣은 직후 demo_ext.so를 엽니다. ELF 헤더 832바이트를 읽고, 파일을 mmap()하고, 가운데 틈을 PROT_NONE으로, 데이터 영역을 PROT_READ|PROT_WRITE로 다시 매핑합니다. 마지막 mprotect(..., PROT_READ)는 동적 링커가 심볼 주소를 채운 영역을 읽기 전용으로 바꾸는 것(RELRO)입니다. .so를 여는 것은 한 번뿐입니다. 나머지 CREATE FUNCTION 세 개는 이미 올라온 라이브러리를 그대로 씁니다. 16389, 16393 같은 새 파일은 스크립트의 CREATE TABLE demo_note가 만든 테이블과 인덱스입니다. 마지막에 커밋하며 WAL에 씁니다. 서버 로그의 _PG_init $ cat /home/postgres/server.log ... 2026-09-26 11:01:47.373 UTC [165] LOG: demo_ext 1.0: _PG_init in pid 165 (shared_preload_libraries: no) 2026-09-26 11:01:47.373 UTC [165] CONTEXT: SQL statement \u0026#34;CREATE FUNCTION demo_add(integer, integer) RETURNS integer AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_add\u0026#39; LANGUAGE C STRICT IMMUTABLE\u0026#34; extension script file \u0026#34;demo_ext--1.0.sql\u0026#34;, near line 3 2026-09-26 11:01:47.373 UTC [165] STATEMENT: CREATE EXTENSION demo_ext; _PG_init()이 backend 165에서 실행되었고, CONTEXT가 그 시점을 정확히 알려 줍니다. 스크립트 3번째 줄의 첫 CREATE FUNCTION이고, 스크립트의 'MODULE_PATHNAME'이 이미 '$libdir/demo_ext'로 바뀌어 있습니다.\nbackend 주소 공간에 생긴 매핑 A=# SELECT l FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext%\u0026#39;; l ----------------------------------------------------------------------------------------------------------- ffffaa30c000-ffffaa30e000 r-xp 00000000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so ffffaa30e000-ffffaa32b000 ---p 00002000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so ffffaa32b000-ffffaa32c000 r--p 0000f000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so ffffaa32c000-ffffaa32d000 rw-p 00010000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so (4 rows) A=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name -------------+---------+------------- demo_ext | 1.0 | demo_ext.so (1 row) 앞에서 본 네 영역이 strace의 mmap() 주소 그대로 보입니다. 다섯째 열 1306724는 파일의 inode 번호입니다. 라이브러리를 교체할 때 이 번호가 중요해집니다. pg_get_loaded_modules()의 version은 C 소스의 PG_MODULE_MAGIC_EXT에 적은 값입니다.\n카탈로그에 남은 것 A=# SELECT oid, extname, extversion, extrelocatable, extnamespace::regnamespace, extconfig::regclass[] FROM pg_extension; oid | extname | extversion | extrelocatable | extnamespace | extconfig -------+----------+------------+----------------+--------------+------------- 13554 | plpgsql | 1.0 | f | pg_catalog | 16384 | demo_ext | 1.0 | t | public | {demo_note} (2 rows) A=# SELECT proname, (SELECT lanname FROM pg_language WHERE oid = prolang) AS lang, probin, prosrc FROM pg_proc WHERE proname IN (\u0026#39;demo_add\u0026#39;, \u0026#39;demo_build\u0026#39;, \u0026#39;demo_local_count\u0026#39;, \u0026#39;demo_shared_count\u0026#39;) ORDER BY proname; proname | lang | probin | prosrc -------------------+------+------------------+------------------- demo_add | c | $libdir/demo_ext | demo_add demo_build | c | $libdir/demo_ext | demo_build demo_local_count | c | $libdir/demo_ext | demo_local_count demo_shared_count | c | $libdir/demo_ext | demo_shared_count (4 rows) A=# SELECT classid::regclass, pg_describe_object(classid, objid, objsubid) AS member, deptype FROM pg_depend WHERE refclassid = \u0026#39;pg_extension\u0026#39;::regclass AND refobjid = (SELECT oid FROM pg_extension WHERE extname = \u0026#39;demo_ext\u0026#39;) ORDER BY 1, 2; classid | member | deptype ----------+------------------------------------+--------- pg_type | type demo_note | e pg_type | type demo_note[] | e pg_proc | function demo_add(integer,integer) | e pg_proc | function demo_build() | e pg_proc | function demo_local_count() | e pg_proc | function demo_shared_count() | e pg_class | table demo_note | e (7 rows) pg_extension: 이름, 버전, 설치 스키마와 함께 extconfig에 demo_note가 들어 있습니다. 스크립트의 pg_extension_config_dump('demo_note', '')가 이 테이블을 \u0026ldquo;사용자가 데이터를 넣는 설정 테이블\u0026quot;로 표시한 것입니다(pg_dump 절). pg_proc: C 함수의 probin(라이브러리 파일)과 prosrc(심볼 이름)입니다. 함수를 부를 때 이 두 값으로 .so와 심볼을 찾습니다. pg_depend: 스크립트가 만든 객체가 모두 deptype = 'e'로 extension을 가리킵니다. 테이블을 만들 때 자동으로 생긴 행 타입(demo_note, demo_note[])도 들어 있습니다. 기본 키 인덱스는 테이블에 딸린 객체라 여기 없습니다. C 함수가 호출되기까지 SQL에서 demo_add(1, 2)를 부르면 함수 관리자(fmgr)가 pg_proc 행을 보고 실제 C 함수 주소를 찾습니다. 언어가 c이면 fmgr_info_C_lang()이 불리고 다음 순서로 동적 로더(dfmgr)를 거칩니다.\n이 backend에서 찾아 둔 적이 있는가: 한 번 찾은 함수 주소는 backend의 해시 테이블에 캐시합니다. 두 번째 호출부터는 아래 과정을 건너뜁니다. 파일 이름 풀기 (expand_dynamic_library_name()): probin에 /가 있으면 $libdir을 실제 경로로 바꾸고, 없으면 dynamic_library_path에 적힌 디렉터리에서 찾습니다. 파일이 없으면 끝에 .so를 붙여 다시 찾습니다. '$libdir/demo_ext'는 /usr/local/pgsql/lib/demo_ext.so가 됩니다. 이미 올린 파일인가 (internal_load_library()): backend마다 올린 라이브러리 목록이 있습니다. 먼저 경로 문자열로 찾고, 없으면 stat()으로 inode를 얻어 같은 파일을 다른 경로로 올린 적이 있는지 봅니다. 찾으면 해당 핸들을 그대로 씁니다. dlopen(RTLD_NOW | RTLD_GLOBAL) (L244): 처음 보는 파일이면 올립니다. undefined 심볼은 postgres 실행 파일이 내보낸 심볼과 이때 연결됩니다. magic block 검사 (L257-L291): dlsym(\u0026quot;Pg_magic_func\u0026quot;)로 magic block을 꺼내 서버와 비교합니다. 없거나 다르면 dlclose()하고 오류를 냅니다. _PG_init() 호출 (L297): 라이브러리에 _PG_init이 있으면 부릅니다. 파일 하나당 프로세스마다 딱 한 번입니다. 심볼 찾기: dlsym(prosrc)로 함수 주소를, dlsym(\u0026quot;pg_finfo_\u0026quot; + prosrc)로 호출 규약 정보를 찾습니다(fetch_finfo_record()). PG_FUNCTION_INFO_V1(demo_add)가 만든 pg_finfo_demo_add는 \u0026ldquo;버전 1 호출 규약\u0026quot;이라는 뜻의 { 1 }을 돌려줍니다. magic block: 서버와 라이브러리의 ABI 확인 C 라이브러리는 서버의 구조체 크기나 상수 값을 컴파일할 때 그대로 박아 넣습니다. 다른 major 버전의 헤더로 빌드한 라이브러리를 올리면 구조체 모양이 달라 메모리를 엉뚱하게 읽게 됩니다. 이를 막는 것이 magic block입니다. PG_MODULE_MAGIC 매크로는 빌드할 때의 값을 담은 구조체를 돌려주는 Pg_magic_func()를 만들고, 로더는 이 값을 서버 자신의 값과 memcmp()로 비교합니다(fmgr.h).\n필드 값 version major 버전 (PG_VERSION_NUM / 100, 여기서는 18) funcmaxargs 함수 인자 최대 개수 FUNC_MAX_ARGS indexmaxkeys 인덱스 키 최대 개수 INDEX_MAX_KEYS namedatalen 이름 길이 NAMEDATALEN float8byval float8을 값으로 넘기는지 abi_extra 파생 제품이 ABI를 구별하려고 쓰는 문자열 PG18에서는 PG_MODULE_MAGIC_EXT(.name = ..., .version = ...)로 모듈 이름과 버전도 넣을 수 있게 되었고(fmgr.h), 이 값이 pg_get_loaded_modules()에 나옵니다. 이름과 버전은 비교에 쓰이지 않는 설명용 값입니다.\n라이브러리를 올리다 실패하는 네 가지 경우 magic block을 빠뜨린 모듈(nomagic.so)과, 같은 소스를 PostgreSQL 17.11 헤더로 빌드한 모듈(abi_demo.so)을 만들어 PG18에서 올려 봤습니다. probin에는 이렇게 절대 경로를 써도 됩니다.\n$ nm -D --defined-only nomagic/nomagic.so 00000000000005a0 T nomagic_one 0000000000000590 T pg_finfo_nomagic_one $ make -C abi17 PG_CONFIG=/usr/local/pgsql17/bin/pg_config $ nm -D --defined-only abi17/abi_demo.so 00000000000005c0 T Pg_magic_func 00000000000005e0 T abi_demo_one 00000000000005d0 T pg_finfo_abi_demo_one postgres=# CREATE FUNCTION nomagic_one() RETURNS int AS \u0026#39;/home/postgres/lab/nomagic/nomagic.so\u0026#39;, \u0026#39;nomagic_one\u0026#39; LANGUAGE C; ERROR: incompatible library \u0026#34;/home/postgres/lab/nomagic/nomagic.so\u0026#34;: missing magic block HINT: Extension libraries are required to use the PG_MODULE_MAGIC macro. postgres=# CREATE FUNCTION abi_demo_one() RETURNS int AS \u0026#39;/home/postgres/lab/abi17/abi_demo.so\u0026#39;, \u0026#39;abi_demo_one\u0026#39; LANGUAGE C; ERROR: incompatible library \u0026#34;/home/postgres/lab/abi17/abi_demo.so\u0026#34;: version mismatch DETAIL: Server is version 18, library is version 17. postgres=# CREATE FUNCTION demo_nosuch() RETURNS int AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_nosuch\u0026#39; LANGUAGE C; ERROR: could not find function \u0026#34;demo_nosuch\u0026#34; in file \u0026#34;/usr/local/pgsql/lib/demo_ext.so\u0026#34; postgres=# CREATE FUNCTION demo_nolib() RETURNS int AS \u0026#39;$libdir/demo_extx\u0026#39;, \u0026#39;demo_add\u0026#39; LANGUAGE C; ERROR: could not access file \u0026#34;demo_extx\u0026#34;: No such file or directory 오류 실패한 단계 missing magic block 5. dlsym(\u0026quot;Pg_magic_func\u0026quot;)이 없음 version mismatch 5. magic block의 major 버전이 다름. 17용 모듈은 dlopen() 자체는 성공했지만 여기서 걸렸습니다 could not find function 7. dlsym(prosrc)가 없음 could not access file 3. 파일 이름을 풀지 못해 원래 문자열 그대로 stat()했다가 실패 C 함수 검증기가 함수를 만들 때 라이브러리를 올려 보므로 오류는 모두 CREATE FUNCTION 단계에서 났습니다.\n다른 backend는 처음 부를 때 올린다 A가 CREATE EXTENSION을 했으니 A에는 .so가 올라와 있습니다. 새 접속 B는 어떨까요.\nB=# SELECT pg_backend_pid(); pg_backend_pid ---------------- 9320 (1 row) B=# SELECT count(*) AS mapped FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext.so%\u0026#39;; mapped -------- 0 (1 row) B=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name -------------+---------+----------- (0 rows) B=# SELECT demo_add(1, 2); demo_add ---------- 3 (1 row) B=# SELECT count(*) AS mapped FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext.so%\u0026#39;; mapped -------- 4 (1 row) $ grep -A1 \u0026#34;_PG_init\u0026#34; /home/postgres/server.log 2026-09-26 11:01:47.373 UTC [165] LOG: demo_ext 1.0: _PG_init in pid 165 (shared_preload_libraries: no) 2026-09-26 11:01:47.373 UTC [165] CONTEXT: SQL statement \u0026#34;CREATE FUNCTION demo_add(integer, integer) RETURNS integer -- 2026-09-26 11:02:20.705 UTC [9305] LOG: demo_ext 1.0: _PG_init in pid 9305 (shared_preload_libraries: no) 2026-09-26 11:02:20.705 UTC [9305] STATEMENT: CREATE FUNCTION demo_nosuch() RETURNS int AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_nosuch\u0026#39; LANGUAGE C; -- 2026-09-26 11:02:25.807 UTC [9320] LOG: demo_ext 1.0: _PG_init in pid 9320 (shared_preload_libraries: no) 2026-09-26 11:02:25.807 UTC [9320] STATEMENT: SELECT demo_add(1, 2); extension은 DB에 이미 설치되어 있지만 B에는 demo_add()를 처음 부를 때까지 .so가 없었습니다. 처음 부르는 순간 B가 직접 dlopen()했고, _PG_init()도 B에서 따로 한 번 실행되었습니다. 가운데의 9305는 앞 절에서 오류 실험을 한 접속입니다. demo_nosuch를 찾으려고 demo_ext.so를 올렸기 때문에, 함수 생성은 실패했지만 _PG_init()은 실행되었습니다. extension 설치는 DB 단위이고, 라이브러리 로드는 프로세스 단위입니다.\nLOAD 명령으로 함수를 부르지 않고 미리 올릴 수도 있습니다(load_file()). 이때도 _PG_init()은 그 backend에서 실행됩니다(다음 절의 실습).\n_PG_init과 hook .so가 올라오기만 해서는 SQL로 부를 수 있는 함수가 늘어날 뿐입니다. pg_stat_statements나 auto_explain처럼 모든 쿼리에 끼어드는 extension은 _PG_init()에서 hook을 겁니다.\nhook은 서버 곳곳에 있는 함수 포인터 전역 변수입니다. 서버는 어떤 지점에 이르면 이 변수가 NULL이 아닌지 보고 값이 있으면 변수에 담긴 함수를 대신 부릅니다. 예를 들어 실행기의 마지막 단계는 이렇게 생겼습니다(execMain.c).\nvoid ExecutorEnd(QueryDesc *queryDesc) { if (ExecutorEnd_hook) (*ExecutorEnd_hook) (queryDesc); else standard_ExecutorEnd(queryDesc); } 앞에서 nm으로 본 B ExecutorEnd_hook이 바로 이 변수입니다. demo_ext의 _PG_init()은 원래 값을 prev_ExecutorEnd에 저장하고 자기 함수 주소를 써넣습니다. 자기 일을 마친 뒤에는 저장해 둔 이전 hook을, 없으면 원래 함수 standard_ExecutorEnd()를 부릅니다. 여러 extension이 같은 hook을 걸면 이렇게 사슬이 됩니다. _PG_init()이 나중에 실행된 extension이 사슬의 맨 앞에 섭니다. pg_stat_statements도 같은 방식입니다(pg_stat_statements.c). PG18 헤더에는 이런 hook 타입이 31개 정의되어 있습니다(src/include에서 typedef ..._hook_type을 센 값).\nhook 변수도, .so의 static 변수도 결국 프로세스의 전역 변수라서 hook은 _PG_init()이 실행된 프로세스에만 걸립니다.\n_PG_init()에서는 extension 전용 설정(custom GUC)도 등록합니다. DefineCustomBoolVariable(\u0026quot;demo_ext.trace\u0026quot;, ...)는 demo_ext.trace라는 설정을 만들고, MarkGUCPrefixReserved(\u0026quot;demo_ext\u0026quot;)는 demo_ext.로 시작하는 다른 이름을 쓰지 못하게 막습니다.\nhook과 custom GUC의 동작 demo_ext를 이미 올린 B에서 설정을 켜 봅니다.\nB=# SET demo_ext.trace = on; SET B=# SELECT count(*) FROM pg_class WHERE relkind = \u0026#39;r\u0026#39;; NOTICE: demo_ext: 1 rows processed count ------- 69 (1 row) B=# SET demo_ext.tarce = on; ERROR: invalid configuration parameter name \u0026#34;demo_ext.tarce\u0026#34; DETAIL: \u0026#34;demo_ext\u0026#34; is a reserved prefix. 모든 쿼리가 끝날 때 demo_ExecutorEnd()가 불려 NOTICE가 나옵니다. 오타 난 이름은 예약된 접두어라 거부됩니다. 아직 demo_ext를 올리지 않은 새 접속 C에서는 다릅니다.\nC=# SET demo_ext.tarce = on; SET C=# SHOW demo_ext.tarce; demo_ext.tarce ---------------- on (1 row) C=# LOAD \u0026#39;demo_ext\u0026#39;; WARNING: invalid configuration parameter name \u0026#34;demo_ext.tarce\u0026#34;, removing it DETAIL: \u0026#34;demo_ext\u0026#34; is now a reserved prefix. LOAD C=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name -------------+---------+------------- demo_ext | 1.0 | demo_ext.so (1 row) 라이브러리가 올라오기 전에는 서버가 demo_ext.tarce가 맞는 이름인지 알 방법이 없어서 점이 들어간 이름은 일단 임시 설정(placeholder) 으로 받아 둡니다. LOAD로 _PG_init()이 실행되고 나서야 오타를 알아채고 경고와 함께 지웁니다. postgresql.conf에 extension 설정을 적어 둘 때도 오타가 이렇게 조용히 무시될 수 있습니다.\n프로세스마다 따로 있는 static 변수 local_queries는 .so 안의 static 변수이고 쿼리가 끝날 때마다 1씩 늘어납니다. A와 B에서 값을 읽어 봤습니다.\nB=# SELECT demo_local_count(); demo_local_count ------------------ 3 (1 row) B=# SELECT demo_local_count(); demo_local_count ------------------ 4 (1 row) B=# SELECT demo_shared_count(); ERROR: demo_ext must be loaded via \u0026#34;shared_preload_libraries\u0026#34; A=# SELECT demo_local_count(); demo_local_count ------------------ 6 (1 row) 같은 .so, 같은 변수인데 A는 6, B는 4입니다. private 매핑이라 변수 영역이 프로세스마다 따로 있습니다(값은 라이브러리를 올린 뒤 끝난 쿼리 수입니다. 지금 실행 중인 쿼리는 아직 세지 않았습니다). 모든 backend가 함께 보는 값을 두려면 공유 메모리가 필요한데, demo_shared_count()는 공유 메모리가 없다며 오류를 냅니다.\nshared_preload_libraries: postmaster가 먼저 올리는 경우 shared_preload_libraries에 넣으면 postmaster가 시작할 때 한 번 올리고 자식이 fork로 물려받습니다. 넣지 않으면 backend마다 처음 쓸 때 따로 올립니다. 새 창에서 크게 보기 shared_preload_libraries에 적은 라이브러리는 postmaster가 시작할 때 올립니다. 순서가 중요합니다(PostmasterMain()).\nprocess_shared_preload_libraries(): 라이브러리를 올리고 _PG_init()을 부릅니다. 이 동안만 process_shared_preload_libraries_in_progress가 true입니다. process_shmem_requests(): _PG_init()이 걸어 둔 shmem_request_hook을 불러 필요한 공유 메모리 크기를 모읍니다(RequestAddinShmemSpace()). CreateSharedMemoryAndSemaphores(): 모은 크기로 공유 메모리를 만들고, shmem_startup_hook에서 extension이 자기 영역을 초기화합니다(ShmemInitStruct()). 그 뒤로 만드는 모든 자식 프로세스는 fork()로 라이브러리 매핑, hook 포인터, 공유 메모리를 물려받습니다. 공유 메모리는 2번과 3번을 거쳐 한 번 만들어지고 나중에 늘릴 수 없으니, 공유 메모리가 필요한 extension은 반드시 여기 들어가야 합니다. pg_stat_statements의 _PG_init()은 preload 중이 아니면 아무것도 하지 않고 바로 돌아가고(pg_stat_statements.c), 함수가 불리면 오류를 냅니다.\nCREATE EXTENSION은 되는데 조회가 안 되는 이유 A=# CREATE EXTENSION pg_stat_statements; CREATE EXTENSION A=# SELECT count(*) FROM pg_stat_statements; ERROR: pg_stat_statements must be loaded via \u0026#34;shared_preload_libraries\u0026#34; CREATE EXTENSION은 파일을 읽어 카탈로그를 채우는 일이라 preload 여부와 상관없이 성공합니다. C 함수 검증 때 .so도 올라오지만 _PG_init()이 preload 중이 아닌 것을 보고 그냥 돌아갑니다. 공유 메모리가 없으니 뷰를 조회하면 오류가 납니다.\n운영에서는: shared_preload_libraries를 잘못 적으면 서버가 뜨지 않는다 두 라이브러리를 preload하려고 이렇게 적었다가 서버가 뜨지 않았습니다.\npostgres=# ALTER SYSTEM SET shared_preload_libraries = \u0026#39;demo_ext, pg_stat_statements\u0026#39;; ALTER SYSTEM $ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast ... pg_ctl: could not start server Examine the log output. $ tail -2 /home/postgres/server.log 2026-09-26 11:03:02.250 UTC [10095] FATAL: could not access file \u0026#34;demo_ext, pg_stat_statements\u0026#34;: No such file or directory 2026-09-26 11:03:02.250 UTC [10095] LOG: database system is shut down $ cat $PGDATA/postgresql.auto.conf # Do not edit this file manually! # It will be overwritten by the ALTER SYSTEM command. shared_preload_libraries = \u0026#39;\u0026#34;demo_ext, pg_stat_statements\u0026#34;\u0026#39; $ postgres -D $PGDATA -C shared_preload_libraries \u0026#34;demo_ext, pg_stat_statements\u0026#34; shared_preload_libraries는 목록형 설정입니다. ALTER SYSTEM에 작은따옴표 하나로 묶어 넘기면 쉼표까지 포함한 문자열 하나가 원소 하나가 되어 큰따옴표로 감싸져 저장됩니다. postmaster는 demo_ext, pg_stat_statements라는 이름의 파일을 찾다가 실패했습니다(load_libraries()). preload 실패는 곧 서버 시작 실패이고, 서버가 안 떠 있으니 ALTER SYSTEM으로 되돌릴 수도 없습니다. postgresql.auto.conf에서 그 줄을 직접 지우고 서버를 띄운 뒤 따옴표 없이 다시 설정했습니다.\npostgres=# ALTER SYSTEM SET shared_preload_libraries = demo_ext, pg_stat_statements; ALTER SYSTEM $ cat $PGDATA/postgresql.auto.conf # Do not edit this file manually! # It will be overwritten by the ALTER SYSTEM command. shared_preload_libraries = \u0026#39;demo_ext, pg_stat_statements\u0026#39; 라이브러리 파일이 없는 서버(패키지를 설치하지 않은 새 서버나 standby)에 같은 설정 파일을 복사할 때도 똑같이 서버가 뜨지 않습니다. postgres -C shared_preload_libraries로 재시작 전에 값을 확인하는 습관이 도움이 됩니다.\npostmaster의 _PG_init과 자식 프로세스 shared_preload_libraries = 'demo_ext'로 재시작한 뒤의 모습입니다.\n$ PM=$(head -1 $PGDATA/postmaster.pid); echo $PM 9751 $ grep \u0026#34;_PG_init\u0026#34; /home/postgres/server.log | tail -1 2026-09-26 11:02:45.013 UTC [9751] LOG: demo_ext 1.0: _PG_init in pid 9751 (shared_preload_libraries: yes) $ for p in $PM $(pgrep -P $PM); do printf \u0026#34;%6s %-45s %s\\n\u0026#34; $p \u0026#34;$(tr \u0026#34;\\0\u0026#34; \u0026#34; \u0026#34; \u0026lt; /proc/$p/cmdline | cut -c1-45)\u0026#34; \u0026#34;$(grep -c demo_ext.so /proc/$p/maps)\u0026#34;; done 9751 /usr/local/pgsql/bin/postgres -D /var/lib/pos 4 9752 postgres: io worker 0 4 9753 postgres: io worker 1 4 9754 postgres: io worker 2 4 9755 postgres: checkpointer 4 9756 postgres: background writer 4 9758 postgres: walwriter 4 9759 postgres: autovacuum launcher 4 9760 postgres: logical replication launcher 4 _PG_init()이 postmaster(9751)에서 shared_preload_libraries: yes로 한 번 실행되었습니다. 마지막 열은 각 프로세스의 maps에서 demo_ext.so 줄을 센 값입니다. postmaster가 올린 뒤에 fork한 백그라운드 프로세스들도 모두 네 영역을 갖고 있습니다. checkpointer나 walwriter는 demo_ext를 쓸 일이 없지만 fork로 물려받았습니다.\n새 접속이 생길 때 postmaster를 strace로 봤습니다.\n$ strace -p 9751 -f -e trace=clone,clone3,fork,execve,openat -o /tmp/fork.strace A=# SELECT pg_backend_pid(); pg_backend_pid ---------------- 9844 (1 row) $ grep -vE \u0026#34;base/|global/|pg_|\\.conf\u0026#34; /tmp/fork.strace 9751 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xffffab110ef0) = 9844 9844 openat(AT_FDCWD, \u0026#34;/dev/urandom\u0026#34;, O_RDONLY) = 5 9844 openat(AT_FDCWD, \u0026#34;/dev/urandom\u0026#34;, O_RDONLY) = 7 9844 openat(AT_FDCWD, \u0026#34;/dev/shm/PostgreSQL.3186032154\u0026#34;, O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 8 $ grep -c \u0026#34;demo_ext\u0026#34; /tmp/fork.strace 0 $ grep -c \u0026#34;_PG_init\u0026#34; /home/postgres/server.log 5 postmaster가 clone()(glibc의 fork())으로 backend 9844를 만들었고, execve()는 없습니다. 라이브러리가 이미 부모의 주소 공간에 있었으니 새 backend는 demo_ext.so를 한 번도 열지 않았고, 서버 로그의 _PG_init 줄 수도 그대로입니다.\n주소까지 같은지 pg_stat_statements로 확인해 봤습니다(shared_preload_libraries = 'demo_ext, pg_stat_statements' 상태).\n$ grep pg_stat_statements.so /proc/10122/maps ffff8d30f000-ffff8d317000 r-xp 00000000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d317000-ffff8d32e000 ---p 00008000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d32e000-ffff8d32f000 r--p 0000f000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d32f000-ffff8d330000 rw-p 00010000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so $ grep pg_stat_statements.so /proc/10148/maps ffff8d30f000-ffff8d317000 r-xp 00000000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d317000-ffff8d32e000 ---p 00008000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d32e000-ffff8d32f000 r--p 0000f000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so ffff8d32f000-ffff8d330000 rw-p 00010000 00:49 1274832 /usr/local/pgsql/lib/pg_stat_statements.so postmaster(10122)와 backend(10148)의 매핑이 주소와 inode까지 똑같습니다. fork()가 주소 공간을 그대로 복제했다는 뜻입니다.\n공유 메모리의 카운터 preload한 상태에서는 demo_shared_count()가 동작합니다. 두 접속에서 번갈아 읽어 봤습니다.\nA=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name -------------+---------+------------- demo_ext | 1.0 | demo_ext.so (1 row) A=# SELECT demo_local_count(), demo_shared_count(); demo_local_count | demo_shared_count ------------------+------------------- 2 | 2 (1 row) B=# SELECT demo_local_count(), demo_shared_count(); demo_local_count | demo_shared_count ------------------+------------------- 0 | 3 (1 row) B=# SELECT demo_local_count(), demo_shared_count(); demo_local_count | demo_shared_count ------------------+------------------- 1 | 4 (1 row) A=# SELECT demo_local_count(), demo_shared_count(); demo_local_count | demo_shared_count ------------------+------------------- 3 | 5 (1 row) A=# SELECT name, size, allocated_size FROM pg_shmem_allocations WHERE name = \u0026#39;demo_ext\u0026#39;; name | size | allocated_size ----------+------+---------------- demo_ext | 8 | 128 (1 row) demo_local_count()는 A와 B가 따로 세고, demo_shared_count()는 두 접속의 쿼리를 합쳐서 셉니다. pg_shmem_allocations에 ShmemInitStruct(\u0026quot;demo_ext\u0026quot;, ...)로 잡은 8바이트(pg_atomic_uint64 하나)가 보입니다. pg_get_loaded_modules()에 A가 따로 올리지 않은 demo_ext가 처음부터 있는 것도 fork로 물려받은 라이브러리 목록 때문입니다.\npg_stat_statements도 preload하고 재시작하면 조회가 됩니다.\npostgres=# SHOW shared_preload_libraries; shared_preload_libraries ------------------------------ demo_ext, pg_stat_statements (1 row) postgres=# SELECT count(*) \u0026gt; 0 AS has_rows FROM pg_stat_statements; has_rows ---------- t (1 row) postgres=# SELECT * FROM pg_get_loaded_modules(); module_name | version | file_name --------------------+---------+----------------------- demo_ext | 1.0 | demo_ext.so pg_stat_statements | 18.6 | pg_stat_statements.so (2 rows) postgres=# SELECT name, size FROM pg_shmem_allocations WHERE name LIKE \u0026#39;pg_stat_statements%\u0026#39; OR name = \u0026#39;demo_ext\u0026#39; ORDER BY name; name | size -------------------------+------ demo_ext | 8 pg_stat_statements | 64 pg_stat_statements hash | 2896 (3 rows) pg_stat_statements의 라이브러리 버전은 18.6이고, SQL 쪽 extension 버전은 1.12입니다(앞의 pg_available_extensions). contrib 모듈은 PG_MODULE_MAGIC_EXT(.version = PG_VERSION)로 서버 버전을 라이브러리 버전으로 씁니다(pg_stat_statements.c). 라이브러리 버전과 extension 버전은 별개라는 점은 업데이트 절에서 다시 중요해집니다.\n라이브러리를 올리는 방법 정리 방법 누가, 언제 _PG_init 실행 공유 메모리 요청 반영 shared_preload_libraries postmaster, 서버 시작 때 postmaster에서 한 번 가능 재시작 session_preload_libraries backend, 접속할 때 (postinit.c) 접속마다 불가 새 접속부터 local_preload_libraries backend, 접속할 때. $libdir/plugins/의 파일만 접속마다 불가 새 접속부터 LOAD 그 backend, 명령을 실행할 때 그 backend에서 불가 즉시 첫 함수 호출 (CREATE FUNCTION 검증 포함) 그 backend, 처음 필요할 때 그 backend에서 불가 즉시 session_preload_libraries는 auto_explain처럼 공유 메모리는 필요 없지만 모든 세션에 hook을 걸고 싶을 때 씁니다. 재시작 없이 reload만으로 새 접속부터 적용됩니다.\npostgres=# ALTER SYSTEM SET session_preload_libraries = demo_ext; ALTER SYSTEM postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row) $ grep -E \u0026#39;parameter \u0026#34;session_preload_libraries\u0026#34; changed to \u0026#34;demo_ext\u0026#34;|_PG_init\u0026#39; /home/postgres/server.log | tail -2 2026-09-26 11:03:43.750 UTC [10509] LOG: parameter \u0026#34;session_preload_libraries\u0026#34; changed to \u0026#34;demo_ext\u0026#34; 2026-09-26 11:03:44.757 UTC [11137] LOG: demo_ext 1.1: _PG_init in pid 11137 (shared_preload_libraries: no) 새 접속(11137)이 쿼리를 보내기 전, 접속하는 단계에서 _PG_init()이 실행되었습니다. 앞의 로그와 달리 STATEMENT 줄이 없습니다.\nALTER EXTENSION UPDATE와 라이브러리 교체 extension을 새 버전으로 올리는 일은 두 부분으로 나뉩니다.\n파일 교체: 패키지 관리자나 make install이 새 control 파일, 업데이트 스크립트, 새 .so를 디스크에 씁니다. DB와 프로세스는 이 사실을 모릅니다. 카탈로그 업데이트: ALTER EXTENSION ... UPDATE가 업데이트 스크립트(이름--이전--다음.sql)를 실행해 객체를 고치고 pg_extension.extversion을 바꿉니다(ExecAlterExtensionStmt()). DB마다 따로 해야 합니다. 그리고 세 번째로, 이미 떠 있는 프로세스가 어떤 .so를 쓰고 있는가가 따로 있습니다. demo_ext를 1.0에서 1.1로 올리며 이 셋이 어긋나는 모습을 봅니다. 1.1은 함수 demo_sub()를 추가합니다.\n$ cat demo_ext--1.0--1.1.sql \\echo Use \u0026#34;ALTER EXTENSION demo_ext UPDATE TO \u0026#39;1.1\u0026#39;\u0026#34; to load this file. \\quit CREATE FUNCTION demo_sub(integer, integer) RETURNS integer AS \u0026#39;MODULE_PATHNAME\u0026#39;, \u0026#39;demo_sub\u0026#39; LANGUAGE C STRICT IMMUTABLE; 라이브러리 파일을 바꾸면 shared_preload_libraries = 'demo_ext, pg_stat_statements'로 서버가 떠 있고 접속 A가 열려 있는 상태입니다.\nA=# SELECT pg_backend_pid(), demo_build(); pg_backend_pid | demo_build ----------------+----------------- 10148 | demo_ext.so 1.0 (1 row) 이 상태에서 1.1을 설치합니다. control 파일의 default_version을 '1.1'로 바꾸고, Makefile의 DATA에 업데이트 스크립트를 추가하고, demo_sub()가 들어가도록 -DDEMO_V11로 빌드했습니다.\n$ make install /usr/bin/install -c -m 644 .//demo_ext.control \u0026#39;/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 644 .//demo_ext--1.0.sql .//demo_ext--1.0--1.1.sql \u0026#39;/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 755 demo_ext.so \u0026#39;/usr/local/pgsql/lib/\u0026#39; $ ls -li /usr/local/pgsql/lib/demo_ext.so 1306722 -rwxr-xr-x 1 root root 72640 Sep 26 11:03 /usr/local/pgsql/lib/demo_ext.so $ nm -D --defined-only /usr/local/pgsql/lib/demo_ext.so | grep -w demo_sub 0000000000001094 T demo_sub install 명령은 기존 파일을 지우고(unlink) 새 파일을 만들어서 inode가 1306724에서 1306722로 바뀌었습니다. 다시 A에서 봅니다.\nA=# SELECT l FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext.so%\u0026#39;; l --------------------------------------------------------------------------------------------------------------------- ffff8d330000-ffff8d332000 r-xp 00000000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so (deleted) ffff8d332000-ffff8d34f000 ---p 00002000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so (deleted) ffff8d34f000-ffff8d350000 r--p 0000f000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so (deleted) ffff8d350000-ffff8d351000 rw-p 00010000 00:49 1306724 /usr/local/pgsql/lib/demo_ext.so (deleted) (4 rows) A=# SELECT demo_build(); demo_build ----------------- demo_ext.so 1.0 (1 row) A=# SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name = \u0026#39;demo_ext\u0026#39;; name | default_version | installed_version ----------+-----------------+------------------- demo_ext | 1.1 | 1.0 (1 row) A=# SELECT * FROM pg_extension_update_paths(\u0026#39;demo_ext\u0026#39;); source | target | path --------+--------+---------- 1.0 | 1.1 | 1.0--1.1 1.1 | 1.0 | (2 rows) A=# ALTER EXTENSION demo_ext UPDATE; ERROR: could not find function \u0026#34;demo_sub\u0026#34; in file \u0026#34;/usr/local/pgsql/lib/demo_ext.so\u0026#34; CONTEXT: SQL statement \u0026#34;CREATE FUNCTION demo_sub(integer, integer) RETURNS integer AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_sub\u0026#39; LANGUAGE C STRICT IMMUTABLE\u0026#34; extension script file \u0026#34;demo_ext--1.0--1.1.sql\u0026#34;, near line 3 A의 매핑은 옛 inode 1306724를 가리키고 (deleted)가 붙었습니다. 디렉터리에서 이름은 사라졌지만 매핑이 남아 있는 동안 커널은 파일 내용을 지우지 않습니다. A는 계속 1.0 코드를 실행합니다. control 파일은 이미 새것이라 pg_available_extensions는 기본 버전 1.1, 설치 버전 1.0을 보여 주고, 1.0 → 1.1 업데이트 경로도 보입니다. 그런데 ALTER EXTENSION UPDATE가 실패했습니다. 업데이트 스크립트의 CREATE FUNCTION demo_sub를 검증하면서 로더가 $libdir/demo_ext.so를 찾았는데, 이 backend의 라이브러리 목록에 같은 경로 문자열이 이미 있어서 옛 1.0 핸들을 그대로 돌려줬습니다(L200-L204). 1.0 .so에는 demo_sub가 없습니다. 오류로 트랜잭션이 롤백되어 extversion은 1.0 그대로입니다. 새 접속 B를 열면 새 .so를 쓸까요.\nB=# SELECT pg_backend_pid(), demo_build(); pg_backend_pid | demo_build ----------------+----------------- 10396 | demo_ext.so 1.0 (1 row) B=# SELECT count(*) AS deleted_mappings FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext.so (deleted)%\u0026#39;; deleted_mappings ------------------ 4 (1 row) B=# ALTER EXTENSION demo_ext UPDATE; ERROR: could not find function \u0026#34;demo_sub\u0026#34; in file \u0026#34;/usr/local/pgsql/lib/demo_ext.so\u0026#34; CONTEXT: SQL statement \u0026#34;CREATE FUNCTION demo_sub(integer, integer) RETURNS integer AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_sub\u0026#39; LANGUAGE C STRICT IMMUTABLE\u0026#34; extension script file \u0026#34;demo_ext--1.0--1.1.sql\u0026#34;, near line 3 새 접속도 1.0입니다. demo_ext가 preload되어 있어서 B는 postmaster가 서버 시작 때 올린 옛 .so를 fork로 물려받았습니다. 라이브러리 목록까지 물려받았으니 업데이트도 똑같이 실패합니다. preload한 라이브러리는 서버를 재시작해야만 바뀝니다.\n$ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast waiting for server to shut down.... done server stopped waiting for server to start.... done server started $ grep _PG_init /home/postgres/server.log | tail -1 2026-09-26 11:03:20.450 UTC [10486] LOG: demo_ext 1.1: _PG_init in pid 10486 (shared_preload_libraries: yes) postgres=# SELECT demo_build(); demo_build ----------------- demo_ext.so 1.1 (1 row) postgres=# SELECT * FROM pg_get_loaded_modules() WHERE module_name = \u0026#39;demo_ext\u0026#39;; module_name | version | file_name -------------+---------+------------- demo_ext | 1.1 | demo_ext.so (1 row) postgres=# ALTER EXTENSION demo_ext UPDATE; ALTER EXTENSION postgres=# SELECT extversion FROM pg_extension WHERE extname = \u0026#39;demo_ext\u0026#39;; extversion ------------ 1.1 (1 row) postgres=# SELECT demo_sub(10, 3); demo_sub ---------- 7 (1 row) 재시작 뒤에야 업데이트가 됩니다.\npreload하지 않은 라이브러리는 다릅니다. shared_preload_libraries = pg_stat_statements로 바꿔 재시작했습니다. 접속 A가 1.1 .so(hotfix 표시를 붙여 빌드)를 올린 상태에서 다시 빌드한 hotfix2를 make install했습니다.\nA=# SELECT pg_backend_pid(), demo_build(); pg_backend_pid | demo_build ----------------+------------------------ 10589 | demo_ext.so 1.1 hotfix (1 row) $ make install $ ls -li /usr/local/pgsql/lib/demo_ext.so 1306718 -rwxr-xr-x 1 root root 72640 Sep 26 11:03 /usr/local/pgsql/lib/demo_ext.so A=# SELECT demo_build(); demo_build ------------------------ demo_ext.so 1.1 hotfix (1 row) A=# SELECT count(*) AS deleted_mappings FROM regexp_split_to_table(pg_read_file(\u0026#39;/proc/self/maps\u0026#39;), E\u0026#39;\\n\u0026#39;) AS l WHERE l LIKE \u0026#39;%demo_ext.so (deleted)%\u0026#39;; deleted_mappings ------------------ 4 (1 row) # 세션 B 시작: psql -X application_name=sessB B=# SELECT pg_backend_pid(), demo_build(); pg_backend_pid | demo_build ----------------+------------------------- 10732 | demo_ext.so 1.1 hotfix2 (1 row) 이미 올린 A는 옛 파일을, 새 접속 B는 새 파일을 씁니다. 같은 서버에서 접속마다 다른 버전의 코드가 동시에 돌 수 있다는 뜻입니다.\n상황 기존 backend 새 backend preload한 라이브러리 옛 .so 옛 .so (postmaster에게서 물려받음) preload하지 않은 라이브러리 이미 올렸으면 옛 .so 새 .so 서버 재시작 뒤 새 .so 새 .so 운영에서는: 라이브러리를 cp로 덮어쓰지 않는다 install이나 패키지 관리자(rpm)는 새 파일을 만들어 교체하므로 옛 매핑이 안전하게 남습니다. 같은 inode를 제자리에서 덮어쓰면 어떻게 되는지 보려고 A가 hotfix2를 올린 상태에서 다른 빌드를 cp로 덮어썼습니다.\nA=# SELECT pg_backend_pid(), demo_build(), demo_add(1, 2); pg_backend_pid | demo_build | demo_add ----------------+-------------------------+---------- 10823 | demo_ext.so 1.1 hotfix2 | 3 (1 row) $ ls -li /usr/local/pgsql/lib/demo_ext.so 1306718 -rwxr-xr-x 1 root root 72640 Sep 26 11:03 /usr/local/pgsql/lib/demo_ext.so $ cp /tmp/demo_ext.hotfix3.so /usr/local/pgsql/lib/demo_ext.so $ ls -li /usr/local/pgsql/lib/demo_ext.so 1306718 -rwxr-xr-x 1 root root 72640 Sep 26 11:03 /usr/local/pgsql/lib/demo_ext.so A=# SELECT demo_build(); ERROR: unrecognized function API version: 0 A=# SELECT demo_add(1, 2); ERROR: unrecognized function API version: 0 cp는 기존 파일을 비우고 그 자리에 새 내용을 씁니다. inode가 그대로라 A가 매핑한 페이지가 새 파일의 내용을 보게 되고, A가 캐시해 둔 주소에는 더 이상 원래 값이 없습니다. 이 실습에서는 pg_finfo_ 레코드가 있던 자리를 읽어 0이 나왔고(fmgr.c), 방금까지 되던 demo_add()도 실패했습니다.\n여기서 끝나지 않았습니다. 이어서 세션 A를 닫자 그 backend가 죽었습니다.\n$ cat /home/postgres/server.log ... 2026-09-26 11:03:32.559 UTC [10823] ERROR: unrecognized function API version: 0 2026-09-26 11:03:32.559 UTC [10823] STATEMENT: SELECT demo_build(); 2026-09-26 11:03:33.562 UTC [10823] ERROR: unrecognized function API version: 0 2026-09-26 11:03:33.562 UTC [10823] STATEMENT: SELECT demo_add(1, 2); 2026-09-26 11:03:34.564 UTC [10509] LOG: client backend (PID 10823) was terminated by signal 11: Segmentation fault 2026-09-26 11:03:34.564 UTC [10509] LOG: terminating any other active server processes 2026-09-26 11:03:34.565 UTC [10509] LOG: all server processes terminated; reinitializing 2026-09-26 11:03:34.585 UTC [10909] LOG: database system was interrupted; last known up at 2026-09-26 11:03:20 UTC 2026-09-26 11:03:34.615 UTC [10909] LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 11:03:34.615 UTC [10909] LOG: invalid record length at 0/1844298: expected at least 24, got 0 2026-09-26 11:03:34.615 UTC [10909] LOG: redo is not required 2026-09-26 11:03:34.616 UTC [10910] LOG: checkpoint starting: end-of-recovery immediate wait 2026-09-26 11:03:34.621 UTC [10910] LOG: checkpoint complete: wrote 0 buffers (0.0%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.001 s, sync=0.001 s, total=0.006 s; sync files=2, longest=0.001 s, average=0.001 s; distance=0 kB, estimate=0 kB; lsn=0/1844298, redo lsn=0/1844298 2026-09-26 11:03:34.622 UTC [10509] LOG: database system is ready to accept connections backend 10823은 쿼리 없이 접속을 끝내던 중에 SIGSEGV로 죽어서 Failed process was running 줄이 없습니다. 어떤 코드가 망가진 페이지를 실행했는지는 로그로 알 수 없지만, 결과는 C 함수의 버그와 같습니다. postmaster가 모든 프로세스를 끝내고 crash recovery를 한 뒤 접속을 다시 받았습니다. 파일 하나를 덮어쓴 것이 서버 전체의 재시작으로 이어진 것입니다. 라이브러리 파일은 패키지 관리자나 install로 교체합니다.\n운영에서는: 패키지 업그레이드 뒤에 해야 할 일 위 실습을 운영 절차로 옮기면 이렇습니다.\n패키지를 올립니다. 이것만으로는 카탈로그도, 떠 있는 프로세스도 바뀌지 않습니다. preload하는 라이브러리라면 서버를 재시작합니다. 아니라면 적어도 새 접속에서 작업합니다. 커넥션 풀의 오래된 접속은 옛 .so를 쥐고 있을 수 있습니다. 새 버전에 SQL 변경이 있으면 DB마다 ALTER EXTENSION ... UPDATE를 실행합니다. 다음 쿼리로 남은 곳을 찾을 수 있습니다. postgres=# SELECT name, default_version, installed_version FROM pg_available_extensions WHERE installed_version IS DISTINCT FROM default_version AND installed_version IS NOT NULL; pg_get_loaded_modules()(PG18)는 지금 이 프로세스가 쥐고 있는 라이브러리 버전을, pg_extension.extversion은 이 DB의 SQL 쪽 버전을 보여 줍니다. 둘을 함께 보면 어긋난 곳을 알 수 있습니다.\n버전을 건너뛰는 설치 설치 스크립트가 1.0뿐이어도 default_version = '1.1'인 CREATE EXTENSION은 1.0을 설치한 뒤 업데이트 스크립트를 이어서 실행합니다. 새 DB verdb에서 client_min_messages = debug1로 보면 두 스크립트가 차례로 실행됩니다.\nverdb=# SET client_min_messages = debug1; SET verdb=# CREATE EXTENSION demo_ext; DEBUG: executing extension script for \u0026#34;demo_ext\u0026#34; version \u0026#39;1.0\u0026#39; DEBUG: executing extension script for \u0026#34;demo_ext\u0026#34; update from version \u0026#39;1.0\u0026#39; to \u0026#39;1.1\u0026#39; CREATE EXTENSION verdb=# RESET client_min_messages; RESET verdb=# SELECT extversion FROM pg_extension WHERE extname = \u0026#39;demo_ext\u0026#39;; extversion ------------ 1.1 (1 row) 덕분에 extension 배포판은 모든 버전의 설치 스크립트를 들고 다닐 필요 없이, 기준이 되는 설치 스크립트 하나와 업데이트 스크립트만 있으면 됩니다.\nC extension은 서버 프로세스 안에서 돈다 C 함수는 서버 코드와 같은 프로세스, 같은 주소 공간에서 실행되니 C 함수의 버그는 서버의 버그와 같습니다.\n운영에서는: C extension의 버그는 서버 전체를 재시작시킨다 접속 B가 트랜잭션 안에서 demo_note에 행을 넣고 커밋하지 않았습니다. 그 상태에서 다른 접속이 NULL 포인터에 쓰는 demo_crash()를 불렀습니다. 함수는 extension 스크립트 밖에서 같은 .so의 심볼로 따로 만들었습니다. 이렇게 CREATE FUNCTION만으로도 라이브러리의 어떤 심볼이든 SQL 함수로 만들 수 있습니다.\nB=# BEGIN; BEGIN B=*# INSERT INTO demo_note VALUES (1, \u0026#39;written before the crash, not committed\u0026#39;); INSERT 0 1 postgres=# CREATE FUNCTION demo_crash() RETURNS void AS \u0026#39;$libdir/demo_ext\u0026#39;, \u0026#39;demo_crash\u0026#39; LANGUAGE C; CREATE FUNCTION postgres=# SELECT pg_backend_pid(); pg_backend_pid ---------------- 11077 (1 row) postgres=# SELECT demo_crash(); server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost B=*# COMMIT; WARNING: terminating connection because of crash of another server process DETAIL: The postmaster has commanded this server process to roll back the current transaction and exit, because another server process exited abnormally and possibly corrupted shared memory. HINT: In a moment you should be able to reconnect to the database and repeat your command. server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. $ cat /home/postgres/server.log ... 2026-09-26 11:03:39.800 UTC [10509] LOG: client backend (PID 11077) was terminated by signal 11: Segmentation fault 2026-09-26 11:03:39.800 UTC [10509] DETAIL: Failed process was running: SELECT demo_crash(); 2026-09-26 11:03:39.800 UTC [10509] LOG: terminating any other active server processes 2026-09-26 11:03:39.801 UTC [10509] LOG: all server processes terminated; reinitializing 2026-09-26 11:03:39.811 UTC [11082] LOG: database system was interrupted; last known up at 2026-09-26 11:03:34 UTC 2026-09-26 11:03:39.981 UTC [11082] LOG: database system was not properly shut down; automatic recovery in progress 2026-09-26 11:03:39.982 UTC [11082] LOG: redo starts at 0/1844310 2026-09-26 11:03:39.988 UTC [11082] LOG: invalid record length at 0/1C8F118: expected at least 24, got 0 2026-09-26 11:03:39.988 UTC [11082] LOG: redo done at 0/1C8F0B0 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s 2026-09-26 11:03:39.998 UTC [11083] LOG: checkpoint starting: end-of-recovery immediate wait 2026-09-26 11:03:40.115 UTC [11083] LOG: checkpoint complete: wrote 953 buffers (5.8%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.004 s, sync=0.108 s, total=0.119 s; sync files=318, longest=0.004 s, average=0.001 s; distance=4395 kB, estimate=4395 kB; lsn=0/1C8F118, redo lsn=0/1C8F118 2026-09-26 11:03:40.116 UTC [10509] LOG: database system is ready to accept connections postgres=# SELECT count(*) FROM demo_note; count ------- 0 (1 row) backend 하나가 SIGSEGV로 죽자 postmaster는 그 프로세스가 공유 메모리를 망가뜨렸을 수 있다고 보고 아무 잘못 없는 B를 포함한 모든 프로세스를 종료했습니다(HandleChildCrash()). 그다음 공유 메모리를 다시 만들고 WAL로 crash recovery를 한 뒤 접속을 다시 받았습니다(1편, 8편). B가 커밋하지 않은 행은 사라졌습니다.\n같은 일이 extension 코드 안의 NULL 참조, 해제된 메모리 사용, 스택 넘침으로 생깁니다. 로그에 terminated by signal 11이 보이고 Failed process was running의 쿼리가 extension 함수를 쓰고 있다면 그 extension을 먼저 의심합니다. 새 C extension은 운영 전에 부하를 걸어 충분히 시험하고, 코어 덤프를 남기도록 설정해 두면 원인을 찾기 쉽습니다.\n권한: superuser와 trusted C 함수를 만들면 서버 프로세스 안에서 임의의 코드를 실행할 수 있습니다. 그래서 LANGUAGE C 함수는 superuser만 만들 수 있고, control 파일의 superuser도 기본값이 true입니다. PG13부터는 control 파일에 trusted = true를 적은 extension을, superuser가 아니어도 DB에 CREATE 권한이 있으면 설치할 수 있습니다. 이때 스크립트는 bootstrap superuser의 권한으로 실행되고(execute_extension_script()), extension의 소유자만 설치한 사용자가 됩니다.\ntrusted extension과 일반 사용자 $ cat /usr/local/pgsql/share/extension/hstore.control # hstore extension comment = \u0026#39;data type for storing sets of (key, value) pairs\u0026#39; default_version = \u0026#39;1.8\u0026#39; module_pathname = \u0026#39;$libdir/hstore\u0026#39; relocatable = true trusted = true postgres=# SELECT e.name, e.default_version, v.superuser, v.trusted FROM pg_available_extensions e JOIN pg_available_extension_versions v ON v.name = e.name AND v.version = e.default_version WHERE e.name IN (\u0026#39;demo_ext\u0026#39;, \u0026#39;hstore\u0026#39;, \u0026#39;pg_stat_statements\u0026#39;) ORDER BY 1; name | default_version | superuser | trusted --------------------+-----------------+-----------+--------- demo_ext | 1.1 | t | f hstore | 1.8 | t | t pg_stat_statements | 1.12 | t | f (3 rows) postgres=# CREATE ROLE app LOGIN; CREATE ROLE postgres=# CREATE DATABASE appdb; CREATE DATABASE postgres=# GRANT CREATE ON DATABASE appdb TO app; GRANT appdb의 public 스키마에도 app에게 CREATE 권한을 준 뒤, app으로 접속해 설치해 봤습니다.\nappdb=\u003e SELECT current_user, rolsuper FROM pg_roles WHERE rolname = current_user; current_user | rolsuper --------------+---------- app | f (1 row) appdb=\u003e CREATE EXTENSION demo_ext; ERROR: permission denied to create extension \u0026#34;demo_ext\u0026#34; HINT: Must be superuser to create this extension. appdb=\u003e CREATE EXTENSION hstore; CREATE EXTENSION appdb=\u003e SELECT extname, extowner::regrole FROM pg_extension WHERE extname = \u0026#39;hstore\u0026#39;; extname | extowner ---------+---------- hstore | app (1 row) appdb=\u003e SELECT proname, proowner::regrole FROM pg_proc WHERE proname = \u0026#39;hstore_in\u0026#39;; proname | proowner -----------+---------- hstore_in | postgres (1 row) appdb=\u003e SELECT \u0026#39;a=\u0026gt;1, b=\u0026gt;2\u0026#39;::hstore -\u0026gt; \u0026#39;b\u0026#39; AS b; b --- 2 (1 row) trusted가 아닌 demo_ext는 거부되고 hstore는 설치됩니다. extension 소유자는 app이지만 C 함수 hstore_in의 소유자는 bootstrap superuser인 postgres입니다. 일반 사용자가 C 함수를 만든 것이 아니라, superuser가 검토해 trusted로 표시한 스크립트를 대신 실행해 준 것입니다. 이 빌드에 설치된 extension 44개 중 19개가 trusted였습니다.\nPG18: extension_control_path PG17까지 control 파일은 반드시 PostgreSQL 설치 디렉터리의 share/extension/에 있어야 했습니다. PG18에는 extension_control_path가 생겨 다른 디렉터리에 둔 extension도 쓸 수 있습니다(get_extension_control_directories()). 목록의 각 디렉터리 뒤에 /extension을 붙여 찾고, $system은 원래 위치를 뜻합니다. .so는 예전부터 있던 dynamic_library_path로 찾습니다. 컨테이너 이미지처럼 PostgreSQL 설치 디렉터리를 고칠 수 없는 환경을 위한 기능입니다.\n설치 디렉터리 밖에 둔 extension module_pathname = 'pathdemo'(경로 없이 이름만)로 작은 extension을 만들어 DESTDIR로 /opt/pathdemo 아래에 설치했습니다.\n$ make install DESTDIR=/opt/pathdemo /usr/bin/install -c -m 644 .//pathdemo.control \u0026#39;/opt/pathdemo/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 644 .//pathdemo--0.1.sql \u0026#39;/opt/pathdemo/usr/local/pgsql/share/extension/\u0026#39; /usr/bin/install -c -m 755 pathdemo.so \u0026#39;/opt/pathdemo/usr/local/pgsql/lib/\u0026#39; postgres=# SELECT name, setting, context FROM pg_settings WHERE name IN (\u0026#39;extension_control_path\u0026#39;, \u0026#39;dynamic_library_path\u0026#39;); name | setting | context ------------------------+---------+----------- dynamic_library_path | $libdir | superuser extension_control_path | $system | superuser (2 rows) postgres=# CREATE EXTENSION pathdemo; ERROR: extension \u0026#34;pathdemo\u0026#34; is not available HINT: The extension must first be installed on the system where PostgreSQL is running. postgres=# SET extension_control_path = \u0026#39;$system:/opt/pathdemo/usr/local/pgsql/share\u0026#39;; SET postgres=# SELECT name, default_version, comment FROM pg_available_extensions WHERE name = \u0026#39;pathdemo\u0026#39;; name | default_version | comment ----------+-----------------+------------------------------------------------- pathdemo | 0.1 | extension installed outside the PostgreSQL tree (1 row) postgres=# CREATE EXTENSION pathdemo; ERROR: could not access file \u0026#34;pathdemo\u0026#34;: No such file or directory CONTEXT: SQL statement \u0026#34;CREATE FUNCTION pathdemo_hello() RETURNS text AS \u0026#39;pathdemo\u0026#39;, \u0026#39;pathdemo_hello\u0026#39; LANGUAGE C STRICT\u0026#34; extension script file \u0026#34;pathdemo--0.1.sql\u0026#34;, near line 1 postgres=# SET dynamic_library_path = \u0026#39;$libdir:/opt/pathdemo/usr/local/pgsql/lib\u0026#39;; SET postgres=# CREATE EXTENSION pathdemo; CREATE EXTENSION postgres=# SELECT pathdemo_hello(); pathdemo_hello ---------------------------- hello from outside $libdir (1 row) postgres=# SELECT probin FROM pg_proc WHERE proname = \u0026#39;pathdemo_hello\u0026#39;; probin ---------- pathdemo (1 row) control 파일은 extension_control_path로, .so는 dynamic_library_path로 따로 찾는다는 것이 오류 순서에서 드러납니다. control 경로만 추가했을 때는 스크립트까지는 실행되었고 C 함수 검증에서 .so를 찾지 못했습니다. probin에 경로 없이 pathdemo만 저장되었으므로 이 함수를 부르는 모든 세션이 dynamic_library_path를 알아야 합니다. 설정하지 않은 새 접속에서 보면 이렇습니다.\npostgres=# SELECT pathdemo_hello(); ERROR: could not access file \u0026#34;pathdemo\u0026#34;: No such file or directory postgres=# SELECT extname, extversion FROM pg_extension WHERE extname = \u0026#39;pathdemo\u0026#39;; extname | extversion ----------+------------ pathdemo | 0.1 (1 row) postgres=# SELECT name, installed_version FROM pg_available_extensions WHERE name = \u0026#39;pathdemo\u0026#39;; name | installed_version ------+------------------- (0 rows) 카탈로그에는 extension이 설치되어 있다고 나오는데, 이 세션은 control 파일도 .so도 찾지 못합니다. 실습에서는 세션에서 SET했지만 실제로 쓸 때는 두 설정을 postgresql.conf에 넣어야 합니다.\npg_dump와 DROP EXTENSION extension의 멤버 객체는 카탈로그에 있지만 정의의 원본은 디스크의 스크립트 파일입니다. pg_dump는 이 점을 이용합니다.\npg_dump가 남기는 것 postgres=# INSERT INTO demo_note VALUES (1, \u0026#39;kept by pg_dump\u0026#39;); INSERT 0 1 postgres=# \\dx+ demo_ext Objects in extension \u0026#34;demo_ext\u0026#34; Object description ------------------------------------ function demo_add(integer,integer) function demo_build() function demo_local_count() function demo_shared_count() function demo_sub(integer,integer) table demo_note type demo_note type demo_note[] (8 rows) postgres=# DROP FUNCTION demo_add(integer, integer); ERROR: cannot drop function demo_add(integer,integer) because extension demo_ext requires it HINT: You can drop extension demo_ext instead. $ pg_dump -d postgres | grep -vE \u0026#34;^--|^$|^SET |^SELECT pg_catalog.set_config|^\\\\\\\\(un)?restrict\u0026#34; CREATE EXTENSION IF NOT EXISTS demo_ext WITH SCHEMA public; COMMENT ON EXTENSION demo_ext IS \u0026#39;demo extension for extension internals\u0026#39;; CREATE EXTENSION IF NOT EXISTS pathdemo WITH SCHEMA public; COMMENT ON EXTENSION pathdemo IS \u0026#39;extension installed outside the PostgreSQL tree\u0026#39;; CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; COMMENT ON EXTENSION pg_stat_statements IS \u0026#39;track planning and execution statistics of all SQL statements executed\u0026#39;; COPY public.demo_note (id, note) FROM stdin; 1\tkept by pg_dump \\. 멤버 객체는 pg_depend의 'e' 의존성 때문에 따로 지울 수 없습니다. pg_dump는 멤버 객체의 정의를 하나도 내보내지 않고 CREATE EXTENSION 한 줄만 남깁니다. 복원하는 서버가 자기 디스크의 스크립트로 객체를 다시 만듭니다. 이 줄에는 버전이 없습니다. 복원하는 서버의 default_version으로 설치됩니다. pg_extension_config_dump()로 표시한 demo_note는 테이블 정의는 빠지고 데이터만 COPY로 나옵니다. 운영에서는: 복원할 서버에 extension 파일이 없으면 위 덤프에는 앞 절에서 설치 디렉터리 밖에 둔 pathdemo도 들어 있습니다. 이 덤프를 extension_control_path 설정 없이 새 DB에 복원했습니다.\n$ createdb restoredb $ pg_dump -d postgres | grep -n \u0026#34;CREATE EXTENSION\u0026#34; 26:CREATE EXTENSION IF NOT EXISTS demo_ext WITH SCHEMA public; 40:CREATE EXTENSION IF NOT EXISTS pathdemo WITH SCHEMA public; 54:CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; $ pg_dump -d postgres | psql -X -q -d restoredb set_config ------------ (1 row) ERROR: extension \u0026#34;pathdemo\u0026#34; is not available HINT: The extension must first be installed on the system where PostgreSQL is running. ERROR: extension \u0026#34;pathdemo\u0026#34; does not exist extension 파일은 DB 안에 있지 않으므로 덤프에도 없습니다. 복원할 서버에는 같은 extension 패키지를 먼저 설치해야 합니다. 물리 복제도 마찬가지입니다. standby는 WAL로 카탈로그를 그대로 받지만 $libdir과 share/extension/의 파일은 WAL에 실리지 않으므로, standby에도 primary와 같은 패키지를 설치해 둬야 합니다. 그렇지 않으면 앞에서 본 \u0026ldquo;카탈로그에는 있는데 파일은 없는\u0026rdquo; 상태가 되고, shared_preload_libraries에 있는 라이브러리라면 서버가 뜨지 않습니다.\n운영에서는: DROP EXTENSION은 데이터도 지운다 restoredb=# INSERT INTO demo_note VALUES (2, \u0026#39;important\u0026#39;); INSERT 0 1 restoredb=# DROP EXTENSION demo_ext; DROP EXTENSION restoredb=# SELECT to_regclass(\u0026#39;demo_note\u0026#39;); to_regclass ------------- (1 row) DROP EXTENSION은 pg_depend를 따라 멤버 객체를 모두 지웁니다. 사용자가 데이터를 넣던 설정 테이블도 멤버라서 경고 없이 데이터와 함께 사라졌습니다. extension을 지웠다 다시 설치해 문제를 해결하려 할 때는 먼저 \\dx+로 멤버에 테이블이 있는지 확인합니다.\n정리 extension은 control 파일, SQL 스크립트, 공유 라이브러리라는 파일과, pg_extension, pg_depend(deptype = 'e') 라는 카탈로그 행으로 이루어집니다. 파일은 서버에, 카탈로그는 DB에 있습니다. CREATE EXTENSION은 control 파일을 읽고, pg_extension에 행을 넣고, 스크립트의 자리표시자를 바꿔 한 트랜잭션으로 실행합니다. 스크립트가 만든 객체는 모두 pg_depend로 extension에 묶입니다. C 함수는 pg_proc의 probin(파일)과 prosrc(심볼)로 찾습니다. 로더는 .so를 dlopen()으로 매핑하고, magic block으로 ABI를 확인하고, _PG_init()을 부른 뒤 dlsym()으로 함수를 찾습니다. .so의 undefined 심볼은 postgres 실행 파일이 내보낸 심볼로 채워집니다. 라이브러리 로드는 프로세스 단위입니다. 보통은 backend마다 처음 쓸 때 올리고, shared_preload_libraries에 넣으면 postmaster가 한 번 올려 fork로 물려줍니다. 공유 메모리가 필요한 extension은 preload해야 합니다. hook은 서버의 함수 포인터 전역 변수이고, _PG_init()에서 이전 값을 저장하고 자기 함수로 바꿔 사슬을 만듭니다. 한 번 올린 라이브러리는 내릴 수 없습니다. 파일을 바꿔도 기존 프로세스는 옛 코드를 쓰고, preload한 라이브러리는 재시작해야 바뀝니다. 그 전에 ALTER EXTENSION UPDATE를 하면 옛 .so에서 새 심볼을 찾다가 실패할 수 있습니다. C 함수는 서버 프로세스 안에서 돌기 때문에 버그 하나가 모든 접속을 끊고 crash recovery를 일으킵니다. 참고 자료 소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)\nsrc/backend/commands/extension.c: CREATE EXTENSION, ALTER EXTENSION, control 파일, pg_get_loaded_modules() src/backend/utils/fmgr/dfmgr.c: 동적 로더(dlopen, magic block, _PG_init) src/backend/utils/fmgr/fmgr.c: C 함수 찾기와 호출 규약 src/include/fmgr.h: PG_MODULE_MAGIC, PG_MODULE_MAGIC_EXT, PG_FUNCTION_INFO_V1 src/backend/catalog/pg_proc.c: fmgr_c_validator() src/backend/catalog/pg_depend.c: recordDependencyOnCurrentExtension() src/backend/utils/init/miscinit.c: *_preload_libraries, shmem_request_hook contrib/pg_stat_statements/pg_stat_statements.c: preload와 hook을 쓰는 실제 extension PostgreSQL 18 공식 문서\nPackaging Related Objects into an Extension C-Language Functions Extension Building Infrastructure (PGXS) CREATE EXTENSION, ALTER EXTENSION Shared Library Preloading 운영체제\ndlopen(3), mmap(2), fork(2), proc_pid_maps(5) 매뉴얼 페이지 ","permalink":"https://jinlog.dev/posts/postgresql/extension-internals/","summary":"control 파일, SQL 스크립트, 공유 라이브러리가 CREATE EXTENSION과 dlopen을 거쳐 서버 프로세스 안에서 돌기까지","title":"PostgreSQL extension은 어떻게 동작하는가"},{"content":"이 블로그에서는 PostgreSQL과 MongoDB가 내부에서 어떻게 동작하는지, 그리고 그 구조가 실제 운영에서 어떤 현상으로 나타나는지 정리합니다.\n두 연재 PostgreSQL 인터널은 10편입니다. 프로세스와 메모리 구조에서 시작해 데이터 저장 구조, MVCC, VACUUM, WAL, 복제, 쿼리 처리까지 이어집니다. PostgreSQL 18을 기준으로 씁니다.\nMongoDB 인터널은 8편입니다. WiredTiger 스토리지 엔진에서 시작해 체크포인트와 저널, MVCC, 인덱스와 쿼리 플래너, 복제와 선출, 샤딩, Read/Write Concern까지 다룹니다. MongoDB 8.0을 기준으로 씁니다.\n읽는 순서 두 연재 모두 뒤의 글이 앞의 글에서 다룬 개념 위에 쌓이도록 순서를 잡았습니다. 처음이라면 각 연재의 1편부터 차례대로 읽는 것을 권합니다. 글 화면 왼쪽의 연재 목차에서 지금 몇 번째 글인지 확인할 수 있고, 아직 공개하지 않은 글은 \u0026ldquo;준비 중\u0026quot;으로 표시됩니다.\n글 구성 모든 글은 같은 순서로 씁니다.\n동작 원리: 내부 구조가 어떻게 생겼고 어떻게 움직이는지 직접 확인해 보기: 시스템 뷰나 도구로 그 구조를 직접 들여다보는 방법 운영에서는 이렇게 나타납니다: 그 구조가 장애나 성능 문제로 드러나는 모습 ","permalink":"https://jinlog.dev/posts/hello/","summary":"이 블로그에서 다룰 두 연재와 글을 읽는 방법","title":"블로그를 시작합니다"},{"content":"Jin\u0026rsquo;s 블로그(이하 \u0026ldquo;이 블로그\u0026rdquo;, https://jinlog.dev)는 방문자의 개인정보를 소중히 여기며, 어떤 정보가 어디서 처리되는지 아래와 같이 알려 드립니다.\n1. 이 블로그가 직접 수집하는 정보 이 블로그는 회원가입, 댓글, 문의 양식 같은 기능이 없어 방문자의 이름, 이메일 등 개인정보를 직접 수집하거나 보관하지 않습니다.\n다만 화면 설정을 기억하기 위해 브라우저의 로컬 저장소(localStorage)에 아래 값을 저장합니다. 이 값은 방문자의 기기에만 남고 어디로도 전송되지 않으며, 브라우저의 사이트 데이터 삭제로 언제든 지울 수 있습니다.\n이름 용도 pref-theme 다크/라이트 테마 선택 rail-series 연재 목차를 접었는지 여부 2. 광고 (Google AdSense) 이 블로그는 Google AdSense를 통해 광고를 게재합니다.\nGoogle을 포함한 제3자 광고 공급업체는 쿠키를 사용해 방문자가 이 블로그나 다른 웹사이트를 방문한 기록을 바탕으로 광고를 게재합니다. Google은 광고 쿠키를 사용해 방문자가 이 블로그나 인터넷의 다른 사이트를 방문한 기록을 기반으로 방문자에게 맞춤 광고를 제공합니다. 방문자는 Google 광고 설정에서 맞춤 광고를 해제할 수 있습니다. 제3자 공급업체의 맞춤 광고용 쿠키는 www.aboutads.info에서 해제할 수 있습니다. Google이 이 과정에서 정보를 어떻게 사용하는지는 Google 파트너 사이트 정보 사용 방식에서 확인할 수 있습니다. 유럽 경제 지역(EEA), 영국, 스위스에서 방문한 경우 광고 쿠키를 사용하기 전에 Google의 동의 안내가 표시되며, 동의 여부는 언제든 바꿀 수 있습니다.\n3. 페이지를 불러올 때 거치는 외부 서비스 페이지를 표시하는 과정에서 방문자의 브라우저는 아래 서비스에 직접 접속합니다. 이때 IP 주소, 브라우저 정보(User-Agent) 같은 접속 정보가 각 서비스에 전달될 수 있으며, 처리 방식은 각 서비스의 방침을 따릅니다.\n서비스 용도 방침 GitHub Pages 블로그 호스팅 GitHub 개인정보 처리방침 jsDelivr 글꼴·아이콘·다이어그램 스크립트 jsDelivr 개인정보 처리방침 Google Fonts 코드 글꼴 Google Fonts 개인정보 FAQ Google AdSense 광고 게재 Google 개인정보처리방침 4. 쿠키를 거부하는 방법 브라우저 설정에서 쿠키 저장을 거부하거나 저장된 쿠키를 삭제할 수 있습니다. 쿠키를 거부해도 글을 읽는 데에는 지장이 없으며, 광고가 방문자 관심사와 무관하게 표시될 수 있습니다.\n5. 문의 개인정보 처리에 관한 문의는 아래 메일로 보내 주세요.\n운영자: Jin 메일: js.heo7747@gmail.com 6. 방침의 변경 이 방침이 바뀌면 이 페이지에 변경 내용과 시행일을 반영합니다.\n시행일: 2026년 9월 26일 ","permalink":"https://jinlog.dev/privacy/","summary":"\u003cp\u003eJin\u0026rsquo;s 블로그(이하 \u0026ldquo;이 블로그\u0026rdquo;, \u003ccode\u003ehttps://jinlog.dev\u003c/code\u003e)는 방문자의 개인정보를 소중히 여기며, 어떤 정보가 어디서 처리되는지 아래와 같이 알려 드립니다.\u003c/p\u003e","title":"개인정보처리방침"},{"content":"PostgreSQL과 MongoDB를 운영하는 DBA입니다.\n이 블로그에서는 데이터베이스가 내부에서 어떻게 동작하는지, 그리고 그 구조가 실제 운영에서 어떤 현상으로 나타나는지를 기록합니다.\n","permalink":"https://jinlog.dev/about/","summary":"\u003cp\u003ePostgreSQL과 MongoDB를 운영하는 DBA입니다.\u003c/p\u003e\n\u003cp\u003e이 블로그에서는 데이터베이스가 내부에서 어떻게 동작하는지, 그리고 그 구조가 실제 운영에서 어떤 현상으로 나타나는지를 기록합니다.\u003c/p\u003e","title":"소개"}]