Grokking the System Design Interview, Volume II
Vote

0% completed

​

Step 6: ID Generation Strategies

  1. Where uniqueness comes from
  1. Inside Priya's sortable ID
  1. Building the ID safely on one node
  1. Priya's opaque profile ID
  1. What the service stores, and what happens when parts fail

Priya signs up for an online store, and the accounts service in US-East needs two IDs for her. The first is the primary key of her row, which should be a compact number that sorts by creation time. The second goes into the link to her public profile, which should reveal nothing and be impossible to guess. Building those two IDs raises five questions:

  1. Where can uniqueness come from, and which schemes do we choose? (comparing the approaches)
  2. What is inside Priya's sortable ID? (the Snowflake bit layout)
  3. How does a node build that ID safely, many times a millisecond, without asking anyone? (the generator, the coordinator, and the clock)
  4. How is her opaque profile ID made? (the UUID path)
  5. What does the service store, and what happens when a part fails? (state and recovery)

1. Where uniqueness comes from

Every ID scheme gets its uniqueness from one of three places: a space too large for collisions, one central counter, or structure built into the ID itself. That source decides how many IDs a node can make, whether they sort by time, and what must be coordinated.

A large random space: UUIDv4. Any node can generate a UUIDv4 without coordination or shared state, because uniqueness comes from the size of its 122-bit random space, not from agreement between nodes. A UUIDv4 is also opaque: nobody can read a time or a sequence out of it. The costs are size and order. At 128 bits, it is twice the width of a 64-bit integer, and that width is paid again in every index that contains the key. Randomness also slows inserts. Most relational databases keep primary keys in a B-tree index. A random key lands in a different page of that index on almost every insert, so the database keeps splitting full pages, while a time-ordered key always lands at the right-hand edge. That makes UUIDv4 a poor primary key for Priya's row on a large table.

One central counter. The simplest approach keeps one counter in one place, for example a single row in a SQL database that every request increments. Uniqueness and strict order follow with nothing else to build. The problem is scale: at 100,000 IDs per second, every request contends for the same row on the same primary node, and when that database is down, no service can create a record.

A counter that hands out blocks: segment allocation. A database still owns the counter, but each node asks for a block, or segment, such as the next 10,000 IDs. The database adds 10,000 to its counter in one atomic step, one that cannot be interrupted halfway, and returns a range such as 1,000,000 to 1,009,999:

UPDATE id_segments SET max_id = max_id + step WHERE biz_tag = 'orders' RETURNING max_id - step + 1 AS first_id, max_id AS last_id;

The node serves those IDs from memory, so the database sees one query per 10,000 IDs. Three costs remain. The central store is still critical: if it stays unreachable, every node eventually runs out and the service stops. A node that restarts abandons the rest of its block, because reissuing those numbers could create duplicates. Order across nodes is only approximate, since node B may issue 10,001 while node A is still working through 1 to 10,000.

Structure inside the ID: Snowflake. Twitter's Snowflake removes the central step by building uniqueness into the ID itself. A 64-bit Snowflake ID holds a timestamp, a machine number, and a sequence number, so any node can generate IDs alone as long as its machine number is unique and its clock is reasonably accurate. The costs are that someone must assign each machine number exactly once, every clock must stay close to true time, and anyone who knows the layout can read when an ID was made. Other formats, like MongoDB's 96-bit ObjectID or the 128-bit UUIDv7, are wider than we need.

The choice: Snowflake plus UUIDv4. No single scheme meets both of Priya's needs, so the service runs two code paths. Snowflake-style IDs give the sortable format: 64 bits, generated locally on each node with no central bottleneck. UUIDv4 gives the opaque format: near-certain uniqueness with no coordination, and values safe to show in URLs. Many production systems make the same split, with time-based IDs inside and opaque IDs outside. A 128-bit sortable ID would never run out of range, but a 64-bit one fits a native integer type, so indexes stay small and fast.

2. Inside Priya's sortable ID

