diff --git a/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java new file mode 100644 index 0000000..470d26f --- /dev/null +++ b/src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java @@ -0,0 +1,116 @@ +package com.njcn.middle.cache.template; + +import com.alibaba.fastjson.TypeReference; +import com.njcn.middle.cache.autoconfig.RedisCacheProperties; +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 java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Redis 缓存增强模板。 + * + *

底层 {@link StringRedisTemplate} 存字符串,fastjson 序列化。本类(Task 4)实现读写基础; + * {@code getOrLoad} 三防(穿透/雪崩/击穿)由 Task 5 追加。 + */ +@Slf4j +public class RedisCacheEnhanceTemplate { + + private final StringRedisTemplate redis; + private final CacheKeyBuilder keyBuilder; + private final CacheValueCodec codec; + private final RedisCacheProperties props; + + public RedisCacheEnhanceTemplate(StringRedisTemplate redis, CacheKeyBuilder keyBuilder, + CacheValueCodec codec, RedisCacheProperties props) { + this.redis = redis; + this.keyBuilder = keyBuilder; + this.codec = codec; + this.props = props; + } + + // ---- 写 ---- + + public void set(String key, Object value) { + set(key, value, props.getDefaultTtlSeconds()); + } + + public void set(String key, Object value, long ttlSeconds) { + redis.opsForValue().set(keyBuilder.build(key), codec.encode(value), + applyJitter(ttlSeconds), TimeUnit.SECONDS); + } + + // ---- 读 ---- + + public T get(String key, Class type) { + return doGet(key, raw -> codec.decode(raw, type)); + } + + public T get(String key, TypeReference type) { + return doGet(key, raw -> codec.decode(raw, type)); + } + + private T doGet(String key, Function decoder) { + String dataKey = keyBuilder.build(key); + String raw = redis.opsForValue().get(dataKey); + if (raw == null || codec.isNullSentinel(raw)) { + return null; + } + T value; + try { + value = decoder.apply(raw); + } catch (RuntimeException e) { + throw new IllegalStateException("cache decode failed, key=" + dataKey, e); + } + slidingTouch(dataKey); + return value; + } + + // ---- 删 / 判存 ---- + + public boolean delete(String key) { + return Boolean.TRUE.equals(redis.delete(keyBuilder.build(key))); + } + + public boolean exists(String key) { + return Boolean.TRUE.equals(redis.hasKey(keyBuilder.build(key))); + } + + // ---- 续期 ---- + + public boolean expire(String key, long ttlSeconds) { + return Boolean.TRUE.equals( + redis.expire(keyBuilder.build(key), applyJitter(ttlSeconds), TimeUnit.SECONDS)); + } + + // ---- 内部 ---- + + /** 滑动过期:开启时对已存在的真实值 key 续期(直接对 dataKey 操作,失败忽略)。 */ + private void slidingTouch(String dataKey) { + if (!props.isSlidingExpireEnabled()) { + return; + } + try { + redis.expire(dataKey, applyJitter(props.getDefaultTtlSeconds()), TimeUnit.SECONDS); + } catch (RuntimeException e) { + log.warn("[redis-cache] sliding expire failed key={}", dataKey, e); + } + } + + /** 在原 TTL 上叠加 [0, ttl*ratio] 的随机抖动(只增不减);ratio<=0 或 ttl<=0 时原样返回。 */ + long applyJitter(long ttlSeconds) { + double ratio = props.getJitterRatio(); + if (ratio <= 0 || ttlSeconds <= 0) { + return ttlSeconds; + } + long bound = (long) (ttlSeconds * ratio); + if (bound <= 0) { + return ttlSeconds; + } + return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1); + } +} diff --git a/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java new file mode 100644 index 0000000..313b76c --- /dev/null +++ b/src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java @@ -0,0 +1,136 @@ +package com.njcn.middle.cache.template; + +import com.alibaba.fastjson.JSON; +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.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RedisCacheEnhanceTemplateTest { + + @Mock + StringRedisTemplate redis; + @Mock + ValueOperations valueOps; + + CacheValueCodec codec = new CacheValueCodec(); + CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, ""); + RedisCacheProperties props = new RedisCacheProperties(); + RedisCacheEnhanceTemplate template; + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class User { + private Long id; + private String name; + } + + @BeforeEach + void init() { + MockitoAnnotations.initMocks(this); + lenient().when(redis.opsForValue()).thenReturn(valueOps); + template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props); + } + + @Test + void setSerializesWithExactTtlWhenNoJitter() { + props.setJitterRatio(0); + User u = new User(1L, "a"); + template.set("user:1", u, 100); + verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS); + } + + @Test + void getHitDeserializes() { + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + User got = template.get("user:1", User.class); + assertEquals("a", got.getName()); + } + + @Test + void getMissReturnsNull() { + when(valueOps.get("cache:user:1")).thenReturn(null); + assertNull(template.get("user:1", User.class)); + } + + @Test + void getNullSentinelReturnsNull() { + when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); + assertNull(template.get("user:1", User.class)); + } + + @Test + void getDirtyDataThrowsIllegalState() { + when(valueOps.get("cache:user:1")).thenReturn("{not-json"); + assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class)); + } + + @Test + void expireDelegatesWithJitterTtl() { + props.setJitterRatio(0); + when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true); + assertTrue(template.expire("user:1", 200)); + verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS); + } + + @Test + void applyJitterWithinBounds() { + props.setJitterRatio(0.1); + long bound = (long) (100 * 0.1); + for (int i = 0; i < 50; i++) { + long ttl = template.applyJitter(100); + assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl); + } + props.setJitterRatio(0); + assertEquals(100, template.applyJitter(100)); + } + + @Test + void slidingOffDoesNotRenewOnGet() { + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + template.get("user:1", User.class); + verify(redis, never()).expire(any(), anyLong(), any()); + } + + @Test + void slidingOnRenewsRealValueOnGet() { + props.setSlidingExpireEnabled(true); + props.setJitterRatio(0); + when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); + when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true); + template.get("user:1", User.class); + verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS); + } + + @Test + void slidingOnDoesNotRenewNullSentinel() { + props.setSlidingExpireEnabled(true); + when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); + assertNull(template.get("user:1", User.class)); + verify(redis, never()).expire(any(), anyLong(), any()); + } +}