Sertaç Yıldırımfield notes

Home → Engineering

Worker Sharding: The Right Way to Split Work

Four workers are running. There are 200,000 jobs in the queue. You look at the dashboards: one is at 100% CPU, three are nearly idle. Adding workers does not change this picture.

Summary
  • A lock manages collisions; sharding eliminates them. Pick the second when you can.
  • Modulo is the easiest and the most fragile. Change the worker count and most keys move.
  • Distribution depends on the shape of your data. If one customer is 40% of the load, no hash saves you.
  • Rebalancing is where double processing is born. Jobs must be idempotent.

First: is a queue not enough?

In most cases it is, and sharding gets written for nothing. The distinction is simple:

A queue is enough
  • Jobs are independent
  • Order does not matter
  • Workers hold no state

Example: generating a report PDF, resizing an image.

You need sharding
  • Jobs for the same entity must be processed in order
  • The worker keeps state in memory (cache, session)
  • Touching the same entity in parallel corrupts data

Example: movements on a wallet, status transitions of an order.

The key sentence: the point of sharding is not speed, it is removing collisions. With locking, everyone runs to the same door and queues; with sharding, they were already entering through different doors.

Method 1: modulo (easiest, most fragile)

The classic
shard = hash(user_id) % WORKER_COUNT

# worker 2 only processes:
SELECT * FROM jobs
 WHERE status = 'pending'
   AND MOD(hashtext(user_id::text), 4) = 2
 ORDER BY created
 LIMIT 100;

It works, there is no lock, the code is short. Trouble starts when you change scale.

What happens going from 4 to 5

The moment you say % 5 instead of % 4, roughly 80% of keys move to a different worker. That has two consequences:

  • In-memory caches on the workers are thrown away; the system slows down temporarily.
  • If an old worker is still using the old distribution during the transition, the same job can be picked up by two workers.

The sneakiest part: if each worker reads N from its own environment variable and the rollout is gradual, for a while some think it is 4 and others think it is 5.

Method 2: a fixed number of logical shards (best in practice)

My favourite solution and surprisingly simple: decouple the shard count from the worker count. Pick a fixed, generous number — say 64 — and hand out shard numbers to workers.

64 shards, 4 workers
SHARD_COUNT = 64          # never changes
shard = hash(user_id) % 64

# with 4 workers:
worker 0 -> shards 0..15
worker 1 -> shards 16..31
worker 2 -> shards 32..47
worker 3 -> shards 48..63

# add a 5th worker and the shards are redistributed,
# but a key's SHARD does not change. Only its owner does.

The difference is subtle and important: the key → shard mapping stays fixed and only the shard → worker mapping changes. That makes per-shard state, caches and progress information transferable. Kafka's partition model is the same idea.

One rule when choosing the shard count: make it several times the number of workers you can imagine ever running. 64 or 128 is plenty for most systems.

Method 3: a claim table (dynamic and visible)

Instead of fixed shards, workers "claim" the work themselves, in one atomic update:

Atomic claim
UPDATE jobs
   SET owner = :worker_id,
       claim_expires = now() + interval '5 minutes',
       status = 'processing'
 WHERE id IN (
       SELECT id FROM jobs
        WHERE status = 'pending'
           OR (status = 'processing' AND claim_expires < now())  -- a dead worker's job
        ORDER BY created
        LIMIT 50
        FOR UPDATE SKIP LOCKED     -- ← the key line
 )
RETURNING *;

SKIP LOCKED is the trick: it skips without waiting any rows another worker has locked. So workers pick different jobs without ever blocking each other.

The second benefit: thanks to claim_expires, a dead worker's jobs free themselves after five minutes. You do not need a separate health check.

The cost is the loss of ordering: two jobs for the same user can land on different workers. If order matters, this method is not for you.

Two cases from the field

1. One customer, four workers, a hash that did nothing

A notification system was split by customer_id % 4. One enterprise customer alone produced about 40% of the daily notifications.

Result: the worker that customer hashed to was permanently at 100% CPU while the other three sat idle. We raised the worker count to 8 — nothing changed, because that customer still landed on exactly one worker.

The fix: a finer key
-- before
shard = hash(customer_id) % 64

-- after
shard = hash(customer_id + ":" + notification_id) % 64

Now that customer's notifications spread across all shards. We could do this because notifications needed no ordering guarantee. If they had, this fix would not have been available and a dedicated, larger worker for that customer would have been the answer.

2. The day ordering broke

Order status updates were written to a queue: preparing → shipped → delivered. As load grew, the worker count was increased, and a week later support reported something odd: some orders were going back from "delivered" to "shipped".

The reason was straightforward: two messages for the same order had landed on two different workers, and the "shipped" message was processed after the "delivered" message that was produced later.

We fixed it in two layers:

  1. Shard by order id. All messages for the same order go to the same worker and are therefore processed in order.
  2. Guard the transition. Even with sharding, a rebalance can break the order one day, so let the database protect itself too: UPDATE ... WHERE status_rank < :new_rank. A late old message cannot write.

The second point is the same idea as a fencing token: entrust correctness to order, not to timing.

Sharding preserves order most of the time. If "most of the time" is not enough, the protected data itself must reject stale writes.

Rebalancing: the most dangerous moment

When you add or remove workers, shards change hands. In that exact second two workers can believe they own the same shard. It is sharding's most critical moment and its least tested part.

Three defences
  1. Stop before handing over. The new owner should not start until the old owner has confirmed "shard 17 is no longer mine". On Kubernetes that means honouring the shutdown signal and allowing a sensible grace period.
  2. Write idempotent jobs. This one is not negotiable. During a rebalance a job will be processed twice; the system must not be harmed by it.
  3. Manage the distribution from one place. If each worker computes its own share, they will temporarily disagree. Keep the assignment in one place (a table, a coordinator, a lease) and have workers read it from there.

Three things worth measuring

MeasureWhat it showsBad signal
Pending jobs per shard Whether the distribution is even One shard five times the others means a hot key
CPU per worker Whether the load is genuinely split One busy, the rest idle
Time a job waits in the queue Where latency accumulates Good average but bad 95th percentile means one shard is stuck

That third row matters: do not look at the average. With three of four workers idle, average latency looks great; the customer on the congested shard gets their notifications half an hour later.

Checklist

When setting up sharding
  • Do you actually need sharding, or would a plain queue do?
  • Is the shard count independent of the worker count?
  • Does the key choice preserve ordering? (Same entity, same shard?)
  • What percentage of the load is the biggest key in your data? (Have you measured it?)
  • When a worker dies, how long before its shard is taken over?
  • Are jobs idempotent? (Rebalancing will cause double processing.)
  • Does one place decide the distribution, or does each worker compute its own?
  • Do you have per-shard metrics, or only totals?

Conclusion

Sharding is a more elegant answer than locking: instead of managing collisions, it removes them. But it is not free — you have to think about scale changes, dead workers and uneven data.

Back to the opening picture: the reason those three workers were idle was not capacity, it was the choice of key. The hard part of sharding is not writing the code but choosing the right key — and the right key is always chosen by looking at the real distribution of the data, never by guessing.