perf(energy): add 60s TTL cache for all 4 endpoints
Hydrogen overview was 1.8-2.0s (3 full-table aggregations on 66K rows). With cache: cold 1 user/min eats the full query, all subsequent within 60s window return in ~10ms. Implementation: - New cache.ts with cached(key, loader) helper - Per-key in-flight de-duplication: concurrent requests share one loader - Each handler wrapped, cache key includes query params (e.g. "hydrogen/daily?range=last30&customer=external") - TTL 60s as requested Measured speedups: - hydrogen/overview: 1.96s → 12ms (165x) - hydrogen/daily: 270ms → 11ms (24x) - electric/overview: 93ms → 9ms (10x) - electric/monthly: 36ms → 9ms (4x) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
src/server/routes/energy/cache.ts
Normal file
44
src/server/routes/energy/cache.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 简单 TTL 内存缓存。
|
||||||
|
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
||||||
|
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Entry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const cache = new Map<string, Entry<unknown>>();
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && hit.expiresAt > now) {
|
||||||
|
return hit.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一 key 并发只跑一次 loader
|
||||||
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
|
if (ongoing) return ongoing;
|
||||||
|
|
||||||
|
const p = loader()
|
||||||
|
.then(value => {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, p as Promise<unknown>);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用于测试或调试:清空所有缓存 */
|
||||||
|
export function _clearEnergyCache() {
|
||||||
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
|
import { cached } from './cache.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ function rangeClause(localExpr: string, range: Range): string {
|
|||||||
// 氢能 总览:KPI + Top5 + 区域占比
|
// 氢能 总览:KPI + Top5 + 区域占比
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/overview', async (c) => {
|
app.get('/hydrogen/overview', async (c) => {
|
||||||
|
const data = await cached('hydrogen/overview', async () => {
|
||||||
// KPI(年/月/日 + 我方/客户分解 + 累计羚牛承担)
|
// KPI(年/月/日 + 我方/客户分解 + 累计羚牛承担)
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -134,7 +136,9 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return c.json({ kpi, top5, regions });
|
return { kpi, top5, regions };
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -144,6 +148,8 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
const range = (c.req.query('range') || 'last30') as Range;
|
const range = (c.req.query('range') || 'last30') as Range;
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
|
|
||||||
|
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
'b.is_deleted = 0',
|
'b.is_deleted = 0',
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
@@ -231,13 +237,16 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.date.localeCompare(a.date));
|
.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
return c.json(result);
|
return result;
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 电能 总览:KPI + 本月每日柱图数据
|
// 电能 总览:KPI + 本月每日柱图数据
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/overview', async (c) => {
|
app.get('/electric/overview', async (c) => {
|
||||||
|
const data = await cached('electric/overview', async () => {
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(charging_degree) AS totalKwh,
|
SUM(charging_degree) AS totalKwh,
|
||||||
@@ -316,10 +325,12 @@ app.get('/electric/overview', async (c) => {
|
|||||||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return {
|
||||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||||
trend: trendArr,
|
trend: trendArr,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -328,6 +339,8 @@ app.get('/electric/overview', async (c) => {
|
|||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
|
|
||||||
|
const data = await cached(`electric/monthly?customer=${customer}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
'is_deleted = 0',
|
'is_deleted = 0',
|
||||||
customerClause('customer_id', customer),
|
customerClause('customer_id', customer),
|
||||||
@@ -384,7 +397,9 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(months);
|
return months;
|
||||||
|
});
|
||||||
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user