MongoDBでECサイトを実運用する3つのテクニック

こんにちは。Tokyo Otaku Mode CTOの関根です。

Tokyo Otaku Modeでは、2013年8月からotakumode.com上にカート機能を追加し、決済までをワンストップでできる海外向けECサイトをスタートしました。

どういうシステム構成でotakumode.comが運用されているかを聞かれた時、「Node.js + MongoDBです」と答えると、エンジニアの皆さんは一様にびっくりします。特に、MongoDBをメインDBに使っていることに一番驚かれます。信頼性やNoSQLに対するライトなイメージなど、ECサイトのプロダクションとして使うことに疑問を持っている方が多いようです。
しかし、十分実用に耐え、日々機能追加が入り成長し続けるスタートアップの環境で、実際に1年間運用してきたECサイトがここにあることも事実です。

そこで今回は、MongoDBを使ってECサイトを運用するための実用的な3つのテクニックを公開したいと思います。

1. トランザクション問題を回避する

MongoDBを知っている方なら、MongoDBを使ってECサイトを構築することに違和感を感じると思います。
キャッシュやログではなく、実データのストアにMongoDBを利用しているECサイトは実際はほとんどないのではないでしょうか。
それはMongoDBが速度やスケーラビリティーを重視していて、RDBMSでは一般的なトランザクション機能がサポートされていないことが大きいと思います。

カートに商品を入れて、在庫を1つ減らす“ という基本的な仕様をMongoDBの特性を考えないで実装すると

  • カートに入ってるけど在庫が減らない
  • カートに入ってないのに在庫だけが減っている

という、とんでもないバグが普通に起ります。

MongoDBを使ってAtomicな処理をするには、ちょっとしたコツが必要となります。

MongoDB, E-commerce and Transactions

当時の10gen社(現在のMongoDB社)に籍を置くSteve Franciaさんが2011年12月に “MongoDB, E-commerce and Transactions“ というタイトルで公開したスライドには、MongoDBでECの機能を実装する際のヒントが書かれています。

What about transactions?

MongoDBでは、1つのドキュメント (RDBMSで言うところの1レコード) へのupdateに関してのみ、唯一atomicが保証されます。この仕様を常に意識することがとても重要です。
別コレクションにまたがるドキュメントのupdateや、同じコレクション内の複数のドキュメントへのupdateもatomicが保証されません。
つまり、特定の1ドキュメントへのupdate(findAndModify)を上手く利用してトランザクションを実現することになります。

Commerce is ACID In Real Life

スライドでは、このようなトランザクション周りの仕様を踏まえた上で、現実世界での買い物がACIDな特性をもったものなのだから、そのモデルを応用することを説明しています。

  1. I go to Barneys and see a pair of shoes I just have to buy.
  2. I call “dibs” (by grabbing them off the shelf).
  3. I take them up to the cash register and purchase them:
    • Store inventory has been manually decremented.
    • I pay for them with my trusty AmEx.
  4. If all goes according to plan, I walk out of the store.
  5. If my card was declined, the shoes are “rolled back”
    … out onto the shelves and sold to the next customer who wants them.
  1. 買おうと思っていた靴を見にBarneysへ行く
  2. 棚から商品を取る
  3. 商品をレジに持って行き購入する
    • お店の在庫がひとつ減る
    • 信頼できるクレジットカードで支払う
  4. 全て滞り無く進めば、商品を持ってお店をでる
  5. もしカードが通らなければ、靴は”roll back”され棚に戻り、同じ靴は次のお客さんが買う

ここで言うトランザクションの対象は靴です。
2人のお客さんが同じ靴を同時に手に取れないこと、お店の在庫は買われたタイミングで物理的に減ること、手に取ったものの結局買われなかった靴は棚に戻り、再び販売されること。
靴(在庫)を中心に流れをとらえると、物理的な靴が排他的に取り扱われることがわかります。

このモデルを応用して、在庫に該当するドキュメントを1在庫1ドキュメントになるよう設計すれば、MongoDBのトランザクション問題が解決できます。
倉庫に100個の在庫があれば、100個のドキュメントが存在することになります。

