优化数据库读写

This commit is contained in:
biss committed 2026-07-22 16:36:05 +08:00
1 parent f264103417
commit 56f1d7a273
11 files changed
+254 -21

No files matched your search

+55 -10
View File
@@ -5,26 +5,69 @@ function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
}
function disabledCache(status = 'disabled') {
function disabledCache(status = 'disabled', { ttlSeconds = 60, maxEntries = 200 } = {}) {
const values = new Map();
const pending = new Map();
const generations = new Map();
const cacheKey = (namespace, key) => `${namespace}:${key}`;
const generation = namespace => generations.get(namespace) || 0;
const prune = () => {
const now = Date.now();
for (const [key, entry] of values) if (entry.expiresAt <= now) values.delete(key);
while (values.size > maxEntries) values.delete(values.keys().next().value);
};
return {
enabled: false,
status,
async remember(_namespace, _key, loader) {
return loader();
async remember(namespace, key, loader, options = {}) {
const fullKey = cacheKey(namespace, key);
const cached = values.get(fullKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
if (cached) values.delete(fullKey);
const startedGeneration = generation(namespace);
const active = pending.get(fullKey);
if (active?.generation === startedGeneration) return active.promise;
const loading = Promise.resolve(loader()).then(value => {
if (generation(namespace) === startedGeneration) {
const lifetime = positiveInteger(options.ttlSeconds, ttlSeconds, 86400);
values.set(fullKey, { value, expiresAt: Date.now() + lifetime * 1000 });
prune();
}
return value;
}).finally(() => {
if (pending.get(fullKey)?.promise === loading) pending.delete(fullKey);
});
pending.set(fullKey, { generation: startedGeneration, promise: loading });
return loading;
},
async invalidate() { return false; },
async close() {}
async invalidate(namespace) {
generations.set(namespace, generation(namespace) + 1);
const prefix = `${namespace}:`;
for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key);
// Preserve the public meaning of this return value: no Redis namespace
// was refreshed, even though the local fallback was invalidated.
return false;
},
async close() {
values.clear();
pending.clear();
}
};
}
export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
const url = String(env.REDIS_URL || '').trim();
if (!url) return disabledCache();
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
const localMaxEntries = positiveInteger(env.LOCAL_CACHE_MAX_ENTRIES, 200, 5000);
if (!url) return disabledCache('disabled', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
const fallback = disabledCache('unavailable', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
const prefix = String(env.REDIS_CACHE_PREFIX || 'exam-information')
.trim()
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information';
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
const pending = new Map();
let warningReported = false;
@@ -54,7 +97,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
} catch (error) {
warn(error);
if (client.isOpen) client.destroy();
return disabledCache('unavailable');
return fallback;
}
const versionKey = namespace => `${prefix}:namespace:${namespace}`;
@@ -75,7 +118,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
return client.isReady ? 'ready' : 'unavailable';
},
async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
if (!client.isReady) return loader();
if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds });
try {
const version = await namespaceVersion(namespace);
const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
@@ -99,10 +142,11 @@ export async function createRedisCache({ env = process.env, logger = console, cl
return loading;
} catch (error) {
warn(error);
return loader();
return fallback.remember(namespace, key, loader, { ttlSeconds });
}
},
async invalidate(namespace) {
await fallback.invalidate(namespace);
if (!client.isReady) return false;
try {
await client.incr(versionKey(namespace));
@@ -113,6 +157,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
}
},
async close() {
await fallback.close();
if (client.isOpen) await client.quit();
}
};
+18 -1
View File
@@ -1,4 +1,6 @@
export async function api(path, options = {}) {
const pendingReads = new Map();
async function request(path, options) {
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
const response = await fetch(path, {
credentials: 'same-origin',
@@ -15,3 +17,18 @@ export async function api(path, options = {}) {
}
return data;
}
export function api(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
// A quick double click or repeated render must not download and parse the
// same large JSON response more than once while the first request is active.
const key = String(path);
if (pendingReads.has(key)) return pendingReads.get(key);
const loading = request(path, options).finally(() => {
if (pendingReads.get(key) === loading) pendingReads.delete(key);
});
pendingReads.set(key, loading);
return loading;
}
+8 -2
View File
@@ -1,5 +1,7 @@
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
import { createStateCache } from './state-cache.mjs';
export function createMysqlAdapter(context) {
const {
mkdir,
@@ -408,7 +410,9 @@ export function createMysqlAdapter(context) {
partitionConnection.release();
}
let stateCache;
const transaction = async operations => {
stateCache?.invalidate();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
@@ -419,10 +423,11 @@ export function createMysqlAdapter(context) {
await connection.rollback();
throw error;
} finally {
stateCache?.invalidate();
connection.release();
}
};
const read = async () => {
const loadState = async () => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
@@ -436,10 +441,11 @@ export function createMysqlAdapter(context) {
connection.release();
}
};
stateCache = createStateCache({ load: loadState });
return createRepository({
client: 'mysql',
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
read,
read: stateCache.read,
transaction,
close: async () => pool.end()
});
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff. Show more