直接用 find() + sort() 查排行榜会变慢,因无索引或数据量超内存时触发排序内存溢出或全表扫描;需建 {score: -1} 或复合索引,并用 redis zset 缓存保障毫秒响应。

为什么直接用 find() + sort() 查排行榜会变慢?
MongoDB 默认的排序操作在数据量超过内存或缺少合适索引时,会触发 EXECUTOR_ERROR: Sort exceeded memory limit of 104857600 bytes 或明显延迟。排行榜查询本质是高频、低延迟、范围固定(如 Top 100)的 sort({score: -1}) + limit(100),但若集合无索引或 score 分布倾斜(大量相同分数),sort 阶段仍需扫描大量文档。
- 索引必须覆盖
score字段且方向匹配({score: -1}),否则无法跳过排序阶段 - 若查询还带
userId条件(如“查用户当前排名”),单字段索引无法高效支持,需复合索引 -
skip()实现分页(如第 10 页)会导致全扫描前 N 条,完全不可用于实时场景
用 createIndex() 建对索引,比优化查询语句更重要
真正决定毫秒响应的是索引结构,不是查询写法。针对不同查询模式,建索引要区分:
- 只查 Top N(如“全服最高分 100 人”):
db.players.createIndex({score: -1, _id: 1})_id: 1是为了去重和稳定排序(score 相同时靠 _id 排序,避免结果抖动) - 查某用户排名(“用户 X 当前排第几?”):
db.players.createIndex({score: -1, userId: 1})
然后用db.players.countDocuments({score: {$gt: userScore}}) + 1计算名次 —— 这个 count 走索引范围扫描,不扫全表 - 同时支持“Top N”和“用户详情+排名”:建议两个索引都建,不要试图用一个复合索引覆盖全部,因为
{score: -1, userId: 1}对纯 Top N 查询效率略低于{score: -1, _id: 1}(额外键值带来少量 B-tree 节点开销)
避免用 aggregate() 做实时榜,除非真需要动态计算
aggregate([{$sort: {score: -1}}, {$limit: 100}]) 看似清晰,但实际执行计划中,$sort 阶段仍依赖索引;如果索引缺失或字段类型不一致(比如 score 有时是字符串),就会退化为内存排序。
- 如果 score 是浮点数但存成了字符串,
{score: "99.5"}会被字典序排在"100"前面 → 榜单错乱 -
$lookup关联用户信息?别在实时榜查询里做。提前把昵称、头像等冗余到players集合,用空间换毫秒级响应 - 真需要动态加权(如“最近 24 小时得分 × 1.2”),把计算逻辑移到写入端:用户每次得分时,预计算并更新
realtime_score字段,查询只读该字段
缓存不是可选项,而是架构必需项
即使索引到位,每秒数千次 find().sort().limit() 仍会给 MongoDB 主节点造成压力,尤其副本集主节点还要处理写入。
- 用 Redis 的
ZSET同步维护榜单(例如每写入/更新一次 player,就ZADD leaderboard 99.5 userId),查询直接ZREVRANGE leaderboard 0 99 WITHSCORES,稳定 sub-millisecond - 同步策略选「写后双写」而非「读时回源」:应用层写 MongoDB 成功后,立刻发消息到队列(如 Kafka),由独立消费者更新 Redis,避免数据库事务与缓存强耦合
- 注意时钟漂移:如果 MongoDB 和 Redis 时间不一致,可能导致“刚上榜就消失”,建议所有服务统一 NTP,且 Redis 不存时间戳,只存分数和 ID
排行榜的瓶颈从来不在语法怎么写,而在于你是否接受“查得快”是以写入多一步、存储多一份冗余为代价的。只要 score 更新不频繁(比如每分钟 ≤ 10 次/用户),ZSET + 索引双保险基本能稳住 5ms 内响应。最常被忽略的其实是字段类型一致性 —— 一个 string 类型的 score 足以让整个索引失效。











