MongoDB 通配符索引 (wildcard index) 的利与弊

发布时间 2023-12-27 20:20:00作者: abce

MongoDB 支持在单个字段或多个字段上创建索引,以提高查询性能。MongoDB 支持灵活的模式,这意味着文档字段名在集合中可能会有所不同。使用通配符索引可支持针对任意或未知字段的查询。

·一个集合中可以创建多个通配符索引

·通配符索引可以覆盖与集合中其他索引相同的字段

·通配符索引默认省略 _id 字段。要在通配符索引中包含 _id 字段,必须明确指定 { "_id" : 1 }

·通配符索引是稀疏索引,只包含具有索引字段的文档条目,即使索引字段包含空值也是如此

·通配符索引 (wildcard index) 与通配符文本索引 (wildcard text index) 不同,也不兼容。通配符索引不支持使用 $text 操作符的查询。

 

要创建通配符索引,使用通配符指定符 ($**) 作为索引键:

db.collection.createIndex( { "$**": <sortOrder> } )

 

通配符索引的简单理念是,在不预先知道文档中的字段的情况下,提供创建索引的可能性。你可以输入任何你需要的内容,MongoDB 会索引所有内容,无论字段名称如何,无论数据类型如何。这项功能看起来很神奇,但也付出了一些代价。

 

为了测试通配符索引,让我们创建一个用于存储用户详细信息的小型集合。集合有一些固定字段,如姓名、出生日期和性别,但也有一个子文档 userMetadata,用于存储我们事先不知道的任何其他属性。这样,我们就可以存储所需的一切。

 

插入数据

db.user.insert( { name: "John", date_of_birth: new ISODate("2001-02-05"), gender: 'M', userMetadata: { "likes" : [ "dogs", "cats" ] } } )
db.user.insert( { name: "Marie", date_of_birth: new ISODate("2008-03-12"), gender: 'F', userMetadata: { "dislikes" : "hamsters" } } )
db.user.insert( { name: "Tom", date_of_birth: new ISODate("1998-12-23"), gender: 'M', userMetadata: { "age" : 25 } } )
db.user.insert( { name: "Adrian", date_of_birth: new ISODate("1991-06-22"), gender: 'M', userMetadata: "inactive" } )
db.user.insert( { name: "Janice", date_of_birth: new ISODate("1995-09-04"), gender: 'F', userMetadata: { "shoeSize": 8, "likes": [ "horses", "dogs" ] } } )
db.user.insert( { name: "Peter", date_of_birth: new ISODate("2004-01-25"), gender: 'M', userMetadata: { "drivingLicense": { class: "A", "expirationDate": new ISODate("2030-05-05") } } } )

查看数据

> db.user.find()
[
  {
    _id: ObjectId('658927f4bad8d080878a999e'),
    name: 'John',
    date_of_birth: ISODate('2001-02-05T00:00:00.000Z'),
    gender: 'M',
    userMetadata: { likes: [ 'dogs', 'cats' ] }
  },
  {
    _id: ObjectId('658927f4bad8d080878a999f'),
    name: 'Marie',
    date_of_birth: ISODate('2008-03-12T00:00:00.000Z'),
    gender: 'F',
    userMetadata: { dislikes: 'hamsters' }
  },
  {
    _id: ObjectId('658927f4bad8d080878a99a0'),
    name: 'Tom',
    date_of_birth: ISODate('1998-12-23T00:00:00.000Z'),
    gender: 'M',
    userMetadata: { age: 25 }
  },
  {
    _id: ObjectId('658927f5bad8d080878a99a1'),
    name: 'Adrian',
    date_of_birth: ISODate('1991-06-22T00:00:00.000Z'),
    gender: 'M',
    userMetadata: 'inactive'
  },
  {
    _id: ObjectId('658927f5bad8d080878a99a2'),
    name: 'Janice',
    date_of_birth: ISODate('1995-09-04T00:00:00.000Z'),
    gender: 'F',
    userMetadata: { shoeSize: 8, likes: [ 'horses', 'dogs' ] }
  },
  {
    _id: ObjectId('658927f6bad8d080878a99a3'),
    name: 'Peter',
    date_of_birth: ISODate('2004-01-25T00:00:00.000Z'),
    gender: 'M',
    userMetadata: {
      drivingLicense: {
        class: 'A',
        expirationDate: ISODate('2030-05-05T00:00:00.000Z')
      }
    }
  }
]

metaData 子文档包含不同的字段。但所有这些字段都没有索引。

假设数据集包含几百万个文档,如何才能在不触发全数据集扫描的情况下,检索出具有特定驾驶执照类别或特定鞋码的所有用户呢?我们可以使用特殊语法 $** 在 userMetadata 字段上创建通配符索引。

abce> db.user.createIndex({ "userMetadata.$**" : 1 })
userMetadata.$**_1
abce> db.user.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  { v: 2, key: { 'userMetadata.$**': 1 }, name: 'userMetadata.$**_1' }
]
abce> 
]

这样,MongoDB 就会为 userMetadata 中的每个字段和任何数成员在索引中创建一个条目。

 

现在,我们可以利用该索引执行任何类型的查询。

