콘텐츠로 이동

(KO) PostgreSQL Large Objects — pg_largeobject 청크 저장과 lo API

목차

관계형 테이블은 물리적으로 고정 크기 디스크 페이지의 배열이고, 행은 반드시 하나의 페이지 안에 들어가야 한다. PostgreSQL의 기본 페이지 크기는 BLCKSZ = 8 KB다. Database System Concepts(Silberschatz 7판, 13장 “Data Storage Structures”)와 Database Internals(Petrov, “File Formats”) 모두 같은 지점을 지적한다. 단일 속성값이 페이지 크기에 근접하거나 초과하면, 많은 소형 튜플을 전제로 설계된 슬롯 페이지(slotted-page) 레이아웃이 무너진다는 것이다. 50 MB 이미지, PDF, 동영상 클립을 저장하려면 out-of-line 표현이 필요하다. 행에는 작은 참조값만 남기고 실제 바이트는 페이지 단위로 분해해 다른 곳에 둔다.

“다른 곳”에 대한 고전적인 두 가지 답은 대형 값을 컬럼으로 주소화할지 객체로 주소화할지에 따라 갈린다.

  1. 인라인 오버플로 / 대형 값 속성화. 값은 논리적으로 행의 컬럼으로 남는다(bytea, text, BLOB, CLOB, VARBINARY(MAX)). 엔진이 투명하게 바이트를 오버플로 구조에 분산하고 튜플에 포인터를 남긴다. 애플리케이션은 분해를 보지 못한다. PostgreSQL에서는 TOAST(postgres-toast.md 참조)가 이 역할을 한다.

  2. 객체 주소 지정 대형 저장. 대형 값은 컬럼이 아니다. 자체 정체성(OID 또는 “로케이터”)을 가진 독립 객체로, 명시적 호출로 생성·삭제하고 파일 형태의 탐색 가능한 바이트 스트림 APIopen, read, write, lseek, truncate, close — 로 접근한다. 클라이언트는 수 GB 객체를 메모리에 전부 올리지 않고 제한된 버퍼로 스트리밍할 수 있다. SQL 표준의 BLOB/CLOB 로케이터 모델과 JDBC Blob/Clob 인터페이스가 이 방식의 표준화 형태이고, Oracle의 BFILE/LOB 로케이터와 PostgreSQL의 large objects가 구체적인 사례다.

두 모델의 차이는 접근 단위에 있다. 컬럼 모델은 값이 값일 뿐이므로 애플리케이션에 단순하지만, 1바이트를 읽으려면 전체 datum을 가져와 압축 해제해야 하고 1바이트를 바꾸려면 새 행 버전으로 전체를 다시 써야 한다. 객체 모델은 lo_open 디스크립터를 열고 lo_close로 닫아야 하는 API 절차가 추가되지만 임의 접근을 얻는다. 4 GB 객체의 1 GB 오프셋으로 lseek해 나머지를 건드리지 않고 4 KB만 읽을 수 있다. 이 능력이 객체 모델이 bytea 옆에 공존하는 이유다.

PostgreSQL의 large object 기능은 원래 POSTGRES 연구 시스템으로 거슬러 올라간다. large_object.h 헤더 주석에 계보가 남아 있다. “POSTGRES 4.2 supported zillions of large objects (internal, external, jaquith, inversion). Now we only support inversion.” 유일한 생존 방식인 inversion 방식이 이 문서의 주제다. Stonebraker와 동료들의 POSTGRES 스토리지 연구에서 비롯된 핵심 아이디어는 경제적이다. 대형 객체를 위한 새 스토리지 관리자를 만들지 않는다. 대신 객체의 바이트를 고정 크기 청크로 잘라 일반 테이블의 일반 행으로 저장하고, 기존의 heap + B-tree + MVCC + WAL 기계가 내구성·동시성·트랜잭션 롤백을 무료로 제공하게 한다. “Inversion”이라는 이름은 관계의 역전을 뜻한다. 파일 시스템이 데이터베이스 파일을 담는 대신, 데이터베이스가 파일 시스템 객체를 담는다.

이 선택은 문서 전체를 관통하는 결과를 낳는다. large objects는 heap의 모든 속성을 물려받는다. MVCC 버전 관리(쓰기가 새 튜플과 죽은 구 튜플을 만든다), WAL 로깅(충돌 복구가 청크 삽입/갱신을 재생한다), 자동 vacuum(죽은 청크 튜플을 회수한다), 트랜잭션 중단 시 롤백이 모두 해당된다. 대가는 같은 화폐로 치른다. 쓰기 증폭(1바이트 변경이 2 KB 청크 튜플 전체를 다시 쓴다), MVCC 블로트(덮어쓰기마다 죽은 청크가 남는다), 와이어 외부 스트리밍 없음(바이트가 TCP 파일 전송 같은 사이드 채널이 아니라 SQL 함수 호출 프로토콜을 통과한다)이 그 대가다.

두 설계 질문이 이어지며 2–4절의 뼈대를 이룬다.

  1. 청크 크기는 얼마이고, 왜 그 크기인가? 쓰기 증폭, 객체당 카탈로그 튜플 수, 청크 자체의 압축 여부를 결정한다.
  2. 탐색 가능하고 희소하며 트랜잭션 안전한 바이트 스트림을 어떻게 (loid, pageno) 키 카탈로그 테이블 위에 구현하는가? 특히 읽기가 hole을 어떻게 합성하고, 쓰기가 어떻게 읽기-수정-쓰기를 수행하며, 클라이언트 파일 디스크립터와 스냅샷이 트랜잭션에 어떻게 묶이는가.

PostgreSQL 구체론 앞에, 객체 주소 지정 대형 저장 서브시스템이 반드시 제공해야 할 구성 요소를 정리한다. 그러면 PostgreSQL의 심볼들이 공통 설계 공간 안에서 내린 선택으로 읽힌다.

