~learn.

Problem 0411 minIntro+60 XP

Design a Unique ID Generator.

Design a distributed unique ID generator

7 phases3 code

Intro · 1 min

Every system needs IDs, and the first instinct is the database's auto-increment column. It works until you have more than one database, or more than one data centre, or a million writes a second. "Design a unique ID generator" asks what you do then. The answer most interviewers expect fits in a single 64-bit integer, and the interesting part is not the layout but the two things it depends on: a clock you can trust and a worker number nobody else is using.

The brief

Many services (orders, messages, payments, events) need primary keys generated at high volume across several data centres. Design the ID scheme and the service or library that produces it.

In scope
  • The ID format and why
  • How generators avoid collisions without talking to each other
  • What happens when clocks misbehave
ExplainerThe whole problem: unique ID generator

A distributed ID generator hands out unique, 64-bit, roughly time-ordered IDs at an assumed 1M a second with no network call per ID; a random UUID would cost 8 extra bytes per row per index (80 GB per index at 10 billion rows) and scatter inserts. The Snowflake-style layout spends 1 sign bit, 41 bits of milliseconds since a custom epoch (about 69 years), 10 bits of worker ID (1,024 generators) and 12 bits of sequence (4,096 per millisecond), and the 4,097th request in a millisecond waits for the next one. A library in each instance takes its worker ID from an etcd lease and its time from NTP; duplicates come only from a shared worker ID or a clock stepping backwards, and time-ordered IDs make range partitions hot and must reach JavaScript as strings.

Phase 1 · Stage 1 of 7 · 3 min budget · 1 min read

Phase 1: Requirements (3 minutes)#

Transcript
You

"Do IDs have to be strictly sequential, or just roughly ordered by time? Must they fit in 64 bits? Is it fine if an ID reveals when it was created?"

Interviewer

"Roughly time-ordered is enough: newer IDs should usually be larger, so recent rows sort and index well. 64 bits, because the columns are BIGINT. Revealing creation time is acceptable for internal IDs."

Functional requirements#

Functional requirements
  • next() returns a new ID; next(n) returns a batch
  • IDs are unique across all generators, forever
  • IDs are numeric, 64-bit, and sort roughly by creation time

Non-functional requirements#

Non-functional requirements
  • Throughput: about 1M IDs a second across the fleet at peak, with bursts on single hosts
  • Latency: generating an ID should cost microseconds, with no network call on the hot path
  • Availability: ID generation must not depend on a single central service being up
  • Ordering: within one generator, strictly increasing; across generators, ordered to within clock skew

Phase 2 · Stage 2 of 7 · 3 min budget · 1 min read

Phase 2: Estimation (3 minutes)#

Your turn · checkpoint 1not answered

Before the layout: why not a UUID?

UUIDs need no coordination at all. What do you give up by using a random 128-bit UUID as the primary key of a table with 10 billion rows?

Think it through, or jot it down. Then compare.

One strong answer

  • Size: 16 bytes instead of 8. Every index that includes the primary key grows by 8 bytes per row: 10B × 8 B = 80 GB per index.
  • Insert locality: random keys land all over a B-tree, so inserts touch random pages, split pages and use the cache poorly. Time-ordered keys append near the right edge of the index.
  • No order: you can't sort by ID to get the newest rows.

Time-ordered 128-bit UUIDv7 (standardised in RFC 9562) fixes the locality and order problems, but not the size, and the brief asked for 64 bits.

WorksheetBudget the 64 bits
  • 1 bit sign, always 0, so the value stays positive in a signed BIGINT
  • 41 bits of milliseconds since a custom epoch: 2^41 ms ≈ 2.2 trillion ms ≈ 69 years
  • 10 bits of worker ID: 1,024 generators
  • 12 bits of sequence per millisecond: 4,096 IDs per ms = ~4.1M IDs/s per generator
  • Fleet need: 1M IDs/s. In theory one generator could serve it; in practice you run one per service instance (dozens to hundreds), so 1,024 worker IDs is the real constraint to watch
You

"This is the layout Twitter's Snowflake popularised when it was open-sourced in 2010, and many systems use variations of it. I'd keep the shape and choose the bit split for our own fleet."

Phase 3 · Stage 3 of 7 · 2 min budget · 1 min read

Phase 3: API (2 minutes)#

Two ways to expose it, and the embedded library is the default.

