STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

MongoDBでnullの重複を許しつつユニークにしたいときの罠

こんにちは!STORES ブランドアプリ のバックエンドエンジニアをしているotariidaeです。

最近 STORES ネットショップ にコントリビュートする機会があり、データベースとして採用されているMongoDBについて1つ学びを得たので記事にしたいと思います。

ユニークにしたい、でもnullの重複は許容したい

例えば users コレクションがあり、emailフィールドの値は一意にしたい、しかしnullの重複は許す、という要件を考えます。次のようなイメージです。

> db.users.find()
[
  {
    _id: ObjectId('682cb935c578d93e34a00aaa'),
    email: 'hoge@example.com'
  },
  {
    _id: ObjectId('682cb935c578d93e34a00aab'),
    email: 'fuga@example.com'
  },
  { _id: ObjectId('682cb8c4c578d93e34a00aa8'), email: null },
  { _id: ObjectId('682cb8c5c578d93e34a00aa9'), email: null }
]

ここで単に unique: true なインデックスを張るのでは要件を満たせません。MongoDBのユニーク制約はnullとnullで重複するからです。

> db.users.createIndex({email: 1}, {unique: true})
email_1
> db.users.insertOne({email: null})
{
  acknowledged: true,
  insertedId: ObjectId('682cb803c578d93e34a00aa6')
}
> db.users.insertOne({email: null})
MongoServerError: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: null }

単一フィールドのユニークインデックスはユニーク制約により、インデックスエントリに null 値を含むドキュメントを 1 つしか含めることができません。インデックスエントリに null 値があるドキュメントが複数ある場合、インデックス構築は重複キーエラーで失敗します。

出典:Unique Indexes - Database Manual v8.0 - MongoDB Docs

RDBMSに慣れていた身としてはこの仕様にはちょっとひるみました。

部分インデックスの出番

部分インデックスという仕組みでこの困りごとを解決できます。部分インデックスとは、特定の条件を満たすドキュメントのみをインデックスの対象とするMongoDBの機能です。

www.mongodb.com

例えば次のような partialFilterExpression を書けば、値がstring型のドキュメントのみをインデックスの対象にできるため、nullの重複が許容されます。

> db.users.createIndex(
    {email: 1},
    {unique: true, partialFilterExpression: {email: {$type: "string"}}}
  )
email_1
> db.users.insertOne({email: null})
{
  acknowledged: true,
  insertedId: ObjectId('682cb8c4c578d93e34a00aa8')
}
> db.users.insertOne({email: null})
{
  acknowledged: true,
  insertedId: ObjectId('682cb8c5c578d93e34a00aa9')
}
> db.users.find()
[
  { _id: ObjectId('682cb8c4c578d93e34a00aa8'), email: null },
  { _id: ObjectId('682cb8c5c578d93e34a00aa9'), email: null }
]

便利!

効かない

しかし上記の例はパフォーマンス劣化の危険性を孕みます。

次のクエリにインデックスが効きません。

db.users.find({email: "hoge@example.com"})

実行計画を見ると

> db.users.find({ email: "hoge@example.com" }).explain().queryPlanner.winningPlan
{
  stage: 'COLLSCAN',
  filter: { email: { '$eq': 'hoge@example.com' } },
  direction: 'forward'
}

コレクションスキャン(COLLSCAN)となっており、ドキュメントを全件スキャンします。コレクションに含まれるドキュメントが多くなるほどクエリに時間がかかるようになります。おそい!

効かせる

効かせるためには例えば次のようなフィルター式にします。

db.users.createIndex(
  {email: 1},
  {unique: true, partialFilterExpression: {email: {$gte: ""}}}
)

$type: "string"$gte: "" に変えました。

一見すると直感的ではないですが、実行計画を見ると

> db.users.find({ email: "hoge@example.com" }).explain().queryPlanner.winningPlan
{
  stage: 'FETCH',
  inputStage: {
    stage: 'IXSCAN',
    keyPattern: { email: 1 },
    indexName: 'email_1',
    isMultiKey: false,
    multiKeyPaths: { email: [] },
    isUnique: true,
    isSparse: false,
    isPartial: true,
    indexVersion: 2,
    direction: 'forward',
    indexBounds: { email: [ '["hoge@example.com", "hoge@example.com"]' ] }
  }
}

効く!

効かなかった理由

次のフォーラムの回答に $type: "string" が効かない理由が端的に述べられています。

www.mongodb.com

どうやら {email: "hoge@example.com"} のクエリ条件はemailがstring型とsymbol型のドキュメントを結果に含みうるが、$type: "string" の部分インデックスはstring型のみ対象であるため、インデックスが使われない、のだそうです。

試したところ確かにstring型とsymbol型は別物として存在していて、かつ{email: "hoge@example.com"} のクエリ条件で両方とも返ることがわかります。

> db.users.insertMany([
    {email: "hoge@example.com"},
    {email: BSONSymbol("hoge@example.com")}
  ])
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('682cc7c5c578d93e34a00ab1'),
    '1': ObjectId('682cc7d6c578d93e34a00ab3')
  }
}
> db.users.find({email: {$type: "string"}})
[
  {
    _id: ObjectId('682cc7c5c578d93e34a00ab1'),
    email: 'hoge@example.com'
  }
]
> db.users.find({email: {$type: "symbol"}})
[
  {
    _id: ObjectId('682cc7d6c578d93e34a00ab3'),
    email: 'hoge@example.com'
  }
]
> db.users.find({email: "hoge@example.com"})
[
  {
    _id: ObjectId('682cc7c5c578d93e34a00ab1'),
    email: 'hoge@example.com'
  },
  {
    _id: ObjectId('682cc7d6c578d93e34a00ab3'),
    email: 'hoge@example.com'
  }
]

MongoDBくん、そういうのは、先に、言ってね。

おわりに

MongoDBの部分インデックスにまつわるハマりどころを紹介しました。

似た話としてCovered Queryの記事も紹介します。ぜひこちらもご一読ください。

product.st.inc

みなさんもパフォーマンスに気をつけて健康的なMongoDBライフを!