콘텐츠로 이동

(KO) PostgreSQL 테이블 샘플링 — TSM API, SYSTEM과 BERNOULLI

목차

**샘플링(sampling)**은 전체 모집단에서 부분 집합을 골라 그 부분 집합으로 계산한 결과가 전체에 대한 계산을 저렴하게 근사하도록 만드는 기법이다. 데이터베이스에서 모집단은 테이블의 행 집합이고 계산 대상은 쿼리다. 10억 행 테이블에서 “늦게 출하된 주문의 비율이 어느 정도인가?”를 전체 행의 1%만 읽어 답할 수 있다면 쿼리는 약 100배 빨라지고, 분석·탐색 목적이라면 정밀도 손실을 감수할 만하다. Database System Concepts(Silberschatz 7판, 15장 및 25장)는 샘플링을 쿼리 평가 기법이자 옵티마이저 통계 수집의 토대로 함께 다룬다. 샘플링이 유용한 이유는 고전 표본조사 이론에서 찾을 수 있다. 크기 n인 표본에서 유도한 추정치의 정밀도는 모집단 크기 N과 무관하게 1/sqrt(n)으로 개선된다. 단, 이 보장은 표본에 체계적 편향이 없을 때만 성립한다. 편향이 표본 크기보다 더 위험하다.

이 조건은 샘플링 방식의 분류를 강제한다. 통계적으로 가장 깨끗한 대상은 **단순 임의 표본(simple random sample)**이다. 각 행이 동일한 확률 p로 독립적으로 포함·제외되므로 두 행이 함께 나타날 가능성도 동일하고 행 선택이 어떤 속성과도 무상관이다. 이것이 통계학에서 말하는 **베르누이 샘플링(Bernoulli sampling)**이다. N번의 독립 Bernoulli(p) 시행으로 기댓값이 pN인 표본을 만들어 낸다. 베르누이 표본은 어떤 양에 대해서도 비편향이다. 선택 과정이 데이터를 전혀 참조하지 않기 때문이다. 그러나 대가가 있다. 엔진이 모든 행을 검토해야 하므로 실제 반환 행이 1%뿐이어도 전체 테이블을 읽어야 한다.

비용이 더 낮은 대안은 통계적 순수성과 I/O를 맞바꾼다. 데이터베이스는 행을 **페이지(블록)**에 저장하고, 스캔의 지배 비용은 행 검사가 아니라 페이지 읽기다. **블록 샘플링(block sampling)**은 확률 p로 페이지 전체를 선택하고 그 페이지의 모든 행을 반환한다. 이렇게 하면 엔진은 pN_pages개의 페이지만 읽어 1/p배의 I/O를 절약한다. 단점은 표본이 더 이상 단순 임의 표본이 아니라는 점이다. 같은 페이지의 행은 포함 여부가 완전히 연동되므로(클러스터 샘플링) 편향이 생긴다. 물리 레이아웃이 데이터와 상관되어 있다면(날짜 순 정렬된 주문 등) 블록 샘플은 해당 분포를 심하게 왜곡할 수 있다. 이 손실을 통계 용어로 **디자인 효과(design effect)**라 부른다. 클러스터링은 같은 크기의 단순 임의 표본보다 추정치 분산을 키우며, 그 배율은 속성의 페이지 내 동질성에 비례한다.

블록 대 행이라는 축에 직교하는 세 번째 축은 **재현성(repeatability)**이다. 같은 샘플링 쿼리를 다시 실행해도 같은 행이 돌아오면 재현 가능하다고 한다. 이는 자명해 보이지만 실제로는 미묘하다. 두 실행 사이에 테이블이 확장되거나 VACUUM이 실행되거나 다른 위치에서 시작한 동기화된 순차 스캔이 실행될 수 있기 때문이다. 이력이나 동시 활동에 무관하게 재현 가능하려면, 주어진 행에 대한 포함 판정이 행의 안정적 식별자와 고정된 시드의 순수 함수여야 한다. PostgreSQL 소스 주석의 표현으로 하면 **이력 독립적(history independent)**이어야 한다. (식별자, 시드) 쌍을 해시하고 임계값과 비교하면 이 조건을 정확히 만족한다.

SQL:2003 표준은 TABLESAMPLE 절로 샘플링을 선언적으로 노출하고, PostgreSQL이 내장하는 두 메서드를 표준화했다.

SELECT * FROM orders TABLESAMPLE SYSTEM (1.5); -- 1.5% 페이지
SELECT * FROM orders TABLESAMPLE BERNOULLI (1.5); -- 1.5% 행
SELECT * FROM orders TABLESAMPLE SYSTEM (1.5) REPEATABLE (42);

SYSTEM은 블록 샘플링의 표준 명칭이고 BERNOULLI는 행 샘플링이다. 퍼센트는 [0,100] 범위의 확률이며 선택적 REPEATABLE(seed) 절로 시드를 고정한다. 대부분의 주요 엔진이 이 표면에 수렴했다. SQL Server의 TABLESAMPLE (n PERCENT)는 기본적으로 블록 레벨(작은 표본에서 행이 0개일 수 있음)이며 REPEATABLE(seed) 옵션을 지원한다. DB2 LUW는 TABLESAMPLE SYSTEMTABLESAMPLE BERNOULLI 둘 다 REPEATABLE 시드와 함께 구현해 표준에 가장 가깝다. Oracle은 SQL:2003보다 앞서 SAMPLE(n) / SAMPLE BLOCK(n) 구문으로 행·블록 선택을 노출하고 SEED 절로 재현성을 지원한다. 네 독립 제품이 “블록 또는 행, 퍼센트, 선택적 시드”라는 동일한 추상화로 수렴했다는 점이 이 추상화의 자연스러움을 강력히 지지한다.

