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
+ * {@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
+ * {@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