En långvarig PostgreSQL-snapshot kan behålla mellanliggande radversioner som den aldrig läser. Vacuum använder en konservativ transaktionshorisont för att avgöra vad som kan tas bort. Den här genomgången förklarar varför och återskapar sedan beteendet med syntetiska artikelrader och node-postgres på en Mac.

Uppgiften är att skilja en snapshot som hindrar rensning från en vacuum som inte har körts. Exemplet riktar in sig på PostgreSQL 18 och använder två databasanslutningar: en läsare som håller en Repeatable Read-snapshot och en skrivare som uppdaterar en enda artikel. PostgreSQLs dokumentation anger för närvarande version 18 som den aktuella versionen med stöd; implementeringsdetaljerna nedan är uttryckligen knutna till dess 18-gren. PostgreSQL 18-dokumentation.

Varför behålls versioner som den gamla läsaren inte kan se?

En uppdatering skapar en ny radversion. PostgreSQL behåller den föregående versionen så länge en annan transaktion kan behöva den. Så småningom kan vacuum frigöra inaktuella versioner så att deras lagringsutrymme kan återanvändas. Det är den grundläggande MVCC-livscykel som beskrivs i Regelbunden vacuum-rensning.

Det subtila är att synlighet för snapshots och fysisk borttagbarhet besvarar olika frågor. Synlighet handlar om huruvida en viss fråga ska returnera en version. Rensning använder en konservativ gräns i stället för att för varje version bevisa exakt vilka befintliga snapshots som skulle kunna läsa den.

I PostgreSQL 18 jämför HeapTupleSatisfiesVacuum transaktionen som gjorde en version inaktuell med OldestXmin. En genomförd borttagning eller ersättning gör inte omedelbart den föregående versionen borttagbar: versioner vars borttagande transaktion är vid eller efter den gränsen klassificeras fortfarande som nyligen döda. Regeln dokumenteras direkt i den officiella implementeringen av tupelsynlighet.

Tänk på följande illustrativa sekvens. Varje uppdatering genomförs separat, och inga andra läsare är inblandade:

Steg Åtgärd Vad den gamla läsaren ser Senaste genomförda version
1 Infoga artikelrevision 0 — Revision 0
2 Läsaren upprättar en Repeatable Read-snapshot Revision 0 Revision 0
3 Skrivaren genomför revision 1 Revision 0 Revision 1
4 Skrivaren genomför revision 2 Revision 0 Revision 2
5 Skrivaren genomför revision 3 Revision 0 Revision 3

Läsaren behöver revision 0 direkt. Revision 1 och 2 är mellanliggande versioner: läsaren kan inte se dem eftersom de skapades efter dess snapshot. Deras ersättningstransaktioner är ändå också nyare än den kvarhållna rensningsgränsen. Den dokumenterade gränsregeln förklarar varför dessa versioner kan finnas kvar tillsammans med revision 0. Att de behålls betyder inte att läsaren kan gå igenom dem som historiska frågeresultat. Implementering av tupelsynlighet i PostgreSQL 18.

Horisonten omfattar mer än den valda raden som läsaren hämtar. PostgreSQL 18 beräknar horisonter som är anpassade för relationen utifrån backendarnas transaktions-ID:n och snapshot-horisonter; för vanliga tabelldata tar man i regel hänsyn till sessioner i samma databas. Systemet spårar inte bara raderna som returneras av en viss SELECT. Det förklarar varför en liten fråga kan påverka rensning på andra håll i databasen. Delade relationer och andra relationstyper har särskilda regler, så påståendet ”en snapshot stoppar all vacuum överallt” är alltför svepande. Implementering av horisonter i PostgreSQL 18.

Vilken transaktion håller faktiskt en långvarig snapshot?

Repeatable Read upprättar sin snapshot vid den första satsen som inte styr transaktionen och använder sedan den snapshoten för efterföljande frågor. Det räcker inte att bara utfärda BEGIN för den här demonstrationen; den första artikel-frågan etablerar den vy som måste behållas. Read Committed, PostgreSQLs standardisoleringsnivå, tar i stället en ny snapshot för varje kommando. Transaktionsisolering.

Därför kan en inaktiv Repeatable Read-transaktion som har frågat efter tabellen behålla sin snapshot även efter att frågan har avslutats. En långvarig Read Committed-sats kan också behålla en snapshot medan den körs, men separata avslutade satser på den nivån delar inte samma fasta vy. Transaktionens varaktighet räcker alltså inte i sig för att avgöra vilken snapshot som behålls. Transaktionsisolering.

En skrivskyddad transaktion kan bidra till horisonten utan att själv ha ett transaktions-ID på toppnivå. Implementeringen tar hänsyn till både backendarnas transaktions-ID:n och snapshot-horisonter. I det här exemplet är ett null-värde för backend_xid tillsammans med ett icke-null-värde för backend_xmin därför förenligt med att läsaren behåller en snapshot. Implementering av horisonter i PostgreSQL 18.