Priya's sortable ID is a 64-bit integer whose top bit, bit 63, is always zero. That bit keeps the value positive in a signed 64-bit type such as SQL's BIGINT, so every sort and range comparison stays correct. The other 63 bits hold three fields.

Timestamp, 41 bits. The number of milliseconds since a custom epoch, a start date we choose instead of the Unix epoch. 2^41 milliseconds is about 69 years, and because the count starts at zero on our chosen date, an epoch close to launch gives the full 69 years from the day the system goes live. The timestamp occupies the highest bits, so a later ID is always a larger number, and IDs sort by creation time.

Region and machine, 10 bits. This field is split between a region number and a machine number within the region. A 5/5 split gives 32 regions of 32 nodes, which fits, since even the largest companies run a few dozen data centers. The one rule, which the coordinator enforces, is that the 10-bit value must be unique among live generator nodes. It is what keeps two nodes' IDs apart when their timestamps and sequences match.

Sequence, 12 bits. A counter that separates IDs made by one node within the same millisecond. It increases by one per ID and resets to zero when the millisecond changes, which allows 4,096 IDs per node per millisecond, about 4 million per second. When a node uses all 4,096 within one millisecond, it waits for the next millisecond and starts again from zero, so no sequence value is reused within one timestamp. That wait is the only way the service slows under load, so nodes that keep hitting it are the signal to add nodes.

The three widths trade against each other: more machine bits allow more generators, more sequence bits more IDs per millisecond, and more time bits a longer lifetime. For this service, the 41, 10, and 12 split leaves wide margins on all three. The diagram below shows how the fields fit into 64 bits.

Snowflake 64-bit ID
Snowflake 64-bit ID

Written out in order, the fields are:

[timestamp: 41 bits][region+machine: 10 bits][seq: 12 bits]

Anyone who knows the layout can take an ID apart, which is why this format is not opaque. Teams often ship the decoding as a small helper that turns an ID from a log line into the exact node and millisecond that produced it:

def decode(id_value): return { "created_at_ms": (id_value >> 22) + EPOCH, "region": (id_value >> 17) & 0x1F, "machine": (id_value >> 12) & 0x1F, "sequence": id_value & 0xFFF, }

3. Building the ID safely on one node

When the accounts service asks for Priya's ID, the node runs a short routine:

def next_id(): with lock: now = current_millis() - EPOCH if now < last_ms: raise ClockMovedBackwards(last_ms - now) # Step 8 decides: wait or leave the pool if now == last_ms: seq = (seq + 1) & 0xFFF if seq == 0: # 4,096 used in this millisecond now = wait_until_after(last_ms) else: seq = 0 last_ms = now return (now << 22) | (machine_id << 12) | seq

The lock guards two comparisons and an increment, which a CPU runs millions of times per second, so it costs almost nothing. The routine runs in well under a microsecond, so most of a request's latency is network time. The node returns the 64-bit value as a decimal string, as Step 4 requires, because JavaScript would turn a 64-bit number into a float and lose its last digits.

Each node keeps its own machine number, last timestamp, and counter, but none of that state is shared, so no request depends on any other node. The routine is safe only if two things hold: no two live nodes share a machine number, and no node's clock runs backward.

Machine numbers. A ZooKeeper cluster, a coordination service, hands out the 10-bit machine numbers. When a generator starts, it creates an ephemeral node such as /id-generators/<region>/<machine_id>, an entry that ZooKeeper deletes automatically when the creating session ends. Because ZooKeeper tracks the live entries, two live generators never hold the same number. The coordinator is used once, at startup, and is never on the path of Priya's request.

If ZooKeeper becomes unreachable, nodes that already hold numbers keep generating, and only new nodes cannot join. Because an expired session deletes its entry, a generator should keep using its number, re-register it when the coordinator returns, and ask for a new one only if it was taken in the meantime.

When a generator dies, its machine number becomes free, but reissuing it at once is unsafe. If the new holder's clock is behind the old holder's last timestamp, the two can produce the same ID. Step 8 therefore holds a freed number back for a quarantine period longer than the clock error we are willing to accept.

