Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi System Design — #11

Unique ID Üretimi: Auto-Increment, UUIDv4, UUIDv7 ve Snowflake

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Unique ID Üretimi: Auto-Increment, UUIDv4, UUIDv7 ve Snowflake

Sharding'li ya da çok instance'lı bir sistemde ID seçimi benzersizlik, sıralanabilirlik, boyut, koordinasyon ve ID'nin dışarıya ne sızdırdığı arasında bir trade-off'tur: UUIDv7 koordinasyonsuz ve index dostu, Snowflake 64 bite sığar ama worker ID ve saat yönetimi ister. .NET'in CreateVersion7'sinin milisaniye içinde sıra garanti etmediğinin, C# Snowflake generator'ın ve JavaScript'in 2^53 sınırının gerçek çıktıları.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; Snowflake ölçümü Release build'de alındı.

Önce Tahmin Et

Bookstore'un SQL (Structured Query Language) Server'daki Orders tablosu yıllardır int IDENTITY ile yaşıyor. Sipariş hacmi büyüdü, ekip serinin "Veri Katmanını Ölçeklemek: Replication, Sharding ve SQL mi NoSQL mi?" yazısındaki gibi tabloyu customer_id ile dört shard'a bölmeye karar verdi. Artık dört ayrı veritabanının her biri kendi identity'sini 1'den sayıyor. Ekipten biri "Guid.CreateVersion7() kullanalım, zamana göre sıralı" diyor; bir diğeri "Snowflake gibi 64 bitlik bir sayı üretelim, bigint'e sığar" diyor.

İki soru. Tek bir process'te art arda üretilen 100.000 UUIDv7'nin kaçı, kendisinden hemen önce üretilenden "küçük" sıralanır? Ve Snowflake ID'yi JSON (JavaScript Object Notation) içinde sayı olarak Angular uygulamasına gönderdiğinde, tarayıcı aynı sayıyı mı görür?

ID'den Ne İstiyoruz?

"Benzersiz bir ID" tek bir gereksinim gibi görünür ama altında en az altı ayrı soru var. Benzersizlik: bütün sistemde, bütün zamanlarda çakışmamalı. Sıralanabilirlik: ID'ye göre sıralamak oluşturulma sırasına yakın bir sıra vermeli; index'e eklemenin maliyeti ve "en yeni siparişler" sorgusu buna bağlı. Boyut: 8 byte'lık bir bigint ile 16 byte'lık bir UUID (Universally Unique Identifier) arasındaki fark, her foreign key'de ve her index'te tekrar eder. Koordinasyon: ID'yi üretmek için merkezi bir yere gitmek gerekiyor mu, o yer düşerse yazmalar durur mu? Tahmin edilemezlik: ID'yi gören biri bir sonrakini tahmin edebilir mi? Bilgi sızıntısı: ID, oluşturulma zamanını, hangi makinede üretildiğini ya da günde kaç sipariş aldığını söylüyor mu?

SeçenekBoyutSıralı mıKoordinasyonDışarıya ne söyler
Veritabanı identity / sequence4–8 byteTam sıralıTek veritabanıToplam kayıt sayısı, hacim
Ticket server / hi-lo blok8 byteKabaca sıralıBlok başına bir çağrıHacim
UUIDv416 byteHayırYokHiçbir şey
UUIDv716 byteMilisaniye düzeyindeYokOluşturulma zamanı
Snowflake8 byteMilisaniye düzeyindeWorker ID atamasıZaman, worker, ms içindeki sıra

Veritabanı Sequence'i ve Sharding

