Skip to content

Commit a29827e

Browse files
committed
Add latency-aware cluster read balancing
1 parent 88d16d0 commit a29827e

7 files changed

Lines changed: 589 additions & 8 deletions

File tree

benchmarks/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,20 @@ Stop the example stack when finished:
123123
```shell
124124
docker compose -f docs/examples/opentelemetry/docker-compose.yml down
125125
```
126+
127+
## Cluster Latency-Aware Read Balancing
128+
129+
`cluster_latency_load_balancing.py` compares round-robin reads with
130+
`LoadBalancingStrategy.LATENCY_BASED` on one fixed key slot. It adds a
131+
configurable delay to one replica's async client command path, then reports
132+
that replica's selection share and the measured p99 latency:
133+
134+
```shell
135+
python -m benchmarks.cluster_latency_load_balancing \
136+
--host 127.0.0.1 --port 7000 --delay-ms 10 \
137+
--requests 2000 --concurrency 32
138+
```
139+
140+
The cluster must expose at least one replica for the selected key slot and
141+
must permit read-only connections. Pass `--delayed-node HOST:PORT` to choose a
142+
specific replica; otherwise the last replica in the slot's topology is used.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Compare round-robin and latency-aware reads on a Redis cluster.
2+
3+
The benchmark injects delay into one replica's client-side command path. This
4+
keeps the setup small and repeatable while exercising the same async
5+
``RedisCluster`` selection and measurement paths used in production.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import asyncio
12+
import math
13+
import time
14+
from collections import Counter
15+
from dataclasses import dataclass, field
16+
17+
from redis.asyncio.cluster import ClusterNode, RedisCluster
18+
from redis.cluster import LoadBalancingStrategy
19+
20+
21+
@dataclass
22+
class Measurements:
23+
counts: Counter[str] = field(default_factory=Counter)
24+
latencies: list[float] = field(default_factory=list)
25+
26+
@property
27+
def p99_ms(self) -> float:
28+
if not self.latencies:
29+
return 0.0
30+
ordered = sorted(self.latencies)
31+
index = min(len(ordered) - 1, math.ceil(len(ordered) * 0.99) - 1)
32+
return ordered[index] * 1000
33+
34+
35+
async def run_mode(
36+
host: str,
37+
port: int,
38+
key: str,
39+
requests: int,
40+
concurrency: int,
41+
delay_ms: float,
42+
strategy: LoadBalancingStrategy,
43+
delayed_node_name: str | None,
44+
) -> tuple[str, Measurements, str]:
45+
client = await RedisCluster(
46+
host=host,
47+
port=port,
48+
load_balancing_strategy=strategy,
49+
)
50+
slot_nodes = client.nodes_manager.slots_cache[client.keyslot(key)]
51+
replicas = slot_nodes[1:]
52+
if not replicas:
53+
await client.aclose()
54+
raise RuntimeError("the selected key slot must have at least one replica")
55+
56+
delayed_node = next(
57+
(node for node in replicas if node.name == delayed_node_name),
58+
replicas[-1] if delayed_node_name is None else None,
59+
)
60+
if delayed_node is None:
61+
await client.aclose()
62+
raise ValueError(
63+
f"node {delayed_node_name!r} is not a replica for key slot "
64+
f"{client.keyslot(key)}"
65+
)
66+
67+
measurements = Measurements()
68+
original_execute_command = ClusterNode.execute_command
69+
70+
async def delayed_execute_command(node, *args, **kwargs):
71+
started = time.perf_counter()
72+
if args and args[0] == "GET":
73+
measurements.counts[node.name] += 1
74+
if node.name == delayed_node.name:
75+
await asyncio.sleep(delay_ms / 1000)
76+
try:
77+
return await original_execute_command(node, *args, **kwargs)
78+
finally:
79+
if args and args[0] == "GET":
80+
measurements.latencies.append(time.perf_counter() - started)
81+
82+
ClusterNode.execute_command = delayed_execute_command
83+
try:
84+
requests_per_worker, remainder = divmod(requests, concurrency)
85+
86+
async def worker(worker_index: int) -> None:
87+
worker_requests = requests_per_worker + (worker_index < remainder)
88+
for _ in range(worker_requests):
89+
await client.get(key)
90+
91+
await asyncio.gather(*(worker(i) for i in range(concurrency)))
92+
finally:
93+
ClusterNode.execute_command = original_execute_command
94+
await client.aclose()
95+
96+
return strategy.value, measurements, delayed_node.name
97+
98+
99+
def parse_args() -> argparse.Namespace:
100+
parser = argparse.ArgumentParser(description=__doc__)
101+
parser.add_argument("--host", default="127.0.0.1")
102+
parser.add_argument("--port", type=int, default=6379)
103+
parser.add_argument("--key", default="{latency-benchmark}:key")
104+
parser.add_argument("--requests", type=int, default=2000)
105+
parser.add_argument("--concurrency", type=int, default=32)
106+
parser.add_argument("--delay-ms", type=float, default=10.0)
107+
parser.add_argument("--delayed-node")
108+
return parser.parse_args()
109+
110+
111+
async def main(args: argparse.Namespace) -> None:
112+
if args.requests < args.concurrency:
113+
raise ValueError("--requests must be at least --concurrency")
114+
115+
for strategy in (
116+
LoadBalancingStrategy.ROUND_ROBIN,
117+
LoadBalancingStrategy.LATENCY_BASED,
118+
):
119+
name, measurements, delayed_node = await run_mode(
120+
host=args.host,
121+
port=args.port,
122+
key=args.key,
123+
requests=args.requests,
124+
concurrency=args.concurrency,
125+
delay_ms=args.delay_ms,
126+
strategy=strategy,
127+
delayed_node_name=args.delayed_node,
128+
)
129+
total = sum(measurements.counts.values())
130+
delayed_share = measurements.counts[delayed_node] / total * 100
131+
print(
132+
f"strategy={name:<14} delayed_node={delayed_node:<24} "
133+
f"delayed_share={delayed_share:6.2f}% p99={measurements.p99_ms:8.3f}ms"
134+
)
135+
136+
137+
if __name__ == "__main__":
138+
asyncio.run(main(parse_args()))

