-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Support client-side caching (RESP3 tracking) for Redis Cluster #3885
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
321ff1a
1567405
f81f808
40bcf1d
59da6e0
79bef0e
8cafd4e
f16d3d8
bec4d17
d3828fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # Client-Side Caching | ||
|
|
||
| Lettuce supports server-assisted client-side caching as described in the | ||
| [Redis client-side caching documentation](https://redis.io/docs/latest/develop/reference/client-side-caching/). | ||
| The `ClientSideCaching` utility creates a `CacheFrontend` that represents a | ||
| two-level cache: values are first looked up in a local, application-provided | ||
| cache and only fetched from Redis on a cache miss. Redis notifies the client | ||
| through `CLIENT TRACKING` invalidation messages when a cached key is modified, | ||
| and the corresponding local entry is evicted. | ||
|
|
||
| ## Standalone connections | ||
|
|
||
| ```java | ||
| Map<String, String> clientCache = new ConcurrentHashMap<>(); | ||
|
|
||
| StatefulRedisConnection<String, String> connection = redisClient.connect(); | ||
|
|
||
| CacheFrontend<String, String> frontend = ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, | ||
| TrackingArgs.Builder.enabled()); | ||
|
|
||
| String value = frontend.get(key); | ||
| ``` | ||
|
|
||
| The `CacheFrontend` is associated with the Redis connection. Close the | ||
| frontend through `CacheFrontend.close()` to release the connection after use. | ||
|
|
||
| The example above requires RESP3: invalidation messages are delivered as push | ||
| messages on the connection itself, and the frontend evicts local entries | ||
| automatically. With RESP2, Redis delivers invalidations only to a redirected | ||
| client (`TrackingArgs.Builder.enabled().redirect(clientId)`), so the | ||
| frontend's built-in invalidation listener never receives them. In that case | ||
| subscribe to the `__redis__:invalidate` Pub/Sub channel on the redirect target | ||
| connection and evict entries from the client-side cache | ||
| (`CacheAccessor.evict(...)`) in the Pub/Sub listener yourself. | ||
|
|
||
| ## Redis Cluster connections | ||
|
|
||
| Since version 7.7, client-side caching is also supported for Redis Cluster | ||
| connections: | ||
|
|
||
| ```java | ||
| Map<String, String> clientCache = new ConcurrentHashMap<>(); | ||
|
|
||
| StatefulRedisClusterConnection<String, String> connection = clusterClient.connect(); | ||
|
|
||
| CacheFrontend<String, String> frontend = ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, | ||
| TrackingArgs.Builder.enabled()); | ||
|
|
||
| String value = frontend.get(key); | ||
| ``` | ||
|
|
||
| `CLIENT TRACKING` is enabled on each cluster node connection because | ||
| invalidation messages for a key are emitted by the node that serves the key's | ||
| slot. Upstream (master) nodes are tracked through their write-intent | ||
| connections. When a `ReadFrom` setting routes reads to replicas, the replicas | ||
| that the read policy can select are tracked through their read-intent | ||
| connections as well. | ||
|
|
||
| The following constraints apply to Redis Cluster: | ||
|
|
||
| - **RESP3 is required.** Enabling the cache fails with an | ||
| `IllegalStateException` if a node connection did not negotiate RESP3. | ||
| - **`REDIRECT` is not supported** as invalidation messages can originate from | ||
| any node. Redirected `TrackingArgs` are rejected with an | ||
| `IllegalArgumentException`. | ||
| - **`OPTIN` is not supported** as the cache frontend does not issue | ||
| `CLIENT CACHING yes` before reads. Opt-in `TrackingArgs` are rejected with an | ||
| `IllegalArgumentException`. | ||
| - **Topology and read-policy changes are not tracked.** Tracking is configured | ||
| for the topology and `ReadFrom` setting present when the cache is enabled. | ||
| Nodes added to the cluster afterwards do not have tracking enabled, and | ||
| changing the read policy through `setReadFrom(...)` does not track newly | ||
| selectable replicas. Applications that must observe such changes should | ||
| re-enable tracking afterwards. | ||
|
|
||
| `FLUSHALL` and `FLUSHDB` emit a full invalidation, which clears the entire | ||
| client-side cache. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,29 @@ | ||
| package io.lettuce.core.support.caching; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Collection; | ||
| import java.util.Collections; | ||
| import java.util.Iterator; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import java.util.concurrent.Callable; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.function.Consumer; | ||
|
|
||
| import io.lettuce.core.ReadFrom; | ||
| import io.lettuce.core.RedisURI; | ||
| import io.lettuce.core.StatefulRedisConnectionImpl; | ||
| import io.lettuce.core.TrackingArgs; | ||
| import io.lettuce.core.api.StatefulRedisConnection; | ||
| import io.lettuce.core.cluster.StatefulRedisClusterConnectionImpl; | ||
| import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; | ||
| import io.lettuce.core.cluster.models.partitions.RedisClusterNode; | ||
| import io.lettuce.core.codec.RedisCodec; | ||
| import io.lettuce.core.internal.LettuceAssert; | ||
| import io.lettuce.core.models.role.RedisNodeDescription; | ||
| import io.lettuce.core.protocol.ConnectionIntent; | ||
| import io.lettuce.core.protocol.ProtocolVersion; | ||
|
|
||
| /** | ||
| * Utility to provide server-side assistance for client-side caches. This is a {@link CacheFrontend} that represents a two-level | ||
|
|
@@ -31,6 +46,7 @@ | |
| * @param <K> Key type. | ||
| * @param <V> Value type. | ||
| * @author Mark Paluch | ||
| * @author Julien Ruaux | ||
|
tishun marked this conversation as resolved.
|
||
| * @since 6.0 | ||
| */ | ||
| public class ClientSideCaching<K, V> implements CacheFrontend<K, V> { | ||
|
|
@@ -92,11 +108,165 @@ public static <K, V> CacheFrontend<K, V> create(CacheAccessor<K, V> cacheAccesso | |
| return create(cacheAccessor, redisCache); | ||
| } | ||
|
|
||
| /** | ||
| * Enable server-assisted Client side caching for the given {@link CacheAccessor} and | ||
| * {@link StatefulRedisClusterConnection}. | ||
| * <p> | ||
| * {@code CLIENT TRACKING} is enabled on each cluster node connection: invalidation messages for a key are emitted by the | ||
| * node that serves the key's slot (or, for replica reads, by the replica the value was read from), so tracking must be | ||
| * active there. Upstream (master) nodes are tracked through their write-intent connections. Replicas are tracked through | ||
| * their read-intent connections only when the connection's {@link ReadFrom} setting can select them for reads, so no | ||
| * replica connections are opened when reads are routed to upstream nodes only (the default). Note that a keyless | ||
| * {@code CLIENT TRACKING} command issued through the cluster command API would be routed to the default connection only, | ||
| * whose push messages are not associated with cluster node connections. | ||
| * <p> | ||
| * Client-side caching for Redis Cluster requires RESP3. {@code TrackingArgs} redirection is not supported as invalidation | ||
| * messages can originate from any node, and {@code OPTIN} tracking is not supported as the cache frontend does not issue | ||
| * {@code CLIENT CACHING yes} before reads; this method throws {@link IllegalArgumentException} for redirected or opt-in | ||
| * tracking parameters and {@link IllegalStateException} if a node connection did not negotiate RESP3. | ||
|
tishun marked this conversation as resolved.
|
||
| * <p> | ||
| * Tracking is configured for the topology and {@link ReadFrom} setting present at the time of this call. Nodes added to the | ||
| * cluster afterwards do not have tracking enabled, and changing the read policy through | ||
| * {@link StatefulRedisClusterConnection#setReadFrom} does not track newly selectable replicas. Applications that must | ||
| * observe such changes should re-enable tracking afterwards. | ||
| * <p> | ||
| * Note that the {@link CacheFrontend} is associated with a Redis connection. Make sure to {@link CacheFrontend#close() | ||
| * close} the frontend object to release the Redis connection after use. | ||
| * | ||
| * @param cacheAccessor the accessor used to interact with the client-side cache. | ||
| * @param connection the Redis Cluster connection to use. The connection will be associated with {@link CacheFrontend} and | ||
| * must be closed through {@link CacheFrontend#close()}. | ||
| * @param tracking the tracking parameters. | ||
| * @param <K> Key type. | ||
| * @param <V> Value type. | ||
| * @return the {@link CacheFrontend} for value retrieval. | ||
| * @since 7.7 | ||
| */ | ||
| public static <K, V> CacheFrontend<K, V> enable(CacheAccessor<K, V> cacheAccessor, | ||
| StatefulRedisClusterConnection<K, V> connection, TrackingArgs tracking) { | ||
|
tishun marked this conversation as resolved.
tishun marked this conversation as resolved.
|
||
|
|
||
| LettuceAssert.isTrue(!tracking.isRedirect(), | ||
|
tishun marked this conversation as resolved.
|
||
| "TrackingArgs REDIRECT is not supported for Redis Cluster client-side caching"); | ||
| LettuceAssert.isTrue(!tracking.isOptin(), "TrackingArgs OPTIN is not supported for Redis Cluster client-side caching"); | ||
|
tishun marked this conversation as resolved.
tishun marked this conversation as resolved.
|
||
|
|
||
| for (RedisClusterNode node : connection.getPartitions()) { | ||
| // use host/port connections: slot-routed commands are served by connections keyed | ||
| // by intent, host and port, not by the nodeId-keyed connections | ||
| if (isServingUpstream(node)) { | ||
| enableTracking(connection, node.getUri(), ConnectionIntent.WRITE, tracking); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| for (RedisNodeDescription node : readCandidates(connection)) { | ||
| // reads from upstream nodes are served by the write-intent connections tracked above | ||
| if (node.getRole().isReplica()) { | ||
| enableTracking(connection, node.getUri(), ConnectionIntent.READ, tracking); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This computes the tracked read candidates only once during Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 59da6e0: documented in the Javadoc and user guide that setReadFrom(...) changes after enabling are not tracked and require re-enabling tracking. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 8cafd4e: unreachable replicas no longer fail cache setup; tracking is skipped with a warning, matching the read path's fault tolerance. |
||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| return create(cacheAccessor, connection); | ||
| } | ||
|
|
||
| private static Collection<RedisNodeDescription> readCandidates(StatefulRedisClusterConnection<?, ?> connection) { | ||
|
|
||
| ReadFrom readFrom = connection.getReadFrom(); | ||
|
|
||
| if (readFrom == null) { | ||
| // without a ReadFrom setting, all reads are routed to upstream nodes | ||
| return Collections.emptyList(); | ||
| } | ||
|
|
||
| // mirror the per-slot selection of the cluster connection provider: ReadFrom sees an | ||
| // upstream node and its replicas, not the whole cluster | ||
| Set<RedisNodeDescription> candidates = new LinkedHashSet<>(); | ||
|
|
||
| for (RedisClusterNode upstream : connection.getPartitions()) { | ||
| if (isServingUpstream(upstream)) { | ||
| candidates.addAll(readFrom.select(slotGroup(connection, upstream))); | ||
| } | ||
| } | ||
|
|
||
| return candidates; | ||
| } | ||
|
|
||
| private static boolean isServingUpstream(RedisClusterNode node) { | ||
| // upstream nodes without slots cannot serve slot-routed reads or writes | ||
| return node.is(RedisClusterNode.NodeFlag.UPSTREAM) && !node.hasNoSlots(); | ||
| } | ||
|
|
||
| private static ReadFrom.Nodes slotGroup(StatefulRedisClusterConnection<?, ?> connection, RedisClusterNode upstream) { | ||
|
|
||
| List<RedisNodeDescription> nodes = new ArrayList<>(); | ||
|
tishun marked this conversation as resolved.
|
||
| nodes.add(upstream); | ||
|
|
||
| for (RedisClusterNode node : connection.getPartitions()) { | ||
| // consider only replicas that contain data from replication, mirroring the connection provider | ||
| if (node.is(RedisClusterNode.NodeFlag.REPLICA) && upstream.getNodeId().equals(node.getSlaveOf()) | ||
| && node.getReplOffset() != 0) { | ||
| nodes.add(node); | ||
| } | ||
| } | ||
|
|
||
| return new ReadFrom.Nodes() { | ||
|
|
||
| @Override | ||
| public List<RedisNodeDescription> getNodes() { | ||
| return nodes; | ||
| } | ||
|
|
||
| @Override | ||
| public Iterator<RedisNodeDescription> iterator() { | ||
| return nodes.iterator(); | ||
| } | ||
|
|
||
| }; | ||
| } | ||
|
|
||
| private static <K, V> void enableTracking(StatefulRedisClusterConnection<K, V> connection, RedisURI uri, | ||
| ConnectionIntent intent, TrackingArgs tracking) { | ||
|
|
||
| StatefulRedisConnection<K, V> nodeConnection = connection.getConnection(uri.getHost(), uri.getPort(), intent); | ||
|
|
||
| ProtocolVersion protocolVersion = ((StatefulRedisConnectionImpl<K, V>) nodeConnection).getConnectionState() | ||
| .getNegotiatedProtocolVersion(); | ||
| LettuceAssert.assertState(protocolVersion == ProtocolVersion.RESP3, | ||
| "Client-side caching for Redis Cluster requires RESP3"); | ||
|
|
||
| nodeConnection.sync().clientTracking(tracking); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a tracked cluster node connection reconnects after a network drop or node restart, this completed Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 8cafd4e: a RedisConnectionStateListener re-applies CLIENT TRACKING on each tracked node connection when it reconnects. |
||
| } | ||
|
|
||
| /** | ||
| * Create a server-assisted Client side caching for the given {@link CacheAccessor} and | ||
| * {@link StatefulRedisClusterConnection}. This method expects that client key tracking is already configured on the cluster | ||
| * node connections. | ||
| * <p> | ||
| * Note that the {@link CacheFrontend} is associated with a Redis connection. Make sure to {@link CacheFrontend#close() | ||
| * close} the frontend object to release the Redis connection after use. | ||
| * | ||
| * @param cacheAccessor the accessor used to interact with the client-side cache. | ||
| * @param connection the Redis Cluster connection to use. The connection will be associated with {@link CacheFrontend} and | ||
| * must be closed through {@link CacheFrontend#close()}. | ||
| * @param <K> Key type. | ||
| * @param <V> Value type. | ||
| * @return the {@link CacheFrontend} for value retrieval. | ||
| * @since 7.7 | ||
| */ | ||
| public static <K, V> CacheFrontend<K, V> create(CacheAccessor<K, V> cacheAccessor, | ||
| StatefulRedisClusterConnection<K, V> connection) { | ||
|
|
||
| StatefulRedisClusterConnectionImpl<K, V> connectionImpl = (StatefulRedisClusterConnectionImpl) connection; | ||
| RedisCodec<K, V> codec = connectionImpl.getCodec(); | ||
| RedisCache<K, V> redisCache = new ClusterRedisCache<>(connection, codec); | ||
|
|
||
| return create(cacheAccessor, redisCache); | ||
| } | ||
|
|
||
| private static <K, V> CacheFrontend<K, V> create(CacheAccessor<K, V> cacheAccessor, RedisCache<K, V> redisCache) { | ||
|
|
||
| ClientSideCaching<K, V> caching = new ClientSideCaching<>(cacheAccessor, redisCache); | ||
|
|
||
| redisCache.addInvalidationListener(caching::notifyInvalidate); | ||
| redisCache.addClearListener(cacheAccessor::clear); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| caching.addInvalidationListener(cacheAccessor::evict); | ||
|
|
||
| return caching; | ||
|
|
||
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.
For RESP2 redirection, full invalidations from
FLUSHALL/FLUSHDBhave no key payload, so a Pub/Sub listener that only callsCacheAccessor.evict(...)as described here cannot remove any specific entry and will leave the local cache stale after a flush. The manual RESP2 wiring should also tell users to callCacheAccessor.clear()when the invalidation message is null.Useful? React with 👍 / 👎.
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.
Addressed in f16d3d8: the RESP2 wiring docs now instruct calling CacheAccessor.clear() when the invalidation message is null.