MongoDB: ドキュメントとBSON:MongoDBデータの基礎
最終更新:2026-08-26
BSONはMongoDBのデータ形式です。JSONの機能を拡張し、Date、Binary、Decimal128などのネイティブ型をサポートしています。
このコースでは、BSONデータ形式、ObjectIdの内部構造、フィールド型システムを深く理解し、ドキュメント設計のベストプラクティスを学びます。
1. 学習内容
- BSONデータ形式とJSONの根本的な違い
- MongoDBドキュメントの内部構造(_id、フィールド、値)
- ObjectIdの構成、タイムスタンプ抽出、一意性保証
- 12種類のBSONデータ型(String、Number、Date、Array、Object、ObjectIdなど)
- フィールド命名規則(キャメルケース vs スネークケース vs ケバブケース)
- ドキュメントサイズ制限(16 MB)の設計思想
- 埋め込みドキュメントと参照の選択戦略
2. フルスタックエンジニアの実例
(1) 課題:JSONをMongoDBに保存すると日付が文字列に変換される
CharlieはフルスタックNode.jsエンジニアで、MySQLデータをMongoDBに移行しています:
「MySQLから注文データをJSONに変換してMongoDBに保存したら、すべての日付が
new Date()という文字列に変換されて解析できず、金額の精度が失われ(0.1 + 0.2 ≠ 0.3)、バイナリのプロフィール画像が保存できなかった。」
JSON.stringify()で注文データをシリアライズしたため、型情報が失われました:
// ❌ エラー:JSON.stringifyで型が失われる
const order = {
createdAt: new Date(), // Dateオブジェクト
total: new Number('0.30'), // Decimal128を使用すべきより正確な型
avatar: Buffer.from('...'), // バイナリアバター
_id: new ObjectId() // MongoDBが期待するObjectId
};
const json = JSON.stringify(order);
// {"createdAt":"2026-07-01T...","total":0.3,"avatar":"...","_id":"..."}
// ^^^^^^^^^^^^^^^^ 文字列 ^ 浮動小数点(精度損失) ^ 文字列(復元不可)
(2) BSONによる解決策
MongoDBはデータをBSON(Binary JSON)形式で直接保存し、すべての型情報を保持します。
// ✅ 正しい:mongooseでBSON型を直接操作
const OrderSchema = new mongoose.Schema({
createdAt: { type: Date, default: Date.now }, // BSON Date
total: { type: mongoose.Schema.Types.Decimal128 }, // BSON Decimal128(正確)
avatar: { type: Buffer }, // BSON Binary
_id: { type: mongoose.Schema.Types.ObjectId, auto: true } // BSON ObjectId
});
const order = await Order.create({
total: mongoose.Types.Decimal128.fromString('0.30'),
// TODO: 替换为实际头像文件路径
avatar: fs.readFileSync('avatar.jpg')
});
(3) 成果
| 項目 | JSON | BSON |
|---|---|---|
| 日付型 | 文字列(手動解析が必要) | ネイティブDate(ミリ秒精度) |
| 数値精度 | 浮動小数点(精度損失) | Decimal128(34桁精度) |
| バイナリデータ | 非対応 | ネイティブBinary |
| フィールド順序 | 順序なし | 順序あり(重要!) |
| サイズとリソース | よりコンパクト | わずかに大きい(5〜15%増) |
3. BSONデータ形式
概念概要:BSON(Binary JSON)はMongoDB専用のバイナリシリアライゼーション形式で、JSONのスーパーセットです。JSONには6つのデータ型(文字列、数値、ブール、null、配列、オブジェクト)しかありませんが、BSONはDate、Binary、ObjectId、Decimal128など12種類以上の型をサポートしています。BSONの核心的な利点は、豊富なデータ型、フィールドの順序保持、極めて高速な解析です。
仕組み:BSONドキュメントはバイナリ形式で保存されます。各ドキュメントは4バイトの長さヘッダーで始まり、キーと値のペアのシーケンスが続き、0x00で終わります。JSONのテキストベース解析と異なり、BSONの長さヘッダーは不要なフィールドを迅速にスキップできます(バイナリプロトコルの「固定長ヘッダー」設計に類似)。解析パフォーマンスはJSONより3〜5倍高速です。トレードオフとして、スペースオーバーヘッドが5〜15%増加します(型と長さ情報を保存するため)。
graph TB
subgraph "BSONドキュメントの内部構造"
A[4バイト<br/>ドキュメントの全長] --> B[型コード1B<br/>+ フィールド名<br/>+ 値]
B --> C[型コード1B<br/>+ フィールド名<br/>+ 値]
C --> D[...より多くのキーと値のペア...]
D --> E[0x00<br/>終了タグ]
end
style A fill:#cce5ff
| 項目 | JSON | BSON |
|---|---|---|
| 型 | テキスト形式 | バイナリ形式 |
| 可読性 | ✅ 人間が読める | ❌ バイナリ |
| パフォーマンス | 解析が遅い | 極めて高速な解析(3〜5倍) |
| 種類の豊富さ | 6種類 | 12種類以上 |
| フィールド順序 | 順序なし | 順序あり |
| スペース | よりコンパクト | 5〜15%増 |
(1) BSONとは?
BSON(Binary JSON)はMongoDBが使用するバイナリシリアライゼーション形式です。その特徴:
graph LR
A[JavaScriptオブジェクト] -->|JSON.stringify| B[JSONテキスト]
A -->|BSONシリアライズ| C[BSONバイナリ]
B --> D[送信 / 保存]
C --> D
style C fill:#d4edda
| 項目 | JSON | BSON |
|---|---|---|
| 型 | テキスト形式 | バイナリ形式 |
| 可読性 | ✅ 人間が読める | ❌ バイナリ |
| パフォーマンス | 解析が遅い | 極めて高速な解析 |
| 種類の豊富さ | 6種類 | 12種類以上 |
| フィールド順序 | 順序なし | 順序あり |
| スペース | よりコンパクト | 5〜15%増 |
(2) BSONドキュメント構造
ポイント解説:
- BSONフィールドの挿入順序 — これはMongoDBでのインデックス作成とクエリ最適化に重要
- 各フィールドの前には1バイトの型コードがあり、BSONはDateとStringを区別できます(JSONと異なる)
- ネストされたドキュメントと配列はBSONで再帰的に保存され、最大ネスト深度は100レベル
_idフィールドは常にドキュメントの先頭にあり、クエリパフォーマンスを最適化
// 1つのBSONドキュメントの内部表現(簡略化)
{
_id: ObjectId("507f1f77bcf86cd799439011"), // 12バイトObjectId
name: "Alice", // 文字列(UTF-8)
age: 28, // Int32
balance: Decimal128("12345.6789"), // Decimal128(高精度)
joinedAt: ISODate("2026-07-01T10:00:00Z"), // 日付(64ビット整数)
isActive: true, // ブール
hobbies: ["reading", "coding", "hiking"], // 配列
address: { // 埋め込みドキュメント
city: "Tokyo",
country: "Japan"
},
profile: null, // Null
avatar: BinData(0, "..."), // バイナリ
// フィールド順序:BSONはフィールドの挿入順序を保持(JSONは保証しない)
}
▶ サンプル 1:mongoshでBSON詳細を確認
// ドキュメントを挿入
db.users.insertOne({
name: "Alice",
age: 28,
joinedAt: new Date(),
balance: NumberDecimal("12345.6789"),
address: { city: "Tokyo", country: "Japan" }
});
// BSON詳細を確認(bsonSize関数を使用)
db.users.findOne({ name: "Alice" });
出力:
TEXT 📖 参照専用{ _id: ObjectId('507f1f77bcf86cd799439011'), name: 'Alice', age: 28, joinedAt: ISODate('2026-07-01T10:00:00.000Z'), balance: NumberDecimal('12345.6789'), address: { city: 'Tokyo', country: 'Japan' } }
// フィールド型を確認
const doc = db.users.findOne({ name: "Alice" });
print(typeof doc.age); // number
print(doc.joinedAt instanceof Date); // true
出力:
TEXT 📖 参照専用number true
4. ObjectId主キーメカニズム
概念説明:ObjectIdはMongoDBのデフォルト主キー型で、12バイト(96ビット)のバイナリ値で構成されます。従来のデータベースの自動採番整数主キーと異なり、ObjectIdは分散設計を採用 — タイムスタンプ、ランダム値、カウンターで構成され、集中管理なしでグローバルな一意性を保証します。ObjectIdのもう一つの大きな利点は、作成時刻が組み込まれているため、追加のフィールドなしで直接抽出できることです。
仕組み:ObjectIdの12バイトは3つのセグメントに分かれます。最初の4バイトはUnixタイムスタンプ(秒精度)、中間の5バイトはランダム値(最初に生成された時にマシンIDとプロセスIDで決定され、その後は変わらない)、最後の3バイトはインクリメンタルカウンター(同じ秒内でランダムな開始値から増分)。この設計により、単一プロセスは1秒間に約1,677万件の一意なObjectIdを生成できます。
graph LR
A[ObjectId 12バイト] --> B[4バイト タイムスタンプ<br/>秒精度]
A --> C[5バイト ランダム値<br/>マシン/プロセス固有]
A --> D[3バイト インクリメンタルカウンター<br/>同一秒内で一意]
style A fill:#cce5ff
| セクション | 長さ | 内容 | 目的 |
|---|---|---|---|
| タイムスタンプ | 4バイト | Unixタイムスタンプ(秒) | 作成時刻を抽出可能 |
| ランダム | 5バイト | マシンID + プロセスID | プロセス間で一意 |
| カウンター | 3バイト | インクリメンタルカウンター | 同一秒内で一意 |
| _id戦略 | 利点 | 欠点 | 用途 |
|---|---|---|---|
| 自動生成ObjectId | 分散、一意、タイムスタンプ付き、自然な順序 | 12バイト(比較的大きい) | 汎用(デフォルト) |
| 文字列ビジネスキー | 明確な意味、可読性 | 手動で一意性を保証する必要がある | 注文番号、SKU |
| 自動採番整数 | コンパクト、可読 | カウンター集合が必要、シャーディングに不向き | レガシーシステム |
| UUID | グローバルに一意 | 16バイト、順序なし | システム間で一意 |
(1) ObjectIdとは?
ObjectIdはMongoDBのデフォルト主キー型で、12バイト(96ビット)のバイナリ値です:
graph LR
A[ObjectId 12バイト] --> B[4バイト タイムスタンプ<br/>秒精度]
A --> C[5バイト ランダム値<br/>マシン/プロセス固有]
A --> D[3バイト インクリメンタルカウンター<br/>同一秒内で一意]
style A fill:#cce5ff
| セクション | 長さ | 内容 | 目的 |
|---|---|---|---|
| タイムスタンプ | 4バイト | Unixタイムスタンプ(秒) | 作成時刻を抽出可能 |
| ランダム | 5バイト | マシンID + プロセスID | プロセス間で一意 |
| カウンター | 3バイト | インクリメンタルカウンター | 同一秒内で一意 |
(2) ObjectIdの利点
ポイント解説:
- ObjectIdのタイムスタンプ部分は自然にエントリを挿入時間順にソート — 追加の
createdAtインデックスなしで時間範囲クエリが可能 - 5バイトのランダム値はプロセス開始時に生成されてキャッシュされ、プロセス間の一意性を保証(2^40 ≈ 1兆通りの可能性)
- 3バイトのカウンターは同一秒内で増分し、1秒間に2^24 ≈ 1,677万件の一意なIDを生成
getTimestamp()メソッドでObjectIdから作成時刻を直接抽出可能、追加クエリ不要
// mongoshでObjectIdを作成
const id1 = ObjectId(); // 自動生成
const id2 = ObjectId("507f1f77bcf86cd799439011"); // 文字列から生成
出力:
TEXT 📖 参照専用ObjectId('507f1f77bcf86cd799439011')
// 作成時刻を取得(主な利点!)
id2.getTimestamp();
出力:
TEXT 📖 参照専用ISODate("2012-10-17T20:46:11.000Z")
// Node.js + mongooseで使用
const mongoose = require('mongoose');
const id = new mongoose.Types.ObjectId();
console.log(id.getTimestamp()); // 2026-07-01T10:00:00.000Z
出力:
TEXT 📖 参照専用2026-07-01T10:00:00.000Z
(3) ObjectIdの一意性保証
graph TB
A[クライアントA<br/>同一秒に生成されたID] --> A1[time=1000<br/>random=ABC<br/>counter=1]
A --> A2[time=1000<br/>random=ABC<br/>counter=2]
A --> A3[time=1000<br/>random=ABC<br/>counter=3]
B[クライアントB<br/>同一秒に生成されたID] --> B1[time=1000<br/>random=DEF<br/>counter=1]
B --> B2[time=1000<br/>random=DEF<br/>counter=2]
style A1 fill:#d4edda
style B1 fill:#d4edda
▶ サンプル 2:ObjectIdタイムスタンプの抽出
// === mongosh内で ===
const products = db.products.find().toArray();
products.forEach(p => {
print(`Product ${p._id} created at ${p._id.getTimestamp()}`);
});
// === ObjectIdによる時間範囲クエリ ===
const startOfDay = ObjectId.createFromTime(
Math.floor(new Date('2026-07-01').getTime() / 1000)
);
const endOfDay = ObjectId.createFromTime(
Math.floor(new Date('2026-07-02').getTime() / 1000)
);
db.products.find({
_id: { $gte: startOfDay, $lt: endOfDay }
});
// === Node.js / mongoose ===
const Product = mongoose.model('Product', productSchema);
const products = await Product.find({
_id: {
$gte: mongoose.Types.ObjectId.createFromTime(
Math.floor(Date.parse('2026-07-01') / 1000)
),
$lt: mongoose.Types.ObjectId.createFromTime(
Math.floor(Date.parse('2026-07-02') / 1000)
)
}
});
5. BSONデータ型
概念説明:BSONは12種類以上のデータ型をサポートしており、JSONの6種類を遥かに上回ります。最も大きな違いは数値型です — JSONにはNumber型(IEEE 754倍精度浮動小数点)しかありませんが、BSONはDouble、Int32、Int64(Long)、Decimal128の4つの数値型を提供します。誤った数値型を選択すると精度の損失が発生します(例:金額計算で0.1 + 0.2 ≠ 0.3)。
使用場面:金融金額にはDecimal128(34桁の十進精度)を使用、カウンターにはInt32、大きな整数IDにはLong、科学計算と統計にはDoubleを使用します。Date型はBSONで64ビットミリ秒タイムスタンプとして保存され、JSONの文字列ベースの日付とは根本的に異なります。
| 型 | 型コード | 例 | 用途 |
|---|---|---|---|
| Double | 1 | 3.14, 0.1+0.2 |
浮動小数点(デフォルトの数値) |
| String | 2 | "Alice" |
UTF-8文字列 |
| Object | 3 | { key: "value" } |
ネストされたドキュメント |
| Array | 4 | [1, 2, 3] |
配列 |
| Binary data | 5 | BinData(0, "...") |
バイナリデータ(画像、ファイル) |
| Undefined | 6 | undefined |
非推奨 |
| ObjectId | 7 | ObjectId("...") |
デフォルト主キー |
| Boolean | 8 | true, false |
ブール |
| Date | 9 | ISODate("...") |
日時 |
| Null | 10 | null |
空の値 |
| Regular Expression | 11 | /pattern/i |
正規表現 |
| 32-bit Integer | 16 | NumberInt(123) |
32ビット整数 |
| 64-bit Integer | 18 | NumberLong(123) |
64ビット整数(BigInt) |
| Decimal128 | 19 | NumberDecimal("0.30") |
高精度十進数(金融) |
| MinKey/MaxKey | -1 / 127 | MinKey(), MaxKey() |
比較境界 |
(1) 12種類のBSONデータ型
(2) 数値型の選択
概念説明:数値データ型の選択はBSONデータ型を扱う際の最も重要な決定です。JSONにはNumber型(倍精度浮動小数点)しかなく、金融計算で有名な精度損失問題が発生します:0.1 + 0.2 = 0.30000000000000004。BSONのDecimal128型はこの問題を解決し、34桁の十進精度を提供し、金額や税率など正確な計算が必要な場面に適しています。
| 数値型 | 精度 | 範囲 | ストレージサイズ | 用途 |
|---|---|---|---|---|
| Double | 15〜17有効桁 | ±1.7×10^308 | 8バイト | 科学計算、統計、グラフィックス |
| Int32 | 正確 | -2^31 ~ 2^31-1 | 4バイト | 汎用カウント、在庫 |
| Int64/Long | 正確 | -2^63 ~ 2^63-1 | 8バイト | 大きな整数ID、タイムスタンプ |
| Decimal128 | 34桁十進 | ±10^6145 | 16バイト | 金融金額(推奨) |
graph TB
A[MongoDB数値型] --> B[Double<br/>デフォルト]
A --> C[Int32<br/>32ビット整数]
A --> D[Long<br/>64ビット整数]
A --> E[Decimal128<br/>34桁十進]
B --> B1[適用:科学計算、統計]
C --> C1[適用:汎用カウント]
D --> D1[適用:大きな整数ID]
E --> E1[適用:金融、金額]
style E fill:#d4edda
▶ サンプル 3:数値型の操作
// === Double(デフォルト)===
db.products.insertOne({
sku: "PHONE-001",
price: 599.99 // Doubleとして保存
});
// === Decimal128(金融で推奨)===
db.accounts.insertOne({
balance: NumberDecimal("1234567890.12345678901234567890")
// 正確に保存、精度損失なし
});
// === Int32(カウント)===
db.products.insertOne({
sku: "BOOK-001",
stock: NumberInt(150)
});
// === Long(大きな整数ID)===
db.orders.insertOne({
_id: NumberLong("1700000000000") // タイムスタンプをIDとして使用
});
// === JavaScriptでDecimal128を処理 ===
const account = await Account.findOne({});
console.log(account.balance.toString()); // "1234567890.12345678901234567890"
// === 数値の精度の罠 ===
0.1 + 0.2; // 0.30000000000000004 ❌
NumberDecimal("0.1") + NumberDecimal("0.2"); // NumberDecimal("0.3") ✅
出力:
TEXT 📖 参照専用0.30000000000000004 NumberDecimal("0.3")
6. フィールド命名規則
概念説明:フィールドの命名は些細なことと思えますが、チーム協業と長期的なメンテナンスに大きな影響を与えます。MongoDBはフィールド名に3つの厳格な制限を課しています($で始められない、.を含められない、空文字列であってはならない)。また、いくつかのソフトな推奨事項があります(キャメルケース推奨、予約語を避ける、長さを制限する)。一貫した命名規則はデータベースの保守性の基礎です。
使用場面:JavaScript/TypeScriptエコシステムではキャメルケースを推奨(コードの変数名と一貫)、Python/SQLエコシステムではスネークケースを推奨(データベースのカラム名と一貫)。MongoDB + Mongooseの技術スタックでは、データベースフィールド名にキャメルケースを推奨し、MongooseのtoJSON変換でAPI層でスネークケース出力します。
| 命名スタイル | 例 | 利点 | 欠点 | 推奨 |
|---|---|---|---|---|
| キャメルケース | firstName |
JS/TSで標準サポート | SQLに不向き | ⭐⭐⭐(推奨) |
| スネークケース | first_name |
SQL/Pythonに適している | JSで引用符が必要 | ⭐⭐ |
| ケバブケース | first-name |
URLに適している | MongoDBで引用符が必要 | ⭐ |
(1) MongoDBフィールド命名規則
✅ 有効な命名:
- フィールド名は
$で始められない(予約語) - フィールド名に
.を含められない(ドット表記が予約されている) - フィールド名は空文字列
""であってはならない
// ✅ 有効なフィールド名
db.users.insertOne({
firstName: "Alice", // キャメルケース
first_name: "Alice", // スネークケース
"first-name": "Alice", // ケバブケース(引用符が必要)
"user 1": "Alice", // スペースを含む(引用符が必要)
age28: 28 // 数字で終わる
});
// ❌ 無効なフィールド名
db.users.insertOne({
$name: "Alice", // $で始まる ❌
"user.name": "Alice", // .を含む ❌
"": "Alice" // 空文字列 ❌
});
(2) 3つの命名規則の比較
| スタイル | 例 | 利点 | 欠点 |
|---|---|---|---|
| キャメルケース | firstName |
JS/TSで標準サポート | SQLに不向き |
| スネークケース | first_name |
SQL/Pythonに適している | JSで引用符が必要 |
| ケバブケース | first-name |
URLに適している | MongoDBで引用符が必要 |
(3) 推奨:キャメルケース + MongoDB公式スタイル
// ✅ 推奨スタイル:キャメルケース
db.users.insertOne({
firstName: "Alice",
lastName: "Smith",
emailAddress: "alice@example.com",
dateOfBirth: new Date("1998-01-01"),
isActive: true,
totalSpent: NumberDecimal("1234.56")
});
▶ サンプル 4:Mongooseスキーマの命名規則
// mongooseはキャメルケースを自動的にデータベースフィールドに変換
const UserSchema = new mongoose.Schema({
firstName: { type: String, required: true }, // データベースフィールド:firstName
emailAddress: { type: String, required: true }, // データベースフィールド:emailAddress
createdAt: { type: Date, default: Date.now }, // データベースフィールド:createdAt
isActive: { type: Boolean, default: true } // データベースフィールド:isActive
});
// toJSONでスネークケース命名規則に変換(APIで返す場合)
UserSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: (doc, ret) => {
ret.first_name = ret.firstName;
delete ret.firstName;
return ret;
}
});
7. ドキュメントサイズ制限
概念説明:MongoDB単一ドキュメントの最大サイズは16 MB、最大ネスト深度は100レベルです。この制限はMongoDBの核心的な設計思想です。関連データを単一ドキュメントに埋め込むことを推奨し(JOINを回避)、非常に大きなドキュメントの保存を推奨しません。16 MBの制限により、MongoDBはメモリ内で個々のドキュメントを効率的に処理でき、クエリと更新の高速な応答時間を保証します。
仕組み:16 MB制限の根本的な理由は、MongoDBのWiredTigerストレージエンジンがドキュメント変更時に「インプレース更新」戦略を使用することです — 更新後にドキュメントが大きくなり、元の場所に十分なスペースがない場合、ドキュメントを新しい場所に移動する必要があり、これがインデックス更新をトリガーします(そのドキュメントを指すすべてのインデックスエントリを更新する必要がある)。ドキュメントが大きいほど、移動コストが高くなります。そのため、MongoDBは16 MBをバランスポイントとして選択しました。
| 項目 | 制限 | 理由 |
|---|---|---|
| 単一ドキュメントサイズ | 16 MB | 最大BSONドキュメントサイズ |
| ネスト深度 | 100レベル(デフォルト) | スタックオーバーフローを防止 |
| フィールド名の長さ | 255バイト | UTF-8エンコーディング |
| インデックス数 | コレクションあたり64 | インデックスメタデータサイズ |
| 単一クラスターインデックスキーの全長 | 1024バイト | インデックス効率 |
| 範囲外のシナリオ | 解決策 | 説明 |
|---|---|---|
| 大きなファイル(画像/動画) | GridFS | ブロック保存、ブロックあたり255 KB |
| 非常に長いテキスト | Elasticsearch + 参照 | ドキュメントはIDを保存、ESが全文を保存 |
| 配列が大きすぎる(コメントリスト) | 別のコレクションに分割 | commentsコレクション + 参照 |
| 階層が深すぎる | フラット設計 | 階層レベルを減らす |
(1) 16 MB制限
(2) なぜ16 MBなのか?
MongoDBの設計思想:巨大なドキュメントの保存を避ける:
- ✅ 単一クエリでドキュメント全体を返す(JOINなし)
- ✅ ドキュメント転送効率が高い(ネットワーク転送に適している)
- ❌ 大きなバイナリファイルの保存に不向き(GridFSを使用)
- ❌ 非常に長いテキストの保存に不向き(Elasticsearchを使用)
(3) 大きなドキュメントシナリオの解決策
graph TB
A[大きなドキュメントシナリオ] --> B[バイナリファイル<br/>画像/動画]
A --> C[長いテキスト<br/>記事/ログ]
A --> D[配列が大きすぎる<br/>コメントリスト]
B --> E[GridFS<br/>ブロック保存]
C --> F[テキスト検索<br/>Elasticsearch]
D --> G[コレクション分割<br/>commentsコレクション]
style E fill:#d4edda
style F fill:#d4edda
style G fill:#d4edda
▶ サンプル 5:GridFSで大きなファイルを保存
// === 大きなファイルの保存(>16MB)===
const mongoose = require('mongoose');
const Grid = require('gridfs-stream');
const fs = require('fs');
const conn = mongoose.connection;
let gfs;
conn.once('open', () => {
gfs = Grid(conn.db, mongoose.mongo);
gfs.collection('uploads');
});
// ファイルをアップロード
const writestream = gfs.createWriteStream({
filename: 'large-video.mp4',
content_type: 'video/mp4'
});
fs.createReadStream('./local-video.mp4').pipe(writestream);
writestream.on('close', (file) => {
console.log(`File stored: ${file._id}`);
});
// ファイルをダウンロード
const readstream = gfs.createReadStream({
_id: ObjectId('507f1f77bcf86cd799439011')
});
readstream.pipe(fs.createWriteStream('./downloaded-video.mp4'));
8. 埋め込みドキュメントと参照
概念説明:MongoDBでドキュメント間の関係をモデリングするには2つの主要な戦略があります — 埋め込み(Embed)と参照(Reference)。埋め込み方式は関連データを親ドキュメント内に直接埋め込み、すべてのデータを単一クエリで取得できます。参照方式は関連データを別のコレクションに保存し、ObjectId参照でアクセスし、$lookupまたはアプリケーション層で複数クエリが必要です。この2つの戦略の選択はMongoDBデータモデリングにおける最も重要な決定です。
仕組み:埋め込みドキュメントと親ドキュメントは同じBSONドキュメントに保存され、同じライフサイクルを共有します — 親ドキュメントが更新されると埋め込みドキュメントも上書きされ、親ドキュメントがクエリされると埋め込みドキュメントも一緒に返されます。参照ドキュメントは独自の_idとライフサイクルを持つ独立したBSONドキュメントです。一方の更新は他方に影響しませんが、クエリには追加の結合操作が必要です。
graph TB
A[ドキュメント関係モデリング] --> B{データ特性}
B -->|1:1関係<br/>データセットが小さい<br/>一緒に頻繁に読み取る| C[埋め込み ✅<br/>単一クエリで取得]
B -->|1:N関係<br/>Nが小さい<br/>単独でクエリされることが少ない| D[埋め込み ✅<br/>ネスト配列]
B -->|1:N関係<br/>Nが大きい<br/>別途クエリが必要| E[参照スタイル ✅<br/>独立コレクション]
B -->|N:N関係| F[参照スタイル ✅<br/>双方向ID配列]
B -->|サブドキュメントが頻繁に更新される| G[参照スタイル ✅<br/>ドキュメント全体の書き換えを回避]
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 1:1関係(ユーザー-住所) | 埋め込み(住所が頻繁に変更されない限り) | 単一クエリで全て取得 |
| 1:N関係(ユーザー-注文) | Nの値による: 小さい → 埋め込み;大きい → 参照 |
ドキュメントサイズ制限 |
| N:N関係(ユーザー-役割) | 参照(双方向ID配列) | 関係が複雑 |
| サブドキュメントが頻繁に更新される | 参照 | ドキュメント全体の書き換えを回避 |
| サブドキュメントを別途クエリする必要がある | 参照 | 別途クエリのパフォーマンス |
(1) 関係をモデリングする2つの戦略
graph TB
subgraph "埋め込みドキュメント(Embed)"
A1[usersコレクション] --> A2[ドキュメント1<br/>address: {<br/> city: Tokyo<br/> country: Japan<br/>}]
end
subgraph "参照スタイルドキュメント(Reference)"
B1[usersコレクション] --> B2[ドキュメント1<br/>address_id: ObjectId]
B3[addressesコレクション] --> B4[ドキュメント1<br/>city: Tokyo]
B2 -.->|検索| B3
end
(2) 戦略の選択
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 1:1関係(ユーザー-住所) | 埋め込み(住所が頻繁に変更されない限り) | 単一クエリで全て取得 |
| 1:N関係(ユーザー-注文) | Nの値による: 小さい → 埋め込み;大きい → 参照 |
ドキュメントサイズ制限 |
| N:N関係(ユーザー-役割) | 参照(双方向ID配列) | 関係が複雑 |
| サブドキュメントが頻繁に更新される | 参照 | ドキュメント全体の書き換えを回避 |
| サブドキュメントを別途クエリする必要がある | 参照 | 別途クエリのパフォーマンス |
(3) 埋め込みドキュメントの例
// === 埋め込み:ユーザー + 複数の住所 ===
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice",
email: "alice@example.com",
addresses: [ // ネストされた配列
{
type: "home",
street: "123 Main St",
city: "Tokyo",
country: "Japan",
zip: "100-0001"
},
{
type: "work",
street: "456 Office Rd",
city: "Tokyo",
country: "Japan",
zip: "100-0002"
}
]
});
// === 検索:東京に住むユーザー ===
db.users.find({ "addresses.city": "Tokyo" });
▶ サンプル 6:参照スタイルドキュメントの例
// === 参照スタイル:ユーザー + 注文(多対一)===
// usersコレクション
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice",
email: "alice@example.com"
});
// ordersコレクション
db.orders.insertMany([
{
_id: ObjectId("507f1f77bcf86cd799439012"),
user_id: ObjectId("507f1f77bcf86cd799439011"), // 参照
items: ["PHONE-001", "CASE-002"],
total: NumberDecimal("649.98"),
createdAt: new Date()
},
{
_id: ObjectId("507f1f77bcf86cd799439013"),
user_id: ObjectId("507f1f77bcf86cd799439011"), // 参照
items: ["LAPTOP-001"],
total: NumberDecimal("1299.99"),
createdAt: new Date()
}
]);
// === $lookupで結合クエリを使用(SQL JOINに類似)===
db.users.aggregate([
{ $match: { name: "Alice" } },
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "user_id",
as: "orders"
}}
]);
9. 総合実践演習:ECユーザードキュメントの設計
(1) シナリオ要件
ECプラットフォームのユーザードキュメントを設計します。要件:
- 基本ユーザー情報(氏名、メールアドレス、登録日)
- 複数の配送先住所(埋め込み)
- 設定(言語、通貨、通知)
- 統計情報(注文総数、売上総額)
- フォローリスト(他のユーザーを参照)
- プロフィール画像(GridFS参照)
(2) ドキュメント設計
// === 包括的なユーザードキュメント ===
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
// === 基本情報 ===
email: "alice@example.com",
username: "alice_chen",
displayName: "Alice Chen",
phone: "+81-90-1234-5678",
// === 認証 ===
passwordHash: "$2b$10$...", // bcryptハッシュ(平文ではない)
emailVerified: true,
twoFactorEnabled: false,
// === 設定(ネストされたドキュメント)===
preferences: {
language: "ja",
currency: "JPY",
timezone: "Asia/Tokyo",
notifications: {
email: true,
sms: false,
push: true,
marketing: false
}
},
// === 配送先住所(ネストされた配列)===
addresses: [
{
addressId: ObjectId("..."),
type: "home",
isDefault: true,
street: "1-2-3 Shibuya",
city: "Tokyo",
prefecture: "Tokyo",
zip: "150-0002",
country: "Japan",
phone: "+81-90-1234-5678"
}
],
// === 統計(頻繁に更新されるフィールドは分割推奨)===
stats: {
totalOrders: 25,
totalSpent: NumberDecimal("125430.50"),
averageRating: 4.7,
lastOrderAt: ISODate("2026-06-15T10:30:00Z")
},
// === ウォッチリスト(参照スタイル)===
followingIds: [
ObjectId("507f1f77bcf86cd799439012"),
ObjectId("507f1f77bcf86cd799439013")
],
// === プロフィール画像参照(GridFS)===
avatarFileId: ObjectId("507f1f77bcf86cd799439099"),
// === メタデータ ===
createdAt: ISODate("2025-03-01T10:00:00Z"),
updatedAt: ISODate("2026-07-01T15:23:00Z"),
lastLoginAt: ISODate("2026-07-01T10:00:00Z"),
isActive: true,
role: "customer" // customer | admin | moderator
});
(3) Mongooseスキーママッピング
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
username: { type: String, required: true, unique: true, index: true },
displayName: { type: String, required: true },
phone: { type: String },
passwordHash: { type: String, required: true, select: false },
emailVerified: { type: Boolean, default: false },
twoFactorEnabled: { type: Boolean, default: false },
preferences: {
language: { type: String, default: 'en' },
currency: { type: String, default: 'USD' },
timezone: { type: String, default: 'UTC' },
notifications: {
email: { type: Boolean, default: true },
sms: { type: Boolean, default: false },
push: { type: Boolean, default: true },
marketing: { type: Boolean, default: false }
}
},
addresses: [{
addressId: { type: mongoose.Schema.Types.ObjectId, default: () => new mongoose.Types.ObjectId() },
type: { type: String, enum: ['home', 'work', 'other'], default: 'home' },
isDefault: { type: Boolean, default: false },
street: { type: String, required: true },
city: { type: String, required: true },
prefecture: String,
zip: { type: String, required: true },
country: { type: String, required: true },
phone: String
}],
stats: {
totalOrders: { type: Number, default: 0 },
totalSpent: { type: mongoose.Schema.Types.Decimal128, default: 0 },
averageRating: { type: Number, default: 0 },
lastOrderAt: Date
},
followingIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
avatarFileId: { type: mongoose.Schema.Types.ObjectId },
role: { type: String, enum: ['customer', 'admin', 'moderator'], default: 'customer', index: true },
isActive: { type: Boolean, default: true, index: true }
}, { timestamps: true });
❓ よくある質問
_id: ObjectId()またはUUIDを指定できます。firstNameとFirstNameは異なるフィールドです。MongoDBは厳密に大文字と小文字を区別します。全体で一貫した命名規則を使用することを推奨します(キャメルケースを推奨)。_idフィールドは変更できますか?_idはドキュメントの一意識別子で、変更すると参照関係が壊れます。ビジネス主キーが必要な場合(例:注文番号)、_id: "ORDER-2026-07-001"のようなカスタム主キーを使用できますが、クエリパフォーマンスが低下します。0.1 + 0.2 = 0.30000000000000004の問題が発生します。{ 氏名: "Alice" }は有効ですが、デバッグ、ログ、サードパーティツールでのサポートが不十分で、運用上の問題が発生する可能性があります。全体で英語を使用することを推奨します。📖 まとめ
- BSONはMongoDBのバイナリ保存形式で、JSONよりも多くのデータ型(Date、Decimal128、Binaryなど)をサポート
- ObjectIdは12バイトの一意識別子で、タイムスタンプ、ランダム値、カウンターで構成
- BSONは12種類以上のデータ型をサポートし、Double、Int32、Long、Decimal128の違いを理解することが重要
- フィールド名にはキャメルケースを推奨。
$で始まる名前や.を含む名前は避ける - 単一ドキュメントのサイズ制限は16 MB。大きなファイルにはGridFSを、長いテキストにはElasticsearchを使用
- 埋め込み関係は1:1や少数の1:N関係に適し、参照関係は複雑な関係に適している
- Mongooseスキーマはドキュメント構造を定義し、BSON型変換を自動的に処理
📝 練習問題
-
基礎問題(⭐):mongoshに製品ドキュメントを挿入し(String、Number、Date、Array、Object、Booleanの6つ以上のフィールドを含む)、
findOne()でクエリしてフィールド型を確認してください。 -
基礎問題(⭐):Node.jsスクリプトを書き、Mongooseを使用してユーザースキーマを作成し(
Decimal128型のbalanceフィールドとBuffer型のavatarフィールドを含む)、データを挿入し、ObjectIdのタイムスタンプを出力してください。 -
応用問題(⭐⭐):ブログ記事のドキュメント構造を設計し(5つ以上のフィールド)、
insertManyで5つの記事を挿入し、埋め込みコメント配列の設計を示してください。 -
応用問題(⭐⭐):スクリプトを書き、100件のドキュメントのObjectIdからタイムスタンプを抽出し、日付でグループ化して各日のドキュメント数を集計してください。
-
応用問題(⭐⭐):埋め込みと参照ドキュメントのクエリパフォーマンスを比較してください。100万件のレコードを使用し、「ユーザー-注文」関係を埋め込みと参照の両方の保存方法で保存し、
$lookupとネストクエリで応答時間を測定してください。 -
チャレンジ(⭐⭐⭐):GridFSを使用して、最大100MBのファイルをアップロード/ダウンロードできるAPIを実装し、チャンク保存メカニズムを検証し、ダウンロードの進捗追跡を実装してください。