跳转到主要内容

Nest 使用笔记

第十八章:MongoDB——行为日志、全局埋点拦截器与 DAU 分析

打开 learnhub 的 BehaviorLogInterceptor,在真实代码里讲透为什么行为日志走 Mongo 而业务数据走 MySQL、Mongoose 在 Nest 里怎么集成、lazyConnection 为什么让启动容错、一个全局拦截器怎么把每个请求自动写进 Mongo 喂给 DAU 分析。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • MongoDB
  • 数据层

第八章用 TypeORM 把 MySQL 接进了 learnhub,业务核心(用户、帖子、评论)都落在关系库。但还有一类数据塞进 MySQL 很别扭——用户行为日志:每个请求都要写一条、字段会变(今天加个 referer,明天加个 deviceId)、写多读少、最后还要按时间聚合算日活。learnhub 的做法是再开一个 Mongo,专门吃这种高写量、schema 灵活、用来分析的数据。这一章打开 learnhub 真实跑了很久的 BehaviorLogInterceptor——一个全局拦截器把每个请求自动记录到 Mongo,再喂给 GraphQL 分析看板算 DAU。和「写个 Product 跑通四条 CRUD」不一样,这一章讲的是生产里审计 + 分析入口怎么搭。

先搞懂:MongoDB 是什么 / 为什么需要 / 企业级怎么用

MongoDB 是什么:文档型 NoSQL。存的是 JSON-like 的 BSON 文档,同一个 collection 里每条文档字段可以不一样、还能嵌套很深;查询用 API(find / insertOne / aggregate),不是 SQL,主键是自动生成的 _id(ObjectId)。

为什么需要它:MySQL 的表结构是死的,字段一变就 ALTER 全表,嵌套深的数据还得拆好几张表 JOIN。判断:数据结构不固定、嵌套层级深、写多读少、不强依赖跨表事务(日志 / 商品动态属性 / 内容配置)→ 用 MongoDB;强关系、重事务、要强一致 → 还是 MySQL。很多系统两者并存:主数据走 MySQL、日志和分析辅助走 MongoDB——learnhub 就是这么做的。

企业级怎么用:单机不叫生产,至少副本集(Primary + Secondary)做高可用和自动故障转移,数据量再大就分片(shard)横向扩;_id 用 ObjectId 别自己造;索引照样要建,没索引的查询照样全表扫;日志型集合要考虑 TTL 索引或 capped collection 做自动轮转,否则磁盘早晚撑爆。

这一章你会做出什么

  • 在 learnhub 的真实代码里讲清「polyglot persistence」——为什么帖子/用户放 MySQL、行为日志放 Mongo。
  • MongooseModule.forRootAsync 接 Mongo,理解 lazyConnection: true + serverSelectionTimeoutMS 为什么是生产级容错。
  • 定义 BehaviorLog schema(@Schema / @Prop / timestamps),写 BehaviorLogService(写入 + 聚合管道算 DAU)。
  • 打开本章主角 BehaviorLogInterceptor——一个全局拦截器,自动把每个请求(method/path/status/duration/userId/ip/ua)落进 Mongo,fire-and-forget 保证日志不拖垮业务。
  • 看这些日志怎么喂给分析看板算 DAU(Mongo 聚合管道 + GraphQL/REST 多数据源合流)。

前置:第八章的 TypeORM 已经接好 MySQL;learnhub 的 docker/docker-compose.yml 里有 Mongo 服务。

第一步:为什么 learnhub 同时用 MySQL 和 Mongo

这不是「学完 MySQL 再学 Mongo 凑数」,是真实项目的取舍。learnhub 的数据分两类:

  • 关系型业务数据(用户、帖子、评论、标签、收藏):强一致、有外键关联、要事务(发帖 + 绑标签必须原子)。这些放 MySQL,第八章讲透了。
  • 行为日志:每个 HTTP 请求写一条、字段会随业务演进(今天加 referer、明天加 deviceId)、几乎只追加不修改、不参与事务、最后要按时间聚合算日活。这种数据塞 MySQL 难受——schema 一变就 ALTER 全表(行为日志表轻松上千万行),写入压力也直接压到主库。

