feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)

This commit is contained in:
root
2026-06-26 04:55:42 +08:00
parent d164b89067
commit 037fdc4f9c
2 changed files with 256 additions and 0 deletions

View File

@@ -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());
}
}