feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)
This commit is contained in:
@@ -14,15 +14,21 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -133,4 +139,105 @@ class RedisCacheEnhanceTemplateTest {
|
||||
assertNull(template.get("user:1", User.class));
|
||||
verify(redis, never()).expire(any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadHitDoesNotCallLoader() {
|
||||
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
Supplier<User> loader = () -> {
|
||||
calls.incrementAndGet();
|
||||
return new User(9L, "x");
|
||||
};
|
||||
User got = template.getOrLoad("user:1", User.class, 60, loader);
|
||||
assertEquals("a", got.getName());
|
||||
assertEquals(0, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadNullSentinelHitReturnsNullNoLoader() {
|
||||
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
assertNull(template.getOrLoad("user:1", User.class, 60, () -> {
|
||||
calls.incrementAndGet();
|
||||
return new User(1L, "a");
|
||||
}));
|
||||
assertEquals(0, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadMissAcquiresLockLoadsAndCaches() {
|
||||
props.setJitterRatio(0);
|
||||
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
User loaded = new User(1L, "a");
|
||||
User got = template.getOrLoad("user:1", User.class, 60, () -> loaded);
|
||||
assertEquals("a", got.getName());
|
||||
verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS);
|
||||
// 释放锁:执行了 Lua 脚本
|
||||
verify(redis).execute(any(RedisScript.class), anyList(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadLoaderReturnsNullWritesSentinel() {
|
||||
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
assertNull(template.getOrLoad("user:1", User.class, 60, () -> null));
|
||||
verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL,
|
||||
props.getNullTtlSeconds(), TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadNotLockedWaitsThenReadsValue() {
|
||||
props.getBreakdown().setWaitIntervalMs(1);
|
||||
props.getBreakdown().setWaitTimeoutMs(1000);
|
||||
// 初始 tryGet 返回 null,未抢到锁,随后等待中读到值
|
||||
when(valueOps.get("cache:user:1"))
|
||||
.thenReturn(null)
|
||||
.thenReturn(null)
|
||||
.thenReturn(JSON.toJSONString(new User(1L, "a")));
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
User got = template.getOrLoad("user:1", User.class, 60, () -> {
|
||||
calls.incrementAndGet();
|
||||
return new User(9L, "x");
|
||||
});
|
||||
assertEquals("a", got.getName());
|
||||
assertEquals(0, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadWaitTimeoutFallsBackToLoader() {
|
||||
props.getBreakdown().setWaitIntervalMs(1);
|
||||
props.getBreakdown().setWaitTimeoutMs(5);
|
||||
when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中
|
||||
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
User got = template.getOrLoad("user:1", User.class, 60, () -> {
|
||||
calls.incrementAndGet();
|
||||
return new User(7L, "fallback");
|
||||
});
|
||||
assertEquals("fallback", got.getName());
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadBreakdownDisabledLoadsWithoutLock() {
|
||||
props.getBreakdown().setEnabled(false);
|
||||
when(valueOps.get("cache:user:1")).thenReturn(null);
|
||||
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a"));
|
||||
assertEquals("a", got.getName());
|
||||
verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOrLoadInternalDecodeFailureFallsBackToReload() {
|
||||
// 初始读到脏数据(当未命中降级),抢到锁后回源重建
|
||||
when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty");
|
||||
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh"));
|
||||
assertEquals("fresh", got.getName());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user