所以 learnhub 把两类数据拆到两个库:MySQL 管业务真相,Mongo 管行为流水。这就是常说的 polyglot persistence(多模型存储)——不为所有数据找一个万能库,而是按数据特性选最合适的。第七步你会看到 AnalyticsService 同时注入 MySQL 的 Repository 和 Mongo 的 BehaviorLogService,那个文件就是 polyglot 在代码层的落点。

注意:千万别把有事务需求的核心业务数据搬到 Mongo。MongoDB 4.0 起虽然支持多文档事务,但跨 collection 的事务成本远高于 MySQL 的 InnoDB,也没有外键约束帮你兜数据完整性。learnhub 的判断很清晰:用户/帖子这种「业务真相」留 MySQL,日志这种「派生数据」才进 Mongo——派生数据丢了能重算,业务数据丢了是事故。

第二步:起 Mongo + mongosh 增删改查

先用 Docker 起一个 Mongo(learnhub 的 docker/docker-compose.yml 里也有同样的服务,开发时直接 docker compose up mongo):

docker run -d --name mongo-test -p 27017:27017 mongo
docker ps   # mongo-test 在运行

进 shell 认个脸:

docker exec -it mongo-test mongosh
use learnhub; // 切到/创建 learnhub 库(插数据后才真正落盘)

// 增
db.behavior_log.insertOne({ method: 'GET', path: '/api/v1/posts', status: 200, durationMs: 12, userId: 1 });
db.behavior_log.insertMany([
  { method: 'GET', path: '/api/v1/posts/1', status: 200, durationMs: 8, userId: 1 },
  { method: 'POST', path: '/api/v1/posts', status: 403, durationMs: 3, userId: null },
]);

// 查
db.behavior_log.find({ status: { $gte: 400 } }); // 只看报错请求
db.behavior_log.findOne({ path: '/api/v1/posts' });

// 改
db.behavior_log.updateOne({ status: 200 }, { $set: { durationMs: 15 } });

// 删
db.behavior_log.deleteMany({ status: { $lt: 200 } });

// 聚合(第五步会细讲)
db.behavior_log.aggregate([
  { $group: { _id: '$path', count: { $sum: 1 } } }, // 按 path 分组计数
]);

GUI 推荐 MongoDB Compass。mongosh 是开发调试用的,真实写入由下面 Nest 里的拦截器自动完成,业务代码完全无感。

第三步:在 Nest 里连 Mongo——forRootAsync + lazyConnection

装包:

cd learnhub  # 或你自己的项目
npm install @nestjs/mongoose mongoose

连接配置走 ConfigService,和第八章 TypeORM 一样用动态模块(动态模块本身见第四章):

// learnhub/src/app.module.ts
MongooseModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    uri: config.get<string>('mongo.uri'),
    lazyConnection: true,
    serverSelectionTimeoutMS: 3000,
  }),
}),

