From cd16fa333f3dd8c1217df60878af4bd08ef281ce Mon Sep 17 00:00:00 2001 From: Kim Sumin Date: Sat, 31 May 2025 13:06:48 +0900 Subject: [PATCH] Add configurable deletion strategy for Redis repository operations Allow applications to choose between DEL and UNLINK commands for Redis key deletion operations in repository contexts. This provides better performance for applications with frequent updates on existing keys, especially when dealing with large data structures under high load. Changes include DeletionStrategy enum with DEL and UNLINK options, extension of @EnableRedisRepositories annotation with deletionStrategy attribute, updates to RedisKeyValueAdapter to apply the configured strategy, and comprehensive tests covering configuration and functionality. Closes #2294 Signed-off-by: Kim Sumin --- .../data/redis/core/RedisKeyValueAdapter.java | 100 ++++++++++++++++-- .../EnableRedisRepositories.java | 22 +++- ...RedisRepositoryConfigurationExtension.java | 6 +- .../core/MappingExpirationListenerTest.java | 7 +- .../redis/core/RedisKeyValueAdapterTests.java | 56 ++++++++++ ...sitoryConfigurationExtensionUnitTests.java | 30 ++++++ 6 files changed, 207 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java b/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java index ea74c8fc5c..c77b6d9a0e 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java +++ b/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -103,6 +104,7 @@ * @author Mark Paluch * @author Andrey Muchnik * @author John Blum + * @author Kim Sumin * @since 1.7 */ public class RedisKeyValueAdapter extends AbstractKeyValueAdapter @@ -126,6 +128,7 @@ public class RedisKeyValueAdapter extends AbstractKeyValueAdapter private EnableKeyspaceEvents enableKeyspaceEvents = EnableKeyspaceEvents.OFF; private @Nullable String keyspaceNotificationsConfigParameter = null; private ShadowCopy shadowCopy = ShadowCopy.DEFAULT; + private DeletionStrategy deletionStrategy = DeletionStrategy.DEL; /** * Lifecycle state of this factory. @@ -134,6 +137,43 @@ enum State { CREATED, STARTING, STARTED, STOPPING, STOPPED, DESTROYED; } + /** + * Strategy for deleting Redis keys in Repository operations. + *

