cangku/backend/src/services/dataPageCache.js

365 lines
9.7 KiB
JavaScript

const crypto = require('crypto');
const redis = require('../config/redis');
const { runHeavyDatabaseTask } = require('./databaseLoadGuard');
const memoryStore = new Map();
const pendingLoads = new Map();
const MAX_RECENT_EVENTS = 200;
const CACHE_MISS_LOADER_GUARD_ENABLED = String(
process.env.DATA_PAGE_CACHE_MISS_GUARD_ENABLED || 'true'
).toLowerCase() !== 'false';
const cacheStats = {
total: 0,
memoryHits: 0,
redisHits: 0,
pendingHits: 0,
misses: 0,
lastResetAt: new Date().toISOString(),
labels: new Map(),
recentEvents: [],
};
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
function normalizeValue(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeValue(item));
}
if (isPlainObject(value)) {
return Object.keys(value)
.sort()
.reduce((acc, key) => {
acc[key] = normalizeValue(value[key]);
return acc;
}, {});
}
if (typeof value === 'string') {
return value.trim();
}
return value;
}
function buildHashedCacheKey(namespace, payload, version = 'v1') {
const normalizedPayload = normalizeValue(payload);
const hash = crypto
.createHash('md5')
.update(JSON.stringify(normalizedPayload))
.digest('hex');
return `${namespace}:${version}:${hash}`;
}
function getMemoryEntry(key) {
const cached = memoryStore.get(key);
if (!cached) {
return null;
}
if (cached.expiresAt <= Date.now()) {
memoryStore.delete(key);
return null;
}
return cached.value;
}
function setMemoryEntry(key, value, ttlSeconds) {
if (!ttlSeconds || ttlSeconds <= 0) {
return;
}
memoryStore.set(key, {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
function recordCacheEvent({ label, hit, elapsedMs, cacheStored }) {
cacheStats.total += 1;
if (hit === 'memory') cacheStats.memoryHits += 1;
if (hit === 'redis') cacheStats.redisHits += 1;
if (hit === 'pending') cacheStats.pendingHits += 1;
if (hit === 'miss') cacheStats.misses += 1;
if (label) {
const labelStats = cacheStats.labels.get(label) || {
label,
total: 0,
memoryHits: 0,
redisHits: 0,
pendingHits: 0,
misses: 0,
totalElapsedMs: 0,
lastHit: null,
lastSeenAt: null,
};
labelStats.total += 1;
labelStats.totalElapsedMs += elapsedMs;
labelStats.lastHit = hit;
labelStats.lastSeenAt = new Date().toISOString();
if (hit === 'memory') labelStats.memoryHits += 1;
if (hit === 'redis') labelStats.redisHits += 1;
if (hit === 'pending') labelStats.pendingHits += 1;
if (hit === 'miss') labelStats.misses += 1;
cacheStats.labels.set(label, labelStats);
}
cacheStats.recentEvents.unshift({
label: label || '(no-label)',
hit,
elapsedMs,
cacheStored,
timestamp: new Date().toISOString(),
});
if (cacheStats.recentEvents.length > MAX_RECENT_EVENTS) {
cacheStats.recentEvents.length = MAX_RECENT_EVENTS;
}
}
async function getOrSetCachedPayload({
key,
memoryTtlSeconds = 60,
redisTtlSeconds = 300,
staleKey = '',
staleMemoryTtlSeconds = memoryTtlSeconds,
staleRedisTtlSeconds = redisTtlSeconds,
returnStaleWhileRevalidate = false,
loader,
shouldCache,
label,
guardLoader = CACHE_MISS_LOADER_GUARD_ENABLED,
}) {
const startedAt = Date.now();
const canUseStaleCache = Boolean(returnStaleWhileRevalidate && staleKey);
const readStaleCache = async () => {
if (!canUseStaleCache) {
return null;
}
const staleMemoryValue = getMemoryEntry(staleKey);
if (staleMemoryValue !== null) {
return {
value: staleMemoryValue,
hit: 'stale-memory',
};
}
const staleRedisValue = await redis.get(staleKey);
if (staleRedisValue !== null) {
setMemoryEntry(staleKey, staleRedisValue, staleMemoryTtlSeconds);
return {
value: staleRedisValue,
hit: 'stale-redis',
};
}
return null;
};
const writeStaleCacheFromFreshValue = (value) => {
if (!canUseStaleCache) {
return;
}
setMemoryEntry(staleKey, value, staleMemoryTtlSeconds);
redis.setex(staleKey, staleRedisTtlSeconds, value).catch((error) => {
console.warn(`[DataPageCache] ${label || staleKey} 写入兜底缓存失败:`, error.message || error);
});
};
const startLoad = () => {
let allowedToCache = false;
const loadPromise = (async () => {
const loadedValue = guardLoader
? await runHeavyDatabaseTask(`data-page-cache:${label || key}`, loader)
: await loader();
allowedToCache = typeof shouldCache === 'function' ? shouldCache(loadedValue) : true;
if (allowedToCache) {
setMemoryEntry(key, loadedValue, memoryTtlSeconds);
await redis.setex(key, redisTtlSeconds, loadedValue);
if (canUseStaleCache) {
setMemoryEntry(staleKey, loadedValue, staleMemoryTtlSeconds);
await redis.setex(staleKey, staleRedisTtlSeconds, loadedValue);
}
}
return {
value: loadedValue,
allowedToCache,
};
})()
.finally(() => {
if (pendingLoads.get(key) === loadPromise) {
pendingLoads.delete(key);
}
});
pendingLoads.set(key, loadPromise);
return loadPromise;
};
const memoryValue = getMemoryEntry(key);
if (memoryValue !== null) {
writeStaleCacheFromFreshValue(memoryValue);
if (label) {
console.log(`[DataPageCache] ${label} hit=memory elapsedMs=${Date.now() - startedAt}`);
}
recordCacheEvent({ label, hit: 'memory', elapsedMs: Date.now() - startedAt, cacheStored: true });
return {
value: memoryValue,
hit: 'memory',
};
}
const redisValue = await redis.get(key);
if (redisValue !== null) {
setMemoryEntry(key, redisValue, memoryTtlSeconds);
writeStaleCacheFromFreshValue(redisValue);
if (label) {
console.log(`[DataPageCache] ${label} hit=redis elapsedMs=${Date.now() - startedAt}`);
}
recordCacheEvent({ label, hit: 'redis', elapsedMs: Date.now() - startedAt, cacheStored: true });
return {
value: redisValue,
hit: 'redis',
};
}
const pendingLoad = pendingLoads.get(key);
if (pendingLoad) {
const staleResult = await readStaleCache();
if (staleResult) {
if (label) {
console.log(`[DataPageCache] ${label} hit=${staleResult.hit} refresh=pending elapsedMs=${Date.now() - startedAt}`);
}
recordCacheEvent({ label, hit: staleResult.hit, elapsedMs: Date.now() - startedAt, cacheStored: true });
return staleResult;
}
const pendingResult = await pendingLoad;
const value = pendingResult?.value;
if (pendingResult?.allowedToCache) {
setMemoryEntry(key, value, memoryTtlSeconds);
}
if (label) {
console.log(`[DataPageCache] ${label} hit=pending elapsedMs=${Date.now() - startedAt}`);
}
recordCacheEvent({
label,
hit: 'pending',
elapsedMs: Date.now() - startedAt,
cacheStored: Boolean(pendingResult?.allowedToCache),
});
return {
value,
hit: 'pending',
};
}
const loadPromise = startLoad();
const staleResult = await readStaleCache();
if (staleResult) {
loadPromise.catch((error) => {
console.warn(`[DataPageCache] ${label || key} 后台刷新失败:`, error.message || error);
});
if (label) {
console.log(`[DataPageCache] ${label} hit=${staleResult.hit} refresh=background elapsedMs=${Date.now() - startedAt}`);
}
recordCacheEvent({ label, hit: staleResult.hit, elapsedMs: Date.now() - startedAt, cacheStored: true });
return staleResult;
}
let value;
let allowedToCache = false;
const loadedResult = await loadPromise;
value = loadedResult.value;
allowedToCache = Boolean(loadedResult.allowedToCache);
if (label) {
console.log(
`[DataPageCache] ${label} hit=miss cacheStored=${allowedToCache ? 'yes' : 'no'} elapsedMs=${Date.now() - startedAt}`
);
}
recordCacheEvent({
label,
hit: 'miss',
elapsedMs: Date.now() - startedAt,
cacheStored: allowedToCache,
});
return {
value,
hit: 'miss',
};
}
async function deleteByPrefix(prefix) {
for (const key of memoryStore.keys()) {
if (key.startsWith(prefix)) {
memoryStore.delete(key);
}
}
for (const key of pendingLoads.keys()) {
if (key.startsWith(prefix)) {
pendingLoads.delete(key);
}
}
await redis.delPattern(`${prefix}*`);
}
function getStatsSnapshot() {
const labels = Array.from(cacheStats.labels.values())
.map((item) => ({
...item,
avgElapsedMs: item.total ? Number((item.totalElapsedMs / item.total).toFixed(2)) : 0,
}))
.sort((a, b) => b.total - a.total);
return {
total: cacheStats.total,
memoryHits: cacheStats.memoryHits,
redisHits: cacheStats.redisHits,
pendingHits: cacheStats.pendingHits,
misses: cacheStats.misses,
memoryHitRate: cacheStats.total ? Number((cacheStats.memoryHits * 100 / cacheStats.total).toFixed(2)) : 0,
redisHitRate: cacheStats.total ? Number((cacheStats.redisHits * 100 / cacheStats.total).toFixed(2)) : 0,
pendingHitRate: cacheStats.total ? Number((cacheStats.pendingHits * 100 / cacheStats.total).toFixed(2)) : 0,
missRate: cacheStats.total ? Number((cacheStats.misses * 100 / cacheStats.total).toFixed(2)) : 0,
lastResetAt: cacheStats.lastResetAt,
labels,
recentEvents: cacheStats.recentEvents,
};
}
function resetStats() {
cacheStats.total = 0;
cacheStats.memoryHits = 0;
cacheStats.redisHits = 0;
cacheStats.pendingHits = 0;
cacheStats.misses = 0;
cacheStats.labels.clear();
cacheStats.recentEvents = [];
cacheStats.lastResetAt = new Date().toISOString();
}
module.exports = {
buildHashedCacheKey,
deleteByPrefix,
getOrSetCachedPayload,
getStatsSnapshot,
resetStats,
};