Skip to content

Commit 5cfa218

Browse files
author
Mert Ovun
committed
groupcache: populate hotCache based on reported peer QPS
Previously, getFromPeer mirrored a remotely fetched value into the hotCache on a fixed 10% of fetches, chosen at random. This was a placeholder (noted in a TODO) and is a poor signal: it pollutes the hotCache with one-off keys while taking ~10 round trips on average to mirror a genuinely hot key, defeating the purpose of the hotCache. Use the value's owner as the authority on hotness instead. The owner tracks a per-key request rate using an exponentially weighted moving average and reports it in the GetResponse.MinuteQps field (which the wire format already carried but nobody populated). A non-owning peer mirrors a key into its hotCache only once the owner reports a rate at or above hotQPS. The per-key rate state lives inside the mainCache entry, so it is bounded by cache residency and cleaned up by ordinary eviction, with no separate accounting to evict. This replaces the test-only *rand.Rand hook (added in #175) with an injectable clock, which TestPeers and the new stats tests use to drive QPS deterministically.
1 parent 6eb08e7 commit 5cfa218

5 files changed

Lines changed: 229 additions & 27 deletions

File tree

groupcache.go

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,21 @@ package groupcache
2727
import (
2828
"context"
2929
"errors"
30-
"math/rand"
3130
"strconv"
3231
"sync"
3332
"sync/atomic"
33+
"time"
3434

3535
pb "github.com/golang/groupcache/groupcachepb"
3636
"github.com/golang/groupcache/lru"
3737
"github.com/golang/groupcache/singleflight"
3838
)
3939

40+
// hotQPS is the request rate, in requests per qpsWindow as seen by a key's
41+
// owner, above which non-owning peers begin mirroring the key into their
42+
// hotCache to shed load from the owner.
43+
const hotQPS = 10
44+
4045
// A Getter loads data for a key.
4146
type Getter interface {
4247
// Get returns the value identified by key, populating dest.
@@ -103,6 +108,7 @@ func newGroup(name string, cacheBytes int64, getter Getter, peers PeerPicker) *G
103108
cacheBytes: cacheBytes,
104109
loadGroup: &singleflight.Group{},
105110
}
111+
g.mainCache.trackQPS = true
106112
if fn := newGroupHook; fn != nil {
107113
fn(g)
108114
}
@@ -172,9 +178,16 @@ type Group struct {
172178
// Stats are statistics on the group.
173179
Stats Stats
174180

175-
// rand is only non-nil when testing,
176-
// to get predictable results in TestPeers.
177-
rand *rand.Rand
181+
// now returns the current time. It is overridden in tests to make
182+
// the QPS-driven hotCache population deterministic.
183+
now func() time.Time
184+
}
185+
186+
func (g *Group) timeNow() time.Time {
187+
if g.now != nil {
188+
return g.now()
189+
}
190+
return time.Now()
178191
}
179192

