Skip to content

Commit dddf353

Browse files
jxblumchristophstrobl
authored andcommitted
Add support for TTI expiration in Redis Cache implementation.
We now support time-to-idle (TTI) expiration policies for cache reads. The TTI implementation is achieved with the use of the Redis GETEX command on Cache.get(key) operations as well as consistently using the same TTL configuration for all cache operations when TTI is enabled and TTL expiration has been configured, with the use of a TtlFunction or fixed Duration. Closes #2351 Original pull request: #2643
1 parent 862e344 commit dddf353

12 files changed

+599
-89
lines changed

src/main/asciidoc/reference/redis-cache.adoc

+143-16
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
NOTE: Changed in 2.0
55

6-
Spring Redis provides an implementation for the Spring {spring-framework-reference}/integration.html#cache[cache abstraction] through the `org.springframework.data.redis.cache` package. To use Redis as a backing implementation, add `RedisCacheManager` to your configuration, as follows:
6+
Spring Data Redis provides an implementation of Spring Framework's {spring-framework-reference}/integration.html#cache[Cache Abstraction] in the `org.springframework.data.redis.cache` package. To use Redis as a backing implementation, add `RedisCacheManager` to your configuration, as follows:
77

88
[source,java]
99
----
@@ -17,61 +17,65 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory)
1717

1818
[source,java]
1919
----
20-
RedisCacheManager cm = RedisCacheManager.builder(connectionFactory)
21-
.cacheDefaults(defaultCacheConfig())
22-
.withInitialCacheConfigurations(singletonMap("predefined", defaultCacheConfig().disableCachingNullValues()))
20+
RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory)
21+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
2322
.transactionAware()
23+
.withInitialCacheConfigurations(Collections.singletonMap("predefined",
24+
RedisCacheConfiguration.defaultCacheConfig().disableCachingNullValues()))
2425
.build();
2526
----
2627

27-
As shown in the preceding example, `RedisCacheManager` allows definition of configurations on a per-cache basis.
28+
As shown in the preceding example, `RedisCacheManager` allows custom configuration on a per-cache basis.
2829

29-
The behavior of `RedisCache` created with `RedisCacheManager` is defined with `RedisCacheConfiguration`. The configuration lets you set key expiration times, prefixes, and `RedisSerializer` implementations for converting to and from the binary storage format, as shown in the following example:
30+
The behavior of `RedisCache` created by `RedisCacheManager` is defined with `RedisCacheConfiguration`. The configuration lets you set key expiration times, prefixes, and `RedisSerializer` implementations for converting to and from the binary storage format, as shown in the following example:
3031

3132
[source,java]
3233
----
33-
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
34+
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
3435
.entryTtl(Duration.ofSeconds(1))
3536
.disableCachingNullValues();
3637
----
3738

3839
`RedisCacheManager` defaults to a lock-free `RedisCacheWriter` for reading and writing binary values.
3940
Lock-free caching improves throughput.
40-
The lack of entry locking can lead to overlapping, non-atomic commands for the `putIfAbsent` and `clean` methods, as those require multiple commands to be sent to Redis. The locking counterpart prevents command overlap by setting an explicit lock key and checking against presence of this key, which leads to additional requests and potential command wait times.
41+
The lack of entry locking can lead to overlapping, non-atomic commands for the `Cache` `putIfAbsent` and `clean` operations, as those require multiple commands to be sent to Redis. The locking counterpart prevents command overlap by setting an explicit lock key and checking against presence of this key, which leads to additional requests and potential command wait times.
4142

4243
Locking applies on the *cache level*, not per *cache entry*.
4344

4445
It is possible to opt in to the locking behavior as follows:
4546

4647
[source,java]
4748
----
48-
RedisCacheManager cm = RedisCacheManager.build(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory))
49-
.cacheDefaults(defaultCacheConfig())
49+
RedisCacheManager cacheMangager = RedisCacheManager
50+
.build(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory))
51+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
5052
...
5153
----
5254

53-
By default, any `key` for a cache entry gets prefixed with the actual cache name followed by two colons.
55+
By default, any `key` for a cache entry gets prefixed with the actual cache name followed by two colons (`::`).
5456
This behavior can be changed to a static as well as a computed prefix.
5557

5658
The following example shows how to set a static prefix:
5759