uri 从环境变量读(MONGO_URI,默认 mongodb://127.0.0.1:27017/learnhub)。两个参数是这一节的重点,初学最容易直接抄 forRoot('mongodb://...') 然后上线被坑:

  • lazyConnection: true:默认 forRootAsync 一启动就立刻连 Mongo,连不上整个 Nest 启动失败。开了 lazyConnection 后,启动时不连,真到第一次用到 Model 才懒连。为什么这么干?因为 learnhub 的 Mongo 只服务于行为日志(派生数据),不能因为日志库挂了导致整个服务起不来——主业务(MySQL 那一套)照样得对外提供服务。这是真实的容错设计:日志库挂,业务不受影响;这也是第四章讲生命周期钩子时强调的「启动顺序不要被次要依赖绑架」。
  • serverSelectionTimeoutMS: 3000:连 Mongo 时找可用节点的超时。默认 30 秒太长——开了 lazy 后,第一次写日志如果 Mongo 真挂了,请求会卡 30 秒等超时;改成 3 秒,失败得快。失败后日志写入会 .catch 兜住(第六步看 BehaviorLogInterceptor),只警告、不影响业务响应。

注意:lazy 连接的副作用是——服务刚启动后的头几个请求,可能赶上第一次建立 Mongo 连接的几十毫秒延迟。对行为日志这种 fire-and-forget 的场景无所谓;如果你把 lazy 用在业务关键路径上,要权衡首次延迟,关键路径建议预热或不用 lazy。

思考:为什么这里用 forRootAsync 而不是 forRoot?和第八章 TypeORM 同一个理由——ConfigService 是个 provider,要等 IoC 容器起来才能注入,forRoot 是静态同步的拿不到。useFactory 把「等配置就绪 → 再建连接」这件事交给 Nest,工厂函数的参数就是 inject 里声明的依赖,第四章动态模块细讲过。

第四步:定义 Schema——@Schema / @Prop / timestamps

Mongo 是 schema-less 的,但 Mongoose 还是建议声明一个 schema 类来约束字段、给类型提示、加索引。learnhub 的 BehaviorLog

// learnhub/src/modules/behavior-log/schemas/behavior-log.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ collection: 'behavior_log', timestamps: true })
export class BehaviorLog extends Document {
  @Prop({ required: true }) method: string;
  @Prop({ required: true }) path: string;
  @Prop({ required: true }) status: number;
  @Prop({ required: true }) durationMs: number;
  @Prop({ type: Number, default: null }) userId: number | null;
  @Prop() ip?: string;
  @Prop() userAgent?: string;
  @Prop({ default: () => new Date().toISOString() }) time: string;
}

export const BehaviorLogSchema = SchemaFactory.createForClass(BehaviorLog);
// 按createdAt建索引,方便按时间范围聚合
BehaviorLogSchema.index({ createdAt: -1 });

几个要点,和 TypeORM 的 Entity 对照着看:

  • @Schema({ collection: 'behavior_log', timestamps: true })collection 显式指定集合名(不传的话 Mongoose 会把类名小写复数化,BehaviorLogbehaviorlogs,不直观)。timestamps: true 是 Mongo 版的 @CreateDateColumn / @UpdateDateColumn——自动加 createdAt / updatedAt 两个字段,DAU 聚合就靠 createdAt
  • @Prop({ required: true }) 声明必填。Mongoose 的 schema 既能约束(必填/类型),又保留了灵活——后面要加个 referer 字段,直接 @Prop() referer?: string;不需要 migration。第八章 TypeORM 加字段要 migration:generate + migration:run,Mongo 这边加个 @Prop 就生效,这对字段多变的日志场景非常合适。
  • extends Document:让 TS 知道这个类的实例是个 Mongo 文档(自带 _id、有 createdAt 等),方便类型推断。Mongoose 的 Model API(find / create / aggregate)都基于这个类型。
  • SchemaFactory.createForClass(BehaviorLog):把装饰器类编译成 Mongoose 能用的 Schema 对象,这一行是固定写法。
  • 最后一行 BehaviorLogSchema.index({ createdAt: -1 })createdAt 建降序索引——DAU 聚合会按时间范围 $match,没这个索引会全集合扫描,日志量大了直接慢查询。

注意:日志类集合写入量极大(每个请求一条),生产环境一定要考虑轮转,否则磁盘早晚撑爆。两种主流做法:给 createdAtTTL 索引{ expireAfterSeconds: 30 * 24 * 3600 },30 天后自动删),或者用 capped collection(固定大小、写满自动覆盖最老的)。learnhub 学习项目没加,真实上线必须补上。

第五步:注册 Model + Service——写日志、算 DAU

和 TypeORM 的 forFeature([Post]) 类似,Mongoose 也要在模块里登记 schema,Nest 才会造出可注入的 Model<BehaviorLog>

// learnhub/src/modules/behavior-log/behavior-log.module.ts
@Module({
  imports: [
    MongooseModule.forFeature([{ name: BehaviorLog.name, schema: BehaviorLogSchema }]),
  ],
  controllers: [BehaviorLogController],
  providers: [BehaviorLogService, BehaviorLogInterceptor],
  exports: [BehaviorLogService, BehaviorLogInterceptor],
})
export class BehaviorLogModule {}

