개요

1편에서 PostgreSQL이 프로세스 여러 개로 동작하고, 그 프로세스들이 공유 메모리라는 작업대를 함께 쓴다는 것을 봤습니다. 이번 글은 그 작업대 위에 무엇이 놓여 있는지, 그리고 backend 프로세스가 자기만 쓰는 메모리에는 무엇이 있는지를 살펴봅니다.

이 글에서 답할 질문은 다음과 같습니다.

  • shared buffers는 어떤 구조이고, 꽉 차면 어떤 페이지를 내보내는가
  • 큰 테이블을 한 번 훑으면 캐시가 다 밀려나지 않을까
  • work_mem은 무엇에 쓰이고, 넘치면 어떻게 되는가
  • WAL buffers는 왜 따로 있는가
메모리위치기본값쓰임
shared_buffers공유128MB테이블과 인덱스 페이지 캐시
wal_buffers공유shared_buffers의 1/32 (최대 16MB)디스크에 쓰기 전의 WAL 레코드
work_membackend 개인4MB정렬, 해시 테이블 하나당 사용량
hash_mem_multiplierbackend 개인2.0해시 테이블은 work_mem의 몇 배까지 쓸지
maintenance_work_membackend 개인64MBVACUUM, CREATE INDEX 같은 유지보수 작업
temp_buffersbackend 개인8MB임시 테이블 전용 페이지 캐시

기준 버전: PostgreSQL 18, REL_18_STABLE 커밋 39a0db1. 소스 링크는 모두 이 커밋에 고정했고, 실습 출력은 이 소스를 Rocky Linux 9.8에서 빌드해 실행한 결과입니다.

한눈에 보기

PostgreSQL 18 메모리 구조. 가운데가 모든 프로세스가 함께 쓰는 공유 메모리, 왼쪽이 backend마다 따로 있는 개인 메모리입니다. 새 창에서 크게 보기

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는 세 부분으로 이루어져 있습니다.

  1. Buffer Blocks: 8kB 칸 16384개가 이어진 큰 배열. 실제 페이지 내용이 여기 들어갑니다.
  2. Buffer Descriptors: 칸마다 하나씩 있는 관리 정보(BufferDesc). CPU 캐시 라인에 맞춰 64바이트로 채워 둡니다. 이 칸에 어느 파일의 몇 번째 블록이 들어 있는지를 나타내는 태그(BufferTag: 테이블스페이스, DB, 파일 번호, fork, 블록 번호)와 상태 값을 담습니다.
  3. 버퍼 매핑 해시 테이블: “이 블록이 몇 번 칸에 있나"를 찾는 해시 테이블(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하고 바로 쓰고, 없으면(miss) 비울 칸을 골라 디스크에서 읽어 옵니다. 새 창에서 크게 보기

hit. 매핑 해시에서 블록을 찾으면 그 칸을 pin(refcount +1)하고 usage count를 1 올립니다. 이미 5면 그대로 둡니다(PinBuffer()). 디스크 I/O가 전혀 없습니다.

miss. 없으면 새 페이지를 담을 칸을 구해야 합니다(StrategyGetBuffer()). 아래에서 볼 ring buffer를 쓰는 중이면 ring에서 먼저 고르고, 아직 한 번도 쓰지 않은 칸의 목록(freelist)에 남은 칸이 있으면 그 칸을 씁니다. 둘 다 없을 때 clock sweep을 돌립니다(freelist.c). 서버를 띄운 직후에는 빈 칸이 많아 대부분 freelist에서 해결되고, clock sweep은 캐시가 가득 찬 뒤에 본격적으로 일합니다.

  1. 시계 바늘처럼 칸을 하나씩 돌아가며 봅니다.
  2. pin된 칸은 건너뜁니다.
  3. usage count가 0이 아니면 1 깎고 넘어갑니다.
  4. 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_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가 유난히 느린 이유 중 하나입니다.

운영에서는: 대량 적재 직후 첫 조회가 느리다

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 기준)

PostgreSQL 18 공식 문서