Case study, digital bank

Allocating 11,000 customer IDs a day with no duplicates

How I replaced sequence-based issuance of customer IDs in a digital bank's onboarding flow with pre-allocated pools.

The job

Every customer who opens an account at the bank gets a CIF number, the ID that follows them through every product. The bank has about 3 million customers, and the onboarding flow issues around 11,000 of these numbers a day. The previous design took them from a sequence, which kept them unique but made each one easy to guess from the last.

What the IDs had to do

How the CIF pools fit togetherOnboarding pods claim IDs from per-type pools in PostgreSQL with SKIP LOCKED. A capped refill job inserts new Feistel-generated IDs, a reclaim job returns reservations left by crashed pods, and pool-depth gauges raise alerts.Onboardingnew customerOnboarding podsCIF poolstype Atype Btype CfreereservedusedAlertpool running lowFeistelseed · prefix · lengthRefill croncapped batchesReclaim joborphaned reservations123claimSKIP LOCKED
1. Pods claim a free ID without waiting on each other. 2. The refill job tops up a pool when it drops below its threshold. 3. The reclaim job frees IDs a crashed pod reserved but never used.

One pool per customer type

Each customer type has its own pool, and each pool carries its own Feistel seed, prefix and ID length.

Why a Feistel network

A Feistel network turns a counter into a scrambled number of the same size. Because it is a permutation, two different inputs never produce the same output, so the IDs are unique by construction and there is no table to check. Issuing one takes constant time. Nothing is generated ahead of time, and nothing queries the database to see whether an ID is already taken.

How one CIF is producedA counter goes through the Feistel rounds for its pool. If the result is outside the ID range it goes through again, then the prefix and fixed length are applied.Counternext n for the poolFeistel roundskeyed by pool seedIn range?else walk againFormatprefix + lengthCIFunique, unguessablecycle-walkSame input always gives the same output, and no two inputs collide.
Uniqueness comes from the permutation, so nothing has to check a table.
// Simplified sketch, not the production code.
long permute(long counter, long domain, long[] roundKeys) {
    long x = counter;
    do {
        x = feistel(x, roundKeys);
    } while (x >= domain); // cycle-walk until the result fits the ID length
    return x;
}

Claiming without waiting

Pods claim IDs with PostgreSQL FOR UPDATE SKIP LOCKED. When one pod has locked a row, the next pod skips it and takes another, so concurrent onboarding requests do not queue behind each other.

-- Simplified sketch.
SELECT id FROM cif_pool
WHERE customer_type = :type AND status = 'AVAILABLE'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

Keeping the pools full

A cron job refills a pool when it drops below a minimum threshold. Each run is capped by a max-batches-per-run setting, so a drained pool cannot send a burst of inserts at the database. A separate reclaim job returns reservations that were left behind when a pod crashed.

Watching it

Micrometer gauges report the depth of every pool, and alerts fire before a pool runs dry.

Tuning the hot table

The allocation table is the busiest one in the flow. Restructuring it cut write amplification, and I dropped a dedup index that had become the throughput ceiling.

Result

Across the pools, the flow issues around 11,000 CIFs a day with no duplicates.

Details are simplified and the client is not named.