閱讀277 返回首頁    go 阿裏雲 go 技術社區[雲棲]


基於空間數據庫MongoDB實現全國電影票預定係統

前言

受到中文社區《電商參考架構第二部分:庫存優化方法》啟發,想到了去年做過類似的電影票預定係統,如果用MongoDB去做存儲支撐,那應該是怎樣架構的呢?本文的目的是為了更好的學習掌握MongoDB,所以某些設計上更偏向於功能的展示,在實際使用上要因地製宜的改變,合適才是最好的。

需求

電影票預定係統與電商係統非常類似,都可以抽象理解為商品的售賣。進一步的講電影票係統是電商係統的一個庫存特例場景:

  • 每個場次,每個座位,都隻有一個庫存
  • 每個訂單所預定的座位有鎖定狀態,在支付前對應的作為不能被再次購買
  • 訂單涉及到的座位要不全成功,要不全失敗
  • “全國”級的,數據容量不是太大問題,但性能上要支持水平擴展

PS:實際上的理論TPS並不高,目前全國5000家影院,假設平均8個影廳,每個廳200個位置,每個影廳6個場次,早中晚各3個高峰,每個高峰1個小時。計算得出TPS大概是:5000 * 8 * 6 * 200/ 3 / 3600 = 4400 TPS;但是設計上我們還是要保證性能的可水平擴展,否則怎麼體現MongoDB的特色呢?^-^

描述信息文檔結構

影院描述信息

保存最基本的影院信息,包括地理信息,名稱,_id為MongoDB由MongoDB自動分配

CinemaManager.cinema_detail

{
  _id: <ObjectID>,
  name: "<cinema name>",
  city: "<city name>"
  location: [<longitude>, <latitude>],
  comments: "<detail message>"
}

例如:

rs0:PRIMARY> db.cinema_detail.insert({ 
    "name" : "大時代電影院", 
    "city" : "杭州",
    "location" : [ 120.13, 30.16 ],  
    "comments" : "IMAX 4K,有停車位"  
});

因為影院信息的查詢一般都是按照城市和名稱,或者地理坐標檢索,所以這裏建立兩個索引

Index1:城市+名稱的複合索引,因為查詢電影院時一般都會指定城市名

rs0:PRIMARY> db.cinema_detail.ensureIndex({city:1, name:1})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 2,
    "numIndexesAfter" : 2,
    "ok" : 1
}

注意,這裏使用的是複合索引,所以針對 city + name的查詢,或者city的查詢是有效的,隻查找name字段是無法通過索引優化的。

Index2:地理坐標索引,用來應付"最近的電影院"類查詢

rs0:PRIMARY> db.cinema_detail.ensureIndex({location: "2d"})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 3,
    "numIndexesAfter" : 4,
    "ok" : 1
}

例如,查詢在杭州最近的某個電影院

rs0:PRIMARY> db.cinema_detail.find({city:"杭州", location: { $near: [1.0, 2.0] }}).pretty()
{
    "_id" : ObjectId("559a3ef8c6058dae1ac49ce8"),
    "name" : "大時代電影院",
    "city" : "杭州",
    "location" : [
        120.13,
        30.16
    ],
    "comments" : "IMAX 4K,有停車位"
}

影廳描述信息

theater_detail.cinema_id與cinema_detail._id集合形成references關係,通過cinema_detail._id可以快速找到所屬影廳的信息。另一個關鍵字段theater_detail.seat用來描述座位信息,每排所有的座位是一個數組,不同排可以有不同數量的座位。

CinemaManager.theater_detail

{
  _id: <ObjectID>,
  cinema_id: <ObjectID(cinema_detail._id)>, 
  name: <theater name>,
  seat: 
  {
    row1: [<seat valid>],
    row2: [<seat valid>],
    row3: [<seat valid>],
    <seat row>: [<seat valid>]
  }
  comments: "<detail message>"
}
rs0:PRIMARY> db.theater_detail.insert({ 
  cinema_id:ObjectId("559a3ef8c6058dae1ac49ce8"), 
  name:"IMAX廳", 
  seat:
  {
    row1: [1, 1, 1, 1], 
    row2: [1, 1, 1], 
    row3: [1, 1, 1, 1], 
    row4: [1, 1, 1, 1, 1], 
  },
  comments: "可容納哦xxx人,弧形熒幕"
})

