TranslationCacheService.java
package com.yumu.noveltranslator.adapter.out.redis;
import com.yumu.noveltranslator.adapter.out.persistence.entity.TranslationCache;
import com.yumu.noveltranslator.adapter.out.persistence.mapper.TranslationCacheMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.yumu.noveltranslator.port.out.TranslationCacheAdminPort;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 翻译缓存服务
* 二级缓存架构:Caffeine (L1) → Redis (L2) → 翻译引擎
*
* <p>缓存策略:</p>
* <ul>
* <li>L1 Caffeine:JVM 内存缓存,10 分钟过期,单实例最高性能</li>
* <li>L2 Redis:分布式缓存,30 分钟过期,支持多实例共享</li>
* </ul>
*
* <p>MySQL 已从查询热路径移除,仅用于异步持久化(saveToDatabaseAsync)。</p>
*
* <p>防缓存问题:</p>
* <ul>
* <li>缓存穿透:空值占位(5 分钟短暂过期)</li>
* <li>缓存击穿:双层锁(JVM 本地 synchronized + Redis SET NX 分布式锁)</li>
* <li>缓存雪崩:过期时间添加随机抖动</li>
* <li>缓存一致性:版本号前缀 + 延迟双删 + Redis pub/sub</li>
* </ul>
*/
@Service
@Slf4j
public class TranslationCacheService implements TranslationCacheAdminPort {
private final TranslationCacheMapper translationCacheMapper;
private final StringRedisTemplate stringRedisTemplate;
private final CacheVersionService cacheVersionService;
/** 专用调度线程池,替代 Thread.sleep 保证延迟任务可靠执行 */
private final ScheduledExecutorService delayedCleanupExecutor = Executors.newScheduledThreadPool(
2, r -> {
Thread t = new Thread(r, "cache-cleanup");
t.setDaemon(true);
return t;
});
public TranslationCacheService(
TranslationCacheMapper translationCacheMapper,
StringRedisTemplate stringRedisTemplate,
@Lazy CacheVersionService cacheVersionService) {
this.translationCacheMapper = translationCacheMapper;
this.stringRedisTemplate = stringRedisTemplate;
this.cacheVersionService = cacheVersionService;
}
// ==================== L1: Caffeine 内存缓存 ====================
/**
* Caffeine 本地缓存,最大 10000 条,写入后 10 分钟过期
*/
private final Cache<String, String> caffeineCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
// ==================== L2: Redis 配置常量 ====================
private static final String REDIS_KEY_PREFIX = "translator:cache:";
private static final long REDIS_CACHE_SECONDS = 30 * 60; // Redis 缓存 30 分钟
private static final long REDIS_NULL_SECONDS = 5 * 60; // 空值占位 5 分钟
// ==================== 术语反向索引 ====================
/** Redis Set key 前缀: glossary:cache_keys:{word} */
private static final String GLOSSARY_CACHE_KEYS_PREFIX = "glossary:cache_keys:";
/** 反向索引 Set 的 TTL(24 小时) */
private static final long GLOSSARY_INDEX_TTL_SECONDS = 24 * 3600;
/** 提取源文本中长度 >= 3 的单词,用于构建反向索引 */
private static final Pattern WORD_EXTRACT_PATTERN = Pattern.compile("\\b[\\w\\p{L}]{3,}\\b");
// ==================== L3: 数据库配置常量 ====================
private static final long DATABASE_CACHE_EXPIRY_HOURS = 24; // 数据库缓存 24 小时
// ==================== 抖动配置(防雪崩) ====================
private static final long REDIS_JITTER_SECONDS = 60; // Redis 过期时间抖动 ±60 秒
private static final long DB_JITTER_HOURS = 2; // 数据库过期时间抖动 ±2 小时
// ==================== 模式缓存层级 ====================
/**
* 模式缓存层级:查询顺序
*/
private static final Map<String, List<String>> MODE_CACHE_HIERARCHY = Map.of(
"fast", List.of("team", "expert", "fast"),
"expert", List.of("team", "expert"),
"team", List.of("team")
);
// ==================== 核心查询方法 ====================
/**
* 空值占位标记
*/
private static final String NULL_PLACEHOLDER = "__NULL__";
// ==================== 缓存统计 ====================
private final AtomicLong l1HitCount = new AtomicLong(0);
private final AtomicLong l2HitCount = new AtomicLong(0);
private final AtomicLong cacheMissCount = new AtomicLong(0);
private final AtomicLong nullHitCount = new AtomicLong(0); // 空值命中(穿透防护)
// ==================== 核心查询方法 ====================
/**
* 获取翻译缓存(L1 Caffeine → L2 Redis)
*/
public String getCache(String cacheKey) {
// 1. L1: 尝试 Caffeine 缓存
String l1Value = caffeineCache.getIfPresent(cacheKey);
if (l1Value != null) {
if (NULL_PLACEHOLDER.equals(l1Value)) {
nullHitCount.incrementAndGet();
return null;
}
l1HitCount.incrementAndGet();
return l1Value;
}
// 2. L2: 直接查 Redis(无分布式锁,高并发下由翻译引擎自然防击穿)
String redisKey = REDIS_KEY_PREFIX + cacheKey;
String value = stringRedisTemplate.opsForValue().get(redisKey);
if (value != null) {
if (NULL_PLACEHOLDER.equals(value)) {
caffeineCache.put(cacheKey, NULL_PLACEHOLDER);
return null;
}
caffeineCache.put(cacheKey, value);
l2HitCount.incrementAndGet();
return value;
}
cacheMissCount.incrementAndGet();
return null;
}
/**
* 根据翻译模式获取缓存,使用 MGET 批量获取多个模式 key
* 按模式层级搜索:fast→[team, expert, fast], expert→[team, expert], team→[team]
*/
public String getCacheByMode(String cacheKey, String currentMode) {
List<String> modesToSearch = MODE_CACHE_HIERARCHY.getOrDefault(
currentMode != null ? currentMode : "fast", List.of("fast"));
// 构建待查 Redis key 列表
List<String> redisKeys = new ArrayList<>(modesToSearch.size());
for (String mode : modesToSearch) {
String modeKey = cacheKey + "_" + mode;
// 先查 L1
String l1 = caffeineCache.getIfPresent(modeKey);
if (l1 != null) {
if (NULL_PLACEHOLDER.equals(l1)) return null;
l1HitCount.incrementAndGet();
log.debug("L1 模式缓存命中: mode={}", mode);
return l1;
}
redisKeys.add(REDIS_KEY_PREFIX + modeKey);
}
// L1 全未命中 → MGET 批量查 L2
if (!redisKeys.isEmpty()) {
List<String> values = stringRedisTemplate.opsForValue().multiGet(redisKeys);
if (values != null && !values.isEmpty()) {
for (int i = 0; i < values.size() && i < modesToSearch.size(); i++) {
String modeKey = cacheKey + "_" + modesToSearch.get(i);
String v = values.get(i);
if (v != null) {
if (NULL_PLACEHOLDER.equals(v)) {
caffeineCache.put(modeKey, NULL_PLACEHOLDER);
return null;
}
caffeineCache.put(modeKey, v);
l2HitCount.incrementAndGet();
log.debug("L2 模式缓存命中: mode={}", modesToSearch.get(i));
return v;
}
}
}
}
cacheMissCount.incrementAndGet();
return null;
}
// ==================== 核心写入方法 ====================
/**
* 从数据库查询缓存(高并发降级模式)
*/
private TranslationCache queryDatabaseCache(String cacheKey) {
try {
TranslationCache cache = translationCacheMapper.selectById(cacheKey);
if (cache == null) {
cache = translationCacheMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
.eq("cache_key", cacheKey)
.gt("expire_time", LocalDateTime.now())
);
}
return cache;
} catch (Exception e) {
log.debug("L3 数据库查询跳过(连接池繁忙或异常): {}", e.getMessage());
return null;
}
}
/**
* 保存翻译缓存到 L1 + L2 + L3
* 写入时使用当前版本号作为 key 前缀
*
* @param cacheKey 缓存键(不含版本号,由内部拼接)
* @param sourceText 源文本
* @param targetText 目标文本
* @param sourceLang 源语言
* @param targetLang 目标语言
* @param engine 翻译引擎
*/
public void putCache(String cacheKey, String sourceText, String targetText,
String sourceLang, String targetLang, String engine) {
putCache(cacheKey, sourceText, targetText, sourceLang, targetLang, engine, null);
}
/**
* 保存翻译缓存到 L1 + L2 + L3(带模式标签)
* 写入时使用当前版本号作为 key 前缀
*
* @param cacheKey 基础缓存键(不含版本号)
* @param sourceText 源文本
* @param targetText 目标文本
* @param sourceLang 源语言
* @param targetLang 目标语言
* @param engine 翻译引擎
* @param mode 翻译模式(fast/expert/team),为空则不附加模式标签
*/
public void putCache(String cacheKey, String sourceText, String targetText,
String sourceLang, String targetLang, String engine, String mode) {
// 获取当前版本号
String version = cacheVersionService.getVersion(sourceLang, targetLang);
// cacheKey 可能已经包含 v{N}: 前缀(来自 CacheKeyUtil),先剥离再重新拼接
String strippedKey = cacheKey.replaceFirst("^v\\d+:", "");
String baseKey = "v" + version + ":" + strippedKey;
String finalKey = (mode != null && !mode.isBlank()) ? baseKey + "_" + mode : baseKey;
String redisKey = REDIS_KEY_PREFIX + finalKey;
// 1. 写入 L1 Caffeine
caffeineCache.put(finalKey, targetText);
// 2. 写入 L2 Redis(带随机抖动防雪崩)
long redisTtl = REDIS_CACHE_SECONDS + jitter(REDIS_JITTER_SECONDS);
stringRedisTemplate.opsForValue().set(redisKey, targetText, Duration.ofSeconds(redisTtl));
// 3. 异步维护术语反向索引(非关键路径,不阻塞响应)
Thread.startVirtualThread(() -> buildReverseIndex(finalKey, sourceText));
// 4. 异步写入 L3 数据库(带版本号)
saveToDatabaseAsync(finalKey, sourceText, targetText, sourceLang, targetLang, engine, version);
log.debug("L1+L2+L3 写入成功: key={}, version={}, mode={}", finalKey, version, mode);
}
/**
* 保存空值占位(防缓存穿透)
*
* @param cacheKey 缓存键
*/
public void putNullCache(String cacheKey) {
String redisKey = REDIS_KEY_PREFIX + cacheKey;
// L1 写入空值占位
caffeineCache.put(cacheKey, NULL_PLACEHOLDER);
// L2 Redis 写入空值占位(短过期)
stringRedisTemplate.opsForValue().set(redisKey, NULL_PLACEHOLDER, Duration.ofSeconds(REDIS_NULL_SECONDS));
log.debug("空值占位写入:{}", cacheKey);
}
/**
* 仅保存到 L1 缓存(用于数据库缓存已存在的场景)
*/
public void putToMemoryCache(String cacheKey, String targetText) {
caffeineCache.put(cacheKey, targetText);
}
// ==================== 术语反向索引 ====================
/**
* 从 sourceText 中提取长度 >= 3 的单词,维护反向索引 Set。
* Redis key: glossary:cache_keys:{word_lowercase} -> Set of cacheKeys
* TTL: 24 小时
*/
void buildReverseIndex(String cacheKey, String sourceText) {
if (sourceText == null || sourceText.isBlank()) {
return;
}
Set<String> uniqueWords = new HashSet<>();
Matcher matcher = WORD_EXTRACT_PATTERN.matcher(sourceText);
while (matcher.find()) {
uniqueWords.add(matcher.group().toLowerCase());
}
for (String word : uniqueWords) {
String indexKey = GLOSSARY_CACHE_KEYS_PREFIX + word;
stringRedisTemplate.opsForSet().add(indexKey, cacheKey);
stringRedisTemplate.expire(indexKey, Duration.ofSeconds(GLOSSARY_INDEX_TTL_SECONDS));
}
if (!uniqueWords.isEmpty()) {
log.debug("反向索引已更新: cacheKey={}, 词条数={}", cacheKey, uniqueWords.size());
}
}
/**
* 当术语发生变化时,使所有包含该词的缓存失效。
* 用于 glossary term 变更时的细粒度缓存失效。
*
* @param sourceWord 发生变化的原词(如 "Apple")
*/
public void invalidateKeysForTerm(String sourceWord) {
if (sourceWord == null || sourceWord.isBlank()) {
return;
}
String lowerWord = sourceWord.toLowerCase();
String indexKey = GLOSSARY_CACHE_KEYS_PREFIX + lowerWord;
// 1. 获取所有受影响的 cache keys
Set<String> cacheKeys = stringRedisTemplate.opsForSet().members(indexKey);
if (cacheKeys == null || cacheKeys.isEmpty()) {
log.debug("术语反向索引无匹配: word={}", lowerWord);
return;
}
log.info("开始术语细粒度失效: word={}, 影响 {} 个缓存键", lowerWord, cacheKeys.size());
// 2. 清空本地 Caffeine L1 缓存中对应的 key
for (String key : cacheKeys) {
caffeineCache.invalidate(key);
}
// 3. 删除 L2 Redis 中的缓存 key
try {
String[] redisKeys = cacheKeys.stream()
.map(k -> REDIS_KEY_PREFIX + k)
.toArray(String[]::new);
if (redisKeys.length > 0) {
long deleted = stringRedisTemplate.delete(java.util.List.of(redisKeys));
log.info("L2 Redis 删除 {} 个术语相关缓存键", deleted);
}
} catch (Exception e) {
log.warn("L2 Redis 术语缓存删除失败: word={}, error={}", lowerWord, e.getMessage());
}
// 4. 删除 L3 数据库中的缓存记录
try {
for (String key : cacheKeys) {
translationCacheMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
.eq("cache_key", key)
);
}
log.info("L3 数据库删除 {} 个术语相关记录", cacheKeys.size());
} catch (Exception e) {
log.warn("L3 数据库术语缓存删除失败: word={}, error={}", lowerWord, e.getMessage());
}
// 5. 清理反向索引 Set 条目
stringRedisTemplate.delete(indexKey);
log.info("术语细粒度失效完成: word={}", lowerWord);
}
// ==================== 异步数据库写入 ====================
/**
* 异步保存缓存到数据库(使用虚拟线程,带重试机制)
*/
private void saveToDatabaseAsync(String cacheKey, String sourceText, String targetText,
String sourceLang, String targetLang, String engine, String version) {
Thread.startVirtualThread(() -> {
int maxRetries = 2;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
TranslationCache cache = new TranslationCache();
cache.setCacheKey(cacheKey);
cache.setSourceText(sourceText);
cache.setTargetText(targetText);
cache.setSourceLang(sourceLang);
cache.setTargetLang(targetLang);
cache.setEngine(engine);
cache.setVersion(Integer.parseInt(version));
// 24 小时 + 随机抖动(0~4 小时),防雪崩
long jitterHours = jitter(DB_JITTER_HOURS);
cache.setExpireTime(LocalDateTime.now().plusHours(DATABASE_CACHE_EXPIRY_HOURS + jitterHours));
translationCacheMapper.insertCache(cache);
log.debug("L3 数据库缓存保存成功:{}", cacheKey);
break;
} catch (Exception e) {
if (attempt == maxRetries) {
log.warn("L3 数据库缓存保存失败(重试 {} 次后放弃):{} - {}", maxRetries, cacheKey, e.getMessage());
} else {
try {
Thread.sleep(500L * (attempt + 1));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
});
}
// ==================== 延迟双删 ====================
/**
* 延迟双删:在数据变更时保证缓存一致性
*
* 流程:
* 1. 前置删除:清空 L1 + L2 对应语言对的缓存
* 2. 版本号 bump:Redis INCR + 发布 pub/sub 事件
* 3. 延迟 2 秒后通过 ScheduledExecutorService 调度后置删除
* 4. 后置删除:再次清空 L2 Redis(版本前缀匹配)+ L3 过期版本记录
*
* 注:使用 ScheduledExecutorService 替代 Thread.sleep,避免容器重启/调度导致后置删除丢失。
* 即使后置删除未执行,版本号机制已保证旧 key 不可达,仅占用少量内存直到 TTL 自然淘汰。
*
* @param sourceLang 源语言
* @param targetLang 目标语言
*/
public void delayedDoubleDelete(String sourceLang, String targetLang) {
try {
// Step 1: 前置删除 — 清空本地 L1 缓存
log.info("延迟双删 [前置删除]: sourceLang={}, targetLang={}", sourceLang, targetLang);
clearLocalCache();
// Step 2: 版本号 bump + 发布 pub/sub 事件(其他实例收到后会清空 L1)
String newVersion = cacheVersionService.bumpVersionAndPublish(sourceLang, targetLang);
// Step 3-5: 通过 ScheduledExecutorService 调度后置删除,避免 Thread.sleep 不可靠
delayedCleanupExecutor.schedule(() -> {
try {
// 后置删除 — 清空 L2 Redis 中旧版本的所有 key
String oldVersion = String.valueOf(Integer.parseInt(newVersion) - 1);
deleteRedisByOldVersion(oldVersion);
// 删除 L3 数据库中旧版本的记录
deleteDbCacheByOldVersion(Integer.parseInt(newVersion));
log.info("延迟双删完成: sourceLang={}, targetLang={}, newVersion={}", sourceLang, targetLang, newVersion);
} catch (Exception e) {
log.warn("延迟双删 [后置删除] 失败: {}", e.getMessage());
}
}, 2, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("延迟双删异常: sourceLang={}, targetLang={}, error={}", sourceLang, targetLang, e.getMessage(), e);
}
}
/**
* 删除 Redis 中旧版本的所有缓存 key
*/
private void deleteRedisByOldVersion(String oldVersion) {
String pattern = REDIS_KEY_PREFIX + "v" + oldVersion + ":*";
var keys = stringRedisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
long deleted = stringRedisTemplate.delete(keys);
log.info("延迟双删 [L2] 删除旧版本 Redis key: version={}, 删除 {} 条", oldVersion, deleted);
}
}
/**
* 删除数据库中旧版本的所有缓存记录
*/
private void deleteDbCacheByOldVersion(int currentVersion) {
int deleted = translationCacheMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
.lt("version", currentVersion)
);
log.info("延迟双删 [L3] 删除旧版本 DB 记录: currentVersion={}, 删除 {} 条", currentVersion, deleted);
}
// ==================== 本地缓存清空(供 CacheVersionService 调用) ====================
/**
* 清空本地 Caffeine 缓存,供 pub/sub 事件处理使用
*/
public void clearLocalCache() {
long size = caffeineCache.estimatedSize();
caffeineCache.invalidateAll();
log.debug("本地 Caffeine 缓存已清空,原条目数: {}", size);
}
// ==================== 辅助方法 ====================
/**
* 生成随机抖动值,范围为 [0, max)
* 用于防止缓存雪崩
*/
private long jitter(long maxSeconds) {
return ThreadLocalRandom.current().nextLong(maxSeconds);
}
/**
* 检查缓存是否存在(不返回值)
*/
public boolean hasCache(String cacheKey) {
return getCache(cacheKey) != null;
}
/**
* 获取缓存命中率统计
*/
public Map<String, Object> getCacheStats() {
long total = l1HitCount.get() + l2HitCount.get() + cacheMissCount.get();
double hitRate = total > 0 ? (double) (l1HitCount.get() + l2HitCount.get()) / total * 100 : 0;
return Map.of(
"l1Hits", l1HitCount.get(),
"l2Hits", l2HitCount.get(),
"nullHits", nullHitCount.get(),
"misses", cacheMissCount.get(),
"hitRate", String.format("%.2f%%", hitRate),
"totalRequests", total,
"caffeineStats", caffeineCache.stats().toString()
);
}
/**
* 清理所有缓存(调试用)
*/
public void clearAllCache() {
caffeineCache.invalidateAll();
log.info("L1 Caffeine 缓存已清空");
try {
stringRedisTemplate.delete(
stringRedisTemplate.keys(REDIS_KEY_PREFIX + "*")
);
log.info("L2 Redis 缓存已清空(前缀:{})", REDIS_KEY_PREFIX);
} catch (Exception e) {
log.warn("L2 Redis 缓存清空失败:{}", e.getMessage());
}
try {
int deleted = translationCacheMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
);
log.info("L3 数据库缓存已清空,删除 {} 条记录", deleted);
} catch (Exception e) {
log.warn("L3 数据库缓存清空失败:{}", e.getMessage());
}
// 同时清空版本号
try {
var versionKeys = stringRedisTemplate.keys("translator:cache_version:*");
if (versionKeys != null && !versionKeys.isEmpty()) {
stringRedisTemplate.delete(versionKeys);
log.info("缓存版本号已清空");
}
} catch (Exception e) {
log.warn("缓存版本号清空失败: {}", e.getMessage());
}
}
/**
* 清理过期缓存
* 定期调用此方法清理数据库中的过期缓存
*/
public void cleanupExpiredCache() {
try {
int deleted = translationCacheMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
.lt("expire_time", LocalDateTime.now())
);
log.info("清理过期数据库缓存记录:{} 条", deleted);
} catch (Exception e) {
log.error("清理过期数据库缓存失败:{}", e.getMessage());
}
}
/**
* 预热缓存 - 批量加载热门翻译到 L1
*/
public void warmupCache(Iterable<String> cacheKeys) {
int count = 0;
for (String cacheKey : cacheKeys) {
TranslationCache dbCache = queryDatabaseCache(cacheKey);
if (dbCache != null) {
String value = dbCache.getTargetText();
caffeineCache.put(cacheKey, value);
// 同时回写 L2
String redisKey = REDIS_KEY_PREFIX + cacheKey;
stringRedisTemplate.opsForValue().setIfAbsent(
redisKey,
value,
Duration.ofSeconds(REDIS_CACHE_SECONDS + jitter(REDIS_JITTER_SECONDS))
);
count++;
}
}
log.info("缓存预热完成:加载 {} 条记录到 L1+L2", count);
}
/**
* 应用关闭时清理缓存资源
*/
@PreDestroy
public void shutdown() {
log.info("翻译缓存服务关闭,清理 Caffeine 缓存...");
caffeineCache.invalidateAll();
log.info("翻译缓存服务关闭,关闭调度线程池...");
delayedCleanupExecutor.shutdown();
try {
if (!delayedCleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
delayedCleanupExecutor.shutdownNow();
}
} catch (InterruptedException e) {
delayedCleanupExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
log.info("翻译缓存服务已关闭");
}
}