Skip to content

RedisCache synchronises all get(key, valueLoader) calls #2079

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

Closed
miensol opened this issue Jun 10, 2021 · 6 comments
Closed

RedisCache synchronises all get(key, valueLoader) calls #2079

miensol opened this issue Jun 10, 2021 · 6 comments
Assignees
Labels
status: ideal-for-contribution An issue that a contributor can help us with type: enhancement A general enhancement

Comments

@miensol
Copy link
Contributor

miensol commented Jun 10, 2021

I was examining the performance in our Spring Boot app. Based on profiling data, I noticed that we have a lot of wait times on RedisCache#get(key, valueLoader) method.

The method signature makes it obvious why this is happening:

public synchronized <T> T get(Object key, Callable<T> valueLoader) {

In case of our app, it causes all cache lookups to be executed sequentially. (Admittedly a great protection for Redis server 😅).

In the past this method wasn't synchronized. It changed in this commit.

Documentation of the Cache interface states:

Return the value to which this cache maps the specified key, obtaining that value from valueLoader if necessary. This method provides a simple substitute for the conventional "if cached, return; otherwise create, cache and return" pattern.

If possible, implementations should ensure that the loading operation is synchronized so that the specified valueLoader is only called once in case of concurrent access on the same key.

Technically speaking, the current synchronized implementation adheres to documentation. However, so would version that is not synchronized.

For comparison, here's how the EhCacheCache is implemented:

    public <T> T get(Object key, Callable<T> valueLoader) {
		Element element = lookup(key);
		if (element != null) {
			return (T) element.getObjectValue();
		}
		else {
			this.cache.acquireWriteLockOnKey(key);
			try {
				element = lookup(key);  // one more attempt with the write lock
				if (element != null) {
					return (T) element.getObjectValue();
				}
				else {
					return loadValue(key, valueLoader);
				}
			}
			finally {
				this.cache.releaseWriteLockOnKey(key);
			}
		}
	}

I'm happy to submit a PR that changes the implementation. However, before doing so, I'd love to ask if the synchronized method was implemented in the current way deliberately and there's and edge case which makes changing it harder?
LV link

@mp911de
Copy link
Member

mp911de commented Jun 10, 2021

I think we should be able to optimize and add synchronization only if the lookup yields null. Feel free to submit a pull request.

@mp911de mp911de added status: ideal-for-contribution An issue that a contributor can help us with type: enhancement A general enhancement labels Jun 10, 2021
@miensol
Copy link
Contributor Author

miensol commented Jun 10, 2021

Thanks. That's certainly easy.

How do you feel about synchronising (in JVM) when loading values on a per key basis?

@mp911de
Copy link
Member

mp911de commented Jun 10, 2021

Synchronizing on externally supplied keys is actually prone to create more problems than actually solve. We would need to maintain a decoupled copy and do proper housekeeping to evict keys as we do not have an in-memory reference backed by a cache like EhCache and others do.

@miensol
Copy link
Contributor Author

miensol commented Jun 10, 2021

If we talk about single JVM locking it doesn't sound that complicated. I may be missing something though.
Wouldn't using ConcurrentReferenceHashMap to store a mutex/lock object for key be enough?

@mp911de
Copy link
Member

mp911de commented Jun 11, 2021

There are two main issues why this isn't going to work with just a Map:

  1. The externally given key is computed from the cache KeyGenerator. It isn't a stable object that would be reused.
  2. The consequence is that we need to maintain our own mutex (key wrapper) that is looked up by comparing cache key equality
  3. Since no one except the Redis Cache would have a reference to the mutex, a ReferenceHashMap is going to evict mutexes upon GC runs so the net effect is increased memory usage and CPU overhead to maintain mutexes.

We do not want to follow that way. A last note: When using a locking RedisCacheWriter, you get improved locking behavior out of the box.

miensol added a commit to miensol/spring-data-redis that referenced this issue Jun 11, 2021
The previous version synchronize all calls to `get(key, valueLoader)`. After this PR the calls to value loader will only be synchronised if we do not have value for the given key.

Improves spring-projects#2079
miensol added a commit to miensol/spring-data-redis that referenced this issue Jun 11, 2021
The previous version synchronize all calls to `get(key, valueLoader)`. After this PR the calls to value loader will only be synchronised if we do not have value for the given key.

Fixes spring-projects#2079
@miensol
Copy link
Contributor Author

miensol commented Jun 11, 2021

Thanks for explanation. I understand your concerns, and they are very true in general case. I have no knowledge though if those cases, e.g. using unstable key generator happen in practice nor what a user would expect in such case.

  1. Since no one except the Redis Cache would have a reference to the mutex, a ReferenceHashMap is going to evict mutexes upon GC runs so the net effect is increased memory usage and CPU overhead to maintain mutexes.

Yes, AFAIK no one except RedisCache would hold on to a mutex, and thus it wouldn't stay in memory for much longer than needed. If the mutex is used in a lock by some thread, it wouldn't be evicted by GC, would it?

I agree that there will be an increased memory usage. I think though that it will be relatively small, in general case, when compared to sizes of objects saved and received from cache.

I don't have any data other than our project. However, in case of our project without such mechanism, we'll spend time waiting on locks, since all threads that which to compute a value for given key are processed in queue.

Without per key locks, we need to do a workaround on our side.

A last note: When using a locking RedisCacheWriter, you get improved locking behavior out of the box.

Would you mind explaining how this helps one using RedisCache#get(key, valueLoader) ?

@mp911de mp911de self-assigned this Jun 15, 2021
@mp911de mp911de added this to the 2.5.2 (2021.0.2) milestone Jun 15, 2021
mp911de added a commit that referenced this issue Jun 15, 2021
Inline valueFromLoader method. Refine tests to not rely on the number of runtime CPU cores.

See #2079
Original pull request: #2082.
mp911de pushed a commit that referenced this issue Jun 15, 2021
RedisCache.get(…) now optimistically fetches the cache value before entering cache-wide synchronization. The previous version synchronized all calls to `get(key, valueLoader)`.

Closes #2079
Original pull request: #2082.
mp911de added a commit that referenced this issue Jun 15, 2021
Inline valueFromLoader method. Refine tests to not rely on the number of runtime CPU cores.

See #2079
Original pull request: #2082.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: ideal-for-contribution An issue that a contributor can help us with type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants