-
Notifications
You must be signed in to change notification settings - Fork 2.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
cache: implement single-flight caching in the inmemory cache #4379
cache: implement single-flight caching in the inmemory cache #4379
Conversation
Implement a single-flight mode for the inmemory cache. Since our code goes and does something when Fetch() misses, let's implement the single-flight mode based on this assumption - wait for the results of the previous request and use it. https://pkg.go.dev/golang.org/x/sync/singleflight is not used to avoid complexity - there is no single function that we can call to get results of some operation. Potentially it could be wrapped around the `Bucket` interface but then the caching/remote object storage interfaces would be muddled together and I really think we should avoid that. Tested on a cluster and with unit tests. Signed-off-by: Giedrius Statkevičius <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting approach. I think I like it generally. Have you tried run any benchmarks with it? (:
Signed-off-by: Giedrius Statkevičius <[email protected]>
I don't write benchmarks every day but this small benchmark that I wrote only shows a difference with high concurrency for the same keys:
This is expected since we now do some locking 🤔 But it potentially saves lots of requests and reduces costs so it's kind of like a no brainer 😄 |
706f07b
to
033ae20
Compare
Signed-off-by: Giedrius Statkevičius <[email protected]>
033ae20
to
af0c1e8
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like a good optimisation to make 👍
The main questions I have is:
- Do we need this? Are we getting a big enough increase in performance to justify the non-trivial code overhead? I would hope that the benchmarks will tell us this concretely.
Aside from that it took me quite a while to figure out what is single-flighting
and what does it do for us. In fact - I'm still not 100% sure 😅 It would be good to explicitly spell this out both in the PR and the code.
pkg/cache/inmemory.go
Outdated
added prometheus.Counter | ||
current prometheus.Gauge | ||
sfSaved prometheus.Counter | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: unintentional newline?
pkg/cache/inmemory.go
Outdated
|
||
c.mu.Lock() | ||
|
||
if c.subs[key] == nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is just checking for key
's membership of map subs
then let's be consistent with L300
if val, ok := c.subs[key]; !ok {
continue
}
pkg/cache/inmemory.go
Outdated
} | ||
|
||
c.mu.Lock() | ||
if c.subs[key] == nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same comment as above RE membership tests
g := &errgroup.Group{} | ||
|
||
g.Go(func() error { | ||
hits := c.Fetch(context.Background(), testKeys) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hits := c.Fetch(context.Background(), testKeys) | |
// This blocks :( | |
hits := c.Fetch(context.Background(), testKeys) |
To be consistent with the other test cases :)
return nil | ||
}) | ||
|
||
time.Sleep(1 * time.Second) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need these sleeps? Magic sleeps always make me wonder what wasn't working before these were added 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To wait until the previous goroutine spawned and blocked on the Fetch() call. In practice, that will take only a few nano/milliseconds. It would be hard to implement some kind of synchronization primitive here because we are actually calling the caching code here hence a simple time.Sleep()
has been added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time.Sleep(1 * time.Second) | |
// We need to wait until the previous goroutine has spawned and blocked on the Fetch() call. In practice, this will take only a few nano/milliseconds, so we wait for that to happen. | |
time.Sleep(1 * time.Second) |
Ok - let's make that explicit then 👍
|
||
ctx, cancel := context.WithCancel(context.Background()) | ||
|
||
const testKey = "test" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: does this need to be a const
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not? It's constant and doesn't change. Ideally this would be a const slice but no such things exist in Go (:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Haha fair point - on the whole I only really see const
s defined at the package-scope instead of in the function-scope. Not a blocker ¯\_(ツ)_/¯
}) | ||
cancel() | ||
|
||
time.Sleep(1 * time.Second) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto - are these magic sleeps needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, because we want to wait until c.Fetch() has been called. In practice, we should only wait for a millisecond or so but a second is here to be on the safe side.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time.Sleep(1 * time.Second) | |
// We need to wait until c.Fetch() has been called. In practice, we should only wait for a millisecond or so but a second is here to be on the safe side. | |
time.Sleep(1 * time.Second) |
continue | ||
} | ||
|
||
c.mu.Lock() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it ok to be sharing this mutex across the read and write path? The naive argument would say - might this actually increase the time it takes to respond as we're waiting around a bunch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading and writing from a map
from more than one goroutine panic
s the whole program hence this lock is needed. Yes, the benchmarks show a negligible increase in the time it takes to respond however those extra nanoseconds are much smaller than requesting data from the remote object storage
From my tests, the in-memory cache blows away all other options with this improvement because Thanos Store will try harder to avoid expensive read operations which is what's causing the sluggishness. Of course, it doesn't work well with multiple nodes. Ideally, we'd have #2962 but the library there needs extra improvements. Also, you can see this in action by opening some dashboards with this feature. The counter jumps to a hundred and thousands very quickly. In fact, single-flight functionality for caches (not just the simple, in-memory one) is so good that I would argue that this needs to be the default. But this PR adds a toggle switch for it and users who do not want this will have no impact on the performance. So, I would really like to see this get merged. EDIT: there's also #4407 which is tangentially related so it's another statistical point in favor of this albeit it is anecdotal. |
Signed-off-by: Giedrius Statkevičius <[email protected]>
…leflight Signed-off-by: Giedrius Statkevičius <[email protected]>
Signed-off-by: Giedrius Statkevičius <[email protected]>
@thanos-io/thanos-maintainers could you PTAL? I'd love to get this into the next release :( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a functional point of view - I'm confident that this change does what it says it does. I do still think we are missing some benchmarks to make it obvious beyond any reasonable doubt that the increase in complexity is worth the performance boost.
From a maintainability perspective, I think there are still some improvements to be made.
This is a non-trivial bit of code that has taken me ~30 mins on both reviews to understand and conceptualise how it fits into the wider read & write paths. If the code is easy to understand, people will maintain it and vice versa - what if Giedrus was hit by a bus?
Personally, I would like to see extra context in the comments and ideally a sequence diagram that explains why we are making this change.
I'm not going to block this PR as I'm still pretty inexperienced in the code base, but that is my 2c 🤗
return nil | ||
}) | ||
|
||
time.Sleep(1 * time.Second) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time.Sleep(1 * time.Second) | |
// We need to wait until the previous goroutine has spawned and blocked on the Fetch() call. In practice, this will take only a few nano/milliseconds, so we wait for that to happen. | |
time.Sleep(1 * time.Second) |
Ok - let's make that explicit then 👍
|
||
ctx, cancel := context.WithCancel(context.Background()) | ||
|
||
const testKey = "test" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Haha fair point - on the whole I only really see const
s defined at the package-scope instead of in the function-scope. Not a blocker ¯\_(ツ)_/¯
}) | ||
cancel() | ||
|
||
time.Sleep(1 * time.Second) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time.Sleep(1 * time.Second) | |
// We need to wait until c.Fetch() has been called. In practice, we should only wait for a millisecond or so but a second is here to be on the safe side. | |
time.Sleep(1 * time.Second) |
@@ -289,6 +314,24 @@ func (c *InMemoryCache) reset() { | |||
func (c *InMemoryCache) Store(ctx context.Context, data map[string][]byte, ttl time.Duration) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this implemented on the InMemoryCache
and not a remote cache? I would have thought that the benefits of this code would be much greater with a remote cache 🤔 Perhaps this is a 'test' implementation to get right before implementing the remote cache?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The answer probably is that popular remote caches do not have such functionality - as far as I can tell from my research, it is impossible to block until a key appears on Redis and/or Memcached. For example, here is one implementation: https://github.com/sqlalchemy/dogpile.cache/blob/dbf57e4a7b8f4061b735be8b24bbb880fb75802f/dogpile/cache/backends/memcached.py#L42
As you can see, the "locking" works by creating another key and then sleeping on it. No bueno. Something like this https://github.com/mailgun/groupcache needs to be used but before that mailgun/groupcache#9 should be implemented. For now, let's implement the same functionality in memory - these two features are completely orthogonal :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Off-topic: My attempt to add groupcache failed because of the drastic changes that needs to be introduced to cache interfaces. The Get
issue actually would help to solve this. It would be amazing to migrate groupcache and simplify what we have (we can drop memcached support when we move).
Thanks for your review! I will try adding those things. Coming back to it, I still cannot see a way of implementing this in some other way besides working with |
Signed-off-by: Giedrius Statkevičius <[email protected]>
Signed-off-by: Giedrius Statkevičius <[email protected]>
Signed-off-by: Giedrius Statkevičius <[email protected]>
Signed-off-by: Giedrius Statkevičius <[email protected]>
I have added a benchmark of Series() with 4 different setups:
The results are pretty good 😱 In some cases, the difference is 10-100x on small throughput benchmarks: ( Benchmark results
Essentially, the get operation now has a small overhead and the overhead becomes small in comparison when lots of requests for the same data come in which, in my experience, is a common thing because Grafana dashboards commonly re-use the same metrics but with different PromQL operators, for example. Being network-bound is the main limiting factor ATM in Thanos performance IMHO so this seems to me like a very important thing to have. Please see some of the rationales here: #4379 (comment). Given this, I'd like to merge this functionality for the in-memory cache and then we could move to the more advanced case where there are multiple Store instances. |
BTW, don't merge this yet:
|
@GiedriusS I have converted to draft, just in case to prevent accidental merges. |
Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward? This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
What's the status of this @GiedriusS? Looks like this was nearly finished but then marked stale? Would be a shame to leave this unmerged it seems! |
We have groupcache now which does "single-flight" so I don't think this is needed anymore. |
Implement a single-flight mode for the inmemory cache. Since our code
goes and does something when Fetch() misses, let's implement the
single-flight mode based on this assumption - wait for the results of
the previous request and use it.
https://pkg.go.dev/golang.org/x/sync/singleflight is not used to avoid
complexity - there is no single function that we can call to get results
of some operation. Potentially it could be wrapped around the
Bucket
interface but then the caching/remote object storage interfaces would be
muddled together and I really think we should avoid that.
Tested on a cluster and with unit tests.
Signed-off-by: Giedrius Statkevičius [email protected]