python3 lines
# 1 · embedded library: no network hop
ids = SnowflakeGenerator(worker_id=lease.worker_id)
order_id = ids.next()                  # 64-bit int, e.g. 240044212224069649
http3 lines
# 2 · a small ID service, for languages or jobs that can't embed the library
POST /v1/ids?count=100
→ 200 { "ids": ["240044212224069649", "240044212224069650", ...] }

IDs go over JSON as strings. JavaScript numbers are exact only up to 2^53 − 1, and a Snowflake ID passes that within weeks of its epoch, so a browser parsing it as a number would silently round it.

Phase 4 · Stage 4 of 7 · 4 min budget · 1 min read

Phase 4: High-level design (4 minutes)#

DiagramSnowflake-style IDs, generated inside each service
%% title: Snowflake-style IDs, generated inside each service
flowchart TD
  OS["Order service instance<br/>ID library · worker 17"]:::core
  CS["Chat service instance<br/>ID library · worker 18"]:::core
  E@{ icon: "tech:etcd", label: "etcd<br/>worker-ID leases" }
  T@{ icon: "sd:clock", label: "NTP time sources" }
  DB@{ icon: "sd:database", label: "Databases<br/>B-tree index on id" }
  API@{ icon: "sd:api", label: "ID service<br/>(batch endpoint)" }
  B@{ icon: "sd:browser", label: "Web clients<br/>IDs as strings" }
  OS -->|"lease worker 17, renew every 10 s"| E
  CS --> E
  API --> E
  T -.->|"clock sync"| OS
  T -.-> CS
  OS -->|"insert id"| DB
  CS --> DB
  OS -->|"JSON: id as string"| B
  class E,DB store
  class T,API service
  class B client
  • The library in every service instance builds IDs from its clock, its worker ID and a per-millisecond counter. No network call per ID.
  • etcd (or ZooKeeper) hands out worker IDs as leases. An instance must hold a live lease to generate; that is what keeps two instances from using the same worker number.
  • NTP keeps clocks close so IDs from different instances are ordered to within a few milliseconds.
  • The ID service is the same library behind an endpoint, for callers that can't embed it. Callers fetch IDs in batches so the extra hop is rare.
The alternatives you should mention

DB auto-increment

  • Simple, strictly ordered
  • One primary is a bottleneck and a single point of failure
  • Multiple primaries with offsets (server k issues k, k + N, ...) lose time order and are hard to grow

Central range allocator

  • A counter hands each server a block of IDs
  • Compact 64-bit IDs, no per-ID call
  • Blocks break time order across servers, and the allocator must be highly available

UUIDv4 / UUIDv7

  • No coordination at all
  • 128 bits; v4 is random (poor index locality), v7 is time-ordered
  • A fine choice when 64 bits is not a requirement

Phase 5 · Stage 5 of 7 · 5 min budget · 3 min read

Phase 5: Deep dives (5 minutes)#

Deep dive 1: Building an ID, and the 4,097th request in a millisecond#

pythonSnowflakeGenerator · 28 lines
import time

EPOCH_MS = 1735689600000          # custom epoch: 2025-01-01T00:00:00Z
WORKER_BITS, SEQ_BITS = 10, 12
MAX_SEQ = (1 << SEQ_BITS) - 1     # 4,095

class SnowflakeGenerator:
    def __init__(self, worker_id: int):
        assert 0 <= worker_id < (1 << WORKER_BITS)
        self.worker_id, self.last_ms, self.seq = worker_id, -1, 0

    def next(self) -> int:
        now = int(time.time() * 1000)
        if now < self.last_ms:                       # clock went backwards: deep dive 2
            now = self._wait_until(self.last_ms)
        if now == self.last_ms:
            self.seq = (self.seq + 1) & MAX_SEQ
            if self.seq == 0:                        # 4,096 used this ms: wait for the next ms
                now = self._wait_until(self.last_ms + 1)
        else:
            self.seq = 0
        self.last_ms = now
        return ((now - EPOCH_MS) << (WORKER_BITS + SEQ_BITS)) | (self.worker_id << SEQ_BITS) | self.seq

    def _wait_until(self, target_ms: int) -> int:
        while (now := int(time.time() * 1000)) < target_ms:
            time.sleep(0.0001)
        return now
  • Timestamp first makes IDs sort by time, so new rows append at the end of the index.
  • The sequence separates IDs generated in the same millisecond by the same worker. The 4,097th request in a millisecond waits for the next millisecond (at most 1 ms), so it is a throughput ceiling of ~4.1M a second per generator, never a duplicate.
  • A custom epoch (2025-01-01 here, not 1970) spends the 69 years from when the system starts. (The code is a sketch: a real generator also guards next() with a lock if threads share it.)
  • Order across workers is only as good as clock sync: two IDs made 1 ms apart on different hosts can come out in either order. That is "roughly time-ordered", which is what the interviewer agreed to.