モデル概要図

靴というProductがあり、サイズ(や色)ごとにそれぞれSKUがあり、SKUごとにStockが在庫数分結びつきます。

Stockにはstate(状態)があり、availableが棚に並んでいる状態。ここから手に取るとin_cart、購入が完了するとorderedとstateが移り変わり、ユーザーが排他的にStockを触ることでトランザクションを機能させます。

2. スキーマをしっかりと設計する

スキーマレスは便利だけど不便

MongoDBのメリットしてスキーマレスが上げられます。スキーマレスというのは、事前のデータ設計なしに、動的にデータを入れることができるということです。
RDBMSで言えば、フィールド名もデータ型も違うレコードが同じテーブルに同居しているいる状態です。

スキーマレスを活用することで、以下のようなドキュメントを同じコレクションに保存することができます。

本をあらわすドキュメント
1
2
3
4
5
6
7
8
9
10
11
12
{
  _id: ObjectId('53d7beec4e32fd3514fbd027'),
  type: 'book'
  name: 'New Book'
  description: '............',
  price: 48.99
  details: {
    isbn: '.................',
    author: '..............',
    size: 'Paperback'
  }
}
DVDをあらわすドキュメント
1
2
3
4
5
6
7
8
9
10
11
12
{
  _id: ObjectId('53d7beec4e32fd3514fbd028'),
  type: 'dvd'
  name: 'New DVD'
  description: '............',
  price: 60.99
  details: {
    director: '.................',
    actor: '..............',
    media: 'dvd'
  }
}

商品として共通の項目を持ちながら、detailsに関しては商品の特徴にあわせて構造を変えています。
これをRDBMSでやろうとすると、book用detailテーブルとdvd用detailテーブル(種類が増えればその分だけ)を用意して、商品種類によって結合するテーブルを決め、商品idでJOINして…と、MongoDBと比べるとどうしても複雑になりますのでMongoDBを使うメリットの一つだと言えそうです。

しかし、こういう設計もデータをとりあえず保存しておくだけであれば問題がおこりませんが、実際のサービスでは特定の項目で検索をしたり、取り出したデータを加工して表示する必要があります。

上記の例で、本のISBNで検索をかけようと思ったら、

1
db.products.find({type:'book', "details.isbn":"xxxxxxxxx"});

こういうクエリを発行することになります。スキーマレスにもかかわらず、検索をする際には事前にどんなプロパティがあるかを知っている必要があります。
さらに、detailsを展開して画面に表示しようとした時、どういったプロパティが入っているかわからないと最適なUIで表示することもできなくなります。

一見、MongoDBの特徴をうまく利用したいい設計のようでも、実運用では使いにくいことが多いと思います。

ベターな設計は基本reference、データが小さく検索性を考えればembed

MongoDBのスキーマ設計では、別の意味を持ったデータを一つにしてしまうか(embed)、別のコレクションとして分け、IDだけを埋め込む(reference)かがよく議論になります。

商品とSKUの関係で説明すると、

  1. embed

    productコレクション(embed)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    {
      _id: ObjectId("53d84d8dbef6458e5cf693ed"),
      name: 'New T-shirts'
      skus:[
        {
          size: 'S',
          color: 'black'
        },
        {
          size: 'M',
          color: 'white'
        }
      ]
    }
    
  2. reference

product, skuコレクション(reference)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// product
{
  _id: ObjectId("53d84d8dbef6458e5cf693ed"),
  name: 'New T-shirts',
  skus: [
    ObjectId("53d84e156a96245237d03d35"),
    ObjectId("53d84e156a96245237d03d36")
  ]
}

//productに参照されるsku
[
  {
    _id: ObjectId("53d84e156a96245237d03d35"),
    size: 'S',
    color: 'black'        
  },
  {
    _id: ObjectId("53d84e156a96245237d03d36"),
    size: 'M',
    color: 'white'
  }
]

こういう違いになります。

MongoDBはドキュメントあたり最大16MBという制限があるので、どこまでもドキュメントが大きくなるような設計はNGです。