abce> db.user.find({ "userMetadata.likes": "dogs" })
[
  {
    _id: ObjectId('658927f4bad8d080878a999e'),
    name: 'John',
    date_of_birth: ISODate('2001-02-05T00:00:00.000Z'),
    gender: 'M',
    userMetadata: { likes: [ 'dogs', 'cats' ] }
  },
  {
    _id: ObjectId('658927f5bad8d080878a99a2'),
    name: 'Janice',
    date_of_birth: ISODate('1995-09-04T00:00:00.000Z'),
    gender: 'F',
    userMetadata: { shoeSize: 8, likes: [ 'horses', 'dogs' ] }
  }
]
abce> db.user.find({ "userMetadata.likes": "dogs" }).explain()
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'abce.user',
    indexFilterSet: false,
    parsedQuery: { 'userMetadata.likes': { '$eq': 'dogs' } },
    queryHash: 'E2BC0D70',
    planCacheKey: '7C6EEF39',
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { '$_path': 1, 'userMetadata.likes': 1 },
        indexName: 'userMetadata.$**_1',
        isMultiKey: true,
        multiKeyPaths: {
          '$_path': [],
          'userMetadata.likes': [ 'userMetadata.likes' ]
        },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          '$_path': [ '["userMetadata.likes", "userMetadata.likes"]' ],
          'userMetadata.likes': [ '["dogs", "dogs"]' ]
        }
      }
    },
    rejectedPlans: []
  },
  command: {
    find: 'user',
    filter: { 'userMetadata.likes': 'dogs' },
    '$db': 'abce'
  },
  serverInfo: {
    host: 'test',
    port: 27017,
    version: '6.0.12',
    gitVersion: '21e6e8e11a45dfbdb7ca6cf95fa8c5f859e2b118'
  },
  serverParameters: {
    internalQueryFacetBufferSizeBytes: 104857600,
    internalQueryFacetMaxOutputDocSizeBytes: 104857600,
    internalLookupStageIntermediateDocumentMaxSizeBytes: 104857600,
    internalDocumentSourceGroupMaxMemoryBytes: 104857600,
    internalQueryMaxBlockingSortMemoryUsageBytes: 104857600,
    internalQueryProhibitBlockingMergeOnMongoS: 0,
    internalQueryMaxAddToSetBytes: 104857600,
    internalDocumentSourceSetWindowFieldsMaxMemoryBytes: 104857600
  },
  ok: 1
}
abce> 

通过关键字 IXSCAN 可以检查一下查询是否用到了索引。

 

同理,下面的查询也可以从刚才创建的索引受益:

db.user.find( { "userMetadata.age" : { $gt: 20 }  } )
db.user.find( { "userMetadata": "inactive" } )
db.user.find( { "userMetadata.drivingLicense.class": "A", "userMetadata.drivingLicense.expirationDate": { $lt: ISODate("2032-01-01") } } )
db.user.find( { "userMetadata.shoeSize": 8})

 

在整个文档上创建通配符索引

在整个文档上创建通配符索引如何?这可行吗?

是的,可以。如果我们事先对将在集合中获得的文档一无所知,我们就可以这样做。

有一种特殊的语法可以做到这一点。在不指定字段名的情况下,再次使用 $**。

abce> db.user.createIndex( { "$**" : 1 } )
$**_1
abce>

 

同样可以执行一下刚才执行过的查询。可以看到所有列都被索引了:

abce> db.user.find( { name: "Marie" } )
[
  {
    _id: ObjectId('658927f4bad8d080878a999f'),
    name: 'Marie',
    date_of_birth: ISODate('2008-03-12T00:00:00.000Z'),
    gender: 'F',
    userMetadata: { dislikes: 'hamsters' }
  }
]
abce> db.user.find( { name: "Marie" } ).explain()
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'abce.user',
    indexFilterSet: false,
    parsedQuery: { name: { '$eq': 'Marie' } },
    queryHash: '64908032',
    planCacheKey: 'A6C0273F',
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { '$_path': 1, name: 1 },
        indexName: '$**_1',
        isMultiKey: false,
        multiKeyPaths: { '$_path': [], name: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          '$_path': [ '["name", "name"]' ],
          name: [ '["Marie", "Marie"]' ]
        }
      }
    },
    rejectedPlans: []
  },
  command: { find: 'user', filter: { name: 'Marie' }, '$db': 'abce' },
  serverInfo: {
    host: 'test',
    port: 27017,
    version: '6.0.12',
    gitVersion: '21e6e8e11a45dfbdb7ca6cf95fa8c5f859e2b118'
  },
  serverParameters: {
    internalQueryFacetBufferSizeBytes: 104857600,
    internalQueryFacetMaxOutputDocSizeBytes: 104857600,
    internalLookupStageIntermediateDocumentMaxSizeBytes: 104857600,
    internalDocumentSourceGroupMaxMemoryBytes: 104857600,
    internalQueryMaxBlockingSortMemoryUsageBytes: 104857600,
    internalQueryProhibitBlockingMergeOnMongoS: 0,
    internalQueryMaxAddToSetBytes: 104857600,
    internalDocumentSourceSetWindowFieldsMaxMemoryBytes: 104857600
  },
  ok: 1
}
abce>  

 

优点和缺点

通配符索引的优点不言而喻,那就是它具有极大的灵活性。只需索引所有内容,甚至是你意想不到的内容。

缺点在于索引的大小。索引如果能被内存缓存,就能发挥最大功效。如果我们无法控制(或无法预见)我们创建的数据量,通配符索引的大小就会爆炸。

测试的数据集非常小,所以不用担心这些数字。但想想如果是非常大的集合,会发生什么情况。索引的大小可能会失控。

可以使用一个简单的技巧来增加集合的大小。运行以下语句,随时将文档数量翻倍。根据你想要的文档数量,执行八次或十次,或者更多。

db.user.find( {}, {_id:0}).forEach(function (doc) { db.user.insertOne(doc); } )

通配符索引一开始的好处是让事情变得更灵活,但最后却成为性能的严重瓶颈,导致更多的内存使用和交换。此外,大多数情况下,最频繁的查询只使用几个字段。使用通配符索引并不总是有意义的。