개요
1편에서 PostgreSQL이 프로세스 여러 개로 동작하고, 그 프로세스들이 공유 메모리라는 작업대를 함께 쓴다는 것을 봤습니다. 이번 글은 그 작업대 위에 무엇이 놓여 있는지, 그리고 backend 프로세스가 자기만 쓰는 메모리에는 무엇이 있는지를 살펴봅니다.
이 글에서 답할 질문은 다음과 같습니다.
- shared 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에서 빌드해 실행한 결과입니다.
한눈에 보기
PostgreSQL의 메모리는 크게 두 종류입니다.
- 공유 메모리: postmaster가 기동할 때 한 번 만들고 모든 프로세스가 같이 씁니다. 이 메인 영역은 크기가 고정이라 운영 중에는 늘거나 줄지 않습니다(병렬 쿼리처럼 필요할 때만 잡는 동적 공유 메모리는 따로 있습니다). 기본 설정에서 약 150MB이고, 그 대부분이 shared buffers(128MB)입니다.
- backend 개인 메모리: 각 backend가 필요할 때 운영체제에서 받아 쓰고, 쿼리나 세션이 끝나면 돌려줍니다.
work_mem,temp_buffers처럼 “한도"를 정하는 설정만 있고, 실제 사용량은 하는 일에 따라 달라집니다.
메모리 관련 기본 설정
$ 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 ('shared_buffers', 'wal_buffers', 'work_mem', 'hash_mem_multiplier',
postgres-# 'maintenance_work_mem', 'temp_buffers', 'block_size', 'shared_memory_size')
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 "work_mem" 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'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입니다.
shared buffers: 테이블 페이지를 담는 캐시
PostgreSQL은 테이블과 인덱스를 8kB 페이지 단위로 읽고 씁니다(block_size = 8192). 디스크에서 읽은 페이지는 shared buffers의 한 칸(버퍼)에 올라가고, 다른 backend도 같은 페이지가 필요하면 디스크 대신 이 칸을 읽습니다. 기본값 128MB는 8kB 칸 16384개입니다.
shared buffers는 세 부분으로 이루어져 있습니다.
- Buffer Blocks: 8kB 칸 16384개가 이어진 큰 배열. 실제 페이지 내용이 여기 들어갑니다.
- Buffer Descriptors: 칸마다 하나씩 있는 관리 정보(
BufferDesc). CPU 캐시 라인에 맞춰 64바이트로 채워 둡니다. 이 칸에 어느 파일의 몇 번째 블록이 들어 있는지를 나타내는 태그(BufferTag: 테이블스페이스, DB, 파일 번호, fork, 블록 번호)와 상태 값을 담습니다. - 버퍼 매핑 해시 테이블: “이 블록이 몇 번 칸에 있나"를 찾는 해시 테이블(
BufTableLookup()).
descriptor의 상태 값은 32비트 정수 하나에 여러 정보를 담아 둡니다(buf_internals.h). 락 없이 원자적 연산 한 번으로 바꿀 수 있게 하려는 설계입니다.
| 비트 | 이름 | 뜻 |
|---|---|---|
| 18비트 | refcount (pin 수) | 지금 이 칸을 쓰고 있는 프로세스 수. 0이 아니면 내보낼 수 없습니다. |
| 4비트 | usage count | 최근에 얼마나 자주 쓰였는지. 0에서 5까지 올라갑니다. |
| 10비트 | 플래그 | BM_DIRTY(디스크보다 새 내용), BM_VALID, BM_IO_IN_PROGRESS 등 |
공유 메모리는 무엇으로 채워져 있나
pg_shmem_allocations는 공유 메모리를 이름별로 얼마나 잡았는지 보여 줍니다.
postgres=# 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
<anonymous> | 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가 쓰는 공간입니다. 이름이 빈 줄은 아직 쓰지 않은 여유 공간이고, <anonymous>는 이름을 붙이지 않고 잡은 영역들의 합입니다.
테이블을 읽으면 shared buffers에 페이지가 올라온다
pg_buffercache 확장을 설치하고, 14MB(1728페이지) 테이블을 만들어 이 테이블의 페이지를 캐시에서 내보낸 뒤 한 번 읽어 봅니다. 캐시를 비우는 데는 PG18의 pg_buffercache_evict_relation()을 씁니다. 서버를 재시작하지 않고 특정 테이블의 페이지만 shared buffers에서 내보냅니다.
postgres=# CREATE EXTENSION pg_buffercache;
postgres=# CREATE TABLE small AS SELECT g AS id, repeat('x', 100) AS pad FROM generate_series(1, 100000) g;
SELECT 100000
postgres=# SELECT pg_size_pretty(pg_relation_size('small')) AS size, pg_relation_size('small') / 8192 AS pages;
size | pages
-------+-------
14 MB | 1728
(1 row)
postgres=# SELECT pg_buffercache_evict_relation('small');
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('small');
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이 “이 행을 만든 트랜잭션은 커밋되었다"는 표시(hint bit)를 페이지에 적어 두기 때문입니다. 4편(MVCC)에서 자세히 다룹니다.
페이지 하나를 읽는 과정
backend가 어떤 블록을 읽으려 할 때의 흐름입니다.
hit. 매핑 해시에서 블록을 찾으면 그 칸을 pin(refcount +1)하고 usage count를 1 올립니다. 이미 5면 그대로 둡니다(PinBuffer()). 디스크 I/O가 전혀 없습니다.
miss. 없으면 새 페이지를 담을 칸을 구해야 합니다(StrategyGetBuffer()). 아래에서 볼 ring buffer를 쓰는 중이면 ring에서 먼저 고르고, 아직 한 번도 쓰지 않은 칸의 목록(freelist)에 남은 칸이 있으면 그 칸을 씁니다. 둘 다 없을 때 clock sweep을 돌립니다(freelist.c). 서버를 띄운 직후에는 빈 칸이 많아 대부분 freelist에서 해결되고, clock sweep은 캐시가 가득 찬 뒤에 본격적으로 일합니다.
- 시계 바늘처럼 칸을 하나씩 돌아가며 봅니다.
- pin된 칸은 건너뜁니다.
- usage count가 0이 아니면 1 깎고 넘어갑니다.
- usage count가 0인 칸을 만나면 그 칸을 씁니다.
자주 쓰이는 페이지는 usage count가 높아서 바늘이 여러 바퀴 돌아야 0이 됩니다. 그래서 오래 살아남습니다. 한 번 쓰고 만 페이지는 금방 0이 되어 먼저 밀려납니다. 정확한 LRU(가장 오래 안 쓴 것을 내보내기)는 아니지만 비슷한 효과를 냅니다. LRU처럼 hit할 때마다 전역 목록의 순서를 고칠 필요가 없어서, 여러 backend가 동시에 읽어도 락 경쟁이 적습니다.
처음 읽으면 read, 다시 읽으면 hit
PG18부터는 EXPLAIN ANALYZE가 BUFFERS를 기본으로 보여 줍니다(explain_state.c).
postgres=# SELECT pg_buffercache_evict_relation('small');
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
-> Gather (actual rows=2.00 loops=1)
Workers Planned: 1
Workers Launched: 1
Buffers: shared read=1728
-> Partial Aggregate (actual rows=1.00 loops=2)
Buffers: shared read=1728
-> 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
-> Gather (actual rows=2.00 loops=1)
Workers Planned: 1
Workers Launched: 1
Buffers: shared hit=1728
-> Partial Aggregate (actual rows=1.00 loops=2)
Buffers: shared hit=1728
-> 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에서 바로 찾은 페이지 수입니다.
usage count: 자주 읽는 페이지일수록 오래 살아남는다
postgres=# SELECT pg_buffercache_evict_relation('small');
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('small') 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('small') GROUP BY 1 ORDER BY 1;
usagecount | count
------------+-------
5 | 1728
(1 row)
캐시에 처음 올라온 페이지는 usage count가 1입니다. 그 뒤로 다섯 번 더 읽었으니 제한이 없다면 6이어야 하지만, 5에서 멈췄습니다. 최댓값 BM_MAX_USAGE_COUNT = 5 그대로입니다. clock sweep의 바늘이 이 칸들을 내보내려면 다섯 바퀴를 더 돌아야 합니다.
칸을 비우고 파일에서 읽어 오기
고른 칸이 dirty(디스크보다 새 내용)면 먼저 디스크에 써야 합니다. 이때 규칙이 하나 있습니다. 그 페이지의 변경을 기록한 WAL이 먼저 디스크에 있어야 합니다(FlushBuffer()가 XLogFlush()를 먼저 부릅니다). 이 규칙 덕분에 장애가 나도 WAL로 데이터 파일을 복구할 수 있습니다. 7편에서 다시 다룹니다.
마지막으로 칸의 태그를 새 블록으로 바꾸고 파일에서 읽어 옵니다. PG18에서 순차 스캔처럼 여러 블록을 미리 읽어 두는 경로는 1편에서 본 io worker가 대신 읽습니다. 반면 인덱스로 행 하나를 찾을 때처럼 블록 하나가 당장 필요한 경우에는, 비동기로 넘겨 봐야 기다리기만 하므로 backend가 직접 읽습니다(bufmgr.c).
read는 디스크에서 읽었을까, 운영체제 캐시에서 읽었을까
postgres=# SET track_io_timing = on;
SET
postgres=# SET max_parallel_workers_per_gather = 0;
SET
postgres=# SELECT pg_buffercache_evict_relation('small');
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
-> 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 '^(MemTotal|Cached):' /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)에 남아 있었을 가능성이 큽니다.
PostgreSQL은 파일을 운영체제를 통해 읽습니다(direct I/O를 쓰지 않는 기본 설정). 그래서 같은 페이지가 shared buffers와 운영체제 페이지 캐시에 두 번 캐시될 수 있습니다. PostgreSQL 입장의 “read"가 곧 느린 디스크 읽기는 아니라는 점을 기억해 두면 됩니다.
수정된 페이지(dirty)는 체크포인트가 디스크로 내보낸다
postgres=# CHECKPOINT;
CHECKPOINT
postgres=# SELECT buffers_dirty FROM pg_buffercache_summary();
buffers_dirty
---------------
0
(1 row)
postgres=# UPDATE small SET pad = repeat('y', 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('small');
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 늘었습니다.
운영에서는: shared_buffers를 무작정 키우면 안 되는 이유
shared buffers가 클수록 hit가 늘어나는 것은 맞습니다. 하지만 앞에서 본 것처럼 PostgreSQL은 운영체제 페이지 캐시 위에서 동작합니다. shared buffers를 너무 크게 잡으면 운영체제가 쓸 캐시가 줄어들고, 같은 페이지를 두 곳에 들고 있는 낭비도 커집니다. 또 shared buffers는 기동할 때 한 번에 잡는 고정 영역이라, 크게 잡을수록 기동할 때 확보할 메모리와 체크포인트 때 내보낼 dirty 페이지 양이 늘어납니다. PostgreSQL 문서는 전용 서버에서 메모리의 25% 정도에서 시작하고, 40%를 넘기면 이득이 적다고 안내합니다(shared_buffers). 실제 값은 pg_buffercache와 hit 비율을 보면서 조정합니다.
ring buffer: 큰 테이블 스캔이 캐시를 망치지 않게
shared buffers가 128MB인데 1GB짜리 테이블을 처음부터 끝까지 한 번 읽으면 어떻게 될까요? 그대로라면 자주 쓰던 페이지까지 모두 밀려납니다. 이를 막으려고 PostgreSQL은 테이블이 shared buffers의 1/4보다 크면(heapam.c) 순차 스캔에 ring buffer라는 작은 전용 영역만 돌려 씁니다. 스캔이 칸 몇 개만 계속 재사용하므로, 나머지 캐시는 그대로 남습니다.
ring의 크기는 작업 종류에 따라 다릅니다(GetAccessStrategy()).
| 작업 | ring 크기 |
|---|---|
큰 테이블 순차 스캔 (BAS_BULKREAD) | 256kB에서 시작해 I/O 동시성만큼 늘림. 단, ‘backend 하나의 pin 한도’와 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))를 넘지 못합니다. 아래 실습에서 이 값을 직접 계산해 맞춰 봅니다.
또 하나, ring으로 읽은 페이지는 usage count를 1보다 올리지 않습니다. 한 번 훑고 지나가는 페이지가 캐시에 오래 눌러앉지 않게 하려는 것입니다.
큰 테이블 순차 스캔은 ring buffer만 쓴다
81MB(10368페이지)짜리 테이블을 만듭니다. shared buffers의 1/4인 4096페이지보다 크므로 ring buffer 대상입니다.
postgres=# CREATE TABLE big AS SELECT g AS id, repeat('x', 100) AS pad FROM generate_series(1, 600000) g;
SELECT 600000
postgres=# SELECT pg_size_pretty(pg_relation_size('big')) AS size,
postgres-# pg_relation_size('big') / 8192 AS pages,
postgres-# current_setting('shared_buffers') AS shared_buffers,
postgres-# (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers') / 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('big');
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
-> Gather (actual rows=3.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared read=10368 dirtied=10345 written=10086
-> Partial Aggregate (actual rows=1.00 loops=3)
Buffers: shared read=10368 dirtied=10345 written=10086
-> 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('big');
buffers_of_big
----------------
282
(1 row)
테이블 전체(10368페이지)를 읽었는데 끝나고 남은 이 테이블의 페이지는 282개뿐입니다. 이 숫자를 소스로 계산해 보겠습니다.
postgres=# SELECT current_setting('max_connections')::int
postgres-# + current_setting('autovacuum_worker_slots')::int
postgres-# + current_setting('max_worker_processes')::int
postgres-# + current_setting('max_wal_senders')::int + 2 AS max_backends,
postgres-# (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers')
postgres-# / (current_setting('max_connections')::int
postgres-# + current_setting('autovacuum_worker_slots')::int
postgres-# + current_setting('max_worker_processes')::int
postgres-# + current_setting('max_wal_senders')::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_limit128kB = 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가 유난히 느린 이유 중 하나입니다.
운영에서는: 대량 적재 직후 첫 조회가 느리다
shared buffers에 페이지가 올라오는 실습과 위 실습에서 본 것처럼, 새로 쓴 행을 처음 읽는 쿼리는 hint bit를 적느라 페이지를 dirty로 만들고, 큰 테이블이면 ring buffer 때문에 그 페이지를 곧바로 디스크에 씁니다. 대량 적재 뒤에 VACUUM(또는 VACUUM (FREEZE))을 한 번 돌려 두면 이 작업이 미리 끝나서, 사용자 쿼리가 그 비용을 떠안지 않습니다. VACUUM은 5편에서 다룹니다.
backend 개인 메모리: work_mem과 메모리 컨텍스트
backend는 쿼리를 처리하면서 개인 메모리를 씁니다. PostgreSQL은 이 메모리를 메모리 컨텍스트라는 단위로 관리합니다(mmgr/README). 컨텍스트는 “이 쿼리를 실행하는 동안”, “이 트랜잭션 동안”, “세션 내내"처럼 수명별로 나뉘어 있고, 수명이 끝나면 컨텍스트째 한 번에 해제합니다. 메모리를 하나하나 free()하지 않아도 새지 않게 하는 장치입니다.
backend의 메모리 컨텍스트
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가 있음)의 실행 상태와 정렬용 메모리로, 쿼리가 끝나면 컨텍스트째 해제됩니다.
work_mem: 정렬과 해시
backend 개인 메모리에서 가장 큰 영향을 주는 설정이 work_mem입니다.
- 정렬(
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 = '4MB';
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
-> 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 = '256MB';
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
-> Seq Scan on big (actual rows=600000.00 loops=1)
Buffers: shared hit=376 read=9992
(6 rows)
$ grep 'temporary file' /home/postgres/server.log | tail -3
2026-09-26 10:07:55.017 UTC [137] LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp137.0", 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)가 큰 이유는 메모리 안에서는 행마다 포인터와 관리 정보가 붙기 때문입니다.
해시 테이블은 work_mem × hash_mem_multiplier까지 쓴다
postgres=# SET max_parallel_workers_per_gather = 0;
SET
postgres=# SET enable_mergejoin = off;
SET
postgres=# SET work_mem = '1MB';
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
-> 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
-> Seq Scan on big b1 (actual rows=600000.00 loops=1)
Buffers: shared hit=564 read=9804
-> Hash (actual rows=600000.00 loops=1)
Buckets: 65536 Batches: 64 Memory Usage: 840kB
Buffers: shared hit=473 read=9895, temp written=1700
-> 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 = '64MB';
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
-> Hash Join (actual rows=600000.00 loops=1)
Hash Cond: (b1.id = b2.id)
Buffers: shared hit=1413 read=19323
-> Seq Scan on big b1 (actual rows=600000.00 loops=1)
Buffers: shared hit=752 read=9616
-> Hash (actual rows=600000.00 loops=1)
Buckets: 2097152 Batches: 1 Memory Usage: 37478kB
Buffers: shared hit=661 read=9707
-> 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가 사라졌습니다.
운영에서는: 정렬이 디스크로 넘어가는 순간 찾기
log_temp_files를 켜 두면 임시 파일을 만든 쿼리가 서버 로그에 남습니다. 0이면 모든 임시 파일, 예를 들어 10MB로 두면 10MB 이상인 것만 남습니다.
2026-09-26 10:07:55.017 UTC [137] LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp137.0", size 69197824
EXPLAIN (ANALYZE)에서 Sort Method: external merge나 Batches:가 1보다 큰 해시가 보이면 work_mem이 부족하다는 신호입니다. 이런 쿼리만 골라 세션 단위로 work_mem을 올리면, 전역 메모리 위험 없이 성능을 높일 수 있습니다.
work_mem은 노드마다 따로 잡힌다
주의할 점은 work_mem이 쿼리 하나의 한도가 아니라 정렬이나 해시 노드 하나의 한도라는 것입니다. 정렬 두 개와 해시 하나가 있는 쿼리는 work_mem의 몇 배를 쓸 수 있고, 그런 쿼리가 동시에 여러 개 돌면 곱절로 늘어납니다.
merge join의 정렬 두 개가 각각 work_mem을 쓴다
hash join을 끄고 같은 조인을 merge join으로 실행하면, 양쪽 입력을 각각 정렬합니다.
postgres=# SET max_parallel_workers_per_gather = 0;
SET
postgres=# SET enable_hashjoin = off;
SET
postgres=# SET work_mem = '32MB';
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
-> Merge Join (actual rows=600000.00 loops=1)
Merge Cond: (b1.id = b2.id)
Buffers: shared hit=1790 read=18950
-> Sort (actual rows=600000.00 loops=1)
Sort Key: b1.id
Sort Method: quicksort Memory: 24577kB
Buffers: shared hit=850 read=9522
-> Seq Scan on big b1 (actual rows=600000.00 loops=1)
Buffers: shared hit=846 read=9522
-> Materialize (actual rows=600000.00 loops=1)
Storage: Memory Maximum Storage: 17kB
Buffers: shared hit=940 read=9428
-> Sort (actual rows=600000.00 loops=1)
Sort Key: b2.id
Sort Method: quicksort Memory: 24577kB
Buffers: shared hit=940 read=9428
-> 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의 몇 배까지 갈 수 있습니다.
운영에서는: work_mem이 메모리 폭증을 부르는 경우
위 실습에서 본 것처럼 work_mem은 정렬이나 해시 노드 하나의 한도입니다. 대략적인 최악의 사용량은 다음과 같습니다.
동시 실행 쿼리 수 × 쿼리당 정렬, 해시 노드 수 ×
work_mem(해시는 ×hash_mem_multiplier)
work_mem을 전역으로 256MB로 올려 두고 커넥션 200개가 복잡한 리포트 쿼리를 동시에 돌리면, 이론상 수십 GB가 필요해질 수 있습니다. 메모리가 모자라면 리눅스 OOM killer가 backend를 죽이고, 1편에서 본 것처럼 서버 전체가 재시작합니다. 그래서 전역 값은 보수적으로 두고, 큰 정렬이 필요한 배치나 리포트 세션에서만 SET work_mem으로 올리는 방식을 권합니다.
temp_buffers: 임시 테이블 전용 캐시
temp_buffers는 임시 테이블(CREATE TEMP TABLE) 전용 캐시입니다. 임시 테이블은 그 세션만 보므로 공유 메모리를 쓸 이유가 없고, backend 개인 메모리의 local buffer에 올라갑니다.
임시 테이블은 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
-> 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('tmp');
shared_buffers_used_by_tmp
----------------------------
0
(1 row)
Buffers: local hit=448로, 임시 테이블 페이지는 shared가 아니라 local 버퍼에서 읽었습니다. shared buffers에서 이 테이블의 페이지를 찾아보면 0개입니다.
WAL buffers
테이블을 바꾸는 모든 작업은 WAL 레코드를 남깁니다. 레코드는 바로 디스크에 쓰지 않고 공유 메모리의 WAL buffers에 먼저 모읍니다. 커밋할 때, 또는 walwriter가 주기적으로 깨어날 때 디스크로 내보냅니다. 여러 트랜잭션의 WAL을 모아 한 번에 쓰면 디스크 쓰기 횟수가 줄어듭니다.
크기의 기본값은 -1이고, 이 경우 XLOGChooseNumBuffers()가 shared_buffers의 1/32로 정합니다. 최소는 8페이지(64kB), 최대는 WAL 세그먼트 하나(기본 16MB)입니다. 기본 설정(shared_buffers 128MB)에서는 4MB가 됩니다.
WAL buffers가 가득 차서 빈자리가 없으면, WAL을 쓰려던 backend가 직접 앞쪽 WAL을 디스크에 써서 자리를 만들어야 합니다. 이 횟수가 pg_stat_wal.wal_buffers_full입니다.
WAL buffers가 꽉 차면
WAL buffers 기본값(4MB)과 최소에 가까운 64kB에서 같은 대량 INSERT(약 46MB의 WAL)를 실행해 비교합니다.
postgres=# SHOW wal_buffers;
wal_buffers
-------------
4MB
(1 row)
postgres=# SELECT pg_stat_reset_shared('wal');
pg_stat_reset_shared
----------------------
(1 row)
postgres=# INSERT INTO big SELECT g, repeat('z', 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 = '64kB';
$ pg_ctl -D $PGDATA -l /home/postgres/server.log restart -m fast > /dev/null
postgres=# SHOW wal_buffers;
wal_buffers
-------------
64kB
(1 row)
postgres=# SELECT pg_stat_reset_shared('wal');
pg_stat_reset_shared
----------------------
(1 row)
postgres=# INSERT INTO big SELECT g, repeat('z', 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편에서 이어 가겠습니다.
정리
- 공유 메모리의 대부분은 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를 살펴봅니다.
참고 자료
소스 코드 (REL_18_STABLE 커밋 39a0db1 기준)
- src/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 공식 문서