MongoDBでインデックスのパワーを体感する #MongoDB #documentDB
MongoDBテクニカルサポート担当の山森です。子供たちは夏休みに突入したようです。外に出れば自転車に乗る笑顔の小中学生を見ますし、オンラインミーティングの後ろからお子さんの声が聞こえたりします。わたしの夏休みは、もう少し先です😿
最近はテクニカルサポートの業務でMongoDBのSlow queryが出た時の挙動について調べる機会が多いのですが、インデックスを作成してクエリが高速化する様子をきちんと見たことがないので、夏休みの自由研究よろしく、観察してみることにしました。
MongoDBにおけるインデックスとは
有名なRDBMSで用いるインデックスとほとんど同じものと思ってもらってよいかと思います。「実行頻度が高いクエリで使用するフィールドに対して作成すると効果が高い」「B-Treeのデータ構造を利用する」といった点はRDBMSと通じる面があります。
一方で、一部ドキュメントDBならではのインデックスタイプもあります。MongoDBにおけるインデックスの詳細と、タイプについては以下のドキュメントを参照してください。
https://www.mongodb.com/docs/manual/indexes/
https://www.mongodb.com/docs/manual/core/indexes/index-types/#index-types
テストデータ
こちらの記事内のプログラムで作成したものを使用します。
{ "_id": { "$oid": "6687a0a809ab7dd03e0bf0c2" }, "username": "FQyFMgO", "password": "yAkXuXRUxAsXREILqyYSAJmnekCNOAShyUsoGTZFGAraKXUhHA", "age": 43, "gender": "male", "firstname": "Lizzie", "lastname": "Feil", "income": 4988 }
MongoDBは配列やオブジェクト型もデータとして格納できるので、これを読む皆さんが日々向き合っている本番データはもう少し複雑かもしれません。実は、配列やオブジェクト型を対象としたインデックスがMongoDBには存在していますが、そこまで含めて検証すると長くなるので、また別の機会に検証してみることにしました。今回検証するのは一番基本的なインデックスであるSingle Field IndexとCompound Indexの2つです。
今回取り扱うインデックス
Single Field Index
日本語だと「単一インデックス」「単一列インデックス」という呼び方になるかと思います。文字通り、一つのフィールドに対して作成するインデックスです。この記事では以降「単一インデックス」と呼ぶことにします。
Compound Index
日本語だと「複合インデックス」という呼び方になるかと思います。文字通り、複数のフィールドに対して作成するインデックスです。この記事では以降「複合インデックス」と呼ぶことにします。
こちらの記事によると、「姓」「名」の両方を含む複合インデックスを作成した場合は、姓のみを指定するクエリをフィルタ処理するためにも利用できるとのことです。複数のクエリをカバーするインデックスを作成する場合は、同じフィールドに対して単一インデックスと複合インデックスの両方を構築する必要はなさそうです。
検証:Slow queryはインデックス構築で改善するのか?
では、前回の記事でもやったように、chatGPTさんにSlow queryを起こすクエリを提案してもらいましょう。
今回は、以下の5つのクエリを使うことにしました。実際にSlow queryが起こし、インデックス作成で改善する様子もあわせて見ていきます。対象コレクションは「PersonalData_Fake」で、ドキュメント数は400万件です。
1. firstnameがLizzieのドキュメントを抽出するクエリ
db.PersonalData_Fake.find({ "firstname": "Lizzie" })
該当のクエリを実行してみましょう。以下が実行結果です。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.find({ "firstname": "Lizzie" }) [ { _id: ObjectId('6687a0a809ab7dd03e0bf0c2'), username: 'FQyFMgO', password: 'yAkXuXRUxAsXREILqyYSAJmnekCNOAShyUsoGTZFGAraKXUhHA', age: 43, gender: 'male', firstname: 'Lizzie', lastname: 'Feil', income: 4988 }, { _id: ObjectId('6687a0a809ab7dd03e0bf89f'), username: 'aeGhRDb', password: 'mvwBPcfHJlqetbZcuauZluGfVvFMKDPkRqvBcHNLgNoxFPkEVr', age: 91, gender: 'female', firstname: 'Lizzie', lastname: 'Robel', income: 769 }, { _id: ObjectId('6687a0a809ab7dd03e0c0c92'), username: 'iXCvFoZ', password: 'RAcTUSwUHGkLJjRuEbwJMSOgFYSyEfHLxybOmIdJWjbjMYjQDV', age: 92, gender: 'male', firstname: 'Lizzie', lastname: 'Hudson', income: 4033 }, (以降省略)
実行したクエリの詳細な情報を知りたいときは、Database Profilerを使用します。これは各データベース内のコレクション「system.profile」内に記録されます。
今回のクエリを実行したときのDatabase Profilerの出力は以下の通りです。planSummaryがCOLLSCAN(インデックスを用いないフルスキャン)となっており、millis(クエリ実行にかかる時間。単位msec)が112となっていることがわかります。
MongoDBはデフォルトでは実行時間が100ms以上でSlow queryとしてログに出力されます。今回のクエリがSlow queryに該当し、改善の余地があることがわかります。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { firstname: 'Lizzie' }, lsid: { id: UUID('494089c5-8e0c-4e47-b40c-5cb6c28c77aa') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721354593, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('7562945650136782672'), keysExamined: 0, docsExamined: 330434, numYield: 330, nreturned: 101, queryHash: '53664B0A', planCacheKey: '53664B0A', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('332') } }, Global: { acquireCount: { r: Long('332') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, storage: {}, responseLength: 19979, protocol: 'op_msg', millis: 112, planSummary: 'COLLSCAN', execStats: { stage: 'COLLSCAN', filter: { firstname: { '$eq': 'Lizzie' } }, nReturned: 101, executionTimeMillisEstimate: 9, works: 330434, advanced: 101, needTime: 330333, needYield: 0, saveState: 331, restoreState: 330, isEOF: 0, direction: 'forward', docsExamined: 330434 }, ts: ISODate('2024-07-19T02:06:06.701Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
それではインデックスを作成してみましょう。今回はクエリにfirstnameのフィールドを使っていますから、firstnameに単一インデックスを作成するのが良さそうです。インデックスを作成するコマンドは以下の通りです。
db.PersonalData_Fake.createIndex( { firstname: 1 } )
実行結果は以下の通りです。「firstname_1」というインデックスが作成されました。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.createIndex( { firstname: 1 } ) firstname_1
心配ならば、インデックスが作成されたかどうか確認しても良いでしょう。以下が確認コマンドです。
db.PersonalData_Fake.getIndexes()
実行すると、こういう風に結果が出ます。_idは自動的に作られているインデックスです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.getIndexes() [ { v: 2, key: { _id: 1 }, name: '_id_' }, { v: 2, key: { firstname: 1 }, name: 'firstname_1' } ]
それでは、さっきのクエリを実行して速度がどの程度変わるか見てみましょう。以下がDatabase Profilerの結果です。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { firstname: 'Lizzie' }, lsid: { id: UUID('3bf5e024-3dd5-445c-a873-64ca39f4ba77') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721355203, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('9016247581363988996'), keysExamined: 101, docsExamined: 101, numYield: 0, nreturned: 101, queryHash: '53664B0A', planCacheKey: '786B6A05', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19979, protocol: 'op_msg', millis: 0, planSummary: 'IXSCAN { firstname: 1 }', execStats: { stage: 'FETCH', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, docsExamined: 101, alreadyHasObj: 0, inputStage: { stage: 'IXSCAN', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, keyPattern: { firstname: 1 }, indexName: 'firstname_1', isMultiKey: false, multiKeyPaths: { firstname: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: 'forward', indexBounds: { firstname: [ '["Lizzie", "Lizzie"]' ] }, keysExamined: 101, seeks: 1, dupsTested: 0, dupsDropped: 0 } }, ts: ISODate('2024-07-19T02:13:43.703Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
millisが0となり、planSummaryがIXSCAN(インデックスを使ったスキャン)になったことがわかります。実行速度がほぼ0となりました。インデックスがしっかりと効いていますね!
2. ageが20以上50以下のドキュメントを抽出するクエリ
db.PersonalData_Fake.find({ "age": { "$gt": 20, "$lt": 50 } })
インデックス構築前の実行結果は以下の通りです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.find({ "age": { "$gt": 20, "$lt": 50 } }) [ { _id: ObjectId('6687a0a809ab7dd03e0bf0c2'), username: 'FQyFMgO', password: 'yAkXuXRUxAsXREILqyYSAJmnekCNOAShyUsoGTZFGAraKXUhHA', age: 43, gender: 'male', firstname: 'Lizzie', lastname: 'Feil', income: 4988 }, { _id: ObjectId('6687a0a809ab7dd03e0bf0c4'), username: 'tcZYkQu', password: 'hHSUvCFDIujOTmYrrylNjtTtcuyMDSFiPpDULLkvBfaoDXnLml', age: 25, gender: 'female', firstname: 'Velva', lastname: 'Halvorson', income: 1020 }, (以下省略)
インデックス作成前のDatabase Profilerの結果は以下の通りです。
すでにmillisが0なので改善の余地がないかもしれませんが…COLLSCANなのでインデックスを作成する意味はありそうですね。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { age: { '$gt': 20, '$lt': 50 } }, lsid: { id: UUID('a8c471be-244f-4374-a6b6-4b7e13a936c8') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721873810, i: 5 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('3824592146084759771'), keysExamined: 0, docsExamined: 298, numYield: 0, nreturned: 101, queryHash: 'FC6DB468', planCacheKey: 'FC6DB468', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19992, protocol: 'op_msg', millis: 0, planSummary: 'COLLSCAN', execStats: { stage: 'COLLSCAN', filter: { '$and': [ { age: { '$lt': 50 } }, { age: { '$gt': 20 } } ] }, nReturned: 101, executionTimeMillisEstimate: 0, works: 298, advanced: 101, needTime: 197, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, direction: 'forward', docsExamined: 298 }, ts: ISODate('2024-07-25T02:18:28.385Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
インデックスを作成します。クエリで使用するageの単一インデックスが良さそうです。
db.PersonalData_Fake.createIndex( { age: 1 } )
クエリをもう一度実行して、Database Profilerがどう変化するか見てみます。
IXSCANでageのインデックスを使っていることが分かりました。このクエリでは実行速度の変化はありませんでしたが、レコード数が今より増えればインデックスを作成した意味が出てくるかもしれません。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { age: { '$gt': 20, '$lt': 50 } }, lsid: { id: UUID('82c1230a-cecb-4d4f-86d5-1da22b412bac') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721874515, i: 5 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('6161333855123535493'), keysExamined: 101, docsExamined: 101, numYield: 0, nreturned: 101, queryHash: 'FC6DB468', planCacheKey: 'AD51D913', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19963, protocol: 'op_msg', millis: 2, planSummary: 'IXSCAN { age: 1 }', execStats: { stage: 'FETCH', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, docsExamined: 101, alreadyHasObj: 0, inputStage: { stage: 'IXSCAN', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, keyPattern: { age: 1 }, indexName: 'age_1', isMultiKey: false, multiKeyPaths: { age: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: 'forward', indexBounds: { age: [ '(20, 50)' ] }, keysExamined: 101, seeks: 1, dupsTested: 0, dupsDropped: 0 } }, ts: ISODate('2024-07-25T02:28:51.699Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
3. income(収入)で降順にソートするクエリ
このクエリはCOLLSCANの場合、すべてのレコードを精査することになるので、確かにSlow queryになりそうです。
db.PersonalData_Fake.find().sort({ "income": -1 })
クエリの実行結果は以下の通りです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.find().sort({ "income": -1 }) [ { _id: ObjectId('6687a0a809ab7dd03e0bffb3'), username: 'pBCCiai', password: 'STIUYGTaXOxSxopaCWLwoLysMcryYGQySakEhZVwJMvbuJAxgh', age: 31, gender: 'female', firstname: 'Oda', lastname: 'Goldner', income: 4999 }, { _id: ObjectId('6687a0a809ab7dd03e0c069b'), username: 'gWpQuMc', password: 'MQbQWUAPVJBiJePkucuKXcHmoFnwCuPgGfOSTGXULohWreqtpm', age: 39, gender: 'male', firstname: 'Holly', lastname: 'Koss', income: 4999 }, { _id: ObjectId('6687a0a809ab7dd03e0c0d43'), username: 'fCPfGLC', password: 'ChGNXBODkTSkTBJLIigTgWEiATodMCrSOvQienGnKltqnmNcXY', age: 67, gender: 'female', firstname: 'Rosanna', lastname: 'Corwin', income: 4999 }, (以下省略)
Database Profilerの結果は以下の通りです。millis: 5124 となっており、そこそこ時間がかかっていることがわかります。実行していても時間がかかるな…という体感がありました。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: {}, sort: { income: -1 }, lsid: { id: UUID('4c5af205-ce79-4a76-b020-0b29f6ef326a') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721874532, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('1625677525133993037'), keysExamined: 0, docsExamined: 4000000, hasSortStage: true, usedDisk: true, numYield: 4007, nreturned: 101, queryHash: 'E611643C', planCacheKey: 'E611643C', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('4009') } }, Global: { acquireCount: { r: Long('4009') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, storage: { data: { bytesRead: Long('23944658'), timeReadingMicros: Long('6193') } }, responseLength: 19954, protocol: 'op_msg', millis: 5124, planSummary: 'COLLSCAN', execStats: { stage: 'SORT', nReturned: 101, executionTimeMillisEstimate: 3417, works: 4000102, advanced: 101, needTime: 4000001, needYield: 0, saveState: 4008, restoreState: 4007, isEOF: 0, sortPattern: { income: -1 }, memLimit: 104857600, type: 'simple', totalDataSizeSorted: 894068591, usedDisk: true, spills: 9, inputStage: { stage: 'COLLSCAN', nReturned: 4000000, executionTimeMillisEstimate: 89, works: 4000001, advanced: 4000000, needTime: 0, needYield: 0, saveState: 4008, restoreState: 4007, isEOF: 1, direction: 'forward', docsExamined: 4000000 } }, ts: ISODate('2024-07-25T02:37:01.284Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
インデックスを作成していきましょう。今回はincomeの単一インデックスにするのが良さそうです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.createIndex( { income: 1 } ) income_1
再度クエリを実行してみました。実行時間は明らかに早かったです。Database Profilerを見てみます。IXSCANになり、millis: 1になっていることがわかります。大幅に改善されていますね!
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: {}, sort: { income: -1 }, lsid: { id: UUID('7762166d-58c7-428f-8ca8-9704b7254a13') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721875591, i: 5 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('3310703320849628612'), keysExamined: 101, docsExamined: 101, numYield: 0, nreturned: 101, queryHash: 'E611643C', planCacheKey: 'E611643C', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19952, protocol: 'op_msg', millis: 1, planSummary: 'IXSCAN { income: 1 }', execStats: { stage: 'FETCH', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, docsExamined: 101, alreadyHasObj: 0, inputStage: { stage: 'IXSCAN', nReturned: 101, executionTimeMillisEstimate: 0, works: 101, advanced: 101, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, keyPattern: { income: 1 }, indexName: 'income_1', isMultiKey: false, multiKeyPaths: { income: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: 'backward', indexBounds: { income: [ '[MaxKey, MinKey]' ] }, keysExamined: 101, seeks: 1, dupsTested: 0, dupsDropped: 0 } }, ts: ISODate('2024-07-25T02:47:11.589Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
4. ageが30歳以上、genderがmale,incomeが10000以下のドキュメントを抽出するクエリ
db.PersonalData_Fake.find({ "age": { "$gt": 30 }, "gender": "male", "income": { "$lt": 10000 } })
インデックスを作成する前のDatabase Profilerの実行結果を見てみます。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.find({ "age": { "$gt": 30 }, "gender": "male", "income": { "$lt": 10000 } }) [ { _id: ObjectId('6687a0a809ab7dd03e0bf0c2'), username: 'FQyFMgO', password: 'yAkXuXRUxAsXREILqyYSAJmnekCNOAShyUsoGTZFGAraKXUhHA', age: 43, gender: 'male', firstname: 'Lizzie', lastname: 'Feil', income: 4988 }, { _id: ObjectId('6687a0a809ab7dd03e0bf0c3'), username: 'sVUekQC', password: 'WCUqCmOMKxNXjrFArrHZyKSeetUuTciRAIrNSoPEIYefsyPWqW', age: 70, gender: 'male', firstname: 'Summer', lastname: 'Feeney', income: 4925 }, (以下省略)
続いて、これを実行したときのDatabase Profilerも見てみましょう。millisが0なのであまり時間がかかっていないようですが、planSummaryがCOLLSCANとなっているので、ドキュメントが増えたときにインデックスが効いてきそうです。
[ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { age: { '$gt': 30 }, gender: 'male', income: { '$lt': 10000 } }, lsid: { id: UUID('ae6cd9ae-6f37-46a7-acbf-d0486e8ee8ea') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721959774, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('45392186795322355'), keysExamined: 0, docsExamined: 242, numYield: 0, nreturned: 101, queryHash: '2219425B', planCacheKey: '2219425B', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19862, protocol: 'op_msg', millis: 0, planSummary: 'COLLSCAN', execStats: { stage: 'COLLSCAN', filter: { '$and': [ { gender: { '$eq': 'male' } }, { income: { '$lt': 10000 } }, { age: { '$gt': 30 } } ] }, nReturned: 101, executionTimeMillisEstimate: 0, works: 242, advanced: 101, needTime: 141, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, direction: 'forward', docsExamined: 242 }, ts: ISODate('2024-07-26T02:10:30.291Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
これのクエリが速くなるようなインデックスを考えてみましょう。登場するフィールドはage,gender,incomeなので、これらに複合インデックスを作成すれば良い気もします。しかし、genderのフィールドは非常にカーディナリティ性が低く(データが一意でなく、重複したデータが多い)、インデックスを作成する意味はあまりなさそうです。今回はageとincomeの複合インデックスにしてみましょう。
以下がインデックスを作成したときの実行結果です。age_1とincome_1それぞれのインデックスではなく、age_1_income_1というインデックスが作成されたことがわかります。これが複合インデックスです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.createIndex( { age: 1, income: 1 } ) age_1_income_1
インデックス作成後に再度クエリを実行して、Database Profilerを見てみましょう。
なぜかmillisが0から3に増えてしまいましたが、この程度なら誤差の範囲だと思います。planSummaryを見ると先ほど作成した複合インデックスを使ったIXSCANになっており、インデックスがしっかり生かされていることがわかります。
[ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { age: { '$gt': 30 }, gender: 'male', income: { '$lt': 10000 } }, lsid: { id: UUID('2eb6554b-bd6c-425c-b903-87658572633e') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721960184, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, cursorid: Long('3546649872400127482'), keysExamined: 202, docsExamined: 202, numYield: 0, nreturned: 101, queryHash: '2219425B', planCacheKey: '1C5932DF', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 19881, protocol: 'op_msg', millis: 3, planSummary: 'IXSCAN { age: 1, income: 1 }', execStats: { stage: 'FETCH', filter: { gender: { '$eq': 'male' } }, nReturned: 101, executionTimeMillisEstimate: 0, works: 202, advanced: 101, needTime: 101, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, docsExamined: 202, alreadyHasObj: 0, inputStage: { stage: 'IXSCAN', nReturned: 202, executionTimeMillisEstimate: 0, works: 202, advanced: 202, needTime: 0, needYield: 0, saveState: 1, restoreState: 0, isEOF: 0, keyPattern: { age: 1, income: 1 }, indexName: 'age_1_income_1', isMultiKey: false, multiKeyPaths: { age: [], income: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: 'forward', indexBounds: { age: [ '(30, inf.0]' ], income: [ '[-inf.0, 10000)' ] }, keysExamined: 202, seeks: 1, dupsTested: 0, dupsDropped: 0 } }, ts: ISODate('2024-07-26T02:21:46.562Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
5. usernameの先頭文字がFQyにヒットするドキュメントを抽出するクエリ
db.PersonalData_Fake.find({ "username": { "$regex": "^FQy" } })
インデックス構築前にクエリを実行し結果を見てみます。実行時は少し引っかかった印象がありました。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.find({ "username": { "$regex": "^FQy" } }) [ { _id: ObjectId('6687a0a809ab7dd03e0bf0c2'), username: 'FQyFMgO', password: 'yAkXuXRUxAsXREILqyYSAJmnekCNOAShyUsoGTZFGAraKXUhHA', age: 43, gender: 'male', firstname: 'Lizzie', lastname: 'Feil', income: 4988 }, { _id: ObjectId('6687a0a809ab7dd03e0bf84e'), username: 'FQyGPAI', password: 'VgoXXZAPDWIyQjYAvNKRQCoZlYRdlAqJYcFGkQvhaPuteWWEmR', age: 29, gender: 'female', firstname: 'Evie', lastname: "O'Hara", income: 1169 }, (以下省略)
Database Profilerを見てみましょう。planSummaryがCOLLSCANとなっており、millisも1515と、それなりに時間がかかっていることがわかります。改善の余地がありそうです。
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { username: { '$regex': '^FQy' } }, lsid: { id: UUID('72f16ac6-e640-4d56-aeb8-51fcfc15e72b') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721960504, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, keysExamined: 0, docsExamined: 4000000, cursorExhausted: true, numYield: 4000, nreturned: 44, queryHash: 'E9DF4413', planCacheKey: 'E9DF4413', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('4002') } }, Global: { acquireCount: { r: Long('4002') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, storage: { data: { bytesRead: Long('26455367'), timeReadingMicros: Long('8430') } }, responseLength: 8838, protocol: 'op_msg', millis: 1515, planSummary: 'COLLSCAN', execStats: { stage: 'COLLSCAN', filter: { username: { '$regex': '^FQy' } }, nReturned: 44, executionTimeMillisEstimate: 153, works: 4000001, advanced: 44, needTime: 3999956, needYield: 0, saveState: 4000, restoreState: 4000, isEOF: 1, direction: 'forward', docsExamined: 4000000 }, ts: ISODate('2024-07-26T02:23:44.071Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
今回はusernameの単一インデックスにしてみます。おっと!
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.createIndex( { usename:1 } ) usename_1
"username"ではなく、"usename"のフィールドに対してインデックスを作成してしまいました。よくあるtypoです。でも特にエラーはありません。今存在するインデックスを確認してみましょう。typoしたインデックスがそのまま作られてしまっています!
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.getIndexes() [ { v: 2, key: { _id: 1 }, name: '_id_' }, { v: 2, key: { age: 1, income: 1 }, name: 'age_1_income_1' }, { v: 2, key: { usename: 1 }, name: 'usename_1' } ]
このように、MongoDBは存在しないフィールドにインデックスを作成してもエラーが出ません。ここはPostgreSQLなどのRDBMSと異なるところですので注意してください。
誤ったインデックスは削除しましょう。削除するときはdropIndexを使います。以下は実行結果です。
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.dropIndex("usename_1") { nIndexesWas: 3, ok: 1, '$clusterTime': { clusterTime: Timestamp({ t: 1721961035, i: 1 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, operationTime: Timestamp({ t: 1721961035, i: 1 }) }
MongoDBでインデックスを作成するときは、改善を狙ったクエリでしっかりとインデックスが使われているかどうかも併せて確認したほうが良さそうです。
正しいフィールド名で再度インデックスを作成して…
Enterprise rs0 [direct: primary] PersonalDataBase> db.PersonalData_Fake.createIndex( { username:1 } ) username_1
クエリを再度実行し、Database Profilerを確認してみましょう。以下がその結果です。planSummaryがIXSCANになり、millisが1になっています。大幅に改善されましたね!
Enterprise rs0 [direct: primary] PersonalDataBase> db.system.profile.find().sort({ ts: -1 }).limit(1) [ { op: 'query', ns: 'PersonalDataBase.PersonalData_Fake', command: { find: 'PersonalData_Fake', filter: { username: { '$regex': '^FQy' } }, lsid: { id: UUID('72f16ac6-e640-4d56-aeb8-51fcfc15e72b') }, '$clusterTime': { clusterTime: Timestamp({ t: 1721961075, i: 5 }), signature: { hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0), keyId: Long('0') } }, '$readPreference': { mode: 'primaryPreferred' }, '$db': 'PersonalDataBase' }, keysExamined: 45, docsExamined: 44, cursorExhausted: true, numYield: 0, nreturned: 44, queryHash: 'E9DF4413', planCacheKey: '26C08CC3', queryFramework: 'classic', locks: { FeatureCompatibilityVersion: { acquireCount: { r: Long('1') } }, Global: { acquireCount: { r: Long('1') } }, Mutex: { acquireCount: { r: Long('1') } } }, flowControl: {}, readConcern: { level: 'local', provenance: 'implicitDefault' }, responseLength: 8838, protocol: 'op_msg', millis: 1, planSummary: 'IXSCAN { username: 1 }', execStats: { stage: 'FETCH', nReturned: 44, executionTimeMillisEstimate: 0, works: 46, advanced: 44, needTime: 1, needYield: 0, saveState: 0, restoreState: 0, isEOF: 1, docsExamined: 44, alreadyHasObj: 0, inputStage: { stage: 'IXSCAN', nReturned: 44, executionTimeMillisEstimate: 0, works: 46, advanced: 44, needTime: 1, needYield: 0, saveState: 0, restoreState: 0, isEOF: 1, keyPattern: { username: 1 }, indexName: 'username_1', isMultiKey: false, multiKeyPaths: { username: [] }, isUnique: false, isSparse: false, isPartial: false, indexVersion: 2, direction: 'forward', indexBounds: { username: [ '["FQy", "FQz")', '[/^FQy/, /^FQy/]' ] }, keysExamined: 45, seeks: 2, dupsTested: 0, dupsDropped: 0 } }, ts: ISODate('2024-07-26T02:32:51.442Z'), client: '127.0.0.1', appName: 'mongosh 2.2.1', allUsers: [], user: '' } ]
さいごに
MongoDBで単一および複合インデックスを作成するときは、(データにもよりますが)RDBMSと同じ感覚で作成してもある程度有効であることが分かりました。一方で、どんなデータも受け入れてしまうためか、かっちりしたフィールドの概念がなく、存在しないフィールドにインデックスを作成してもエラーが出ないことが分かりました。
今回の検証で、むやみにインデックスを作成するのではなく、改善したいクエリのアタリをつける→インデックスを作成する→効果を確認する の流れをしっかり行うことが大切だと感じました。
この記事が誰かの役に立てば幸いです。