5860
[source,java]
5961
----
6062
// static key prefix
61-
RedisCacheConfiguration.defaultCacheConfig().prefixKeysWith("( ͡° ᴥ ͡°)");
63+
RedisCacheConfiguration.defaultCacheConfig().prefixKeysWith("(͡° ᴥ ͡°)");
6264
6365
The following example shows how to set a computed prefix:
6466
6567
// computed key prefix
66-
RedisCacheConfiguration.defaultCacheConfig().computePrefixWith(cacheName -> "¯\_(ツ)_/¯" + cacheName);
68+
RedisCacheConfiguration.defaultCacheConfig()
69+
.computePrefixWith(cacheName -> "¯\_(ツ)_/¯" + cacheName);
6770
----
6871

69-
The cache implementation defaults to use `KEYS` and `DEL` to clear the cache. `KEYS` can cause performance issues with large keyspaces. Therefore, the default `RedisCacheWriter` can be created with a `BatchStrategy` to switch to a `SCAN`-based batch strategy. The `SCAN` strategy requires a batch size to avoid excessive Redis command roundtrips:
72+
The cache implementation defaults to use `KEYS` and `DEL` to clear the cache. `KEYS` can cause performance issues with large keyspaces. Therefore, the default `RedisCacheWriter` can be created with a `BatchStrategy` to switch to a `SCAN`-based batch strategy. The `SCAN` strategy requires a batch size to avoid excessive Redis command round trips:
7073

7174
[source,java]
7275
----
73-
RedisCacheManager cm = RedisCacheManager.build(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.scan(1000)))
74-
.cacheDefaults(defaultCacheConfig())
76+
RedisCacheManager cacheManager = RedisCacheManager
77+
.build(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.scan(1000)))
78+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
7579
...
7680
----
7781

