基於空間數據庫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,原子性的修改庫存信息。其他的描述信息是否需要再次冗餘取決於具體的業務狀況了,具體問題具體分析。我本人更傾向於目前的數據結構方案,不做過多的冗餘,原因:
- 數據訂正複雜,多一個冗餘,多一份複雜
- 其他信息基本都是靜態數據,數據量又小,完全可以通過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