대형 값을 페이지 단위로 분해하는 모든 구현은 세 가지가 필요하다. 청크 크기, 청크 컨테이너, 조각의 순서를 잡는 청크 키다. 컨테이너는 보통 숨겨진/시스템 테이블이다(SQL Server의 LOB 할당 단위, Oracle의 LOB 세그먼트, PostgreSQL의 pg_largeobject). 키는 (객체 식별자, 일련번호) 쌍으로, 바이트 범위가 연속 청크 키 범위에 매핑되어 인덱스 범위 스캔으로 순서대로 스트리밍할 수 있다. 청크 크기는 조정 가능한 값이다. 너무 크면 작은 편집이 많은 바이트를 다시 쓰고, 너무 작으면 청크당 튜플/행 오버헤드와 인덱스 항목 수가 폭발한다.

flowchart LR
  subgraph App["클라이언트 / SQL"]
    A1["lo_open(loid, INV_READ)"]
    A2["lo_lseek(fd, off)"]
    A3["lo_read(fd, n)"]
  end
  subgraph API["Inversion API (inv_api.c)"]
    B1["LargeObjectDesc<br/>id, offset, snapshot, flags"]
    B2["인덱스 스캔 loid eq,<br/>pageno ge start"]
  end
  subgraph Cat["시스템 카탈로그"]
    C1["pg_largeobject_metadata<br/>(oid, lomowner, lomacl)"]
    C2["pg_largeobject<br/>(loid, pageno, data bytea)"]
    C3["btree (loid, pageno)"]
  end
  A1 --> B1
  A2 --> B1
  A3 --> B2
  B1 -.권한 + 존재 확인.-> C1
  B2 --> C3 --> C2

대형 객체는 0바이트 상태에서도 정체성과 권한을 갖는다. 구현들은 작은 메타데이터 레코드(소유자, ACL, 경우에 따라 크기/타임스탬프)를 대용량 데이터 청크와 분리한다. 메타데이터 레코드가 CREATE에서 삽입되고 DROP에서 제거되는 대상이며, 데이터 청크는 바이트가 실제로 써질 때 지연 삽입된다. 이 분리로 “이 객체가 존재하는가?”와 “이 역할이 읽을 수 있는가?”를 청크 저장소를 건드리지 않고 작은 단일 조회로 답할 수 있다.

hole이 있는 탐색 가능 바이트 스트림

섹션 제목: “hole이 있는 탐색 가능 바이트 스트림”

파일 추상화는 lseek 방식의 커서와 희소 객체(hole)의 가능성을 내포한다. 클라이언트가 끝을 훨씬 지나서 seek한 뒤 쓰면, 간격은 Unix 희소 파일처럼 논리적으로 0바이트로 읽혀야 하며 물리적으로 0 청크를 만들지 않아야 한다. hole을 가로지르는 읽기는 0을 만들어 내야 하고, hole에 쓰기는 실제로 건드린 청크만 만든다.

트랜잭션 안전과 충돌 복구 의미론

섹션 제목: “트랜잭션 안전과 충돌 복구 의미론”

large objects가 SQL 트랜잭션에 참여하므로, 쓰기는 둘러싼 트랜잭션과 원자적이어야 하고(중단 시 롤백) 커밋 후 내구성을 보장해야 한다. 청크를 일반 행으로 저장하는 엔진은 테이블 엔진의 MVCC + WAL에서 이를 얻는다. 맞춤 LOB 세그먼트를 쓰는 엔진은 로깅과 undo를 직접 구현해야 한다. 트레이드오프는 블로트 대 특수화다. 행 기반 LOB는 과도한 덮어쓰기 아래 블로트되지만 복구 코드가 전혀 필요 없다.

고정 스냅샷을 가진 트랜잭션 스코프 디스크립터

섹션 제목: “고정 스냅샷을 가진 트랜잭션 스코프 디스크립터”

open이 반환하는 파일 디스크립터는 세션/트랜잭션 로컬 핸들이지, 지속 객체가 아니다. 현재 seek 오프셋을 담고, 읽기 모드에서는 스냅샷을 가지므로 다른 트랜잭션이 객체를 수정하더라도 동일한 객체의 연속 읽기가 일관된 시점 이미지를 본다. 디스크립터와 스냅샷은 트랜잭션 종료 시(그리고 서브트랜잭션 경계를 넘을 때) 정리되어야 하며, 그렇지 않으면 누수된다.

PostgreSQL은 inversion 방식을 두 개의 시스템 카탈로그와 하나의 API 파일로 구현한다. 숫자는 large_object.h에 고정되어 있다.

// LOBLKSIZE / MAX_LARGE_OBJECT_SIZE — src/include/storage/large_object.h
#define LOBLKSIZE (BLCKSZ / 4)
#define MAX_LARGE_OBJECT_SIZE ((int64) INT_MAX * LOBLKSIZE)

기본 8 KB 블록 크기에서 LOBLKSIZE2048바이트다. 헤더 주석은 BLCKSZ/4를 선택한 두 가지 이유를 설명한다. 첫째, 작은 청크는 부분 페이지 쓰기 시 재작성 데이터량을 줄인다. 둘째, 더 미묘하게, 이 값은 “의도적으로 튜플 토스터를 트리거할 만큼 크게 선택되었다. 그래야 인라인 압축을 시도한다.” 즉, 2 KB data 필드는 TOAST 임계값을 넘으므로 PostgreSQL이 각 청크를 LZ 압축하려 시도한다(단, DBA가 pg_largeobject에 직접 TOAST 테이블을 만들지 않는 한 청크를 외부 TOAST 테이블로 밀어내지는 않는다). pagenoint32이고 INT_MAX개의 2 KB 청크가 있으면 최대 객체 크기는 INT_MAX * 20484 TB다.

pg_largeobject_metadata는 객체당 한 행을 가진다. OID가 large object의 정체성이고 소유자와 ACL이 저장된다. pg_largeobject(loid, pageno) 키로 데이터 청크를 저장한다.

// FormData_pg_largeobject — src/include/catalog/pg_largeobject.h
CATALOG(pg_largeobject,2613,LargeObjectRelationId)
{
Oid loid BKI_LOOKUP(pg_largeobject_metadata); /* LO identity */
int32 pageno; /* Page number (starting from 0) */
bytea data BKI_FORCE_NOT_NULL; /* Data for page (may be zero-length) */
} FormData_pg_largeobject;
DECLARE_UNIQUE_INDEX_PKEY(pg_largeobject_loid_pn_index, 2683,
LargeObjectLOidPNIndexId, pg_largeobject, btree(loid oid_ops, pageno int4_ops));