Återskapa beteendet från Mac Terminal

Du behöver en lokal PostgreSQL 18-server som körs, dess kommandoradsverktyg och Node.js med npm. Node.js 20 eller 24 anges i kompatibilitetsdokumentationen för node-postgres. Genomgången förutsätter att dina befintliga lokala anslutningsinställningar fungerar. Den skapar en separat databas och använder enbart syntetisk artikeltext. Introduktion och installation av node-postgres.

I Terminal skapar du en arbetskatalog, installerar drivrutinen och skapar exempeldatabasen:

mkdir article-snapshot-lab
cd article-snapshot-lab
npm init -y
npm install pg
createdb snapshot_lab

Spara hela programmet nedan som snapshot-demo.cjs. Anslutningarna ärver PostgreSQLs miljöinställningar, inklusive PGDATABASE, PGHOST och PGPORT, enligt dokumentationen i Ansluta med node-postgres. Använd samma lokala anslutningsinställningar för createdb och programmet.

Läsaren håller alla transaktionssatser på en enda dedikerad klient. Detta krävs av node-postgres: en transaktion måste använda samma klient hela tiden, så det fungerar inte att skicka dess satser via pool.query(). Den andra klienten kör uppdateringarna och vacuum-kommandona oberoende av varandra. Transaktioner med node-postgres.

Exemplet indexerar också revision, som ändras vid varje uppdatering. Det hindrar medvetet dessa uppdateringar från att använda HOT, vilket gör demonstrationen lättare att tolka. PostgreSQL dokumenterar att HOT kräver att uppdateringar lämnar kolumner som vanliga index refererar till oförändrade och att det finns tillräckligt med utrymme på den ursprungliga sidan. Endast-heap-tupler.

const { Client } = require('pg');