내부 구현에서 엔진들이 반복적으로 직면하는 설계 결정은 세 가지다.

  1. 샘플링 판정이 접근 경로 안에 있는가, 아니면 전체 스캔 위의 필터인가? 블록 샘플링 메서드는 접근 경로 자체다. 어떤 페이지를 스토리지 엔진이 가져올지 결정해야 하므로 스캔 내부까지 내려가야 한다. 행 샘플링 메서드는 원칙적으로 일반 스캔 위의 사후 필터로 구현할 수도 있지만, 옵티마이저가 균일하게 비용을 산정하도록 대부분의 엔진은 스캔 안으로 접어 넣는다. PostgreSQL의 선택 — 블록·튜플 선택을 메서드에 위임하는 전용 SampleScan 플랜 노드 — 이 바로 그 접혀진 설계다.

  2. 메서드를 어떻게 플러그인 가능하게 만드는가? 일부 엔진은 SYSTEM과 BERNOULLI를 고정으로 내장한다. PostgreSQL은 작은 콜백 인터페이스(TSM API)를 정의해 두 내장 메서드가 두 구현에 불과하고 확장이 세 번째 메서드를 제공할 수 있게 했다. 예컨대 contrib의 tsm_system_rowstsm_system_time은 퍼센트가 아닌 고정 행 수나 시간 예산으로 샘플링한다. 인터페이스는 의도적으로 작다: 크기 추정, 시작, 블록 선택, 튜플 선택.

  3. 재현성과 이력 독립성을 어떻게 달성하는가? PostgreSQL이 사용하는 견고한 방법은 해시-임계값 기법이다. 요청된 확률에서 cutoff를 한 번 계산한 뒤, 각 후보 식별자마다 (식별자, 시드)를 해시해서 cutoff 미만이면 수락한다. 해시가 식별자와 시드의 결정적 함수이므로 표본은 재현 가능하고(시드 고정) 이력 독립적이다(스캔 순서, 테이블 증가, 동시 유지보수에 무관). PRNG 스트림을 스캔 순서로 소비하는 대안에 비해 이 방식이 엄밀히 낫다.

모든 구현이 처리해야 하는 미묘한 점은 샘플링과 가시성·동시성의 상호작용이다. 샘플링 메서드는 해당 오프셋에 현재 스냅샷에 가시적인 튜플이 있는지 모르는 채로 튜플 슬롯(블록 번호와 오프셋)을 선택한다. 메서드의 역할은 순전히 포함 여부 결정이고, 스토리지 엔진이 이후 정상적인 MVCC 가시성 검사를 수행해 죽거나 불가시한 슬롯을 조용히 제거한다. 이 순서가 비편향 행 표본을 유지하는 이유는, 각 오프셋에 독립적으로 동전을 던지기 때문이다. 불가시 튜플에 쓴 추가 동전 던지기가 생존 행에 편향을 만들지 않는다. 모든 튜플이 동일한 수락 확률을 공유하기 때문이다.

PostgreSQL은 TABLESAMPLE을 네 계층 스택으로 실현한다.

맨 위에서 파서(parse_clause.ctransformRangeTableSample)는 메서드 이름을 핸들러 함수 OID로 해석한다. SYSTEMBERNOULLI는 문법에 내장된 키워드가 아니라 tsm_handler 의사 타입을 반환하는 일반 함수 tsm_system_handler / tsm_bernoulli_handlerpg_proc에서 조회된다. 핸들러는 GetTsmRoutine(tablesample.c)으로 한 번 호출되어 TsmRoutine 노드를 반환한다. TsmRoutine은 함수 포인터 vtable과 두 플래그(쿼리 간 재현성, 스캔 간 재현성)로 구성된다. 이는 PostgreSQL이 인덱스 AM(IndexAmRoutine), 테이블 AM(TableAmRoutine), FDW에서 사용하는 “핸들러가 루틴 구조체를 반환하는” 동일한 패턴이다.

플래너에서 set_tablesample_rel_size(allpaths.c)는 메서드의 SampleScanGetSampleSize를 호출해 읽을 페이지 수와 반환할 튜플 수를 파악하고 릴레이션 추정치를 그 값으로 덮어쓴다. 이어서 cost_samplescan(costsize.c)이 경로에 비용을 매긴다. NextSampleBlock이 있는 메서드(비순차 블록 접근)에는 랜덤 페이지 비용을, 없는 메서드에는 순차 페이지 비용을 사용한다. 플래너는 repeatable_across_scans 플래그도 확인한다. 샘플링된 릴레이션이 두 번 이상 스캔될 가능성이 있고(예: nestloop의 내측) 메서드가 표본을 재현할 수 없다면 SampleScan을 Materialize 노드로 감싼다.

실행 시에는 nodeSamplescan.cSampleScan 플랜 노드를 구현한다. ExecInitSampleScan은 릴레이션을 열고, TABLESAMPLE 인수 표현식과 선택적 REPEATABLE 표현식을 초기화하고, REPEATABLE 절이 없으면 전역 PRNG에서 랜덤 시드를 한 번 뽑아 저장한 뒤, 핸들러를 실행해 루틴을 얻고 메서드의 InitSampleScan을 호출한다. 첫 번째 튜플 요청 시 tablesample_init이 퍼센트 인수와 시드를 평가하고 BeginSampleScan을 호출한다. 이후 드라이버 루프 tablesample_getnext가 테이블 AM에 다음 샘플 블록과 다음 샘플 튜플을 반복적으로 요청한다. NextSampleBlock이 있는 메서드를 쓸 때 syncscan이 비활성화됨에 주목해야 한다. 블록 선택 메서드는 블록 순서를 직접 결정하므로 다른 위치에서 시작한 동기화 스캔이 그 순서를 교란해서는 안 된다.

스토리지 계층에서 힙 테이블 AM(heapam_handler.cheapam_scan_sample_next_block / heapam_scan_sample_next_tuple)은 추상 메서드와 물리 페이지를 연결한다. NextSampleBlock을 호출해 페이지를 고르거나, 메서드에 NextSampleBlock이 없으면 syncscan 위치를 보고하며 순차적으로 페이지를 읽는다. 그 다음 NextSampleTuple을 반복 호출해 오프셋을 선택하고 MVCC 가시성 검사를 거쳐 각 튜플을 결과 슬롯에 저장한다. 이 연결 고리가 테이블 AM 안에 있기 때문에 TSM API는 스토리지에 무관하다.