(loid, pageno) 복합 유니크 B-tree가 핵심이다. 모든 inversion 연산은 “객체 loidpageno가 어떤 범위 안에 있는 청크를 순서대로 스캔”이라는 형태로 표현된다. LargeObjectCreate는 메타데이터 행만 삽입한다. 데이터 청크는 없으므로 새로 만든 객체의 크기는 0이고 권한 검사 목적에서는 즉시 존재한다.

// LargeObjectCreate — src/backend/catalog/pg_largeobject.c (condensed)
pg_lo_meta = table_open(LargeObjectMetadataRelationId, RowExclusiveLock);
if (OidIsValid(loid)) loid_new = loid;
else loid_new = GetNewOidWithIndex(pg_lo_meta, LargeObjectMetadataOidIndexId,
Anum_pg_largeobject_metadata_oid);
values[Anum_pg_largeobject_metadata_oid - 1] = ObjectIdGetDatum(loid_new);
values[Anum_pg_largeobject_metadata_lomowner - 1] = ObjectIdGetDatum(GetUserId());
/* ... lomacl from get_user_default_acl ... */
ntup = heap_form_tuple(RelationGetDescr(pg_lo_meta), values, nulls);
CatalogTupleInsert(pg_lo_meta, ntup);

pg_largeobject_metadata는 PostgreSQL 9.0에서 large objects에 소유권과 per-object ACL을 붙이기 위해 도입됐다. 그 이전에는 모든 LO가 사실상 공유 소유였다. 단, 의존성 추적은 아직도 데이터 카탈로그 OID인 LargeObjectRelationId(2613) 아래에 객체를 등록한다. inv_create 주석은 이것이 pg_dump와 다른 클라이언트를 위한 하위 호환성 결정이라고 설명한다.

inv_open에서 존재 여부, 권한, 스냅샷 정책이 결정된다. 가장 중요한 설계 포인트는 읽기/쓰기 스냅샷 비대칭이다.

// inv_open — src/backend/storage/large_object/inv_api.c (condensed)
if (flags & INV_WRITE) descflags |= IFS_WRLOCK | IFS_RDLOCK;
if (flags & INV_READ) descflags |= IFS_RDLOCK;
/* ... reject descflags == 0 ... */
/* Get snapshot. If write is requested, use an instantaneous snapshot. */
if (descflags & IFS_WRLOCK)
snapshot = NULL; /* => "current" semantics each access */
else
snapshot = GetActiveSnapshot(); /* stable point-in-time view */
if (!LargeObjectExistsWithSnapshot(lobjId, snapshot)) /* existence check */
ereport(ERROR, ... "large object %u does not exist" ...);
/* ACL_SELECT for RDLOCK, ACL_UPDATE for WRLOCK, both snapshot-aware */

읽기 모드 open은 GetActiveSnapshot()을 캡처한다. 디스크립터에 대한 모든 lo_read 호출이 하나의 일관된 이미지를 본다. 동시 트랜잭션이 덮어쓰기를 커밋해도 마찬가지다. 쓰기 모드 open은 snapshot = NULL을 쓴다. 각 카탈로그 접근이 최신 커밋 상태(SnapshotSelf/current)를 본다. 읽기-수정-쓰기에 필요한 방식이다. 방금 쓴 청크와 최신 커밋 상태를 모두 봐야 하기 때문이다. IFS_RDLOCK/IFS_WRLOCK 플래그는 “이 모드에 대한 권한이 검사됐다”는 마커 역할도 한다. large_object.h 주석대로 v11부터 권한은 open 시점에 검사한다.

flowchart TD
  O["inv_open(loid, flags, mcxt)"] --> M{"INV_WRITE<br/>설정?"}
  M -->|yes| W["descflags = WRLOCK|RDLOCK<br/>snapshot = NULL (current)"]
  M -->|no, INV_READ| R["descflags = RDLOCK<br/>snapshot = GetActiveSnapshot()"]
  W --> E["LargeObjectExistsWithSnapshot"]
  R --> E
  E -->|없음| ERR["ERROR: does not exist"]
  E -->|있음| P["aclcheck: WRLOCK이면 ACL_UPDATE,<br/>RDLOCK이면 ACL_SELECT"]
  P --> D["mcxt에 LargeObjectDesc 할당<br/>id, offset=0, flags, snapshot"]

읽기: 순서 인덱스 스캔 + hole 제로 채우기

섹션 제목: “읽기: 순서 인덱스 스캔 + hole 제로 채우기”

inv_read는 바이트 오프셋을 시작 pageno로 변환하고, 두 개의 스캔 키(loid = idpageno >= start)로 순서 있는 systable 스캔을 열어 청크를 앞으로 걸어가며 각 청크의 관련 슬라이스를 복사한다. 예상 오프셋과 다음 청크의 실제 오프셋 사이의 간격에 0을 채워 large objects를 희소하게 만드는 부분이 핵심이다.

// inv_read — src/backend/storage/large_object/inv_api.c (condensed)
int32 pageno = (int32) (obj_desc->offset / LOBLKSIZE);
ScanKeyInit(&skey[0], Anum_pg_largeobject_loid, BTEqualStrategyNumber, F_OIDEQ, ...);
ScanKeyInit(&skey[1], Anum_pg_largeobject_pageno, BTGreaterEqualStrategyNumber,
F_INT4GE, Int32GetDatum(pageno));
sd = systable_beginscan_ordered(lo_heap_r, lo_index_r, obj_desc->snapshot, 2, skey);
while ((tuple = systable_getnext_ordered(sd, ForwardScanDirection)) != NULL)
{
pageoff = ((uint64) data->pageno) * LOBLKSIZE;
if (pageoff > obj_desc->offset) /* a hole: synthesize zeros */
{
n = pageoff - obj_desc->offset;
n = (n <= (nbytes - nread)) ? n : (nbytes - nread);
MemSet(buf + nread, 0, n);
nread += n; obj_desc->offset += n;
}
if (nread < nbytes) { /* copy slice from this chunk's data field */ }
if (nread >= nbytes) break;
}

