diff --git a/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java index 470d26f..b1a8688 100644 --- a/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java +++ b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java @@ -1,15 +1,21 @@ package com.njcn.middle.cache.template; +import cn.hutool.core.util.IdUtil; import com.alibaba.fastjson.TypeReference; 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.CacheValueCodec; import lombok.extern.slf4j.Slf4j; 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.TimeUnit; import java.util.function.Function; +import java.util.function.Supplier; /** * Redis 缓存增强模板。 @@ -20,6 +26,11 @@ import java.util.function.Function; @Slf4j public class RedisCacheEnhanceTemplate { + /** 释放锁 Lua:token 匹配才删,防误删他人锁。 */ + private static final DefaultRedisScript 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 CacheKeyBuilder keyBuilder; private final CacheValueCodec codec; @@ -113,4 +124,142 @@ public class RedisCacheEnhanceTemplate { } return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1); } + + // ---- 读取或回源(三防核心)---- + + public T getOrLoad(String key, Class type, Supplier loader) { + return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader); + } + + public T getOrLoad(String key, Class type, long ttlSeconds, Supplier loader) { + return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader); + } + + public T getOrLoad(String key, TypeReference type, long ttlSeconds, Supplier loader) { + return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader); + } + + private T doGetOrLoad(String key, Function decoder, long ttlSeconds, Supplier loader) { + String dataKey = keyBuilder.build(key); + + Lookup 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 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 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 Lookup tryGet(String dataKey, Function 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 loadAndCache(String dataKey, long ttlSeconds, Supplier 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 { + 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 Lookup miss() { + return new Lookup<>(false, false, null); + } + + static Lookup nullHit() { + return new Lookup<>(true, false, null); + } + + static Lookup hit(T v) { + return new Lookup<>(true, true, v); + } + } } diff --git a/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java index 313b76c..c1b0bba 100644 --- a/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java +++ b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java @@ -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 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()); + } }