UUIDv7 heeft niet de eigenschappen die je denkt
Dit artikel maakt deel uit van een collectie over UUID's.
Ik zal het gewoon zeggen: UUIDv7 is vreemd.
De meeste blogposts over UUIDv7 prijzen de volgende voordelen aan:
- Monotoon toenemende ID's;
- Een handige tijdstempel van wanneer het object is aangemaakt;
- Niet te raden (unguessable) ID's.
Geen van deze eigenschappen wordt echter strikt gegarandeerd. Bepaalde RFC-conforme implementaties van UUIDv7 schenden deze eigenschappen zelfs in extreme mate. De enige nuttige eigenschap die UUIDv7 strikt lijkt te bieden, is uniciteit en daarmee botsingsbestendigheid (collision resistance).
Ik heb al besproken hoe UUIDv7-ID's voorspelbaar kunnen zijn; dit artikel onderzoekt de overige eigenschappen.
Is de tijdstempel nuttig?
Oppervlakkig gezien lijkt het inbedden van een tijdstempel in het ID de noodzaak weg te nemen om de aanmaaktijd in een aparte databasekolom bij te houden, wat databaseoperaties marginaal efficiënter maakt. Als je objecten zeer vaak moet sorteren op aanmaaktijd, is het handig als de primaire index dit gedrag biedt, waardoor een extra index overbodig wordt.
Helaas kan de tijdstempel die in een UUIDv7-ID is ingebed, geen relatie hebben met het werkelijke tijdstip van aanmaak van het object. De Postgres-documentatie voor uuidextracttimestamp waarschuwt terecht dat "de geëxtraheerde tijdstempel niet noodzakelijkerwijs exact gelijk is aan het tijdstip waarop de UUID is gegenereerd".
RFC 9562 staat implementaties toe om het tijdstempelveld aan te passen, waarbij waarden worden gebruikt die niet strikt zijn afgeleid van de systeemklok. Dit hangt samen met de spanning tussen UUID-monotoniciteit en de nauwkeurigheid van de tijdstempel.
In bepaalde scenario's kan het verschil tussen de tijdstempel en de huidige tijd willekeurig groot zijn.
"Implementaties MOGEN de werkelijke tijdstempel wijzigen." — RFC 9562 §6.1
"Deze specificatie stelt geen eisen aan of geeft geen garantie over hoe dicht de klokwaarde bij de werkelijke tijd moet liggen." — RFC 9562 §6.1
"... implementaties MOGEN de tijdstempel vooruitstellen ten opzichte van de werkelijke tijd ..." — RFC 9562 §6.2
Het vooruitstellen van de tijdstempel ten opzichte van de systeemklok is een interessant probleem. Als de generator zoveel belasting ervaart dat een tellerveld (counter field) regelmatig overloopt, kan de tijdstempel versnellen naar de toekomst. Het gebruik van een voldoende grote teller zou verstandig zijn; de RFC stelt dat de minimale grootte 12 bits ZOU moeten zijn.
Een overflow van de teller zou een zeer agressieve aanmaaksnelheid vereisen—meer dan 4.096 ID's per milliseconde per node—wat buiten de limieten van veel systemen ligt. Deze overflow wordt echter relevant als de tijdstempel bevriest, wat ik in een later gedeelte zal bespreken.
Voorbeeld: Python uuid.uuid7()
Een prominente implementatie die de tijdstempel verhoogt, is die in de standaardbibliotheek van Python. De verhoging vindt alleen plaats onder bepaalde omstandigheden, zoals wanneer de teller overloopt.
Helaas kan de generator vast komen te zitten in een verhogingscyclus als de klok ooit terugspringt, waardoor de tijdstempel met een willekeurig groot bedrag vooruit kan racen ten opzichte van de systeemtijd.
Hier is een eenvoudig voorbeeld:
import uuid
from datetime import datetime
# Initialiseer de generator met de huidige tijd
t = uuid.uuid7().time
now = datetime.now()
delta = (datetime.fromtimestamp(t/1000) - now).total_seconds()
print("Initial delta: ", delta)
# Vraag de gebruiker om de tijd aan te passen
input("Zet de klok één uur terug")
while True:
for _ in range(100000):
uuid.uuid7()
t = uuid.uuid7().time
now = datetime.now()
delta = (datetime.fromtimestamp(t/1000) - now).total_seconds()
print("New delta: ", delta)
Dit geeft het volgende resultaat:
$ uv run --python 3.14 main.py
Initial delta: -0.000748
Set clock back one hour
New delta: 3695.277581
New delta: 3795.042411
New delta: 3894.80756
New delta: 3994.573106
^C
We zouden verwachten dat het verschil 3600 seconden (één uur) is, aangezien de implementatie weigert de ingebedde tijdstempel terug te laten springen. We verwachten dat het verschil afneemt naarmate de systeemklok de bevroren tijdstempel inhaalt. Maar wanneer de belasting boven één ID per milliseconde ligt, zal het verschil onbeperkt toenemen. Bij een lagere belasting duurt het langer voordat de systeemklok de tijdstempel inhaalt, maar we zien dan niet dit runaway-effect.
In een gerelateerde issue redeneren Python-ontwikkelaars dat dit consistent is met de RFC en andere implementaties, en dat er "niet veel te doen is" aan een "gebrekkig systeem". Ik denk dat deze punten correct zijn, aangezien ik dit gedrag in andere implementaties zie. Desalniettemin zou het nuttig zijn als bestaande UUIDv7-implementaties sprongen in de systeemklok kunnen afhandelen zonder dit runaway-gedrag. Er zijn veel praktijkvoorbeelden van klokken die terugspringen, zelfs decennia uit sync in tijdgevoelige omgevingen.
Is het lekken van een tijdstempel schadelijk?
Het inbedden van de tijdstempel in het ID betekent dat je het ID niet kunt delen zonder ook de tijdstempel te delen. Het is lastig om een voorbeeld te bedenken waar dit echt problematisch is.
Eén voorbeeld zou een gebruikersprofielpagina kunnen zijn die niet onthult wanneer het account is aangemaakt, maar waarbij het UUIDv7-ID in de URL de tijdstempel met hoge precisie lekt. Dit probleem zou doorgaans heel laag op mijn lijst met beveiligingszorgen staan, omdat ik bij beveiligingsbeoordelingen veel ernstigere problemen tegenkom. Toch zijn er contexten waarin dit uitmaakt en waar engineeringmiddelen worden ingezet om dit aan te pakken. Als je in dat laatste kamp valt, wees dan waakzaam voor UUIDv7 die metadata lekt.
Zijn UUIDv7's monotoon?
Kort gezegd: UUIDv7-ID's bieden een soort ruwe volgorde, wat mogelijk voldoende is voor jouw vereisten.
Toen ik de UUIDv7 RFC las, was ik verrast te zien dat er een methode wordt beschreven voor het maken van ID's over verschillende nodes die monotoon geordend zijn. Dit is al in de jaren 70 als onmogelijk bewezen, wat leidde tot de ontwikkeling van vector clocks en soortgelijke technologie. In essentie is het probleem dat systeemklokken niet met een geschikte precisie kunnen worden gesynchroniseerd.
Het sorteren van zaken op tijdstempel werkt alleen als je precies weet hoe laat het is. Elke onzekerheid in de tijd resulteert in onzekerheid over de volgorde van gebeurtenissen. Kortom: tijdstempels zijn alleen tijdgeordend als je weet hoe laat het is, maar je weet niet precies hoe laat het is.
Om deze reden beweren vergelijkbare ID-formaten dat ze "bijna gesorteerd" (nearly sorted) zijn. Het is vreemd dat UUIDv7 een striktere claim maakt, maar deze niet waarmaakt. In ieder geval maken de uitdagingen bij het synchroniseren van klokken over systemen heen de milliseconde- en zeker de sub-milliseconde-tijdstempels zeer twijfelachtig.
Maar zelfs op een enkele node, met één implementatie, zijn UUIDv7-ID's niet strikt monotoon. De makkelijkste manier om UUIDv7-ID's in de verkeerde volgorde te krijgen, is door een implementatie te gebruiken die een milliseconde-tijdstempel gebruikt zonder teller. Deze aanpak genereert triviaal ID's in de verkeerde volgorde binnen een kleine for-loop.
Er staat dus niets in de UUIDv7-specificatie dat garandeert dat de ID's strikt monotoon toenemend zijn. Specifieke implementaties kunnen beter presteren dan andere (met sub-milliseconde tijdstempels, tellers, goed gesynchroniseerde klokken, etc.), maar we weten sinds de jaren 70 dat dit probleem niet strikt oplosbaar is.
In het voordeel van UUIDv7: ruw geordende ID's zijn nog steeds zeer nuttig. Verwijder UUIDv7 dus nog niet uit je systemen, maar wees je bewust van de aannames die je doet.
Een UUIDv7-generator herstarten
De RFC bevat veel discussie over de omgang met klokken die terugspringen. Tijdstempels zijn alleen tijdgeordend als klokken vooruit tikken, dus is speciale zorg nodig.
We hebben al één mitigatie besproken: het bijhouden van de laatst gegenereerde tijdstempel om ervoor te zorgen dat er nooit een eerdere tijdstempel wordt gebruikt. Om dit veilig te doen, moet je de laatst gebruikte tijdstempel naar persistente opslag schrijven, zodat je deze na een systeemherstart of een procesherstart opnieuw kunt lezen.
Ik heb verschillende UUIDv7-implementaties bekeken en geen enkele gevonden die tijdstempels naar persistente opslag schrijft. Implementateurs lijken dus geen interesse te hebben in het aanpakken van dit probleem, en ze genereren ID's in de verkeerde volgorde wanneer een procesherstart volgt op een terugsprong van de klok.
Kijkend naar echte problemen met kloksynchronisatie, vind je veel voorbeelden van klokken die worden ingesteld op datums in de toekomst. Wanneer dit gebeurt, zou een terugsprong de tijdstempel weer in lijn brengen met de werkelijke tijd, maar een beveiliging die de laatst gebruikte tijdstempel bijhoudt, zou dwingen om die onjuiste tijdstempel te blijven gebruiken totdat de systeemklok hem inhaalt. Als de systeemklok er lang over doet om in te halen, kun je last krijgen van counter rollovers.
Dus, wat is UUIDv7?
Mijn mentale model voor UUIDv7 is dat het een mengelmoes is van verschillende ideeën waar implementaties vrij in zijn om uit te kiezen naar eigen inzicht. Er is een grote variatie in eigenschappen die UUIDv7-implementaties kunnen bieden, met verschillende gradaties van zekerheid dat ze die eigenschappen daadwerkelijk kunnen leveren.
Als je je eigen implementatie bouwt, kun je waarschijnlijk iets maken dat de eigenschappen heeft die je nodig hebt. Maar de meeste van ons willen een kant-en-klare versie gebruiken, vooral wanneer standaardbibliotheken een implementatie bieden; we zijn dus over het algemeen overgeleverd aan de beslissingen van iemand anders. Implementaties kunnen in de loop der tijd veranderen en grote wijzigingen in hun gedrag aanbrengen terwijl ze nog steeds RFC-conform zijn. Gedistribueerde systemen kunnen meerdere implementaties hebben over hun subsystemen, elk met verschillende eigenschappen.
Het enige dat je echt kunt verwachten is sterke botsingsvermijding en ID-uniciteit.
Stel deze vragen wanneer je een UUIDv7-implementatie kiest:
- Is de implementatie conform de RFC?
- Welke extra eigenschappen garandeert deze implementatie?
- Binnen welke limieten kunnen deze garanties worden nagekomen?
- Zijn er aannames nodig om deze eigenschappen te behouden, zoals een nauwkeurige systeemklok?
- Onder welke omstandigheden zal de implementatie de tijdstempel wijzigen?
- Hoe voorspelbaar zijn de gegenereerde ID's?
Helaas is dit soort analyse veel moeilijker dan het bouwen van een RFC-conforme generator. Auteurs van UUIDv7-generatoren weten mogelijk geen antwoord op deze vragen en kunnen zelf verrast zijn dat de RFC dit niet voor hen regelt.
Groetjes,