inv_write가 가장 복잡한 루틴이다. 쓰기 범위가 닿는 각 청크마다 두 경로 중 하나를 밟는다. 기존 청크 갱신(기존 청크를 스택 workbuf에 로드하고, 새 바이트를 끼워 넣고, 청크 내 hole을 0으로 채우고, heap_modify_tuple + CatalogTupleUpdateWithInfo)하거나 새 청크 삽입(heap_form_tuple + CatalogTupleInsertWithInfo)한다. 1바이트 변경이 2 KB 튜플 전체를 다시 쓰는 이유가 바로 이 읽기-수정-쓰기다.

// inv_write — src/backend/storage/large_object/inv_api.c (condensed, update branch)
if (olddata != NULL && olddata->pageno == pageno)
{
getdatafield(olddata, &datafield, &len, &pfreeit); /* load old chunk */
memcpy(workb, VARDATA(datafield), len);
off = (int) (obj_desc->offset % LOBLKSIZE);
if (off > len) MemSet(workb + len, 0, off - len); /* intra-chunk hole */
n = LOBLKSIZE - off;
n = (n <= (nbytes - nwritten)) ? n : (nbytes - nwritten);
memcpy(workb + off, buf + nwritten, n); /* splice new bytes */
nwritten += n; obj_desc->offset += n; off += n;
len = (len >= off) ? len : off;
SET_VARSIZE(&workbuf.hdr, len + VARHDRSZ);
values[Anum_pg_largeobject_data - 1] = PointerGetDatum(&workbuf);
replace[Anum_pg_largeobject_data - 1] = true;
newtup = heap_modify_tuple(oldtuple, RelationGetDescr(lo_heap_r),
values, nulls, replace);
CatalogTupleUpdateWithInfo(lo_heap_r, &newtup->t_self, newtup, indstate);
}

루프 후 inv_writeCommandCounterIncrement()를 호출한다. 방금 쓴 청크가 같은 커맨드 시퀀스의 이후 연산에서 보여야 하기 때문이다. 같은 트랜잭션에서 lo_write 직후 lo_read나 또 다른 lo_write가 이어질 때 필수적이다.

SQL에서 보이는 함수들(lo_open, loread, lowrite, lo_lseek, lo_creat, lo_unlink, lo_import, lo_export, lo_get, lo_put 등)은 be-fsstubs.cbe_lo_* fmgr 래퍼로 있다. per-transaction 파일 디스크립터 테이블 — 개인 fscxt 메모리 컨텍스트에 할당된 cookies 배열(LargeObjectDesc *) — 을 관리한다. 정수 “fd”는 그 배열의 인덱스일 뿐이다. be_lo_open은 슬롯을 할당하고, inv_open을 호출하고, 디스크립터에 현재 서브트랜잭션 id를 찍고, 읽기 스냅샷TopTransactionResourceOwner에 등록해 현재 포털이 끝나도 살아남게 한다.

// be_lo_open — src/backend/libpq/be-fsstubs.c (condensed)
fd = newLOfd(); /* index into cookies[] in fscxt */
lobjDesc = inv_open(lobjId, mode, fscxt);
lobjDesc->subid = GetCurrentSubTransactionId();
if (lobjDesc->snapshot)
lobjDesc->snapshot = RegisterSnapshotOnOwner(lobjDesc->snapshot,
TopTransactionResourceOwner);
cookies[fd] = lobjDesc;
PG_RETURN_INT32(fd);

이 디스크립터들은 열었던 트랜잭션 안에서만 유효하다. AtEOXact_LargeObject가 트랜잭션 종료 시 전체 fscxt를 해제하고, AtEOSubXact_LargeObject가 서브트랜잭션 경계를 넘으며 디스크립터를 재배정하거나 닫는다.

구현은 세 레이어로 깔끔하게 나뉜다. 두 개의 시스템 테이블을 소유하는 카탈로그 레이어(pg_largeobject.c), 탐색 가능한 바이트 스트림을 제공하는 inversion 레이어(inv_api.c), SQL 함수를 노출하고 디스크립터를 관리하는 fmgr/FD 레이어(be-fsstubs.c)다. 아래에서 위로 순서대로 살펴본다.

카탈로그 레이어 — pg_largeobject.c

섹션 제목: “카탈로그 레이어 — pg_largeobject.c”

LargeObjectCreate(loid)pg_largeobject_metadata 행 하나를 삽입한다(소유자 = GetUserId(), get_user_default_acl을 통한 기본 ACL). loidInvalidOid이면 GetNewOidWithIndex로 새 OID를 선택한다. 데이터 청크는 쓰이지 않으므로 첫 번째 쓰기 전까지 객체 크기는 0이다.

LargeObjectDrop(loid)는 두 절반을 모두 삭제한다. 먼저 LargeObjectMetadataOidIndexId로 스캔한 pg_largeobject_metadata 행 하나를 삭제하고, 이어서 (loid, pageno) 인덱스를 범위 스캔해 CatalogTupleDelete로 해당 loid모든 pg_largeobject 청크를 삭제한다.

// LargeObjectDrop — src/backend/catalog/pg_largeobject.c (condensed)
ScanKeyInit(&skey[0], Anum_pg_largeobject_loid, BTEqualStrategyNumber, F_OIDEQ,
ObjectIdGetDatum(loid));
scan = systable_beginscan(pg_largeobject, LargeObjectLOidPNIndexId, true,
NULL, 1, skey);
while (HeapTupleIsValid(tuple = systable_getnext(scan)))
CatalogTupleDelete(pg_largeobject, &tuple->t_self);
systable_endscan(scan);

LargeObjectExists(loid) / LargeObjectExistsWithSnapshot(loid, snapshot)pg_largeobject_metadata(데이터 카탈로그가 아닌 메타데이터 카탈로그)를 주어진 스냅샷으로 조회해 존재 여부를 답한다. 함수 주석에 따르면 LO 메타데이터는 “로컬 메모리를 너무 많이 쓸 우려” 때문에 syscache에 캐시되지 않는다. 따라서 각 검사는 신선한 systable_beginscan이다. 스냅샷 인식 변형은 읽기 모드 inv_open이 읽기에 쓸 스냅샷과 동일한 스냅샷으로 존재 여부를 확인하기 위해 존재한다.