また、データがインサートされた時に少し大きめにディスクサイズを占領し(padding)、アップデート時に多少データサイズが大きくなってもpadding領域分はパフォーマンスを落とさずに更新できるように設計されているので、このpadding領域を超えるサイズでアップデートされるとフラグメントが発生しパフォーマンスが落ちてしまいます。

16MB以内に抑え、padding分以上増えないように設計しようとすると、基本的にはreferenceを選択することになります。

referenceの懸念点として、productとそれに含まれるskuを全て取得しようとすると、product数 + productに含まれるsku数分のクエリが発行され、パフォーマンスが悪くなることが予想されます。
これについては、運用の経験上、MongoDBのRead性能が充分高いのと、Node.jsを利用することでskuに対するクエリ発行を並列で処理できるので、全く問題ありません。

それでもreference方式だとreference先のコレクションに保存されているデータを簡単に検索することができません。
color: whiteのskuを持っているproductを検索しようとすると、skuの中から該当の商品があるかを検索し、ヒットしたskuを含むproductを探すことになり、対象ドキュメント数が多い場合はパフォーマンス低下になります。

mongo shell
1
2
3
4
5
var ids = [];
db.skus.find({ color: 'white' }, { _id: 1 }).forEach(function(o){
  ids.push(o._id); //ここの件数が多いとパフォーマンス低下になる
});
db.products.find({ $in: ids });

あらかじめ、reference先のデータを検索にすることがわかっている場合は、検索要素をembedすることで対応します。

product, skuコレクション(reference)
1
2
3
4
5
6
7
8
9
10
// product
{
  _id: ObjectId("53d84d8dbef6458e5cf693ed"),
  name: 'New T-shirts',
  skus: [
      ObjectId("53d84e156a96245237d03d35"),
      ObjectId("53d84e156a96245237d03d36")
    ],
  colors: ['black', 'white']
}
mongo shell
1
db.skus.find({ colors: "white" });

実際にどういうふうにそのドキュメントを利用するか、どういう構造だったら最も効率よくデータが取得できるかを考えて調整することになると思いますが
基本はreference、データが小さく検索性を考えればembedです。

3. 強力な集計エンジンAggregationを活用する

MongoDBにはAggregationという強力な集計機能があります。
Tokyo Otaku Modeでは、商品が売れた時にいろいろな角度でデータを集計するため、集計用のログ形式に整形して保存しています。

商品や購入者といった基本的な情報以外に、どういう経路で購入されたか、購入者はどういう経路でユーザーになった人なのか、商品の仕入先はどこの会社か、買い付け担当は誰かなど、様々な切り口の情報が含まれています。

今でこそボリュームが増えていますが、スタート時はもっと少ない情報でした。
サービスを運営していく中で、売上と一緒に取りたい情報が増えていき、その都度データを拡張してきました。

以下、実際に利用しているログのサンプルデータです。

