feat(cache): 模板读写基础(set/get/delete/exists/expire + TTL 抖动 + 滑动续期)
This commit is contained in:
116
src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
vendored
Normal file
116
src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java
vendored
Normal file
@@ -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 缓存增强模板。
|
||||
*
|
||||
* <p>底层 {@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> T get(String key, Class<T> type) {
|
||||
return doGet(key, raw -> codec.decode(raw, type));
|
||||
}
|
||||
|
||||
public <T> T get(String key, TypeReference<T> type) {
|
||||
return doGet(key, raw -> codec.decode(raw, type));
|
||||
}
|
||||
|
||||
private <T> T doGet(String key, Function<String, T> 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);
|
||||
}
|
||||
}
|
||||
136
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
vendored
Normal file
136
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
vendored
Normal file
@@ -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<String, String> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user