Fanning a query out to every tablet with yb_hash_code
In the post on bucketing a hot index I used
yb_hash_code() to decide where a row lands. It works the other way round too.
Put it in a WHERE clause and YugabyteDB will seek straight into the tablet
which owns that slice of the hash space, so you can hand every worker of a
batch job or a queue its own slice of the table and have them land on different
nodes rather than all piling onto the same one. I had assumed something like
WHERE yb_hash_code = rand(0, 8) would be enough to fan the workers out across
the tservers, so this post checks what the planner actually does with it.
Glossary
- Tablet - the shard YugabyteDB splits a table into, which lives on one node and is replicated to a couple of others
- Tablet leader - the replica which actually serves a tablet’s reads and writes, ie: the node doing the work
- Hash space - the 65,536 hash codes (
0x0000to0xFFFF) a hash sharded row can land on, each tablet owns a contiguous chunk of them yb_hash_code(...)- YSQL function which returns the DocDB hash code of its arguments, which lets you work out which tablet a row lives in- PgGate - the layer in each YSQL backend which turns a Postgres scan into read requests against the tablets
All the numbers below come from a disposable three node cluster running
YugabyteDB 2026.1.2 in Docker, driven by
run-benchmark.sh. The
full output is on the benchmark results page.
How a table maps onto the hash space
Create a table with a hash primary key and six tablets, and YugabyteDB cuts the
hash space into six equal ranges at floor(i * 65536 / 6), then spreads the
leaders of those ranges across your nodes. You can see the result in
yb_tablet_metadata, the same view you will be using later on to work out
your slices.
CREATE TABLE jobs (
id uuid DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL,
payload text NOT NULL,
PRIMARY KEY (id HASH)
) SPLIT INTO 6 TABLETS;
SELECT start_hash_code, end_hash_code, leader
FROM yb_tablet_metadata
WHERE relname = 'jobs'
ORDER BY start_hash_code;
start_hash_code | end_hash_code | leader
-----------------+---------------+--------------
0 | 10922 | ybfan-1:5433
10922 | 21845 | ybfan-1:5433
21845 | 32768 | ybfan-2:5433
32768 | 43690 | ybfan-3:5433
43690 | 54613 | ybfan-3:5433
54613 | 65536 | ybfan-2:5433
The end of each range is exclusive, so a predicate of
yb_hash_code(id) >= 10922 AND yb_hash_code(id) < 21845 covers just the second
tablet.
When a yb_hash_code range becomes a seek
When the arguments you pass to yb_hash_code() are exactly the hash key of a
table or index, in the same order, the planner turns a range on it into
bounds on the scan itself. DocDB is then only asked for the tablets which
overlap that range, and EXPLAIN (ANALYZE, DIST) on the 2 million row jobs
table shows the difference pretty clearly.
-- SELECT id, payload FROM jobs
Seq Scan on jobs
Storage Table Read Requests: 979
Storage Table Read Ops: 1957
Storage Table Rows Scanned: 2000000
-- ... WHERE yb_hash_code(id) >= 10922 AND yb_hash_code(id) < 21845
Index Scan using jobs_pkey on jobs
Index Cond: ((yb_hash_code(id) >= 10922) AND (yb_hash_code(id) < 21845))
Storage Table Read Requests: 326
Storage Table Read Ops: 326
Storage Table Rows Scanned: 333453
-- ... WHERE yb_hash_code(id) >= 5000 AND yb_hash_code(id) < 16000
Index Scan using jobs_pkey on jobs
Index Cond: ((yb_hash_code(id) >= 5000) AND (yb_hash_code(id) < 16000))
Storage Table Read Requests: 177
Storage Table Read Ops: 329
-- ... WHERE yb_hash_code(id) % 6 = 1
Seq Scan on jobs
Filter: ((yb_hash_code(id) % 6) = 1)
Rows Removed by Filter: 1666509
Storage Table Rows Scanned: 2000000
The aligned range sends exactly one op per request, because there is only one
tablet to ask. The range from 5000 to 16000 straddles the first two tablets
and sends two ops per request, one to each, in parallel. The modulus
reads all 2 million rows and throws away five sixths of them, because % 6
scatters the matching codes across the whole space and leaves no range for the
planner to seek to. A modulus is still fine inside an index expression, which
is how the bucketing post used it. However, as a predicate on a plain hash key
it gives the planner nothing to turn into scan bounds, so it reads everything.
Reading the whole table
Before slicing anything by hand it is worth knowing that PgGate already does
part of this fan-out itself. An unordered scan of a hash sharded table creates
one operation per tablet and keeps min(2 * tservers, 16) of them in flight at
once. The logic lives in
PgDocReadOp::PopulateParallelSelectOps,
next to a comment explaining that ordered scans need tablet order and so go
one tablet at a time. However, it cannot spread the rows out again, and every
one of them still comes back through a single Postgres backend on a single
node, which has to decode each row and send it on to your client regardless
of how many tablets it was read from.
I read every row of jobs with one reader doing a plain SELECT, then with
2, 3, 6 and 12 readers. Each reader owned an equal slice of the hash space and
was connected round robin to one of the three nodes.
| Readers | Wall time | vs 1 reader |
|---|---|---|
| 1 | 2,884 ms | 1.0x |
| 2 | 2,054 ms | 1.4x |
| 3 | 1,281 ms | 2.3x |
| 6 | 1,370 ms | 2.1x |
| 12 | 1,387 ms | 2.1x |
The gain stops at three readers, one per node, and I would expect the same on your cluster. Past that point every node already has a backend running and serving its own tablets, so extra readers are just more backends fighting for the same CPUs. There is no point sizing a pool of 64 workers for a nine node cluster if the work is a plain read, since the node count is the limit.
There is also a yb_enable_parallel_scan_hash_sharded setting, off by
default in 2026.1. Turning it on did not get me a parallel plan for this
table, so for now slicing by hand is how you get your backends spread across
the cluster.
A queue of workers
The other use is a job queue polled by many workers with
FOR UPDATE SKIP LOCKED, a pattern which I covered for Postgres in an earlier
post. The problem with a queue is 12
workers all going for the same rows at the same moment, so it is contention you
are trying to spread out this time and bandwidth hardly comes into it.
The setup is a queue table with 1 million rows, 1 in 10 of them pending, and
12 workers spread over the three nodes for 30 seconds. Each worker claims 10
jobs per transaction and marks them done.
UPDATE queue SET status = 'done' WHERE id IN (
SELECT id FROM queue q
WHERE status = 'pending'
AND yb_hash_code(id) >= :slice * 1024
AND yb_hash_code(id) < (:slice + 1) * 1024
LIMIT 10 FOR UPDATE SKIP LOCKED
);
I ran four variants of that, starting from the index you would probably write first.
| Variant | Index | Slice | Claims/s |
|---|---|---|---|
| a | (status HASH, created_at ASC), oldest first |
none | 93 |
| b | (id HASH) WHERE status = 'pending' |
none | 129 |
| c | (id HASH) WHERE status = 'pending' |
random 1 of 6, one per tablet | 367 |
| d | (id HASH) WHERE status = 'pending' |
random 1 of 64 | 1,003 |
No transaction failed in any run.
Variant (a) is the index I would have written first, and it is the worst of the
four. yb_hash_code('pending') is 39657, so every pending entry in the index
sits under one hash code in one tablet with one leader, and all 12 workers
ask that tablet for the same 10 oldest rows. Hashing on id in (b) spreads the
entries across six tablets, but without a slice every worker still starts its
scan from the same place, so they keep racing each other for the same handful
of rows. A random slice per transaction in (c) gives each worker a different
starting point most of the time. Narrowing it to 1 of 64 in (d) makes two
workers sharing a slice rare, so most polls find rows which no other worker is
touching.
That is an 11x difference between (a) and (d) on the same cluster and the same data. Every node sat at its full two CPUs in all four runs, so (a) had exactly as much CPU as (d) and spent it on 12 workers fighting over the same 10 rows. With a replication factor of 3 on three nodes every node also holds a replica of every tablet, so the hot tablet in (a) did not show up as one hot node here, but on a larger cluster it would and that node would then hold back your whole queue.
It is also pretty easy to slice on the wrong thing here. You have to slice on
the hash key of whichever index the scan actually ends up using, which is not
necessarily the table’s primary key. I found this out by giving the queue
table its index on (status HASH, created_at ASC) and filtering it on status
plus a hash code range on id.
Index Scan using queue_status on queue
Index Cond: (status = 'pending'::text)
Filter: ((yb_hash_code(id) >= 10922) AND (yb_hash_code(id) < 21845))
That index is placed by yb_hash_code(status), so the hash code of id tells
DocDB nothing about where to look and the range becomes a plain filter which
runs row by row once the rows are back in Postgres. That is why (b) to (d) slice
on id, since their index is hashed on id.
I also had to pin the partial index with a pg_hint_plan hint, because
FOR UPDATE needs the table row anyway and so the planner preferred to scan
the primary key and filter on status, a plan which walks past every row the
workers have already marked done and by the end of a run meant reading 12,000
rows to find 10.
/*+ IndexScan(q queue_pending) */ UPDATE queue SET status = 'done' WHERE id IN (...);
Slices after the tablets split
Your tablet boundaries will not stay at sixths, since tablets split as they
grow and the split points land wherever the data is. For a batch job it is
worth reading yb_tablet_metadata when the job starts and handing out one
slice per tablet, or per group of tablets led by the same node, which keeps
each reader on one leader. Nothing breaks when a tablet splits mid-run. The
slice just straddles two tablets and PgGate sends two ops instead of one, so
that reader talks to two leaders for the rest of the job and gets the same
rows.
For a queue you do not need to track tablets at all. A fixed number of slices comfortably larger than your worker count, eg: 64 slices of 1,024 codes, keeps workers apart regardless of how the tablets split, and every slice is still a seek into at most a couple of tablets.
Takeaway
- A range on
yb_hash_code()is a seek only when its arguments are exactly the hash key of the table or index being scanned, otherwise it is a row by row filter - Use
>=and<ranges rather than a modulus, since a modulus cannot be turned into scan bounds and reads everything - For bulk reads, one slice per node got me 2.3x over a single
SELECT, and going past one per node did not help - For a
SKIP LOCKEDqueue, index on the job’s hash key rather than onstatus, and give every poll a random slice from many more slices than you have workers - Read
yb_tablet_metadatawhen you need slices to line up with tablets, and do not worry when a split makes one straddle
TL;DR - slicing on yb_hash_code does fan the work out, but rand(0, 8) is far
too few slices, and going from 6 to 64 was worth another 2.7x on its own.