릴레이션 캐싱. open_lo_relation()은 트랜잭션당 한 번 pg_largeobject와 그 인덱스를 열고, TopTransactionResourceOwner에 참조를 보유해 서브트랜잭션 중단이 이후 연산에서 빼앗아 가지 못하게 한다. 읽기 “이후에 쓸 수도 있기 때문에” RowExclusiveLock을 취한다. close_lo_relation(isCommit)은 주 트랜잭션 종료 시 릴리스한다(AtEOXact_LargeObject에서 호출된다).

청크 디코딩. getdatafield()data 컬럼의 원시값을 건드리는 유일한 위치다. 2 KB 청크는 TOAST 임계값을 넘으므로 압축 저장되거나 짧은 헤더 형식으로 저장될 수 있다. VARATT_IS_EXTENDED이면 detoast_attr로 압축 해제하고, 0 <= len <= LOBLKSIZE를 검증하며(ERRCODE_DATA_CORRUPTED 아니면), 호출자가 압축 해제된 복사본을 pfree해야 하는지 알린다.

// getdatafield — src/backend/storage/large_object/inv_api.c (condensed)
datafield = &(tuple->data); /* see note at top of file */
if (VARATT_IS_EXTENDED(datafield)) {
datafield = (bytea *) detoast_attr((struct varlena *) datafield);
freeit = true;
}
len = VARSIZE(datafield) - VARHDRSZ;
if (len < 0 || len > LOBLKSIZE)
ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED),
errmsg("pg_largeobject entry for OID %u, page %d has invalid data field size %d",
tuple->loid, tuple->pageno, len)));

크기. inv_getsize()(loid, pageno) 인덱스 순서를 활용한다. 역순 스캔 한 번으로 pageno가 가장 큰 청크를 먼저 얻으므로 크기는 그 튜플의 pageno * LOBLKSIZE + len이다. 전체 스캔이 필요 없다. LO는 “Unix 파일처럼 hole을 가질 수 있다”는 주석대로 이 값은 마지막 바이트 오프셋 + 1이지 저장된 바이트 수가 아니다.

Seek/tell. inv_seek(obj_desc, offset, whence)SEEK_SET/SEEK_CUR/SEEK_END에 따라 디스크립터의 offset을 갱신한다(SEEK_ENDinv_getsize를 호출한다). 음수 결과나 MAX_LARGE_OBJECT_SIZE를 초과하는 결과는 거부한다. inv_tell은 현재 오프셋을 반환할 뿐이다. 둘 다 읽기 또는 쓰기 권한이 있으면 허용되므로 ACL 검사를 하지 않는다.

읽기. inv_read(§3에서 설명)는 오프셋의 청크부터 순서 있는 두 키 스캔을 시작해 앞으로 복사하며 MemSet으로 hole을 0으로 채운다. IFS_RDLOCK을 강제하고 obj_desc->snapshot 아래에서 읽는다.

쓰기. inv_write(§3에서 설명)는 청크 단위로 반복한다. 대상 pageno에 기존 청크가 있으면 제자리 갱신(heap_modify_tupleCatalogTupleUpdateWithInfo), 기존 청크가 없으면 새로 삽입(heap_form_tupleCatalogTupleInsertWithInfo), 청크 내 또는 전체 청크 hole은 MemSet으로 0 채운 뒤 끼워 넣는다. IFS_WRLOCK을 강제하고 MAX_LARGE_OBJECT_SIZE를 초과하는 쓰기를 거부하며 CommandCounterIncrement()로 끝낸다.

Truncate. inv_truncate(obj_desc, len)은 절단점을 포함하는 청크를 찾는다. 그 청크가 존재하면 제자리에서 줄이고(청크 내 hole은 0 채움), 절단점이 hole에 걸리면 데이터 끝을 표시하는 짧은 청크를 새로 만든다. 이후 모든 청크는 삭제된다. 순서 있는 스캔을 계속해 CatalogTupleDelete를 호출한다.

// inv_truncate — src/backend/storage/large_object/inv_api.c (condensed tail)
/* ... write/shorten the boundary chunk at pageno ... */
if (olddata != NULL) /* delete every chunk strictly after the boundary */
{
while ((oldtuple = systable_getnext_ordered(sd, ForwardScanDirection)) != NULL)
CatalogTupleDelete(lo_heap_r, &oldtuple->t_self);
}
systable_endscan_ordered(sd);
CatalogCloseIndexes(indstate);
CommandCounterIncrement();

Create/open/close/drop. inv_createLargeObjectCreate를 호출하고, 소유자 의존성을 기록하고(호환성 때문에 LargeObjectRelationId 아래), post-create 훅을 실행하고, CommandCounterIncrement한다. inv_open(§3에서 설명)은 LargeObjectDesc를 만든다. inv_close는 디스크립터를 pfree할 뿐이다. inv_dropperformDeletionDROP_CASCADE로 호출해(의존성 기계가 LargeObjectDrop으로 라우팅) CommandCounterIncrement한다. “역사적 이유로” 항상 1을 반환한다.

디스크립터 테이블. newLOfd()fscxt를 지연 생성하고(TopMemoryContext 아래 AllocSetContext, 이름 "Filesystem"), cookies에서 빈 슬롯을 찾거나 배열을 두 배로 늘리고(첫 할당은 64 슬롯), 인덱스를 반환한다. closeLOfd(fd)는 슬롯을 NULL로 만든 해제한다. “누수가 충돌보다 낫다”는 원칙이다. 스냅샷 등록을 해제하고 inv_close한다.

// closeLOfd — src/backend/libpq/be-fsstubs.c (condensed)
lobj = cookies[fd];
cookies[fd] = NULL; /* null first: avoid double-free on error */
if (lobj->snapshot)
UnregisterSnapshotFromOwner(lobj->snapshot, TopTransactionResourceOwner);
inv_close(lobj);

