Skip to content

Commit 6533bd7

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: kssumin <[email protected]>
1 parent 5029094 commit 6533bd7

File tree

6 files changed

+209
-14
lines changed

6 files changed

+209
-14
lines changed

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

Lines changed: 93 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,9 @@ 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+
// 제거
272+
// boolean isNew = connection.del(objectKey) == 0;
273+
boolean isNew = applyDeletionStrategy(connection, objectKey) == 0;
232274

233275
connection.hMSet(objectKey, rdo.getBucket().rawMap());
234276

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

247289
if (expires(rdo)) {
248-
connection.del(phantomKey);
290+
applyDeletionStrategy(connection, phantomKey);
249291
connection.hMSet(phantomKey, rdo.getBucket().rawMap());
250292
connection.expire(phantomKey, rdo.getTimeToLive() + PHANTOM_KEY_TTL);
251293
} else if (!isNew) {
252-
connection.del(phantomKey);
294+
applyDeletionStrategy(connection, phantomKey);
253295
}
254296
}
255297

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

324366
redisOps.execute((RedisCallback<Void>) connection -> {
325367

326-
connection.del(keyToDelete);
368+
applyDeletionStrategy(connection, keyToDelete);
327369
connection.sRem(binKeyspace, binId);
328370
new IndexWriter(connection, converter).removeKeyFromIndexes(keyspace, binId);
329371

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

336378
byte[] phantomKey = ByteUtils.concat(keyToDelete, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX);
337379

338-
connection.del(phantomKey);
380+
applyDeletionStrategy(connection, phantomKey);
339381
}
340382
}
341383
return null;
@@ -485,7 +527,7 @@ public void update(PartialUpdate<?> update) {
485527
connection.persist(redisKey);
486528

487529
if (keepShadowCopy()) {
488-
connection.del(ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX));
530+
applyDeletionStrategy(connection, ByteUtils.concat(redisKey, BinaryKeyspaceIdentifier.PHANTOM_SUFFIX));
489531
}
490532
}
491533
}
@@ -495,6 +537,18 @@ public void update(PartialUpdate<?> update) {
495537
});
496538
}
497539

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

@@ -704,6 +758,30 @@ public boolean isRunning() {
704758
return State.STARTED.equals(this.state.get());
705759
}
706760

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

793871
if (this.expirationListener.get() == null) {
794872
MappingExpirationListener listener = new MappingExpirationListener(messageListenerContainer, this.redisOps,
795-
this.converter, this.shadowCopy);
873+
this.converter, this.shadowCopy, this.deletionStrategy);
796874

797875
listener.setKeyspaceNotificationsConfigParameter(keyspaceNotificationsConfigParameter);
798876

@@ -819,17 +897,19 @@ static class MappingExpirationListener extends KeyExpirationEventMessageListener
819897
private final RedisOperations<?, ?> ops;
820898
private final RedisConverter converter;
821899
private final ShadowCopy shadowCopy;
900+
private final DeletionStrategy deletionStrategy;
822901

823902
/**
824903
* Creates new {@link MappingExpirationListener}.
825904
*/
826905
MappingExpirationListener(RedisMessageListenerContainer listenerContainer, RedisOperations<?, ?> ops,
827-
RedisConverter converter, ShadowCopy shadowCopy) {
906+
RedisConverter converter, ShadowCopy shadowCopy, DeletionStrategy deletionStrategy) {
828907

829908
super(listenerContainer);
830909
this.ops = ops;
831910
this.converter = converter;
832911
this.shadowCopy = shadowCopy;
912+
this.deletionStrategy = deletionStrategy;
833913
}
834914

835915
@Override
@@ -883,7 +963,11 @@ private Object readShadowCopy(byte[] key) {
883963
Map<byte[], byte[]> phantomValue = connection.hGetAll(phantomKey);
884964

885965
if (!CollectionUtils.isEmpty(phantomValue)) {
886-
connection.del(phantomKey);
966+
if (deletionStrategy == DeletionStrategy.UNLINK) {
967+
connection.unlink(phantomKey);
968+
} else {
969+
connection.del(phantomKey);
970+
}
887971
}
888972

889973
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)