feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)
This commit is contained in:
@@ -1,15 +1,21 @@
|
|||||||
package com.njcn.middle.cache.template;
|
package com.njcn.middle.cache.template;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
import com.alibaba.fastjson.TypeReference;
|
import com.alibaba.fastjson.TypeReference;
|
||||||
import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
|
import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
|
||||||
|
import com.njcn.middle.cache.constant.CacheConstant;
|
||||||
import com.njcn.middle.cache.support.CacheKeyBuilder;
|
import com.njcn.middle.cache.support.CacheKeyBuilder;
|
||||||
import com.njcn.middle.cache.support.CacheValueCodec;
|
import com.njcn.middle.cache.support.CacheValueCodec;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 缓存增强模板。
|
* Redis 缓存增强模板。
|
||||||
@@ -20,6 +26,11 @@ import java.util.function.Function;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class RedisCacheEnhanceTemplate {
|
public class RedisCacheEnhanceTemplate {
|
||||||
|
|
||||||
|
/** 释放锁 Lua:token 匹配才删,防误删他人锁。 */
|
||||||
|
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
||||||
|
Long.class);
|
||||||
|
|
||||||
private final StringRedisTemplate redis;
|
private final StringRedisTemplate redis;
|
||||||
private final CacheKeyBuilder keyBuilder;
|
private final CacheKeyBuilder keyBuilder;
|
||||||
private final CacheValueCodec codec;
|
private final CacheValueCodec codec;
|
||||||
@@ -113,4 +124,142 @@ public class RedisCacheEnhanceTemplate {
|
|||||||
}
|
}
|
||||||
return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1);
|
return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 读取或回源(三防核心)----
|
||||||
|
|
||||||
|
public <T> T getOrLoad(String key, Class<T> type, Supplier<T> loader) {
|
||||||
|
return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader) {
|
||||||
|
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T getOrLoad(String key, TypeReference<T> type, long ttlSeconds, Supplier<T> loader) {
|
||||||
|
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T doGetOrLoad(String key, Function<String, T> decoder, long ttlSeconds, Supplier<T> loader) {
|
||||||
|
String dataKey = keyBuilder.build(key);
|
||||||
|
|
||||||
|
Lookup<T> r = tryGet(dataKey, decoder);
|
||||||
|
if (r.hit) {
|
||||||
|
if (r.realValue) {
|
||||||
|
slidingTouch(dataKey);
|
||||||
|
}
|
||||||
|
return r.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisCacheProperties.Breakdown bd = props.getBreakdown();
|
||||||
|
if (!bd.isEnabled()) {
|
||||||
|
return loadAndCache(dataKey, ttlSeconds, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
String lockKey = dataKey + CacheConstant.LOCK_SUFFIX;
|
||||||
|
String token = IdUtil.fastSimpleUUID();
|
||||||
|
boolean locked = Boolean.TRUE.equals(redis.opsForValue()
|
||||||
|
.setIfAbsent(lockKey, token, Duration.ofMillis(bd.getLockTtlMs())));
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
try {
|
||||||
|
Lookup<T> r2 = tryGet(dataKey, decoder); // double-check
|
||||||
|
if (r2.hit) {
|
||||||
|
if (r2.realValue) {
|
||||||
|
slidingTouch(dataKey);
|
||||||
|
}
|
||||||
|
return r2.value;
|
||||||
|
}
|
||||||
|
return loadAndCache(dataKey, ttlSeconds, loader);
|
||||||
|
} finally {
|
||||||
|
releaseLock(lockKey, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未抢到锁:自旋等待重读,超时降级直接回源
|
||||||
|
long waited = 0;
|
||||||
|
while (waited < bd.getWaitTimeoutMs()) {
|
||||||
|
sleep(bd.getWaitIntervalMs());
|
||||||
|
waited += bd.getWaitIntervalMs();
|
||||||
|
Lookup<T> r3 = tryGet(dataKey, decoder);
|
||||||
|
if (r3.hit) {
|
||||||
|
if (r3.realValue) {
|
||||||
|
slidingTouch(dataKey);
|
||||||
|
}
|
||||||
|
return r3.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("[redis-cache] breakdown wait timeout, fallback to loader, key={}", key);
|
||||||
|
return loader.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取三态:未命中 / 命中空值标记 / 命中真实值;反序列化失败当未命中降级。 */
|
||||||
|
private <T> Lookup<T> tryGet(String dataKey, Function<String, T> decoder) {
|
||||||
|
String raw = redis.opsForValue().get(dataKey);
|
||||||
|
if (raw == null) {
|
||||||
|
return Lookup.miss();
|
||||||
|
}
|
||||||
|
if (codec.isNullSentinel(raw)) {
|
||||||
|
return Lookup.nullHit();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Lookup.hit(decoder.apply(raw));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[redis-cache] decode failed, treat as miss key={}", dataKey, e);
|
||||||
|
return Lookup.miss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回源并写缓存:null → 空值标记(穿透);非 null → 抖动 TTL(雪崩)。 */
|
||||||
|
private <T> T loadAndCache(String dataKey, long ttlSeconds, Supplier<T> loader) {
|
||||||
|
T value = loader.get();
|
||||||
|
if (value == null) {
|
||||||
|
redis.opsForValue().set(dataKey, CacheConstant.NULL_SENTINEL,
|
||||||
|
props.getNullTtlSeconds(), TimeUnit.SECONDS);
|
||||||
|
} else {
|
||||||
|
redis.opsForValue().set(dataKey, codec.encode(value),
|
||||||
|
applyJitter(ttlSeconds), TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseLock(String lockKey, String token) {
|
||||||
|
try {
|
||||||
|
redis.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[redis-cache] release lock failed lockKey={}", lockKey, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取三态载体(对外不暴露)。 */
|
||||||
|
private static final class Lookup<T> {
|
||||||
|
final boolean hit; // 命中(真实值或空值标记)
|
||||||
|
final boolean realValue; // 命中且为真实值
|
||||||
|
final T value;
|
||||||
|
|
||||||
|
private Lookup(boolean hit, boolean realValue, T value) {
|
||||||
|
this.hit = hit;
|
||||||
|
this.realValue = realValue;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static <T> Lookup<T> miss() {
|
||||||
|
return new Lookup<>(false, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static <T> Lookup<T> nullHit() {
|
||||||
|
return new Lookup<>(true, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static <T> Lookup<T> hit(T v) {
|
||||||
|
return new Lookup<>(true, true, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,21 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.MockitoAnnotations;
|
import org.mockito.MockitoAnnotations;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
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.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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -133,4 +139,105 @@ class RedisCacheEnhanceTemplateTest {
|
|||||||
assertNull(template.get("user:1", User.class));
|
assertNull(template.get("user:1", User.class));
|
||||||
verify(redis, never()).expire(any(), anyLong(), any());
|
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