Dein Order-Service nutzt seit Jahren einen Postgres-BIGSERIAL-Schlüssel. Dann wird die Tabelle orders auf vier Shards verteilt, die Sequenz jedes Shards vergibt Bestellung 10452, und der Support findet zwei Kunden hinter einer Bestellnummer. Oder die Mobile-App braucht eine ID, bevor sie den Server überhaupt erreichen kann.
IDs sind nicht mehr kostenlos, sobald mehr als eine Maschine sie erzeugt. Die Optionen tauschen Größe, Sortierung, Koordination pro ID und die Verträglichkeit mit einem B-Tree-Index gegeneinander. Dieser Beitrag vergleicht Sequenzen, Ticket-Server, UUIDv4, UUIDv7, ULID und IDs im Snowflake-Stil und entwirft dann einen Snowflake-Generator: Bitlayout, Worker-ID-Leases, Zeitsprünge und Kollisionsrechnung.
Anforderungen an verteilte IDs
- Eindeutig, für immer. Über Shards, Regionen und Neustarts hinweg.
- Grob nach Zeit sortiert, damit Paginierung mit
WHERE id > ?und zeitlich geordnete Indizes funktionieren. Twitters ursprüngliches Snowflake versprach Ordnung über Maschinen hinweg innerhalb einer Sekunde. - Kompakt. 64 Bit passen in einen
BIGINT; 128 Bit verdoppeln jeden Schlüssel. - Keine Koordination pro ID. Snowflakes Ziel waren mindestens 10.000 IDs pro Sekunde und Prozess, jede innerhalb von 2 ms.
- Sicher offenzulegen, ohne mehr preiszugeben als beabsichtigt.
Sechs Wege, IDs zu erzeugen
| Ansatz | Größe | Sortierung | Koordination | B-Tree-Inserts |
|---|---|---|---|---|
| Datenbank-Sequenz | 64 Bit | Aufsteigend | Jede ID aus einer Datenbank | Rechter Rand |
| Ticket-Server | 64 Bit | Aufsteigend pro Server | Ein Aufruf pro ID oder pro Bereich | Rechter Rand |
| UUIDv4 | 128 Bit | Keine | Keine | Zufällige Seiten |
| UUIDv7 | 128 Bit | Nach Millisekunde | Keine | Nahe am rechten Rand |
| ULID | 128 Bit | Nach Millisekunde | Keine | Nahe am rechten Rand |
| Snowflake-Stil | 64 Bit | Nach Millisekunde, grob | Eine Worker-ID pro Generator | Nahe am rechten Rand |
Eine Datenbank-Sequenz passt, solange eine Datenbank jede Zeile schreibt, und bricht, sobald mehrere Datenbanken einen ID-Raum teilen. Sie hinterlässt Lücken, da nextval nie zurückgerollt wird, und in PostgreSQL hält ein CACHE über 1 die Werte zwar verschieden, aber über Sessions hinweg ungeordnet. Fortlaufende Bestellnummern verraten außerdem dein Bestellvolumen.
Ein Ticket-Server zentralisiert die Sequenz. Flickrs MySQL-Ticket-Server waren zwei Maschinen, eine vergab ungerade, eine gerade Nummern, und die Clients wechselten sich ab. Das Leasen von ID-Blöcken spart den Aufruf pro ID, wie im URL-Shortener-Design.
UUIDv7, ULID und Snowflake stellen einen Zeitstempel voran, sodass zusammen erzeugte IDs zusammen sortieren; nur Snowflake passt in 64 Bit und zahlt dafür mit Worker-IDs und einer Abhängigkeit von der Uhr.
Das Snowflake-Bitlayout
| Bits | Feld | Kapazität |
|---|---|---|
| 1 | Vorzeichenbit, immer 0 | IDs bleiben als vorzeichenbehaftete 64-Bit-Ganzzahlen positiv |
| 41 | Millisekunden seit einer eigenen Epoche | 2^41 ms, etwa 69,7 Jahre |
| 10 | Worker-ID | 1.024 Generatoren |
| 12 | Sequenz innerhalb der Millisekunde | 4.096 IDs pro Millisekunde und Worker |
Twitters Release von 2010 nutzte diese Aufteilung mit einer Epoche im November 2010, sodass seine IDs im Juli 2080 ausgehen. Ab der Unix-Epoche wären 41 Bit im September 2039 aufgebraucht, also setze die Epoche nahe an deinen Start. Jede Instanz merkt sich ihre letzte Millisekunde und einen Zähler:
// Next is called with g.mu held.
func (g *Generator) Next() (int64, error) {
if time.Now().After(g.leaseDeadline) {
return 0, ErrLeaseLost // another instance may own this worker ID now
}
now := time.Now().UnixMilli()
if now < g.lastMs {
if g.lastMs-now > 5 {
return 0, ErrClockMovedBack
}
now = g.waitUntil(g.lastMs) // small step back: wait it out
}
if now == g.lastMs {
g.seq = (g.seq + 1) & 0xFFF // 12 bits
if g.seq == 0 { // 4,096 IDs used in this millisecond
now = g.waitUntil(g.lastMs + 1)
}
} else {
g.seq = 0
}
g.lastMs = now
return (now-epochMs)<<22 | g.workerID<<12 | g.seq, nil
}
Ein Worker vergibt bis zu 4,1 Millionen IDs pro Sekunde; der RPC drumherum ist die eigentliche Grenze, also lass Clients IDs in Batches holen. IDs überschreiten 2^53 etwa 25 Tage nach der Epoche, jenseits von JavaScripts Number.MAX_SAFE_INTEGER, also sende sie als JSON-Strings, wie es die APIs von X und Discord tun.
Die Aufteilung ist ein Budget. Discord nutzt 42 Bit für die Zeit und teilt die Node-Bits in Worker und Prozess. Instagram erzeugte IDs innerhalb jedes PostgreSQL-Shards mit einer 13-Bit-Shard-ID, was hilft, wenn du eine Datenbank shardest. Sonyflake zählt in 10-ms-Einheiten, um 174 Jahre zu halten, bei 256 IDs pro 10 ms und Instanz.
Worker-IDs vergeben
Snowflake braucht keine Koordination pro ID, nur pro Prozess: Zwei laufende Generatoren mit derselben Worker-ID können dieselbe ID ausgeben.
- Statische Konfiguration. Jeder Host bekommt eine
WORKER_ID. Gut für eine feste Flotte; eine kopierte Konfigurationsdatei erzeugt Duplikate. - StatefulSet-Ordinals. Kubernetes nummeriert die Pods eines StatefulSets ab 0 und versieht jeden mit dem Label
apps.kubernetes.io/pod-index(stabil seit v1.32), das die Downward API weitergibt. Ordinals sind nur innerhalb eines StatefulSets in einem Cluster eindeutig, und das erzwungene Löschen eines Pods kann zwei Pods mit demselben Ordinal hinterlassen. - Leases in etcd oder ZooKeeper. Beim Start beansprucht die Instanz den niedrigsten freien Schlüssel, etwa
/idgen/workers/17, mit einer Transaktion, die nur gelingt, wenn der Schlüssel fehlt, auf einem 10-Sekunden-Lease, der alle 3 Sekunden erneuert wird. Stirbt die Instanz, läuft der Schlüssel ab. Twitters Snowflake von 2010 nutzte einen ephemeren ZooKeeper-Node und weigerte sich zu starten, wenn seine Uhr mehr als 10 Sekunden vom Mittel seiner Peers abwich.
env:
- name: WORKER_ID
valueFrom:
fieldRef:
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
Leases brauchen zwei Regeln. Hör auf, IDs auszugeben, sobald das letzte erfolgreiche Keepalive älter ist als die gewährte TTL minus eine Marge, damit eine pausierte oder abgetrennte Instanz stoppt, bevor eine andere ihre ID beanspruchen kann. Und speichere eine High-Water-Mark: Lege alle paar Sekunden einen Zeitstempel knapp vor der Uhr in einem separaten Schlüssel ab, gib nie IDs darüber hinaus aus und lass den nächsten Halter warten, bis seine Uhr ihn überschreitet. etcd hält beides mit Raft konsistent.
Wenn die Uhr rückwärts springt
NTP-Daemons gleichen die Uhr meist per Slew an, lassen sie also etwas schneller oder langsamer laufen, springen aber bei großen Abweichungen: ntpd springt standardmäßig ab 128 ms, und im Standardmodus von chrony für Schaltsekunden setzt der Kernel die Uhr für eine eingefügte Schaltsekunde um eine Sekunde zurück. Die chrony-Dokumentation rät, nur beim Booten zu springen:
makestep 1.0 3 # step (if off by over 1 s) only in the first three updates
leapsecmode slew # absorb a leap second by slewing, not by stepping back
Für einen Sprung, der durchkommt, wähle eine Regel:
| Strategie | Was der Generator tut | Kosten |
|---|---|---|
| Fail | Lehnt Anfragen ab, bis die Uhr last_ms überschreitet |
Fehler, solange der Sprung dauert |
| Wait | Schläft, bis die Uhr last_ms überschreitet |
Gut für wenige ms; lange Sprünge blockieren jeden Aufrufer |
| Logische Uhr | Zählt ab last_ms weiter |
Blockiert nie; Zeitstempel laufen der Echtzeit voraus |
Twitters Original schlug fehl. Wenige Millisekunden zu warten und dann mit einem Alarm fehlzuschlagen, ist ein vernünftiger Standard. Zeitmessung mit CLOCK_MONOTONIC, das nie rückwärts läuft, schützt einen laufenden Prozess; die High-Water-Mark deckt Neustarts ab.
UUIDv7 und B-Tree-Indizes
RFC 9562, im Mai 2024 veröffentlicht, ersetzte RFC 4122 und definierte UUIDv7: ein 48-Bit-Unix-Zeitstempel in Millisekunden, 4 Versionsbits, 2 Variantenbits und 74 Bit für Zufall, oder für einen Zähler, wenn du strikte Ordnung innerhalb einer Millisekunde brauchst. Der Zeitstempel hält bis zum Jahr 10889.
Der RFC warnt, dass UUIDs ohne Zeitordnung eine schlechte Index-Lokalität haben, mit Auswirkungen auf B-Trees, die dramatisch sein können. In PostgreSQL landen neue v7-Schlüssel auf der ganz rechten Blattseite, und Splits dort lassen Seiten zu 90 % gefüllt, dem Standard-Fillfactor von B-Trees. Zufällige v4-Schlüssel treffen jede Seite: Der ganze Index wird zum Working Set, volle Seiten splitten, und nach jedem Checkpoint schreibt die erste Änderung an jeder Seite die ganze Seite ins WAL. MySQLs InnoDB kopiert außerdem den Primärschlüssel in jeden Sekundärindex, sodass ein 16-Byte-Schlüssel sie alle vergrößert.
PostgreSQL 18 brachte uuidv7() im September 2025. Speichere UUIDs als 16-Byte-Binärwert, nicht als 36 Zeichen Text. Und v7 verrät seine Erzeugungszeit; der RFC empfiehlt v4 überall dort, wo eine ID eine Sicherheitsrolle spielt.
ULID
Die ULID-Spezifikation hat dieselbe Form: einen 48-Bit-Millisekunden-Zeitstempel und 80 Zufallsbits. Ihr 26 Zeichen langer Crockford-Base32-Text ohne I, L, O und U sortiert als String korrekt und liest sich gut in URLs. Der Monotonic-Modus addiert 1 innerhalb einer Millisekunde, was die Ordnung erhält, aber den nächsten Wert erratbar macht. ULID ist eine Community-Spezifikation; für Datenbankschlüssel bietet UUIDv7 dasselbe mit nativer Unterstützung.
Wie wahrscheinlich ist eine Kollision?
Für n IDs mit b Zufallsbits liegt die Wahrscheinlichkeit irgendeiner Kollision bei etwa n² / 2^(b+1). Zeitpräfix-IDs können nur innerhalb einer Millisekunde kollidieren, also zählt die Rate, nicht die Gesamtzahl. Bei 1 Million IDs pro Sekunde über ein Jahr, gleichmäßig verteilt:
| Verfahren | Zufallsbits | Kann kollidieren mit | Wahrscheinlichkeit in einem Jahr |
|---|---|---|---|
| UUIDv4 | 122 | Jeder anderen ID | Etwa 1 zu 10 Milliarden |
| ULID | 80 | IDs derselben Millisekunde | Etwa 1 zu 77 Millionen |
| UUIDv7 | 74 | IDs derselben Millisekunde | Etwa 1 zu 1,2 Millionen |
| Snowflake | 0 | Gleiche Worker-ID und Millisekunde | Null, solange Worker-IDs eindeutig sind und Uhren vorwärts laufen |
Zufallsverfahren scheitern mit einer kleinen, berechenbaren Wahrscheinlichkeit. Snowflake scheitert nur durch den Betrieb, dann aber mit Sicherheit. Behalte in beiden Fällen eine Unique-Constraint.
Das Diagramm des Snowflake-ID-Generators
Diagramminhalt
- ID service · 3 replicas
- id-gen-0 · worker ID 0
- id-gen-1 · worker ID 1
- id-gen-2 · worker ID 2
- Payment service
- Order service
- orders · id BIGINT PRIMARY KEY
- etcd cluster
- NTP servers
- 0
- 41 bits · milliseconds since the custom epoch 2^41 ms ≈ 69.7 years
- 10 bits · worker ID 1,024 instances
- 12 bits · sequence 4,096 IDs per ms
- NextId()
- INSERT row with the new id
- lease worker ID
- time sync
So liest du es:
- Clients rufen
NextId()an einer von drei Instanzen auf, die aus dem Speicher antworten: letzte Millisekunde und ein 12-Bit-Zähler. - etcd hält jede Worker-ID auf einem 10-Sekunden-Lease, dazu die gespeicherten Zeitstempel.
- NTP hält die Uhren nah beieinander (gestrichelte Kante); chrony springt nur beim Booten.
- Der Order-Service schreibt Zeilen nach Postgres, wo steigende IDs auf der ganz rechten B-Tree-Seite landen.
- Der Balken zeigt die 64 Bit maßstabsgetreu; der Markdown-Block listet die Einstellungen.
ID-Erzeugung klar zeichnen
- Zeichne das Bitlayout maßstabsgetreu. Es zeigt das Budget auf einen Blick.
- Zeige, woher Worker-IDs kommen, mit der Lease-TTL. Dort beginnen Duplikate.
- Zeichne die Uhr als Abhängigkeit. Der Generator ist nur so korrekt wie seine Uhr.
- Schreibe die Fehlerregeln auf die Zeichenfläche: Uhr zurückgesprungen, Lease verloren, Neustart. Das Systemdesign-Diagramm-Beispiel setzt Zahlen auf dieselbe Weise neben die Boxen.
In Archboard nachbauen
- Melde dich mit Google oder GitHub an und erstelle ein Diagramm.
- Füge für den ID-Service einen Rahmen aus dem Pack Basis hinzu, darin drei bernsteinfarbene Container-Nodes aus dem Pack C4 Model: id-gen-0 bis id-gen-2, je eine Worker-ID.
- Füge links zwei Container-Nodes für Payment- und Order-Service hinzu und darunter einen Postgres-Node aus dem Pack Daten.
- Füge rechts einen violetten Container für etcd, einen blauen Server aus dem Pack Netzwerk für NTP und einen Markdown-Block aus dem Pack Basis mit den Einstellungen hinzu.
- Verbinde sie: NextId() von beiden Clients zum Rahmen, INSERT zu Postgres, das Lease zu etcd und eine gestrichelte Zeitsync-Kante von NTP.
- Füge darunter vier rechteckige Ellipse-Nodes aus dem Pack Basis hinzu, im Verhältnis 1 : 41 : 10 : 12, für das Bitlayout, und ein paar Text-Notizen aus Basis.
Ein Mermaid-Flowchart des Designs wird mit dem Mermaid-Konverter zu einem editierbaren ersten Entwurf. Das Beispiel nutzt 14 Diagramm-Nodes und bleibt damit im Free-Plan.
FAQ
Was ist eine Snowflake-ID? Eine 64-Bit-ID aus Zeitstempel, Worker-ID und einer Sequenz pro Millisekunde, 2010 von Twitter eingeführt. Generatoren arbeiten, ohne miteinander zu sprechen, und IDs sortieren grob nach Erzeugungszeit.
Ist UUIDv7 besser als ULID?
Beide tragen einen 48-Bit-Millisekunden-Zeitstempel und Zufallsbits. UUIDv7 ist ein IETF-Standard mit Datenbankunterstützung, etwa uuidv7() in PostgreSQL 18; ULID hat eine kürzere Textform. Für Datenbankschlüssel nimm UUIDv7.
Soll ich UUIDv4 oder UUIDv7 als Primärschlüssel nutzen? UUIDv7 für die meisten Tabellen: Neue Werte sortieren nach alten, sodass Inserts nahe am rechten Rand des Index bleiben. Nutze UUIDv4, wo eine ID ihre Erzeugungszeit nicht verraten darf.
Wie viele IDs pro Sekunde kann ein Snowflake-Generator erzeugen? 4.096 pro Millisekunde und Worker, etwa 4,1 Millionen pro Sekunde, oder 4,2 Milliarden über 1.024 Worker. Der Netzwerkaufruf um den Generator ist meist die eigentliche Grenze.
Was passiert, wenn die Uhr rückwärts geht? Der Generator darf keine Millisekunde wiederverwenden, die er vielleicht schon genutzt hat. Er kann fehlschlagen, bis die Uhr aufholt, warten oder eine logische Uhr führen, die der Echtzeit vorausläuft; Twitters Original schlug fehl.