URL shortener: short codes, fast redirects
Turn a long URL into a 7-character code and redirect every click. Writes are easy; the redirect path and the code generator decide the design.
- 1Reads outnumber writes 100 to 1. Design the redirect path first: Redis in front, Postgres behind.
- 27 base62 characters give 3.5 trillion codes, enough for 10 years at 100 million links a day.
- 3Let the primary key catch collisions. Key ranges per server need one database call per 1,000 codes.
- 4Use 302 with no-store to count clicks, and count them in memory, in batches.
"Design a service that turns a long URL into a short one and redirects, at the scale of a large consumer site."
| question | answer assumed |
|---|---|
| How many new links a day? | 100 million |
| Reads per write? | 100 redirects per link |
| How long do links live? | 10 years by default; optional expiry |
| Custom aliases? | Yes, for signed-in users |
| Click analytics? | Clicks per link per day |
| Can a link change target? | No; delete and create |
- functional
- Shorten a URL. Redirect a code. Custom alias. Expiry. Clicks per day.
- latency
- Redirect p99 under 50 ms at the server; create p99 under 300 ms.
- availability
- Redirect 99.99%: a broken redirect breaks every page that links to it. Create 99.9%.
- durability
- A created link is never lost or changed.
I assume 100 million new links a day, 100 redirects per link, links kept 10 years, and redirects in under 50 ms at p99.
| number | arithmetic | result |
|---|---|---|
| creates, average | 100 M ÷ 86,400 s | 1,157/s |
| creates, peak (3×) | 1,157 × 3 | 3,472/s |
| redirects, average | 100 × 100 M ÷ 86,400 s | 115,741/s |
| redirects, peak (3×) | 115,741 × 3 | 347,222/s |
| storage a day | 100 M × 500 B | 50 GB |
| storage, 10 years | 50 GB × 3,650 | 183 TB, 1 copy |
| links, 10 years | 100 M × 3,650 | 365 billion |
| codes, 7 chars | 62⁷ | 3.52 trillion |
| space used at 10 years | 365 B ÷ 3.52 T | 10.4% |
| bandwidth out, peak | 347,222 × 500 B × 8 | 1.4 Gbit/s |
| cache, 1% of a year | 365 M × 551 B | 201 GB |
500 B per link row and per redirect response, and the 3× peak, are assumptions; say them out loud. 551 B is Redis memory for one cached link with a 500 B URL, measured in the lab. The method is on the R2 sheet.
Writes peak at about 3,500 a second; redirects at about 350,000. Ten years of links is 183 TB, so the data needs about 19 shards.
| call | body | success | errors | retry safe by |
|---|---|---|---|---|
| POST /api/links + Idempotency-Key | {url, alias?, expires_in_days?} | 201 {code} | 400 bad URL, 409 alias taken, 429 | the key: a retry gets the first code |
| GET /{code} | none | 302 + Location | 404 unknown, 410 expired | a read |
| GET /api/links/{code}/clicks?from=&to= | none | 200 [{day, clicks}] | 403 not owner, 404 | a read |
| DELETE /api/links/{code} | none | 204 | 403 not owner, 404 | DELETE is idempotent |
- The redirect lives at the root path, so the short URL stays short. Reserve the paths the site itself uses.
- Without an Idempotency-Key, a create retried after a timeout makes a second code. With it, the unique index returns the first.
- A deleted code is not reused. Old pages may still link to it, so it answers 404 or 410 for ever.
Create is a POST with an Idempotency-Key. The redirect is a GET on the short path that answers 302, 404 or 410.
- rows
- 365 billion links in 10 years; 183 TB for one copy
- shard
- by hash(code): a redirect reads one shard
- cache key
- link:<code> holds the long URL; an empty value means "no such code"
-- One row per short link. The primary key refuses a duplicate code.
CREATE TABLE links (
code text PRIMARY KEY1,
long_url text NOT NULL,
owner_id bigint,
custom boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
idem_key text UNIQUE2,
CHECK (length(code) BETWEEN 4 AND 30)
);
-- Expired links leave in batches; this index finds them.
CREATE INDEX links_expires_at ON links (expires_at)
WHERE expires_at IS NOT NULL3;
-- One counter per generator. A server claims a block with one UPDATE.
CREATE TABLE key_ranges (
name text PRIMARY KEY,
next_start bigint NOT NULL4
);
-- Clicks per link per day, written in batches.
CREATE TABLE clicks_daily (
code text NOT NULL,
day date NOT NULL,
clicks bigint NOT NULL,
PRIMARY KEY (code, day)5
);- 1The primary key is the collision check. A duplicate insert changes 0 rows, and the generator tries again.
- 2A retried create finds its key and returns the first code.
- 3A partial index: most links never expire, so the cleanup index stays small.
- 4One row per generator. A server claims a block by adding 1,000 to it.
- 5One row per link and day. Batched clicks add to it.
One table keyed by code. A redirect is one primary-key read, so the table shards by a hash of the code.
Step 1: Create
- Check the long URL: http or https, at most 2 KB, not this domain, not on the block list.
- Take the next code from this server's block. One block claim per 1,000 codes.
- Insert the row. The primary key refuses a duplicate code.
- Return 201 with the short URL.
If it fails
A duplicate code: try the next one. Postgres down: return 503; the client retries with the same Idempotency-Key and gets one link.
Creates go to Postgres. Redirects read Redis first and Postgres on a miss. Clicks and expiry run in the background.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Primary key and INSERT ... ON CONFLICT DO NOTHING | The insert is the collision check. 0 rows changed means "try the next code". | Idempotent writes, dedupe |
| Postgres | UPDATE ... RETURNING on one counter row | Claim a block of 1,000 values in one statement. The row lock puts concurrent claims in a queue. | Invoice numbers, ticket numbers |
| Postgres | Unique index on the idempotency key | A retried create returns the first code. | Payments, bookings |
| Postgres | Upsert that adds: clicks = clicks + n | A batch of counts merges into one row per link and day. | Rollups, counters |
| Postgres | Partial index on expires_at | The cleanup job finds expired rows without a full scan. | Soft deletes, job queues |
| Redis | GET and SET with an expiry | Cache-aside for redirects. The TTL never outlives the link. | Sessions, page caches |
| Redis | An empty value with a 60 s TTL | A negative cache. A scan of random codes reaches Postgres once per code, not once per request. | Cache penetration defence |
| Redis | INCR | A single global counter for the counter generator, atomic across servers. | Rate limits, sequence numbers |
| Redis | Asynchronous replication | Limit A failover can lose recent INCRs, so the counter can repeat. The primary key catches it. | |
| Service | SHA-256, base62, a multiply modulo 62⁷ | Deterministic codes from a URL; non-sequential codes from a counter, with no collision. | ID obfuscation |
| HTTP | 301 or 302 with Cache-Control | Decides whether browsers and CDNs repeat the redirect without asking the service. | Moved pages, login flows |
Postgres gives me a primary key that refuses duplicate codes and a counter row I can claim blocks from. Redis gives me a cache with expiry.
| method | where | status |
|---|---|---|
| Key ranges per server, scrambled | Postgres | Approved |
| Random 7 chars, insert, retry on a duplicate | Postgres | Approved |
| Hash the URL, truncate, salt on a collision | Service | Dedupe wanted |
| One global counter, base62 | Redis | Scramble it |
| 64-bit time-ordered ID, base62 | Service | 11 chars |
| UUID, base62 | Service | 22 chars |
| SELECT to check, then INSERT | Postgres | Race |
- A hash gives the same URL the same code, which is dedupe for free. But two users then share one link, with one expiry and one owner.
- A raw counter is guessable: anyone can walk every link. Multiply by a constant that shares no factor with 62⁷, modulo 62⁷. Every value still gets its own code.
- Lab, 8 servers at once: 160,000 codes, 160,000 distinct, 160 database calls.
shorten(url, idem_key):
FOR attempt IN 0 .. 4:
code = source.next(url, attempt) // hash + salt, random, or a range1
IF insert(code, url, idem_key) adds 1 row2:
RETURN code
IF idem_key is stored: RETURN its code // a retried request3
IF the row at code has this url:
RETURN code // same URL, same hash4
FAIL "no free code"
next_from_range(): // key ranges
IF this block is used up:
start = UPDATE counter row: next += 1000, RETURNING old value5
block = [start, start + 1000) // nobody else gets it
RETURN scramble(next value of block)6 // 7 characters- 1Every generator plugs into one loop. Only hash and random can return a taken code.
- 2INSERT ... ON CONFLICT DO NOTHING. A taken code or a reused key changes 0 rows and raises no error.
- 3The client timed out and sent the same Idempotency-Key. It gets the first code; no second link.
- 4Hash and truncate only: this URL is stored already, so its code is the answer.
- 5One row, one lock. Two servers that claim at once get two different blocks.
- 6A multiply modulo 62⁷. It is a bijection, so it hides the order and never collides.
Tested source Go: the create loop · Go: claim a block · SQL: claim a block · Go: scramble · Go: hash and truncate
// Shorten stores l under a code from src. It retries with the next candidate when the code is
// taken. A retried request with the same idempotency key gets the first code back. A hashed URL
// that is already stored under its code also returns that code.
func (s *Service) Shorten(ctx context.Context, src Source, l Link) (string, int, error) {
for attempt := range MaxAttempts {
code, err := src.Candidate(ctx, l.LongURL, attempt)
if err != nil {
return "", attempt + 1, err
}
l.Code = code
ok, err := s.Store.insert(ctx, l)
if err != nil {
return "", attempt + 1, err
}
if ok {
s.forget(ctx, code)
return code, attempt + 1, nil
}
if l.IdemKey != "" {
first, found, err := s.Store.codeForIdemKey(ctx, l.IdemKey)
if err != nil {
return "", attempt + 1, err
}
if found {
return first, attempt + 1, nil
}
}
stored, _, err := s.Store.Find(ctx, code)
if err != nil && !errors.Is(err, ErrNotFound) {
return "", attempt + 1, err
}
if stored == l.LongURL {
return code, attempt + 1, nil
}
}
return "", MaxAttempts, ErrNoFreeCode
}
// Candidate implements Source. Only the first code of each block costs a database call.
func (r *RangeSource) Candidate(ctx context.Context, _ string, _ int) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.next == r.end {
var start int64
if err := r.store.db.QueryRow(ctx, sqls["claim_range"], r.name, int64(r.block)).Scan(&start); err != nil {
return "", fmt.Errorf("claim a block of %d from %s: %w", r.block, r.name, err)
}
r.next, r.end = uint64(start), uint64(start)+r.block
r.claims++
}
n := r.next
r.next++
return Scramble(n, r.length)
}
UPDATE key_ranges
SET next_start = next_start + $2
WHERE name = $1
RETURNING next_start - $2;
// Scramble maps counter value n to a code that does not look sequential. It is a bijection on
// [0, 62^length), so it never creates a collision. It hides the order; it is not encryption.
func Scramble(n uint64, length int) (string, error) {
space := Space(length)
if n >= space {
return "", fmt.Errorf("counter %d does not fit in %d characters (%d codes)", n, length, space)
}
return Base62(mulMod(n, scrambleFactor%space, space), length), nil
}
// HashValue is the first 8 bytes of SHA-256(url + "#" + salt). The salt changes the code when two
// different URLs land on the same one.
func HashValue(url string, salt int) uint64 {
sum := sha256.Sum256([]byte(url + "#" + strconv.Itoa(salt)))
return binary.BigEndian.Uint64(sum[:8])
}
// HashCode truncates the hash to length base62 characters.
func HashCode(url string, salt, length int) string {
return Base62(HashValue(url, salt)%Space(length), length)
}
I take codes from a block of counter values that each server claims from Postgres. Random codes with a retry also work at 7 characters.
| answer | who caches it | status |
|---|---|---|
| 302 + no-store | Nobody. Every click reaches the service. | Count clicks |
| 301 + max-age | Browser and CDN, until max-age ends | No analytics |
| 301, no Cache-Control | Browsers may keep it with no end | Not approved |
| 200 + HTML refresh | Nobody; one more round trip | Warning page |
redirect(code):
url = Redis GET "link:" + code
IF url == "": RETURN 404 // a cached miss1
IF url is missing:
row = Postgres: SELECT by primary key2
IF no row: cache "" for 60 s; RETURN 404
IF row expired: RETURN 4103
cache url for min(24 h, time to expiry)4
count the click in memory
RETURN 302, Location: url, Cache-Control: no-store5- 1Redis holds an empty value for codes that do not exist. A scan of random codes stops here.
- 2One index lookup on one shard. A Redis error also falls back here; the cache is never the only copy.
- 3Gone: the link existed and expired. It is not cached, so it cannot come back.
- 4The cache never serves a link after its expiry.
- 5The browser asks again on the next click, so the service sees and counts it.
Tested source Go: resolve through the cache · Go: the HTTP redirect
// Resolve returns the long URL for code: from Redis when cached, else from Postgres, and then
// caches the answer. A Redis error falls back to Postgres; the cache is never the only copy.
func (s *Service) Resolve(ctx context.Context, code string) (string, error) {
v, err := s.Cache.Get(ctx, CacheKey(code)).Result()
switch {
case err == nil && v == missing:
return "", ErrNotFound
case err == nil:
return v, nil
case !errors.Is(err, redis.Nil):
s.Log.Warn("link cache unavailable; reading Postgres", "code", code, "err", err)
}
s.DBReads.Add(1)
url, exp, err := s.Store.Find(ctx, code)
if errors.Is(err, ErrNotFound) {
s.cache(ctx, code, missing, s.MissTTL)
return "", ErrNotFound
}
if err != nil {
return "", err
}
ttl := s.CacheTTL
if exp != nil {
left := exp.Sub(s.Now())
if left <= 0 {
return "", ErrExpired
}
ttl = min(ttl, left)
}
s.cache(ctx, code, url, ttl)
return url, nil
}
func (h *Handler) redirect(w http.ResponseWriter, r *http.Request) {
code := strings.TrimPrefix(r.URL.Path, "/")
long, err := h.Svc.Resolve(r.Context(), code)
switch {
case errors.Is(err, ErrNotFound):
http.NotFound(w, r)
return
case errors.Is(err, ErrExpired):
http.Error(w, "this link has expired", http.StatusGone)
return
case err != nil:
h.Svc.Log.Error("resolve failed", "code", code, "err", err)
http.Error(w, "try again", http.StatusServiceUnavailable)
return
}
status := http.StatusFound
w.Header().Set("Cache-Control", "private, no-store")
if h.Permanent {
status = http.StatusMovedPermanently
w.Header().Set("Cache-Control", "public, max-age=86400")
}
if h.OnClick != nil {
h.OnClick(code)
}
http.Redirect(w, r, long, status)
}
| lookup by code, lab | p50 | p99 | 32 clients |
|---|---|---|---|
| Redis GET | 190 µs | 437 µs | 56,700/s |
| Postgres PK | 90 µs | 152 µs | 84,600/s |
| LRU holds | share of redirects it answers |
|---|---|
| 0.1% of links | 60% |
| 1% of links | 75% |
| 10% of links | 86% |
Both stores answer one lookup in under 1 ms on one machine. The cache exists to take load off Postgres, not to make one lookup faster. LRU replay: 1,000,000 redirects over 1,000,000 links, with popularity on a Zipf curve of exponent 1.1 (an assumption).
I answer 302 with no-store, so every click reaches me and I can count it. Redis answers most redirects; Postgres answers the misses by primary key.
| method | where | status |
|---|---|---|
| UPDATE links SET clicks + 1, per click | Postgres | Hot row |
| Count in memory, upsert every 5 s | Service | Approved |
| INCR per link and day | Redis | Totals only |
| Log each click to a stream, aggregate | Kafka | Per-click detail |
| HyperLogLog per link and day | Redis | Unique visitors |
on redirect:
buffer[code, today] += 1 // memory only1
every 5 s:
batch = swap buffer with an empty one2
upsert batch: clicks = clicks + n3 // one statement
IF it fails: add batch back to buffer4- 1A map update per click. No network call on the redirect path.
- 2Redirects keep counting into the new map while the old one is written.
- 3ON CONFLICT DO UPDATE adds to the stored total, so batches from many servers merge.
- 4A failed write loses nothing. The next flush retries it.
Tested source Go: flush · SQL: add clicks
// Flush writes every buffered count as one statement: one row per link and day, added to the
// stored total. A background loop calls it every few seconds.
func (b *ClickBuffer) Flush(ctx context.Context) error {
b.mu.Lock()
batch := b.counts
b.counts = map[clickKey]int64{}
b.mu.Unlock()
if len(batch) == 0 {
return nil
}
codes := make([]string, 0, len(batch))
days := make([]time.Time, 0, len(batch))
clicks := make([]int64, 0, len(batch))
for k, n := range batch {
codes, days, clicks = append(codes, k.code), append(days, k.day), append(clicks, n)
}
if _, err := b.store.db.Exec(ctx, sqls["add_clicks"], codes, days, clicks); err != nil {
b.restore(batch)
return fmt.Errorf("flush %d click rows: %w", len(batch), err)
}
b.mu.Lock()
b.flushes++
b.rowsWritten += len(batch)
b.mu.Unlock()
return nil
}
INSERT INTO clicks_daily (code, day, clicks)
SELECT * FROM unnest($1::text[], $2::date[], $3::bigint[])
ON CONFLICT (code, day) DO UPDATE SET clicks = clicks_daily.clicks + excluded.clicks;Tested: 8 servers clicked 10,000 times each on 100 links while a flusher ran. Every link got exactly 800, written in batches.
Each redirect server counts clicks in memory and writes one upsert every 5 seconds. At larger scale, I log clicks to a stream and aggregate there.
| alias design | status |
|---|---|
| Same table and key as generated codes | Approved |
| Any shape, same table | Clash later |
| A separate alias table | 2 lookups per miss |
create_alias(alias, url):
refuse unless 4 to 30 chars of [0-9A-Za-z-_]
refuse reserved paths: admin, login, static
refuse 7 plain base62 chars1 // a generated shape
insert; 0 rows: RETURN 409 taken2
expire(): // a job, every minute
REPEAT: delete 1000 rows3 WHERE expires_at < now
UNTIL a pass deletes nothing- 1A generated code could equal this alias later. Any other shape can never clash.
- 2The same primary key as generated codes. No separate namespace to keep in sync.
- 3Short transactions: few row locks, little replication lag.
Tested source Go: alias rules · SQL: delete expired
// CheckAlias applies the alias rules: 4 to 30 characters from base62 plus - and _, not a reserved
// path, and not shaped like a generated code.
func CheckAlias(alias string) error {
if len(alias) < 4 || len(alias) > 30 {
return fmt.Errorf("%w: %q must have 4 to 30 characters", ErrBadAlias, alias)
}
plain := true
for _, c := range alias {
if c == '-' || c == '_' {
plain = false
continue
}
if !strings.ContainsRune(Alphabet, c) {
return fmt.Errorf("%w: %q has the character %q", ErrBadAlias, alias, c)
}
}
if reserved[strings.ToLower(alias)] {
return fmt.Errorf("%w: %q is a reserved path", ErrBadAlias, alias)
}
if plain && len(alias) == GeneratedLength {
return fmt.Errorf("%w: %q looks like a generated code; add - or _, or change the length", ErrBadAlias, alias)
}
return nil
}
DELETE FROM links
WHERE code IN (
SELECT code FROM links
WHERE expires_at < $1
LIMIT $2
);| abuse | defence | where |
|---|---|---|
| Bulk creates of spam links | Rate limit per user and per IP (B3) | Redis |
| Malware targets | Check the host against a block list at create; recheck later | Service |
| A link to the shortener itself | Refuse the own domain, so links cannot loop | Service |
| Walking every code | Random or scrambled codes; a rate limit on 404s | Service |
| Scans that miss the cache | Cache misses for 60 s | Redis |
An alias goes through the same primary key as generated codes, so a taken alias is a 409. A rate limit and a block list stop most abuse at create time.
Draw a code, insert it; on a duplicate key, draw again. No coordination between servers.
- codes of 7 characters
- 3.52 trillion 627
- lasts, at 100 M links a day
- 96.5 years codes ÷ 100 M ÷ 365
- collisions in 1 M inserts
- 0 birthday estimate 0.14
- first collision at insert
- none estimate 2,351,965
At 10 years, a new code is taken with chance 10%. A create then needs 1.12 tries on average. Each try is one insert that the primary key refuses.
| length | collisions in 1 M | estimate | first at | most tries |
|---|---|---|---|---|
| 4 | 33,993 | 33,838 | 5,135 | 6 |
| 5 | 576 | 546 | 50,477 | 2 |
| 6 | 4 | 8.80 | 348,015 | 2 |
| 7 | 0 | 0.14 | none | 1 |
| 8 | 0 | under 0.01 | none | 1 |
At 7 characters, 1 million random codes did not collide once. After 10 years at 100 million a day, a random code collides about 1 time in 10, and the retry handles it.
| event | result | why it is safe | saved by |
|---|---|---|---|
| Two servers draw the same random code | The second insert changes 0 rows. | It draws again. The lab saw at most 1 try at 7 characters in 1 million creates. | Primary key |
| Redis fails over and loses counter increments | The counter hands out a value twice. | The second insert is refused; the loop takes the next value. | Primary key |
| A server crashes mid-block | The rest of its block is never used. | The counter row moved past it, so no value repeats. Lab: 700 skipped, 0 reused. | Counter row |
| Redis is down | Every redirect reads Postgres. | Redirects get slower, not wrong. Postgres replicas absorb the reads. | Fallback |
| A create times out and the client retries | The retry finds its Idempotency-Key. | It returns the first code. One link, not two. | Unique key |
| A cached link reaches its expiry | Redis drops it at the same moment. | The cache TTL is never longer than the time left. The next read sees 410. | TTL |
| A 301 is cached, then the link is deleted | Browsers still redirect. | Not safe: a 301 cannot be recalled. Use 302 for links that may change or go away. | Cache-Control |
| The click flush fails | The batch goes back into the buffer. | The next flush writes it. A crash loses at most one interval of counts. | Buffer |
| One link goes viral | One Redis key takes every read. | Keep the hottest codes in each server's memory for a few seconds. | Local cache |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One Postgres and a stateless service. | Creates are no problem: 3,472 a second at peak against about 36,465 inserts measured. Lookups reach about 84,600 a second. | Redirects near what one node reads. |
| 2 | Redis cache-aside for redirects. | An LRU of 1% of links answered 75% of redirects in the lab. Postgres sees the misses: 85,417 a second at peak. | The misses alone near one node, or the cache outgrows one Redis. |
| 3 | Read replicas and a Redis Cluster. | Reads grow with replicas and cache nodes. Writes stay on one primary. | Storage passes about 10 TB: after about 7 months here. |
| 4 | Shard Postgres by hash(code). | 183 TB ÷ 10 TB per node is 19 shards. Every redirect reads one shard. | Users far from the region wait 100 ms or more for a redirect. |
| 5 | Edge redirects: copy hot codes to caches in each region. | A redirect costs one round trip to the nearest region. | Top of the ladder. |
Demand as in panel B. 10 TB per node is the planning ceiling from R2: 50 GB a day reaches it in 200 days. Lab capacity comes from one laptop; read it as orders of magnitude.
I start with one Postgres and a stateless service. I add Redis when reads near one node, and shards when storage passes about 10 TB.
| question | answer | deeper |
|---|---|---|
| How do you shard? | By a hash of the code, because every redirect knows the code. Consistent hashing keeps most keys in place when you add a shard. | X2 |
| The creator clicks the new link at once. Can a replica miss it? | Yes, if the read goes to a lagging replica. Read the creator's own links from the primary for a few seconds. | X1 |
| How do you count unique visitors? | A HyperLogLog per link and day. Redis documents about 12 KB per key and a standard error of 0.81%. | B10 |
| The analytics team wants clicks by country and hour. | Log every click to a stream with its metadata. Aggregate per minute into a columnar store. | B7, B12 |
| Where does the cache live: Redis or the service? | Both, for hot keys. Each service keeps the top links for a few seconds; Redis holds the rest. | B4 |
| How do you get fast redirects worldwide? | Run redirects at the edge, from a copy of the hot codes. Creates still go to one primary region. | B5 |
| How available must each path be? | The redirect more than the create. It keeps working on Redis alone or on a replica alone. | O1 |
| Why not a time-ordered 64-bit ID as the code? | It is unique without a database call, but it needs 11 base62 characters. Short codes are the point of the product. | X4 |
0 of 9 known
100 million new links a day for 10 years. Is a 6-character code long enough?
Why not SELECT the code first and insert only if it is free?
The lab inserted 1 million random 6-character codes. About how many collided, and why?
A server holds a block of 1,000 values and crashes after 300. What happens to the other 700?
301 or 302 for the redirect?
A bot requests millions of random codes. What reaches Postgres?
Why count clicks in memory and not with UPDATE links SET clicks = clicks + 1?
A user wants the alias "Abc1234". Why refuse it?
How do you shard the links table?
- creates
- 100 M a day: 1,157 a second; 3,472 at a 3× peak.
- redirects
- 100 per link: 115,741 a second; 347,222 at peak.
- codes
- 62⁷ = 3.52 trillion. 10 years of links use 10%.
- storage
- 500 B a link: 50 GB a day, 183 TB in 10 years.
- collisions
- 1 M random codes: 576 collided at 5 chars, 4 at 6, 0 at 7.
- ranges
- 1 database call per 1,000 codes.
- cache
- 551 B per cached link; an LRU of 1% of links answered 75%.
- lookup
- Under 1 ms at p50 in Redis or Postgres; 10⁴ to 10⁵ a second per node.
Postgres 16 and Redis 8 on a 16-thread laptop shared with other jobs, 32 clients. Use the lab numbers as orders of magnitude.