Open/close/read/write/seek. be_lo_open(§3에서 설명)은 INV_WRITEPreventCommandIfReadOnly로 막고, fd를 할당하고, 스냅샷을 등록한다. be_lo_close는 fd를 검증하고 closeLOfd한다. 기본 lo_read/lo_write 헬퍼는 디스크립터의 IFS_RDLOCK/IFS_WRLOCK을 재검사해 오류 메시지가 하위 권한이 아닌 FD 모드에 대한 것이 되게 하고 inv_read/inv_write에 위임한다. be_loread/be_lowrite는 그 위의 bytea fmgr 진입점이다. be_lo_lseek/be_lo_tell(32비트)은 int32 오버플로를 막고 ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE를 발생시킨다. be_lo_lseek64/be_lo_tell64는 완전한 int64를 반환한다.

Create/unlink. be_lo_creat/be_lo_createinv_create를 호출한다. be_lo_unlink는 소유권을 확인하고(lo_compat_privileges가 아니면 object_ownercheck), cookies를 스캔해 그 loid를 참조하는 열린 FD를 모두 닫고, inv_drop한다. 소유권 오류를 FD 닫기 전에 의도적으로 발생시키는 순서가 설계상 중요하다.

Import/export. lo_import_internalOpenTransientFile로 서버 측 파일을 열고, LO를 만들어 BUFSIZE(8192바이트) 단위로 inv_write에 넘겨 스트리밍한다. be_lo_exportinv_read로 역방향을 수행하며 OpenTransientFilePerm 주변에서 umask를 022로 낮춰 내보낸 파일이 world-writable이 되지 않게 한다. 둘 다 서버 측에서만 실행되며 실질적으로 superuser 수준 접근이 필요하다.

전체 객체 SQL 헬퍼. lo_get_fragment_internal은 읽기 모드로 열고, SEEK_END로 크기를 재고, MaxAllocSize로 클램핑한 뒤 다시 되돌아가 bytea로 읽는다. be_lo_get은 전체 객체를 읽고, be_lo_get_fragment는 범위를 읽는다. be_lo_from_bytea는 LO를 만들어 전체 byteainv_write한다. be_lo_put은 seek하고 범위를 덮어쓴다. FD 없는 단일 호출 접근자로 현대적인 방식이다.

트랜잭션 생명 주기. AtEOXact_LargeObject(isCommit)이 end-of-transaction 훅이다. LO 작업이 있었으면(lo_cleanup_needed), 커밋 시 열린 디스크립터를 모두 closeLOfd하고(누수 경고 방지), cookies 배열을 초기화하고, fscxt를 통째로 삭제해 디스크립터가 트랜잭션을 넘어 살아남지 못하게 하고, 마지막으로 close_lo_relation(isCommit)을 호출한다.

// AtEOXact_LargeObject — src/backend/libpq/be-fsstubs.c (condensed)
if (!lo_cleanup_needed) return;
if (isCommit)
for (i = 0; i < cookies_size; i++)
if (cookies[i] != NULL) closeLOfd(i);
cookies = NULL; cookies_size = 0;
if (fscxt) MemoryContextDelete(fscxt); /* descriptors die with the xact */
fscxt = NULL;
close_lo_relation(isCommit);
lo_cleanup_needed = false;

AtEOSubXact_LargeObject(isCommit, mySubid, parentSubid)는 서브트랜잭션 경계를 처리한다. subid가 커밋 중인 서브트랜잭션과 일치하는 디스크립터는 부모에 재배정된다(lo->subid = parentSubid). 중단 시에는 closeLOfd된다. 모든 디스크립터가 자신을 소유한 서브트랜잭션을 기록하는 이유가 여기에 있다.

flowchart TD
  T0["BEGIN"] --> OP["lo_open -> newLOfd<br/>fscxt 내 cookies[fd]"]
  OP --> RW["lo_read / lo_write<br/>inv_read / inv_write 경유"]
  RW --> SUB{"서브트랜잭션<br/>경계?"}
  SUB -->|commit| RE["AtEOSubXact: subid := parent"]
  SUB -->|abort| CL["AtEOSubXact: closeLOfd"]
  RE --> EOX
  CL --> EOX
  RW --> EOX{"트랜잭션 종료"}
  EOX -->|commit| CC["AtEOXact: closeLOfd 전체,<br/>MemoryContextDelete(fscxt),<br/>close_lo_relation(true)"]
  EOX -->|abort| CA["AtEOXact: fscxt 해제,<br/>close_lo_relation(false)"]

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
LOBLKSIZE (BLCKSZ / 4)src/include/storage/large_object.h70
MAX_LARGE_OBJECT_SIZEsrc/include/storage/large_object.h76
LargeObjectDesc 구조체src/include/storage/large_object.h39
IFS_RDLOCK / IFS_WRLOCKsrc/include/storage/large_object.h48–49
FormData_pg_largeobjectsrc/include/catalog/pg_largeobject.h30
pg_largeobject_loid_pn_indexsrc/include/catalog/pg_largeobject.h48
FormData_pg_largeobject_metadatasrc/include/catalog/pg_largeobject_metadata.h30
INV_WRITE / INV_READsrc/include/libpq/libpq-fs.h21–22
LargeObjectCreatesrc/backend/catalog/pg_largeobject.c36
LargeObjectDropsrc/backend/catalog/pg_largeobject.c95
LargeObjectExistssrc/backend/catalog/pg_largeobject.c167
LargeObjectExistsWithSnapshotsrc/backend/catalog/pg_largeobject.c176
open_lo_relationsrc/backend/storage/large_object/inv_api.c73
close_lo_relationsrc/backend/storage/large_object/inv_api.c97
getdatafieldsrc/backend/storage/large_object/inv_api.c131
inv_createsrc/backend/storage/large_object/inv_api.c173
inv_opensrc/backend/storage/large_object/inv_api.c215
inv_closesrc/backend/storage/large_object/inv_api.c299
inv_dropsrc/backend/storage/large_object/inv_api.c311
inv_getsizesrc/backend/storage/large_object/inv_api.c340
inv_seeksrc/backend/storage/large_object/inv_api.c388
inv_tellsrc/backend/storage/large_object/inv_api.c437
inv_readsrc/backend/storage/large_object/inv_api.c450
inv_writesrc/backend/storage/large_object/inv_api.c543
inv_truncatesrc/backend/storage/large_object/inv_api.c740
be_lo_opensrc/backend/libpq/be-fsstubs.c87
be_lo_closesrc/backend/libpq/be-fsstubs.c126
lo_read / lo_writesrc/backend/libpq/be-fsstubs.c154 / 182
be_lo_creat / be_lo_createsrc/backend/libpq/be-fsstubs.c249 / 262
be_lo_unlinksrc/backend/libpq/be-fsstubs.c314
be_loread / be_lowritesrc/backend/libpq/be-fsstubs.c362 / 380
lo_import_internalsrc/backend/libpq/be-fsstubs.c424
be_lo_exportsrc/backend/libpq/be-fsstubs.c486
be_lo_truncate / be_lo_truncate64src/backend/libpq/be-fsstubs.c579 / 591
AtEOXact_LargeObjectsrc/backend/libpq/be-fsstubs.c607
AtEOSubXact_LargeObjectsrc/backend/libpq/be-fsstubs.c653
newLOfd / closeLOfdsrc/backend/libpq/be-fsstubs.c680 / 721
lo_get_fragment_internalsrc/backend/libpq/be-fsstubs.c746
be_lo_get / be_lo_from_bytea / be_lo_putsrc/backend/libpq/be-fsstubs.c797 / 832 / 855