+ * Allows configuration of whether to use synchronous {@literal DEL} or asynchronous {@literal UNLINK} commands for + * key deletion operations. + * + * @author [Your Name] + * @since 3.6 + * @see Redis DEL + * @see Redis UNLINK + */ + public enum DeletionStrategy { + + /** + * Use Redis {@literal DEL} command for key deletion. + *

+ * 기key from memory. The command blocks until the key is completely removed, which can cause performance issues when + * deleting large data structures under high load. + *

+ * This is the default strategy for backward compatibility. + */ + DEL, + + /** + * Use Redis {@literal UNLINK} command for key deletion. + *

+ * This is a non-blocking operation that asynchronously removes the key. The key is immediately removed from the + * keyspace, but the actual memory reclamation happens in the background, providing better performance for + * applications with frequent updates on existing keys. + *

+ * Requires Redis 4.0 or later. + * + * @since Redis 4.0 + */ + UNLINK + } + /** * Creates new {@link RedisKeyValueAdapter} with default {@link RedisMappingContext} and default * {@link RedisCustomConversions}. @@ -228,7 +268,7 @@ public Object put(Object id, Object item, String keyspace) { byte[] key = toBytes(rdo.getId()); byte[] objectKey = createKey(rdo.getKeyspace(), rdo.getId()); - boolean isNew = connection.del(objectKey) == 0; + boolean isNew = applyDeletionStrategy(connection, objectKey) == 0; connection.hMSet(objectKey, rdo.getBucket().rawMap()); @@ -245,11 +285,11 @@ public Object put(Object id, Object item, String keyspace) { byte[] phantomKey = ByteUtils.concat(objectKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX); if (expires(rdo)) { - connection.del(phantomKey); + applyDeletionStrategy(connection, phantomKey); connection.hMSet(phantomKey, rdo.getBucket().rawMap()); connection.expire(phantomKey, rdo.getTimeToLive() + PHANTOM_KEY_TTL); } else if (!isNew) { - connection.del(phantomKey); + applyDeletionStrategy(connection, phantomKey); } } @@ -323,7 +363,7 @@ public T delete(Object id, String keyspace, Class type) { redisOps.execute((RedisCallback) connection -> { - connection.del(keyToDelete); + applyDeletionStrategy(connection, keyToDelete); connection.sRem(binKeyspace, binId); new IndexWriter(connection, converter).removeKeyFromIndexes(keyspace, binId); @@ -335,7 +375,7 @@ public T delete(Object id, String keyspace, Class type) { byte[] phantomKey = ByteUtils.concat(keyToDelete, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX); - connection.del(phantomKey); + applyDeletionStrategy(connection, phantomKey); } } return null; @@ -485,7 +525,7 @@ public void update(PartialUpdate update) { connection.persist(redisKey); if (keepShadowCopy()) { - connection.del(ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX)); + applyDeletionStrategy(connection, ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX)); } } } @@ -495,6 +535,18 @@ public void update(PartialUpdate update) { }); } + /** + * Apply the configured deletion strategy to delete the given key. + * + * @param connection the Redis connection + * @param key the key to delete + * @return the number of keys that were removed + */ + private Long applyDeletionStrategy(RedisConnection connection, byte[] key) { + return Objects + .requireNonNull(deletionStrategy == DeletionStrategy.UNLINK ? connection.unlink(key) : connection.del(key)); + } + private RedisUpdateObject fetchDeletePathsFromHashAndUpdateIndex(RedisUpdateObject redisUpdateObject, String path, RedisConnection connection) { @@ -704,6 +756,30 @@ public boolean isRunning() { return State.STARTED.equals(this.state.get()); } + /** + * Configure the deletion strategy for Redis keys. + *

+ * {@link DeletionStrategy#DEL DEL} performs synchronous key deletion, while {@link DeletionStrategy#UNLINK UNLINK} + * performs asynchronous deletion which can improve performance under high load scenarios. + * + * @param deletionStrategy the strategy to use for key deletion operations + * @since 3.6 + */ + public void setDeletionStrategy(DeletionStrategy deletionStrategy) { + Assert.notNull(deletionStrategy, "DeletionStrategy must not be null"); + this.deletionStrategy = deletionStrategy; + } + + /** + * Get the current deletion strategy. + * + * @return the current deletion strategy + * @since 3.6 + */ + public DeletionStrategy getDeletionStrategy() { + return this.deletionStrategy; + } + /** * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() * @since 1.8 @@ -792,7 +868,7 @@ private void initKeyExpirationListener(RedisMessageListenerContainer messageList if (this.expirationListener.get() == null) { MappingExpirationListener listener = new MappingExpirationListener(messageListenerContainer, this.redisOps, - this.converter, this.shadowCopy); + this.converter, this.shadowCopy, this.deletionStrategy); listener.setKeyspaceNotificationsConfigParameter(keyspaceNotificationsConfigParameter); @@ -819,17 +895,19 @@ static class MappingExpirationListener extends KeyExpirationEventMessageListener private final RedisOperations ops; private final RedisConverter converter; private final ShadowCopy shadowCopy; + private final DeletionStrategy deletionStrategy; /** * Creates new {@link MappingExpirationListener}. */ MappingExpirationListener(RedisMessageListenerContainer listenerContainer, RedisOperations ops, - RedisConverter converter, ShadowCopy shadowCopy) { + RedisConverter converter, ShadowCopy shadowCopy, DeletionStrategy deletionStrategy) { super(listenerContainer); this.ops = ops; this.converter = converter; this.shadowCopy = shadowCopy; + this.deletionStrategy = deletionStrategy; } @Override @@ -883,7 +961,11 @@ private Object readShadowCopy(byte[] key) { Map phantomValue = connection.hGetAll(phantomKey); if (!CollectionUtils.isEmpty(phantomValue)) { - connection.del(phantomKey); + if (deletionStrategy == DeletionStrategy.UNLINK) { + connection.unlink(phantomKey); + } else { + connection.del(phantomKey); + } } return phantomValue; diff --git a/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java b/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java index 1783584d4e..d8cb372513 100644 --- a/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java +++ b/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java @@ -28,6 +28,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.keyvalue.core.KeyValueOperations; import org.springframework.data.keyvalue.repository.config.QueryCreatorType; +import org.springframework.data.redis.core.RedisKeyValueAdapter.DeletionStrategy; import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents; import org.springframework.data.redis.core.RedisKeyValueAdapter.ShadowCopy; import org.springframework.data.redis.core.RedisOperations; @@ -47,6 +48,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Kim Sumin * @since 1.7 */ @Target(ElementType.TYPE) @@ -129,7 +131,9 @@ /** * Configure a specific {@link BeanNameGenerator} to be used when creating the repositoy beans. - * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate context default. + * + * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate + * context default. * @since 3.4 */ Class nameGenerator() default BeanNameGenerator.class; @@ -204,4 +208,20 @@ */ String keyspaceNotificationsConfigParameter() default "Ex"; + /** + * Configure the deletion strategy for Redis keys during repository operations. + *

+ * {@link DeletionStrategy#DEL DEL} uses synchronous deletion (blocking), while {@link DeletionStrategy#UNLINK UNLINK} + * uses asynchronous deletion (non-blocking). + *

+ * {@literal UNLINK} can provide better performance for applications with frequent updates on existing keys, + * especially when dealing with large data structures under high load. + *

+ * Requires Redis 4.0 or later when using {@link DeletionStrategy#UNLINK}. + * + * @return the deletion strategy to use + * @since 3.6 + * @see DeletionStrategy + */ + DeletionStrategy deletionStrategy() default DeletionStrategy.DEL; } diff --git a/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java index 5d008d5cd1..e65576c27a 100644 --- a/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java +++ b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java @@ -28,6 +28,7 @@ import org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.RedisKeyValueAdapter; +import org.springframework.data.redis.core.RedisKeyValueAdapter.DeletionStrategy; import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents; import org.springframework.data.redis.core.RedisKeyValueAdapter.ShadowCopy; import org.springframework.data.redis.core.RedisKeyValueTemplate; @@ -44,6 +45,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Kim Sumin * @since 1.7 */ public class RedisRepositoryConfigurationExtension extends KeyValueRepositoryConfigurationExtension { @@ -145,7 +147,9 @@ private static AbstractBeanDefinition createRedisKeyValueAdapter(RepositoryConfi configuration.getRequiredAttribute("enableKeyspaceEvents", EnableKeyspaceEvents.class)) // .addPropertyValue("keyspaceNotificationsConfigParameter", configuration.getAttribute("keyspaceNotificationsConfigParameter", String.class).orElse("")) // - .addPropertyValue("shadowCopy", configuration.getRequiredAttribute("shadowCopy", ShadowCopy.class)); + .addPropertyValue("shadowCopy", configuration.getRequiredAttribute("shadowCopy", ShadowCopy.class)) + .addPropertyValue("deletionStrategy", + configuration.getRequiredAttribute("deletionStrategy", DeletionStrategy.class)); configuration.getAttribute("messageListenerContainerRef") .ifPresent(it -> builder.addPropertyReference("messageListenerContainer", it)); diff --git a/src/test/java/org/springframework/data/redis/core/MappingExpirationListenerTest.java b/src/test/java/org/springframework/data/redis/core/MappingExpirationListenerTest.java index c3df6384aa..aba2a34376 100644 --- a/src/test/java/org/springframework/data/redis/core/MappingExpirationListenerTest.java +++ b/src/test/java/org/springframework/data/redis/core/MappingExpirationListenerTest.java @@ -40,6 +40,7 @@ /** * @author Lucian Torje * @author Christoph Strobl + * @author Kim Sumin */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -58,7 +59,7 @@ void testOnNonKeyExpiration() { byte[] key = "testKey".getBytes(); when(message.getBody()).thenReturn(key); listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter, - RedisKeyValueAdapter.ShadowCopy.ON); + RedisKeyValueAdapter.ShadowCopy.ON, RedisKeyValueAdapter.DeletionStrategy.DEL); listener.onMessage(message, null); @@ -74,7 +75,7 @@ void testOnValidKeyExpirationWithShadowCopiesDisabled() { when(message.getBody()).thenReturn(key); listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter, - RedisKeyValueAdapter.ShadowCopy.OFF); + RedisKeyValueAdapter.ShadowCopy.OFF, RedisKeyValueAdapter.DeletionStrategy.DEL); listener.setApplicationEventPublisher(eventList::add); listener.onMessage(message, null); @@ -97,7 +98,7 @@ void testOnValidKeyExpirationWithShadowCopiesEnabled() { when(conversionService.convert(any(), eq(byte[].class))).thenReturn("foo".getBytes()); listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter, - RedisKeyValueAdapter.ShadowCopy.ON); + RedisKeyValueAdapter.ShadowCopy.ON, RedisKeyValueAdapter.DeletionStrategy.DEL); listener.setApplicationEventPublisher(eventList::add); listener.onMessage(message, null); diff --git a/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java b/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java index a445a6bb17..ca906623f6 100644 --- a/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java +++ b/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java @@ -56,6 +56,7 @@ * @author Christoph Strobl * @author Mark Paluch * @author Andrey Muchnik + * @author Kim Sumin */ @ExtendWith(LettuceConnectionFactoryExtension.class) public class RedisKeyValueAdapterTests { @@ -788,6 +789,61 @@ void updateWithRefreshTtlAndWithoutPositiveTtlShouldDeletePhantomKey() { assertThat(template.hasKey("persons:1:phantom")).isFalse(); } + @Test // GH-2294 + void shouldUseDELByDefault() { + // given + RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext); + + // when & then + assertThat(adapter.getDeletionStrategy()).isEqualTo(RedisKeyValueAdapter.DeletionStrategy.DEL); + } + + @Test // GH -2294 + void shouldAllowUNLINKConfiguration() { + // given + RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext); + + // when + adapter.setDeletionStrategy(RedisKeyValueAdapter.DeletionStrategy.UNLINK); + + // then + assertThat(adapter.getDeletionStrategy()).isEqualTo(RedisKeyValueAdapter.DeletionStrategy.UNLINK); + } + + @Test // GH-2294 + void shouldRejectNullDeletionStrategy() { + // given + RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext); + + // when & then + assertThatIllegalArgumentException().isThrownBy(() -> adapter.setDeletionStrategy(null)) + .withMessageContaining("DeletionStrategy must not be null"); + } + + @Test // GH-2294 + void shouldMaintainFunctionalityWithUNLINKStrategy() { + // given + adapter.setDeletionStrategy(RedisKeyValueAdapter.DeletionStrategy.UNLINK); + + Person person = new Person(); + person.id = "unlink-test"; + person.firstname = "test"; + + // when & then + adapter.put(person.id, person, "persons"); + assertThat(adapter.get(person.id, "persons", Person.class)).isNotNull(); + + person.firstname = "updated"; + adapter.put(person.id, person, "persons"); + + Person result = adapter.get(person.id, "persons", Person.class); + assertThat(result.firstname).isEqualTo("updated"); + + adapter.delete(person.id, "persons"); + assertThat(adapter.get(person.id, "persons", Person.class)).isNull(); + } + + /** * Wait up to 5 seconds until {@code key} is no longer available in Redis. * diff --git a/src/test/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtensionUnitTests.java b/src/test/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtensionUnitTests.java index b515de2ac4..a31195186a 100644 --- a/src/test/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtensionUnitTests.java +++ b/src/test/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtensionUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import java.util.Collection; +import java.util.Objects; import org.junit.jupiter.api.Test; @@ -32,6 +33,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.keyvalue.repository.KeyValueRepository; import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.RedisKeyValueAdapter.DeletionStrategy; import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents; import org.springframework.data.repository.Repository; import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; @@ -43,6 +45,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Kim Sumin */ class RedisRepositoryConfigurationExtensionUnitTests { @@ -117,6 +120,22 @@ void explicitlyEmptyKeyspaceNotificationsConfigParameterShouldBeCapturedCorrectl assertThat(getKeyspaceNotificationsConfigParameter(beanDefintionRegistry)).isEqualTo(""); } + @Test // GH-2294 + void picksUpDeletionStrategyDefaultCorrectly() { + metadata = new StandardAnnotationMetadata(ConfigWithDefaultDeletionStrategy.class, true); + BeanDefinitionRegistry beanDefinitionRegistry = getBeanDefinitionRegistry(); + + assertThat(getDeletionStrategy(beanDefinitionRegistry)).isEqualTo(DeletionStrategy.DEL); + } + + @Test // GH-2294 + void picksUpDeletionStrategyUnlinkCorrectly() { + metadata = new StandardAnnotationMetadata(ConfigWithUnlinkDeletionStrategy.class, true); + BeanDefinitionRegistry beanDefinitionRegistry = getBeanDefinitionRegistry(); + + assertThat(getDeletionStrategy(beanDefinitionRegistry)).isEqualTo(DeletionStrategy.UNLINK); + } + private static void assertDoesNotHaveRepo(Class repositoryInterface, Collection> configs) { @@ -166,6 +185,11 @@ private Object getKeyspaceNotificationsConfigParameter(BeanDefinitionRegistry be .getPropertyValue("keyspaceNotificationsConfigParameter").getValue(); } + private Object getDeletionStrategy(BeanDefinitionRegistry beanDefinitionRegistry) { + return Objects.requireNonNull(beanDefinitionRegistry.getBeanDefinition("redisKeyValueAdapter").getPropertyValues() + .getPropertyValue("deletionStrategy").getValue()); + } + @EnableRedisRepositories(considerNestedRepositories = true, enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP) private static class Config { @@ -188,6 +212,12 @@ private static class ConfigWithEmptyConfigParameter { } + @EnableRedisRepositories(considerNestedRepositories = true) + private static class ConfigWithDefaultDeletionStrategy {} + + @EnableRedisRepositories(considerNestedRepositories = true, deletionStrategy = DeletionStrategy.UNLINK) + private static class ConfigWithUnlinkDeletionStrategy {} + @RedisHash static class Sample { @Id String id;