Clocks. Every generator runs an NTP (Network Time Protocol) daemon, which keeps its clock within a few milliseconds of true time. Small differences between nodes only reorder IDs slightly and never create duplicates. That is also why IDs are strictly ordered within one node but only roughly ordered across the fleet: two nodes read their own clocks, which differ by a few milliseconds.

The real danger is a clock that moves backward, which a sudden NTP correction, a virtual machine restored from a snapshot, or a manual change can each cause. If the clock of the node serving Priya moves back 100 ms, it repeats timestamps it has already used, with the same machine number and a reset sequence, which is exactly how a duplicate is made. The routine therefore refuses to generate while the clock is behind the last timestamp it used. NTP can fix a clock by stepping it to the right time at once or by slewing it, running it slightly fast or slow until it catches up. Slewing never moves the clock backward, so generator hosts allow only slewing in normal operation, and they take time from a source that spreads a leap second across many hours instead of repeating a second. A clock that jumps forward is less dangerous: IDs stay unique and only look out of place in a sorted list. Step 8 covers the remaining clock repairs.

4. Priya's opaque profile ID

The opaque path has nothing to coordinate. The node takes random bytes from a CSPRNG, a cryptographically secure pseudo-random number generator, sets the version and variant bits that UUIDv4 requires, and formats the result in the standard form, such as f47ac10b-58cc-4372-a567-0e02b2c3d479. This takes microseconds, and a generator per thread removes any contention.

The service does not check for duplicates. At 10^10 IDs a day, the system is nowhere near the volume at which a collision becomes plausible, so the size of the space is the guarantee. The one thing to get right is the randomness source, because a weak generator, or one seeded the same way on several machines, can repeat values. This path needs no coordinator and no clock, so none of the failures in section 3 can affect it.

The format exists for its security property. If Priya's profile link used a sequential ID, an attacker could step from her ID to the next one and walk through other people's records. A random ID makes that search hopeless, and it reveals neither when she signed up nor how many users exist. The trade-off is that opaque IDs cannot be sorted by creation time, so a caller that needs the time stores it in a separate field. The guidance to callers is short: use sortable IDs when order or a compact key matters, and opaque IDs when the value will be public.

5. What the service stores, and what happens when parts fail

Uniqueness here is computed, never looked up. The service keeps no record of issued IDs, which avoids a store that would have to accept every ID at the rate they are made, and there is no database or cache on the request path. Operational logs record counts, not IDs: IDs per second, sequence waits, and backward clock events.

The only durable state is in the coordinator: about one small entry per active generator, at most around a thousand. That state must survive a full restart, because losing it while every node restarted could assign one machine number twice. The coordinator therefore runs with disk persistence and is recovered before any generator starts. If its data were ever lost, the safe procedure is to assign numbers by hand from a recorded mapping.

A generator node fails. IDs it already issued stay valid, because they are only numbers that clients hold. ZooKeeper notices the lost session, a replacement starts and registers, and it does not receive the dead node's number until that number's quarantine ends.

A data center fails. Its clients move to another region. The region number is part of every sortable ID, so IDs from the other region remain unique. When the failed region returns, its clocks have moved forward, so its new IDs are larger than its old ones, and the gap does not matter because IDs never need to be consecutive.

The coordinator fails. As section 3 described, existing nodes keep generating and only new nodes cannot join.

A bug slips through. Each node keeps the last ID it issued and checks that every new sortable ID is larger. Keeping millions of recent IDs per node would be expensive, but keeping one costs nothing, and a failed check points straight at a clock or sequence bug.

The fleet outgrows 1,024 nodes. Bits can move from the sequence to the machine field: 12 machine bits and 10 sequence bits allow 4,096 nodes at 1,024 IDs per millisecond each.

Priya's two IDs come from two algorithms in one service. The sortable one depends on the bit layout, the machine numbers, and the clocks, and the opaque one only on a good random source.

Reading Progress

0%


Vote for new content

On This Page

  1. Where uniqueness comes from
  1. Inside Priya's sortable ID
  1. Building the ID safely on one node
  1. Priya's opaque profile ID
  1. What the service stores, and what happens when parts fail