不该直接存成大文档;应将每次点击作为独立文档存储,精简字段、建合理索引,并用唯一复合索引实现去重,配合覆盖索引优化聚合查询,按需预聚合。

点击事件该不该直接存成大文档?
别把每次点击都塞进一个嵌套数组里——这是最典型的“日志当文档”误用。MongoDB 单文档上限 64MB,但更现实的瓶颈是:一旦 clicks 数组涨到几千条,$push 写入会变慢,$elemMatch 查询变卡,连 db.collection.stats() 都可能因文档膨胀而失真。
真正该做的,是把「点击」当作原子事件独立建模:
- 每个点击事件存为一条独立文档,字段精简:
{ ad_id: ObjectId, user_id: "u_123", timestamp: ISODate("..."), ip: "192.168.1.1", ua_hash: "a1b2c3..." } - 去掉冗余字段(如完整
user_agent字符串),改用哈希值 + 索引,省空间也提速 - 避免在广告文档里用
clicks: [...]嵌入——这会让广告文档随时间不可控膨胀,且无法对“过去24小时所有点击”做高效聚合
如何低成本实现点击去重?
去重不是靠应用层查一遍再写,而是靠 MongoDB 原生约束和结构设计。核心思路:把“用户 × 广告 × 时间窗口”变成唯一键,让数据库自己拦住重复。
实操上分两步走:
- 定义自然去重粒度,比如“同一用户 15 分钟内对同一广告的多次点击只记一次”,那么复合唯一索引就是:
db.clicks.createIndex({ ad_id: 1, user_id: 1, window_start: 1 }, { unique: true }),其中window_start是timestamp向下取整到最近 15 分钟(如new Date(Math.floor(ts.getTime() / 900000) * 900000)) - 写入时用
insertOne而非updateOne+ upsert,配合writeConcern: "majority",失败即说明已存在,无需额外查 - 切忌用
find().count()判断是否存在——高并发下必然漏判,且性能崩盘
聚合查询慢?先检查索引覆盖和时间范围
广告后台最常跑的是“某广告近7天点击量”或“某渠道各广告 CTR”,这类聚合慢,90% 不是管道写得差,而是没让索引扛住过滤和排序。
关键检查点:
-
db.clicks.explain("executionStats").aggregate([...])看是否命中索引;若totalDocsExamined > totalKeysExamined,说明索引没覆盖查询条件 - 时间字段必须单独建索引:
db.clicks.createIndex({ timestamp: -1 }),且聚合时务必用{$match: { timestamp: { $gte: ..., $lt: ... } }}开头,否则全表扫 - 避免在
$group后才$match——MongoDB 不会下推,先 group 出几万桶再筛,内存爆表 - 如果要按
ad_id+ 时间聚合,优先建组合索引:{ ad_id: 1, timestamp: -1 },比两个单字段索引高效得多
要不要建 clicks_summary 集合做预聚合?
要看你的 SLA 和数据新鲜度要求。实时看板要求秒级延迟?别预聚合,老实用带索引的聚合管道。但如果运营每天只跑一次日报、且原始点击量超千万,那预聚合就不是“优化”,而是刚需。
正确做法是用 Change Stream + 定时任务双保险:
- 每 5 分钟触发一次:
db.clicks.aggregate([ { $match: { timestamp: { $gte: ... } } }, { $group: { _id: "$ad_id", count: { $sum: 1 } } } ]),结果写入clicks_summary,_id设为"ad_abc_20260312_0905"这类可排序字符串 - 同时监听
clicks的变更流,捕获迟到数据(如网络延迟导致的晚到 2 分钟点击),反向修正对应 summary 文档 - 绝不要用定时 job 全量重算——既浪费资源,又在修正窗口造成数据抖动
最容易被忽略的一点:点击事件的 ip 和 ua_hash 字段,看似只用于分析,实则常被拿去做设备去重或反作弊。一旦业务侧开始依赖它们查关联行为,就必须给这两个字段加索引——但别单独建,优先合并进高频查询的复合索引里,否则索引数量会失控。










