4
4
import threading
5
5
import time
6
6
from collections import OrderedDict
7
+ from enum import Enum
7
8
from typing import Any , Callable , Dict , List , Optional , Tuple , Union
8
9
9
10
from redis ._parsers import CommandsParser , Encoder
@@ -482,6 +483,11 @@ class initializer. In the case of conflicting arguments, querystring
482
483
"""
483
484
return cls (url = url , ** kwargs )
484
485
486
+ @deprecated_args (
487
+ args_to_warn = ["read_from_replicas" ],
488
+ reason = "Please configure the 'load_balancing_strategy' instead" ,
489
+ version = "5.0.3" ,
490
+ )
485
491
def __init__ (
486
492
self ,
487
493
host : Optional [str ] = None ,
@@ -492,6 +498,7 @@ def __init__(
492
498
require_full_coverage : bool = False ,
493
499
reinitialize_steps : int = 5 ,
494
500
read_from_replicas : bool = False ,
501
+ load_balancing_strategy : Optional ["LoadBalancingStrategy" ] = None ,
495
502
dynamic_startup_nodes : bool = True ,
496
503
url : Optional [str ] = None ,
497
504
address_remap : Optional [Callable [[Tuple [str , int ]], Tuple [str , int ]]] = None ,
@@ -520,11 +527,16 @@ def __init__(
520
527
cluster client. If not all slots are covered, RedisClusterException
521
528
will be thrown.
522
529
:param read_from_replicas:
530
+ @deprecated - please use load_balancing_strategy instead
523
531
Enable read from replicas in READONLY mode. You can read possibly
524
532
stale data.
525
533
When set to true, read commands will be assigned between the
526
534
primary and its replications in a Round-Robin manner.
527
- :param dynamic_startup_nodes:
535
+ :param load_balancing_strategy:
536
+ Enable read from replicas in READONLY mode and defines the load balancing
537
+ strategy that will be used for cluster node selection.
538
+ The data read from replicas is eventually consistent with the data in primary nodes.
539
+ :param dynamic_startup_nodes:
528
540
Set the RedisCluster's startup nodes to all of the discovered nodes.
529
541
If true (default value), the cluster's discovered nodes will be used to
530
542
determine the cluster nodes-slots mapping in the next topology refresh.
@@ -629,6 +641,7 @@ def __init__(
629
641
self .command_flags = self .__class__ .COMMAND_FLAGS .copy ()
630
642
self .node_flags = self .__class__ .NODE_FLAGS .copy ()
631
643
self .read_from_replicas = read_from_replicas
644
+ self .load_balancing_strategy = load_balancing_strategy
632
645
self .reinitialize_counter = 0
633
646
self .reinitialize_steps = reinitialize_steps
634
647
if event_dispatcher is None :
@@ -683,7 +696,7 @@ def on_connect(self, connection):
683
696
"""
684
697
connection .on_connect ()
685
698
686
- if self .read_from_replicas :
699
+ if self .read_from_replicas or self . load_balancing_strategy :
687
700
# Sending READONLY command to server to configure connection as
688
701
# readonly. Since each cluster node may change its server type due
689
702
# to a failover, we should establish a READONLY connection
@@ -810,6 +823,7 @@ def pipeline(self, transaction=None, shard_hint=None):
810
823
cluster_response_callbacks = self .cluster_response_callbacks ,
811
824
cluster_error_retry_attempts = self .cluster_error_retry_attempts ,
812
825
read_from_replicas = self .read_from_replicas ,
826
+ load_balancing_strategy = self .load_balancing_strategy ,
813
827
reinitialize_steps = self .reinitialize_steps ,
814
828
lock = self ._lock ,
815
829
)
@@ -934,7 +948,9 @@ def _determine_nodes(self, *args, **kwargs) -> List["ClusterNode"]:
934
948
# get the node that holds the key's slot
935
949
slot = self .determine_slot (* args )
936
950
node = self .nodes_manager .get_node_from_slot (
937
- slot , self .read_from_replicas and command in READ_COMMANDS
951
+ slot ,
952
+ self .read_from_replicas and command in READ_COMMANDS ,
953
+ self .load_balancing_strategy if command in READ_COMMANDS else None ,
938
954
)
939
955
return [node ]
940
956
@@ -1158,7 +1174,11 @@ def _execute_command(self, target_node, *args, **kwargs):
1158
1174
# refresh the target node
1159
1175
slot = self .determine_slot (* args )
1160
1176
target_node = self .nodes_manager .get_node_from_slot (
1161
- slot , self .read_from_replicas and command in READ_COMMANDS
1177
+ slot ,
1178
+ self .read_from_replicas and command in READ_COMMANDS ,
1179
+ self .load_balancing_strategy
1180
+ if command in READ_COMMANDS
1181
+ else None ,
1162
1182
)
1163
1183
moved = False
1164
1184
@@ -1307,6 +1327,12 @@ def __del__(self):
1307
1327
self .redis_connection .close ()
1308
1328
1309
1329
1330
+ class LoadBalancingStrategy (Enum ):
1331
+ ROUND_ROBIN = "round_robin"
1332
+ ROUND_ROBIN_REPLICAS = "round_robin_replicas"
1333
+ RANDOM_REPLICA = "random_replica"
1334
+
1335
+
1310
1336
class LoadBalancer :
1311
1337
"""
1312
1338
Round-Robin Load Balancing
@@ -1316,15 +1342,38 @@ def __init__(self, start_index: int = 0) -> None:
1316
1342
self .primary_to_idx = {}
1317
1343
self .start_index = start_index
1318
1344
1319
- def get_server_index (self , primary : str , list_size : int ) -> int :
1320
- server_index = self .primary_to_idx .setdefault (primary , self .start_index )
1321
- # Update the index
1322
- self .primary_to_idx [primary ] = (server_index + 1 ) % list_size
1323
- return server_index
1345
+ def get_server_index (
1346
+ self ,
1347
+ primary : str ,
1348
+ list_size : int ,
1349
+ load_balancing_strategy : LoadBalancingStrategy = LoadBalancingStrategy .ROUND_ROBIN ,
1350
+ ) -> int :
1351
+ if load_balancing_strategy == LoadBalancingStrategy .RANDOM_REPLICA :
1352
+ return self ._get_random_replica_index (list_size )
1353
+ else :
1354
+ return self ._get_round_robin_index (
1355
+ primary ,
1356
+ list_size ,
1357
+ load_balancing_strategy == LoadBalancingStrategy .ROUND_ROBIN_REPLICAS ,
1358
+ )
1324
1359
1325
1360
def reset (self ) -> None :
1326
1361
self .primary_to_idx .clear ()
1327
1362
1363
+ def _get_random_replica_index (self , list_size : int ) -> int :
1364
+ return random .randint (1 , list_size - 1 )
1365
+
1366
+ def _get_round_robin_index (
1367
+ self , primary : str , list_size : int , replicas_only : bool
1368
+ ) -> int :
1369
+ server_index = self .primary_to_idx .setdefault (primary , self .start_index )
1370
+ if replicas_only and server_index == 0 :
1371
+ # skip the primary node index
1372
+ server_index = 1
1373
+ # Update the index for the next round
1374
+ self .primary_to_idx [primary ] = (server_index + 1 ) % list_size
1375
+ return server_index
1376
+
1328
1377
1329
1378
class NodesManager :
1330
1379
def __init__ (
@@ -1428,7 +1477,21 @@ def _update_moved_slots(self):
1428
1477
# Reset moved_exception
1429
1478
self ._moved_exception = None
1430
1479
1431
- def get_node_from_slot (self , slot , read_from_replicas = False , server_type = None ):
1480
+ @deprecated_args (
1481
+ args_to_warn = ["server_type" ],
1482
+ reason = (
1483
+ "In case you need select some load balancing strategy "
1484
+ "that will use replicas, please set it through 'load_balancing_strategy'"
1485
+ ),
1486
+ version = "5.0.3" ,
1487
+ )
1488
+ def get_node_from_slot (
1489
+ self ,
1490
+ slot ,
1491
+ read_from_replicas = False ,
1492
+ load_balancing_strategy = None ,
1493
+ server_type = None ,
1494
+ ):
1432
1495
"""
1433
1496
Gets a node that servers this hash slot
1434
1497
"""
@@ -1443,11 +1506,14 @@ def get_node_from_slot(self, slot, read_from_replicas=False, server_type=None):
1443
1506
f'"require_full_coverage={ self ._require_full_coverage } "'
1444
1507
)
1445
1508
1446
- if read_from_replicas is True :
1447
- # get the server index in a Round-Robin manner
1509
+ if read_from_replicas is True and load_balancing_strategy is None :
1510
+ load_balancing_strategy = LoadBalancingStrategy .ROUND_ROBIN
1511
+
1512
+ if len (self .slots_cache [slot ]) > 1 and load_balancing_strategy :
1513
+ # get the server index using the strategy defined in load_balancing_strategy
1448
1514
primary_name = self .slots_cache [slot ][0 ].name
1449
1515
node_idx = self .read_load_balancer .get_server_index (
1450
- primary_name , len (self .slots_cache [slot ])
1516
+ primary_name , len (self .slots_cache [slot ]), load_balancing_strategy
1451
1517
)
1452
1518
elif (
1453
1519
server_type is None
@@ -1730,7 +1796,7 @@ def __init__(
1730
1796
first command execution. The node will be determined by:
1731
1797
1. Hashing the channel name in the request to find its keyslot
1732
1798
2. Selecting a node that handles the keyslot: If read_from_replicas is
1733
- set to true, a replica can be selected.
1799
+ set to true or load_balancing_strategy is set , a replica can be selected.
1734
1800
1735
1801
:type redis_cluster: RedisCluster
1736
1802
:type node: ClusterNode
@@ -1826,7 +1892,9 @@ def execute_command(self, *args):
1826
1892
channel = args [1 ]
1827
1893
slot = self .cluster .keyslot (channel )
1828
1894
node = self .cluster .nodes_manager .get_node_from_slot (
1829
- slot , self .cluster .read_from_replicas
1895
+ slot ,
1896
+ self .cluster .read_from_replicas ,
1897
+ self .cluster .load_balancing_strategy ,
1830
1898
)
1831
1899
else :
1832
1900
# Get a random node
@@ -1969,6 +2037,7 @@ def __init__(
1969
2037
cluster_response_callbacks : Optional [Dict [str , Callable ]] = None ,
1970
2038
startup_nodes : Optional [List ["ClusterNode" ]] = None ,
1971
2039
read_from_replicas : bool = False ,
2040
+ load_balancing_strategy : Optional [LoadBalancingStrategy ] = None ,
1972
2041
cluster_error_retry_attempts : int = 3 ,
1973
2042
reinitialize_steps : int = 5 ,
1974
2043
lock = None ,
@@ -1984,6 +2053,7 @@ def __init__(
1984
2053
)
1985
2054
self .startup_nodes = startup_nodes if startup_nodes else []
1986
2055
self .read_from_replicas = read_from_replicas
2056
+ self .load_balancing_strategy = load_balancing_strategy
1987
2057
self .command_flags = self .__class__ .COMMAND_FLAGS .copy ()
1988
2058
self .cluster_response_callbacks = cluster_response_callbacks
1989
2059
self .cluster_error_retry_attempts = cluster_error_retry_attempts
0 commit comments