Aby odrzucić zapis nieaktualnego dokumentu, uwzględnij w warunku aktualizacji rewizję pierwotnie wczytaną przez edytor, a następnie zwiększ ją atomowo. Samo SERIALIZABLE nie pozwala wyrazić tego wymagania, gdy dokument został wczytany we wcześniejszej transakcji.
Ten samouczek jest przeznaczony dla programisty pracującego w Terminalu na Macu, który potrzebuje, aby edytor artykułów oparty na PostgreSQL odrzucał nieaktualny tekst zastępczy. Wykorzystuje przykładowe wiersze artykułów i kompletną demonstrację z node-postgres. Wymaganie jest precyzyjne: zapisuj tylko wtedy, gdy przechowywana rewizja nadal odpowiada rewizji, na której oparto przesłany szkic.
Dlaczego SERIALIZABLE może dopuścić zastąpienie nieaktualną treścią?
PostgreSQL definiuje wykonanie serializowalne jako takie, które daje efekt równoważny uruchomieniu zatwierdzonych transakcji pojedynczo, w pewnej kolejności. Ta gwarancja dotyczy transakcji bazodanowych i ich operacji. PostgreSQL: izolacja transakcji.
Rozważmy następującą przykładową sekwencję:
- Edytor wczytuje artykuł 1 w rewizji 1, po czym transakcja odczytu zostaje zakończona.
- Inny zapis zastępuje tekst artykułu i zatwierdza rewizję 2.
- Pierwotny edytor przesyła tekst zastępczy oparty na rewizji 1.
- Nowa transakcja serializowalna aktualizuje artykuł 1, używając wyłącznie jego identyfikatora.
Ostatnia transakcja może się powieść: transakcje zostały już wykonane sekwencyjnie. W aktualizacji, której warunek obejmuje tylko id = 1, nic nie wskazuje, że przesłany tekst wymaga rewizji 1. To zastosowanie udokumentowanej definicji serializowalności, a nie jej wyjątek.
Nakładające się transakcje to inny przypadek. PostgreSQL może odrzucić transakcję serializowalną, gdy współbieżna aktualizacja koliduje z jej migawką albo gdy zależności uniemożliwiałyby wykonanie szeregowe. Migawka jest ustalana przez pierwszą instrukcję, która nie jest instrukcją sterującą transakcją; samo wcześniejsze wydanie BEGIN nie zachowuje wcześniejszego odczytu edytora. PostgreSQL: izolacja transakcji.
Kluczowe pytanie diagnostyczne brzmi: Gdzie zapis jawnie wymaga rewizji wczytanej przez edytor? Jeśli odpowiedź brzmi „nigdzie”, zmiana poziomu izolacji nie uzupełni brakującego warunku.
Jakie gwarancje ma zapewnić kontrola rewizji?
Traktuj każdy wczytany szkic jako parę: edytowalny tekst i rewizję bazową. Zapis przenosi identyfikator artykułu, tekst zastępczy i niezmienioną rewizję bazową. Aktualizacja bazy danych musi dopasować zarówno identyfikator, jak i tę rewizję, zastąpić tekst oraz zwiększyć przechowywaną rewizję w tej samej instrukcji.
Na przykład warunek WHERE id = $1 AND revision = $2 wyraża wymaganie wstępne, a SET body = $3, revision = revision + 1 wykonuje zmianę. PostgreSQL aktualizuje wiersze spełniające warunek, a RETURNING zwraca wartości z faktycznie zaktualizowanych wierszy. Aktualizacja obejmująca zero wierszy jest poprawnie wykonanym poleceniem SQL, więc aplikacja musi jawnie zinterpretować taki wynik. PostgreSQL: UPDATE.
Ten wzorzec jest często nazywany optymistyczną kontrolą współbieżności lub compare-and-swap. W projekcie opisanym w tym artykule każda operacja zmieniająca edytowalną treść artykułu musi zwiększać rewizję. Rewizje powinny być niepuste; nie resetuj ich ani nie używaj ponownie tożsamości artykułu ze starym tokenem rewizji. W przeciwnym razie równość mogłaby błędnie sugerować, że stary szkic nadal pasuje.
Oddzielny odczyt, po którym następuje aktualizacja używająca wyłącznie identyfikatora, nie uwzględnia tego wymagania w samej operacji zapisu. Podobnie pobranie najnowszej rewizji tuż przed zapisem i zastąpienie nią przesłanej rewizji zmienia znaczenie żądania: pozwala zastąpić wersję, której edytor nie użył jako podstawy.
Uruchom kompletny przykład na fikcyjnych danych na Macu
Przygotuj działającą instancję PostgreSQL, istniejącą tymczasową bazę danych oraz aktualną lub wspieraną wersję LTS Node.js z npm. Otwórz Terminal, utwórz folder roboczy i zainstaluj sterownik za pomocą udokumentowanego polecenia npm install pg. node-postgres: wprowadzenie i instalacja.
mkdir revision-write-demo
cd revision-write-demo
npm install pg
Zapisz poniższy kompletny program w tym folderze jako revision-demo.mjs. Korzysta on z udokumentowanego importu domyślnego zgodnego z ESM. Zarówno Client, jak i Pool mogą odczytywać ustawienia połączenia z PostgreSQL ze zmiennych środowiskowych; przed uruchomieniem skonfiguruj je dla istniejącej tymczasowej bazy danych. node-postgres: ESM, node-postgres: nawiązywanie połączenia.
Program porównuje dwa przykładowe artykuły. Artykuł 1 otrzymuje dwa bezwarunkowe zapisy serializowalne. Artykuł 2 otrzymuje dwa zapisy sprawdzające rewizję, oba z użyciem tej samej pierwotnej rewizji. Jego tabela tymczasowa oddziela demonstrację od trwałych tabel artykułów. Składnię JavaScript sprawdzono za pomocą Node.js; demonstracji z bazą danych nie uruchomiono w środowisku badawczym.
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();
async function saveArticle(id, expectedRevision, body) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let active = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
active = true;
const result = await client.query(
`UPDATE revision_demo_articles
SET body = $3, revision = revision + 1
WHERE id = $1 AND revision = $2
RETURNING id, body, revision`,
[id, expectedRevision, body]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
active = false;
return { status: 'stale-or-missing' };
}
await client.query('COMMIT');
active = false;
return { status: 'saved', article: result.rows[0] };
} catch (error) {
if (active) await client.query('ROLLBACK');
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE revision_demo_articles (
id integer PRIMARY KEY,
body text NOT NULL,
revision integer NOT NULL CHECK (revision > 0)
)
`);
await client.query(
`INSERT INTO revision_demo_articles (id, body, revision)
VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
);
const loaded = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
const expectedRevision = loaded.rows[1].revision;
// Illustrative blind saves: each is a separate transaction.
for (const body of ['First edit', 'Stale replacement']) {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
try {
await client.query(
`UPDATE revision_demo_articles
SET body = $1, revision = revision + 1 WHERE id = 1`,
[body]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
const first = await saveArticle(2, expectedRevision, 'First edit');
const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
assert.equal(first.status, 'saved');
assert.equal(first.article.revision, 2);
assert.equal(stale.status, 'stale-or-missing');
const final = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
assert.deepEqual(final.rows, [
{ id: 1, body: 'Stale replacement', revision: 3 },
{ id: 2, body: 'First edit', revision: 2 }
]);
console.table(final.rows);
console.log(stale.status);
} finally {
await client.end();
}
Jeśli istniejąca tymczasowa baza danych nazywa się revision_demo, uruchom poniższe polecenie. Podstaw nazwę swojej bazy danych i zachowaj wszystkie ustawienia połączenia wymagane przez lokalną instancję. node-postgres: nawiązywanie połączenia.
PGDATABASE=revision_demo node revision-demo.mjs
Oczekiwane wartości końcowe:
| Artykuł | Treść | Rewizja | Znaczenie |
|---|---|---|---|
| 1 | Stale replacement |
3 | Oba bezwarunkowe zapisy zostały zatwierdzone |
| 2 | First edit |
2 | Drugi zapis sprawdzający rewizję został odrzucony |
Wydrukowany status końcowy powinien mieć wartość stale-or-missing.
Sekwencja celowo nie zależy od współbieżności uwarunkowanej czasem. Pokazuje bezpośrednio kluczową granicę: zastąpienie nieaktualną treścią może się powieść, nawet jeśli każda transakcja zapisu jest serializowalna, ponieważ zapisy przebiegają w prawidłowej kolejności szeregowej.
Jak node-postgres powinien obsługiwać poszczególne wyniki?
Program przekazuje wartości oddzielnie od tekstu SQL, korzystając z udokumentowanego interfejsu sterownika do zapytań parametryzowanych. Sprawdza również rowCount, które informuje, ile wierszy przetworzyła aktualizacja; sama pusta tablica rows nie zastępuje ogólnie sprawdzania liczby aktualizowanych wierszy. node-postgres: zapytania, node-postgres: pg.Result.
Podczas dostosowywania funkcji kieruj się poniższą tabelą decyzji:
| Wynik | Działanie aplikacji |
|---|---|
| Zaktualizowano jeden wiersz i zatwierdzono transakcję | Zaakceptuj zapis i zachowaj zwróconą rewizję |
| Nie zaktualizowano żadnego wiersza | Odrzuć zapis jako nieaktualny lub dotyczący brakującego artykułu; zachowaj przesłany szkic |
SQLSTATE 40001 |
Wycofaj transakcję i ponów całą transakcję, zgodnie z ograniczoną polityką ponowień |
| Inny błąd lub osiągnięty limit ponowień | Zgłoś niepowodzenie; nie informuj o pomyślnym zapisie |
Wynik obejmujący zero wierszy nie pozwala stwierdzić, czy artykuł został usunięty, czy zmieniła się jego rewizja. W demonstracji te przypadki celowo są traktowane łącznie. Jeśli interfejs wymaga bardziej szczegółowej informacji, później wczytaj bieżący artykuł i przedstaw wynik jako późniejszą obserwację. Nie opisuj go jako dowodu dokładnego stanu z chwili nieudanej aktualizacji.
Błędy serializacji mają kod SQLSTATE 40001. PostgreSQL wymaga ponowienia całej transakcji, w tym logiki określającej instrukcje i wartości. Może to wymagać wielu prób i nie gwarantuje ostatecznego powodzenia. W przykładzie przyjęto trzy próby jako przykładową politykę aplikacji. PostgreSQL: obsługa błędów serializacji.
Zachowaj niezmienioną wartość expectedRevision podczas tych prób. Reprezentuje ona wymaganie wstępne przesłanego szkicu. Jeśli inny zapis zwiększył rewizję, nowa transakcja może wtedy odrzucić stary token, aktualizując zero wierszy. Automatyczne zastąpienie tokenu najnowszą rewizją usunęłoby ochronę przed zapisem nieaktualnych danych.
Funkcja zwraca powodzenie dopiero po zakończeniu COMMIT. Wszystkie instrukcje transakcyjne korzystają z tego samego Client, zgodnie z wymaganiami node-postgres. Jeśli dostosowujesz ten kod do puli, pobierz klienta za pomocą pool.connect(), używaj go przez całą transakcję, a następnie zwolnij. node-postgres: transakcje, node-postgres: pg.Pool.
Czy nadal potrzebujesz SERIALIZABLE lub SELECT FOR UPDATE?
W przypadku tego wąsko określonego, warunkowego zapisu pojedynczego wiersza poziom Read Committed również może egzekwować wymaganie rewizji: po zaczekaniu na zatwierdzenie współbieżnej aktualizacji PostgreSQL ponownie ocenia warunek aktualizacji względem zmienionego wiersza. Nie daje to jednak Read Committed gwarancji dotyczących złożonych reguł obejmujących wiele wierszy. PostgreSQL: izolacja transakcji.
Zachowaj SERIALIZABLE, gdy otaczająca transakcja zależy od szerszego zakresu odczytów i zapisów wymagających tych gwarancji. Warunek rewizji wyraża wymaganie wstępne edytora; poziom izolacji reguluje wzajemne oddziaływanie operacji bazodanowych w transakcji. Dobierz oba ustawienia do faktycznego zadania.
SELECT FOR UPDATE może służyć transakcji, która musi sprawdzić wiersz i wykonać kilka zależnych od siebie operacji. Blokada wiersza wstrzymuje sprzeczne operacje zapisu do końca transakcji. Przy poziomie Repeatable Read lub Serializable zablokowanie wiersza zmienionego od czasu migawki transakcji może spowodować błąd. PostgreSQL: jawne blokowanie.
Samo uzyskanie tej blokady dopiero po nadejściu żądania zapisu nadal pozostawia aplikacji obowiązek porównania przesłanej rewizji bazowej. Zablokowanie bieżącego wiersza nie pozwala bazie danych ustalić, która wcześniejsza rewizja posłużyła za podstawę szkicu. W opisanej tu operacji zastąpienia jednego wiersza porównanie i zmianę łączy już aktualizacja warunkowa.
Przed wdrożeniem tego wzorca sprawdź wszystkie ścieżki zapisu treści, zachowaj szkic edytora po odrzuceniu i zmieniaj rewizję bazową edytora dopiero po potwierdzonym zapisie. Po odrzuceniu nieaktualnego zapisu udostępnij bieżący artykuł obok zachowanego szkicu, aby czytelnik mógł uzgodnić treść i przesłać nowy zapis oparty na faktycznie sprawdzonej rewizji.
Źródła
- 13.2. Izolacja transakcji — PostgreSQL Global Development Group.
- UPDATE — PostgreSQL Global Development Group.
- 13.5. Obsługa błędów serializacji — PostgreSQL Global Development Group.
- 13.3. Jawne blokowanie — PostgreSQL Global Development Group.
- node-postgres: wprowadzenie i instalacja — node-postgres.
- ESM — node-postgres.
- Nawiązywanie połączenia — node-postgres.
- Zapytania — node-postgres.
- pg.Result — node-postgres.
- Transakcje — node-postgres.
- pg.Pool — node-postgres.