backend/pg-primary-keys@v1.3.2
artykuł··9 min czytania

Postgres na edge: przemyślenie kluczy głównych dla globalnych zapisów

#postgres #distributed-systems #ulid #replication

Seryjny klucz główny nie jest kluczem. Jest uzgodnieniem między każdym writerem, że będą kolejno czekać na swoją kolej. Dotrzymaj tej umowy w dwóch regionach, a z definicji zrezygnowałeś z dostępności lub świeżości.

ULIDy i UUIDv7 nie są egzotyczne. To najbardziej nudna możliwa odpowiedź na pytanie "jak pozwolić drugiemu regionowi pisać bez dzwonienia do domu po numer sekwencji". Ciekawa część to wszystko, co zależy od starego klucza (klucze obce, indeksy, tabele audytu, pipeline analityczny) i jak migrować bez sobotniej nocnej przerwy serwisowej.

Dlaczego sekwencje psują się na edge

Klucz główny bigserial wymaga scentralizowanego licznika sekwencji. Każdy insert w regionie B musi albo czekać na round-trip do regionu A, albo ryzykować lukę w sekwencji. Przy 40ms latencji między regionami i 5000 zapisach na sekundę, to 200 równoległych żądań czekających w kolejce na numer.

Alternatywy dzielą się na dwie rodziny. Losowe identyfikatory jak UUIDv4 są globalnie unikalne bez koordynacji, ale nie da się ich sortować. Fragmentacja indeksu na dużych tabelach jest poważna, a złączenia na kolumnach UUID są mierzalnie wolniejsze niż na liczbach całkowitych. Identyfikatory z porządkiem czasowym (UUIDv7 i ULID) są globalnie unikalne, mniej więcej sortowalne po czasie tworzenia i przyjazne B-tree przy insertach. To właściwa odpowiedź dla nowych tabel.

Ścieżka migracji (bez downtime)

Przenieśliśmy 240M wierszy w tabeli orders. Strategia to podejście shadow column: dodaj nowy klucz obok starego, uzupełnij dane, zbuduj covering index, zamień za pomocą widoku.

-- shadow column, backfill, swap. celowo nudne.
alter table orders add column id_v2 uuid;

update orders set id_v2 = uuidv7_from_timestamp(created_at)
where id_v2 is null;

create unique index concurrently orders_id_v2_uq on orders (id_v2);

-- zamień za pomocą widoku; przełącz w jednej transakcji.
begin;
  alter table orders rename to orders_legacy;
  create view orders as
    select id_v2 as id, /* ...inne kolumny... */ from orders_legacy;
commit;

Funkcja uuidv7_from_timestamp konwertuje istniejące created_at na UUIDv7 z porządkiem czasowym, zachowując kolejność sortowania, od której zależy downstream pipeline analityczny. To pojedyncze rozszerzenie C, ~50 linii.

Sztuczka z widokiem

Widok pozwala nam atomowo zmienić nazwę kolumny z perspektywy aplikacji. Stary kod czytający orders.id nadal działa. Nowy kod używa tej samej nazwy kolumny. Uruchamiamy oboje w produkcji przez dwa tygodnie, weryfikujemy referencje kluczy obcych, a potem dropujemy legacy tabelę.

Czego nie przewidzieliśmy

Tabela audytu miała order_id bigint jako klucz obcy. Musieliśmy uzupełnić i to. Zajęło dłużej niż uzupełnienie tabeli orders, tabela audytu była trzy razy większa i nie miała kolumny created_at, więc musieliśmy joinować z powrotem do orders żeby wyderywować timestamps. Następnym razem najpierw zmigrowalibyśmy tabelę audytu używając logical replication i zamienili ją w tej samej transakcji.

Wyniki

Przepustowość zapisów w regionie B wzrosła z 1200 do 4800 insertów na sekundę. Latencja p99 zapisu spadła z 38ms do 4ms. Serwer sekwencji (pojedyncza instancja Postgres, która nic nie robiła poza obsługą nextval i stała się najważniejszą maszyną w całym parku) został wycofany z eksploatacji.

koniec artykułu