rs0:PRIMARY> db.theater_detail.insert({ 
  cinema_id:ObjectId("559a3ef8c6058dae1ac49ce8"), 
  name:"中國巨幕廳", 
  seat:
  {
    row1: [1, 1, 1, 1], 
    row2: [1, 1, 1], 
    row3: [1, 1, 1, 1]
  },
  comments: "可容納哦xxx人,弧形熒幕"
})

建立索引

rs0:PRIMARY> db.theater_detail.ensureIndex({cinema_id:1})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 1,
    "numIndexesAfter" : 2,
    "ok" : 1
}

影片描述信息

影片說明

{
  _id: <ObjectID>,
  name: "<movie name>", 
  director: "director name"
  actor: [<actor name>]
  comments: "<detail message>"
}
rs0:PRIMARY> db.movie_detail.insert({
  name: "一路向西",
  director: "胡耀輝",
  actor:["張建聲", "王宗堯", "胡耀輝", "何佩瑜", "張暖雅", "郭穎兒"],
  comments: "該影片描寫的是當代香港社會中普通年輕人對“愛”與“性”的追求而逐漸改變的心路曆程的故事"
})

索引

rs0:PRIMARY> db.movie_detail.ensureIndex({name:1})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 1,
    "numIndexesAfter" : 2,
    "ok" : 1
}

影片放映文檔結構

放映信息包含放映時間段,放映影廳,票價。雖然Document結構可以做複雜的嵌套,但原則上期望Document盡量小,利用數據Shard,性能優化。所以在movie_schedule的設計上每個影片的每場放映獨立一個Document表達。

{
  _id: <ObjectID>,
  cinema_id: <ObjectID(cinema_detail._id)>
  movie_id: <ObjectID(movie_detail._id)>, 
  theater_id: <ObjectID(theater_detail._id)>,
  start_time: <ISODate>,
  end_time: <ISODate>,
  comments: "<detail message>"
}

movie_schedule的References關係較多,需要與電影院,影廳,電影三者分別建立關係。

db.movie_schedule.insert({
  cinema_id:ObjectId("559a3ef8c6058dae1ac49ce8"),
  movie_id:ObjectId("559b68f372b34f216246cb1d"),
  theater_id:ObjectId("559b625072b34f216246cb1b"),
  start_time: ISODate("2015-07-07T10:00:00.00Z"),
  end_time: ISODate("2015-07-07T12:00:00.000Z"),
  comments: "首映"
)}

db.movie_schedule.insert({
  cinema_id:ObjectId("559a3ef8c6058dae1ac49ce8"),
  movie_id:ObjectId("559b68f372b34f216246cb1d"),
  theater_id:ObjectId("559b625072b34f216246cb1b"),
  start_time: ISODate("2015-07-07T12:30:00.00Z"),
  end_time: ISODate("2015-07-07T14:30:00.000Z"),
  comments: ""
)}

還是建立一個複合索引,優化查詢某一電影院的某部影片(的某一影廳)上映信息

rs0:PRIMARY> db.movie_schedule.ensureIndex({cinema_id:1, movie_id:1, theater_id:1})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 1,
    "numIndexesAfter" : 2,
    "ok" : 1
}

PS:也可以建立相應的索引,用來優化某一時間段內的影片信息查詢,讀者自行思考

交易係統

至此,基本的信息文檔集合均已建立完成,一般的查詢需求都可以滿足了。接下來是重點:庫存售賣係統。抽象的來看,售賣係統就是對上訴所有集合的一個整合,外加一套庫存字段。我們認為一場放映就是一個主商品,每個座位可以認為是這個商品的SKU,每個SKU都是1份。

通過Reference關係結合movie_schedule與theater_detail,注意這裏引用了

{
  _id: <ObjectID>,
  movie_schedule_id: <ObjectID(movie_schedule._id)>
  theater_id: <ObjectID(theater_detail._id)>,
  seat:
  {
    row1: [2, 2, 2, 2], 
    row2: [2, 2, 2], 
    row3: [2, 2, 2, 2], 
    row4: [2, 2, 2, 2, 2], 
  }
}

注意,這裏不僅僅是Reference的引用關係,還複製了theater_detail.seat字段,每個seat都有一個庫存數字,因為在MongoDB中一個Document的操作是可以保證原子的,不需要對Collection加任何鎖。數字2並不是表示可以賣2次:

  • 數字2表示,可銷售
  • 數字1表示,已鎖定
  • 數字0表示,已售完

