Skip to content

Commit 0000434

Browse files
artembilancppwfs
authored andcommitted
GH-11180: Fix caching logic in the RedisLockRegistry (#11181)
Fixes: #11180 The previous solution, based on the shared pool of `ReentrantLock` from the `DefaultLockRegistry`, is proven to be inconvenient with keys collision: different keys might stumble on the same shared `ReentrantLock` making the application being blocked for nothing. * Remove `DefaultLockRegistry` logic from the `RedisLockRegistry` * Remove local `RedisLock.holdCount` as we rely on the `localLock` again * Revert back default `cacheCapacity` to `100_000` as we don't use a shared pool of locks anymore * Do not evict from the cache still in-held locks * In the `obtain()`, try to remove unused lock when `cacheCapacity` is reached. If it cannot clean up the room, throw a `CannotAcquireLockException`. This is to prevent an out-of-memory error * Also, throw a `CannotAcquireLockException` on lock calls for an orphaned lock. This is to prevent a race condition when two distinct local locks are locked in different threads against the same key * Add human-readable `toString()` into the `RedisLockRegistry` **Auto-cherry-pick to `7.0.x`**
1 parent 91f5426 commit 0000434

3 files changed

Lines changed: 113 additions & 92 deletions

File tree

spring-integration-redis/src/main/java/org/springframework/integration/redis/util/RedisLockRegistry.java

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@
5959
import org.springframework.data.redis.listener.PatternTopic;
6060
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
6161
import org.springframework.data.redis.listener.Topic;
62-
import org.springframework.integration.support.locks.DefaultLockRegistry;
6362
import org.springframework.integration.support.locks.DistributedLock;
6463
import org.springframework.integration.support.locks.ExpirableLockRegistry;
6564
import org.springframework.integration.support.locks.RenewableLockRegistry;
@@ -79,14 +78,21 @@
7978
* Locks are reentrant.
8079
* <p>
8180
* <b>However, locks are scoped by the registry; a lock from a different registry with the
82-
* same key (even if the registry uses the same 'registryKey') are different
81+
* same key (even if the registry uses the same 'registryKey') is different
8382
* locks, and the second cannot be acquired by the same thread while the first is
8483
* locked.</b>
8584
* <p>
86-
* <b>Note: This is not intended for low latency applications.</b> It is intended
85+
* <b>Note: This is not intended for low-latency applications.</b> It is intended
8786
* for resource locking across multiple JVMs.
8887
* <p>
8988
* {@link Condition}s are not supported.
89+
* <p>
90+
* The lock instances are cached by their key for future reuse.
91+
* If all the locks in the cache are acquired,
92+
* the {@link #obtain(Object)} method throws {@link CannotAcquireLockException}.
93+
* Otherwise, the old, unused locks are evicted from the cache.
94+
* An attempt to lock such an orphaned {@link RedisLock} throws another {@link CannotAcquireLockException}
95+
* to avoid double locking from different threads on the same key.
9096
*
9197
* @author Gary Russell
9298
* @author Konstantin Yakimov
@@ -114,16 +120,14 @@ public final class RedisLockRegistry
114120

115121
private static final long DEFAULT_EXPIRE_AFTER = 60000L;
116122

117-
private static final int DEFAULT_CAPACITY = 256;
123+
private static final int DEFAULT_CAPACITY = 100_000;
118124

119125
private static final int DEFAULT_IDLE = 100;
120126

121127
private final Lock lock = new ReentrantLock();
122128

123129
private Duration idleBetweenTries = Duration.ofMillis(DEFAULT_IDLE);
124130

125-
private DefaultLockRegistry defaultLockRegistry = new DefaultLockRegistry();
126-
127131
private final Map<String, RedisLock> locks =
128132
new LinkedHashMap<>(16, 0.75F, true) {
129133

@@ -132,7 +136,8 @@ public final class RedisLockRegistry
132136

133137
@Override
134138
protected boolean removeEldestEntry(Entry<String, RedisLock> eldest) {
135-
return size() > RedisLockRegistry.this.cacheCapacity;
139+
return size() > RedisLockRegistry.this.cacheCapacity
140+
&& !eldest.getValue().isAcquiredInThisProcess();
136141
}
137142

138143
};
@@ -249,14 +254,18 @@ public void setRenewalTaskScheduler(TaskScheduler renewalTaskScheduler) {
249254

250255
/**
251256
* Set the capacity of cached locks.
252-
* @param cacheCapacity The capacity of cached lock, (default 256 locks).
257+
* If the number of currently cached locks exceeds the new capacity,
258+
* this method tries to evict unused locks.
259+
* @param cacheCapacity The capacity of cached lock, (default 100_000).
253260
* @since 5.5.6
261+
* @see #expireUnusedOlderThan(long)
254262
*/
255263
public void setCacheCapacity(int cacheCapacity) {
264+
Assert.isTrue(cacheCapacity > 0, "'cacheCapacity' must be greater than 0");
265+
if (this.locks.size() > cacheCapacity) {
266+
expireUnusedOlderThan(0);
267+
}
256268
this.cacheCapacity = cacheCapacity;
257-
// Find the highest power of 2 for (n + 1), then subtract 1 to get the mask
258-
int mask = Integer.highestOneBit(cacheCapacity + 1) - 1;
259-
this.defaultLockRegistry = new DefaultLockRegistry(mask);
260269
}
261270

262271
/**
@@ -290,7 +299,22 @@ public DistributedLock obtain(Object lockKey) {
290299
String path = (String) lockKey;
291300
this.lock.lock();
292301
try {
293-
return this.locks.computeIfAbsent(path, getRedisLockConstructor(this.redisLockType));
302+
RedisLock redisLock = this.locks.computeIfAbsent(path, getRedisLockConstructor(this.redisLockType));
303+
if (!redisLock.isAcquiredInThisProcess() && this.locks.size() > this.cacheCapacity) {
304+
this.locks.entrySet()
305+
.removeIf(entry -> {
306+
RedisLock lock = entry.getValue();
307+
return lock != redisLock && !lock.isAcquiredInThisProcess();
308+
});
309+
310+
if (this.locks.size() > this.cacheCapacity) {
311+
this.locks.remove(path);
312+
throw new CannotAcquireLockException("There are already " +
313+
this.cacheCapacity + " unique Redis locks acquired in the application " +
314+
"from " + this + ". Cannot obtain more at the moment.");
315+
}
316+
}
317+
return redisLock;
294318
}
295319
finally {
296320
this.lock.unlock();
@@ -305,11 +329,7 @@ public void expireUnusedOlderThan(long age) {
305329
this.locks.entrySet()
306330
.removeIf(entry -> {
307331
RedisLock lock = entry.getValue();
308-
long lockedAt = lock.getLockedAt();
309-
return now - lockedAt > age
310-
// 'lockedAt = 0' means that the lock is still not acquired!
311-
&& lockedAt > 0
312-
&& !lock.isAcquiredInThisProcess();
332+
return now - lock.getLockedAt() >= age && !lock.isAcquiredInThisProcess();
313333
});
314334
}
315335
finally {
@@ -359,6 +379,13 @@ public void renewLock(Object lockKey, Duration ttl) {
359379
}
360380
}
361381

382+
@Override
383+
public String toString() {
384+
return "RedisLockRegistry{" +
385+
"registryKey=" + this.registryKey +
386+
'}';
387+
}
388+
362389
/**
363390
* The mode in which this registry is going to work with locks.
364391
*/
@@ -410,19 +437,19 @@ private abstract class RedisLock implements DistributedLock {
410437
public static final RedisScript<Boolean> RENEW_REDIS_SCRIPT =
411438
new DefaultRedisScript<>(RENEW_SCRIPT, Boolean.class);
412439

413-
protected final String lockKey;
440+
private final String key;
414441

415-
private final ReentrantLock localLock;
442+
protected final String lockKey;
416443

417-
private int holdCount;
444+
private final ReentrantLock localLock = new ReentrantLock();
418445

419-
private volatile long lockedAt;
446+
private volatile long lockedAt = System.currentTimeMillis();
420447

421448
private volatile @Nullable ScheduledFuture<?> renewFuture;
422449

423450
private RedisLock(String path) {
451+
this.key = path;
424452
this.lockKey = constructLockKey(path);
425-
this.localLock = (ReentrantLock) RedisLockRegistry.this.defaultLockRegistry.obtain(this.lockKey);
426453
}
427454

428455
private String constructLockKey(String path) {
@@ -460,7 +487,6 @@ public void lock(Duration ttl) {
460487
while (true) {
461488
try {
462489
if (tryRedisLock(-1L, ttl.toMillis())) {
463-
this.holdCount++;
464490
return;
465491
}
466492
}
@@ -488,7 +514,6 @@ public final void lockInterruptibly() throws InterruptedException {
488514
while (true) {
489515
try {
490516
if (tryRedisLock(-1L, RedisLockRegistry.this.expireAfter.toMillis())) {
491-
this.holdCount++;
492517
return;
493518
}
494519
}
@@ -530,9 +555,6 @@ public boolean tryLock(Duration waitTime, Duration ttl) throws InterruptedExcept
530555
if (!acquired) {
531556
this.localLock.unlock();
532557
}
533-
else {
534-
this.holdCount++;
535-
}
536558
return acquired;
537559
}
538560
catch (Exception e) {
@@ -543,6 +565,10 @@ public boolean tryLock(Duration waitTime, Duration ttl) throws InterruptedExcept
543565
}
544566

545567
private boolean tryRedisLock(long time, long expireAfter) throws ExecutionException, InterruptedException {
568+
if (!RedisLockRegistry.this.locks.containsKey(this.key)) {
569+
throw new IllegalStateException("The lock '" + this.key + "' was evicted from the exhausted cache. " +
570+
"Obtain a fresh one from " + RedisLockRegistry.this);
571+
}
546572
final boolean acquired = tryRedisLockInner(time, expireAfter);
547573
if (acquired) {
548574
if (LOGGER.isDebugEnabled()) {
@@ -571,8 +597,7 @@ public final void unlock() {
571597
if (!this.localLock.isHeldByCurrentThread()) {
572598
throw new IllegalStateException("You do not own lock at " + this.lockKey);
573599
}
574-
if (this.holdCount > 1) {
575-
this.holdCount--;
600+
if (this.localLock.getHoldCount() > 1) {
576601
this.localLock.unlock();
577602
return;
578603
}
@@ -617,7 +642,6 @@ public final void unlock() {
617642
ReflectionUtils.rethrowRuntimeException(e);
618643
}
619644
finally {
620-
this.holdCount--;
621645
this.localLock.unlock();
622646
}
623647
}
@@ -667,7 +691,7 @@ public String toString() {
667691
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd@HH:mm:ss.SSS");
668692
return "RedisLock [lockKey=" + this.lockKey
669693
+ ",lockedAt=" + dateFormat.format(new Date(this.lockedAt))
670-
+ ", clientId=" + RedisLockRegistry.this.clientId
694+
+ ", clientId=" + RedisLockRegistry.this
671695
+ "]";
672696
}
673697

@@ -676,7 +700,7 @@ public int hashCode() {
676700
final int prime = 31;
677701
int result = 1;
678702
result = prime * result + getOuterType().hashCode();
679-
result = prime * result + ((this.lockKey == null) ? 0 : this.lockKey.hashCode());
703+
result = prime * result + this.lockKey.hashCode();
680704
result = prime * result + Long.hashCode(this.lockedAt);
681705
result = prime * result + RedisLockRegistry.this.clientId.hashCode();
682706
return result;
@@ -687,9 +711,6 @@ public boolean equals(Object obj) {
687711
if (this == obj) {
688712
return true;
689713
}
690-
if (obj == null) {
691-
return false;
692-
}
693714
if (getClass() != obj.getClass()) {
694715
return false;
695716
}
@@ -763,7 +784,7 @@ private boolean subscribeLock(long time, long expireAfter) throws ExecutionExcep
763784
return true;
764785
}
765786
try {
766-
//if short expireAfter key expire for ttl, no receive unlock msg
787+
//if short expireAfter key expire for ttl, no received unlock msg
767788
long waitTime = time >= 0 ? time : RedisLockRegistry.this.expireAfter.toMillis();
768789
future.get(waitTime, TimeUnit.MILLISECONDS);
769790
}

spring-integration-redis/src/test/java/org/springframework/integration/redis/util/RedisLockRegistryTests.java

Lines changed: 50 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.junit.jupiter.params.ParameterizedTest;
4848
import org.junit.jupiter.params.provider.EnumSource;
4949

50+
import org.springframework.dao.CannotAcquireLockException;
5051
import org.springframework.data.redis.connection.RedisConnectionFactory;
5152
import org.springframework.data.redis.core.StringRedisTemplate;
5253
import org.springframework.integration.redis.RedisContainerTest;
@@ -1019,7 +1020,7 @@ void testInitialiseWithCustomExecutor() {
10191020
}
10201021

10211022
@Test
1022-
void noSecondLockOnEviction() throws InterruptedException {
1023+
void notUsedLockIsEvictedOnCacheLimit() throws InterruptedException {
10231024
RedisLockRegistry registry = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
10241025
registry.setRedisLockType(RedisLockType.SPIN_LOCK);
10251026
registry.setCacheCapacity(2);
@@ -1044,11 +1045,11 @@ void noSecondLockOnEviction() throws InterruptedException {
10441045
});
10451046

10461047
assertThat(furtherLocksLatch.await(10, TimeUnit.SECONDS)).isTrue();
1047-
// Two new locks to trigger cache eviction for the 'lock1'
1048+
// Two new locks to overcharge the cache.
1049+
// The 'lock2' is evicted by 'lock3' because it is not locked.
10481050
registry.obtain("lock2");
10491051
registry.obtain("lock3");
10501052

1051-
// Request 'lock1' again: will trigger new RedisLock instance
10521053
DistributedLock lock1 = registry.obtain("lock1");
10531054

10541055
// Cannot lock because 'lock1' is still locked by another thread
@@ -1062,6 +1063,52 @@ void noSecondLockOnEviction() throws InterruptedException {
10621063
});
10631064
}
10641065

1066+
@ParameterizedTest
1067+
@EnumSource(RedisLockType.class)
1068+
void cannotAcquireLockExceptionOnCacheLimit(RedisLockType redisLockType) {
1069+
RedisLockRegistry registry = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
1070+
registry.setRedisLockType(redisLockType);
1071+
registry.setCacheCapacity(2);
1072+
1073+
DistributedLock lock1 = registry.obtain("lock1");
1074+
lock1.lock();
1075+
try {
1076+
DistributedLock lock2 = registry.obtain("lock2");
1077+
lock2.lock();
1078+
try {
1079+
assertThatExceptionOfType(CannotAcquireLockException.class)
1080+
.isThrownBy(() -> registry.obtain("lock3"))
1081+
.withMessageStartingWith("There are already 2 unique Redis locks acquired in the application")
1082+
.withMessageEndingWith("Cannot obtain more at the moment.");
1083+
}
1084+
finally {
1085+
lock2.unlock();
1086+
}
1087+
}
1088+
finally {
1089+
lock1.unlock();
1090+
}
1091+
registry.destroy();
1092+
}
1093+
1094+
@ParameterizedTest
1095+
@EnumSource(RedisLockType.class)
1096+
void unusedLockIsStale(RedisLockType redisLockType) {
1097+
RedisLockRegistry registry = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
1098+
registry.setRedisLockType(redisLockType);
1099+
registry.setCacheCapacity(1);
1100+
1101+
DistributedLock lock1 = registry.obtain("lock1");
1102+
registry.obtain("lock2");
1103+
1104+
assertThatExceptionOfType(CannotAcquireLockException.class)
1105+
.isThrownBy(lock1::lock)
1106+
.havingCause()
1107+
.isInstanceOf(IllegalStateException.class)
1108+
.withMessageStartingWith("The lock 'lock1' was evicted from the exhausted cache.");
1109+
registry.destroy();
1110+
}
1111+
10651112
@Test
10661113
void testUnlockWhenInterruptedBlocksUntilKeyRemoved() throws Exception {
10671114
RedisLockRegistry registry = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
@@ -1133,55 +1180,6 @@ private void waitForExpire(String key) throws Exception {
11331180
assertThat(n < 100).as(key + " key did not expire").isTrue();
11341181
}
11351182

1136-
@Test
1137-
void sharedDelegateIsDeletedFromRedisOnUnlock() throws Exception {
1138-
RedisLockRegistry registry1 = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
1139-
// setCacheCapacity(2) gives mask=1 in DefaultLockRegistry → only 2 slots, guaranteed collision in ≤3 keys
1140-
registry1.setCacheCapacity(2);
1141-
RedisLockRegistry registry2 = new RedisLockRegistry(redisConnectionFactory, this.registryKey);
1142-
1143-
// Find two distinct lock keys that share the same ReentrantLock (same DefaultLockRegistry slot)
1144-
String keyB = null;
1145-
DistributedLock lockA = null;
1146-
DistributedLock lockB = null;
1147-
outer:
1148-
for (int i = 0; i < 10; i++) {
1149-
for (int j = i + 1; j < 10; j++) {
1150-
DistributedLock la = registry1.obtain("key-" + i);
1151-
DistributedLock lb = registry1.obtain("key-" + j);
1152-
if (TestUtils.getPropertyValue(la, "localLock") == TestUtils.getPropertyValue(lb, "localLock")) {
1153-
keyB = "key-" + j;
1154-
lockA = la;
1155-
lockB = lb;
1156-
break outer;
1157-
}
1158-
}
1159-
}
1160-
assertThat(lockA)
1161-
.as("Could not find two lock keys mapping to the same DefaultLockRegistry slot")
1162-
.isNotNull();
1163-
1164-
lockA.lock();
1165-
// Shared localLock: localLock.holdCount becomes 2 after this call
1166-
lockB.lock();
1167-
1168-
// Unlock B first — before the fix, localLock.getHoldCount()>1 short-circuited and skipped the Redis delete
1169-
lockB.unlock();
1170-
1171-
// Another process must be able to acquire keyB immediately (its Redis key must be deleted)
1172-
DistributedLock lockBOtherProcess = registry2.obtain(keyB);
1173-
assertThat(lockBOtherProcess.tryLock(500, TimeUnit.MILLISECONDS))
1174-
.as("Redis key for '" + keyB + "' was not deleted on unlock — orphaned lock detected")
1175-
.isTrue();
1176-
lockBOtherProcess.unlock();
1177-
1178-
assertThatNoException().isThrownBy(lockA::unlock);
1179-
1180-
registry1.destroy();
1181-
registry2.destroy();
1182-
}
1183-
1184-
@SuppressWarnings("unchecked")
11851183
private static Map<String, Lock> getRedisLockRegistryLocks(RedisLockRegistry registry) {
11861184
return TestUtils.getPropertyValue(registry, "locks", Map.class);
11871185
}

0 commit comments

Comments
 (0)