forFeature([{ name, schema }]) 干两件事:把 BehaviorLog 这个 schema 登记给 Mongoose,同时把 Model<BehaviorLog> 注册成可注入的 provider。name: BehaviorLog.name 是字符串 'BehaviorLog'——注入时也用这个字符串作 token,要对得上。exports 把 Service 和 Interceptor 暴露出去,第七步的 analytics 模块要复用 Service 算 DAU。

注入 Model、写 Service:

// learnhub/src/modules/behavior-log/behavior-log.service.ts
@Injectable()
export class BehaviorLogService {
  private readonly logger = new Logger(BehaviorLogService.name);

  constructor(@InjectModel(BehaviorLog.name) private readonly model: Model<BehaviorLog>) {}

  /** 异步写日志,失败不影响请求(调用方应 .catch) */
  async log(entry: LogEntry): Promise<void> {
    await this.model.create(entry);
  }

  /** 近 N 天每日活跃用户数(distinct userId) */
  async dau(days = 7): Promise<{ date: string; activeUsers: number }[]> {
    const since = new Date(Date.now() - days * 24 * 3600 * 1000);
    const rows = await this.model.aggregate<{
      _id: string;
      activeUsers: number;
    }>([
      { $match: { createdAt: { $gte: since }, userId: { $ne: null } } },
      {
        $group: {
          _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } },
          users: { $addToSet: '$userId' },
        },
      },
      { $project: { _id: 1, activeUsers: { $size: '$users' } } },
      { $sort: { _id: 1 } },
    ]);
    return rows.map((r) => ({ date: r._id, activeUsers: r.activeUsers }));
  }

  /** 最近 N 条日志(调试/演示用,lean 返回纯对象) */
  async recent(limit = 50): Promise<Record<string, unknown>[]> {
    return this.model.find().sort({ createdAt: -1 }).limit(limit).lean().exec() as Promise<
      Record<string, unknown>[]
    >;
  }
}

几个真实项目才会注意的点:

  • @InjectModel(BehaviorLog.name):和 TypeORM 的 @InjectRepository(Post) 对应,按名字拿 Model。token 是字符串 BehaviorLog.name,必须和上面 forFeaturename 字段对得上,拼错就 Nest can't resolve dependencies

  • log() 就一个 create(),一行插一条。Service 自己不 catch——它把「写日志可能失败」这件事上抛给调用方(第六步看拦截器怎么 .catch),职责分明:Service 只管写,调用方决定失败怎么办。

  • dau() 是 Mongo 的聚合管道(aggregation pipeline),管道从上到下流水线执行,每一步的输出是下一步的输入:

    • $match:先过滤近 N 天、且 userId 不为 null 的文档(匿名请求不算活跃用户);
    • $group:按 createdAt 转成的日期字符串(2026-07-19)分组,每组用 $addToSet 收集去重后userId 集合;
    • $project{ $size: '$users' } 算每个集合的大小,就是当天的活跃用户数;
    • $sort:按日期升序,画折线图方便。

    SQL 写法是 SELECT DATE(created_at) d, COUNT(DISTINCT user_id) FROM behavior_log WHERE created_at >= ? AND user_id IS NOT NULL GROUP BY d ORDER BY d,思路完全一样,只是换了套 API。这就是行为日志的「产品价值」——单条日志没用,聚合起来就是 DAU 趋势。

  • recent().lean() 是个性能优化——默认 Mongoose 查回来的是带一堆方法的 Document 实例(占内存、有开销),lean() 让它直接返回纯 JS 对象。调试接口、不需要修改文档的场景都该加 lean()

第六步:BehaviorLogInterceptor——全局自动埋点(本章核心)

这一节是和「写个玩具 CRUD」差距最大的一段。前面五步都是基础设施,这一步才是 learnhub 真正在用的审计 + 分析入口:一个全局拦截器,每个 HTTP 请求结束自动写一条 Mongo 文档。