flowchart TD
  SQL["SELECT ... TABLESAMPLE SYSTEM(1.5) REPEATABLE(42)"]
  PARSE["transformRangeTableSample<br/>parse_clause.c<br/>메서드 이름 → 핸들러 OID 해석"]
  HANDLER["tsm_system_handler / tsm_bernoulli_handler<br/>TsmRoutine vtable 반환"]
  PLAN["set_tablesample_rel_size + cost_samplescan<br/>SampleScanGetSampleSize -> 페이지, 튜플"]
  INIT["ExecInitSampleScan<br/>seed = REPEATABLE? hashfloat8(seed) : random<br/>InitSampleScan (tsm_state 할당)"]
  BEGIN["tablesample_init -> BeginSampleScan<br/>퍼센트 검증, cutoff 계산"]
  DRIVE["tablesample_getnext 루프"]
  NB["NextSampleBlock<br/>(SYSTEM만; BERNOULLI = NULL -> 순차 페이지)"]
  NT["NextSampleTuple<br/>페이지 내 오프셋 선택"]
  VIS["MVCC 가시성 검사<br/>SampleHeapTupleVisible"]
  OUT["결과 슬롯 -> ExecScan"]

  SQL --> PARSE --> HANDLER --> PLAN --> INIT --> BEGIN --> DRIVE
  DRIVE --> NB --> NT --> VIS --> OUT
  VIS -. 불가시/죽은 튜플 .-> NT
  NT -. 페이지 소진 .-> NB

이 가이드는 호출 흐름을 위에서 아래로 따라간다. API 계약(TsmRoutine), 메서드를 해석하고 비용을 산정하는 파서·플래너 접착부, 실행기 드라이버 루프, 두 내장 메서드(SYSTEM과 BERNOULLI), 마지막으로 가시성을 적용하는 힙 AM 연결 고리 순서다. 모든 코드 발췌는 REL_18(273fe94)에서 압축한 것이며, 각 발췌 앞에는 그렙할 수 있도록 // 심볼 — 경로 형태의 주석을 달았다.

1. API 계약 — TsmRoutineGetTsmRoutine

섹션 제목: “1. API 계약 — TsmRoutine과 GetTsmRoutine”

테이블샘플 메서드는 콜백 구조체 하나와 두 개의 기능 플래그로 완전히 기술된다. 헤더는 NULL이 허용되는 콜백을 문서화한다.

// TsmRoutine — src/include/access/tsmapi.h
typedef struct TsmRoutine
{
NodeTag type;
List *parameterTypes; /* TABLESAMPLE 인수의 데이터타입 OID */
bool repeatable_across_queries;
bool repeatable_across_scans;
SampleScanGetSampleSize_function SampleScanGetSampleSize; /* 플래너 */
InitSampleScan_function InitSampleScan; /* NULL 가능 */
BeginSampleScan_function BeginSampleScan;
NextSampleBlock_function NextSampleBlock; /* NULL 가능 */
NextSampleTuple_function NextSampleTuple;
EndSampleScan_function EndSampleScan; /* NULL 가능 */
} TsmRoutine;

두 플래그는 플래너가 읽는 정책 힌트다. repeatable_across_queriesREPEATABLE 절 허용 여부를 결정하고 (이 플래그를 끈 메서드에 REPEATABLE을 쓰면 파서가 거부), repeatable_across_scans는 여러 번 스캔되는 SampleScan을 Materialize로 감쌀지 결정한다. NextSampleBlock의 NULL 허용 여부는 핵심 의미를 가진다. 블록을 선택하지 않는 메서드(BERNOULLI)는 이를 NULL로 두고, 이 사실 하나가 바깥으로 파급된다. 실행기는 syncscan을 활성화하고, 힙 AM은 페이지를 순차적으로 읽고, 비용 모델은 순차 페이지 비용을 적용한다.

GetTsmRoutinetablesample.c의 유일한 공개 진입점이다.

// GetTsmRoutine — src/backend/access/tablesample/tablesample.c
TsmRoutine *
GetTsmRoutine(Oid tsmhandler)
{
Datum datum;
TsmRoutine *routine;
datum = OidFunctionCall1(tsmhandler, PointerGetDatum(NULL));
routine = (TsmRoutine *) DatumGetPointer(datum);
if (routine == NULL || !IsA(routine, TsmRoutine))
elog(ERROR, "tablesample handler function %u did not return a TsmRoutine struct",
tsmhandler);
return routine;
}

핸들러는 NULL 인수로 호출되며, 메서드는 인수를 무시하고 vtable만 반환한다. 핸들러 자체는 간단하다. makeNode(TsmRoutine)으로 설정되지 않은 필드를 NULL로 초기화한 뒤 콜백을 할당한다. SYSTEM은 두 재현성 플래그를 모두 세우고 EndSampleScan을 제외한 다섯 콜백을 모두 공급한다.

// tsm_system_handler — src/backend/access/tablesample/system.c
Datum
tsm_system_handler(PG_FUNCTION_ARGS)
{
TsmRoutine *tsm = makeNode(TsmRoutine);
tsm->parameterTypes = list_make1_oid(FLOAT4OID);
tsm->repeatable_across_queries = true;
tsm->repeatable_across_scans = true;
tsm->SampleScanGetSampleSize = system_samplescangetsamplesize;
tsm->InitSampleScan = system_initsamplescan;
tsm->BeginSampleScan = system_beginsamplescan;
tsm->NextSampleBlock = system_nextsampleblock; /* SYSTEM은 블록 선택 */
tsm->NextSampleTuple = system_nextsampletuple;
tsm->EndSampleScan = NULL;
PG_RETURN_POINTER(tsm);
}

BERNOULLI는 NextSampleBlock = NULL만 다르고 나머지는 동일하다. 블록을 선택하지 않고 AM이 건네주는 블록 안에서 튜플만 고른다.

transformRangeTableSampleTABLESAMPLE method(args) REPEATABLE(seed)TableSampleClause로 변환한다. 메서드 이름은 tsm_handler 의사 타입을 반환하는 일반 함수로 해석된다.

// transformRangeTableSample — src/backend/parser/parse_clause.c
handlerOid = LookupFuncName(rts->method, 1, funcargtypes, true);
/* ... OidIsValid 실패나 반환 타입 불일치 시 오류 ... */
tsm = GetTsmRoutine(handlerOid);
tablesample = makeNode(TableSampleClause);
tablesample->tsmhandler = handlerOid;
if (list_length(rts->args) != list_length(tsm->parameterTypes))
ereport(ERROR, (errcode(ERRCODE_INVALID_TABLESAMPLE_ARGUMENT), ...));
/* ... 각 인수를 메서드의 parameterTypes로 형 변환 ... */
if (rts->repeatable != NULL)
{
if (!tsm->repeatable_across_queries)
ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("tablesample method %s does not support REPEATABLE", ...)));
/* REPEATABLE 표현식을 float8로 강제 변환 */
}

