LearnHLDDesign a unique ID generator

Design a unique ID generator

Two rows in a distributed system end up with the same id. Nothing crashes. The write succeeds, the API returns 200, and one of the two records quietly overwrites the other. You find out weeks later when a customer says an order disappeared, and by then the evidence is gone.

That is why this question gets asked. It is small enough to answer completely in twenty minutes, and every part of the answer is a real decision with a real failure attached to it.

Before you scroll

Before you scroll: why not just use a UUID v4? Give one reason that is about the database rather than about collisions.

What you are building

In scope
  • Generate ids that are unique across the whole fleet. Without coordination on the hot path.
  • Roughly sortable by creation time. So a range scan and an index on the id both do useful work.
  • 64 bits, so it fits a bigint. Half the storage of a UUID and it indexes far better.
The numbers you commit to
  • 10,000 ids a second at peak, with headroom.
  • Under 1ms to generate, because this sits inside every write.
  • No single point of failure on the generation path.
  • Ids must not be guessable in sequence if they are ever exposed publicly.
Cut, and say so out loud
  • Short human friendly codes. Different problem, covered in the URL shortener page.
  • Cryptographically random tokens for sessions or password resets. Those need real entropy and must never be time ordered.
  • Global ordering guarantees. These ids are roughly ordered, not a consensus sequence, and pretending otherwise causes bugs.

The numbers

How many ids do you actually need
10 thousand
64
50
Ids per second10,000
Ids per host per millisecond10,000 / 64 / 1000 = 0.16
Milliseconds in that many years2^41
Total ids over the period15.8 trillion
under 1 per ms per host
the sequence counter barely has to work

A millisecond timestamp needs 41 bits to cover 50 years. Everything left over is split between naming the host and counting within a millisecond, and the numbers above show the counter needs very few of those bits.

That last point is the one people miss. At 10,000 writes a second across 64 hosts, each host is producing well under one id per millisecond. The sequence bits exist for bursts, not for sustained throughput.

The layout

A 64 bit id is a bit budget, and every bit you spend on one field is a bit the others do not get. This is Twitter’s Snowflake scheme, which almost everyone copies.

Drag the slider and watch the trade.

1
41
milliseconds since a custom epoch
10
machine id
12
sequence within the millisecond
10
Years before the timestamp rolls over70 years
Machines you can name1,024
Ids per machine per millisecond4,096
Ids per machine per second4,096,000

The default 10 and 12 split is 1,024 machines and 4,096 ids per machine per millisecond, which is four million ids a second per host. That is the standard for a reason.

The sign bit is left at zero on purpose. Java’s long is signed, and a lot of systems downstream will treat the id as a signed integer. Setting the top bit gives them negative ids, and negative ids break sort order in exactly the place you were relying on it.

The epoch is custom, not 1970. Starting the clock at the day you deploy buys back every year since 1970 that you were never going to use. With a 2010 epoch, 41 bits lasts until about 2079. With the Unix epoch it would already be more than half spent.

Where the machine id comes from

The scheme is only unique if no two live processes hold the same machine id, and that is not free. Hardcoding it in config breaks the first time someone copies a deployment. Deriving it from the IP breaks when addresses get reused. The usual answer is a short-lived lease from ZooKeeper, etcd or a database row that the process renews, so a dead host’s id returns to the pool only after its lease expires.

The design

Figure 1. Ids are generated in the application process. The coordination service is only touched at startup and on lease renewal, never on the path of a write.

There is no id service in Figure 1. That is the point of the design, and it is worth saying out loud: a central id service would put a network call inside every write, and inherit an availability requirement stricter than the database it feeds.

Deep dive one: the clock is not a monotonic counter

Snowflake trades coordination for a hard dependency on the local clock. Time only moves forward, so ids only move forward. Except time does not only move forward.

1/5 A request arrives and the generator reads the clock. It gets 1,700,000,050 milliseconds.

There are two defences and you should name both.

Configure NTP to slew rather than step, so corrections are applied by running the clock slightly slow until it catches up, instead of jumping. This makes backwards movement rare rather than routine.

Then handle it anyway. Keep the last timestamp you issued. If the clock reads earlier than that, do not generate. Either wait until the clock passes the last timestamp, which is correct and costs latency, or refuse and fail the request loudly. What you must not do is generate anyway and hope. Snowflake implementations that quietly carry on are the ones that produce the duplicate nobody notices for a month.

Deep dive two: what is actually wrong with a UUID

UUID v4 is 122 random bits. Collisions are genuinely not the problem: you would need to generate billions of them before a collision becomes likely. The problem is what random ids do to a database.

