基於MongoDB的全國電影票預定系統

chenfeng發表於2016-01-26

前言

受到中文社群《電商參考架構第二部分:庫存最佳化方法》啟發,想到了去年做過類似的電影票預定系統,如果用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如何構建一個電影票系統,但距離生長系統還是有一定的距離,仍有很多其他的技術點需要討論,可以延伸開的還有,下單失敗,過期未付款,資料唯一性等問題。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/15498/viewspace-1982585/,如需轉載,請註明出處,否則將追究法律責任。

相關文章