REPEATABLE을 float8(정수가 아님)로 강제 변환하는 이유는 의도적이다. nodeSamplescan.c 소스 주석에 따르면, 정수 시드를 기대하는 사용자와 setseed()처럼 [-1, 1] 범위의 값을 기대하는 사용자 모두에게 직관적인 동작을 보장하기 위해서다.

플래너에서 set_tablesample_rel_size는 메서드에 표본 크기를 묻고 릴레이션 추정치를 덮어쓴다. 이후 플래너 전체가 샘플링된 릴레이션을 더 작은 테이블로 인식한다.

// set_tablesample_rel_size — src/backend/optimizer/path/allpaths.c
tsm = GetTsmRoutine(tsc->tsmhandler);
tsm->SampleScanGetSampleSize(root, rel, tsc->args, &pages, &tuples);
rel->pages = pages;
rel->tuples = tuples;
set_baserel_size_estimates(root, rel);

set_tablesample_rel_pathlist는 유일한 SampleScan 경로를 만들고 재현성 가드를 적용한다.

// set_tablesample_rel_pathlist — src/backend/optimizer/path/allpaths.c
path = create_samplescan_path(root, rel, required_outer);
if ((root->query_level > 1 ||
bms_membership(root->all_query_rels) != BMS_SINGLETON) &&
!(GetTsmRoutine(rte->tablesample->tsmhandler)->repeatable_across_scans))
{
path = (Path *) create_material_path(rel, path);
}
add_path(rel, path);

cost_samplescan은 경로에 비용을 매긴다. NextSampleBlock 유무로 페이지 비용 종류를 결정하는 핵심 줄이 있다.

// cost_samplescan — src/backend/optimizer/path/costsize.c
/* NextSampleBlock 사용 시 랜덤 접근, 그렇지 않으면 순차 접근 */
spc_page_cost = (tsm->NextSampleBlock != NULL) ?
spc_random_page_cost : spc_seq_page_cost;
run_cost += spc_page_cost * baserel->pages; /* pages는 이미 덮어쓴 값 */
/* ... */
cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple;
run_cost += cpu_per_tuple * baserel->tuples; /* tuples도 이미 덮어쓴 값 */

baserel->pagesbaserel->tuples가 이미 SampleScanGetSampleSize로 덮어써졌으므로, 비용 모델은 별도의 샘플링 수식 없이 표준 페이지·튜플 단가를 곱하면 된다.

3. 실행기 드라이버 — nodeSamplescan.c

섹션 제목: “3. 실행기 드라이버 — nodeSamplescan.c”

ExecInitSampleScan은 노드를 설정하고 시드를 한 번 고정해 리스캔에도 살아남게 한다. REPEATABLE 절이 없으면 전역 PRNG에서 스캔별 랜덤 시드를 뽑는다.

// ExecInitSampleScan — src/backend/executor/nodeSamplescan.c
scanstate->args = ExecInitExprList(tsc->args, (PlanState *) scanstate);
scanstate->repeatable = ExecInitExpr(tsc->repeatable, (PlanState *) scanstate);
/* REPEATABLE 절이 없으면 랜덤 시드를 한 번만 선택한다. */
if (tsc->repeatable == NULL)
scanstate->seed = pg_prng_uint32(&pg_global_prng_state);
tsm = GetTsmRoutine(tsc->tsmhandler);
scanstate->tsmroutine = tsm;
scanstate->tsm_state = NULL;
if (tsm->InitSampleScan)
tsm->InitSampleScan(scanstate, eflags);
scanstate->begun = false; /* BeginSampleScan은 첫 튜플 요청까지 지연 */

인수 값은 초기화 시에 평가할 수 없다(외부 LATERAL 열을 참조할 수 있기 때문). 그래서 BeginSampleScan은 첫 번째 SampleNext로 지연되며, tablesample_init이 처리한다. REPEATABLE의 경우 float8 값을 hashfloat8으로 해시해 시드를 확정한다. 소스 주석은 이 방식이 REPEATABLE(0)을 머신 독립적으로 만들어 회귀 테스트에 유용하다고 설명한다.

// tablesample_init — src/backend/executor/nodeSamplescan.c
if (scanstate->repeatable)
{
datum = ExecEvalExprSwitchContext(scanstate->repeatable, econtext, &isnull);
if (isnull)
ereport(ERROR, (errcode(ERRCODE_INVALID_TABLESAMPLE_REPEAT), ...));
/* hashfloat8으로 float8 시드를 uint32로 변환; REPEATABLE(0)은 이식 가능 */
seed = DatumGetUInt32(DirectFunctionCall1(hashfloat8, datum));
}
else
seed = scanstate->seed; /* 초기화 시 뽑은 스캔별 랜덤 시드 */
scanstate->use_bulkread = true; /* BeginSampleScan이 재정의할 수 있음 */
scanstate->use_pagemode = true;
tsm->BeginSampleScan(scanstate, params, list_length(scanstate->args), seed);
/* NextSampleBlock 함수가 없으면 syncscan 허용 */
allow_sync = (tsm->NextSampleBlock == NULL);

마지막 줄이 실제 중요한 이유가 있다. 블록을 스스로 고르는 메서드는 동기화된 순차 스캔이 시작 블록을 결정하게 해서는 안 된다. 그렇게 되면 표본이 동시 스캔 활동에 의존하게 된다. NextSampleBlock이 없는 메서드는 페이지를 순차적으로 읽으므로 syncscan을 안전하게 탈 수 있다.

드라이버 루프는 테이블 AM에 위임하는 블록·튜플 이중 반복이다.

// tablesample_getnext — src/backend/executor/nodeSamplescan.c
for (;;)
{
if (!scanstate->haveblock)
{
if (!table_scan_sample_next_block(scan, scanstate))
{
scanstate->done = true;
return NULL; /* 릴레이션 소진 */
}
scanstate->haveblock = true;
}
if (!table_scan_sample_next_tuple(scan, scanstate, slot))
{
scanstate->haveblock = false; /* 페이지 소진, 다음 블록으로 */
continue;
}
break; /* 가시적인 샘플 튜플 발견 */
}
scanstate->donetuples++;
return slot;