/data/hgryoo/references/postgresREL_18_STABLE, 커밋 273fe94(PostgreSQL 18.x) 기준으로 검증했다.

  • LOBLKSIZE = BLCKSZ / 4MAX_LARGE_OBJECT_SIZE = INT_MAX * LOBLKSIZE: large_object.h 70번, 76번 행에서 원문 확인. 기본 8 KB BLCKSZ 기준 청크당 2048바이트, 최대 약 4 TB.
  • 두 카탈로그 분리: pg_largeobject(CATALOG(...,2613,...), 컬럼 loid, pageno, data)와 pg_largeobject_metadata(CATALOG(...,2995,...), 컬럼 oid, lomowner, lomacl) 각 헤더에서 확인. 복합 유니크 B-tree pg_largeobject_loid_pn_index(btree(loid oid_ops, pageno int4_ops))는 pg_largeobject.h:48에 선언.
  • data BKI_FORCE_NOT_NULL: 확인됨. inv_api.c 헤더 주석과 HeapTupleHasNulls “paranoia” 검사로 뒷받침.
  • 읽기/쓰기 스냅샷 비대칭: inv_openIFS_WRLOCKsnapshot = NULL, 읽기 전용에 snapshot = GetActiveSnapshot()을 설정함을 inv_api.c:238–241에서 확인. 매칭되는 RegisterSnapshotOnOwner / UnregisterSnapshotFromOwner 호출은 be_lo_opencloseLOfd에 있음.
  • 희소 hole 0 채우기: inv_readpageoff > obj_desc->offset 분기에서 MemSet(buf + nread, 0, n) 확인(inv_api.c:505–512).
  • 읽기-수정-쓰기: inv_write의 갱신 분기는 heap_modify_tuple + CatalogTupleUpdateWithInfo, 삽입 분기는 heap_form_tuple + CatalogTupleInsertWithInfo. 둘 다 확인(약 675–721행). 각 변경 루틴은 CommandCounterIncrement()로 끝남.
  • fscxt 내 FD 테이블: cookies/cookies_size/fscxt 정적 변수와 newLOfd/closeLOfd/AtEOXact_LargeObject/AtEOSubXact_LargeObject 생명 주기를 be-fsstubs.c에서 확인. AtEOXact_LargeObject는 정리 시 MemoryContextDelete(fscxt) 호출.
  • 권한 모델: inv_open에서 ACL_SELECT(읽기) / ACL_UPDATE(쓰기)로 pg_largeobject_aclcheck_snapshot. be_lo_unlink에서 object_ownercheck. 모두 lo_compat_privileges GUC로 제어. 확인됨.
  • 주의 / 버전 메모: inv_read/inv_writeint(32비트) 바이트 카운트를 반환하므로 단일 SQL loread/lowrite 호출은 4 TB 객체 한도에 관계없이 최대 INT_MAX 바이트를 이동한다. be_lo_lseek / be_lo_tell 32비트 래퍼는 2 GB를 넘으면 ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE를 발생시키는데 이것이 *64 변형이 존재하는 이유다. REL_18 기준이며 PG19 전용 심볼은 사용하지 않았다.

PostgreSQL 너머 — 비교 설계와 연구 프론티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

Large objects 대 bytea/TOAST — 내부 비교

섹션 제목: “Large objects 대 bytea/TOAST — 내부 비교”

첫 번째 비교는 내부에 있다. PostgreSQL은 큰 바이너리 값을 저장하는 두 가지 방식을 함께 제공하며, 대비가 시사적이다.

차원Large object (lo)bytea + TOAST
주소화객체 OID, 행과 독립행 안의 컬럼 값
접근탐색 가능: lo_lseek/lo_read 임의 접근전부 아니면 없음 datum (전체 detoast)
최대 크기~4 TB (INT_MAX * LOBLKSIZE)~1 GB (varlena 30비트 길이)
청크 저장소pg_largeobject, 모든 LO 공유per-table TOAST 릴레이션, TOAST_MAX_CHUNK_SIZE
부분 갱신lo_put/inv_write가 건드린 청크만전체 datum을 새 행 버전으로 재작성
클라이언트 스트리밍loread/lowrite 라운드트립으로 청크 단위와이어 위 하나의 큰 값
권한per-object 소유자 + ACL (9.0부터)테이블의 행 권한 상속
트랜잭션예 (heap MVCC + WAL)예 (heap MVCC + WAL)

PostgreSQL 문서가 사실상 인코딩한 결정 규칙은 이렇다. 값이 수 MB 이하이고 전체를 읽고 쓰는 경우라면 bytea를 쓴다. 수 GB 규모의 blob에 임의 접근이 필요하거나 1 GB bytea 한도를 넘는 경우라면 large objects를 쓴다. 두 방식 모두 아래는 동일한 heap + B-tree 청크 저장소다. TOAST는 컬럼을 청킹하고, inversion은 객체를 청킹한다. 그래서 둘 다 과도한 덮어쓰기 아래 MVCC 블로트를 겪는다. 컬럼 측은 postgres-toast.md를 참조한다.

