前言
受到中文社区《电商参考架构第二部分:库存优化方法》启发,想到了去年做过类似的电影票预定系统,如果用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: ,
name: "",
city: ""
location: [, ],
comments: ""
}
例如:
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: ,
cinema_id: <ObjectID(cinema_detail._id)>,
name: ,
seat:
{
row1: [],
row2: [],
row3: [],
: []
}
comments: ""
}
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: ,
name: "",
director: "director name"
actor: []
comments: ""
}
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: ,
cinema_id: <ObjectID(cinema_detail._id)>
movie_id: <ObjectID(movie_detail._id)>,
theater_id: <ObjectID(theater_detail._id)>,
start_time: ,
end_time: ,
comments: ""
}
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: ,
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如何构建一个电影票系统,但距离生长系统还是有一定的距离,仍有很多其他的技术点需要讨论,可以延伸开的还有,下单失败,过期未付款,数据唯一性等问题。
关于作者:
杨成虎,阿里巴巴集团技术专家,擅长通过NoSQL存储系统、Cache系统去解决海量数据的互联网问题。2009年加入阿里巴巴,先后开发了阿里的小文件系统,KV存储系统,负责阿里Tair系统的开发与架构设计。2013年至今主导研发了阿里云分布式缓存服务OCS,目前仍致力于NoSQL产品的云服务化工作。
非常好的分享。
感谢分享