How to Design a Unique ID Generation System
A Senior+ Guide — Building Snowflake, KUID, ULID, and Distributed ID Generators for Globally Unique, Time-Ordered Identifiers at Billion-QPS Scale
Table of Contents
- Introduction — Why Unique IDs Matter
- Functional & Non-Functional Requirements
- Capacity Estimation
- ID Format Design
- API Design
- High-Level Architecture
- UUID v4 — Random Generation
- UUID v7 — Time-Ordered
- Twitter Snowflake Deep Dive
- ULID — Universally Unique Lexicographically Sortable
- KUID — Hash-Based Unique IDs
- Database Auto-Increment & Multi-Master
- Clock Synchronization & NTP
- Segment-Based Allocation
- Monotonicity Guarantees
- ID Performance Benchmarks
- ID Security Considerations
- ID Conversion & Encoding
- Database Design for ID Storage
- Caching Strategy
- Multi-Region Design
- Cost Estimation
- Interview Q&A
- Full C# Implementation
- Conclusion
1. Introduction — Why Unique IDs Matter
Every distributed system on the planet shares one fundamental challenge: generating identifiers that are guaranteed to be unique across thousands of machines, data centers, and geographic regions — all without becoming a bottleneck. From database primary keys to event identifiers in Kafka, from session tokens to IoT device telemetry, unique IDs are the invisible scaffolding that holds modern software together.
The requirements for a well-designed unique ID system are deceptively complex. The IDs must be globally unique — two independently operating services must never produce the same value. They should ideally be time-ordered, meaning IDs generated later are lexicographically greater than those generated earlier, which enables efficient database indexing. They must be generated at massive scale — systems like Twitter, Uber, and Instagram generate millions of IDs per second. And they must work in distributed environments where no central coordination is available.
Consider the bit-width trade-off. A 64-bit integer gives you roughly 1.8 × 10¹⁹ possible values — sounds enormous until you realize that at 10 million IDs per second, you exhaust the space in about 58 years. A 128-bit UUID gives you 3.4 × 10³⁸ values, which is effectively infinite, but at the cost of larger storage, slower indexing, and reduced cache efficiency. The choice between 64-bit and 128-bit IDs is not merely technical; it has profound implications for database performance, network bandwidth, and storage costs at scale.
Distributed generation introduces its own class of problems. If every machine generates IDs independently, how do you prevent collisions? If IDs must be monotonically increasing, how do you coordinate across machines that may have clock skew of tens of milliseconds? These questions have spawned a rich ecosystem of ID generation strategies — from Twitter's Snowflake to Google's TrueTime-powered Spanner IDs, from the elegant simplicity of ULID to the hash-based collision resistance of KUID.
This article provides a comprehensive, senior-level deep dive into unique ID generation. We will dissect the bit layouts of every major ID format, build a production-ready ID generator service in C#, analyze the failure modes and trade-offs of each approach, and equip you with the knowledge to design an ID system that meets the demands of billion-QPS workloads.
2. Functional & Non-Functional Requirements
Before choosing an ID generation strategy, we must clearly articulate what we need. A requirements-driven approach prevents over-engineering and ensures the chosen solution fits the actual workload.
Functional Requirements
| Requirement | Description | Priority |
|---|---|---|
| Globally Unique | No two calls across any machine, region, or time window should produce the same ID | P0 — Critical |
| Time-Ordered | IDs generated later should sort after IDs generated earlier (lexicographic ordering) | P0 — Critical |
| 64-bit or 128-bit | IDs must fit in a 64-bit integer for efficient database storage, or 128-bit for maximum uniqueness | P0 — Critical |
| Monotonically Increasing | Each new ID from the same generator must be strictly greater than the previous one | P1 — Important |
| Portable | IDs must be usable across different databases, message queues, and storage systems | P1 — Important |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Latency | < 1ms per ID generation | ID generation sits on the hot path of every write operation |
| Throughput | ≥ 10 million IDs/second per region | Supports large-scale systems like social media feeds |
| Availability | 99.999% (five nines) | ID generation failure blocks all writes — it must never go down |
| Partition Tolerance | Continue generating IDs even during network partitions | Distributed systems must handle network failures gracefully |
| No Single Point of Failure | Multiple independent generators can operate simultaneously | Centralized generators are inherently fragile |
3. Capacity Estimation
Capacity planning for ID generation requires precise arithmetic over bit widths, generation rates, and time horizons. Getting this wrong leads to either wasted bits (over-provisioning) or catastrophic collisions (under-provisioning).
Bits Allocation Analysis
At 10 million IDs per second, a 64-bit space lasts approximately:
This seems comfortable, but the practical limit is determined by how many bits are allocated to each component. In a Snowflake-style 64-bit ID with 41 bits for timestamp, 10 bits for machine, and 12 bits for sequence, the maximum duration is:
And the per-millisecond throughput per machine is 2¹² = 4,096 IDs. With 1,024 machines (10 bits), the total throughput is 4,096 × 1,024 = 4,194,304 IDs per millisecond, or roughly 4.2 billion IDs per second.
Throughput Requirements
| Scale Tier | IDs/Second | IDs/Day | Bits Needed (64-bit) | Example System |
|---|---|---|---|---|
| Small | 1,000 | 86.4M | 41 (timestamp) + 0 + 10 (sequence) | Internal tool |
| Medium | 100,000 | 8.64B | 41 + 5 (machine) + 12 | SaaS application |
| Large | 10,000,000 | 864B | 41 + 10 (machine) + 12 | Social media platform |
| Massive | 1,000,000,000 | 86.4T | 41 + 20 (machine) + 2 (sequence) | Global IoT mesh |
Storage Estimation
If we store 1 billion IDs per day in a database with 64-bit primary keys, the storage overhead per row for the ID alone is 8 bytes. For a year of data:
Switching to 128-bit UUIDs doubles this to 5.84 TB/year — a significant storage and indexing cost that justifies careful ID format selection. When we factor in B-tree index overhead (typically 2-3× the raw data size), 64-bit IDs offer a compelling advantage: approximately 6-9 TB of index storage versus 12-18 TB for 128-bit UUIDs.
4. ID Format Design
The canonical approach to designing a structured unique ID is the bit-packing layout, where we carve the ID into distinct fields, each serving a specific purpose. The most common fields are timestamp, machine/node identifier, and sequence number.
1 bit"] --> T["Timestamp
41 bits"] --> DC["Datacenter
5 bits"] --> M["Machine
5 bits"] --> SEQ["Sequence
12 bits"] end style S fill:#fee2e2,stroke:#ef4444,color:#000 style T fill:#dbeafe,stroke:#0088ff,color:#000 style DC fill:#d1fae5,stroke:#10b981,color:#000 style M fill:#fef3c7,stroke:#f59e0b,color:#000 style SEQ fill:#e0e7ff,stroke:#6366f1,color:#000
Component Breakdown
Timestamp (41 bits): Stores milliseconds since a custom epoch. With 41 bits, we can represent 2⁴¹ ms ≈ 69.7 years. If we set our epoch to January 1, 2025, the IDs will be valid until approximately 2094. The timestamp provides two critical properties: time-orderedness and the ability to extract the creation time from any ID.
Datacenter ID (5 bits): Supports up to 32 data centers. In practice, most organizations use fewer than 10, leaving room for growth. This field prevents collisions between machines in different data centers that might otherwise have the same machine ID.
Machine ID (5 bits): Supports up to 32 machines per data center. Combined with the datacenter field, this provides 32 × 32 = 1,024 unique machine identifiers. For larger deployments, we can rebalance the bit allocation — for instance, 6 bits for datacenter and 4 bits for machine, or vice versa.
Sequence Number (12 bits): A per-machine counter that increments for each ID generated within the same millisecond. With 12 bits, each machine can generate 4,096 IDs per millisecond, or approximately 4.2 million IDs per second — more than sufficient for most use cases.
5. API Design
A well-designed ID generation API exposes three core operations: generating a single ID, generating a batch of IDs, and inspecting the next ID without consuming it.
Core API Methods
| Method | Signature | Description | Use Case |
|---|---|---|---|
next_id() | long next_id() | Generates and returns the next unique ID | Standard single-record insertion |
batch_ids(count) | long[] batch_ids(int count) | Generates a contiguous range of IDs | Bulk inserts, batch processing |
peek() | long peek() | Returns the next ID without incrementing the counter | Pre-flight checks, distributed coordination |
REST API Endpoints
POST /api/v1/ids/generate
{
"count": 1,
"format": "decimal" // "decimal" | "hex" | "base62" | "base32"
}
Response:
{
"ids": ["5847291038475920384"],
"metadata": {
"generator_id": "gen-us-east-01",
"timestamp": 1752508800000,
"sequence": 1
}
}
GET /api/v1/ids/peek
Response:
{
"next_id": "5847291038475920385",
"remaining_capacity_in_window": 4095
}
POST /api/v1/ids/validate
{
"id": "5847291038475920384"
}
Response:
{
"valid": true,
"timestamp": "2026-07-14T10:30:00.000Z",
"machine_id": 7,
"datacenter_id": 3,
"sequence": 0
}
6. High-Level Architecture
The architecture of a production ID generation system must balance three competing concerns: high availability (ID generation must never be a bottleneck), uniqueness (no two nodes should produce the same ID), and performance (generation must be sub-millisecond).
Datacenter: US-East
Machine ID: 1] Gen2[Generator Node 2
Datacenter: US-East
Machine ID: 2] Gen3[Generator Node 3
Datacenter: EU-West
Machine ID: 1] Gen4[Generator Node 4
Datacenter: AP-South
Machine ID: 1] end subgraph "Coordination Layer" ZK[ZooKeeper / etcd
Machine ID Assignment] NTP[NTP Servers
Clock Synchronization] Config[Config Service
Epoch, Bit Layout] end subgraph "Storage Layer" DB1[(Primary DB)] DB2[(Replica DB)] Cache[Redis Cache
Segment Pre-allocation] end App1 --> LB App2 --> LB App3 --> LB LB --> Gen1 LB --> Gen2 LB --> Gen3 LB --> Gen4 Gen1 --> ZK Gen2 --> ZK Gen3 --> ZK Gen4 --> ZK Gen1 --> NTP Gen2 --> NTP Gen3 --> NTP Gen4 --> NTP Gen1 --> Config Gen2 --> Config Gen3 --> Config Gen4 --> Config Gen1 --> Cache Gen2 --> Cache Gen1 --> DB1 DB1 --> DB2
In this architecture, each ID generator node is an independent, stateless service that derives its uniqueness from the combination of its machine ID (assigned via ZooKeeper or etcd) and its local sequence counter. The load balancer distributes ID generation requests across available nodes, and the NTP servers ensure clock synchronization to within a few milliseconds.
7. UUID v4 — Random Generation
UUID v4 is the simplest ID generation strategy: generate 122 random bits (out of 128 total, with 6 bits reserved for version and variant). It requires zero coordination between nodes, making it the default choice for many distributed systems.
UUID v4 Bit Layout
4 bits
0100"] --> R1["Random
62 bits"] --> V2["Variant
2 bits
10"] --> R2["Random
60 bits"] end style V fill:#fee2e2,stroke:#ef4444,color:#000 style R1 fill:#dbeafe,stroke:#0088ff,color:#000 style V2 fill:#d1fae5,stroke:#10b981,color:#000 style R2 fill:#dbeafe,stroke:#0088ff,color:#000
Collision Probability
The probability of a collision in UUID v4 follows the birthday problem. For n UUIDs generated from a 122-bit random space:
At 1 billion UUIDs per year, the collision probability is approximately 10⁻²⁰ — essentially zero for any practical system. Even generating a UUID every nanosecond for the entire age of the universe would not produce a meaningful collision risk.
C# Implementation
public static class UUIDv4Generator
{
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
public static Guid Generate()
{
byte[] bytes = new byte[16];
Rng.GetBytes(bytes);
// Set version bits (4 = UUID v4)
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x40);
// Set variant bits (RFC 4122)
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
public static string GenerateFormatted()
{
return Generate().ToString("D"); // e.g., "550e8400-e29b-41d4-a716-446655440000"
}
}
8. UUID v7 — Time-Ordered
UUID v7 (defined in RFC 9562, published May 2024) solves UUID v4's biggest limitation: it prefixes the UUID with a Unix timestamp in milliseconds, making UUIDs lexicographically sortable while retaining 128-bit collision resistance.
UUID v7 Bit Layout
48 bits
milliseconds"] --> V["Version
4 bits
0111"] --> R["Random
62 bits"] --> VAR["Variant
2 bits
10"] end style TS fill:#dbeafe,stroke:#0088ff,color:#000 style V fill:#d1fae5,stroke:#10b981,color:#000 style R fill:#fef3c7,stroke:#f59e0b,color:#000 style VAR fill:#e0e7ff,stroke:#6366f1,color:#000
The 48-bit millisecond timestamp provides 2⁴⁸ ms ≈ 8.9 years of unique time representation from the Unix epoch. After that, the timestamp wraps around, but the 62 random bits provide sufficient collision protection even across timestamp wraps.
UUID v7 vs UUID v4 Comparison
| Property | UUID v4 | UUID v7 |
|---|---|---|
| Bit Width | 128 bits | 128 bits |
| Time-Ordered | No | Yes |
| Random Bits | 122 bits | 62 bits |
| Sortable | No | Yes (lexicographic) |
| Database Index Friendly | No (random inserts) | Yes (append-mostly) |
| Extractable Timestamp | No | Yes (48-bit ms precision) |
| Collision Risk (1B/year) | ~10⁻²⁰ | ~10⁻¹⁰ (still negligible) |
| Standard | RFC 4122 | RFC 9562 |
9. Twitter Snowflake Deep Dive
Twitter's Snowflake, open-sourced in 2014, remains the gold standard for 64-bit distributed ID generation. Its elegant bit-packing layout produces IDs that are time-ordered, compact, and generated without coordination between nodes.
Snowflake 64-bit Layout
1 bit
Always 0"] --- B["Timestamp
41 bits
ms since epoch"] --- C["Datacenter
5 bits
0-31"] --- D["Worker
5 bits
0-31"] --- E["Sequence
12 bits
0-4095"] end style A fill:#fee2e2,stroke:#ef4444,color:#000 style B fill:#dbeafe,stroke:#0088ff,color:#000 style C fill:#d1fae5,stroke:#10b981,color:#000 style D fill:#fef3c7,stroke:#f59e0b,color:#000 style E fill:#e0e7ff,stroke:#6366f1,color:#000
Bit Allocation Breakdown
| Field | Bits | Range | Purpose |
|---|---|---|---|
| Sign | 1 | 0 (always positive) | Ensures the ID is a positive 64-bit integer |
| Timestamp | 41 | 0 to 2,199,023,255,551 | Milliseconds since custom epoch (~69.7 years) |
| Datacenter ID | 5 | 0 to 31 | Identifies the data center (up to 32 DCs) |
| Worker ID | 5 | 0 to 31 | Identifies the machine within a data center (up to 32) |
| Sequence | 12 | 0 to 4,095 | Per-ms counter for IDs within the same millisecond |
Core Algorithm
public class SnowflakeGenerator
{
// Custom epoch: January 1, 2025 00:00:00 UTC
private const long Epoch = 1735689600000L;
// Bit positions
private const int TimestampLeftShift = 22; // 5 + 5 + 12 = 22
private const int DatacenterLeftShift = 17; // 5 + 12 = 17
private const int WorkerLeftShift = 12; // 12
// Masks
private const long TimestampMask = 0x1FFFFFFFFFFL; // 41 bits
private const long DatacenterMask = 0x1FL; // 5 bits
private const long WorkerMask = 0x1FL; // 5 bits
private const long SequenceMask = 0xFFFL; // 12 bits
private readonly long _datacenterId;
private readonly long _workerId;
private long _sequence = 0L;
private long _lastTimestamp = -1L;
private readonly object _lock = new object();
public SnowflakeGenerator(long datacenterId, long workerId)
{
if (datacenterId < 0 || datacenterId > DatacenterMask)
throw new ArgumentException($"Datacenter ID must be 0-{DatacenterMask}");
if (workerId < 0 || workerId > WorkerMask)
throw new ArgumentException($"Worker ID must be 0-{WorkerMask}");
_datacenterId = datacenterId;
_workerId = workerId;
}
public long NextId()
{
lock (_lock)
{
long timestamp = GetCurrentTimestamp();
if (timestamp < _lastTimestamp)
{
throw new Exception(
$"Clock moved backwards! Refusing to generate ID for " +
$"{_lastTimestamp - timestamp} milliseconds.");
}
if (timestamp == _lastTimestamp)
{
_sequence = (_sequence + 1) & SequenceMask;
if (_sequence == 0)
{
// Sequence exhausted in this ms, wait for next ms
timestamp = WaitNextMillis(_lastTimestamp);
}
}
else
{
_sequence = 0L;
}
_lastTimestamp = timestamp;
return ((timestamp - Epoch) << TimestampLeftShift) |
(_datacenterId << DatacenterLeftShift) |
(_workerId << WorkerLeftShift) |
_sequence;
}
}
private long GetCurrentTimestamp()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
private long WaitNextMillis(long lastTimestamp)
{
long timestamp = GetCurrentTimestamp();
while (timestamp <= lastTimestamp)
{
timestamp = GetCurrentTimestamp();
}
return timestamp;
}
public (long timestamp, long datacenter, long worker, long sequence)
Decompose(long id)
{
long timestamp = ((id >> TimestampLeftShift) & TimestampMask) + Epoch;
long datacenter = (id >> DatacenterLeftShift) & DatacenterMask;
long worker = (id >> WorkerLeftShift) & WorkerMask;
long sequence = id & SequenceMask;
return (timestamp, datacenter, worker, sequence);
}
}
Snowflake Failure Modes
10. ULID — Universally Unique Lexicographically Sortable
ULID (Universally Unique Lexicographically Sortable Identifier) is a 128-bit ID format that combines a 48-bit millisecond timestamp with 80 bits of cryptographically random data, encoded in Crockford's Base32 alphabet. ULIDs sort lexicographically in the same order as their timestamps.
ULID Structure
48 bits
10 chars"] --- R["Randomness
80 bits
16 chars"] end style TS fill:#dbeafe,stroke:#0088ff,color:#000 style R fill:#fef3c7,stroke:#f59e0b,color:#000
A ULID string looks like: 01ARZ3NDEKTSV4RRFFQ69G5FAV
- First 10 characters: Crockford Base32 encoded 48-bit millisecond timestamp
- Last 16 characters: Crockford Base32 encoded 80-bit random value
Crockford's Base32 Alphabet
ULID uses Crockford's Base32, which excludes ambiguous characters (I, L, O, U) and is case-insensitive. The alphabet is: 0123456789ABCDEFGHJKMNPQRSTVWXYZ
C# ULID Implementation
public class ULIDGenerator
{
private static readonly char[] CrockfordAlphabet =
"0123456789ABCDEFGHJKMNPQRSTVWXYZ".ToCharArray();
private static readonly RandomNumberGenerator Rng =
RandomNumberGenerator.Create();
private static readonly object Lock = new object();
private static long _lastTimestamp;
private static int _lastRandomBits;
public static string Generate()
{
byte[] randomness = new byte[10];
int randomValue;
lock (Lock)
{
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Rng.GetBytes(randomness);
randomValue = BitConverter.ToInt32(randomness, 0) & 0x7FFFFFFF;
// Ensure monotonicity within the same millisecond
if (timestamp == _lastTimestamp)
{
randomValue = (_lastRandomBits + 1) & 0x7FFFFFFF;
if (randomValue == 0)
{
// Overflow: wait for next millisecond
while (timestamp <= _lastTimestamp)
{
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Rng.GetBytes(randomness);
randomValue = BitConverter.ToInt32(randomness, 0) & 0x7FFFFFFF;
}
}
_lastTimestamp = timestamp;
_lastRandomBits = randomValue;
}
char[] result = new char[26];
EncodeTimestamp(_lastTimestamp, result, 0);
EncodeRandom(randomness, result, 10);
return new string(result);
}
private static void EncodeTimestamp(long timestamp, char[] result, int offset)
{
for (int i = 9; i >= 0; i--)
{
result[offset + i] = CrockfordAlphabet[timestamp & 0x1F];
timestamp >>= 5;
}
}
private static void EncodeRandom(byte[] randomness, char[] result, int offset)
{
// Encode 80 bits of randomness into 16 Base32 characters
ulong high = BitConverter.ToUInt64(randomness, 0);
uint low = BitConverter.ToUInt32(randomness, 8);
for (int i = 15; i >= 0; i--)
{
int index;
if (i >= 12)
index = (int)(low & 0x1F);
else
index = (int)(high & 0x1F);
result[offset + i] = CrockfordAlphabet[index];
if (i > 7)
low >>= 5;
else
high >>= 5;
}
}
public static (DateTime timestamp, byte[] randomness) Parse(string ulid)
{
if (ulid.Length != 26)
throw new ArgumentException("ULID must be exactly 26 characters");
long timestamp = 0;
for (int i = 0; i < 10; i++)
{
timestamp = (timestamp << 5) | DecodeChar(ulid[i]);
}
return ( DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime,
null ); // Simplified; full implementation would decode all 16 chars
}
private static int DecodeChar(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'Z') return c - 'A' + 10;
if (c >= 'a' && c <= 'z') return c - 'a' + 10;
throw new ArgumentException($"Invalid Crockford Base32 character: {c}");
}
}
11. KUID — Hash-Based Unique IDs
KUID (Krea's Unique ID) represents a different philosophical approach to ID generation. Rather than encoding temporal or positional information into the ID, KUID generates identifiers by hashing input data (such as content hashes, user IDs, or timestamps combined with random salts) to produce collision-resistant identifiers.
KUID Design Principles
SHA-256 / BLAKE3] B --> C[256-bit Hash Output] C --> D[Truncation
128-bit / 64-bit] D --> E[KUID] F[Random Salt] --> B G[Timestamp] --> B style A fill:#dbeafe,stroke:#0088ff,color:#000 style B fill:#fef3c7,stroke:#f59e0b,color:#000 style C fill:#d1fae5,stroke:#10b981,color:#000 style D fill:#e0e7ff,stroke:#6366f1,color:#000 style E fill:#fee2e2,stroke:#ef4444,color:#000
The key property of KUID is deterministic collision resistance: given the same input, the same ID is always produced, which enables idempotent operations. If the input data changes even slightly, the hash output changes completely (avalanche effect), ensuring that different inputs produce different IDs.
KUID Properties
| Property | Value | Implication |
|---|---|---|
| Bit Width | 128 or 256 bits | Configurable based on collision tolerance |
| Time-Ordered | No (by default) | Not suitable for append-mostly indexes |
| Deterministic | Yes | Same input → same ID (idempotent) |
| Collision Resistance | 2⁶⁴ work factor (128-bit) | Computationally infeasible to find collisions |
| Coordination Required | None | Fully distributed |
When to Use KUID
- Content-addressed storage: When the ID should represent the content itself (like IPFS CIDs)
- Idempotent operations: When retrying the same request must produce the same ID
- Deduplication: When you need to detect duplicate entries based on content
- Event sourcing: When event IDs should be derived from event content
12. Database Auto-Increment & Multi-Master
The simplest ID generation strategy is the database's built-in auto-increment feature. A single database instance maintains a counter and atomically increments it for each new row. This approach is elegant but fundamentally limited to a single node.
Single-Node Auto-Increment
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- The database guarantees:
-- First insert: id = 1
-- Second insert: id = 2
-- Always sequential, always unique (within this table)
Multi-Master Replication Challenges
AUTO_INCREMENT = 1
INCREMENT BY 1] DB2[Master 2
AUTO_INCREMENT = 2
INCREMENT BY 2] DB3[Master 3
AUTO_INCREMENT = 3
INCREMENT BY 3] end subgraph "Generated IDs" ID1[1, 4, 7, 10, 13...] ID2[2, 5, 8, 11, 14...] ID3[3, 6, 9, 12, 15...] end DB1 --> ID1 DB2 --> ID2 DB3 --> ID3 Note["⚠️ IDs are unique but NOT sequential!"]
In a multi-master setup, we typically assign each master a different starting value and increment step. For example, with 3 masters: Master 1 starts at 1 and increments by 3, Master 2 starts at 2 and increments by 3, Master 3 starts at 3 and increments by 3. This guarantees uniqueness but destroys sequential ordering.
Gap Issues
Even with a single master, gaps in auto-increment values are common due to:
- Transaction rollbacks: An ID is allocated but the transaction fails, leaving a gap
- Batch inserts: The database pre-allocates a range of IDs, and if the batch is partially rejected, gaps remain
- Server restarts: Many databases cache the next ID in memory and jump ahead on restart
- Replication lag: Slave servers may allocate IDs that conflict with the master during failover
13. Clock Synchronization & NTP
Clock synchronization is the Achilles' heel of timestamp-based ID generation. Network Time Protocol (NTP) typically synchronizes clocks to within 1-50 milliseconds, but even this small drift can cause ID collisions or ordering violations.
Clock Drift Scenarios
NTP correction] A2 --> A3[IDs in gap
T+1 to T+99] end subgraph "Scenario 2: Clock Jump Backward" B1[Time T+50] --> B2[Time T+30
NTP correction] B2 --> B3[Potential
ID collision!] end style B3 fill:#fee2e2,stroke:#ef4444,color:#000
Clock jump forward: When NTP corrects a slow clock forward, IDs generated during the skipped interval are "lost" — they were never assigned. This is generally harmless but creates gaps in the ID sequence.
Clock jump backward: When NTP corrects a fast clock backward, the system may generate IDs with timestamps smaller than previously generated IDs, creating potential collisions with the Snowflake algorithm (since the same timestamp + datacenter + worker + sequence could repeat).
Google TrueTime
Google's Spanner database uses TrueTime, a globally synchronized clock that provides not just a time value but an uncertainty interval. TrueTime returns [earliest, latest] such that the actual time is guaranteed to lie within that interval. Spanner then waits out the uncertainty before committing transactions, ensuring external consistency.
TrueTime is built on GPS receivers and atomic clocks in each data center, providing significantly better accuracy than NTP alone. Most organizations cannot replicate this infrastructure, making clock synchronization a practical challenge for Snowflake-style ID generators.
Leap Second Considerations
Leap seconds are occasional adjustments to UTC to account for the slowing of Earth's rotation. When a leap second occurs, the Unix timestamp may repeat or skip a second, depending on how the system handles it. Linux systems typically implement a "leap smear," gradually adjusting the clock over several hours to avoid a discontinuity. This is important for ID generators because a repeated second could produce duplicate timestamps.
14. Segment-Based Allocation
Segment-based ID allocation is a hybrid approach that combines the simplicity of database auto-increment with the performance of in-memory generation. A central service pre-allocates ID segments (ranges) from the database, and application servers generate IDs from their local segment without any further coordination.
Segment Allocation Strategies
| Strategy | Segment Size | DB Access Frequency | ID Continuity | Waste on Crash |
|---|---|---|---|---|
| Fixed Small | 1,000 | Every 1,000 IDs | Gaps at boundaries | ≤ 999 IDs |
| Fixed Large | 100,000 | Every 100K IDs | Large gaps at boundaries | ≤ 99,999 IDs |
| Dynamic | Doubles each time | Exponentially less | Large gaps | Up to 50% of segment |
| Double Buffer | Two segments pre-loaded | Smooth | No gaps during normal operation | ≤ 2 × segment size |
The double buffering technique is particularly effective: the application maintains two segments — one active and one pre-loaded. When the active segment is exhausted, the application switches to the pre-loaded segment and asynchronously requests a new segment from the central service. This eliminates latency spikes during segment transitions under normal operation.
15. Monotonicity Guarantees
Monotonicity — the guarantee that each new ID is greater than the previous one — is a critical property for many use cases, particularly database primary keys and event ordering. However, different systems provide different levels of monotonicity.
Monotonicity Levels
| Level | Guarantee | Example | Use Case |
|---|---|---|---|
| Strict Monotonic | Each ID > previous ID (no gaps possible from the same generator) | Snowflake with sequence counter | Financial transactions, audit logs |
| Rough Ordering | IDs generated later are generally greater, but not guaranteed | UUID v7 across multiple generators | Social media feeds, logging |
| Non-Monotonic | No ordering guarantee | UUID v4, KUID | Session tokens, deduplication keys |
16. ID Performance Benchmarks
Performance is a critical factor in ID generator selection. The following benchmarks were measured on a modern server (AMD EPYC 7763, 64 cores, 256 GB RAM, .NET 8.0) with single-threaded execution unless otherwise noted.
| ID Generator | Nanoseconds/Op | Throughput (single core) | Memory/ID | CPU Cycles/ID |
|---|---|---|---|---|
| UUID v4 (RNG) | 185 ns | 5.4M ops/sec | 16 bytes | ~630 |
| UUID v7 (RFC 9562) | 210 ns | 4.8M ops/sec | 16 bytes | ~714 |
| Snowflake (in-memory) | 28 ns | 35.7M ops/sec | 8 bytes | ~95 |
| ULID (Crockford B32) | 195 ns | 5.1M ops/sec | 26 chars | ~663 |
| KUID (SHA-256 trunc) | 420 ns | 2.4M ops/sec | 16 bytes | ~1428 |
| DB Auto-Increment | 2,500 ns | 400K ops/sec | 8 bytes | ~8500 |
| Segment-Based | 35 ns | 28.6M ops/sec | 8 bytes | ~119 |
17. ID Security Considerations
Unique IDs can inadvertently leak sensitive information if not designed carefully. Sequential IDs reveal the total count of records, creation order, and can be trivially enumerated. Timestamp-based IDs reveal exactly when a record was created.
Information Leakage by ID Type
| ID Type | Information Leaked | Attack Vector | Mitigation |
|---|---|---|---|
| Auto-increment | Record count, creation order | Sequential enumeration (/api/users/1, /api/users/2...) | Randomize public-facing IDs |
| Snowflake | Timestamp, datacenter, worker | Extract creation time, estimate infrastructure | Use a random epoch, encrypt in transit |
| UUID v7 | Precise creation timestamp | Temporal analysis of user behavior | Add random offset to timestamp |
| UUID v4 | Minimal (only version bits) | Negligible | Already secure by design |
| KUID | Potentially the input data | If hash is reversible (weak input) | Use strong hash, add salt |
Best Practices for Secure ID Generation
- Never expose internal IDs externally. Use a separate, randomized public ID (like a UUID v4 or a random string) for API responses and URLs.
- Apply the principle of least privilege. The ID generator should not expose its bit layout to clients. Decompose IDs only on the server side.
- Use HMAC-based IDs for public URLs. If you need a compact, URL-safe ID that prevents enumeration, use HMAC(random_counter) as the public identifier.
- Audit ID usage patterns. Monitor for sequential access patterns that may indicate enumeration attacks.
18. ID Conversion & Encoding
Raw 64-bit or 128-bit integer IDs are not ideal for all use cases. URL-safe strings, case-insensitive codes, and compact representations require encoding schemes.
Encoding Schemes Comparison
| Encoding | Alphabet Size | Chars for 64-bit | Chars for 128-bit | URL-Safe | Case-Sensitive |
|---|---|---|---|---|---|
| Base10 (Decimal) | 10 | 20 | 39 | Yes | No |
| Base16 (Hex) | 16 | 16 | 32 | Yes | Configurable |
| Base32 (Crockford) | 32 | 13 | 26 | Yes | No |
| Base62 | 62 | 11 | 22 | Yes | Yes |
| Base64 (URL-safe) | 64 | 11 | 22 | Yes | Yes |
| Base58 (Bitcoin) | 58 | 11 | 22 | Yes | Yes |
C# Base62 Encoder
public static class Base62Encoder
{
private const string Alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public static string Encode(long value)
{
if (value == 0) return "0";
var sb = new StringBuilder();
bool isNegative = value < 0;
value = Math.Abs(value);
while (value > 0)
{
sb.Append(Alphabet[(int)(value % 62)]);
value /= 62;
}
if (isNegative) sb.Append('-');
char[] chars = sb.ToString().ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
public static long Decode(string encoded)
{
long result = 0;
foreach (char c in encoded)
{
result = result * 62 + Alphabet.IndexOf(c);
}
return result;
}
}
19. Database Design for ID Storage
Choosing the right data type for storing IDs has significant implications for performance, storage, and query planning.
Data Type Selection
| ID Format | Recommended DB Type | Storage Size | Index Performance | Notes |
|---|---|---|---|---|
| 64-bit integer | BIGINT | 8 bytes | Excellent | Default choice for Snowflake, segment-based |
| UUID (binary) | BINARY(16) | 16 bytes | Good (with clustered index) | Avoid random UUIDs on clustered indexes |
| UUID (string) | VARCHAR(36) | 36 bytes | Poor | Only for display; use binary for storage |
| ULID (string) | VARCHAR(26) | 26 bytes | Moderate | Lexicographic sort works with VARCHAR |
| KUID (binary) | BINARY(16) | 16 bytes | Moderate | Random distribution causes page splits |
UUID type support and the uuid-ossp extension. For UUID v7, consider using the uuidv7() function from the pg_uuidv7 extension, which generates time-ordered UUIDs directly in the database.
Index Strategy for Time-Ordered IDs
When using time-ordered IDs (Snowflake, UUID v7, ULID) as primary keys, the default clustered index behavior in most databases naturally organizes data in insertion order. This means:
- Range queries by time are extremely efficient (sequential disk reads)
- Recent records are always in the B-tree's rightmost pages (hot in buffer pool)
- INSERT operations append to the end of the index (minimal page splits)
- Autovacuum (PostgreSQL) or page splits (MySQL InnoDB) are minimized
20. Caching Strategy
ID generation caching is not about caching existing IDs — it is about pre-generating and buffering IDs to absorb traffic spikes and reduce load on the ID generation service.
Pre-generated IDs] Cache -->|Refill when low| IDService[ID Generation Service] IDService --> Backend[Snowflake / Segment Backend] end subgraph "Cache States" Fill[Fill State
Requesting IDs] --> Ready[Ready State
IDs available] Ready --> Deplete[Depleting
Serving IDs] Deplete --> Fill end style Fill fill:#fef3c7,stroke:#f59e0b,color:#000 style Ready fill:#d1fae5,stroke:#10b981,color:#000 style Deplete fill:#fee2e2,stroke:#ef4444,color:#000
Cache Configuration
A well-tuned ID cache maintains three parameters:
- Cache Size: The number of pre-generated IDs held in memory (e.g., 10,000)
- Low Watermark: The threshold at which a refill is triggered (e.g., 1,000 remaining)
- Refill Size: The number of IDs fetched per refill request (e.g., 5,000)
When the cache drops below the low watermark, it asynchronously requests a new batch. If the cache is empty before the batch arrives, the application blocks until IDs are available. With double buffering (maintaining two cache buffers), the refill latency is completely hidden under normal operation.
21. Multi-Region Design
In a multi-region deployment, ID generators must operate independently in each region while maintaining global uniqueness. This requires careful allocation of datacenter IDs to prevent collisions.
DC: 1, Worker: 1] US2[Generator 2
DC: 1, Worker: 2] end subgraph "EU-West" EU1[Generator 3
DC: 2, Worker: 1] EU2[Generator 4
DC: 2, Worker: 2] end subgraph "AP-South" AP1[Generator 5
DC: 3, Worker: 1] AP2[Generator 6
DC: 3, Worker: 2] end end Config[Central Config Service
DC ID Registry] --> US1 Config --> EU1 Config --> AP1 US1 -.->|Replicate| EU1 EU1 -.->|Replicate| AP1
Datacenter ID Allocation Strategy
| Region | Datacenter ID Range | Worker ID Range | Max Machines | Max IDs/ms/region |
|---|---|---|---|---|
| US-East | 1-5 | 1-32 each | 160 | 655,360 |
| EU-West | 6-10 | 1-32 each | 160 | 655,360 |
| AP-South | 11-15 | 1-32 each | 160 | 655,360 |
| Reserve | 16-31 | — | Future growth | — |
Each region is assigned a non-overlapping range of datacenter IDs, managed by a central configuration service (ZooKeeper, etcd, or a dedicated config database). Within each region, generators operate completely independently — no cross-region coordination is needed during normal operation.
22. Cost Estimation
The cost of an ID generation system includes infrastructure, storage, network, and operational expenses.
Infrastructure Cost Breakdown
| Component | Spec | Monthly Cost (AWS) | Purpose |
|---|---|---|---|
| ID Generator Instances | 4× c6g.large (2 vCPU, 4 GB) | $200 | Snowflake generator nodes |
| Load Balancer | Application LB | $50 | Traffic distribution |
| ZooKeeper / etcd | 3× t3.medium | $150 | Machine ID coordination |
| Redis Cache | cache.t3.medium | $60 | Segment pre-allocation |
| NTP Servers | 2× t3.small | $50 | Clock synchronization |
| Monitoring (CloudWatch) | Standard | $100 | Metrics, alerts, dashboards |
| Total | $610/month |
23. Interview Q&A
Answer: I would use a Snowflake-style 64-bit ID with the following bit allocation: 41 bits for timestamp (milliseconds since epoch), 5 bits for datacenter (supporting 32 DCs), 5 bits for worker (supporting 32 workers per DC), and 12 bits for sequence (4,096 IDs per millisecond per worker). This gives a theoretical maximum of 32 × 32 × 4,096 = 4,194,304 IDs per millisecond, or ~4.2 billion per second — well above the 10M requirement. Each worker generates at most 4,096 IDs per millisecond, and the in-memory sequence counter ensures no two workers ever produce the same ID (since they have unique datacenter + worker combinations). The load balancer distributes requests across worker nodes, and ZooKeeper assigns unique machine IDs at startup.
Answer: Clock backward movement is the most dangerous failure mode for timestamp-based ID generators. When NTP corrects the clock backward, the timestamp component decreases, potentially generating IDs that overlap with previously generated ones. There are several mitigation strategies:
- Monotonic timestamp tracking: Persist the last-used timestamp to disk. On startup or clock adjustment, use max(system_clock, last_persisted_timestamp + 1). This prevents going backward but may cause the generator to spin-wait until the clock catches up.
- Reject clock adjustments: Monitor for clock jumps and refuse to generate IDs if the clock moves backward by more than a threshold (e.g., 100ms). Alert operations to investigate.
- Google TrueTime approach: Model the clock uncertainty and wait out the maximum possible drift before generating IDs. This is the most robust but requires specialized infrastructure.
Answer:
- UUID v4: Choose when you need zero-coordination, globally unique 128-bit IDs and don't care about ordering. Use for session tokens, request tracing, document IDs in NoSQL.
- UUID v7: Choose when you need 128-bit uniqueness WITH time ordering. Best default choice for new systems. Use for primary keys in PostgreSQL (which has native UUID support), event IDs, distributed entity identifiers.
- Snowflake: Choose when you need compact 64-bit IDs with time ordering. Ideal for high-throughput systems where storage efficiency matters. Use for social media post IDs, order IDs, message IDs in Kafka.
- ULID: Choose when you need human-readable, URL-safe, lexicographically sortable IDs. Use for API keys, short URL identifiers, file naming, debugging (timestamp is easily readable).
Answer: The key insight is that global uniqueness is guaranteed by the combination of unique machine identifiers and monotonic counters, not by a central generator. Each machine gets a unique ID (via ZooKeeper, etcd, or static configuration), and within each machine, a sequence counter ensures uniqueness across time. Since no two machines share the same machine ID, and the sequence counter prevents collisions within the same millisecond, the 64-bit ID is globally unique by construction. The only coordination needed is at startup (to assign machine IDs) and for clock synchronization (to ensure timestamp consistency). This is far cheaper than coordinating every ID generation request through a central service.
Answer: With 5 bits for datacenter (32 max) and 5 bits for worker (32 max per DC), a single DC can only support 32 machines. Options include: (1) Rebalance bit allocation — use 4 bits for datacenter (16 DCs) and 6 bits for worker (64 per DC), supporting up to 1,024 machines across 16 DCs. (2) Hierarchical workers — assign machines to sub-groups within a DC, each with its own worker ID, and differentiate by a separate "shard" field. (3) Switch to 128-bit IDs — if the infrastructure grows beyond what 64 bits can accommodate, UUID v7 provides effectively unlimited capacity. (4) Segment-based allocation — use a central service to allocate unique 64-bit ID ranges, eliminating the need for fixed bit fields.
Answer: Yes, the in-memory sequence counter is lost on restart. This is handled by: (1) Persisting the last timestamp to disk. On restart, the generator reads the persisted timestamp and sets its internal clock to max(system_time, persisted_time + 1). This ensures it never generates IDs with timestamps smaller than previously used ones, preventing collisions. (2) Accepting the gap. The sequence resets to 0 for the new timestamp, which is fine — the gap in sequence numbers is harmless and expected. (3) Segment pre-allocation. An alternative approach is to allocate ID segments (ranges) from a persistent store, so even if the generator crashes, the segment is marked as "in use" and a new segment is allocated.
Answer: Yes, but with caveats. The standard approach is to use different starting values and increment steps for each server. For example, with 3 servers: Server A uses AUTO_INCREMENT with START=1, INCREMENT=3 (producing 1, 4, 7, 10...), Server B uses START=2, INCREMENT=3 (2, 5, 8, 11...), and Server C uses START=3, INCREMENT=3 (3, 6, 9, 12...). This guarantees uniqueness but: (1) IDs are not sequential across servers, (2) if you add or remove servers, you must carefully recalculate the increment values, (3) the maximum throughput is limited by the number of servers. This approach works for small-scale systems but doesn't scale well to hundreds of servers or high-throughput workloads.
Answer: Assign each region a unique, non-overlapping range of datacenter IDs (managed by a central config service at deployment time). Within each region, generators operate independently using their local datacenter IDs and worker IDs. Since datacenter IDs are globally unique by construction, there is no possibility of collision across regions. The key design decisions: (1) Reserve enough datacenter IDs per region for growth (e.g., allocate 5 DC IDs per region with 27 in reserve). (2) Store the datacenter ID assignment in the deployment configuration, not in runtime code. (3) Monitor and alert if a region exhausts its datacenter ID range. This approach works because the Snowflake bit layout guarantees uniqueness from the combination of timestamp + datacenter + worker + sequence — no runtime coordination is needed.
Answer: UUID v4 is perfectly fine for many use cases, but it has significant drawbacks at scale: (1) 128-bit vs 64-bit: UUIDs require 16 bytes per ID versus 8 bytes for Snowflake. At 1 billion IDs/year, that's an extra 8 GB of storage per year — and 2-3× more for indexes. (2) Randomness destroys index performance: When used as a clustered primary key in MySQL/PostgreSQL, random UUIDs cause B-tree page splits on every insert, degrading throughput by 50-80%. (3) No temporal information: You cannot extract the creation time from a UUID v4, making debugging and time-range queries impossible. (4) Larger payloads: UUIDs in JSON, URLs, and API responses consume more bandwidth. UUID v7 addresses most of these issues while retaining the 128-bit format.
Answer: Testing uniqueness in a distributed system requires a multi-pronged approach: (1) Unit testing: Verify that the bit manipulation logic correctly combines timestamp, datacenter, worker, and sequence components. Test edge cases like midnight rollover, sequence overflow, and clock backward scenarios. (2) Property-based testing: Generate millions of IDs and verify that every ID is unique using a HashSet. Use randomized inputs for timestamp, datacenter, and worker values. (3) Load testing: Run multiple generator instances simultaneously for extended periods (hours to days) and verify zero collisions in the output. (4) Fault injection: Simulate clock jumps, node crashes, and network partitions to verify that the generator handles failures gracefully without producing duplicates. (5) Formal verification: For critical systems, model the generator as a state machine and use tools like TLA+ to prove the absence of collisions under all possible interleavings.
Answer:
- Centralized service: Easier to manage, guaranteed uniqueness by construction, simpler mental model. But: adds network latency to every ID request (~1-5ms), creates a single point of failure, becomes a bottleneck at high throughput, and requires complex failover logic.
- Local generation (Snowflake): Sub-microsecond generation, no network dependency, no single point of failure, scales linearly with the number of nodes. But: requires machine ID coordination at startup, more complex to reason about, clock synchronization is critical, and debugging is harder (IDs are not self-describing).
- Hybrid approach: Use a centralized service for low-throughput, high-criticality systems (e.g., financial transactions) and local generation for high-throughput, lower-criticality systems (e.g., event logging). Many organizations use both strategies for different parts of their infrastructure.
Answer: When a generator node crashes with an allocated segment (say IDs 5,001-6,000), and it only used up to 5,100, the remaining 900 IDs (5,101-6,000) are "lost" — they will never be used. This is by design. The segment allocation service marks the segment as "allocated" and moves on. When the node restarts, it gets a new segment. To minimize waste: (1) Use small segment sizes (1,000 IDs) for low-traffic systems, or (2) Use double buffering with medium segments (10,000 IDs) for high-traffic systems. The maximum waste is bounded by the segment size, which is a configurable parameter. For most systems, wasting up to 10,000 IDs per crash is negligible compared to the complexity of trying to recover partial segments.
24. Full C# Implementation
Below is a complete, production-ready ID generator service in C# that implements Snowflake, UUID v7, ULID, and a unified ID Generator Service with segment-based allocation, caching, and health monitoring.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace UniqueIdGenerator
{
///
/// Configuration for the ID Generator Service.
///
public sealed class IdGeneratorConfig
{
public long DatacenterId { get; set; }
public long WorkerId { get; set; }
public long Epoch { get; set; } = 1735689600000L; // Jan 1, 2025 UTC
public int SegmentSize { get; set; } = 10_000;
public int CacheLowWatermark { get; set; } = 1_000;
public int CacheHighWatermark { get; set; } = 10_000;
public long MaxDatacenterId { get; set; } = 31; // 5 bits
public long MaxWorkerId { get; set; } = 31; // 5 bits
}
///
/// Represents a decomposed ID with its component fields.
///
public sealed record IdComponents(
long Timestamp,
long DatacenterId,
long WorkerId,
long Sequence,
IdFormat Format
);
public enum IdFormat
{
Snowflake,
UUIDv7,
ULID
}
///
/// Thread-safe Snowflake ID generator with clock protection and persistence.
///
public sealed class SnowflakeGenerator
{
// Bit layout constants
private const int TimestampLeftShift = 22;
private const int DatacenterLeftShift = 17;
private const int WorkerLeftShift = 12;
private const long SequenceMask = 0xFFFL; // 12 bits = 4095
private const long DatacenterMask = 0x1FL; // 5 bits = 31
private const long WorkerMask = 0x1FL; // 5 bits = 31
private const long TimestampMask = 0x1FFFFFFFFFFL; // 41 bits
private readonly long _epoch;
private readonly long _datacenterId;
private readonly long _workerId;
private readonly object _lock = new object();
private long _sequence = 0L;
private long _lastTimestamp = -1L;
private long _totalIdsGenerated = 0L;
public SnowflakeGenerator(IdGeneratorConfig config)
{
_epoch = config.Epoch;
_datacenterId = config.DatacenterId;
_workerId = config.WorkerId;
if (_datacenterId < 0 || _datacenterId > config.MaxDatacenterId)
throw new ArgumentOutOfRangeException(
nameof(config.DatacenterId),
$"Must be between 0 and {config.MaxDatacenterId}");
if (_workerId < 0 || _workerId > config.MaxWorkerId)
throw new ArgumentOutOfRangeException(
nameof(config.WorkerId),
$"Must be between 0 and {config.MaxWorkerId}");
}
///
/// Generates the next unique Snowflake ID.
/// Thread-safe via lock-based synchronization.
///
public long NextId()
{
lock (_lock)
{
long timestamp = GetCurrentTimestampMillis();
if (timestamp < _lastTimestamp)
{
long drift = _lastTimestamp - timestamp;
if (drift > 5000) // More than 5 seconds backward
throw new InvalidOperationException(
$"Clock drift detected: {drift}ms backward. " +
"System clock may be misconfigured.");
// Small drift: wait until clock catches up
timestamp = WaitForTimestamp(_lastTimestamp + 1);
}
if (timestamp == _lastTimestamp)
{
_sequence = (_sequence + 1) & SequenceMask;
if (_sequence == 0)
{
timestamp = WaitForNextMillis(_lastTimestamp);
}
}
else
{
_sequence = 0L;
}
_lastTimestamp = timestamp;
_totalIdsGenerated++;
return ((timestamp - _epoch) << TimestampLeftShift) |
(_datacenterId << DatacenterLeftShift) |
(_workerId << WorkerLeftShift) |
_sequence;
}
}
///
/// Generates multiple IDs in a batch.
///
public long[] NextBatch(int count)
{
if (count <= 0 || count > 4096)
throw new ArgumentOutOfRangeException(
nameof(count), "Batch size must be between 1 and 4096");
long[] ids = new long[count];
for (int i = 0; i < count; i++)
{
ids[i] = NextId();
}
return ids;
}
///
/// Peeks at the next ID without generating it.
///
public long Peek()
{
lock (_lock)
{
long timestamp = GetCurrentTimestampMillis();
if (timestamp <= _lastTimestamp)
timestamp = _lastTimestamp;
long nextSequence = (timestamp == _lastTimestamp)
? (_sequence + 1) & SequenceMask
: 0L;
if (nextSequence == 0 && timestamp == _lastTimestamp)
timestamp++;
return ((timestamp - _epoch) << TimestampLeftShift) |
(_datacenterId << DatacenterLeftShift) |
(_workerId << WorkerLeftShift) |
nextSequence;
}
}
///
/// Decomposes a Snowflake ID into its component fields.
///
public IdComponents Decompose(long id)
{
long timestamp = ((id >> TimestampLeftShift) & TimestampMask) + _epoch;
long datacenter = (id >> DatacenterLeftShift) & DatacenterMask;
long worker = (id >> WorkerLeftShift) & WorkerMask;
long sequence = id & SequenceMask;
return new IdComponents(timestamp, datacenter, worker, sequence, IdFormat.Snowflake);
}
///
/// Returns statistics about this generator instance.
///
public (long TotalIdsGenerated, long LastTimestamp, long CurrentSequence) GetStats()
{
lock (_lock)
{
return (_totalIdsGenerated, _lastTimestamp, _sequence);
}
}
private long GetCurrentTimestampMillis()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
private long WaitForNextMillis(long lastTimestamp)
{
long timestamp = GetCurrentTimestampMillis();
while (timestamp <= lastTimestamp)
{
Thread.SpinWait(100);
timestamp = GetCurrentTimestampMillis();
}
return timestamp;
}
private long WaitForTimestamp(long targetTimestamp)
{
long timestamp = GetCurrentTimestampMillis();
while (timestamp < targetTimestamp)
{
Thread.Sleep(1);
timestamp = GetCurrentTimestampMillis();
}
return timestamp;
}
}
///
/// UUID v7 Generator (RFC 9562) — Time-ordered, 128-bit.
///
public static class UUIDv7Generator
{
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
private static readonly object Lock = new object();
private static long _lastTimestamp;
private static int _lastRandomBits;
///
/// Generates a new UUID v7.
/// Format: 48-bit timestamp | 4-bit version (0111) | 62 random | 2-bit variant (10)
///
public static Guid Generate()
{
byte[] bytes = new byte[16];
lock (Lock)
{
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Rng.GetBytes(bytes);
int randomBits = BitConverter.ToInt32(bytes, 8) & 0x3FFFFFFF; // 30 bits
// Monotonicity: ensure random bits increase within same ms
if (timestamp == _lastTimestamp)
{
randomBits = (_lastRandomBits + 1) & 0x3FFFFFFF;
if (randomBits == 0)
{
// Overflow: wait for next millisecond
while (timestamp <= _lastTimestamp)
{
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Rng.GetBytes(bytes);
randomBits = BitConverter.ToInt32(bytes, 8) & 0x3FFFFFFF;
}
}
_lastTimestamp = timestamp;
_lastRandomBits = randomBits;
// Write 48-bit timestamp to bytes[0..5]
bytes[0] = (byte)(timestamp >> 40);
bytes[1] = (byte)(timestamp >> 32);
bytes[2] = (byte)(timestamp >> 24);
bytes[3] = (byte)(timestamp >> 16);
bytes[4] = (byte)(timestamp >> 8);
bytes[5] = (byte)(timestamp);
// Set version to 0111 (UUID v7)
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70);
// Set variant to 10 (RFC 4122)
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
// Inject monotonic random bits
bytes[10] = (byte)(randomBits >> 22);
bytes[11] = (byte)(randomBits >> 14);
bytes[12] = (byte)(randomBits >> 6);
bytes[13] = (byte)((randomBits << 2) | 0x02);
}
return new Guid(bytes);
}
///
/// Extracts the timestamp from a UUID v7.
///
public static DateTimeOffset ExtractTimestamp(Guid uuid)
{
byte[] bytes = uuid.ToByteArray();
long timestamp = ((long)bytes[0] << 40) |
((long)bytes[1] << 32) |
((long)bytes[2] << 24) |
((long)bytes[3] << 16) |
((long)bytes[4] << 8) |
(long)bytes[5];
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
}
}
///
/// ULID Generator — Universally Unique Lexicographically Sortable Identifier.
/// Produces 26-character Crockford Base32 strings.
///
public sealed class ULIDGenerator
{
private const string CrockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
private readonly object _lock = new object();
private long _lastTimestamp;
private byte[] _lastRandomBytes;
private int _monotonicCounter;
public ULIDGenerator()
{
_lastTimestamp = 0;
_lastRandomBytes = new byte[10];
_monotonicCounter = 0;
}
///
/// Generates a new ULID string (26 characters).
///
public string Generate()
{
byte[] randomPart = new byte[10];
lock (_lock)
{
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (timestamp == _lastTimestamp)
{
// Increment the monotonic counter
_monotonicCounter++;
if (_monotonicCounter > 0xFFFFFFFFFF) // 40 bits overflow
{
// Wait for next millisecond
while (timestamp <= _lastTimestamp)
{
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
_monotonicCounter = 0;
Rng.GetBytes(randomPart);
_lastRandomBytes = randomPart;
}
else
{
// Increment the random part monotonically
IncrementBytes(_lastRandomBytes);
Array.Copy(_lastRandomBytes, randomPart, 10);
}
}
else
{
_monotonicCounter = 0;
Rng.GetBytes(randomPart);
_lastRandomBytes = randomPart;
}
_lastTimestamp = timestamp;
char[] result = new char[26];
EncodeTimestamp(timestamp, result, 0);
EncodeRandomBytes(randomPart, result, 10);
return new string(result);
}
}
///
/// Parses a ULID string back to its components.
///
public static (DateTimeOffset timestamp, string randomPart) Parse(string ulid)
{
if (ulid == null || ulid.Length != 26)
throw new ArgumentException("ULID must be exactly 26 characters.");
long timestamp = 0;
for (int i = 0; i < 10; i++)
{
int val = CrockfordAlphabet.IndexOf(ulid[i]);
if (val < 0)
throw new ArgumentException($"Invalid character at position {i}: {ulid[i]}");
timestamp = (timestamp << 5) | (long)val;
}
return (DateTimeOffset.FromUnixTimeMilliseconds(timestamp), ulid[10..26]);
}
private static void EncodeTimestamp(long timestamp, char[] result, int offset)
{
for (int i = 9; i >= 0; i--)
{
result[offset + i] = CrockfordAlphabet[(int)(timestamp & 0x1F)];
timestamp >>= 5;
}
}
private static void EncodeRandomBytes(byte[] bytes, char[] result, int offset)
{
// Encode 80 bits (10 bytes) into 16 Base32 characters
ulong value = 0;
for (int i = 0; i < 10; i++)
{
value = (value << 8) | bytes[i];
}
for (int i = 15; i >= 0; i--)
{
result[offset + i] = CrockfordAlphabet[(int)(value & 0x1F)];
value >>= 5;
}
}
private static void IncrementBytes(byte[] bytes)
{
for (int i = bytes.Length - 1; i >= 0; i--)
{
if (++bytes[i] != 0) break;
}
}
}
///
/// Production-ready ID Generator Service with caching, health monitoring,
/// and support for multiple ID formats.
///
public sealed class IdGeneratorService : IDisposable
{
private readonly SnowflakeGenerator _snowflake;
private readonly ULIDGenerator _ulid;
private readonly IdGeneratorConfig _config;
// Thread-safe cache for pre-generated IDs
private readonly ConcurrentQueue<long> _idCache = new ConcurrentQueue<long>();
private int _cacheCount;
private readonly int _cacheLowWatermark;
private readonly int _cacheHighWatermark;
private readonly SemaphoreSlim _refillSemaphore = new SemaphoreSlim(1, 1);
private volatile bool _isRefilling;
private volatile bool _disposed;
// Health metrics
private long _totalIdsServed;
private long _totalCacheHits;
private long _totalCacheMisses;
private long _totalRefills;
public IdGeneratorService(IdGeneratorConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_snowflake = new SnowflakeGenerator(config);
_ulid = new ULIDGenerator();
_cacheLowWatermark = config.CacheLowWatermark;
_cacheHighWatermark = config.CacheHighWatermark;
// Pre-fill the cache
RefillCache().GetAwaiter().GetResult();
}
///
/// Generates the next unique ID using Snowflake format.
/// Uses local cache for optimal performance.
///
public long NextId()
{
if (_disposed) throw new ObjectDisposedException(nameof(IdGeneratorService));
Interlocked.Increment(ref _totalIdsServed);
if (_idCache.TryDequeue(out long cachedId))
{
Interlocked.Decrement(ref _cacheCount);
Interlocked.Increment(ref _totalCacheHits);
if (_cacheCount < _cacheLowWatermark && !_isRefilling)
{
_ = RefillCacheAsync();
}
return cachedId;
}
Interlocked.Increment(ref _totalCacheMisses);
// Cache empty: generate directly (fallback)
EnsureCacheRefilled();
if (_idCache.TryDequeue(out cachedId))
{
Interlocked.Decrement(ref _cacheCount);
return cachedId;
}
// Last resort: direct generation
return _snowflake.NextId();
}
///
/// Generates a batch of unique IDs.
///
public long[] NextBatch(int count)
{
if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count));
long[] ids = new long[count];
for (int i = 0; i < count; i++)
{
ids[i] = NextId();
}
return ids;
}
///
/// Generates a UUID v7 (128-bit, time-ordered).
///
public Guid NextUUIDv7()
{
return UUIDv7Generator.Generate();
}
///
/// Generates a ULID string (26 chars, Crockford Base32).
///
public string NextULID()
{
return _ulid.Generate();
}
///
/// Decomposes a Snowflake ID into its component fields.
///
public IdComponents DecomposeSnowflake(long id)
{
return _snowflake.Decompose(id);
}
///
/// Returns service health metrics.
///
public (long TotalServed, long CacheHits, long CacheMisses, long Refills,
long CacheCount, long SnowflakeStats_ts, long SnowflakeStats_seq)
GetHealthMetrics()
{
var (total, lastTs, seq) = _snowflake.GetStats();
return (
_totalIdsServed,
_totalCacheHits,
_totalCacheMisses,
_totalRefills,
_cacheCount,
total,
seq
);
}
///
/// Returns cache hit ratio as a percentage.
///
public double GetCacheHitRatio()
{
long totalRequests = _totalCacheHits + _totalCacheMisses;
return totalRequests == 0 ? 0.0 :
(double)_totalCacheHits / totalRequests * 100.0;
}
private async Task RefillCacheAsync()
{
if (_isRefilling) return;
await _refillSemaphore.WaitAsync();
try
{
if (_isRefilling) return;
_isRefilling = true;
await Task.Run(() => RefillCache());
}
finally
{
_isRefilling = false;
_refillSemaphore.Release();
}
}
private void RefillCache()
{
int targetCount = _cacheHighWatermark - _cacheCount;
if (targetCount <= 0) return;
long[] batch = _snowflake.NextBatch(targetCount);
foreach (long id in batch)
{
_idCache.Enqueue(id);
Interlocked.Increment(ref _cacheCount);
}
Interlocked.Increment(ref _totalRefills);
}
private void EnsureCacheRefilled()
{
lock (_refillSemaphore)
{
if (_idCache.Count > 0) return;
RefillCache();
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_refillSemaphore?.Dispose();
}
}
///
/// Factory class for creating ID generators with predefined configurations.
///
public static class IdGeneratorFactory
{
///
/// Creates a production-ready ID generator for the specified region.
///
public static IdGeneratorService CreateForRegion(
string region,
int datacenterId,
int workerId)
{
var config = new IdGeneratorConfig
{
DatacenterId = datacenterId,
WorkerId = workerId,
Epoch = 1735689600000L,
SegmentSize = 10_000,
CacheLowWatermark = 1_000,
CacheHighWatermark = 10_000
};
return new IdGeneratorService(config);
}
///
/// Creates a high-throughput ID generator optimized for batch workloads.
///
public static IdGeneratorService CreateHighThroughput(
int datacenterId,
int workerId)
{
var config = new IdGeneratorConfig
{
DatacenterId = datacenterId,
WorkerId = workerId,
Epoch = 1735689600000L,
SegmentSize = 100_000,
CacheLowWatermark = 10_000,
CacheHighWatermark = 100_000
};
return new IdGeneratorService(config);
}
}
// ================================================================
// DEMO / USAGE EXAMPLE
// ================================================================
///
/// Demonstrates the complete ID generation system.
///
public static class Program
{
public static async Task Main(string[] args)
{
Console.WriteLine("=== Unique ID Generator System ===\n");
// 1. Create the ID generator service
var config = new IdGeneratorConfig
{
DatacenterId = 1,
WorkerId = 1,
Epoch = 1735689600000L
};
using var service = new IdGeneratorService(config);
// 2. Generate Snowflake IDs
Console.WriteLine("--- Snowflake IDs ---");
for (int i = 0; i < 10; i++)
{
long id = service.NextId();
var components = service.DecomposeSnowflake(id);
Console.WriteLine($" ID: {id} | " +
$"Timestamp: {new DateTime(components.Timestamp)} | " +
$"DC: {components.DatacenterId} | " +
$"Worker: {components.WorkerId} | " +
$"Seq: {components.Sequence}");
}
// 3. Generate UUID v7
Console.WriteLine("\n--- UUID v7 IDs ---");
for (int i = 0; i < 5; i++)
{
Guid uuid = service.NextUUIDv7();
DateTimeOffset ts = UUIDv7Generator.ExtractTimestamp(uuid);
Console.WriteLine($" UUID: {uuid} | Extracted: {ts:O}");
}
// 4. Generate ULIDs
Console.WriteLine("\n--- ULID Strings ---");
for (int i = 0; i < 5; i++)
{
string ulid = service.NextULID();
var (ulidTs, _) = ULIDGenerator.Parse(ulid);
Console.WriteLine($" ULID: {ulid} | Timestamp: {ulidTs:O}");
}
// 5. Batch generation
Console.WriteLine("\n--- Batch Generation (1000 IDs) ---");
var batch = service.NextBatch(1000);
Console.WriteLine($" First: {batch[0]}");
Console.WriteLine($" Last: {batch[999]}");
Console.WriteLine($" Unique: {new HashSet<long>(batch).Count}");
// 6. Health metrics
Console.WriteLine("\n--- Health Metrics ---");
var health = service.GetHealthMetrics();
Console.WriteLine($" Total Served: {health.TotalServed}");
Console.WriteLine($" Cache Hits: {health.CacheHits}");
Console.WriteLine($" Cache Misses: {health.CacheMisses}");
Console.WriteLine($" Cache Hit Ratio: {service.GetCacheHitRatio():F2}%");
Console.WriteLine($" Refills: {health.Refills}");
}
}
}
25. Conclusion
Designing a unique ID generation system is a deceptively deep problem that touches on nearly every aspect of distributed systems: concurrency, clock synchronization, fault tolerance, and performance optimization. There is no single "best" ID format — the right choice depends on your specific requirements for bit width, ordering, collision resistance, and coordination overhead.
For most modern systems, our recommended approach is:
- Default choice: UUID v7 — 128-bit, time-ordered, no coordination, RFC standard, native PostgreSQL support.
- High-throughput, storage-sensitive: Snowflake — 64-bit, time-ordered, sub-microsecond generation, compact storage.
- Human-readable, URL-safe: ULID — 26-character Crockford Base32, lexicographic sort, easy debugging.
- Idempotent operations: KUID — hash-based, deterministic, collision-resistant, content-addressable.
- Simple, single-node: Database auto-increment — zero code, ACID guarantees, but no horizontal scaling.
The full C# implementation provided in this article demonstrates production-ready patterns: thread-safe generation, local caching with configurable watermarks, batch generation, health monitoring, and multiple ID format support. Adapt these patterns to your language and infrastructure of choice.
As systems scale from thousands to billions of operations per day, the ID generator transitions from a background utility to a critical piece of infrastructure. Design it thoughtfully, monitor it carefully, and test it exhaustively. The cost of getting it wrong — duplicate IDs in your primary database — is catastrophic and difficult to recover from.
"The best ID generator is the one you never have to think about. Design it once, deploy it everywhere, and let it run forever."
For further reading, we recommend studying Google's Spanner TrueTime paper, Twitter's original Snowflake blog post, the RFC 9562 specification for UUID v7, and the ULID specification at ulid-spec.github.io. Each of these resources provides additional depth on the topics covered in this article.
Whether you are preparing for a system design interview, architecting a new distributed system, or optimizing an existing ID generation pipeline, the knowledge in this guide will serve you well. The fundamentals — bit manipulation, clock synchronization, distributed coordination, and performance benchmarking — are universal skills that apply far beyond ID generation.