A B-tree index keeps entries in sorted order. Insert with a time ordered key and every new row lands at the right hand edge of the tree, in a page that is already in memory, next to the row inserted a millisecond ago. Insert with a random key and every new row lands in a random page, which usually has to be read from disk first, modified, and written back. At scale the difference is not subtle: the same insert rate produces far more disk activity, the index fragments, and the working set stops fitting in memory.

There is also the width. A UUID stored as a 36 character string is 36 bytes, or 16 as raw binary, against 8 for a bigint. That cost repeats in every secondary index and every foreign key.

UUID v7 fixes the ordering by putting a millisecond timestamp in the high bits, which makes it a reasonable choice when you want a standard format and do not want to run a machine id lease. It is still 128 bits.

Break it

The dial here is not traffic. It is the health of the one dependency this design cannot avoid.

Clock and lease health
healthy
healthyburstclock skewclock steps backlease lost
Healthy. NTP is slewing, the lease is renewed, ids increase. Nothing in this picture makes a network call to generate an id.

Both of the red states produce duplicate ids, and neither of them produces an error by itself. That is what makes this design worth being careful with: its failure mode is silent, and the safe behaviour in both cases is to stop rather than to continue.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Snowflake style 64 bit idsNo coordination on the write path, time sortable, half the width of a UUID and far better index locality.A machine id to allocate and a hard dependency on a well behaved clock.High write volume where the id is a primary key and ordering earns its keep.
UUID v4Zero infrastructure, generate anywhere including the client, no coordination of any kind.128 bits, and random insert order that fragments B-tree indexes and blows up the working set.Low write volume, or ids created offline on a device, or when the id is not the clustered key.
UUID v7A standard format with a timestamp prefix, so it sorts and indexes almost as well as Snowflake, with no machine id to lease.Still 128 bits, and still leaks creation time.You want time ordering without running a coordination service. Often the pragmatic middle.
Database ticket serverTrivially correct, strictly increasing, no clock involved.A network round trip per id and a single point of failure, unless you hand out ranges, which brings back gaps.Modest scale where correctness matters more than throughput, or as the id source behind a range allocator.

Interview replay

Interviewer
You need unique ids across a hundred service instances. What do you use?
Deliberately open. The first sentence tells them how you think.
You
A Snowflake style 64 bit id: timestamp in the high bits, then a machine id, then a per millisecond sequence. The reason over UUIDs is index locality. Random ids scatter B-tree inserts across the whole index, and at high write rates that costs far more in disk activity than the id generation itself ever will.
Answers with the database consequence, not the collision math. That is the reason that actually drives the decision.
Interviewer
Where does the machine id come from?
The weak point of the scheme, and they know it.
You
A lease from ZooKeeper or etcd at startup, renewed periodically. Config files break the first time a deployment gets cloned, and deriving it from the IP breaks when addresses are recycled. The important part is what happens when the lease cannot be renewed: the process has to stop generating ids, because if another host has taken that id, both are producing the same ids and nothing will tell you.
Goes straight to the failure case without being prompted.
Interviewer
What if the clock goes backwards?
The standard follow up. There is a correct answer.
You
Track the last timestamp issued. If the clock reads earlier than that, refuse to generate: either wait for the clock to catch up, if the gap is a few milliseconds, or fail the request if it is large. I would also set NTP to slew instead of step so this is rare. The one thing that must not happen is generating anyway, because that produces a duplicate primary key with no error attached to it.
Names both the prevention and the handling. Only doing one of the two is the common half answer.
Interviewer
These ids are in our public URLs. Any concern?
A different axis entirely, and it catches people who have only memorised the bit layout.
You
Yes, two. They leak creation time, since the timestamp is right there in the high bits. And they are close to sequential, so anyone can estimate how many records you create per day and can walk nearby ids. If either matters I would keep the Snowflake id internally and expose a separate opaque identifier, rather than trying to make one id do both jobs.
Separating the internal key from the external identifier is the senior answer, and it is one sentence.

Checkpoint

Checkpoint

1. What is the strongest argument against UUID v4 as a primary key at high write volume?

2. The machine id lease expires during a network partition while the process keeps serving traffic. What must the process do?

3. You move machine id from 10 bits to 14 bits, keeping the layout at 64 bits. What did you just give up?

Say this in 60 seconds

I would use a Snowflake style 64 bit id: a millisecond timestamp against a custom epoch in the high bits, then a machine id, then a sequence counter for ids created within the same millisecond. It generates in process with no network call, so it does not put a dependency inside every write, and being time ordered means inserts land at the right edge of the index instead of scattering across it the way UUID v4 does. The machine id comes from a lease in ZooKeeper or etcd, and the process must stop generating if it cannot renew that lease. The one real risk is the clock: I would set NTP to slew rather than step, track the last timestamp issued, and refuse to generate if the clock ever reads earlier than that, because the failure mode here is a duplicate primary key with no error next to it.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.