UUID vs ULID vs identyfikatory sekwencyjne: dowiedz się, jak każdy wpływa na indeksowanie, sortowanie, shard-ing oraz bezpieczny eksport i import danych w realnych projektach.

Wybór ID wydaje się nudny w pierwszym tygodniu. Potem wypuszczasz produkt, dane rosną, i ta "prosta" decyzja pojawia się wszędzie: indeksy, URL-e, logi, eksporty i integracje.
Prawdziwe pytanie nie brzmi "co jest najlepsze?" lecz "jakiego bólu chcę uniknąć później?" ID trudno zmienić, bo trafiają do innych tabel, cache'owane są przez klientów i zależą od nich inne systemy.
Gdy ID nie pasuje do rozwoju produktu, zwykle widać to w kilku miejscach:
Zawsze jest kompromis między wygodą teraz a elastycznością później. Liczby sekwencyjne są łatwe do czytania i często szybkie, ale ujawniają liczbę rekordów i utrudniają scalanie datasetów. Losowe UUID-y świetnie nadają się do unikalności między systemami, ale obciążają indeksy i są mniej przyjazne dla ludzi. ULID-y dążą do globalnej unikalności z porządkowaniem czasowym, ale też mają swoje koszty w przechowywaniu i narzędziach.
Przydatny sposób myślenia: dla kogo głównie jest ID?
Jeśli ID jest głównie dla ludzi (support, debugging, ops), krótsze i łatwiejsze do odczytu zwykle wygrywają. Jeśli jest dla maszyn (zapis rozproszony, klienci offline, multi-region), ważniejsza jest unikalność globalna i unikanie kolizji.
Gdy ludzie debatują "UUID vs ULID vs serial IDs", wybierają sposób nadawania unikalnej etykiety wierszom. Ta etykieta wpływa na łatwość wstawiania, sortowania, scalania i przenoszenia danych.
Serial to licznik. Baza daje 1, potem 2, potem 3 itd. (zwykle jako integer lub bigint). Łatwo je odczytać, są tanie w przechowywaniu i zwykle szybkie, bo nowe wiersze trafiają na koniec indeksu.
UUID to 128-bitowy identyfikator, który wygląda losowo, np. 3f8a.... W większości konfiguracji można go wygenerować bez pytania bazy o następny numer, więc różne systemy mogą tworzyć ID niezależnie. W zamian za to losowe wstawki mogą obciążać indeksy i zajmować więcej miejsca niż prosty bigint.
ULID też ma 128 bitów, ale jest zaprojektowany tak, aby był mniej więcej uporządkowany czasowo. Nowe ULID-y zwykle sortują się po starszych, zachowując globalną unikalność. Dajesz sobie część korzyści "generowane wszędzie" jak w UUID, z przyjaźniejszym zachowaniem przy sortowaniu.
Proste podsumowanie:
Seriale są powszechne w aplikacjach z jedną bazą i w narzędziach wewnętrznych. UUID pojawiają się, gdy dane tworzone są w wielu serwisach, urządzeniach lub regionach. ULID są popularne, gdy zespół chce generować ID rozproszenie, ale zależy mu też na porządku sortowania i zapytaniach "ostatnie najpierw".
Klucz główny zwykle wspierany jest indeksem (często B-tree). Wyobraź sobie indeks jak posortowaną książkę telefoniczną: każdy nowy wiersz potrzebuje wpisu w odpowiednim miejscu, żeby wyszukiwanie było szybkie.
W przypadku losowych ID (klasyczny UUIDv4) nowe wpisy trafiają w różne miejsca indeksu. To powoduje dotykanie wielu stron indeksu, częstsze dzielenie stron i dodatkowe zapisy. Z czasem indeks „chodzi na boki”: więcej pracy przy wstawianiu, więcej cache missów i większe indeksy niż oczekiwano.
Przy ID rosnących (serial/bigint lub ID czasowo uporządkowane jak wiele ULID-ów) baza zwykle może dopisywać nowe wpisy blisko końca indeksu. To jest bardziej cache-friendly, bo ostatnie strony pozostają gorące, a wstawki są płynniejsze przy dużych szybkościach zapisu.
Wielkość klucza ma znaczenie, bo wpisy indeksu nie są darmowe:
Większe klucze oznaczają mniej wpisów na stronę indeksu. To często prowadzi do głębszych indeksów, większej liczby odczytywanych stron na zapytanie i większych wymagań pamięciowych, by utrzymać szybkość.
Jeśli masz tabelę "events" z intensywnymi wstawkami, losowy UUID jako PK może szybciej zacząć wpływać na wydajność niż bigint, nawet jeśli pojedyncze odczyty wciąż wyglądają dobrze. Przy dużych zapisach koszt indeksowania zwykle jest pierwszą zauważalną różnicą.
Jeśli budowałeś "Load more" albo infinite scroll, już czułeś ból ID, które słabo się sortują. ID "dobrze się sortuje", gdy uporządkowanie po nim daje stabilny, znaczący porządek (często według czasu utworzenia), więc paginacja jest przewidywalna.
Przy losowych ID (jak UUIDv4) nowe wiersze są porozrzucane. Sortowanie po id nie odpowiada czasowi, a paginacja kursorem typu "pokaż elementy po tym id" staje się zawodna. Zwykle wracasz do created_at, co jest ok, ale trzeba to robić ostrożnie.
ULID są zaprojektowane tak, by były mniej więcej uporządkowane czasowo. Jeśli sortujesz po ULID (jako string lub w formie binarnej), nowsze elementy zwykle pojawiają się później. To upraszcza paginację kursorem, bo kursorem może być ostatni widziany ULID.
ULID pomaga z naturalnym porządkiem czasowym dla feedów, prostszymi kursorami i mniejszą losowością wstawiania niż UUIDv4.
Ale ULID nie gwarantuje idealnego porządku czasu, gdy wiele ID generowanych jest w tej samej millisekundzie na wielu maszynach. Jeśli potrzebujesz dokładnego porządku, wciąż chcesz prawdziwego znacznika czasu.
created_at jest lepszeSortowanie po created_at jest często bezpieczniejsze przy backfillach, imporcie historycznych rekordów lub gdy potrzebujesz jednoznacznego rozstrzygacza. Praktyczny wzorzec to sortowanie po (created_at, id), gdzie id służy tylko jako rozstrzygacz.
Sharding to podział jednej bazy na kilka mniejszych, gdzie każdy shard trzyma część danych. Zespoły zwykle robią to później, gdy pojedyncza baza przestaje wystarczać lub staje się zbyt ryzykownym pojedynczym punktem awarii.
Wybór ID może uczynić sharding albo łatwym, albo bolesnym.
Przy sekwencyjnych ID (auto-increment serial lub bigint) każdy shard będzie generował 1, 2, 3.... To samo ID może istnieć na wielu shardach. Gdy będziesz musiał łączyć dane, przenosić wiersze lub budować funkcje cross-shard, pojawią się kolizje.
Można ich uniknąć przez koordynację (centralny serwis ID albo przydziały zakresów na shard), ale to dodaje elementy operacyjne i może stać się wąskim gardłem.
UUID i ULID redukują potrzebę koordynacji, bo każdy shard może generować ID niezależnie z bardzo niskim ryzykiem duplikatów. Jeśli myślisz, że kiedykolwiek podzielisz dane między bazy, to jeden z najsilniejszych argumentów przeciw czystym sekwencjom.
Częstym kompromisem jest dodanie prefiksu sharda, a potem użycie lokalnej sekwencji na każdym shardarze. Można to trzymać w dwóch kolumnach albo zapakować w jedno pole.
Działa, ale tworzy niestandardowy format ID. Każda integracja musi go rozumieć, sortowanie przestaje mieć globalny porządek czasowy bez dodatkowej logiki, a przenoszenie danych między shardami może wymagać przepisania ID (co psuje referencje, jeśli ID są udostępniane).
Zadaj jedno pytanie wcześnie: czy kiedykolwiek będziesz łączyć dane z wielu baz i chcesz zachować stabilne referencje? Jeśli tak, zaplanuj globalnie unikalne ID od początku albo zaplanuj budżet na migrację później.
Eksport/import to moment, w którym wybór ID przestaje być teoretyczny. Gdy klonujesz prod do stagingu, przywracasz backup albo łączysz dane z dwóch systemów, dowiesz się, czy Twoje ID są stabilne i przenośne.
Przy serialnych (auto-increment) ID zwykle nie możesz bezpiecznie odtworzyć wstawek w innej bazie i oczekiwać, że referencje zostaną zachowane, chyba że zachowasz oryginalne numery. Jeśli importujesz tylko podzbiór wierszy (np. 200 klientów i ich zamówienia), musisz ładować tabele we właściwej kolejności i zachować dokładnie te same PK. Jeśli cokolwiek zostanie przenumerowane, klucze obce się zepsują.
UUID i ULID generowane poza sekwencją bazy są łatwiejsze do przenoszenia między środowiskami. Możesz skopiować wiersze, zachować ID i powiązania nadal będą poprawne. To pomaga przy przywracaniu z backupów, częściowych eksportach czy scalaniu zbiorów.
Przykład: eksport 50 kont z produkcji, żeby debugować problem na stagingu. Przy UUID/ULID możesz zaimportować te konta oraz powiązane wiersze (projekty, faktury, logi) i wszystko będzie wskazywać na właściwego rodzica. Przy serialach zwykle budujesz tabelę mapowania (old_id -> new_id) i przepisujesz klucze obce podczas importu.
Dla masowych importów bardziej liczą się podstawy niż sam typ ID:
Możesz podjąć solidną decyzję szybko, jeśli skupisz się na tym, co będzie boleć później.
Wypisz największe ryzyka na przyszłość. Konkretne zdarzenia pomagają: podział na wiele baz, łączenie danych z innego systemu, zapisy offline, częste kopiowanie danych między środowiskami.
Zdecyduj, czy porządek ID musi odpowiadać czasowi. Jeśli chcesz "najnowsze pierwsze" bez dodatkowych kolumn, ULID (lub UUIDv7) jest dobrym wyborem. Jeśli możesz sortować po created_at, UUID i serial też działają.
Oszacuj wolumen zapisów i wrażliwość indeksu. Przy dużej liczbie wstawek i intensywnym obciążeniu indeksu PK, serial BIGINT zwykle najbardziej oszczędza B-tree. Losowe UUID-y zwykle powodują więcej churnu.
Wybierz domyślnie, a potem udokumentuj wyjątki. Uprość: jeden domyślny typ dla większości tabel i jasna reguła, kiedy się od niego odbiegá (np. publiczne ID vs wewnętrzne ID).
Zostaw miejsce na zmianę. Nie koduj znaczenia w ID, zdecyduj, gdzie ID są generowane (DB vs aplikacja) i trzymaj ograniczenia jawne.
Największa pułapka to wybór ID, bo jest modny, a potem odkrycie, że koliduje z tym jak zapytujesz, skalujesz lub dzielisz dane. Większość problemów pojawia się miesiące później.
Typowe porażki:
123, 124, 125, ludzie mogą zgadywać sąsiednie rekordy i sondować system.Ostrzegawcze sygnały, które warto rozwiązać wcześnie:
Wybierz jeden typ klucza głównego i trzymaj się go w większości tabel. Mieszanie typów (bigint w jednym miejscu, UUID w innym) utrudnia joiny, API i migracje.
Os szacuj rozmiar indeksu przy przewidywanej skali. Szersze klucze = większe indeksy = większe potrzeby pamięci i IO.
Zdecyduj, jak będziesz paginować. Jeśli paginujesz po ID, upewnij się, że ID ma przewidywalne uporządkowanie (albo zaakceptuj, że nie). Jeśli paginujesz po znaczniku czasu, indeksuj created_at i rób to konsekwentnie.
Przetestuj plan importu na danych podobnych do produkcyjnych. Sprawdź, czy możesz odtworzyć rekordy bez łamania kluczy obcych i czy ponowne importy nie generują nowych ID.
Zapisz strategię na wypadek kolizji. Kto generuje ID (DB czy aplikacja) i co się dzieje, gdy dwa systemy tworzą rekordy offline i później synchronizują?
Upewnij się, że publiczne URL-e i logi nie ujawniają wzorców, na których Ci zależy (liczba rekordów, tempo tworzenia, wskazówki o shardach). Jeśli używasz seriali, przyjmij, że ludzie mogą zgadywać sąsiednie ID.
Samotny założyciel uruchamia prosty CRM: kontakty, transakcje, notatki. Jedna baza Postgresa, jedna aplikacja webowa, cel to wypuszczenie produktu.
Na początku serial bigint jako PK wydaje się idealny. Wstawki są szybkie, indeksy schludne, łatwo czytać w logach.
Po roku klient prosi o kwartalne eksporty do audytu, a założyciel zaczyna importować leady z narzędzia marketingowego. ID, które były tylko wewnętrzne, zaczynają pojawiać się w CSV, mailach i ticketach. Jeśli dwa systemy używają 1, 2, 3..., scalanie staje się uciążliwe. Kończy się na dodawaniu kolumn źródłowych, tabel mapowania lub przepisywaniu ID przy imporcie.
W drugim roku pojawia się aplikacja mobilna. Musi tworzyć rekordy offline, potem synchronizować. Teraz potrzebujesz ID generowanych po stronie klienta bez kontaktu z bazą i niskiego ryzyka kolizji po synchronizacji.
Komfortowy kompromis, który często się sprawdza:
Jeśli wahasz się między UUID, ULID i serial, wybierz na podstawie tego, jak dane będą się przemieszczać i rosnąć.
Jednozdaniowe wybory dla typowych przypadków:
bigint serial jako PK.Mieszanie często jest najlepsze. Używaj serial bigint dla wewnętrznych tabel, które nie opuszczają bazy (tabele joinów, zadania backgroundowe), i UUID/ULID dla publicznych encji jak users, orgs, invoices i wszystkiego, co możesz eksportować, synchronizować lub odnosić z innego serwisu.
Jeśli budujesz w Koder.ai (koder.ai), warto zdecydować o wzorcu ID zanim wygenerujesz dużo tabel i API. Tryb planowania platformy oraz snapshoty/przywracanie ułatwiają zastosowanie i walidację zmian schematu wcześnie, gdy system jest jeszcze na tyle mały, że można go bezpiecznie zmienić.
Zacznij od bólu, którego chcesz uniknąć w przyszłości: wolnych wstawek spowodowanych losowym zapisem do indeksu, problemów z paginacją, ryzykownych migracji albo kolizji ID przy importach i scalaniu. Jeśli spodziewasz się, że dane będą się przemieszczać między systemami lub będą tworzone w wielu miejscach, domyślnie wybierz globalnie unikalne ID (UUID/ULID) i traktuj kwestie sortowania czasowego oddzielnie.
Serial bigint to dobry wybór, gdy masz jedną bazę danych, duże natężenie zapisów i ID pozostają wewnętrzne. Jest kompaktowy, szybki dla indeksów B-tree i łatwy do czytania w logach. Główną wadą jest trudność w scalaniu danych później (kolizje) oraz ujawnianie liczby rekordów, jeśli ID są publiczne.
Wybierz UUID, gdy rekordy mogą powstawać w wielu serwisach, regionach, urządzeniach lub offline i chcesz minimalnego ryzyka kolizji bez centralnej koordynacji. UUID dobrze sprawdza się też jako publiczne ID, bo trudno je zgadnąć. Kosztem są większe indeksy i bardziej losowy wzorzec zapisów w porównaniu z sekwencyjnymi kluczami.
ULID ma sens, gdy chcesz ID generowane z dowolnego miejsca i jednocześnie zależy Ci, żeby w większości przypadków sortowały się w porządku czasowym. Ułatwia to kursory i zmniejsza losowość zapisów w porównaniu z UUIDv4. Nadal nie traktuj ULID jako dokładnego znacznika czasu — jeśli potrzebujesz ścisłego porządku, użyj rzeczywistego znacznika created_at.
Tak — szczególnie UUIDv4 o losowej treści może wpływać na wydajność w tabelach o dużej liczbie zapisów. Losowe inserty rozrzucają wpisy po indeksie, powodując częstsze dzielenie stron, większe zużycie cache i większe rozmiary indeksów z czasem. Zwykle zauważysz to jako wolniejsze utrzymywalne tempo zapisów i większe potrzeby pamięci/IO, niekoniecznie jako wolniejsze pojedyncze zapytania.
Sortowanie po losowym ID (np. UUIDv4) nie będzie odzwierciedlać czasu tworzenia, więc kursory typu "after this id" nie dadzą stabilnej chronologii. Rozwiązaniem jest paginacja po created_at i użycie ID jako rozstrzygacza w razie remisu, np. (created_at, id). Jeśli chcesz paginować tylko po ID, prostszym wyborem jest ID sortowalne czasowo, jak ULID.
Sekwencyjne ID kolidują między shardami, bo każdy shard będzie generował 1, 2, 3... niezależnie. Można tego uniknąć przez koordynację (zakresy per shard albo centralny serwis ID), ale to dodaje złożoności i może stać się wąskim gardłem. UUID/ULID redukują potrzebę koordynacji, bo każdy shard może bezpiecznie generować ID samodzielnie.
UUID/ULID są bezpieczniejsze do eksportów/importów, bo możesz skopiować wiersze, zachować ID i relacje pozostaną poprawne. Przy serialnych ID częściowe importy zwykle wymagają tabeli tłumaczeń (old_id -> new_id) i przepisywania kluczy obcych, co łatwo zepsuć. Jeśli często klonujesz środowiska lub scalasz zewnętrzne zbiory, globalnie unikalne ID oszczędzą dużo pracy.
Często stosuje się dwa ID: kompaktowy wewnętrzny klucz główny (serial bigint) dla wydajnych joinów i przechowywania, oraz niezmienny publiczny ID (ULID lub UUID) do URL, API, eksportów i odwołań między systemami. Dzięki temu baza zostaje szybka, a integracje i migracje stają się mniej bolesne. Kluczowe jest traktowanie publicznego ID jako stabilnego — nie nadpisuj go ani nie recyklinguj.
Zaplanuj to wcześnie i konsekwentnie stosuj w tabelach oraz API. W Koder.ai (koder.ai) wybierz domyślną strategię ID w trybie planowania zanim wygenerujesz dużo schematu i endpointów, a potem użyj snapshotów/przywróceń, żeby zweryfikować zmiany, gdy projekt jest jeszcze mały. Najtrudniejsze nie jest generowanie nowych ID — to aktualizacja kluczy obcych, cache'y, logów i zewnętrznych payloadów, które wciąż odwołują się do starych wartości.