Skip to content

Commit 6e3a0e1

Browse files
committed
Spread hot key reads across replicas by randomising replica selection in Get and BulkGet
1 parent 61bab9c commit 6e3a0e1

2 files changed

Lines changed: 59 additions & 4 deletions

File tree

load_test/locust.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,55 @@ def bulk_get(self):
185185
@task(1)
186186
def health(self):
187187
self.client.get("/health", name="/health")
188+
189+
190+
# ---------------------------------------------------------------------------
191+
# Hot key user — hammers a single key to verify read load is spread across
192+
# replicas rather than always hitting the same aux node.
193+
# ---------------------------------------------------------------------------
194+
HOT_KEY = "hotkey:burn"
195+
HOT_VALUE = "hot-key-test-value"
196+
197+
198+
class HotKeyUser(HttpUser):
199+
weight = 10
200+
wait_time = between(0.01, 0.05)
201+
202+
def on_start(self):
203+
"""Seed the hot key once when this user starts."""
204+
self.client.post(
205+
"/data",
206+
json={"key": HOT_KEY, "value": HOT_VALUE},
207+
name="/data PUT (hot-key seed)",
208+
)
209+
210+
@task(10)
211+
def read_hot_key(self):
212+
"""Hammer a single key — all reads resolve to the same hash-ring slot."""
213+
with self.client.get(
214+
f"/data/{HOT_KEY}",
215+
name="/data/[hotkey] GET",
216+
catch_response=True,
217+
) as resp:
218+
if resp.status_code == 200:
219+
body = resp.json()
220+
if body.get("value") != HOT_VALUE:
221+
resp.failure(
222+
f"Hot key value mismatch: got {body.get('value')!r}"
223+
)
224+
else:
225+
resp.success()
226+
elif resp.status_code == 404:
227+
# May happen briefly after eviction under memory pressure
228+
resp.success()
229+
else:
230+
resp.failure(f"Unexpected status {resp.status_code}")
231+
232+
@task(1)
233+
def refresh_hot_key(self):
234+
"""Periodically re-seed so the key survives LRU eviction."""
235+
self.client.post(
236+
"/data",
237+
json={"key": HOT_KEY, "value": HOT_VALUE},
238+
name="/data PUT (hot-key refresh)",
239+
)

master/controller.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"log"
10+
"math/rand"
1011
"net/http"
1112
"os"
1213
"strconv"
@@ -188,7 +189,8 @@ func (m *Master) Get(w http.ResponseWriter, r *http.Request) {
188189
return
189190
}
190191

191-
// Try replicas in order; return on the first successful hit.
192+
// Shuffle replicas so reads are spread across all replicas, not always hitting node[0].
193+
rand.Shuffle(len(nodes), func(i, j int) { nodes[i], nodes[j] = nodes[j], nodes[i] })
192194
for _, node := range nodes {
193195
resp, err := m.client.Get(fmt.Sprintf("http://%s/data/%s", node, key))
194196
if err != nil {
@@ -310,15 +312,16 @@ func (m *Master) BulkGet(w http.ResponseWriter, r *http.Request) {
310312
return
311313
}
312314

313-
// Group keys by primary node.
315+
// Group keys by a randomly chosen replica so hot keys spread across replicas.
314316
groups := make(map[string][]string)
315317
for _, key := range keys {
316-
nodes, err := m.hashring.GetNodes(key, 1)
318+
nodes, err := m.hashring.GetNodes(key, m.replicationFactor)
317319
if err != nil {
318320
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
319321
return
320322
}
321-
groups[nodes[0]] = append(groups[nodes[0]], key)
323+
chosen := nodes[rand.Intn(len(nodes))]
324+
groups[chosen] = append(groups[chosen], key)
322325
}
323326

324327
type nodeResult struct {

0 commit comments

Comments
 (0)