180193
// flightGroup is defined as an interface which flightgroup.Group
@@ -215,6 +228,7 @@ func (g *Group) Get(ctx context.Context, key string, dest Sink) error {
215228
if dest == nil {
216229
return errors.New("groupcache: nil dest Sink")
217230
}
231+
g.mainCache.recordRequest(key, g.timeNow())
218232
value, cacheHit := g.lookupCache(key)
219233

220234
if cacheHit {
@@ -316,16 +330,7 @@ func (g *Group) getFromPeer(ctx context.Context, peer ProtoGetter, key string) (
316330
return ByteView{}, err
317331
}
318332
value := ByteView{b: res.Value}
319-
// TODO(bradfitz): use res.MinuteQps or something smart to
320-
// conditionally populate hotCache. For now just do it some
321-
// percentage of the time.
322-
var pop bool
323-
if g.rand != nil {
324-
pop = g.rand.Intn(10) == 0
325-
} else {
326-
pop = rand.Intn(10) == 0
327-
}
328-
if pop {
333+
if res.GetMinuteQps() >= hotQPS {
329334
g.populateCache(key, value, &g.hotCache)
330335
}
331336
return value, nil
@@ -398,13 +403,24 @@ func (g *Group) CacheStats(which CacheType) CacheStats {
398403
// makes values always be ByteView, and counts the size of all keys and
399404
// values.
400405
type cache struct {
401-
mu sync.RWMutex
402-
nbytes int64 // of all keys and values
403-
lru *lru.Cache
406+
mu sync.RWMutex
407+
nbytes int64 // of all keys and values
408+
lru *lru.Cache
409+
// trackQPS records a per-key request rate for owner-side hotness
410+
// reporting. It is enabled only on a group's mainCache.
411+
trackQPS bool
404412
nhit, nget int64
405413
nevict int64 // number of evictions
406414
}
407415

416+
// cacheValue is the value stored in the underlying LRU. Its optional stats
417+
// live and die with the entry, so per-key rate tracking is bounded by cache
418+
// residency and cleaned up by ordinary eviction.
419+
type cacheValue struct {
420+
view ByteView
421+
stats *keyStats
422+
}
423+
408424
func (c *cache) stats() CacheStats {
409425
c.mu.RLock()
410426
defer c.mu.RUnlock()
@@ -423,13 +439,17 @@ func (c *cache) add(key string, value ByteView) {
423439
if c.lru == nil {
424440
c.lru = &lru.Cache{
425441
OnEvicted: func(key lru.Key, value interface{}) {
426-
val := value.(ByteView)
427-
c.nbytes -= int64(len(key.(string))) + int64(val.Len())
442+
cv := value.(*cacheValue)
443+
c.nbytes -= int64(len(key.(string))) + int64(cv.view.Len())
428444
c.nevict++
429445
},
430446
}
431447
}
432-
c.lru.Add(key, value)
448+
cv := &cacheValue{view: value}
449+
if c.trackQPS {
450+
cv.stats = &keyStats{}
451+
}
452+
c.lru.Add(key, cv)
433453
c.nbytes += int64(len(key)) + int64(value.Len())
434454
}
435455

@@ -445,7 +465,39 @@ func (c *cache) get(key string) (value ByteView, ok bool) {
445465
return
446466
}
447467
c.nhit++
448-
return vi.(ByteView), true
468+
return vi.(*cacheValue).view, true
469+
}
470+
471+
// recordRequest registers one request for key against its tracked rate, if the
472+
// key is present and tracked.
473+
func (c *cache) recordRequest(key string, now time.Time) {
474+
c.stats0(key, now, true)
475+
}
476+
477+
// peekQPS returns key's estimated requests-per-qpsWindow without registering a
478+
// new request, or 0 if the key is absent or untracked.
479+
func (c *cache) peekQPS(key string, now time.Time) float64 {
480+
return c.stats0(key, now, false)
481+
}
482+
483+
func (c *cache) stats0(key string, now time.Time, record bool) float64 {
484+
c.mu.RLock()
485+
defer c.mu.RUnlock()
486+
if c.lru == nil {
487+
return 0
488+
}
489+
vi, ok := c.lru.Peek(key)
490+
if !ok {
491+
return 0
492+
}
493+
stats := vi.(*cacheValue).stats
494+
if stats == nil {
495+
return 0
496+
}
497+
if record {
498+
return stats.touch(now)
499+
}
500+
return stats.peek(now)
449501
}
450502

451503
func (c *cache) removeOldest() {

groupcache_test.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"errors"
2424
"fmt"
2525
"hash/crc32"
26-
"math/rand"
2726
"reflect"
2827
"sync"
2928
"testing"
@@ -229,6 +228,7 @@ func TestCacheEviction(t *testing.T) {
229228
type fakePeer struct {
230229
hits int
231230
fail bool
231+
qps float64 // reported owner-side request rate
232232
}
233233

234234
func (p *fakePeer) Get(_ context.Context, in *pb.GetRequest, out *pb.GetResponse) error {
@@ -237,6 +237,7 @@ func (p *fakePeer) Get(_ context.Context, in *pb.GetRequest, out *pb.GetResponse
237237
return errors.New("simulated error from peer")
238238
}
239239
out.Value = []byte("got:" + in.GetKey())
240+
out.MinuteQps = &p.qps
240241
return nil
241242
}
242243

@@ -264,7 +265,6 @@ func TestPeers(t *testing.T) {
264265
return dest.SetString("got:" + key)
265266
}
266267
testGroup := newGroup("TestPeers-group", cacheSize, GetterFunc(getter), peerList)
267-
testGroup.rand = rand.New(rand.NewSource(123))
268268
run := func(name string, n int, wantSummary string) {
269269
// Reset counters
270270
localHits = 0
@@ -303,9 +303,21 @@ func TestPeers(t *testing.T) {
303303
resetCacheSize(1 << 20)
304304
run("base", 200, "localHits = 49, peers = 51 49 51")
305305

306-
// Verify cache was hit. All localHits are gone, and some of
307-
// the peer hits (the ones randomly selected to be maybe hot)
308-
run("cached_base", 200, "localHits = 0, peers = 49 47 48")
306+
// Peers report cold keys, so nothing is mirrored into hotCache.
307+
// Locally-owned keys are served from mainCache (no local hits),
308+
// but peer-owned keys still require a peer fetch every time.
309+
run("cached_base", 200, "localHits = 0, peers = 51 49 51")
310+
311+
// Peers now report hot keys, so peer-owned keys are mirrored into
312+
// hotCache on this pass...
313+
for _, p := range []*fakePeer{peer0, peer1, peer2} {
314+
p.qps = hotQPS
315+
}
316+
run("warm_hot", 200, "localHits = 0, peers = 51 49 51")
317+
318+
// ...and the subsequent pass serves them all from hotCache.
319+
run("cached_hot", 200, "localHits = 0, peers = 0 0 0")
320+
309321
resetCacheSize(0)
310322

311323
// With one of the peers being down.

http.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ func (p *HTTPPool) ServeHTTP(w http.ResponseWriter, r *http.Request) {
174174
}
175175

176176
// Write the value to the response body as a proto message.
177-
body, err := proto.Marshal(&pb.GetResponse{Value: value})
177+
qps := group.mainCache.peekQPS(key, group.timeNow())
178+
body, err := proto.Marshal(&pb.GetResponse{Value: value, MinuteQps: &qps})
178179
if err != nil {
179180
http.Error(w, err.Error(), http.StatusInternalServerError)
180181
return

stats.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
Copyright 2012 Google Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package groupcache
18+
19+
import (
20+
"math"
21+
"sync"
22+
"time"
23+
)
24+
25+
// qpsWindow is the half-life over which request rate is measured.
26+
const qpsWindow = time.Minute
27+
28+
// keyStats estimates the per-minute request rate for a single key using an
29+
// exponentially weighted moving average. Each observation decays toward zero
30+
// with a half-life of qpsWindow, so a key that stops being requested fades out
31+
// of "hotness" on its own.
32+
type keyStats struct {
33+
mu sync.Mutex
34+
rate float64
35+
stamp time.Time
36+
}
37+
38+
// touch records one request at time now and returns the resulting estimated
39+
// rate in requests per qpsWindow.
40+
func (k *keyStats) touch(now time.Time) float64 {
41+
k.mu.Lock()
42+
defer k.mu.Unlock()
43+
k.decay(now)
44+
k.rate++
45+
k.stamp = now
46+
return k.rate
47+
}
48+
49+
// peek returns the estimated rate at time now without recording a request.
50+
func (k *keyStats) peek(now time.Time) float64 {
51+
k.mu.Lock()
52+
defer k.mu.Unlock()
53+
k.decay(now)
54+
k.stamp = now
55+
return k.rate
56+
}
57+
58+
func (k *keyStats) decay(now time.Time) {
59+
if !k.stamp.IsZero() {
60+
k.rate *= math.Exp2(-float64(now.Sub(k.stamp)) / float64(qpsWindow))
61+
}
62+
}

stats_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
Copyright 2012 Google Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package groupcache
18+
19+
import (
20+
"testing"
21+
"time"
22+
)
23+
24+
func TestKeyStatsRampUp(t *testing.T) {
25+
var k keyStats
26+
now := time.Unix(0, 0)
27+
if got := k.touch(now); got != 1 {
28+
t.Fatalf("first touch = %v; want 1", got)
29+
}
30+
// Many requests within the same instant accumulate without decay.
31+
for i := 0; i < 9; i++ {
32+
k.touch(now)
33+
}
34+
if got := k.peek(now); got != 10 {
35+
t.Errorf("rate after 10 instantaneous touches = %v; want 10", got)
36+
}
37+
}
38+
39+
func TestKeyStatsDecay(t *testing.T) {
40+
var k keyStats
41+
now := time.Unix(0, 0)
42+
k.touch(now)
43+
// After one half-life with no requests, the rate should halve.
44+
if got := k.peek(now.Add(qpsWindow)); got != 0.5 {
45+
t.Errorf("rate after one window = %v; want 0.5", got)
46+
}
47+
// And after another, halve again.
48+
if got := k.peek(now.Add(2 * qpsWindow)); got != 0.25 {
49+
t.Errorf("rate after two windows = %v; want 0.25", got)
50+
}
51+
}
52+
53+
func TestCacheQPSUntracked(t *testing.T) {
54+
// hotCache does not track QPS, so peekQPS is always 0.
55+
var c cache
56+
c.add("k", ByteView{s: "v"})
57+
if got := c.peekQPS("k", time.Unix(0, 0)); got != 0 {
58+
t.Errorf("untracked peekQPS = %v; want 0", got)
59+
}
60+
if got := c.peekQPS("absent", time.Unix(0, 0)); got != 0 {
61+
t.Errorf("absent peekQPS = %v; want 0", got)
62+
}
63+
}
64+
65+
func TestCacheQPSTracked(t *testing.T) {
66+
c := cache{trackQPS: true}
67+
c.add("k", ByteView{s: "v"})
68+
now := time.Unix(0, 0)
69+
for i := 0; i < 5; i++ {
70+
c.recordRequest("k", now)
71+
}
72+
if got := c.peekQPS("k", now); got != 5 {
73+
t.Errorf("tracked peekQPS = %v; want 5", got)
74+
}
75+
}

0 commit comments

Comments
 (0)