@@ -130,3 +134,126 @@ The following table lists the default settings for `RedisCacheConfiguration`:
130134
By default `RedisCache`, statistics are disabled.
131135
Use `RedisCacheManagerBuilder.enableStatistics()` to collect local _hits_ and _misses_ through `RedisCache#getStatistics()`, returning a snapshot of the collected data.
132136
====
137+
138+
[[redis:support:cache-abstraction:expiration]]
139+
== Redis Cache Expiration
140+
141+
Spring Data Redis's `Cache` implementation supports _time-to-live_ (TTL) expiration on cache entries. Users can either configure the TTL expiration timeout with a fixed `Duration` or a dynamically computed `Duration` per cache entry by supplying an implementation of the new `RedisCacheWriter.TtlFunction` interface.
142+
143+
> TIP: The `RedisCacheWriter.TtlFunction` interface was introduced in Spring Data Redis `3.2.0`.
144+
145+
If all cache entries should expire after a set duration of time, then simply configure a TTL expiration timeout with a fixed `Duration`, as follows:
146+
147+
[source,java]
148+
----
149+
RedisCacheConfiguration fiveMinuteTtlExpirationCacheConfiguration =
150+
RedisCacheConfiguration.defaultCacheConfig().enableTtl(Duration.ofMinutes(5));
151+
----
152+
153+
However, if the TTL expiration timeout should vary by cache entry, then you must provide a custom implementation of the `RedisCacheWriter.TtlFunction` interface:
154+
155+
[source,java]
156+
----
157+
class MyCustomTtlFunction implements TtlFunction {
158+
159+
static final MyCustomTtlFunction INSTANCE = new MyCustomTtlFunction();
160+
161+
public Duration getTimeToLive(Object key, @Nullable Object value) {
162+
// compute a TTL expiration timeout (Duration) based on the cache entry key and/or value
163+
}
164+
}
165+
----
166+
167+
> NOTE: Under-the-hood, a fixed `Duration` TTL expiration is wrapped in a `TtlFunction` implementation returning the provided `Duration`.
168+
169+
Then, you can either configure the fixed `Duration` or the dynamic, per-cache entry `Duration` TTL expiration on a global basis using:
170+
171+
.Global fixed Duration TTL expiration timeout
172+
[source,java]
173+
----
174+
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory)
175+
.cacheDefaults(fiveMinuteTtlExpirationCacheConfiguration)
176+
.build();
177+
----
178+
179+
Or, alternatively:
180+
181+
.Global, dynamically computed per-cache entry Duration TTL expiration timeout
182+
[source,java]
183+
----
184+
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory)
185+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
186+
.entryTtl(MyCustomTtlFunction.INSTANCE))
187+
.build();
188+
----
189+
190+
> WARNING: If you try to set both a fixed `Duration` and dynamic, per-cache entry `Duration` TTL expiration using a custom `TtlFunction`, then last one wins!
191+
192+
Of course, you can combine both global and per-cache configuration using:
193+
194+
.Global fixed Duration TTL expiration timeout
195+
[source,java]
196+
----
197+
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory)
198+
.cacheDefaults(fiveMinuteTtlExpirationCacheConfiguration)
199+
.withInitialCacheConfiguration(Collections.singletonMap("predefined",
200+
RedisCacheConfiguration.defaultCacheConfig().entryTtl(MyCustomTtlFunction.INSTANCE)))
201+
.build();
202+
----
203+
204+
[[redis:support:cache-abstraction:expiration:tti]]
205+
=== Time-To-Idle (TTI) Expiration
206+
207+
Redis itself does not support the concept of true, time-to-idle (TTI) expiration. Even across different data stores, the implementation of time-to-idle (TTI) as well as time-to-live (TTL) varies in definition and behavior.
208+
209+
In general:
210+
211+
* _time-to-live_ (TTL) _expiration_ - TTL is only set and reset by a create or update data access operation. As long as the entry is written before the TTL expiration timeout, including on creation, an entry's timeout will reset to the configured duration of the TTL expiration timeout. For example, if the TTL expiration timeout is set to 5 minutes, then the timeout will be set to 5 minutes on entry creation and reset to 5 minutes anytime the entry is updated thereafter and before the 5-minute interval expires. If no update occurs within 5 minutes, even if the entry was read several times, or even just read once during the 5-minute interval, the entry will still expire. The entry must be written to prevent the entry from expiring when declaring a TTL expiration policy.
212+
213+
* _time-to-idle_ (TTI) _expiration_ - TTI is reset anytime the entry is also read as well as for entry updates, and is effectively and extension to the TTL expiration policy.
214+
215+
> NOTE: Some data stores expire an entry when TTL is configured no matter what type of data access operation occurs on the entry (reads, writes, or otherwise). After the set, configured TTL expiration timeout, the entry is evicted from the data store regardless. Eviction actions (for example: destroy, invalidate, overflow-to-disk (for persistent stores), etc.) are data store specific.
216+
217+
Using Spring Data Redis's Cache implementation, it is possible to achieve time-to-idle (TTI) expiration-like behavior.
218+
219+
The configuration of TTI in Spring Data Redis's Cache implementation must be explicitly enabled, that is, is opt-in. Additionally, you must also provide TTL configuration using either a fixed `Duration` or a custom implementation of the `TtlFunction` interface as described above in <<redis:support:cache-abstraction:expiration>>.
220+
221+
For example:
222+
223+
[source,java]
224+
----
225+
@Configuration
226+
@EnableCaching
227+
class RedisConfiguration {
228+
229+
@Bean
230+
RedisConnectionFactory redisConnectionFactory() {
231+
// ...
232+
}
233+
234+
@Bean
235+
RedisCacheConfiguration redisCacheConfiguration() {
236+
237+
return RedisCacheConfiguration.defaultCacheConfig()
238+
.entryTtl(Duration.ofMinutes(5))
239+
.enableTimeToIdle();
240+
}
241+
242+
@Bean
243+
RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
244+
RedisCacheConfiguraton cacheConfiguraton) {
245+
246+
return RedisCacheManager.builder(connectionFactory)
247+
.cacheDefaults(cacheConfiguration)
248+
.build();
249+
}
250+
}
251+
----
252+
253+
Because Redis servers do not implement a proper notion of TTI, then TTI can only be achieved with Redis commands accepting expiration options. In Redis, the "expiration" is technically a time-to-live (TTL) policy. However, TTL expiration can be passed when reading the value of a key thereby effectively resetting the TTL expiration timeout, as is now the case in Spring Data Redis's `Cache.get(key)` operation.
254+
255+
`RedisCache.get(key)` is implemented by calling the Redis `GETEX` command.
256+
257+
> WARNING: The Redis https://redis.io/commands/getex[`GETEX`] command is only available in Redis version `6.2.0` and later. Therefore, if you are not using Redis `6.2.0` or later, then it is not possible to use Spring Data Redis's TTI expiration. A command execution exception will be thrown if you enable TTI against an incompatible Redis (server) version. No attempt is made to determine if the Redis server version is correct and supports the `GETEX` command.
258+
259+
> WARNING: In order to achieve true time-to-idle (TTI) expiration-like behavior in your Spring Data Redis application, then an entry must be consistently accessed with (TTL) expiration on every read or write operation. There are no exceptions to this rule. If you are mixing and matching different data access patterns across your Spring Data Redis application (for example: caching, invoking operations using `RedisTemplate` and possibly, or especially when using Spring Data Repository CRUD operations), then accessing an entry may not necessarily prevent the entry from expiring if TTL expiration was set. For example, an entry maybe "put" in (written to) the cache during a `@Cacheable` service method invocation with a TTL expiration (i.e. `SET <expiration options>`) and later read using a Spring Data Redis Repository before the expiration timeout (using `GET` without expiration options). A simple `GET` without specifying expiration options will not reset the TTL expiration timeout on an entry. Therefore, the entry may expire before the next data access operation, even though it was just read. Since this cannot be enforced in the Redis server, then it is the responsibility of your application to consistently access an entry when time-to-idle expiration is configured, in and outside of caching, where appropriate.

