Skip to content

Commit cd16fa3

Browse files
committed
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 <[email protected]>
1 parent 5029094 commit cd16fa3

File tree

6 files changed

+207
-14
lines changed

6 files changed

+207
-14
lines changed

src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.List;
2323
import java.util.Map;
2424
import java.util.Map.Entry;
25+
import java.util.Objects;
2526
import java.util.Set;
2627
import java.util.concurrent.TimeUnit;
2728
import java.util.concurrent.atomic.AtomicReference;
@@ -103,6 +104,7 @@
103104
* @author Mark Paluch
104105
* @author Andrey Muchnik
105106
* @author John Blum
107+
* @author Kim Sumin
106108
* @since 1.7
107109
*/
108110
public class RedisKeyValueAdapter extends AbstractKeyValueAdapter
@@ -126,6 +128,7 @@ public class RedisKeyValueAdapter extends AbstractKeyValueAdapter
126128
private EnableKeyspaceEvents enableKeyspaceEvents = EnableKeyspaceEvents.OFF;
127129
private @Nullable String keyspaceNotificationsConfigParameter = null;
128130
private ShadowCopy shadowCopy = ShadowCopy.DEFAULT;
131+
private DeletionStrategy deletionStrategy = DeletionStrategy.DEL;
129132

130133
/**
131134
* Lifecycle state of this factory.
@@ -134,6 +137,43 @@ enum State {
134137
CREATED, STARTING, STARTED, STOPPING, STOPPED, DESTROYED;
135138
}
136139

140+
/**
141+
* Strategy for deleting Redis keys in Repository operations.
142+
* <p>
143+
* Allows configuration of whether to use synchronous {@literal DEL} or asynchronous {@literal UNLINK} commands for
144+
* key deletion operations.
145+
*
146+
* @author [Your Name]
147+
* @since 3.6
148+
* @see <a href="https://redis.io/commands/del">Redis DEL</a>
149+
* @see <a href="https://redis.io/commands/unlink">Redis UNLINK</a>
150+
*/
151+
public enum DeletionStrategy {
152+
153+
/**
154+
* Use Redis {@literal DEL} command for key deletion.
155+
* <p>
156+
* 기key from memory. The command blocks until the key is completely removed, which can cause performance issues when
157+
* deleting large data structures under high load.
158+
* <p>
159+
* This is the default strategy for backward compatibility.
160+
*/
161+
DEL,
162+
163+
/**
164+
* Use Redis {@literal UNLINK} command for key deletion.
165+
* <p>
166+
* This is a non-blocking operation that asynchronously removes the key. The key is immediately removed from the
167+
* keyspace, but the actual memory reclamation happens in the background, providing better performance for
168+
* applications with frequent updates on existing keys.
169+
* <p>
170+
* Requires Redis 4.0 or later.
171+
*
172+
* @since Redis 4.0
173+
*/
174+
UNLINK
175+
}
176+
137177
/**
138178
* Creates new {@link RedisKeyValueAdapter} with default {@link RedisMappingContext} and default
139179
* {@link RedisCustomConversions}.
@@ -228,7 +268,7 @@ public Object put(Object id, Object item, String keyspace) {
228268
byte[] key = toBytes(rdo.getId());
229269
byte[] objectKey = createKey(rdo.getKeyspace(), rdo.getId());
230270

231-
boolean isNew = connection.del(objectKey) == 0;
271+
boolean isNew = applyDeletionStrategy(connection, objectKey) == 0;
232272

233273
connection.hMSet(objectKey, rdo.getBucket().rawMap());
234274

@@ -245,11 +285,11 @@ public Object put(Object id, Object item, String keyspace) {
245285
byte[] phantomKey = ByteUtils.concat(objectKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX);
246286

247287
if (expires(rdo)) {
248-
connection.del(phantomKey);
288+
applyDeletionStrategy(connection, phantomKey);
249289
connection.hMSet(phantomKey, rdo.getBucket().rawMap());
250290
connection.expire(phantomKey, rdo.getTimeToLive() + PHANTOM_KEY_TTL);
251291
} else if (!isNew) {
252-
connection.del(phantomKey);
292+
applyDeletionStrategy(connection, phantomKey);
253293
}
254294
}
255295

@@ -323,7 +363,7 @@ public <T> T delete(Object id, String keyspace, Class<T> type) {
323363

324364
redisOps.execute((RedisCallback<Void>) connection -> {
325365

326-
connection.del(keyToDelete);
366+
applyDeletionStrategy(connection, keyToDelete);
327367
connection.sRem(binKeyspace, binId);
328368
new IndexWriter(connection, converter).removeKeyFromIndexes(keyspace, binId);
329369

@@ -335,7 +375,7 @@ public <T> T delete(Object id, String keyspace, Class<T> type) {
335375

336376
byte[] phantomKey = ByteUtils.concat(keyToDelete, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX);
337377

338-
connection.del(phantomKey);
378+
applyDeletionStrategy(connection, phantomKey);
339379
}
340380
}
341381
return null;
@@ -485,7 +525,7 @@ public void update(PartialUpdate<?> update) {
485525
connection.persist(redisKey);
486526

487527
if (keepShadowCopy()) {
488-
connection.del(ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX));
528+
applyDeletionStrategy(connection, ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX));
489529
}
490530
}
491531
}
@@ -495,6 +535,18 @@ public void update(PartialUpdate<?> update) {
495535
});
496536
}
497537

538+
/**
539+
* Apply the configured deletion strategy to delete the given key.
540+
*
541+
* @param connection the Redis connection
542+
* @param key the key to delete
543+
* @return the number of keys that were removed
544+
*/
545+
private Long applyDeletionStrategy(RedisConnection connection, byte[] key) {
546+
return Objects
547+
.requireNonNull(deletionStrategy == DeletionStrategy.UNLINK ? connection.unlink(key) : connection.del(key));
548+
}
549+
498550
private RedisUpdateObject fetchDeletePathsFromHashAndUpdateIndex(RedisUpdateObject redisUpdateObject, String path,
499551
RedisConnection connection) {
500552

@@ -704,6 +756,30 @@ public boolean isRunning() {
704756
return State.STARTED.equals(this.state.get());
705757
}
706758

759+
/**
760+
* Configure the deletion strategy for Redis keys.
761+
* <p>
762+
* {@link DeletionStrategy#DEL DEL} performs synchronous key deletion, while {@link DeletionStrategy#UNLINK UNLINK}
763+
* performs asynchronous deletion which can improve performance under high load scenarios.
764+
*
765+
* @param deletionStrategy the strategy to use for key deletion operations
766+
* @since 3.6
767+
*/
768+
public void setDeletionStrategy(DeletionStrategy deletionStrategy) {
769+
Assert.notNull(deletionStrategy, "DeletionStrategy must not be null");
770+
this.deletionStrategy = deletionStrategy;
771+
}
772+
773+
/**
774+
* Get the current deletion strategy.
775+
*
776+
* @return the current deletion strategy
777+
* @since 3.6
778+
*/
779+
public DeletionStrategy getDeletionStrategy() {
780+
return this.deletionStrategy;
781+
}
782+
707783
/**
708784
* @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
709785
* @since 1.8
@@ -792,7 +868,7 @@ private void initKeyExpirationListener(RedisMessageListenerContainer messageList
792868

793869
if (this.expirationListener.get() == null) {
794870
MappingExpirationListener listener = new MappingExpirationListener(messageListenerContainer, this.redisOps,
795-
this.converter, this.shadowCopy);
871+
this.converter, this.shadowCopy, this.deletionStrategy);
796872

797873
listener.setKeyspaceNotificationsConfigParameter(keyspaceNotificationsConfigParameter);
798874

@@ -819,17 +895,19 @@ static class MappingExpirationListener extends KeyExpirationEventMessageListener
819895
private final RedisOperations<?, ?> ops;
820896
private final RedisConverter converter;
821897
private final ShadowCopy shadowCopy;
898+
private final DeletionStrategy deletionStrategy;
822899

823900
/**
824901
* Creates new {@link MappingExpirationListener}.
825902
*/
826903
MappingExpirationListener(RedisMessageListenerContainer listenerContainer, RedisOperations<?, ?> ops,
827-
RedisConverter converter, ShadowCopy shadowCopy) {
904+
RedisConverter converter, ShadowCopy shadowCopy, DeletionStrategy deletionStrategy) {
828905

829906
super(listenerContainer);
830907
this.ops = ops;
831908
this.converter = converter;
832909
this.shadowCopy = shadowCopy;
910+
this.deletionStrategy = deletionStrategy;
833911
}
834912

835913
@Override
@@ -883,7 +961,11 @@ private Object readShadowCopy(byte[] key) {
883961
Map<byte[], byte[]> phantomValue = connection.hGetAll(phantomKey);
884962

885963
if (!CollectionUtils.isEmpty(phantomValue)) {
886-
connection.del(phantomKey);
964+
if (deletionStrategy == DeletionStrategy.UNLINK) {
965+
connection.unlink(phantomKey);
966+
} else {
967+
connection.del(phantomKey);
968+
}
887969
}
888970

889971
return phantomValue;

src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.springframework.context.annotation.Import;
2929
import org.springframework.data.keyvalue.core.KeyValueOperations;
3030
import org.springframework.data.keyvalue.repository.config.QueryCreatorType;
31+
import org.springframework.data.redis.core.RedisKeyValueAdapter.DeletionStrategy;
3132
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
3233
import org.springframework.data.redis.core.RedisKeyValueAdapter.ShadowCopy;
3334
import org.springframework.data.redis.core.RedisOperations;
@@ -47,6 +48,7 @@
4748
*
4849
* @author Christoph Strobl
4950
* @author Mark Paluch
51+
* @author Kim Sumin
5052
* @since 1.7
5153
*/
5254
@Target(ElementType.TYPE)
@@ -129,7 +131,9 @@
129131

130132
/**
131133
* Configure a specific {@link BeanNameGenerator} to be used when creating the repositoy beans.
132-
* @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate context default.
134+
*
135+
* @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate
136+
* context default.
133137
* @since 3.4
134138
*/
135139
Class<? extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class;
@@ -204,4 +208,20 @@
204208
*/
205209
String keyspaceNotificationsConfigParameter() default "Ex";
206210

211+
/**
212+
* Configure the deletion strategy for Redis keys during repository operations.
213+
* <p>
214+
* {@link DeletionStrategy#DEL DEL} uses synchronous deletion (blocking), while {@link DeletionStrategy#UNLINK UNLINK}
215+
* uses asynchronous deletion (non-blocking).
216+
* <p>
217+
* {@literal UNLINK} can provide better performance for applications with frequent updates on existing keys,
218+
* especially when dealing with large data structures under high load.
219+
* <p>
220+
* Requires Redis 4.0 or later when using {@link DeletionStrategy#UNLINK}.
221+
*
222+
* @return the deletion strategy to use
223+
* @since 3.6
224+
* @see DeletionStrategy
225+
*/
226+
DeletionStrategy deletionStrategy() default DeletionStrategy.DEL;
207227
}

src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension;
2929
import org.springframework.data.redis.core.RedisHash;
3030
import org.springframework.data.redis.core.RedisKeyValueAdapter;
31+
import org.springframework.data.redis.core.RedisKeyValueAdapter.DeletionStrategy;
3132
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
3233
import org.springframework.data.redis.core.RedisKeyValueAdapter.ShadowCopy;
3334
import org.springframework.data.redis.core.RedisKeyValueTemplate;
@@ -44,6 +45,7 @@
4445
*
4546
* @author Christoph Strobl
4647
* @author Mark Paluch
48+
* @author Kim Sumin
4749
* @since 1.7
4850
*/
4951
public class RedisRepositoryConfigurationExtension extends KeyValueRepositoryConfigurationExtension {
@@ -145,7 +147,9 @@ private static AbstractBeanDefinition createRedisKeyValueAdapter(RepositoryConfi
145147
configuration.getRequiredAttribute("enableKeyspaceEvents", EnableKeyspaceEvents.class)) //
146148
.addPropertyValue("keyspaceNotificationsConfigParameter",
147149
configuration.getAttribute("keyspaceNotificationsConfigParameter", String.class).orElse("")) //
148-
.addPropertyValue("shadowCopy", configuration.getRequiredAttribute("shadowCopy", ShadowCopy.class));
150+
.addPropertyValue("shadowCopy", configuration.getRequiredAttribute("shadowCopy", ShadowCopy.class))
151+
.addPropertyValue("deletionStrategy",
152+
configuration.getRequiredAttribute("deletionStrategy", DeletionStrategy.class));
149153

150154
configuration.getAttribute("messageListenerContainerRef")
151155
.ifPresent(it -> builder.addPropertyReference("messageListenerContainer", it));

src/test/java/org/springframework/data/redis/core/MappingExpirationListenerTest.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
/**
4141
* @author Lucian Torje
4242
* @author Christoph Strobl
43+
* @author Kim Sumin
4344
*/
4445
@ExtendWith(MockitoExtension.class)
4546
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -58,7 +59,7 @@ void testOnNonKeyExpiration() {
5859
byte[] key = "testKey".getBytes();
5960
when(message.getBody()).thenReturn(key);
6061
listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter,
61-
RedisKeyValueAdapter.ShadowCopy.ON);
62+
RedisKeyValueAdapter.ShadowCopy.ON, RedisKeyValueAdapter.DeletionStrategy.DEL);
6263

6364
listener.onMessage(message, null);
6465

@@ -74,7 +75,7 @@ void testOnValidKeyExpirationWithShadowCopiesDisabled() {
7475
when(message.getBody()).thenReturn(key);
7576

7677
listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter,
77-
RedisKeyValueAdapter.ShadowCopy.OFF);
78+
RedisKeyValueAdapter.ShadowCopy.OFF, RedisKeyValueAdapter.DeletionStrategy.DEL);
7879
listener.setApplicationEventPublisher(eventList::add);
7980
listener.onMessage(message, null);
8081