Tek bir veritabanı olduğu sürece identity ya da sequence rakipsizdir: küçük, sıralı, veritabanının kendisi garanti eder. Sharding'le birlikte dört veritabanı aynı 1, 2, 3'ü üretir. Klasik çareler üç tane. Birincisi her shard'a farklı başlangıç ve aynı artış vermek: dört shard için 1, 5, 9... / 2, 6, 10... Çalışır ama shard sayısını değiştirmek bütün şemayı yeniden düşünmek demektir. İkincisi merkezi bir ticket server: Flickr 2010'da "Ticket Servers: Distributed Unique Primary Keys on the Cheap" yazısında, yalnız ID üretmek için var olan iki MySQL sunucusunu anlattı; biri tek, diğeri çift sayıları veriyordu ki biri düşünce diğeri devam etsin. Üçüncüsü blok tahsisi (hi-lo): uygulama merkezi sequence'ten tek bir ID değil, örneğin 1.000'lik bir blok alır ve blok bitene kadar ID'leri bellekte dağıtır. Entity Framework Core'un (EF Core) SQL Server provider'ındaki UseHiLo() bu pattern'in .NET'teki karşılığı. Bedeli, sıranın kabaca korunması: iki instance'ın blokları iç içe geçer.

Hepsinin ortak noktası, ID'nin bir yerden "alınması". Instagram 2011'de "Sharding & IDs at Instagram" yazısında başka bir yol seçti: ID'yi her logical shard'ın kendi içinde, PostgreSQL'de PostgreSQL'in procedural dilinde (PL/pgSQL) yazılmış bir fonksiyonla üretti; 41 bit zaman, 13 bit shard ID, 10 bit sequence. Bu, aşağıda göreceğimiz Snowflake fikrinin veritabanına gömülmüş hâli.

UUIDv4: Benzersiz Ama Dağınık

UUIDv4'ün 122 biti rastgele; çakışma ihtimali pratikte sıfır ve hiçbir koordinasyon gerektirmiyor. Guid.NewGuid() tam olarak bu. Sorun veritabanında başlıyor. Clustered index (ya da B-tree primary key), satırları key sırasıyla tutar; rastgele bir key her seferinde index'in rastgele bir yerine eklenir, sayfalar bölünür, yarı dolu kalır ve bellekte tutulması gereken "sıcak" sayfa sayısı bütün index'e yayılır. UUID'yi tanımlayan RFC (Request for Comments) 9562, v4'ün bu zayıf index yerelliğini yeni sürümleri tanımlamanın ana gerekçelerinden biri olarak yazıyor. Çözüm Desenleri serisinin "Id'yi Dışarıya Açmak: int Identity, GUID v7 ve Public Id Ayrımı" yazısı bunu SQL Server'da ölçtü: 200.000 satırda rastgele GUID (Globally Unique Identifier) primary key yüzde 99,4 fragmentation ve identity'ye göre yaklaşık yüzde 60 fazla sayfa üretti.

UUIDv7 ve Guid.CreateVersion7()