src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java

+8-1
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,18 @@ public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
125125

126126
@Override
127127
public byte[] get(String name, byte[] key) {
128+
return get(name, key, null);
129+
}
130+
131+
@Override
132+
public byte[] get(String name, byte[] key, @Nullable Duration ttl) {
128133

129134
Assert.notNull(name, "Name must not be null");
130135
Assert.notNull(key, "Key must not be null");
131136

132-
byte[] result = execute(name, connection -> connection.get(key));
137+
byte[] result = shouldExpireWithin(ttl)
138+
? execute(name, connection -> connection.getEx(key, Expiration.from(ttl)))
139+
: execute(name, connection -> connection.get(key));
133140

134141
statistics.incGets(name);
135142

src/main/java/org/springframework/data/redis/cache/RedisCache.java

+14-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.lang.reflect.Method;
1919
import java.nio.ByteBuffer;
20+
import java.time.Duration;
2021
import java.util.Arrays;
2122
import java.util.Collection;
2223
import java.util.Map;
@@ -188,11 +189,21 @@ protected <T> T loadCacheValue(Object key, Callable<T> valueLoader) {
188189
@Override
189190
protected Object lookup(Object key) {
190191

191-
byte[] value = getCacheWriter().get(getName(), createAndConvertCacheKey(key));
192+
byte[] value = getCacheConfiguration().isTimeToIdleEnabled()
193+
? getCacheWriter().get(getName(), createAndConvertCacheKey(key), getTimeToLive(key))
194+
: getCacheWriter().get(getName(), createAndConvertCacheKey(key));
192195

193196
return value != null ? deserializeCacheValue(value) : null;
194197
}
195198

199+
private Duration getTimeToLive(Object key) {
200+
return getTimeToLive(key, null);
201+
}
202+
203+
private Duration getTimeToLive(Object key, @Nullable Object value) {
204+
return getCacheConfiguration().getTtlFunction().getTimeToLive(key, value);
205+
}
206+
196207
@Override
197208
public void put(Object key, @Nullable Object value) {
198209

@@ -208,7 +219,7 @@ public void put(Object key, @Nullable Object value) {
208219
}
209220

210221
getCacheWriter().put(getName(), createAndConvertCacheKey(key), serializeCacheValue(cacheValue),
211-
getCacheConfiguration().getTtlFunction().getTimeToLive(key, value));
222+
getTimeToLive(key, value));
212223
}
213224

214225
@Override
@@ -221,7 +232,7 @@ public ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
221232
}
222233

223234
byte[] result = getCacheWriter().putIfAbsent(getName(), createAndConvertCacheKey(key),
224-
serializeCacheValue(cacheValue), getCacheConfiguration().getTtlFunction().getTimeToLive(key, value));
235+
serializeCacheValue(cacheValue), getTimeToLive(key, value));
225236

226237
return result != null ? new SimpleValueWrapper(fromStoreValue(deserializeCacheValue(result))) : null;
227238
}

0 commit comments

Comments
 (0)