Why this matters
- UUID v4 is unique but random — unusable for time-range queries on the transfers table.
- Database
SEQUENCEobjects become a single-node bottleneck when ShardPay shards horizontally. - Snowflake-style IDs embed timestamp and shard for sortability without a central DB round-trip per insert.
- A centralized sequencer is a single point of failure unless backed by Raft consensus.
Snowflake-style IDs at ShardPay
ShardPay encodes every transfer ID as 64 bits: 41-bit millisecond timestamp, 10-bit shard ID, 12-bit per-shard sequence. IDs are roughly time-ordered, unique cluster-wide, and decodable for debugging ("this transfer landed on shard 42 at 14:03 UTC").
Each ledger shard allocates sequence numbers locally until the millisecond bucket rolls over or the 12-bit sequence exhausts (4096/ms). On exhaustion the shard spins until the next millisecond — rare at ShardPay's per-shard throughput but load-tested to 4k transfers/ms per shard.
Walkthrough: support lookup by time range
A merchant disputes all transfers between 2pm and 3pm. Because IDs embed timestamp, ShardPay's support API computes min/max ID bounds for that window and queries only relevant shards. Without time-ordered IDs the team would full-scan UUID primary keys — unusable at billions of rows.
Key points
- Snowflake layout — timestamp + machine/shard + sequence bit fields packed into a
long. ShardPay'sTransferIdtype decodes shard and creation instant for routing support tickets without joining metadata tables. - Rough time order — IDs sort lexicographically by creation time within a shard; clock skew across shards can invert order by seconds. ShardPay uses Snowflake order for indexing and support, not for causal correctness — that stays with Lamport clocks on audit rows.
- Sequence exhaustion — when 4096 IDs fire in one millisecond on one shard, the generator waits for the next ms. ShardPay alerts if any shard hits exhaustion more than once per minute, signaling hot-account concentration.
- UUID v7 — time-ordered UUIDs (RFC draft) as an alternative to Snowflake; 128 bits, standard library support growing in Java 17+ ecosystems. ShardPay stayed on Snowflake for compact
longprimary keys in B-tree indexes. - Lease-based range — a coordinator assigns each node a block of IDs (e.g. 1–10000); the node allocates locally until the block runs out. ShardPay's batch settlement job uses lease ranges from etcd so millions of fee-line IDs never hit the DB sequence.
Raft-backed centralized sequencers
When strict monotonic sequence numbers must be global — regulatory export batch IDs, exactly-once settlement file sequence — ShardPay runs a small Raft cluster (three nodes) that increments a counter only after majority commit. Clients fetch batches of 1000 IDs per RPC to amortize consensus latency.
The sequencer is a SPOF in theory but not in practice: Raft tolerates one node loss; clients retry on leader election blips. Throughput is lower than Snowflake (roughly 50k IDs/s cluster-wide) so ShardPay reserves it for low-volume, high-assurance sequences.
Walkthrough: leader failover during peak
During Black Friday, the Raft leader for the settlement sequencer dies. Followers elect a new leader in ~300ms. Three in-flight settlement workers receive NOT_LEADER and retry with backoff; none duplicate batch IDs because uncommitted leader proposals are discarded. Settlement files remain strictly ordered.
// ShardPay Snowflake generator on a ledger shard (Java 17)
public final class SnowflakeTransferIdGenerator {
private static final int SHARD_BITS = 10;
private static final int SEQ_BITS = 12;
private static final long MAX_SEQUENCE = (1L << SEQ_BITS) - 1;
private final int shardId;
private long lastMillis = -1;
private long sequence = 0;
public SnowflakeTransferIdGenerator(int shardId) {
if (shardId < 0 || shardId >= (1 << SHARD_BITS)) {
throw new IllegalArgumentException("shardId out of range");
}
this.shardId = shardId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now == lastMillis) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
now = waitNextMillis(now);
}
} else {
sequence = 0;
}
lastMillis = now;
return (now << (SHARD_BITS + SEQ_BITS))
| ((long) shardId << SEQ_BITS)
| sequence;
}
private long waitNextMillis(long current) {
while (System.currentTimeMillis() <= current) { /* spin */ }
return System.currentTimeMillis();
}
}
Choosing an ID strategy
Match the scheme to query patterns and failure tolerance. ShardPay's decision matrix: hot-path transfer IDs → Snowflake on shard; global batch sequence → Raft sequencer; one-off correlation → UUID v4 in HTTP headers. Mixing schemes in one table is fine; mixing semantics (using Snowflake order to infer causality) is not.
Quick recall
Everything you need if you only revisit this box.
- Snowflake IDs are unique, compact, and roughly time-ordered — ideal for ShardPay transfer primary keys.
- Central sequencers need Raft (or equivalent) to avoid SPOF; reserve them for low-volume strict ordering.
- Pick ID scheme for sort, uniqueness, and scale — never conflate rough time order with causal order.
Test yourself
Answer these before moving on — recall is what makes it stick.