async function main() {
  const reader = new Client({
    application_name: 'article_snapshot_reader',
    connectionTimeoutMillis: 5000
  });
  const writer = new Client({
    application_name: 'article_snapshot_writer',
    connectionTimeoutMillis: 5000
  });
  let readerOpen = false;
  writer.on('notice', notice => console.log(notice.message));

  try {
    await reader.connect();
    await writer.connect();
    const version = await writer.query('SHOW server_version_num');
    const major = Math.floor(Number(version.rows[0].server_version_num) / 10000);
    if (major !== 18) throw new Error('This walkthrough targets PostgreSQL 18.');

    await writer.query(
      'CREATE TABLE public.article_snapshot_demo (' +
      'id integer PRIMARY KEY, revision integer NOT NULL, title text NOT NULL)'
    );
    await writer.query(
      'CREATE INDEX article_snapshot_demo_revision_idx ' +
      'ON public.article_snapshot_demo (revision)'
    );
    await writer.query(
      'INSERT INTO public.article_snapshot_demo VALUES (1, 0, $1)',
      ['Article draft']
    );

    await reader.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
    readerOpen = true;
    const selectRow = 'SELECT id, revision, title FROM public.article_snapshot_demo';
    console.log('Reader before:', (await reader.query(selectRow)).rows);

    for (const revision of [1, 2, 3]) {
      await writer.query(
        'UPDATE public.article_snapshot_demo SET revision = $1, title = $2 WHERE id = 1',
        [revision, 'Article revision ' + revision]
      );
    }

    const pid = (await reader.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
    console.log('Reader activity:', (await writer.query(
      'SELECT state, backend_xid, backend_xmin FROM pg_stat_activity WHERE pid = $1',
      [pid]
    )).rows);
    console.log('Writer latest:', (await writer.query(selectRow)).rows);
    console.log('Reader still:', (await reader.query(selectRow)).rows);

    console.log('Vacuum with snapshot open:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');

    await reader.query('COMMIT');
    readerOpen = false;

    console.log('Vacuum after reader commit:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
    console.log('Reader fresh:', (await reader.query(selectRow)).rows);
  } finally {
    if (readerOpen) await reader.query('ROLLBACK').catch(console.error);
    await Promise.allSettled([reader.end(), writer.end()]);
  }
}

main().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

Kör det med databasen uttryckligen vald:

PGDATABASE=snapshot_lab node snapshot-demo.cjs

Programmets JavaScript-syntax kontrollerades med Node.js. Databassekvensen granskades mot den hämtade dokumentationen; den kördes inte på en PostgreSQL-server för den här artikeln.

Så tolkar du de två vacuum-körningarna

De förväntade frågeresultaten är revision 0 för Reader before, revision 3 för Writer latest och återigen revision 0 för Reader still. När läsaren har genomfört transaktionen ska dess nästa fristående fråga returnera revision 3. Detta är förväntade resultat av den syntetiska sekvensen, inte insamlade testresultat.

Aktivitetsresultatet bör identifiera läsaren som idle in transaction medan skrivaren granskar den, med en kvarhållen backend_xmin. PostgreSQL definierar det tillståndet som att en transaktion väntar på ett nytt kommando och backend_xmin som backendens aktuella horisont. Det kumulativa statistiksystemet.

Läs den första vacuum-rapporten för inaktuella versioner som fortfarande inte kan tas bort. När läsaren har genomfört transaktionen jämför du den andra rapporten för att se om dessa versioner tas bort. I en databas som annars är inaktiv ger de tre genomförda ersättningarna tre inaktuella versioner att undersöka. Andra kvarhållna horisonter eller samtidig underhållsaktivitet kan påverka den exakta rapporten, så utgå från mönstret före och efter i stället för att kräva identisk rapporttext.

VACUUM (VERBOSE) ger en detaljerad aktivitetsrapport. Programmet skriver ut servermeddelanden via den dokumenterade notice-händelsen i node-postgres. Båda vacuum-anropen körs utanför uttryckliga transaktionsblock eftersom PostgreSQL inte tillåter VACUUM inuti ett sådant. PostgreSQL VACUUM, node-postgres Client API.

Använd den här vägledningen när resultatet inte stämmer med förväntningarna:

Observation Nästa kontroll
Läsaren ser revision 3 innan den genomför transaktionen Kontrollera att Repeatable Read startades och att den första artikel-frågan kördes före uppdateringarna.
Den första vacuum-körningen behåller inaktuella versioner Jämför med körningen efter att läsaren har genomfört transaktionen; att versionerna behålls medan snapshoten är öppen är förväntat.
Versioner finns kvar efter att läsaren har genomfört transaktionen Leta efter en annan kvarhållen horisont; när den här läsaren avslutar försvinner bara dess bidrag.
Ett tabellstatistikvärde verkar inkonsekvent Betrakta n_dead_tup som en uppskattning och jämför vacuum-rapporterna.
Tabellfilen blir inte mindre Kontrollera om lagringsutrymmet blev återanvändbart; att filen krymper är ett separat utfall.

PostgreSQL beskriver uttryckligen n_dead_tup som ett uppskattat antal. En registrerad vacuum-tid visar också att underhåll har körts, men inte att varje inaktuell version kunde tas bort. Dessa fält hjälper vid felsökning, men inget av dem ersätter en granskning av den kvarhållna snapshoten och vacuum-rapporten. Det kumulativa statistiksystemet.

Vanlig vacuum gör normalt utrymme återanvändbart inne i tabellen i stället för att lämna tillbaka det till macOS. Helt tomma sidor längst bak är ett dokumenterat undantag. En oförändrad filstorlek efter rensning visar därför inte att rensningen misslyckades. Regelbunden vacuum-rensning.

Vad bör ändras i applikationen?

Om flera artikel-frågor måste dela samma konsekventa databasvy bör du begränsa transaktionen till just de frågorna och avsluta den när de är klara. Som en följd för applikationsdesignen av att snapshots behålls bör du, när uppgiften tillåter, utföra orelaterad bearbetning och interaktion med skrivbordet efter att transaktionen har avslutats.

Om varje fråga kan använda den senaste genomförda vyn oberoende av de andra ger Read Committed snapshots på kommandonivå. Att ändra isoleringsnivå eller genomföra transaktionen mellan batcher ändrar semantiken för konsekvens: senare frågor kan se nyligen genomförda artikelrevisioner. Välj det alternativet bara när sådana föränderliga resultat passar läsarens uppgift. Transaktionsisolering.

Att öka vacuum-frekvensen kan inte göra en version borttagbar när gränsen är oförändrad. Om den onödiga snapshoten avslutas upphör dess bidrag till att versioner behålls. På samma sätt skriver VACUUM FULL om en tabell och kräver ett exklusivt lås samt extra utrymme; det är inte den rutinmässiga åtgärden för det här exemplet. Implementering av tupelsynlighet i PostgreSQL 18, PostgreSQL VACUUM.

Skriptet lämnar sin syntetiska tabell tillgänglig för granskning. Det misslyckas medvetet om tabellen redan finns i stället för att ersätta befintligt innehåll. När du har granskat den kör du följande kommando i en frågesession som är ansluten till snapshot_lab om du vill ta bort exempeltabellen. Om en körning misslyckades mitt under konfigurationen bör du granska samma tabell innan du tar bort den och kör igen.

DROP TABLE public.article_snapshot_demo;

Den användbara diagnostiken är förändringen: upprätta en gammal snapshot, genomför flera ersättningar, kör vacuum, avsluta snapshoten och kör vacuum igen. När den ursprungliga revisionen förblir läsbar samtidigt som mellanliggande versioner också står emot rensning blir skillnaden konkret. PostgreSQLs synlighetsregler avgör vad frågan returnerar; rensningshorisonten avgör när inaktuellt lagringsutrymme kan återanvändas.

Källor