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.
- 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:
- Jobs are independent
- Order does not matter
- Workers hold no state
Example: generating a report PDF, resizing an image.
- 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)
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.
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.
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:
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.
-- 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:
- Shard by order id. All messages for the same order go to the same worker and are therefore processed in order.
-
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.
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.
- 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.
- 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.
- 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
| Measure | What it shows | Bad 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
- 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.