RFC 9562 (Mayıs 2024, RFC 4122'nin yerini aldı) zaman sıralı yeni bir sürüm tanımladı: ilk 48 bit milisaniye cinsinden Unix zamanı, ardından 4 bit version, 12 bit rand_a, 2 bit variant ve 62 bit rand_b. RFC, mümkün olan yerde v1 ve v6 yerine v7 kullanılmasını öneriyor. .NET 9 ile gelen Guid.CreateVersion7() bunu üretiyor; PostgreSQL 18'de de uuidv7() fonksiyonu var.

İki tuzak var. Birincisi SQL Server'a özgü: uniqueidentifier string sırasıyla değil, son 6 byte'ı en anlamlı kabul ederek sıralanır; v7'de o byte'lar rastgele kısım. Yukarıda andığım Çözüm Desenleri yazısındaki ölçümde v7 primary key, SQL Server'da v4 kadar dağınık çıktı. PostgreSQL'in uuid tipi byte sırasıyla sıraladığı için orada v7 gerçekten sıralı.

İkincisi, Önce Tahmin Et'teki ilk sorunun konusu: aynı milisaniye içindeki sıra. Sıkı bir döngüde 100.000 tane ürettim ve her birini bir öncekiyle karşılaştırdım:

// Sıkı döngüde Guid.CreateVersion7: üretim sırası ile sıralama aynı mı?
Guid[] v7 = new Guid[100_000];
for (int i = 0; i < v7.Length; i++) v7[i] = Guid.CreateVersion7();
int outOfOrder = 0, sameMs = 0;
for (int i = 1; i < v7.Length; i++)
{
    if (string.CompareOrdinal(v7[i].ToString(), v7[i - 1].ToString()) < 0) outOfOrder++;
    if (v7[i].ToString()[..13] == v7[i - 1].ToString()[..13]) sameMs++;
}
Console.WriteLine($"v7: {v7.Length:N0} ids, {sameMs:N0} share the previous id's ms, {outOfOrder:N0} sort before the previous id");
v7: 100,000 ids, 99,970 share the previous id's ms, 49,969 sort before the previous id

Yaklaşık yarısı. Sebep, dotnet/runtime'daki implementation'da açıkça görülüyor: CreateVersion7 bir NewGuid() üretip yalnız ilk 48 biti zamanla, version ve variant bitlerini de sabitlerle eziyor; rand_a ve rand_b tamamen rastgele kalıyor, milisaniye içinde bir sayaç yok. RFC bu konuda serbest bırakıyor: aynı milisaniyede toplu üretim yapan ve sıranın önemli olduğu implementation'lar için sayaç (Method 1), artan rastgele değer (Method 2) ya da milisaniye altı zaman (Method 3) öneriyor, ama zorunlu tutmuyor. Pratik sonuç şu: v7 index yerelliği için fazlasıyla yeterli, çünkü aynı milisaniyedeki ID'ler zaten index'in aynı sayfasına düşer. Ama "ID'ye göre sırala, oluşturulma sırasını al" varsayımına milisaniye altında güvenme. Sıra iş açısından önemliyse (bir sohbetteki mesajlar gibi) ayrı bir sıra numarası tut; serinin chat case study'si tam bunu yapıyor.

Snowflake: 64 Bit'e Sığan Sıralı ID

Twitter 2010'da tweet ID'leri için Snowflake'i duyurdu. Açık kaynak README'sindeki tasarım (README'de makine ID'si "configured", yani dışarıdan verilen bir değer): 41 bit milisaniye (özel bir epoch ile yaklaşık 69 yıl), 10 bit makine ID'si (1.024 makine), 12 bit sequence (makine başına milisaniyede 4.096 ID). Gereksinimleri de README'de yazıyor: makineler birbirleriyle koordine olmadan üretebilmeli ve ID'ler kabaca zaman sıralı olmalı. Sonuç bigint'e sığan, index'e sondan eklenen, koordinasyonsuz bir ID. C# ile:

// 1 kullanılmayan bit | epoch'tan beri 41 bit milisaniye | 10 bit worker | 12 bit sequence
public sealed class SnowflakeGenerator
{
    public static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
    private const int WorkerBits = 10, SequenceBits = 12;
    private const long MaxSequence = (1L << SequenceBits) - 1;     // 4095

    private readonly TimeProvider _time;
    private readonly long _workerId;
    private readonly Lock _lock = new();
    private long _lastMs = -1, _sequence;

    public SnowflakeGenerator(int workerId, TimeProvider? time = null)
    {
        ArgumentOutOfRangeException.ThrowIfGreaterThan(workerId, (1 << WorkerBits) - 1);
        _workerId = workerId;
        _time = time ?? TimeProvider.System;
    }

    public long WaitedForNextMs { get; private set; }

    public long NextId()
    {
        lock (_lock)
        {
            long ms = NowMs();
            if (ms < _lastMs)
                throw new InvalidOperationException($"Clock moved backwards by {_lastMs - ms} ms; refusing to generate ids.");
            if (ms == _lastMs)
            {
                _sequence = (_sequence + 1) & MaxSequence;
                if (_sequence == 0)                        // bu ms'de 4096 id üretildi: sonraki ms'yi bekle
                {
                    WaitedForNextMs++;
                    while ((ms = NowMs()) <= _lastMs) Thread.SpinWait(50);
                }
            }
            else
            {
                _sequence = 0;
            }
            _lastMs = ms;
            return (ms << (WorkerBits + SequenceBits)) | (_workerId << SequenceBits) | _sequence;
        }
    }

    private long NowMs() => (long)(_time.GetUtcNow() - Epoch).TotalMilliseconds;

    public static (DateTimeOffset CreatedAt, int Worker, int Sequence) Decode(long id) =>
        (Epoch.AddMilliseconds(id >> (WorkerBits + SequenceBits)),
         (int)((id >> SequenceBits) & ((1 << WorkerBits) - 1)),
         (int)(id & MaxSequence));
}

Saat TimeProvider üzerinden geliyor; aşağıda saati elle geri almak için bunu kullanacağız. Dört worker'ı paralel çalıştırıp her birine bir milyon ID ürettirdim:

snowflake: 4,000,000 distinct of 4,000,000 in 296 ms, increasing per worker: True, waited for next ms 932 times

Dört milyonun hepsi farklı, her worker'ın ID'leri kesin artan. "932 kez bekledi" satırı limitin kendisi: bir worker milisaniyede 4.096'dan fazla ID isteyince sequence taşıyor ve generator bir sonraki milisaniyeyi bekliyor. Worker başına saniyede yaklaşık 4 milyon ID tavanı, Bookstore'un ihtiyacının çok üstünde; ama tavan olduğunu bilmek, bit dağılımını kendi ihtiyacına göre değiştirmeyi (daha az worker biti, daha çok sequence biti) mümkün kılıyor.

Snowflake'in gerçek zor kısmı kod değil, worker ID'si. İki instance aynı worker ID ile çalışırsa aynı milisaniyede aynı ID'leri üretir ve benzersizlik garantisi sessizce kaybolur. Seçenekler: configuration'dan sabit değer (autoscaling'de kırılgan), Kubernetes StatefulSet'teki pod sıra numarası, ya da başlangıçta Redis veya ZooKeeper gibi bir store'dan süreli bir lease almak.

Saat Geri Giderse

Snowflake'in sıralılığı saate güveniyor ve saatler geri gidebilir: NTP (Network Time Protocol) senkronizasyonu ileri kaçmış bir saati birkaç milisaniye geri çekebilir, sanal makine taşınırken saat sıçrayabilir. Saat geri giderse generator geçmişteki bir milisaniyenin ID'lerini yeniden üretme riskine girer. Twitter'ın README'si buna açık bir cevap veriyor: Snowflake, son ürettiği zamandan sonraki bir zamana gelene kadar ID üretmeyi reddeder; NTP'nin de saati geri almayan bir modda çalıştırılmasını öneriyor. Aynı davranışı elle kurduğum bir saatle denedim:

ManualClock clock = new(new DateTimeOffset(2026, 11, 8, 9, 30, 0, TimeSpan.Zero));
SnowflakeGenerator gen = new(7, clock);
gen.NextId();
clock.Now = clock.Now.AddMilliseconds(-5);
try { gen.NextId(); }
catch (InvalidOperationException ex) { Console.WriteLine($"clock went back: {ex.Message}"); }
clock went back: Clock moved backwards by 5 ms; refusing to generate ids.

Reddetmek güvenli ama sert bir seçim: birkaç milisaniyelik geri çekilmede kısa bir süre beklemek, büyük bir geri sıçramada ise hata verip alarm üretmek daha pratik bir orta yol. Ne seçersen seç, bu bir metrik olmalı; saatin geri gitmesi sessizce geçiştirilecek bir olay değil. Saatlerin dağıtık sistemlerde neden güvenilmez olduğu ilerideki Distributed Systems Temelleri serisinin konusu.

Public ID Ne Sızdırır?

ID bir API'de (Application Programming Interface), bir adres satırında (URL, Uniform Resource Locator) ya da bir e-postada dışarı çıktığı anda okunabilir bir veri olur. Aynı programda iki ID'yi çözdüm:

snowflake 112719806108546862 -> created 2026-11-08 01:08:16.009, worker 3, sequence 1838
v7 01a1ba7e-463b-70a2-a544-55020b79ccef -> created 2026-11-08 09:30:00.123

Snowflake ID siparişin oluşturulma anını, hangi worker'da üretildiğini ve o milisaniyedeki sırasını söylüyor; v7 de oluşturulma zamanını milisaniyesine kadar veriyor. Ardışık bir int ise toplam sipariş sayısını ve iki tarih arasındaki farkla günlük hacmi söyler. Bunlar bir rakibin iş hacmini tahmin etmesine yetebilir. Daha kötüsü, tahmin edilebilir ID'ler IDOR'a (Insecure Direct Object Reference) davetiye çıkarır: OWASP (Open Worldwide Application Security Project) Top 10 serisinin "OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoint'leri ve ASP.NET Core'da Yetkilendirme" yazısındaki gibi, /orders/1042'yi görebilen biri /orders/1043'ü dener. Tahmin edilemez ID yetkilendirmenin yerini tutmaz, ama ikinci bir savunma hattıdır. Yaygın çözüm Çözüm Desenleri yazısındaki ayrım: içeride sıralı ve küçük bir ID, dışarıda ayrı bir public ID.

Bir de Önce Tahmin Et'teki ikinci soru. JavaScript'te Number bir double'dır ve 2^53 − 1'in üstündeki tam sayıları kesin temsil edemez. Snowflake ID'leri bu sınırın çok üstünde:

snowflake 112719806108546862 as a JS Number -> 112719806108546864, equal: False, max safe integer 9007199254740991

Sayı olarak serialize edilen ID tarayıcıda başka bir sayıya dönüşür ve Angular uygulaması var olmayan bir siparişi ister. Twitter'ın API'si bu yüzden her ID'yi bir de id_str olarak string döndürüyordu. ASP.NET Core'da 64 bitlik ID'leri JSON'da string olarak yazmak için JsonNumberHandling.WriteAsString ya da ID'ye özel bir converter kullan; API sözleşmesinde ID'nin tipi string olsun.

Sık Düşülen Tuzaklar

UUIDv7'yi her veritabanında sıralı sanmak

SQL Server uniqueidentifier'ı son byte'lardan sıralar; v7 orada v4 kadar dağınık. PostgreSQL'de sıralı. Veritabanının karşılaştırma kuralına bak.

Milisaniye içi sıraya güvenmek

Guid.CreateVersion7() aynı milisaniyede sayaç kullanmıyor; ardışık iki ID'nin yarısı ters sıralı. Kesin sıra gerekiyorsa ayrı bir sıra numarası tut.

Worker ID'yi şansa bırakmak

Aynı worker ID'li iki instance, benzersizliği sessizce bozar. Worker ID'yi lease ile ata ve çakışmayı başlangıçta kontrol et.

64 bitlik ID'yi JSON'da sayı olarak göndermek

JavaScript 2^53'ün üstünü yuvarlar. ID'ler API'de string olmalı.

ID'yi yetki sanmak

Tahmin edilemez bir ID, sahiplik kontrolünü gereksiz kılmaz; her sorguda "bu kayıt bu kullanıcının mı?" kontrolü kalır.

Küçük Bir Deneme

★☆☆ SnowflakeGenerator'da worker bitlerini 10'dan 5'e indirip sequence bitlerini 17'ye çıkar. Worker başına milisaniyedeki tavan ne olur, kaç worker çalıştırabilirsin?

★★☆ RFC 9562'deki Method 1'i uygula: aynı milisaniyede rand_a'nın 12 bitini sayaç olarak kullanan bir CreateMonotonicV7() yaz ve yukarıdaki döngüde ters sıralı ID sayısını tekrar ölç.

★★★ Saat 3 ms geri gittiğinde bekleyen, 1 saniyeden fazla geri gittiğinde hata veren bir politika yaz. ManualClock ile iki durumu test et ve bekleme süresini bir metrik olarak dışarı ver.

Yapı taşları tamam: load balancing, cache, veri katmanı, consistent hashing, asenkron işleme, rate limiting, reliability ve ID üretimi. Bir sonraki yazıda bunların hepsini ilk case study'de bir araya getiriyoruz: bir URL shortener'ı requirement'tan deep dive'a kadar tasarlayacağız.