docs/clustering.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ the primary and its replications in a Round-Robin manner.
223223
With load_balancing_strategy you can define a custom strategy for
224224
assigning read commands to the replicas and primary nodes.
225225
226+
``LoadBalancingStrategy.LATENCY_BASED`` uses power-of-two choices and a
227+
peak-sensitive latency estimate to prefer the less busy of two sampled
228+
nodes. The strategy is opt-in; existing round-robin and random strategies
229+
are unchanged. Latency measurements are collected separately for each
230+
cluster node and are shared by regular commands and pipelines.
231+
232+
.. code:: python
233+
234+
>>> from redis.cluster import LoadBalancingStrategy, RedisCluster
235+
>>> rc = RedisCluster(
236+
... startup_nodes=startup_nodes,
237+
... load_balancing_strategy=LoadBalancingStrategy.LATENCY_BASED,
238+
... )
239+
226240
READONLY mode can be set at runtime by calling the readonly() method
227241
with target_nodes=‘replicas’, and read-write access can be restored by
228242
calling the readwrite() method.

redis/asyncio/cluster.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,8 @@ async def _execute_command(
12051205

12061206
while ttl > 0:
12071207
ttl -= 1
1208+
tracked_node_name = None
1209+
attempt_start_time = None
12081210
try:
12091211
if asking:
12101212
target_node = self.get_node(node_name=redirect_addr)
@@ -1223,6 +1225,15 @@ async def _execute_command(
12231225
)
12241226
moved = False
12251227

1228+
if (
1229+
self.load_balancing_strategy == LoadBalancingStrategy.LATENCY_BASED
1230+
and command in READ_COMMANDS
1231+
):
1232+
tracked_node_name = target_node.name
1233+
attempt_start_time = time.monotonic()
1234+
self.nodes_manager.read_load_balancer.start_request(
1235+
tracked_node_name
1236+
)
12261237
response = await target_node.execute_command(*args, **kwargs)
12271238
await self._record_command_metric(
12281239
command_name=command,
@@ -1370,6 +1381,11 @@ async def _execute_command(
13701381
error=e,
13711382
)
13721383
raise
1384+
finally:
1385+
if tracked_node_name is not None and attempt_start_time is not None:
1386+
self.nodes_manager.read_load_balancer.record_latency(
1387+
tracked_node_name, time.monotonic() - attempt_start_time
1388+
)
13731389

13741390
e = ClusterError("TTL exhausted.")
13751391
e.connection = target_node
@@ -2066,7 +2082,10 @@ def get_node_from_slot(
20662082
# get the server index using the strategy defined in load_balancing_strategy
20672083
primary_name = self.slots_cache[slot][0].name
20682084
node_idx = self.read_load_balancer.get_server_index(
2069-
primary_name, len(self.slots_cache[slot]), load_balancing_strategy
2085+
primary_name,
2086+
len(self.slots_cache[slot]),
2087+
load_balancing_strategy,
2088+
nodes=self.slots_cache[slot],
20702089
)
20712090
return self.slots_cache[slot][node_idx]
20722091
return self.slots_cache[slot][0]
@@ -2793,10 +2812,27 @@ async def _execute(
27932812
# Start timing for observability
27942813
start_time = time.monotonic()
27952814

2815+
async def execute_node_pipeline(node, commands):
2816+
track_latency = (
2817+
client.load_balancing_strategy == LoadBalancingStrategy.LATENCY_BASED
2818+
and any(command.args[0] in READ_COMMANDS for command in commands)
2819+
)
2820+
if not track_latency:
2821+
return await node.execute_pipeline(commands)
2822+
2823+
client.nodes_manager.read_load_balancer.start_request(node.name)
2824+
start_time = time.monotonic()
2825+
try:
2826+
return await node.execute_pipeline(commands)
2827+
finally:
2828+
client.nodes_manager.read_load_balancer.record_latency(
2829+
node.name, time.monotonic() - start_time
2830+
)
2831+
27962832
errors = await asyncio.gather(
27972833
*(
2798-
asyncio.create_task(node[0].execute_pipeline(node[1]))
2799-
for node in nodes.values()
2834+
asyncio.create_task(execute_node_pipeline(node, commands))
2835+
for node, commands in nodes.values()
28002836
)
28012837
)
28022838

0 commit comments

Comments
 (0)