Load Balancing Reads Across Replicas
A Distributed table does not spread reads evenly by default — it walks each shard’s replica list in a fixed order and hammers the first reachable one until it dies, which is why a three-replica cluster can show one node at 90% CPU while the other two idle. The lever is the load_balancing setting, backed by max_replica_delay_for_distributed_queries and fallback_to_stale_replicas_for_distributed_queries so a busy or lagging replica is skipped instead of served. This page explains each policy, how to pin them per query or per user, and how to prove the traffic actually moved.
Prerequisites
How a distributed read picks a replica
When you query a Distributed table, the initiator node fans the query out to one replica per shard. Which replica it picks per shard is decided entirely by load_balancing. The delay-aware settings act as a filter applied before the policy chooses: any replica whose absolute_delay exceeds max_replica_delay_for_distributed_queries is removed from the candidate list, and only then does the policy rank whoever is left.
Step 1 — Read the current policy and cluster topology
Confirm what each session is using and enumerate the replicas the policy has to choose from.
SELECT name, value FROM system.settings
WHERE name IN (
'load_balancing',
'max_replica_delay_for_distributed_queries',
'fallback_to_stale_replicas_for_distributed_queries',
'prefer_localhost_replica'
);
SELECT cluster, shard_num, replica_num, host_name, is_local
FROM system.clusters
WHERE cluster = 'analytics_cluster'
ORDER BY shard_num, replica_num;
The default load_balancing is random, max_replica_delay_for_distributed_queries is 300 seconds, and both fallback_to_stale_replicas_for_distributed_queries and prefer_localhost_replica default to 1.
Step 2 — Choose a load_balancing policy
Set the policy per session to test it, then promote the winner into the user profile. The four policies trade evenness against cache locality:
-- Even spread, no locality: best for a homogeneous cluster behind one LB.
SET load_balancing = 'random';
-- Route to the topologically closest replica (fewest hostname edits) to cut cross-AZ latency.
SET load_balancing = 'nearest_hostname';
-- Deterministic: always prefer replica 1, fall through only on failure. Predictable cache warmth.
SET load_balancing = 'in_order';
-- Rotate the starting replica each query so long-running sessions spread load over time.
SET load_balancing = 'round_robin';
Use nearest_hostname when replicas span availability zones and you want to keep traffic local; use round_robin to spread a small number of heavy analytical queries; use in_order only when one replica has warmer page cache and you accept uneven load. random remains the safe default for many small queries.
Step 3 — Fence off lagging replicas
A replica that is behind on replication will happily answer a SELECT with stale data unless you cap the tolerated lag. max_replica_delay_for_distributed_queries removes any replica whose absolute_delay exceeds the threshold before the policy runs.
-- Skip any replica more than 30s behind for this workload.
SET max_replica_delay_for_distributed_queries = 30;
-- If EVERY replica of a shard is stale, decide fail vs. serve-stale:
SET fallback_to_stale_replicas_for_distributed_queries = 1; -- 1 = serve stale rather than error
With fallback_to_stale_replicas_for_distributed_queries = 0, a shard whose replicas are all past the delay threshold returns an error instead of stale rows — the right choice for correctness-critical reporting. Leave it at 1 for availability-first dashboards where a slightly stale answer beats a failed query. The replica states these settings read are exactly the ones you repair in executing replica failover DDL.
Step 4 — Control localhost preference
When the initiator node is itself a replica of a shard, prefer_localhost_replica = 1 short-circuits selection and reads locally, skipping a network hop. That is a latency win but it concentrates load on whichever node clients connect to.
-- Force fan-out even to remote replicas so a single entry node does not absorb all shard-local reads.
SET prefer_localhost_replica = 0;
Turn it off when clients all connect through one address and you want that node’s local shard to share load with its peers; keep it on when clients connect round-robin across all nodes.
Step 5 — Pin the policy in the user profile
Session settings vanish on reconnect. Bake the production policy into the user profile so every connection inherits it.
<!-- users.xml / profile -->
<profiles>
<analytics_reader>
<load_balancing>nearest_hostname</load_balancing>
<max_replica_delay_for_distributed_queries>30</max_replica_delay_for_distributed_queries>
<fallback_to_stale_replicas_for_distributed_queries>0</fallback_to_stale_replicas_for_distributed_queries>
<prefer_localhost_replica>0</prefer_localhost_replica>
</analytics_reader>
</profiles>
Verification
Prove the reads actually spread by attributing each shard slice to the replica that ran it. system.query_log records the initiating query; the per-replica child queries carry the real host. Flush logs first, then group by host:
SYSTEM FLUSH LOGS;
SELECT
hostName() AS executed_on,
count() AS query_slices,
round(avg(query_duration_ms)) AS avg_ms
FROM clusterAllReplicas('analytics_cluster', system.query_log)
WHERE type = 'QueryFinish'
AND is_initial_query = 0 -- the fanned-out child queries
AND event_time > now() - INTERVAL 15 MINUTE
GROUP BY executed_on
ORDER BY query_slices DESC;
With random or round_robin the query_slices count should be roughly even across replicas of the same shard. A single host dominating means either in_order is in effect, prefer_localhost_replica is pinning traffic, or the other replicas are being filtered out by the delay threshold — check absolute_delay in system.replicas to tell those apart.
Gotchas and edge cases
max_replica_delaycan silently collapse your read pool. Set it too tight during a merge storm and every replica trips the threshold, so withfallback_to_stale_replicas_for_distributed_queries = 0the whole shard starts erroring. Tune the delay against the real steady-stateabsolute_delayfrom monitoring replication lag with system.replicas, not an arbitrary small number.in_orderplus a flaky replica 1 causes a thundering fallback. Becausein_orderalways tries the same replica first, a replica that flaps up and down makes every query pay a failed-connection timeout before falling through. Useround_robinorrandomfor any replica set that is not rock-solid.load_balancinggoverns reads, not the write path. Insert routing into aDistributedtable is controlled byinsert_distributed_syncand shard weights, not these settings. Do not expectround_robinto spread inserts — it only affects which replica serves each shard slice of aSELECT.- Localhost preference hides uneven client connections. With
prefer_localhost_replica = 1a load test driven from one node looks perfectly balanced in local latency while remote replicas sit idle. Always verify withis_initial_query = 0grouping acrossclusterAllReplicas, not from a single node’s view.
Related
- Executing Replica Failover DDL — bringing a recovered replica back into the read-routing pool.
- Implementing DNS-Based Fallback Routing for Analytics — moving traffic between whole clusters at the discovery layer.
- Recovering from ClickHouse Keeper Quorum Loss — why replicas go stale and get filtered out of the pool.
- Monitoring Replication Lag with system.replicas — the source of the delay numbers your thresholds depend on.