Deep dive 2: The two ways to get a duplicate#

Transcript
Interviewer

"Your layout can't collide on paper. How does it produce a duplicate in production?"

You

"Two ways: two generators with the same worker ID, or one generator whose clock goes backwards and reuses a millisecond it already used."

Worker IDs that can't be shared
  1. Lease on start: the instance claims a free worker ID in etcd with a lease (say 30 s TTL) and renews it every 10 s.
  2. Stop if the lease is lost: if a renewal fails and the lease might have expired, the library stops issuing IDs until it holds a lease again. A paused VM that wakes up after its lease expired must not continue with its old number.
  3. Fence the gap: after re-acquiring, wait longer than the maximum clock skew before issuing, so the new holder's IDs can't overlap the old holder's last milliseconds.
  4. Don't put worker IDs in config files: two copies of the same deployment template are the classic way to get two worker 17s.
Clocks that go backwards
  1. Why it happens: NTP can step a clock back, a VM can migrate to a host with a different clock, an operator can fix the time by hand.
  2. Small step (a few ms): wait until the clock passes the last timestamp used, as the code does. Generation pauses briefly; nothing collides.
  3. Large step: refuse to generate and alert, rather than waiting many seconds silently or issuing duplicates.
  4. Prevention: run NTP in slewing mode (adjust the rate, don't jump), and have the generator remember the last timestamp across restarts or wait out the maximum skew after starting.
Quick check2 questions · +5 XP each

Layout and duplicates

  1. Q1/2 A worker is asked for its 4,097th ID within the same millisecond. What does the generator do?

    Answer & explanation

    Answer: B

    12 sequence bits give 4,096 IDs per millisecond. The next one waits at most 1 ms, which caps throughput at about 4.1M IDs a second per generator.

  2. Q2/2 Which situation can produce duplicate IDs with a correct Snowflake layout?

    Answer & explanation

    Answer: C

    Different worker IDs make same-millisecond IDs differ, and the sequence overflow waits. A shared worker ID (or a clock going backwards) is what collides.

Phase 6 · Stage 6 of 7 · 2 min budget · 1 min read

Phase 6: Trade-offs and failure modes (2 minutes)#

What breaks, and what you do
IDs leak information

Anyone can read the creation time out of an ID, and two IDs a day apart reveal roughly how many were made in between. If that matters (a competitor estimating your order volume), expose a separate random public ID and keep the Snowflake ID internal.

Monotonic keys make hot partitions

Time-ordered IDs all land at the end of the key space. If a table is range-partitioned by ID, every insert goes to the last partition. Hash-partition by ID, or partition by something else.

Running out of bits

41 bits of milliseconds last about 69 years from the epoch. 10 worker bits allow 1,024 generators; a large fleet may need a split like 5 bits of data centre and 5 of worker, or fewer sequence bits.

etcd is down

Instances that already hold a lease keep generating while they can renew; new instances can't start generating. Keep leases long enough to ride out a short outage.

JavaScript clients

Return IDs as strings in JSON. Precision loss above 2^53 doesn't error; it silently changes the ID.

Phase 7 · Stage 7 of 7 · 1 min

Phase 7: Go deeper in the course#

Wrap-up · 1 min

Final check#

Quick check3 questions · +5 XP each

Final check · Unique IDs

  1. Q1/3 With 41 bits of milliseconds, how long does a Snowflake-style scheme last from its epoch?

    Answer & explanation

    Answer: C

    2^41 ms is about 2.2 trillion milliseconds, roughly 69 years. A custom epoch starts that clock when the system starts, not in 1970.

  2. Q2/3 Why send 64-bit IDs to browsers as JSON strings?

    Answer & explanation

    Answer: B

    Snowflake IDs exceed 2^53 soon after the epoch. A browser parsing one as a number silently rounds it to a different ID.

  3. Q3/3 An orders table is range-partitioned by its Snowflake ID. What goes wrong?

    Answer & explanation

    Answer: D

    Time-ordered keys always grow, so new rows pile onto the newest range. Hash-partition by ID instead.

End of Problem 04 · Design a Unique ID Generator
Finish Problem 04Stages clear as you read them (0/7 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…