ExecReScanSampleScanbegun/done/haveblock을 리셋하지만 시드는 리셋하지 않는다. 재현 가능한 메서드의 리스캔이 동일한 표본을 재현할 수 있는 이유가 여기 있으며, 플래너가 Materialize 래퍼를 생략할 수 있는 전제이기도 하다.

SYSTEM의 비공개 상태는 정수 cutoff, 시드, 다음으로 검토할 블록 번호, 마지막으로 반환한 오프셋을 보유한다.

// SystemSamplerData — src/backend/access/tablesample/system.c
typedef struct
{
uint64 cutoff; /* 이 값 미만의 해시를 가진 블록을 수락 */
uint32 seed;
BlockNumber nextblock; /* 다음으로 검토할 블록 */
OffsetNumber lt; /* 현재 블록에서 마지막으로 반환한 튜플 */
} SystemSamplerData;

BeginSampleScan은 퍼센트를 검증하고 32비트 수락 cutoff로 변환한다. cutoff는 round((2^32) * percent/100)을 uint64에 저장한 값이다. uint32 해시를 이 값과 비교하면 수락 확률이 percent/100이 되고, 0%와 100% 경계에서도 공식이 정확하다.

// system_beginsamplescan — src/backend/access/tablesample/system.c
double percent = DatumGetFloat4(params[0]);
if (percent < 0 || percent > 100 || isnan(percent))
ereport(ERROR, (errcode(ERRCODE_INVALID_TABLESAMPLE_ARGUMENT),
errmsg("sample percentage must be between 0 and 100")));
dcutoff = rint(((double) PG_UINT32_MAX + 1) * percent / 100);
sampler->cutoff = (uint64) dcutoff;
sampler->seed = seed;
sampler->nextblock = 0;
sampler->lt = InvalidOffsetNumber;
/* 아주 작은 비율이 아니면 bulkread; pagemode는 항상(페이지 전체 스캔) */
node->use_bulkread = (percent >= 1);
node->use_pagemode = true;

NextSampleBlock이 SYSTEM의 핵심이다. 블록 번호를 순서대로 스캔하며 각 (blockno, seed) 쌍을 해시해 cutoff 미만인 첫 블록을 수락한다. PRNG가 아닌 블록+시드 해시를 사용하는 덕분에 블록별 결정이 머신 독립적이고 이력 독립적이다.

// system_nextsampleblock — src/backend/access/tablesample/system.c
uint32 hashinput[2];
hashinput[1] = sampler->seed; /* 스캔 전체에서 동일 */
for (; nextblock < nblocks; nextblock++)
{
uint32 hash;
hashinput[0] = nextblock;
hash = DatumGetUInt32(hash_any((const unsigned char *) hashinput,
(int) sizeof(hashinput)));
if (hash < sampler->cutoff)
break; /* 이 블록 수락 */
}
if (nextblock < nblocks)
{
sampler->nextblock = nextblock + 1; /* 다음 호출은 이 이후부터 */
return nextblock;
}
sampler->nextblock = 0;
return InvalidBlockNumber; /* 완료 */

블록이 수락되면 SYSTEM은 그 블록의 모든 튜플을 반환한다. 이것이 블록(클러스터) 샘플링의 정의적 동작이다. NextSampleTuple은 튜플별 동전 던지기 없이 오프셋 1..maxoffset을 순서대로 걷는다.

// system_nextsampletuple — src/backend/access/tablesample/system.c
OffsetNumber tupoffset = sampler->lt;
if (tupoffset == InvalidOffsetNumber)
tupoffset = FirstOffsetNumber;
else
tupoffset++;
if (tupoffset > maxoffset)
tupoffset = InvalidOffsetNumber; /* 페이지 완료 -> 다음 블록 */
sampler->lt = tupoffset;
return tupoffset;

SYSTEM의 SampleScanGetSampleSize는 페이지와 튜플 모두를 fraction * baserel로 추정한다. 전체 페이지 중 일부만 읽기 때문이다.

// system_samplescangetsamplesize — src/backend/access/tablesample/system.c
*pages = clamp_row_est(baserel->pages * samplefract); /* 페이지의 비율 */
*tuples = clamp_row_est(baserel->tuples * samplefract);

BERNOULLI의 상태는 블록 선택을 하지 않으므로 nextblock을 없애고 cutoff, 시드, 마지막 오프셋만 보유한다.

// BernoulliSamplerData — src/backend/access/tablesample/bernoulli.c
typedef struct
{
uint64 cutoff; /* 이 값 미만의 해시를 가진 튜플을 수락 */
uint32 seed;
OffsetNumber lt; /* 현재 블록에서 마지막으로 반환한 튜플 */
} BernoulliSamplerData;

BeginSampleScan은 동일한 cutoff 공식을 계산하되, 버퍼 전략 힌트가 다르다. 모든 페이지를 스캔하므로 항상 bulkread를 쓰고, pagemode는 큰 비율에서만 켠다.

// bernoulli_beginsamplescan — src/backend/access/tablesample/bernoulli.c
dcutoff = rint(((double) PG_UINT32_MAX + 1) * percent / 100);
sampler->cutoff = (uint64) dcutoff;
sampler->seed = seed;
sampler->lt = InvalidOffsetNumber;
/* 모든 페이지 스캔 -> 항상 bulkread; pagemode는 >=25%에서만 효과적 */
node->use_bulkread = true;
node->use_pagemode = (percent >= 25);

NextSampleBlock이 없으므로 힙 AM이 페이지를 순차적으로 읽는다. NextSampleTuple이 튜플별 동전이 있는 곳이다. 각 오프셋마다 (blockno, offset, seed)를 해시해 cutoff 미만이면 수락한다. 오프셋별 독립 해시가 이 방식을 진정한 단순 임의 표본으로 만드는 요소다.