@@ -97,7 +98,7 @@ void testOnValidKeyExpirationWithShadowCopiesEnabled() {
9798
when(conversionService.convert(any(), eq(byte[].class))).thenReturn("foo".getBytes());
9899

99100
listener = new RedisKeyValueAdapter.MappingExpirationListener(listenerContainer, redisOperations, redisConverter,
100-
RedisKeyValueAdapter.ShadowCopy.ON);
101+
RedisKeyValueAdapter.ShadowCopy.ON, RedisKeyValueAdapter.DeletionStrategy.DEL);
101102
listener.setApplicationEventPublisher(eventList::add);
102103
listener.onMessage(message, null);
103104

src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
* @author Christoph Strobl
5757
* @author Mark Paluch
5858
* @author Andrey Muchnik
59+
* @author Kim Sumin
5960
*/
6061
@ExtendWith(LettuceConnectionFactoryExtension.class)
6162
public class RedisKeyValueAdapterTests {
@@ -788,6 +789,61 @@ void updateWithRefreshTtlAndWithoutPositiveTtlShouldDeletePhantomKey() {
788789
assertThat(template.hasKey("persons:1:phantom")).isFalse();
789790
}
790791

792+
@Test // GH-2294
793+
void shouldUseDELByDefault() {
794+
// given
795+
RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext);
796+
797+
// when & then
798+
assertThat(adapter.getDeletionStrategy()).isEqualTo(RedisKeyValueAdapter.DeletionStrategy.DEL);
799+
}
800+
801+
@Test // GH -2294
802+
void shouldAllowUNLINKConfiguration() {
803+
// given
804+
RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext);
805+
806+
// when
807+
adapter.setDeletionStrategy(RedisKeyValueAdapter.DeletionStrategy.UNLINK);
808+
809+
// then
810+
assertThat(adapter.getDeletionStrategy()).isEqualTo(RedisKeyValueAdapter.DeletionStrategy.UNLINK);
811+
}
812+
813+
@Test // GH-2294
814+
void shouldRejectNullDeletionStrategy() {
815+
// given
816+
RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(template, mappingContext);
817+
818+
// when & then
819+
assertThatIllegalArgumentException().isThrownBy(() -> adapter.setDeletionStrategy(null))
820+
.withMessageContaining("DeletionStrategy must not be null");
821+
}
822+
823+
@Test // GH-2294
824+
void shouldMaintainFunctionalityWithUNLINKStrategy() {
825+
// given
826+
adapter.setDeletionStrategy(RedisKeyValueAdapter.DeletionStrategy.UNLINK);
827+
828+
Person person = new Person();
829+
person.id = "unlink-test";
830+
person.firstname = "test";
831+
832+
// when & then
833+
adapter.put(person.id, person, "persons");
834+
assertThat(adapter.get(person.id, "persons", Person.class)).isNotNull();
835+
836+
person.firstname = "updated";
837+
adapter.put(person.id, person, "persons");
838+
839+
Person result = adapter.get(person.id, "persons", Person.class);
840+
assertThat(result.firstname).isEqualTo("updated");
841+
842+
adapter.delete(person.id, "persons");
843+
assertThat(adapter.get(person.id, "persons", Person.class)).isNull();
844+
}
845+
846+
791847
/**
792848
* Wait up to 5 seconds until {@code key} is no longer available in Redis.
793849
*

0 commit comments

Comments
 (0)