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 适用场景

进阶索引适用

  1. 本地生活/LBS 应用——2dsphere 实现附近门店、骑手配送范围、用户收货地址匹配。
  2. CMS/博客——Text Index 做站内搜索(英文),覆盖索引加速分类列表。
  3. 登录/注册系统——TTL 索引自动清理过期验证码和 Token。
  4. IoT 数据管道——Wildcard Index 快速查询任意传感器读数。
  5. 高频 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 思考题

  1. 如果一个查询同时命中了 2dsphere 索引和普通复合索引(如 category + location),MongoDB 会选择哪个?如何通过 hint 指定?
  2. 为什么一个集合中只能有一个 Text Index?如果需要为不同语言的字段做搜索(如 name_cnname_en),应该怎么设计?

(答案将在第 21 章末尾揭晓)


上一章思考题答案

  1. writeConcern: majority + readConcern: majority 不能保证写后立刻读到——原因:readConcern: majority 返回的是"已经被多数节点确认的数据",但数据的多数确认和客户端读取之间的时间差取决于复制延迟。在写的多数确认完成和读的多数确认之间如果有时钟偏移,可能读到的是上一份多数确认的快照而非刚写入的。要保证"写后立刻读到自己写的数据",必须用因果一致性(通过 afterClusterTime 传递写入的 operationTime)。

  2. 在 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 实战修炼与源码剖析

posted on 2026-07-31 17:10  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报