// bernoulli_nextsampletuple — src/backend/access/tablesample/bernoulli.c
OffsetNumber tupoffset = sampler->lt;
uint32 hashinput[3];
if (tupoffset == InvalidOffsetNumber)
tupoffset = FirstOffsetNumber;
else
tupoffset++;
hashinput[0] = blockno; /* 블록 전체에서 동일 */
hashinput[2] = sampler->seed; /* 스캔 전체에서 동일 */
for (; tupoffset <= maxoffset; tupoffset++)
{
uint32 hash;
hashinput[1] = tupoffset;
hash = DatumGetUInt32(hash_any((const unsigned char *) hashinput,
(int) sizeof(hashinput)));
if (hash < sampler->cutoff)
break; /* 이 튜플 수락 */
}
if (tupoffset > maxoffset)
tupoffset = InvalidOffsetNumber; /* 페이지 완료 */
sampler->lt = tupoffset;
return tupoffset;

이 함수 앞의 주석이 통계적 정당성을 설명한다. “테이블의 모든 튜플 오프셋에 동전 던지기를 수행한다. 모든 튜플이 동일한 반환 확률을 가지므로 불가시 튜플에 추가로 동전 던지기를 해도 무방하다.” BERNOULLI의 SampleScanGetSampleSize는 페이지는 전체를 추정하고 튜플만 비율로 추정한다. 전체 테이블을 읽어야 하기 때문이다.

// bernoulli_samplescangetsamplesize — src/backend/access/tablesample/bernoulli.c
*pages = baserel->pages; /* 모든 페이지 방문 */
*tuples = clamp_row_est(baserel->tuples * samplefract);

*pages = baserel->pages 대 SYSTEM의 *pages = pages * fract가 두 메서드 간 전체 비용 차이를 포착하는 단 하나의 추정치다.

6. 힙 AM 연결 고리 — 가시성 적용

섹션 제목: “6. 힙 AM 연결 고리 — 가시성 적용”

드라이버의 table_scan_sample_next_block / _next_tuple은 힙 AM으로 디스패치된다. heapam_scan_sample_next_block은 메서드의 NextSampleBlock을 호출하거나, NULL이면 syncscan 위치를 보고하며 페이지를 순차적으로 읽은 뒤 선택한 페이지를 읽는다(pagemode에서는 pruning과 가시성 사전 계산도 수행).

// heapam_scan_sample_next_block — src/backend/access/heap/heapam_handler.c
if (tsm->NextSampleBlock)
blockno = tsm->NextSampleBlock(scanstate, hscan->rs_nblocks);
else
{ /* 순차 스캔 (BERNOULLI) */
if (hscan->rs_cblock == InvalidBlockNumber)
blockno = hscan->rs_startblock;
else
{
blockno = hscan->rs_cblock + 1;
if (blockno >= hscan->rs_nblocks)
blockno = 0; /* 래핑 */
if (scan->rs_flags & SO_ALLOW_SYNC)
ss_report_location(scan->rs_rd, blockno);
if (blockno == hscan->rs_startblock)
blockno = InvalidBlockNumber; /* 한 바퀴 완료 -> 종료 */
}
}
/* ... 페이지 읽기; pagemode이면 heap_prepare_pagescan으로 pruning + 가시성 계산 ... */

heapam_scan_sample_next_tupleNextSampleTuple에 오프셋을 반복 요청해 MVCC 검사를 통과한 튜플만 반환한다. 죽은 라인 포인터와 불가시 튜플은 건너뛴다. BERNOULLI가 의존하는 “추가 동전 던지기가 편향을 만들지 않는다”는 계약이 여기서 정확히 구현된다.

// heapam_scan_sample_next_tuple — src/backend/access/heap/heapam_handler.c
for (;;)
{
tupoffset = tsm->NextSampleTuple(scanstate, blockno, maxoffset);
if (OffsetNumberIsValid(tupoffset))
{
itemid = PageGetItemId(page, tupoffset);
if (!ItemIdIsNormal(itemid))
continue; /* 죽은/리다이렉트 슬롯 건너뜀 */
/* (blockno, tupoffset)에서 HeapTuple 구성 ... */
if (all_visible)
visible = true;
else
visible = SampleHeapTupleVisible(scan, hscan->rs_cbuf, tuple, tupoffset);
if (!visible)
continue; /* 불가시 -> 다음 오프셋 */
ExecStoreBufferHeapTuple(tuple, slot, hscan->rs_cbuf);
return true;
}
else
{ /* NextSampleTuple이 Invalid 반환 -> 페이지 소진 */
ExecClearTuple(slot);
return false;
}
}

all_visible 고속 경로(PageIsAllVisible(page) && !takenDuringRecovery)는 가시성 맵상 전체 가시 페이지에서 튜플별 가시성 검사를 생략한다 (postgres-visibility-map.md 참조). SYSTEM에서 수락된 페이지의 모든 오프셋을 반환하는 경우에 실질적인 이점이 있다. 비-pagemode에서는 버퍼에 콘텐츠 락을 잡은 채로 가시성 검사와 HeapCheckForSerializableConflictOut을 호출한다. 테이블 샘플링은 다른 스캔과 동일하게 SSI 술어 잠금에 참여한다 (postgres-ssi-predicate-locking.md 참조).

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
TsmRoutine (struct)src/include/access/tsmapi.h56
GetTsmRoutinesrc/backend/access/tablesample/tablesample.c27
tsm_system_handlersrc/backend/access/tablesample/system.c67
SystemSamplerData (struct)src/backend/access/tablesample/system.c37
system_samplescangetsamplesizesrc/backend/access/tablesample/system.c88
system_beginsamplescansrc/backend/access/tablesample/system.c139
system_nextsampleblocksrc/backend/access/tablesample/system.c178
system_nextsampletuplesrc/backend/access/tablesample/system.c236
tsm_bernoulli_handlersrc/backend/access/tablesample/bernoulli.c65
BernoulliSamplerData (struct)src/backend/access/tablesample/bernoulli.c37
bernoulli_samplescangetsamplesizesrc/backend/access/tablesample/bernoulli.c86
bernoulli_beginsamplescansrc/backend/access/tablesample/bernoulli.c136
bernoulli_nextsampletuplesrc/backend/access/tablesample/bernoulli.c181
SampleScanState (struct)src/include/nodes/execnodes.h1631
ExecInitSampleScansrc/backend/executor/nodeSamplescan.c92
tablesample_initsrc/backend/executor/nodeSamplescan.c217
tablesample_getnextsrc/backend/executor/nodeSamplescan.c319
ExecReScanSampleScansrc/backend/executor/nodeSamplescan.c201
transformRangeTableSamplesrc/backend/parser/parse_clause.c907
set_tablesample_rel_sizesrc/backend/optimizer/path/allpaths.c825
set_tablesample_rel_pathlistsrc/backend/optimizer/path/allpaths.c865
cost_samplescansrc/backend/optimizer/path/costsize.c370
heapam_scan_sample_next_blocksrc/backend/access/heap/heapam_handler.c2174
heapam_scan_sample_next_tuplesrc/backend/access/heap/heapam_handler.c2264

