Przeglądaj źródła

1. messagerecord常态化归档;2. messagerecord索引优化,提升查询效率

guoziyun 1 miesiąc temu
rodzic
commit
37f2c24ab7

+ 1 - 0
oms/dist/services/cron-jobs/index.js

@@ -13,6 +13,7 @@ const settings = [
     // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
     ["done-rate", "10 0 * * *", require("./done-rate")], // 每天凌晨0点10分, 统计作品完成率
     ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector")], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
+    ["monthly-archive-messagerecords", "30 2 1 * *", require("./monthly-archive-messagerecords")], // 每月1日凌晨2:30,归档上月messagerecords到冷表并清理热表
     // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
     // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
     // ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试

+ 96 - 0
oms/dist/services/cron-jobs/monthly-archive-messagerecords.js

@@ -0,0 +1,96 @@
+"use strict";
+/**
+ * monthly-archive-messagerecords.ts
+ *
+ * 常态化归档 cron job:每月 1 日凌晨 2:30 自动将上个月的 messagerecords 数据
+ * 归档到 messagerecords_archive_YYYYMM 集合。
+ *
+ * 触发条件:上月月初距今 >= HOT_DAYS(180天),才实际归档;否则跳过。
+ * 幂等:重跑安全,使用 replaceOne+upsert by _id。
+ */
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.run = run;
+const mongoose_1 = __importDefault(require("mongoose"));
+const HOT_DAYS = 180;
+const BATCH_SIZE = 3000;
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+async function run() {
+    const startedAt = Date.now();
+    console.log("[ArchiveCron] Starting monthly messagerecords archive job...");
+    // 判断上个月是否已超出热数据窗口
+    const now = new Date();
+    const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+    const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 1); // 本月月初 = 上月半开区间结束
+    const hotCutoff = new Date(now);
+    hotCutoff.setDate(hotCutoff.getDate() - HOT_DAYS);
+    // 保守:cutoff 取当月月初
+    const hotCutoffMonthStart = new Date(hotCutoff.getFullYear(), hotCutoff.getMonth(), 1);
+    if (lastMonthStart >= hotCutoffMonthStart) {
+        const msg = `[ArchiveCron] Last month (${fmtYYYYMM(lastMonthStart)}) is still within hot window (cutoff=${fmtYYYYMM(hotCutoffMonthStart)}), skip.`;
+        console.log(msg);
+        return msg;
+    }
+    // 获取 MongoDB 连接(复用 mongoose 默认连接,cron 环境已初始化)
+    let conn = mongoose_1.default.connection;
+    if (conn.readyState !== 1) {
+        // 若默认连接未就绪,创建专用连接
+        conn = await mongoose_1.default.createConnection(MONGO_URI).asPromise();
+    }
+    const hotCol = conn.collection("messagerecords");
+    const yyyymm = fmtYYYYMM(lastMonthStart);
+    const archiveCol = conn.collection(`messagerecords_archive_${yyyymm}`);
+    const hotCount = await hotCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+    if (hotCount === 0) {
+        const msg = `[ArchiveCron] ${yyyymm}: hotCount=0, nothing to archive.`;
+        console.log(msg);
+        return msg;
+    }
+    const archiveCount = await archiveCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+    if (archiveCount >= hotCount) {
+        const msg = `[ArchiveCron] ${yyyymm}: already complete (hot=${hotCount} archive=${archiveCount}), skip.`;
+        console.log(msg);
+        return msg;
+    }
+    console.log(`[ArchiveCron] ${yyyymm}: archiving hot=${hotCount} archive=${archiveCount} pending=${hotCount - archiveCount}`);
+    // 确保归档集合索引
+    await archiveCol.createIndex({ createdAt: 1 }, { background: true });
+    await archiveCol.createIndex({ uid: 1 }, { background: true });
+    await archiveCol.createIndex({ strategyName: 1, createdAt: 1 }, { background: true });
+    let batchInserted = 0;
+    let batch = [];
+    const cursor = hotCol.find({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } }).batchSize(BATCH_SIZE);
+    for await (const doc of cursor) {
+        batch.push(doc);
+        if (batch.length >= BATCH_SIZE) {
+            const result = await archiveCol.bulkWrite(batch.map((d) => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true } })), { ordered: false });
+            batchInserted += result.upsertedCount + result.modifiedCount;
+            batch = [];
+        }
+    }
+    if (batch.length > 0) {
+        const result = await archiveCol.bulkWrite(batch.map((d) => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true } })), { ordered: false });
+        batchInserted += result.upsertedCount + result.modifiedCount;
+    }
+    const elapsed = Date.now() - startedAt;
+    // 校验
+    const newArchiveCount = await archiveCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+    const verified = newArchiveCount >= hotCount;
+    console.log(`[ArchiveCron] ${yyyymm}: inserted/updated=${batchInserted} archiveTotal=${newArchiveCount} verified=${verified} elapsed=${elapsed}ms`);
+    if (!verified) {
+        const msg = `[ArchiveCron] ${yyyymm}: WARN archive incomplete (hot=${hotCount} archive=${newArchiveCount}). Will retry next run.`;
+        console.warn(msg);
+        return msg;
+    }
+    // 归档验证通过后自动清理热表中该月数据
+    const deleteResult = await hotCol.deleteMany({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+    console.log(`[ArchiveCron] ${yyyymm}: purged hot table deleted=${deleteResult.deletedCount} elapsed=${Date.now() - startedAt}ms`);
+    const msg = `[ArchiveCron] ${yyyymm}: archive+purge complete. archived=${batchInserted} purged=${deleteResult.deletedCount} elapsed=${Date.now() - startedAt}ms`;
+    console.log(msg);
+    return msg;
+}
+function fmtYYYYMM(d) {
+    return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`;
+}

+ 22 - 8
oms/dist/src/models/messageRecordModel.js

@@ -157,15 +157,29 @@ messageRecordSchema.index({ createdAt: 1, activityName: 1 });
  */
 messageRecordSchema.index({ createdAt: 1, status: 1 });
 // --------------------------
-// 3. 可选:覆盖索引(针对极高频统计查询,进一步减少IO)
+// 3. 聚合覆盖索引
+// 聚合 pipeline 需要 createdAt(match)+ 维度字段(group key)+ status/inforeground/uid(分层聚合字段)
+// 将这些字段全部收入索引,消除聚合阶段的回表 IO
+// 注意:MongoDB 复合索引本身即为覆盖索引,只要索引包含所有访问字段即可
 // --------------------------
 /**
- * 场景:每日趋势统计(getDailySentTrends),聚合需用到 createdAt/status/inforeground/uid
- * 覆盖索引包含所有聚合所需字段,无需查询原始文档
+ * 聚合覆盖索引1:by-template / getDailyTrendsByTemplate
+ * pipeline 访问字段:createdAt, templateName, (templateId 已从分组移除), status, inforeground, uid
  */
-messageRecordSchema.index({ createdAt: 1, strategyName: 1 }, {
-    includeFields: { status: 1, inforeground: 1, uid: 1 }, // MongoDB 原生支持的语法
-    name: "idx_createdAt_strategy_include_agg",
-} // 类型断言,避免 TypeScript 报错
-);
+messageRecordSchema.index({ createdAt: 1, templateName: 1, status: 1, inforeground: 1, uid: 1 }, { name: "idx_agg_template" });
+/**
+ * 聚合覆盖索引2:by-image / getDailyTrendsByImage
+ * pipeline 访问字段:createdAt, image, status, inforeground, uid
+ */
+messageRecordSchema.index({ createdAt: 1, image: 1, status: 1, inforeground: 1, uid: 1 }, { name: "idx_agg_image" });
+/**
+ * 聚合覆盖索引3:by-cc / getDailyTrendsByCc
+ * pipeline 访问字段:createdAt, cc, status, inforeground, uid
+ */
+messageRecordSchema.index({ createdAt: 1, cc: 1, status: 1, inforeground: 1, uid: 1 }, { name: "idx_agg_cc" });
+/**
+ * 聚合覆盖索引4:overall / daily-trends / by-strategy(summary 首屏高频)
+ * pipeline 访问字段:createdAt, strategyName, status, inforeground, uid
+ */
+messageRecordSchema.index({ createdAt: 1, strategyName: 1, status: 1, inforeground: 1, uid: 1 }, { name: "idx_agg_strategy" });
 exports.MessageRecord = (0, mongoose_1.model)("MessageRecord", messageRecordSchema);

+ 50 - 6
oms/dist/src/services/messageRecordService.js

@@ -144,25 +144,69 @@ class MessageRecordService {
      * 按模板获取消息统计数据(含用户点击率)
      */
     async getStatisticsByTemplate(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
+        const cacheKey = this.buildStatsCacheKey("by-template", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+            page,
+            limit,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const groupFields = ["templateId", "templateName"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        // templateId 是 ObjectId,分组时增加基数但对展示无意义,移除后减少第一阶段 group key 大小
+        const groupFields = ["templateName"];
+        const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
     }
     /**
      * 按国家代码获取消息统计数据(含用户点击率)
      */
     async getStatisticsByCc(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
+        const cacheKey = this.buildStatsCacheKey("by-cc", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+            page,
+            limit,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const groupFields = ["cc"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 }, page, limit);
+        const result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
     }
     /**
      * 按图片 URL 获取消息统计数据(含用户点击率)
      */
     async getStatisticsByImage(startDate, endDate, strategyName, page = MessageRecordService.DEFAULT_STATS_PAGE, limit = MessageRecordService.DEFAULT_STATS_LIMIT) {
+        const cacheKey = this.buildStatsCacheKey("by-image", {
+            startDate: startDate?.toISOString(),
+            endDate: endDate?.toISOString(),
+            strategyName: strategyName || null,
+            page,
+            limit,
+        });
+        const cached = await this.getCache(cacheKey);
+        if (cached) {
+            console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+            return cached;
+        }
         const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-        const groupFields = ["image"];
-        return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+        const result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
+        await this.setCache(cacheKey, result);
+        console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+        return result;
     }
     /**
      * 按时间维度的趋势分析,每日统计(含用户点击率)

+ 1 - 0
oms/services/cron-jobs/index.ts

@@ -15,6 +15,7 @@ const settings: [string, string, CronJobModule][] = [
   // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
   ["done-rate", "10 0 * * *", require("./done-rate") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
   ["daily-activity-detector", "50 0 * * *", require("./daily-activity-detector") as CronJobModule], // 每天凌晨0点50分, 检查是否需要生成新的推送消息
+  ["monthly-archive-messagerecords", "30 2 1 * *", require("./monthly-archive-messagerecords") as CronJobModule], // 每月1日凌晨2:30,归档上月messagerecords到冷表并清理热表
   // ["message-sender", "*/5 * * * *", require("./message-sender") as CronJobModule], // 每5分钟运行一次, 已经单独剥离出去了(message-seender-service),定时任务这里取消了
   // ["active-user-daily-notify", "30 18 * * *", require("./active-user-daily-notify") as CronJobModule], // 每天下午6点,开始活跃用户新作品消息推送
   // ["fcm-notify", "30 18 * * *", require("./fcm-notify") as CronJobModule], // 每天下午6点,基于原来的active-user-daily-notify,增加schedule推送,AB测试

+ 117 - 0
oms/services/cron-jobs/monthly-archive-messagerecords.ts

@@ -0,0 +1,117 @@
+/**
+ * monthly-archive-messagerecords.ts
+ *
+ * 常态化归档 cron job:每月 1 日凌晨 2:30 自动将上个月的 messagerecords 数据
+ * 归档到 messagerecords_archive_YYYYMM 集合。
+ *
+ * 触发条件:上月月初距今 >= HOT_DAYS(180天),才实际归档;否则跳过。
+ * 幂等:重跑安全,使用 replaceOne+upsert by _id。
+ */
+
+import mongoose from "mongoose";
+
+const HOT_DAYS = 180;
+const BATCH_SIZE = 3000;
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin";
+
+export async function run(): Promise<string> {
+  const startedAt = Date.now();
+  console.log("[ArchiveCron] Starting monthly messagerecords archive job...");
+
+  // 判断上个月是否已超出热数据窗口
+  const now = new Date();
+  const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+  const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 1); // 本月月初 = 上月半开区间结束
+
+  const hotCutoff = new Date(now);
+  hotCutoff.setDate(hotCutoff.getDate() - HOT_DAYS);
+  // 保守:cutoff 取当月月初
+  const hotCutoffMonthStart = new Date(hotCutoff.getFullYear(), hotCutoff.getMonth(), 1);
+
+  if (lastMonthStart >= hotCutoffMonthStart) {
+    const msg = `[ArchiveCron] Last month (${fmtYYYYMM(lastMonthStart)}) is still within hot window (cutoff=${fmtYYYYMM(hotCutoffMonthStart)}), skip.`;
+    console.log(msg);
+    return msg;
+  }
+
+  // 获取 MongoDB 连接(复用 mongoose 默认连接,cron 环境已初始化)
+  let conn = mongoose.connection;
+  if (conn.readyState !== 1) {
+    // 若默认连接未就绪,创建专用连接
+    conn = await mongoose.createConnection(MONGO_URI).asPromise() as any;
+  }
+
+  const hotCol = conn.collection("messagerecords");
+  const yyyymm = fmtYYYYMM(lastMonthStart);
+  const archiveCol = conn.collection(`messagerecords_archive_${yyyymm}`);
+
+  const hotCount = await hotCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+  if (hotCount === 0) {
+    const msg = `[ArchiveCron] ${yyyymm}: hotCount=0, nothing to archive.`;
+    console.log(msg);
+    return msg;
+  }
+
+  const archiveCount = await archiveCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+  if (archiveCount >= hotCount) {
+    const msg = `[ArchiveCron] ${yyyymm}: already complete (hot=${hotCount} archive=${archiveCount}), skip.`;
+    console.log(msg);
+    return msg;
+  }
+
+  console.log(`[ArchiveCron] ${yyyymm}: archiving hot=${hotCount} archive=${archiveCount} pending=${hotCount - archiveCount}`);
+
+  // 确保归档集合索引
+  await archiveCol.createIndex({ createdAt: 1 }, { background: true });
+  await archiveCol.createIndex({ uid: 1 }, { background: true });
+  await archiveCol.createIndex({ strategyName: 1, createdAt: 1 }, { background: true });
+
+  let batchInserted = 0;
+  let batch: any[] = [];
+  const cursor = hotCol.find({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } }).batchSize(BATCH_SIZE);
+
+  for await (const doc of cursor) {
+    batch.push(doc);
+    if (batch.length >= BATCH_SIZE) {
+      const result = await archiveCol.bulkWrite(
+        batch.map((d) => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true } })),
+        { ordered: false }
+      );
+      batchInserted += result.upsertedCount + result.modifiedCount;
+      batch = [];
+    }
+  }
+
+  if (batch.length > 0) {
+    const result = await archiveCol.bulkWrite(
+      batch.map((d) => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true } })),
+      { ordered: false }
+    );
+    batchInserted += result.upsertedCount + result.modifiedCount;
+  }
+
+  const elapsed = Date.now() - startedAt;
+
+  // 校验
+  const newArchiveCount = await archiveCol.countDocuments({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+  const verified = newArchiveCount >= hotCount;
+  console.log(`[ArchiveCron] ${yyyymm}: inserted/updated=${batchInserted} archiveTotal=${newArchiveCount} verified=${verified} elapsed=${elapsed}ms`);
+
+  if (!verified) {
+    const msg = `[ArchiveCron] ${yyyymm}: WARN archive incomplete (hot=${hotCount} archive=${newArchiveCount}). Will retry next run.`;
+    console.warn(msg);
+    return msg;
+  }
+
+  // 归档验证通过后自动清理热表中该月数据
+  const deleteResult = await hotCol.deleteMany({ createdAt: { $gte: lastMonthStart, $lt: lastMonthEnd } });
+  console.log(`[ArchiveCron] ${yyyymm}: purged hot table deleted=${deleteResult.deletedCount} elapsed=${Date.now() - startedAt}ms`);
+
+  const msg = `[ArchiveCron] ${yyyymm}: archive+purge complete. archived=${batchInserted} purged=${deleteResult.deletedCount} elapsed=${Date.now() - startedAt}ms`;
+  console.log(msg);
+  return msg;
+}
+
+function fmtYYYYMM(d: Date): string {
+  return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`;
+}

+ 36 - 8
oms/src/models/messageRecordModel.ts

@@ -201,18 +201,46 @@ messageRecordSchema.index({ createdAt: 1, activityName: 1 });
 messageRecordSchema.index({ createdAt: 1, status: 1 });
 
 // --------------------------
-// 3. 可选:覆盖索引(针对极高频统计查询,进一步减少IO)
+// 3. 聚合覆盖索引
+// 聚合 pipeline 需要 createdAt(match)+ 维度字段(group key)+ status/inforeground/uid(分层聚合字段)
+// 将这些字段全部收入索引,消除聚合阶段的回表 IO
+// 注意:MongoDB 复合索引本身即为覆盖索引,只要索引包含所有访问字段即可
 // --------------------------
+
 /**
- * 场景:每日趋势统计(getDailySentTrends),聚合需用到 createdAt/status/inforeground/uid
- * 覆盖索引包含所有聚合所需字段,无需查询原始文档
+ * 聚合覆盖索引1:by-template / getDailyTrendsByTemplate
+ * pipeline 访问字段:createdAt, templateName, (templateId 已从分组移除), status, inforeground, uid
  */
 messageRecordSchema.index(
-  { createdAt: 1, strategyName: 1 },
-  {
-    includeFields: { status: 1, inforeground: 1, uid: 1 }, // MongoDB 原生支持的语法
-    name: "idx_createdAt_strategy_include_agg",
-  } as any // 类型断言,避免 TypeScript 报错
+  { createdAt: 1, templateName: 1, status: 1, inforeground: 1, uid: 1 },
+  { name: "idx_agg_template" }
+);
+
+/**
+ * 聚合覆盖索引2:by-image / getDailyTrendsByImage
+ * pipeline 访问字段:createdAt, image, status, inforeground, uid
+ */
+messageRecordSchema.index(
+  { createdAt: 1, image: 1, status: 1, inforeground: 1, uid: 1 },
+  { name: "idx_agg_image" }
+);
+
+/**
+ * 聚合覆盖索引3:by-cc / getDailyTrendsByCc
+ * pipeline 访问字段:createdAt, cc, status, inforeground, uid
+ */
+messageRecordSchema.index(
+  { createdAt: 1, cc: 1, status: 1, inforeground: 1, uid: 1 },
+  { name: "idx_agg_cc" }
+);
+
+/**
+ * 聚合覆盖索引4:overall / daily-trends / by-strategy(summary 首屏高频)
+ * pipeline 访问字段:createdAt, strategyName, status, inforeground, uid
+ */
+messageRecordSchema.index(
+  { createdAt: 1, strategyName: 1, status: 1, inforeground: 1, uid: 1 },
+  { name: "idx_agg_strategy" }
 );
 
 export const MessageRecord = model<IMessageRecord>("MessageRecord", messageRecordSchema);

+ 50 - 6
oms/src/services/messageRecordService.ts

@@ -205,9 +205,25 @@ export class MessageRecordService {
     page: number = MessageRecordService.DEFAULT_STATS_PAGE,
     limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
   ) {
+    const cacheKey = this.buildStatsCacheKey("by-template", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+      page,
+      limit,
+    });
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const groupFields = ["templateId", "templateName"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    // templateId 是 ObjectId,分组时增加基数但对展示无意义,移除后减少第一阶段 group key 大小
+    const groupFields = ["templateName"];
+    const result = await this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
   }
 
   /**
@@ -220,9 +236,23 @@ export class MessageRecordService {
     page: number = MessageRecordService.DEFAULT_STATS_PAGE,
     limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
   ) {
+    const cacheKey = this.buildStatsCacheKey("by-cc", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+      page,
+      limit,
+    });
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const groupFields = ["cc"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { totalRecords: -1 }, page, limit);
+    const result = await this.getStatisticsByGroup(matchConditions, ["cc"], { totalRecords: -1 }, page, limit);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
   }
 
   /**
@@ -235,9 +265,23 @@ export class MessageRecordService {
     page: number = MessageRecordService.DEFAULT_STATS_PAGE,
     limit: number = MessageRecordService.DEFAULT_STATS_LIMIT
   ) {
+    const cacheKey = this.buildStatsCacheKey("by-image", {
+      startDate: startDate?.toISOString(),
+      endDate: endDate?.toISOString(),
+      strategyName: strategyName || null,
+      page,
+      limit,
+    });
+    const cached = await this.getCache(cacheKey);
+    if (cached) {
+      console.log(`[MessageStatsCache] hit key=${cacheKey}`);
+      return cached;
+    }
     const matchConditions = this.buildMatchConditions(startDate, endDate, strategyName);
-    const groupFields = ["image"];
-    return this.getStatisticsByGroup(matchConditions, groupFields, { clickThroughRate: -1 }, page, limit);
+    const result = await this.getStatisticsByGroup(matchConditions, ["image"], { clickThroughRate: -1 }, page, limit);
+    await this.setCache(cacheKey, result);
+    console.log(`[MessageStatsCache] miss key=${cacheKey}`);
+    return result;
   }
 
   /**