Sales log例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
{
  "_id" : ObjectId("50d7944f41d0cdf6170002e9"),
  "signup_channel" : "site B",	
  "channel" : "site A",
  "ua" : "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 ...",
  "payment_system" : "paypal",
  "order" : ObjectId("49d8944441d0cdf6170003c6"),
  "price" : 78.99,
  "team" : "teamA",
  "campaign" : "some_campaign",
  "shipping_address" : {
    "name" : "Name",
    "tel" : "",
    "line2" : "",
    "line1" : "some street 1",
    "city" : "city",
    "state" : "CA",
    "country" : "US",
    "zip" : "0000000",
  },
  "buyer" : {
    "_id" : ObjectId("4268dd889efc54834d000011"),
    "name" : "buyer A"
  },
  "supplier" : {
    "_id" : ObjectId("52df6a238d7fed76813edc10"),
    "name" : "supplier A"
  },
  "user" : {
    "_id" : ObjectId("48d893426d2edd7059000544"),
    "name" : "Name",
    "gender" : "female",
    "birthday" : "01/01/1990",
    "cohort_key" : "some cohort",
    "repeat_cnt" : 0,
    "created_at" : ISODate("2014-05-30T06:40:02.425Z")
  },
  "discount" : {
    "total_amount" : 5,
    "coupon" : {
      "_id" : ObjectId("50d5b4a92783a9435e000844"),
      "name" : "TOM Coupon",
      "discount" : ObjectId("49d893426d2ed7705700053c"),
      "campaign" : "some campaign",
      "amount_off" : 5,
      "percent_off" : null
    },
    "point" : 0
  },
  "labels" : [
    {
      "_id" : ObjectId("4918a3d15f46510f94040e01"),
      "name_en" : "Figures"
    },
    {
      "_id" : ObjectId("5119a3d15f46510a94020814"),
      "name_en" : "supplier A"
    }
  ],
  "sku" : {
    "_id" : ObjectId("5255e18beac9ad6b59083040"),
    "title" : "SKU A",
    "price" : 78.99
  },
  "product" : {
    "_id" : ObjectId("5355e0c98d980e5126000053"),
    "title" : "Product A"
  },
  "sales_at" : ISODate("2014-05-30T06:44:20.366Z"),
  "updated_at" : ISODate("2014-05-30T06:44:31.299Z"),
  "created_at" : ISODate("2014-05-30T06:44:31.298Z"),
}

スキーマ設計のコツとしては、例えばproduct別の売上が見たいと思って、product情報を追加したいと思った時に、ユニークとなる_id以外に、人が見てわかる名前やタイトルのようなものも一緒に保存することです。
集計して数字を出すことだけを考えると、ユニークな値があれば済みますが、

1
2
3
4
5
6
7
8
9
10
[
  {
    _id: "5355e0c98d980e5126000053"
    price: 2345.22,
  },
  {
    _id: "5355e0c98d980e5126000054",
    price: 98.00
  }
}

このような結果が出ても何のことかわからないので、あらかじめ含めておいたstringのデータを使って

1
2
3
4
5
6
7
db.saleslogs.aggregate([
  { $group: {
    _id: "$product._id", //まとめる対象はここ
    title: { $last: "$product.title"} //ここは人間向け
    sales: { $sum: "$price"}
  }}
])

こう問い合わせることで、

1
2
3
4
5
6
7
8
9
10
11
12
[
  {
    _id: "5355e0c98d980e5126000053"
    title: "Product A"
    price: 2345.22,
  },
  {
    _id: "5355e0c98d980e5126000054",
    title: "Product B"
    price: 98.00
  }
]

集計結果だけで速報データを作成することができます。

その他、以下の様な簡単なAggregationクエリーで、さまざまなレポートを出すことができます。

  • ある月の日別売上を出す

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    db.saleslogs.aggregate([
      {$match: {$gte: ISODate('2014-05-01'), $lt: ISODate('2014-06-01')}},
      {$group:{
        _id: {
          year: {$year: "$sales_at"},
          month: {$month: "$sales_at"},
          day: {$dayOfMonth: "$sales_at"}
        },
        sales:{$sum: "$price"}}}
    ])
    
  • 流入経路別売上を出す

    1
    2
    3
    
    db.saleslogs.aggregate([
      {$group:{ _id: "$channel", sales:{$sum: "$price"}}}
    ])
    
  • 国別売上げランキングを出す

    1
    2
    3
    4
    
    db.saleslogs.aggregate([
      {$group:{ _id: "$shipping_address.country", sales:{$sum: "$price"}}},
      {$sort:{sales: -1}} // 売上を降順でソート
    ])
    
  • ラベル(商品属性)別の売上げ出す

    1
    2
    3
    4
    5
    
    db.saleslogs.aggregate([
      {$unwind: "$lables"}, //配列データを分解する
      {$group:{ _id: "$labels", sales:{$sum: "$price"}}},
      {$sort:{sales: -1}}
    ])
    

今回紹介したのはほんの一例で、こうした開発を日々行っています。
Tokyo Otaku Modeでは実践的なNode.js + MongoDBを使った開発に興味があるエンジニアを募集しています。ご興味がありましたら、こちらからご応募ください。