아래의 모든 사항은 커밋 273fe94의 REL_18_STABLE 작업 트리에서 검증했다.

  • 코어 메서드 두 개, 소스 파일 두 개. system.cbernoulli.csrc/backend/access/tablesample/에서 공유 tablesample.c를 제외한 유일한 파일이다. 각각 핸들러(tsm_system_handler / tsm_bernoulli_handler) 하나씩을 정의하며 pg_proc.dattsm_handler 의사 타입을 반환하도록 등록되어 있다.

  • TsmRoutine vtable은 정확히 일곱 개의 콜백과 두 개의 플래그로 구성된다. src/include/access/tsmapi.h에서 확인. InitSampleScan, NextSampleBlock, EndSampleScan/* can be NULL */ 주석이 있으며, SampleScanGetSampleSize, BeginSampleScan, NextSampleTuple은 두 내장 메서드 모두에서 항상 공급된다.

  • SYSTEM은 NextSampleBlock을 공급하고 BERNOULLI는 NULL로 설정한다. 두 핸들러 모두에서 확인. 이 NULL 여부는 세 곳에서 읽힌다. tablesample_init(allow_sync = (tsm->NextSampleBlock == NULL)), cost_samplescan(spc_page_cost = NextSampleBlock != NULL ? random : seq), heapam_scan_sample_next_block(메서드 호출 대 순차 걷기). 모두 검증됨.

  • cutoff 공식이 두 메서드에서 동일하다. 두 메서드 모두 dcutoff = rint(((double) PG_UINT32_MAX + 1) * percent / 100)을 계산해 uint64로 저장한다. system_beginsamplescanbernoulli_beginsamplescan에서 바이트 단위로 동일함을 확인. 인수는 단일 FLOAT4OID(parameterTypes = list_make1_oid(FLOAT4OID))이며, 오류 코드 ERRCODE_INVALID_TABLESAMPLE_ARGUMENT[0,100] 범위를 검증한다.

  • 해시 입력의 원소 수가 다르다. SYSTEM은 2원소 uint32 배열 {blockno, seed}를 해시하고, BERNOULLI는 3원소 배열 {blockno, offset, seed}를 해시한다. 두 메서드 모두 sizeof(hashinput)hash_any를 적용하고 hash < sampler->cutoff일 때 수락한다. system_nextsampleblockbernoulli_nextsampletuple에서 확인.

  • 시드 처리. REPEATABLE 절이 없으면 ExecInitSampleScanpg_prng_uint32(&pg_global_prng_state)를 한 번 뽑고, REPEATABLE이 있으면 tablesample_initDirectFunctionCall1(hashfloat8, datum)으로 결정적 시드를 유도한다. REPEATABLE 인수는 파서에서 FLOAT8OID로 강제 변환된다. 모두 검증됨.

  • 크기 추정 비대칭. SYSTEM은 *pages = clamp_row_est(pages * fract), BERNOULLI는 *pages = baserel->pages. 두 메서드 모두 *tuples = clamp_row_est(tuples * fract). 검증 완료. 이 단 하나의 차이가 블록 대 행 I/O 비용 구분을 인코딩한다.

  • 가시성은 메서드가 아닌 힙 AM이 적용한다. NextSampleTuple은 가시성 보장 없이 오프셋을 반환한다. system.cbernoulli.c 양쪽의 주석이 nodeSamplescan / AM이 처리한다고 명시하며, heapam_scan_sample_next_tuple이 가시성 맵 고속 경로와 함께 SampleHeapTupleVisibleHeapCheckForSerializableConflictOut을 호출한다. 검증됨.

유의할 미세 사항: bulkread 1% 임계값(SYSTEM)과 pagemode 25% 임계값(BERNOULLI)은 소스 주석이 “매우 제한적인 실험에 기반한 추정”이라고 밝힌 조정 상수다. 이 정확한 임계값은 불변이 아니라 버전별 조정 대상으로 취급해야 한다.

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

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

PostgreSQL의 TSM API는 SQL:2003 샘플링을 신실하고 최소한으로 구현하며, 그 설계 선택들은 더 넓은 지형과 비교할 때 의미가 명확해진다.

확장 메서드: 수·시간 기반 샘플링. 퍼센트 기반 인터페이스만이 유용한 계약은 아니다. TSM API의 플러그인 가능성 덕분에 코어는 작게 유지하면서 contrib의 두 모듈이 다른 정책을 추가한다. tsm_system_rows는 퍼센트가 아닌 대략 고정된 행 수를 블록 선택으로 샘플링하고, tsm_system_time은 고정된 시간 예산(밀리초)만큼 샘플링한다. 두 모듈 모두 이 문서의 범위 밖(core-only)이지만, 네 콜백이 진정으로 다른 샘플링 의미론에 충분하다는 정준 증거다. 각 모듈은 인수를 해석하는 자체 BeginSampleScan과 조건에서 멈추는 자체 NextSampleBlock을 제공한다. API의 좁은 허리(추정 / 시작 / 다음 블록 / 다음 튜플)가 올바른 위치에 그어졌다는 교훈이다.

flowchart LR
  subgraph SYSTEM["SYSTEM — 블록 샘플링"]
    SB["hash(blockno, seed) < cutoff?"]
    SACC["블록 수락<br/>모든 오프셋 반환"]
    SREJ["블록 거절"]
    SB -- 예 --> SACC
    SB -- 아니오 --> SREJ
  end
  subgraph BERN["BERNOULLI — 행 샘플링"]
    BB["hash(blockno, offset, seed) < cutoff?"]
    BACC["이 튜플 수락"]
    BREJ["이 튜플 거절"]
    BB -- 예 --> BACC
    BB -- 아니오 --> BREJ
  end
  COST["비용: SYSTEM은 fract*pages 읽기 (랜덤)<br/>BERNOULLI는 전체 페이지 읽기 (순차)"]
  STAT["통계: SYSTEM은 클러스터링됨 (디자인 효과)<br/>BERNOULLI는 진정한 단순 임의 표본"]
  SYSTEM --> COST
  BERN --> COST
  SYSTEM --> STAT
  BERN --> STAT

