はじめに
今月から新しい職場に転職しました。そこではMongoDBを使用しています。今まではRDB(MySQL、Oracleなど)を使用していましたため、MongoDBは初めて使用します。そのため、今回学習しました。
MongoDBのメリット・デメリットを調べると、「スキーマが柔軟」「JOINが苦手」「スケールしやすい」といった特徴が並んで出てきます。ただ、一覧で覚えようとするとなかなか定着しませんでした。
そこでこの記事では、MongoDBの設計思想を2つの軸として先に押さえ、個々の特徴をそこから導く形で整理します。あわせて、RailsでMongoDBを使うときのODMであるMongoidを使う場合に、アプリケーションコードで何に気をつけるべきかもまとめます。
比較対象はRDB(MySQLやPostgreSQLなど)です。
結論: MongoDBの根っこにある2つの考え方
MongoDBの特徴は、ほぼ次の2つから説明できます。
- 関連するデータを1つのドキュメントにまとめ、1回の操作を1台のサーバーで完結させる
- 保存時にスキーマを強制しない
そして、どちらにも共通するのが次の点です。
その代わり、DBが引き受けない責任はアプリケーション側が負う
| 考え方 | 主に関係する観点 |
|---|---|
| 1. まとめて、1台で完結させる | 関連、整合性、検索・集計、スケール |
| 2. スキーマを強制しない | データ構造、運用・開発体験 |
以降の各観点で、この2つからどう特徴が導かれるかを見ていきます。
ドキュメントモデルが生まれた背景
RDBはデータの重複をなくして整合性を保つため、データを複数のテーブルに分けて管理し(正規化)、取得時にJOINで結合します。
しかし、スケールアウトのためにデータを複数サーバーに分割すると、JOINやトランザクションがサーバーをまたいで実行され、処理が遅くなるという問題がありました。
そこでドキュメントモデル(MongoDB)では、関連するデータを1つのドキュメントにまとめて保存します。JSONのように入れ子構造を持てる形式(BSON)を使うので、たとえば注文と注文明細を1つのドキュメントに埋め込めます。
// orders コレクション
{
_id: ObjectId("..."),
user_id: ObjectId("..."),
ordered_at: ISODate("2026-11-08T10:00:00Z"),
items: [
{ product_id: ObjectId("..."), name: "ノート", price: 300, quantity: 2 },
{ product_id: ObjectId("..."), name: "ペン", price: 150, quantity: 1 }
]
}
こうすると取得時に結合が不要になり、1回の操作が1台のサーバーで完結します。スケールアウトしても、サーバーをまたいだ結合が起きにくい設計です。
1. データ構造(スキーマの柔軟さ、埋め込み)
→ 考え方2「スキーマを強制しない」と、考え方1「まとめる」から来る特徴です。
強み
-
保存時にスキーマを強制しない
- RDBは保存時にスキーマ定義と一致するかをチェックします(スキーマオンライト)
- MongoDBはデフォルトではチェックせず、読み込む側がデータを解釈します(スキーマオンリードに近い考え方)
- そのため、ドキュメントごとに項目が違うデータ(商品ごとに属性が違うECなど)や、形式がいつ変わるか分からない外部サービスのデータをそのまま保存できます
-
埋め込みで1回の読み込みで済む
- 注文と注文明細のように、よく一緒に読むデータをまとめて取得できます
弱み
-
型や必須項目のチェックがデフォルトでは効かない
- 同じコレクションに形の違うデータが混ざりやすくなります
- 必要ならスキーマバリデーション(
$jsonSchema)を後から設定します
-
埋め込みは「親と一緒に読む」前提
- 子だけを横断的に扱いたい場合(全注文の明細を集計するなど)は不利です
-
1ドキュメントは16MBまで
- 際限なく増えるデータは埋め込めません
2. 関連(一対多、多対多、JOIN)
→ 考え方1「まとめる」から来る特徴です。まとめられない関係ほど苦手になります。
実現方法
-
一対多: 子を親に埋め込むか、子に親のIDを持たせて
$lookupで結合します - 多対多: ID の配列を持たせるか、中間コレクションを作ります
$lookupのlocalFieldに配列を指定すると、配列のどれかの要素と一致するドキュメントがすべて結合されます。そのため、多対多もIDの配列で表現できます。
// posts: { _id: 1, title: "記事A", tag_ids: [10, 11] }
// tags: { _id: 10, name: "Ruby" }
db.posts.aggregate([
{ $lookup: {
from: "tags",
localField: "tag_ids", // 配列でもOK
foreignField: "_id",
as: "tags"
}}
])
$lookupはアプリ側ではなく、DBサーバー上で実行されるアグリゲーション(MongoDBでデータの絞り込み、結合、集計、加工をDBサーバー上でまとめて行う仕組みです)の一段階です。
Rails(Mongoid)での多対多
IDの配列方式なら、Mongoidのhas_and_belongs_to_many(HABTM)が使えます。
class Post
include Mongoid::Document
field :title, type: String
has_and_belongs_to_many :tags
end
class Tag
include Mongoid::Document
field :name, type: String
has_and_belongs_to_many :posts
end
post.tags << ruby_tag # post.tag_ids と ruby_tag.post_ids の両方が更新される
Post.where(tag_ids: ruby_tag.id) # 特定タグを持つ記事
Post.includes(:tags).each { |p| p.tags } # N+1対策
片側だけにIDを持たせたい場合はinverse_of: nilを指定します。
関連そのものに属性がある場合(登録日やロールなど)は中間モデルを作ります。Mongoidにはhas_many :throughがないため、取得用のメソッドは自作します。
class Student
include Mongoid::Document
has_many :enrollments
def courses
Course.in(id: enrollments.pluck(:course_id))
end
end
class Enrollment
include Mongoid::Document
include Mongoid::Timestamps
belongs_to :student
belongs_to :course
field :role, type: String
index({ student_id: 1, course_id: 1 }, { unique: true })
end
弱み(RDBとの比較)
-
$lookupはRDBのJOINほど最適化されておらず、大量データ同士の結合は遅くなりがちです。そもそも「結合しない」前提の設計だからです - 中間コレクション方式では
$lookupが2段になり、SQLなら数行の問い合わせが冗長になります - 両側にIDの配列を持つと、関連の追加・削除のたびに2つのドキュメントを更新する必要があります
- 配列は16MBの上限に引っかかるため、フォロワーのように際限なく増える関連には向きません
使い分けの目安
| 状況 | おすすめ |
|---|---|
| 関連数に上限がある(タグ、カテゴリなど) | IDの配列(HABTM) |
| 関連そのものに属性がある(登録日、ロールなど) | 中間コレクション |
| 関連が際限なく増える | 中間コレクション |
3. 整合性(制約、トランザクション)
→ 考え方1から来る特徴です。1ドキュメント内はDBが守り、ドキュメントをまたぐとアプリの責任になります。
DB側で設定できる制約・できない制約
| 制約 | MongoDB | 備考 |
|---|---|---|
| ユニーク | できる | ユニークインデックス。配列内の重複は防げない |
| NOT NULL・型・値の範囲 | できる | スキーマバリデーション($jsonSchema) |
| 同じドキュメント内の項目間チェック | できる | バリデーターで$exprを使う |
| 外部キー(参照整合性) | できない | アプリ側の責任 |
| CASCADE削除・更新 | できない | Mongoidのdependent:などで対応 |
| 複数ドキュメントにまたがるチェック | できない | 「1ユーザーにつき契約は3件まで」など |
| デフォルト値 | できない | Mongoidのdefault:はアプリ側の機能 |
スキーマバリデーションの例です。
db.createCollection("events", {
validator: {
$jsonSchema: {
required: ["title", "start_at", "end_at"],
properties: {
title: { bsonType: "string" },
status: { enum: ["draft", "published"] }
}
},
$expr: { $gt: ["$end_at", "$start_at"] }
}
})
バリデーションは挿入・更新時にだけかかり、既存データには遡ってチェックされません。
参照整合性がないと、具体的に何が起きるか
外部キーがないため、MongoDBは「参照先が実在するか」をチェックしません。次のようなケースで、参照先のないIDが残ります。
-
本番で手作業でデータを消す: 問い合わせ対応でmongosh(MongoDBに直接接続してコマンドを実行するための公式のコマンドラインツールです。RDBで言えば、MySQLのmysqlコマンドに当たります。)から
db.users.deleteOne(...)だけ実行すると、そのユーザーの注文やコメントは残ります。RDBなら外部キーでエラーになるかCASCADEで消えますが、MongoDBでは何も言われずに成功します -
コールバックを通らない削除: Railsのコードでも
deleteやdelete_allを使うと、dependent:が動きません - Railsを通らない書き込み: 別のバッチ、別サービス、データ移行スクリプトなど
-
途中で失敗した更新: 「Tagを削除してから、Postの
tag_idsを掃除する」処理が途中で落ちた場合
その結果、次のような症状が特定のデータにだけ出ます。全体には出ないので、気づきにくく調査にも時間がかかります。
- 投稿の作者が削除済みで
post.userがnilになり、post.user.nameでNoMethodError。そのユーザーが関わるページだけ500エラーになる - フォロワー数に削除済みユーザーが含まれ、表示上の人数と一覧の件数が合わない
更新タイミングの問題
アプリ側が責任を持つ部分では、「何をチェックするか」だけでなく「いつ、どの順番で書き込むか」も問題になります。
| 問題 | 例 |
|---|---|
| チェックから書き込みまでの隙間 | 同時リクエストで、両方が「まだない」と判断して重複が入る |
| 読んで計算して書き戻す間の上書き | 在庫数をアプリで「10 − 1」して保存し、同時更新で1回分の減算が消える |
| 複数ドキュメント更新の途中失敗 | HABTMの両側更新で、片側だけ更新された状態が残る |
| 不整合な状態が一瞬見える | 2つの更新の間に別リクエストが読む |
| レプリカの反映遅れ | セカンダリから読むと、書いた直後のデータが見えない |
対策の基本方針は、できるだけDBに1回の操作でやらせて、アプリの「読んでから書く」を減らすことです。
# 在庫がある場合だけ、DB上でアトミックに1減らす
Product.where(id: id, stock: { "$gt" => 0 })
.find_one_and_update({ "$inc" => { stock: -1 } })
# 配列に重複なく追加
post.add_to_set(tag_ids: tag.id)
- 数値や配列の更新は
$inc、$push、$addToSetなどの更新演算子を使う - 複数ドキュメントを必ずセットで更新したい箇所に絞ってトランザクションを使う
- 必要に応じて
write concern: majorityやプライマリからの読み込みを設定する - 消えたIDへの参照を見つけて直すバッチを、冪等に作っておく
そして一番効くのは、同時に更新されるべきデータを同じドキュメントにまとめる設計です。1ドキュメントの更新は常にアトミックなので、トランザクションなしで整合性を保てます。
4. 検索・集計
→ 考え方1「よく使う読み方に合わせてまとめる」から来る特徴です。
強み
- 入れ子のフィールドや配列の要素を直接検索でき、インデックスも張れます(例:
items.product_id) - アグリゲーションパイプライン(
$match、$group、$unwindなど)で、絞り込みから集計までDB側で処理できます
弱み
- 複数コレクションをまたぐ集計は、
$lookup、$unwind、$groupを組み合わせる必要があり、パイプラインが長くなります - 設計時に想定していなかった切り口での集計に弱く、分析用途では別途DWHなどにデータを移すことが多いです
- SQLに比べて、問い合わせを書ける人や周辺ツール(BIなど)が少ないです
5. スケール(シャーディング、レプリケーション)
→ 考え方1「1台で完結させる」が最も活きる観点です。
強み
- シャーディング(データを複数サーバーに分割)が標準機能として組み込まれています
- 関連データを埋め込んでおけば、サーバーをまたぐ結合やトランザクションが起きにくくなります
- レプリカセットにより、プライマリが落ちても自動でセカンダリが引き継ぎます(自動フェイルオーバー)
弱み
- シャードキー(分割の基準にする項目。例: user_id)の選び方を間違えると、特定のサーバーに負荷が偏ったり、全シャードへの問い合わせが増えたりします
- シャーディング環境では、ユニークインデックスにシャードキーを含める必要があるなど、制約が増えます
-
$lookupや複数ドキュメントのトランザクションを多用すると、サーバーをまたぐ処理が増え、スケールの利点が薄れます - 多くのアプリはレプリカセット1つで十分な規模で、その場合はこの強みを活かす場面がありません
6. 運用・開発体験(マイグレーション、Railsとの相性)
→ 考え方2「スキーマを強制しない」から来る特徴です。
強み
- マイグレーションファイルなしで、モデルに
fieldを追加するだけで項目を増やせます - MongoidはMongoDB社が公式にメンテナンスしているODMで、
create、where、バリデーション、has_manyなど、ActiveRecordとほぼ同じAPIが使えます -
embeds_manyやembeds_oneで、埋め込みをモデルとして自然に表現できます
class Order
include Mongoid::Document
field :ordered_at, type: Time
embeds_many :items
end
class Item
include Mongoid::Document
field :name, type: String
field :price, type: Integer
field :quantity, type: Integer
embedded_in :order
end
弱み
- スキーマを変えても既存データはそのままなので、古い形のドキュメントを書き換えるスクリプトや、新旧両方に対応するコードが必要です
- 「どこに埋め込むか、どちらにIDを持つか」をアクセスパターンに合わせて最初に決める必要があり、要件が変わると移行が大がかりになりがちです
- Mongoidには
has_many :throughがありません - Railsのエコシステム(gemや情報)はActiveRecord前提のものが多く、Mongoidでは使えないものがあります
「DB」と「アプリ」の間には「ライブラリ」の層がある
ここまで「DBが守るか、アプリが守るか」で整理してきました。実際にはその間に、Mongoidのようなライブラリの層があります。
| 層 | 例 | 効く範囲 |
|---|---|---|
| DB | ユニークインデックス、スキーマバリデーション | どこから書き込んでも効く |
| ライブラリ | バリデーション、dependent:、コールバック |
そのライブラリの決まった経路を通ったときだけ |
| 自分のコード | 上の2つで守れない部分 | 自分が書いた処理の中だけ |
この層を意識していないと、次の3パターンでずれが生まれます。
パターン1: DBの制約に見えて、実はライブラリの機能
| モデルの記述 | 実際に守っている層 | DB側の状態 |
|---|---|---|
validates :email, uniqueness: true |
ライブラリ | 制約なし |
has_many :comments, dependent: :destroy |
ライブラリ | 連動削除なし |
field :age, type: Integer |
ライブラリ(Rubyで型変換するだけ) | 型チェックなし |
field :status, default: "draft" |
ライブラリ | デフォルト値なし |
index({ email: 1 }, { unique: true }) |
書いただけでは未作成 |
rails db:mongoid:create_indexesで初めて作成 |
特にvalidates uniqueness:は「DBを検索して、なければ保存」の2ステップです。同時に2つのリクエストが来ると、次の順で重複が入ります。
- リクエストAが検索する → まだない
- リクエストBが検索する → まだない
- Aが保存する
- Bが保存する → 重複が入る
MongoDBでもユニークインデックスでDB側の制約はつけられるので、バリデーションとユニークインデックスを両方つけます。バリデーションは分かりやすいエラーメッセージのため、インデックスは確実に重複を防ぐためです。
class User
include Mongoid::Document
field :email, type: String
validates :email, uniqueness: true
index({ email: 1 }, { unique: true })
end
これはMongoDB特有の話ではありません。ActiveRecordのvalidates uniqueness:も同じ2ステップなので、RDBでもユニーク制約を併用します。
パターン2: ライブラリの中にも、保証を飛ばすメソッドがある
user.destroy # コールバック・dependent: が動く
user.delete # 動かない
User.where(...).delete_all # 動かない
User.where(...).update_all(...) # バリデーション・コールバックが動かない
user.save(validate: false) # バリデーションを飛ばす
user.set(name: "x") # アトミック操作。バリデーション・コールバックは動かない
user.inc(points: 1) # 同上
setやincは、DB上でアトミックに更新できる便利なメソッドです。その代わりにライブラリの保証は飛ばす、というトレードオフがあります。
パターン3: 同じRailsでも、ライブラリによってDBに寄せられる範囲が違う
ActiveRecordなら、マイグレーションのnull: falseやadd_foreign_keyで制約をDBに設定できます。
Mongoidでは外部キーがDBに存在しないので、同じbelongs_toやdependent:を書いても、守るのはライブラリまでです。書き方がほぼ同じなので、ActiveRecordと同じ感覚で使うと見落とします。
実務でのルール
ここまでの内容から、MongoidでRailsアプリを作るときのルールをまとめます。
-
削除は原則
destroyを使う。deleteやdelete_allを使うのは、関連を持たない末端のデータ(ログ、セッションなど)か、大量削除で性能が必要な場合に限る。その場合は関連データの削除も明示的に書く
user_ids = User.where(status: "withdrawn").pluck(:id)
Comment.where(:user_id.in => user_ids).delete_all
User.where(:id.in => user_ids).delete_all
-
ルールを仕組みで守らせる。コードレビューの観点に入れる、CIで
deleteの呼び出しを検出する -
本番データの削除・修正はmongoshで直接行わない。
rails runnerやrakeタスクでdestroyを使い、モデルのロジックを通す - 退会処理など頻繁にある作業はスクリプト化・手順書化する
まとめ
MongoDBは「読み方が決まっていて、関連データをまとめて読み書きできる」データに強く、「関連が複雑で、整合性を厳密に守りたい」データに弱いDBです。
| 観点 | RDBと比べた評価 | 責任を持つのは |
|---|---|---|
| データ構造 | 得意(柔軟、埋め込み) | データの形はアプリ |
| 関連 | 一対多は得意、多対多は不得意 | 関連の維持はアプリ |
| 整合性 | 不得意(外部キーなし) | ユニーク・型はDB、参照整合性はアプリ |
| 検索・集計 | 1コレクション内は得意、横断的な集計は不得意 | DB |
| スケール | 得意(シャーディングが標準) | DB(シャードキー設計は人) |
| 運用・開発体験 | 初期は楽、長期的には設計力が必要 | 既存データの移行はアプリ |
向いている用途
- 商品カタログなど、ドキュメントごとに属性が違うデータ
- ログ、イベント、外部APIのレスポンスなど、形式が変わりやすく、量が多いデータ
- 「記事とそのコメント」のように、まとめて読むことがほとんどのデータ
向いていない用途
- 会計・在庫など、複数データ間の整合性が最重要なデータ
- 多対多が中心で、関連をまたいだ検索や集計が多いアプリ
おわりに
個々の特徴を暗記するより、
- 関連するデータを1つのドキュメントにまとめ、1回の操作を1台のサーバーで完結させる
- 保存時にスキーマを強制しない
という2つの設計思想を押さえる。そのうえで、守りたいルールごとに「誰が守っているか(DB・ライブラリ・自分のコード)」と「それを素通りする経路はどれか」を確認する。こうすると、アプリケーションコードやDB設計で考慮すべき点が自然に見えてくると感じました。