모든 청크가 일반 힙 튜플이므로 LO 데이터를 덮어쓰면 pg_largeobject에 죽은 튜플이 남는다. 일반 행 갱신과 동일하다. 같은 객체의 같은 오프셋에 반복 lo_put하는 워크로드는 pg_largeobject를 블로트시키고 autovacuum의 공간 회수에 의존한다(postgres-autovacuum.mdpostgres-vacuum.md 참조). 고아 LO 문제도 있다. 사용자 테이블의 참조 행을 삭제해도 large object는 삭제되지 않는다(OID 컬럼은 그냥 정수일 뿐이다). 버려진 LO가 쌓이는 이유다. 커뮤니티의 답은 vacuumlo 유틸리티와 lo contrib 모듈의 lo_manage 트리거다. 둘 다 클라이언트/contrib 도구라 이 코어 분석의 범위 밖이지만, 코어 메커니즘의 운영 상대역으로 언급할 가치가 있다.

  • OracleBFILE(OS 파일을 가리키는 읽기 전용 포인터, 바이트가 데이터베이스 에 있다)을 내부 LOB(BLOB/CLOB/NCLOB)와 분리한다. 내부 LOB는 자체 청크 크기와 선택적 SecureFiles 계층(압축, 중복 제거, 암호화 추가)을 갖는 전용 LOB 세그먼트에 저장된다. inversion과 달리 Oracle의 LOB 세그먼트는 “그냥 또 다른 테이블”이 아닌 맞춤 스토리지 구조여서 전용 로깅과 별도의 undo/보존 모델을 가진다.
  • SQL Servervarbinary(max)/text/imageLOB_DATA/ROW_OVERFLOW_DATA 할당 단위에 저장하며, FILESTREAMFileTables도 있다. 바이트를 NTFS 파일 시스템에 두면서 트랜잭션 일관성을 유지하는데, Oracle BFILE과 정신적으로 유사하지만 트랜잭션 안전이다. PostgreSQL의 bytea처럼 컬럼 주소 방식이지, 객체 주소 방식이 아니다.
  • MySQL/InnoDBBLOB/TEXT 오버플로를 같은 테이블스페이스의 off-page에 컬럼 주소 방식으로 저장한다. lo_open에 상응하는 객체 로케이터 API가 없다.
  • JDBC/SQL 표준 로케이터. 표준의 BLOB/CLOB 로케이터 모델 — 트랜잭션 기간 동안 유효한 핸들로 getBytes(pos, len)setBytes 지원 — 은 PostgreSQL의 트랜잭션 스코프 FD와 개념적으로 동일한 계약이다. PostgreSQL JDBC 드라이버는 java.sql.Bloblo_* 함수 위에 매핑한다.

inversion 아이디어 — blob을 위한 새 스토리지를 짓는 대신 테이블 엔진을 재사용한다 — 는 스토리지 연구의 반복 주제를 선취한다. 언번들링 대 스토리지 레이어 재사용이다. 현대의 객체 저장소(S3 스타일)와 분리된 스토리지 데이터베이스는 대형 값을 외부의 내용 주소 방식 계층으로 밀어내고 행에는 참조만 남기는데, 이는 BFILE/FILESTREAM 방향을 클라우드 결론까지 끌어간 것이다. 1990년대 PostgreSQL이 내린 트레이드오프 — MVCC 블로트와 쓰기 증폭을 감수하고 공짜로 트랜잭션·충돌 안전 blob을 얻는다 — 는 클라우드 네이티브 엔진이 대형 값이 별도의 추가 전용 로그 구조 계층(제자리 재작성 블로트를 피한다)을 가져야 하는지, 아니면 트랜잭션 단순성을 위해 인라인에 남아야 하는지 결정할 때 다시 검토하는 트레이드오프 바로 그것이다. LOBLKSIZE = BLCKSZ/4int32 pageno가 초기 릴리스 이후 변하지 않은 것도 교훈이다. 청크 크기와 키 폭 선택이 어떻게 고착화되는지 보여 준다. 4 TB 한도를 늘리려면 pageno를 넓히고 initdb가 필요하다. 원래 상수를 동결하는 마이그레이션 비용이다.

  • PostgreSQL 소스 (/data/hgryoo/references/postgres, REL_18_STABLE, 커밋 273fe94):
    • src/backend/storage/large_object/inv_api.c — inversion API: inv_create/open/close/drop/getsize/seek/tell/read/write/truncate, getdatafield, open_lo_relation/close_lo_relation.
    • src/backend/libpq/be-fsstubs.c — fmgr be_lo_* 래퍼, fscxtcookies FD 테이블, lo_import/lo_export, lo_get/lo_put/lo_from_bytea, AtEOXact/AtEOSubXact 생명 주기.
    • src/backend/catalog/pg_largeobject.cLargeObjectCreate/Drop/Exists/ExistsWithSnapshot.
    • src/include/storage/large_object.hLOBLKSIZE, MAX_LARGE_OBJECT_SIZE, LargeObjectDesc, IFS_RDLOCK/IFS_WRLOCK.
    • src/include/catalog/pg_largeobject.h, src/include/catalog/pg_largeobject_metadata.h — 두 카탈로그와 복합 (loid, pageno) 인덱스.
    • src/include/libpq/libpq-fs.hINV_WRITE/INV_READ 플래그.
  • 이론 기반 (knowledge/research/dbms-general/):
    • Database System Concepts, Silberschatz 7판 — 13장 “Data Storage Structures” (페이지 레이아웃, 대형 값 오버플로), 파일/레코드 구성.
    • Database Internals, Petrov — “File Formats” (슬롯 페이지, 오버플로 페이지, 과대 값이 out-of-line 되어야 하는 이유).
  • 상호 참조 (이 저장소): postgres-toast.md (컬럼 주소 방식 대형 값, varlena, 인라인/외부 압축), postgres-heap-am.md (LO 청크가 올라타는 힙 튜플 삽입/갱신/삭제), postgres-autovacuum.md / postgres-vacuum.md (죽은 청크 튜플 회수), postgres-page-layout.md (BLCKSZ, 슬롯 페이지), postgres-nbtree.md ((loid, pageno) 스캔을 지원하는 B-tree 인덱스), postgres-mvcc-snapshots.md (읽기 모드 스냅샷 의미론).