다른 엔진들. SQL Server의 TABLESAMPLE은 블록 전용이며(T-SQL에 BERNOULLI 등가물이 없고 작은 표본은 행 0개를 반환할 수 있음) ROWS 수 형식과 동일한 결정적 시드 아이디어를 구현한 REPEATABLE(seed) 절을 추가한다. DB2 LUW는 REPEATABLE과 함께 SYSTEMBERNOULLI 둘 다 구현해 PostgreSQL에 가장 가깝다. Oracle은 표준보다 앞서 SAMPLE (n)(행)과 SAMPLE BLOCK (n)(블록), SEED 절을 제공하며, 주목할 점은 옵티마이저의 동적 샘플링 및 근사 쿼리(APPROX_COUNT_DISTINCT) 기계와 샘플링을 통합했다는 것이다. PostgreSQL이 코어에서 아직 취하지 않은 방향이다. 네 독립 제품이 “블록 대 행, 퍼센트, 선택적 시드”로 수렴했다는 점은 이 추상화가 자연스러운 것임을 강력히 지지한다.

통계 수집을 위한 샘플링, 쿼리를 위한 샘플링이 아닌. PostgreSQL의 ANALYZE는 TSM API를 사용하지 않는다. 고정 목표 행 수(default_statistics_target * 300)를 랜덤 블록에서 뽑는 자체 2단계 저수지 샘플러(Vitter 알고리즘)를 운용한다. postgres-analyze-transform.md 참조. 두 샘플링 서브시스템은 의도적으로 분리되어 있다. ANALYZE는 유한 메모리로 히스토그램을 만들기 위해 고정 크기 표본이 필요하고, TABLESAMPLE은 SQL에 노출하기 위해 고정 확률 표본이 필요하기 때문이다. 전자에는 저수지 샘플링(Vitter 1985)이, 후자에는 여기서 다룬 해시-임계값 Bernoulli/클러스터 샘플링이 각각 적합한 이론적 기반이다. 연구 프론티어 관점에서 주목할 점은, TABLESAMPLE 결과를 옵티마이저 추정치에 피드백하는 코어 메커니즘이 없다는 것이다. BlinkDB나 AQUA 프로젝트 같은 근사 쿼리 시스템의 진정한 온라인·적응형 샘플링과 달리, PostgreSQL의 샘플링은 내부 추정 루프가 아닌 사용자 대면 쿼리 연산자로 남아 있다.

블록 샘플링의 통계적 위험 재론. 클러스터링으로 인한 디자인 효과는 이론적인 이야기가 아니다. SYSTEM이 수락된 페이지의 모든 행을 반환하므로, 물리적 행 배치와 상관된 속성(일련 ID, 삽입 순서 타임스탬프, 클러스터 인덱스)은 SYSTEM 표본에서 체계적으로 왜곡된다. 표준의 의도와 일치하는 솔직한 지침은 다음과 같다. 빠른 대략적 탐색을 원하고 테이블이 관심 속성으로 물리 정렬되어 있지 않다면 SYSTEM을 쓰고, 비편향 행 표본이 필요하고 전체 페이지 읽기를 감당할 수 있다면 BERNOULLI를 쓰라. PostgreSQL이 두 방법을 모두 노출하고 다르게 비용을 매기는 이유는 정확히 이 트레이드오프를 플래너와 사용자가 명시적으로 선택하게 하려는 것이다.

병렬 처리. 코어에서 SampleScan은 자체적으로 병렬 인식이 없다. 플래너는 func_parallel(rte->tablesample->tsmhandler)와 인수의 병렬 안전성을 확인한 뒤 병렬 플랜 아래 샘플링된 릴레이션을 허용하지만, 샘플링 자체는 단일 워커의 스캔으로 수행된다. 블록 샘플링 병렬화(워커 간 블록 공간 분할, 해시-임계값 결정 보존)는 현재 API로 깔끔하게 확장할 수 있다. 블록 결정이 이미 (blockno, seed)의 순수 함수이므로 어떤 워커도 어떤 블록을 독립적으로 결정할 수 있기 때문이다.

  • PostgreSQL REL_18_STABLE 소스 (커밋 273fe94), 파일:
    • src/backend/access/tablesample/tablesample.cGetTsmRoutine
    • src/backend/access/tablesample/system.c — SYSTEM 메서드
    • src/backend/access/tablesample/bernoulli.c — BERNOULLI 메서드
    • src/include/access/tsmapi.hTsmRoutine vtable과 서명
    • src/backend/executor/nodeSamplescan.c — SampleScan 실행기 노드
    • src/backend/access/heap/heapam_handler.c — 힙 AM 샘플 연결 고리 + 가시성
    • src/backend/optimizer/path/allpaths.cset_tablesample_rel_size / _pathlist
    • src/backend/optimizer/path/costsize.ccost_samplescan
    • src/backend/parser/parse_clause.ctransformRangeTableSample
    • src/include/catalog/pg_proc.dat — 핸들러 함수 등록
  • Database System Concepts, Silberschatz, Korth & Sudarshan, 7판, 15장(쿼리 처리)과 샘플링·근사 쿼리 처리 고급 자료 — knowledge/research/dbms-general/database-system-concepts.md.
  • SQL:2003 표준, <table sample clause> (SYSTEM / BERNOULLI / REPEATABLE).
  • 이 트리 내 상호 참조: postgres-scan-nodes.md (SeqScan / IndexScan 노드 기계), postgres-index-am.md (인덱스 AM 핸들러 표면), postgres-table-am.md (TableAmRoutine), postgres-heap-am.md (힙 스캔 내부), postgres-analyze-transform.md (ANALYZE의 별도 저수지 샘플러), postgres-visibility-map.md (전체 가시 고속 경로), postgres-ssi-predicate-locking.md (스캔 중 직렬화 충돌 검사), postgres-cost-model.md (페이지/튜플 비용 상수).