1. 项目背景
业务场景:本地生活电商发展迅猛,数据量突破 500 万商品、2000 万订单。基础篇第 6 章建立的索引体系开始捉襟见肘——覆盖索引的概念还不清晰,导致每次查询都要 FETCH 回文档层;运营新需求"附近 3 公里内的门店"用应用层计算经纬度距离,每次查询全量扫描所有门店再逐个算距离;商品搜索功能用户激烈吐槽"搜'蓝牙耳机'出来的全是'有线耳机'"——因为 Text Index 对中文分词极差;短信验证码没有 TTL 清理机制,verification_codes 集合已经 2000 万条。
痛点:单字段索引和基础复合索引只是索引体系的"冰山浮角"。地标查询需要 2dsphere 索引,模糊搜索需要 Text Index 配合权重,高频查询需要覆盖索引避免 FETCH 回表,临时数据需要 TTL 过期。这些进阶索引类别如果不会用,轻则性能达不到预期,重则业务需求无法实现。
2. 项目设计
小胖(在地图上戳来戳去):大师,运营说要做个"附近门店"功能——我在代码里用 Haversine 公式算距离,5000 个门店每条都算一遍,接口要 8 秒!MongoDB 能不在地理空间上做文章?
大师:能,而且比你想象的简单得多。MongoDB 有 2dsphere 地理空间索引——你只需要把门店的经纬度存成一个 GeoJSON Point,建一个 2dsphere 索引,查询时写 $nearSphere 就自动按距离排序返回,内部用的是 S2 几何库,性能比你手算 Haversine 快 100 倍。
小胖:那不就跟滴滴打车找附近司机一样?存个坐标,查附近?
大师:没错。而且不止 $nearSphere——你还可以画多边形($geoWithin),找出某个行政区域内的所有门店;可以画圆($centerSphere),找出半径内的门店;可以判断点是否在多边形内($geoIntersects)。
技术映射:GeoJSON 是 MongoDB 地理位置数据的标准格式——{ type: "Point", coordinates: [经度, 纬度] }。2dsphere 索引支持球面几何计算,2d 索引(旧版)支持平面坐标。
小白:那覆盖索引(Covering Index)是什么?我听到很多人说"查询全部索引覆盖就不用 FETCH 了",但不太懂。
大师:覆盖索引是这个意思——MongoDB 的查询过程分两步:① IXSCAN:在索引中找到符合条件的文档 ID;② FETCH:用文档 ID 回到集合中把完整的文档取出来。但如果你的查询只需要返回索引中包含的字段,FETCH 这一步就可以跳过——直接从索引中返回结果。这就是"覆盖索引"。
小胖:说人话!我不懂!
大师:你去图书馆找书。普通查询 = 先在目录卡(索引)中找到"这本书在 3 楼 B 区",然后爬楼梯去 3 楼把书取回来(FETCH)。覆盖索引 = 目录卡上直接写了书的摘要——你翻目录卡就知道了内容,不需要再爬楼梯。
技术映射:一个查询要做到索引覆盖,projection 中的返回字段必须全部包含在索引中。explain 中 totalDocsExamined = 0 且 stage 中没有 FETCH 阶段即是覆盖索引。
小白:那文本索引(Text Index)呢?中文搜索能用吗?
大师:MongoDB 的 Text Index 对英文友好(天然的空格分词),对中文……诚实地说,不推荐用于生产级别中文搜索。原因是 MongoDB Text Index 使用简单的 Unicode 分词,对中文只能按单字拆分——"蓝牙"会被拆成"蓝"和"牙",查"蓝牙耳机"会匹配到所有包含"蓝"、"牙"、"耳"、"机"的文档。如果你的商品搜索是核心功能,建议用 Elasticsearch——MongoDB 的 Text Index 仅适合简单的关键字匹配。
技术映射:Text Index 支持权重(weights)设置——标题比描述重要,标题的 textScore 高于描述的 textScore。$text: { $search: "keyword" } 配合 $meta: "textScore" 完成按相关性排序。
小胖:那 TTL 索引我懂了——验证码 5 分钟过期那种。还有别的进阶索引吗?
大师:四个总结——地理空间走 2dsphere,高频小查询走覆盖索引,关键词搜索走 Text Index(英文好中文差),临时数据走 TTL 索引。另外还有通配符索引(Wildcard Index、MongoDB 4.2+)——你不需要知道文档有哪些字段,{"$**": 1} 自动为所有字段建索引,适合 schema 不固定的场景但占用空间大。
3. 项目实战
3.1 环境准备
沿用复制集或单机环境,不需要特殊准备。
3.2 分步实现
步骤一:2dsphere 地理空间索引——附近门店查询
目标:为门店建立地理位置索引,实现"附近 3 公里"查询。
use local_life
db.stores_geo.drop()
// 插入深圳的 20 个门店(使用真实坐标近似)
const stores = [
{ name: "南山科技园店", location: { type: "Point", coordinates: [113.95, 22.54] } },
{ name: "福田CBD店", location: { type: "Point", coordinates: [114.06, 22.54] } },
{ name: "宝安中心店", location: { type: "Point", coordinates: [113.88, 22.56] } },
{ name: "龙华店", location: { type: "Point", coordinates: [114.02, 22.65] } },
{ name: "罗湖口岸店", location: { type: "Point", coordinates: [114.12, 22.54] } },
{ name: "龙岗中心城店", location: { type: "Point", coordinates: [114.25, 22.72] } },
{ name: "光明新区店", location: { type: "Point", coordinates: [113.93, 22.75] } },
{ name: "坪山店", location: { type: "Point", coordinates: [114.35, 22.69] } },
{ name: "盐田大梅沙店", location: { type: "Point", coordinates: [114.28, 22.60] } },
{ name: "南山海岸城店", location: { type: "Point", coordinates: [113.94, 22.52] } }
]
// 再加一些广州、北京、上海的店
stores.push(
{ name: "广州天河城店", location: { type: "Point", coordinates: [113.32, 23.13] } },
{ name: "广州北京路店", location: { type: "Point", coordinates: [113.27, 23.12] } },
{ name: "北京国贸店", location: { type: "Point", coordinates: [116.46, 39.91] } },
{ name: "上海陆家嘴店", location: { type: "Point", coordinates: [121.50, 31.24] } },
{ name: "上海静安寺店", location: { type: "Point", coordinates: [121.44, 31.23] } }
)
db.stores_geo.insertMany(stores)
// 创建 2dsphere 索引
db.stores_geo.createIndex({ location: "2dsphere" })
// === 查询:以"南山科技园"为中心,5 公里以内的门店 ===
const techPark = [113.95, 22.54]
const radiusMeters = 5000
const nearby = db.stores_geo.find({
location: {
$nearSphere: {
$geometry: { type: "Point", coordinates: techPark },
$maxDistance: radiusMeters
}
}
}).toArray()
print("=== 科技园 5km 以内的门店 ===")
nearby.forEach(s => {
print(` ${s.name} (${s.location.coordinates[0]}, ${s.location.coordinates[1]})`)
})
// === 查询:某个矩形区域内的门店(viewport 地图拖拽) ===
const bottomLeft = [113.90, 22.50]
const topRight = [114.10, 22.60]
const inBox = db.stores_geo.find({
location: {
$geoWithin: {
$box: [bottomLeft, topRight]
}
}
}).toArray()
print("\n=== 矩形区域内的门店 ===")
inBox.forEach(s => print(` ${s.name}`))
// === 计算两点距离(聚合管道) ===
const distResult = db.stores_geo.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: techPark },
distanceField: "distance",
maxDistance: 10000, // 10km
spherical: true
}
},
{ $project: { name: 1, "distance": { $round: ["$distance", 0] } } },
{ $sort: { distance: 1 } }
]).toArray()
print("\n=== 按距离排序(带精确距离值) ===")
distResult.forEach(s => print(` ${s.name}: ${s.distance}m`))
步骤二:覆盖索引实战
目标:创建覆盖索引,对比有 FETCH 和无 FETCH 的查询性能。
db.products_cover.drop()
// 插入 5 万条测试数据
for (let i = 0; i < 50000; i++) {
db.products_cover.insertOne({
name: "覆盖测试_" + i,
category: "数码",
price: NumberDecimal((100 + i).toFixed(2)),
stock: 500,
// 以下是大字段(模拟文档很大)
description: "X".repeat(2000), // 2KB 文本
images: ["img/" + i + ".jpg"],
metadata: { data: "Y".repeat(1000) } // 1KB 数据
})
}
// 创建复合索引:包含查询条件字段和返回字段
db.products_cover.createIndex(
{ category: 1, price: 1, name: 1, stock: 1 },
{ name: "idx_covering_demo" }
)
// 查询 1:非覆盖索引(需要 FETCH)
const nonCovering = db.products_cover.find(
{ category: "数码", price: { $lte: NumberDecimal("200") } },
{ name: 1, price: 1, stock: 1, description: 1 } // description 不在索引中!
).limit(20).explain("executionStats")
print("=== 非覆盖索引 ===")
print(" 执行阶段:", nonCovering.executionStats.executionStages.stage) // FETCH
print(" 扫描文档数:", nonCovering.executionStats.totalDocsExamined)
print(" 索引扫描:", nonCovering.executionStats.totalKeysExamined)
// 查询 2:覆盖索引(projection 全在索引中)
const covering = db.products_cover.find(
{ category: "数码", price: { $lte: NumberDecimal("200") } },
{ _id: 0, category: 1, price: 1, name: 1, stock: 1 } // 全在索引中!
).limit(20).explain("executionStats")
print("\n=== 覆盖索引 ===")
print(" 执行阶段:", covering.executionStats.executionStages.stage) // PROJECTION_COVERED
print(" 扫描文档数:", covering.executionStats.totalDocsExamined) // 0!
print(" 索引扫描:", covering.executionStats.totalKeysExamined)
// 注意:覆盖索引的关键——projection 中的每个字段都必须在复合索引中出现
// 且 _id 必须在 projection 中显式排除(_id: 0),否则仍需 FETCH
步骤三:Text Index——商品搜索
目标:为商品名称和描述创建全文索引,实现关键字搜索。
db.products_text.drop()
// 插入测试数据
db.products_text.insertMany([
{ name: "无线蓝牙耳机 Pro", description: "主动降噪,续航40小时,音质出众" },
{ name: "蓝牙音箱 便携款", description: "360度环绕音,20小时续航,防水" },
{ name: "有线耳机 入耳式", description: "HiFi音质,线控音量,镀金插头" },
{ name: "无线充电器", description: "15W快充,兼容iPhone和安卓" },
{ name: "Type-C充电线", description: "100W快充,尼龙编织线,2米" },
{ name: "机械键盘 青轴", description: "87键,RGB背光,蓝牙有线双模" },
{ name: "游戏鼠标", description: "16000DPI,可编程按键,RGB灯效" },
{ name: "手机散热器", description: "半导体制冷,静音风扇,通用夹子" }
])
// 创建 Text Index(指定字段权重)
db.products_text.createIndex(
{
name: "text",
description: "text"
},
{
name: "idx_text_search",
weights: {
name: 10, // 名称匹配权重高
description: 1 // 描述匹配权重低
}
// 注意:MongoDB Text Index 对中文使用 Unicode 分词(效果差)
// 本演示使用英文关键词,中文搜索推荐用 Elasticsearch
}
)
// 搜索"蓝牙"
const searchResult = db.products_text.find(
{ $text: { $search: "蓝牙" } },
{ score: { $meta: "textScore" } } // 按相关度得分排序
).sort({ score: { $meta: "textScore" } }).toArray()
print("=== 关键词'蓝牙'搜索结果 ===")
searchResult.forEach(p => {
print(` ${p.name} | 得分: ${p.score.toFixed(1)} | ${p.description}`)
})
// 英文搜索效果更好(天然空格分词)
const enResult = db.products_text.find(
{ $text: { $search: "wireless bluetooth" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } }).toArray()
print("\n=== 关键词'wireless bluetooth'搜索结果 ===")
enResult.forEach(p => {
print(` ${p.name} | 得分: ${p.score.toFixed(1)}`)
})
// 精确短语匹配
const phraseResult = db.products_text.find(
{ $text: { $search: "\"蓝牙音箱\"" } } // 加上双引号,精确短语
).toArray()
print("\n=== 精确短语'蓝牙音箱'结果 ===")
phraseResult.forEach(p => print(` ${p.name}`))
// 排除词(-前置)
const excludeResult = db.products_text.find(
{ $text: { $search: "蓝牙 -音箱" } } // 包含蓝牙但不含音箱
).toArray()
print("\n=== '蓝牙 -音箱'结果 ===")
excludeResult.forEach(p => print(` ${p.name}`))
步骤四:TTL 索引——验证码自动过期
目标:为验证码集合创建 TTL 索引。
db.verification_ttl.drop()
// 创建 TTL 索引:5 分钟过期
db.verification_ttl.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 300, name: "ttl_5min" }
)
// 插入验证码
db.verification_ttl.insertMany([
{ phone: "13800000001", code: "1234", type: "login", createdAt: new Date() },
{ phone: "13800000002", code: "5678", type: "register", createdAt: new Date() },
// 插入一条"已过期"的(createdAt 在 10 分钟前)
{
phone: "13800000003",
code: "9999",
type: "login",
createdAt: new Date(Date.now() - 10 * 60 * 1000)
}
])
print("插入后文档数:", db.verification_ttl.countDocuments())
// 等待 TTL 线程扫描(每 60 秒一次)后,过期的验证码将被自动删除
print("提示:等待约 60-120 秒后再次 countDocuments(),过期文档会被自动删除")
// 手动触发 TTL 扫描(仅用于测试,生产环境不需要)
// 方法:在 mongosh 中执行 db.adminCommand({ setParameter: 1, ttlMonitorSleepSecs: 1 })
// 但这会全局加速 TTL 扫描频率,仅测试用
// 查看 TTL 索引信息
const indexes = db.verification_ttl.getIndexes()
const ttlIdx = indexes.find(i => i.expireAfterSeconds !== undefined)
if (ttlIdx) {
print(`TTL 索引: ${ttlIdx.name}, 过期时间: ${ttlIdx.expireAfterSeconds}s`)
}
步骤五:通配符索引(Wildcard Index)
目标:为 schema 不固定的集合创建灵活索引。
db.flexible_data.drop()
// 插入 schema 各异的文档(模拟 IoT 或日志数据)
db.flexible_data.insertMany([
{ device: "sensor_A", temp: 25.3, humidity: 60, ts: new Date() },
{ device: "sensor_B", temp: 18.7, pressure: 1013, ts: new Date() },
{ device: "sensor_A", temp: 27.1, humidity: 55, voltage: 3.3, ts: new Date() },
{ device: "sensor_C", humidity: 72, light: 800, ts: new Date() }
])
// 创建通配符索引——自动为所有文档的所有标量字段创建索引
// ⚠️ 注意:通配符索引会占用大量空间,谨慎使用
db.flexible_data.createIndex(
{ "$**": 1 },
{ name: "idx_wildcard_all" }
)
// 也可以只对指定子路径创建通配符索引
// db.flexible_data.createIndex({ "metadata.$**": 1 })
// 查询任何字段(通配符索引生效)
const result = db.flexible_data.find({ humidity: { $gt: 50 } }).explain("executionStats")
print("通配符索引查询:", result.executionStats.executionStages.stage)
// 期望:IXSCAN
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch20-geo-2dsphere.js |
地理空间索引 + 附近门店查询 |
mongodb-lab/scripts/ch20-covering-index.js |
覆盖索引对比测试 |
mongodb-lab/scripts/ch20-text-index.js |
文本索引 + 权重搜索 |
mongodb-lab/scripts/ch20-ttl-demo.js |
TTL 索引演示 |
mongodb-lab/scripts/ch20-wildcard.js |
通配符索引 |
3.4 测试验证
use local_life
// 1. 验证地理空间查询
const geoNearby = db.stores_geo.find({
location: { $nearSphere: { $geometry: { type:"Point", coordinates:[113.95,22.54] }, $maxDistance: 5000 } }
}).count()
print("5km 内门店数:", geoNearby, geoNearby > 0 ? "PASS" : "FAIL")
// 2. 验证覆盖索引(totalDocsExamined = 0)
const coverExplain = db.products_cover.find(
{ category: "数码" },
{ _id: 0, category: 1, price: 1, name: 1, stock: 1 }
).limit(10).explain("executionStats")
print("覆盖索引:", coverExplain.executionStats.totalDocsExamined === 0 ? "PASS" : "FAIL")
// 3. 验证文本搜索
const textCount = db.products_text.find({ $text: { $search: "无线 蓝牙" } }).count()
print("文本搜索命中:", textCount, textCount > 0 ? "PASS" : "FAIL")
// 4. 验证 TTL 索引创建
const hasTTL = db.verification_ttl.getIndexes().some(i => i.expireAfterSeconds === 300)
print("TTL 索引:", hasTTL ? "PASS" : "FAIL")
// 5. 清理
// db.products_cover.drop()
// db.products_text.dropIndexes()
print("\n=== 进阶索引验证完成 ===")
4. 项目总结
4.1 五种进阶索引对比
| 索引类型 | 用途 | 适用数据 | 性能特点 | 限制 |
|---|---|---|---|---|
| 2dsphere | 地理空间查询(附近/范围内) | 带经纬度的 GeoJSON 数据 | 基于 S2 几何,近 O(logN) | 坐标需 GeoJSON 格式 |
| 覆盖索引 | 查询无需 FETCH 回表 | 高频小字段查询 | totalDocsExamined=0,极快 | projection 必须全在索引中 |
| Text Index | 全文搜索 | 英文内容、简单中文 | 按相关性得分排序 | 中文分词差,不推荐 |
| TTL Index | 数据自动过期 | 验证码、Token、日志 | 后台每 60s 扫描一次 | 仅 Date 类型字段 |
| Wildcard Index | schema 不固定的字段索引 | 多变的 IoT/日志数据 | 灵活但空间占用大 | 不支持复合键 |
4.2 适用场景
进阶索引适用:
- 本地生活/LBS 应用——2dsphere 实现附近门店、骑手配送范围、用户收货地址匹配。
- CMS/博客——Text Index 做站内搜索(英文),覆盖索引加速分类列表。
- 登录/注册系统——TTL 索引自动清理过期验证码和 Token。
- IoT 数据管道——Wildcard Index 快速查询任意传感器读数。
- 高频 API——覆盖索引减少 FETCH,降低查询延迟。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| 2dsphere 坐标顺序 | GeoJSON 规定 [经度, 纬度](不是 [纬度, 经度]),搞反了就全偏移 |
覆盖索引必须排除 _id |
默认 projection 包含 _id,必须显式 _id: 0 才能实现覆盖 |
| Text Index 一个集合只能一个 | 不能为 name 和 description 分别建两个 Text Index |
| TTL 过期有延迟 | 后台线程每 60 秒扫描一次,实际过期时间偏差最大 ±60 秒 |
| Wildcard Index 写放大 | 每个文档的每个字段建立一个索引条目,写入和存储成本很高 |
4.4 常见踩坑经验
故障案例一:经纬度坐标写反——附近门店偏移到太平洋
某外卖平台开发把门店经纬度按 [纬度, 经度](GeoJSON 要求 [经度, 纬度])存入数据库。附近门店查询返回的结果全在几百公里外。根因:经纬度顺序错误。解决:编码规范中写明"MongoDB 地理坐标永远是 [lng, lat]",Code Review 加校验。
故障案例二:Text Index 对中文搜索的灾难性效果
某内容平台用 Text Index 做中文全文搜索,用户搜"机器学习"返回了包含"学习机"的商品,相关性完全错误。根因:Text Index 对中文分词只是按字拆分,没有语义理解。解决:三个方案:① 用 Atlas Search(MongoDB Atlas 专用,支持中文分词);② 接入 Elasticsearch 做搜索层;③ 应用层用 IK 分词器对中文预处理,把分词结果作为字符串数组存入 MongoDB 的 searchTokens 字段,Text Index 索引该字段。
故障案例三:通配符索引导致磁盘暴增 + 写入骤降
某团队为 IoT 数据集合创建了 {"$**": 1} 通配符索引,每个文档 50 个字段,每天写入 5000 万条。创建后磁盘写入速率从 200MB/s 降到 30MB/s,磁盘占用是一周前的 3 倍。根因:每个文档的每个字段都要更新索引。解决:改为只对常用的 5 个查询字段建精确索引,不用的字段通过 { "fields.$**": 1 } 限定通配范围。
4.5 思考题
- 如果一个查询同时命中了 2dsphere 索引和普通复合索引(如 category + location),MongoDB 会选择哪个?如何通过 hint 指定?
- 为什么一个集合中只能有一个 Text Index?如果需要为不同语言的字段做搜索(如
name_cn和name_en),应该怎么设计?
(答案将在第 21 章末尾揭晓)
上一章思考题答案:
writeConcern: majority+readConcern: majority不能保证写后立刻读到——原因:readConcern: majority返回的是"已经被多数节点确认的数据",但数据的多数确认和客户端读取之间的时间差取决于复制延迟。在写的多数确认完成和读的多数确认之间如果有时钟偏移,可能读到的是上一份多数确认的快照而非刚写入的。要保证"写后立刻读到自己写的数据",必须用因果一致性(通过afterClusterTime传递写入的operationTime)。在 Spring Boot 覆写 readPreference:使用
MongoTemplate.withReadPreference(ReadPreference.secondary())在单个查询上临时覆写。验证方法:开启慢查询日志(profiler level 2),执行查询后在system.profile中查看command.$readPreference字段,确认实际生效的 readPreference 是 secondary 而非全局的 primary。
延伸阅读与资源
Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号