交易邏輯上可通過FindAndModify + $inc,原子性的修改庫存信息。其他的描述信息是否需要再次冗餘取決於具體的業務狀況了,具體問題具體分析。我本人更傾向於目前的數據結構方案,不做過多的冗餘,原因:

  1. 數據訂正複雜,多一個冗餘,多一份複雜
  2. 其他信息基本都是靜態數據,數據量又小,完全可以通過Cache技術解決讀取問題

先插入一個我們的商品

db.movie_item.insert({
  movie_schedule_id : ObjectId("559b6ee472b34f216246cb1e"),
  theater_id : ObjectId("559b625072b34f216246cb1b"),
  seat : 
  {
    row1: [2, 2, 2, 2], 
    row2: [2, 2, 2], 
    row3: [2, 2, 2, 2], 
    row4: [2, 2, 2, 2, 2], 
  }
})

索引

rs0:PRIMARY> db.movie_item.ensureIndex({movie_schedule_id:1})
{
    "createdCollectionAutomatically" : false,
    "numIndexesBefore" : 1,
    "numIndexesAfter" : 2,
    "ok" : 1
}

鎖定座位的動作,鎖定第4排的3號位置(從1開始計數)和鎖定第4排的2號位置:

db.movie_item.findAndModify({
  query: { "_id":ObjectId("559b790f72b34f216246cb22"), "seat.row4.2":2 },  
  update: { $inc: {"seat.row4.2":-1}},
  upsert: false
})

db.movie_item.findAndModify({
  query: { "_id":ObjectId("559b790f72b34f216246cb22"), "seat.row4.1":2 },  
  update: { $inc: {"seat.row4.1":-1}},
  upsert: false
})

分別鎖定了第4排3號(row4[2]),第4排2號(row4[1]),
注意,這裏是分兩次鎖定的,鎖定操作並不需要原子完成,否則會造成用戶鎖定失敗概率的上升。

rs0:PRIMARY> db.movie_item.find({_id:ObjectId("559b790f72b34f216246cb22")}).pretty()
{
    "_id" : ObjectId("559b790f72b34f216246cb22"),
    "movie_schedule_id" : ObjectId("559b6ee472b34f216246cb1e"),
    "theater_id" : ObjectId("559b625072b34f216246cb1b"),
    "seat" : {
        "row1" : [
            2,
            2,
            2,
            2
        ],
        "row2" : [
            2,
            2,
            2
        ],
        "row3" : [
            2,
            2,
            2,
            2
        ],
        "row4" : [
            2,
            1,
            1,
            2,
            2
        ]
    }
}

OK,交易成功以此類推,同時修改兩個庫存到0,這裏利用了findAndModify的原子特性

db.movie_item.findAndModify({
  query: { 
    _id:ObjectId("559b790f72b34f216246cb22"), 
    $and:[ {"seat.row4.2":1}, {"seat.row4.1":1}] 
  },  
  update: { 
    $inc: {"seat.row4.2":-1, "seat.row4.1":-1} 
  },  
  upsert: false
})

再查下集合看看:

rs0:PRIMARY> db.movie_item.find({_id:ObjectId("559b790f72b34f216246cb22")}).pretty()
{
    "_id" : ObjectId("559b790f72b34f216246cb22"),
    "movie_schedule_id" : ObjectId("559b6ee472b34f216246cb1e"),
    "theater_id" : ObjectId("559b625072b34f216246cb1b"),
    "seat" : {
        "row1" : [
            2,
            2,
            2,
            2
        ],
        "row2" : [
            2,
            2,
            2
        ],
        "row3" : [
            2,
            2,
            2,
            2
        ],
        "row4" : [
            2,
            0,
            0,
            2,
            2
        ]
    }
}

總結

一套全國級的電影票係統會比這複雜的多,本文的目的還是以教程為主,主要是說明MongoDB如何構建一個電影票係統,但距離生長係統還是有一定的距離,仍有很多其他的技術點需要討論,可以延伸開的還有,下單失敗,過期未付款,數據唯一性等問題。

最後更新:2017-04-01 13:37:07

  上一篇:go PostgreSQL 違反唯一約束的插入操作會產品HEAP垃圾嗎?
  下一篇:go MongoDB Kill Hang問題排查記錄