// learnhub/src/modules/behavior-log/behavior-log.interceptor.ts
@Injectable()
export class BehaviorLogInterceptor implements NestInterceptor {
  private readonly logger = new Logger('BehaviorLog');

  constructor(private readonly logService: BehaviorLogService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    if (context.getType() !== 'http') return next.handle(); // 只埋 HTTP,WebSocket 不走这里

    const req = context.switchToHttp().getRequest<Request & { user?: { userId?: number } }>();
    const res = context.switchToHttp().getResponse();
    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: () => this.write(req, res, start),
        error: () => this.write(req, res, start), // 异常也记录(状态码非 2xx)
      }),
    );
  }

  private write(
    req: Request & { user?: { userId?: number } },
    res: { statusCode: number },
    start: number,
  ): void {
    const url = (req as any).url as string;
    if (url.startsWith('/api/health')) return; // 健康检查不记,避免噪声

    const entry = {
      method: (req as any).method as string,
      path: url,
      status: res.statusCode,
      durationMs: Date.now() - start,
      userId: req.user?.userId ?? null,
      ip: this.ip(req),
      userAgent: (req.headers as any)?.['user-agent'],
    };
    this.logService.log(entry).catch((e) => this.logger.warn(`写日志失败:${e.message}`));
  }

  private ip(req: any): string | undefined {
    const fwd = req.headers?.['x-forwarded-for'];
    if (fwd) return String(fwd).split(',')[0].trim();
    return req.socket?.remoteAddress || req.ip;
  }
}

逐段讲清楚为什么这么写:

  • 为什么是拦截器:拦截器(interceptor)是 Nest AOP 里唯一能同时拿到「请求进来的上下文」和「请求出去的响应」的组件——守(guard)在请求前跑、只决定放不放行,异常过滤器(filter)只在出错时跑,pipe 只处理参数。只有拦截器能用 RxJS 的 tap 在请求成功或失败之后都触发一次,既看得到 res.statusCode(最终的 HTTP 状态码),又能在 start = Date.now() 的对照下算出整次请求的耗时。这正是「埋点」要的时机,第五章讲 AOP 五件套的时候细讲过。
  • tap({ next, error })next 回调在 handler 正常返回时触发,error 在抛异常时触发。两个都写 this.write——成功请求要记(200 也是行为),失败请求更要记(4xx/5xx 是排障的关键)。漏写 error 就只能记到成功的请求,等于审计日志缺了一半。
  • Date.now() - startstart 在拦截器进入时记一次,write 里相减,就是这次请求的耗时(毫秒)。这个值近似「业务处理时长」,是性能监控的核心指标。
  • req.user?.userId:JWT 守卫解出 token 后会把用户信息挂在 req.user 上(第十一章登录细讲)。这里读得到就记,读不到就 null(未登录的匿名请求也算行为)。?? null 而不是 ?? undefined,因为上面 DAU 聚合是按 userId: { $ne: null } 过滤的——undefined 在 Mongo 里会被当成「字段不存在」,行为不一致,统一存 null
  • 健康检查跳过if (url.startsWith('/api/health')) return;。Kubernetes / 负载均衡每隔几秒探一次健康检查,不跳过的话日志表会被 /api/health 淹没。
  • this.ip(req) 优先取 x-forwarded-for:生产环境一般挂在 Nginx 后面,req.socket.remoteAddress 拿到的是 Nginx 的 IP 而不是用户 IP。x-forwarded-for 是代理链路透传的真实用户 IP(格式 客户端IP, 代理1, 代理2),取第一个。注意:这个 header 客户端能伪造,生产环境只信任自己控制的前置代理设置的 x-forwarded-for,别盲信。

最关键的一行——fire-and-forget

this.logService.log(entry).catch((e) => this.logger.warn(`写日志失败:${e.message}`));

log() 返回 Promise,这里不 await——不等它写完就直接返回响应给用户;写入失败也只 warn 一条本地日志,绝不向上抛。这是埋点的铁律:日志不能反过来把业务请求拖垮。如果 await,每次响应都要等一次 Mongo 写入(网络抖动 + 磁盘 fsync),接口 P99 直接恶化;如果失败不 catch,Mongo 抖一下整个应用就被异常过滤器刷屏。

思考:既然日志这么重要,为什么不直接用第五章的 winston 往文件里写就够了?区别在用途——winston 写的文件是给运维看的(排障、追踪一次报错的上下文),是面向人的纯文本;Mongo 里的行为日志是给产品程序看的(DAU 计算、接口耗时分位、错误率监控),是结构化、可查询、可聚合的。文件翻一遍能找到某次报错,但算「过去 7 天每天有多少不同用户访问」就得写脚本扫文件;Mongo 一条 aggregate 就出结果。两者不冲突,learnhub 两个都开——winston 记报错细节,Mongo 记结构化行为。

第七步:挂成全局 APP_INTERCEPTOR + 看分析收益

拦截器写好不会自动生效,得在 AppModule 里用 APP_INTERCEPTOR 注册成全局的:

// learnhub/src/app.module.ts
providers: [
  // 全局异常过滤器:统一错误结构
  { provide: APP_FILTER, useClass: HttpExceptionFilter },
  // 全局拦截器:统一返回结构 + 请求日志
  { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  // 阶段7:行为日志(Mongo,放最外层以记录完整耗时)
  { provide: APP_INTERCEPTOR, useClass: BehaviorLogInterceptor },
  // ...全局 Guard
],

APP_INTERCEPTOR 是 Nest 提供的全局 token——用 useClass 注册的拦截器会自动套到所有 controller 的所有接口上,不用每个 controller 单独 @UseInterceptors。这是「全局埋点」和「手动埋点」的区别:全局注册后,业务代码完全无感,新增任何接口都自动被记录。

注意(拦截器顺序的坑)APP_INTERCEPTOR声明顺序组成调用链,第一个注册的是最外层。这里 BehaviorLogInterceptorTransformInterceptorLoggingInterceptor 之后注册,是这条链上最靠近 handler 的一层——它量的 durationMs 是从它进入到 handler 完成的时间,基本等于纯业务处理耗时,排除了响应变换和 winston 日志那两层的时间。代码注释里写的「放最外层」是开发时的意图,真实生效顺序以声明顺序为准(Nest 的 APP_INTERCEPTOR 执行序 = 注册序)。如果你想让行为日志真的最外层包住一切,应该把它声明在最前面——但那样它会量到其他拦截器的耗时,未必更合理。current 的写法对「业务耗时」这个指标反而更准。

跑起来看效果:

cd learnhub
docker compose -f docker/docker-compose.yml up -d mongo mysql
npm run start:dev

随便调几个接口:

# 登录拿 token(第十一章细讲)
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'

# 发个帖子(会触发 BehaviorLogInterceptor 写一条文档)
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"Mongo 行为日志","content":"..."}'

# 查最近的行为日志(admin 权限)
curl -H "Authorization: Bearer eyJ..." \
  http://localhost:3000/api/v1/behavior-log/recent?limit=5
# [{"method":"POST","path":"/api/v1/posts","status":201,"durationMs":42,"userId":1,
#   "ip":"::1","userAgent":"curl/8.x","createdAt":"2026-07-19T..."}, ...]

# 查近 7 天 DAU
curl -H "Authorization: Bearer eyJ..." \
  http://localhost:3000/api/v1/behavior-log/dau?days=7
# [{"date":"2026-07-19","activeUsers":1}]

每条请求都自动落库,DAU 一条查询就出来了——这就是行为日志的产品价值。

进阶:通过 GraphQL 把多数据源合流成看板

learnhub 还把这些数据通过 GraphQL 暴露出去(GraphQL 章节细讲,这里看一眼 polyglot 在代码层的合流写法):

// learnhub/src/modules/analytics/analytics.service.ts
@Injectable()
export class AnalyticsService {
  constructor(
    private readonly behaviorLog: BehaviorLogService,
    @InjectRepository(User) private readonly userRepo: Repository<User>,
    @InjectRepository(Post) private readonly postRepo: Repository<Post>,
    @InjectRepository(Comment) private readonly commentRepo: Repository<Comment>,
  ) {}

  async dau(days: number): Promise<DauPoint[]> {
    return this.behaviorLog.dau(days);   // ← Mongo
  }

  async summary(): Promise<Summary> {
    const [userCount, postCount, commentCount, viewRow] = await Promise.all([
      this.userRepo.count(),
      this.postRepo.count(),
      this.commentRepo.count(),
      this.postRepo.createQueryBuilder('p').select('COALESCE(SUM(p.view_count),0)', 's').getRawOne(),
    ]);                                     // ← MySQL
    return { userCount, postCount, commentCount, totalViews: Number(viewRow?.s ?? 0) };
  }
}

注意这个 Service 同时注入了 Mongo 的 BehaviorLogService(算 DAU)和 MySQL 的三个 Repository(算总量)——polyglot persistence 在代码层的体现:业务计数走 MySQL,行为聚合走 Mongo,前端通过 GraphQL 拿到一个合并好的 Summary 对象。哪个数据在哪边查,完全按数据特性来,不强迫一个库干所有事。这就是第一章 main.ts 里 GraphQL 那一档配置最终要喂出去的东西。

这一章的成果

  1. 在真实代码里讲清了 polyglot persistence:业务真相(用户/帖子)放 MySQL 要事务,行为流水(日志)放 Mongo 要灵活和聚合,两者不混——派生数据丢了能重算,业务数据丢了是事故。
  2. MongooseModule.forRootAsync + lazyConnection: true + serverSelectionTimeoutMS 实现启动容错——Mongo 挂了主业务照样起、照样服务。
  3. 定义了 BehaviorLog schema(@Schema / @Prop / timestamps),理解了 Mongo 加字段不要 migration 的灵活代价,以及日志类集合上线必须补 TTL/capped 轮转。
  4. 打开本章主角 BehaviorLogInterceptor——一个全局拦截器,自动把每个请求(含成功和失败)写进 Mongo,fire-and-forget + .catch 保证日志绝不拖垮业务。
  5. 看了 DAU 聚合管道($match → $group → $project → $sort)和多数据源 GraphQL 看板的合流写法。

常见问题

  • MongoDB 和 MySQL 怎么选:强一致、有外键、要事务 → MySQL;schema 多变、写多读少、要聚合分析 → Mongo。真实项目常常两者并存,按数据特性分库——learnhub 就是 MySQL 管业务、Mongo 管日志。
  • lazyConnection: true 到底改了什么:默认 forRootAsync 启动就立刻连,连不上启动失败;开了 lazy 后启动不连、用到才连。代价是首次请求有建连延迟。适合 Mongo 这种「挂了不影响主业务」的辅助库,不适合关键路径。
  • 写日志为什么一定要 .catch:日志是派生数据,绝不能反过来拖垮业务请求。logService.log(entry).catch(...) 既不 await(不阻塞响应),又兜住失败(不向上抛),是埋点的标准写法。
  • 行为日志和 winston 日志什么关系:winston 写文件、给运维看、纯文本、排障用;Mongo 存结构化文档、给产品/程序看、可查询可聚合、算 DAU 用。两者互补,不替代。
  • 日志表越来越大怎么办:上 TTL 索引(按 createdAt 过期自动删)或 capped collection(固定大小、写满覆盖最老)。learnhub 学习项目没加,生产必须加。
  • _id 是什么:MongoDB 自动生成的 ObjectId(24 字符 hex 字符串),不是自增数字。参数类型写 string
  • userId: null 和不存这个字段有什么区别null 是显式「已知没有」,undefined/不存是「未知」。DAU 聚合用 $ne: null 过滤时两者都被排除,但语义不同。learnhub 统一存 null,避免 undefined 在 Mongo 里被当成字段不存在导致行为不一致。

下一章讲 MinIO——自己搭一个对象存储(OSS),存文件/图片,前端直传、accessKey 不暴露。