12306 一度成为技术圈的评论热点,期间被鄙视过, 也被理解过。在我 2.5 年的技术生涯中, 至少 读到过 8 篇关于 12306 的文章,当然,最早的文章是骂 12306 的。今年的春节也不出意外读到了关于 12306 的文章,当然是对 12306 的理解与包容,甚至是赞美。前面两个春节读到 12306 的文章并没有 引发我的思考,而这个春节,可能由于我将近 1 个月未上班的原因,我倒是认真思考起 12306 的业务 模型,进而在头脑中有了这个初步设计。
本文的目的不是对 12306 作评价,而是基于本人对 12306 业务的理解,自己重新实现一套 12306 的系统设计。 本人对自己的这个设计方案比较满意,认为该方案可以很好的解决 12306 的性能瓶颈。
免喷声明
由于本人不是 12306 的工作人员,对铁道部的运行方案不是很了解,难免有些分析不当的地方,请专业人士批判指出,请圈外人士勿喷。
本人对自己的文字表达能力相当有怀疑, 如有疑问, 欢迎交流。
业务的抽象与提取
数据结构,算法的设计
数据库设计
设计容易犯的错误
我们容易忽略 1 步骤,交换 2,3 步骤, 也是设计思路是变成了:
数据库设计
算法设计(数据结构被数据库决定了)
以后详细描述, 现在简单提取
Nothing but Motto: 交流的时候统一语言,计算的时候统一量纲。
业务流程
用户输入起点站,终点站,选择日期
根据输入找到相应的班次
查询每个班次的剩余票数
用户点击购买,填写乘客人信息
根据起点站,终点站和日期分配票
准备出票:
用户支付成功,出票
用户超时,票回收(回滚操作)
提取
系统的核心功能是查票,购票。票作为我们的商品, 我们需要根据具体情况来定义票的概念,合理化票的量纲。
火车票的最小单位是:1(座位)* 1(个站区间), 我们约定这个最小单位是
ticket
。后续提到票
都是指用户购买的票(n 个ticket
构造而成)。对于有座位的票,要统一票 id(反正我坐火车从来没有中途被要求换个位置)。对于这一点, 解决还是比较方便的, 对于同一座位, 放入一个票池子, 同时限制一张车票,只能从同一个池子里面去。
对于无座位的票, 统一放入一个票池子,或者分车厢放入几个票池子。
站票的最小单位是:1(个站区间),由于站票的无固定位置特性,站票的最小单位和座位票有区别,因此站票的数据应该跟着列车的站点走。
抽象
根据上述定义,我们可以构造一张 tickets
表,座位号为行,站区间为列。假设列车经过 ABCDEFG 7 个站点,上面有 5 个座位号,那么 tickets
表为(先忽略站票):
实例
火车经过 ABCDEFG 7 个站点, 小王买了一张 B 到 D 的票。
小王买了几张
ticket
?答:2 张,分别为
BC
和CD
区间的ticket
。假设小王买的票是 2 号座位, 请标记相关
ticket
。答: 如下图:
下图出票方式是否合理?
答: 不合理,其中红色的票使得
tickets
池碎片化。 后续只能出 2 张全程票(A➔G)。 如果红色的票分配到 1 号座位,后续还可以出 3 张全程票,可使收益最大化,12306 也是要赚钱的了。那这样是不是会使得位置特别不均匀呢?确实是的,坐过火车的人会注意到这点的。不过这 并不是什么问题,大家总是会自动向座位空空的地方移。但实际上,这根本不是问题, 这图上的位置不过是
逻辑位置
而已,把它跟实际物理位置
做个映射就行。 学过计算机的人都知道内存有逻辑地址
和物理地址
的概念,他们有个映射关系。综上所述:出票分配位置时应该从上向下遍历,找到第一个可用的。
这里不讨论我对有关设计的演进了,直接指出最终设计。
tickets 数据结构
我们观察上述图片的时候,有没有想过 白色区域代表 1, 着色区域代表 0, 就可以得到一个内存图。每个座位对应的行用一个 int64
就可以代替了(我们假设列车最多经过 65 个站点,这个假设应该是合理的,假设不正确也没关系, 不影响我们的算法)。
查询
假设小王要查询 B➔D 的票,占据第 2 和第 3 个区间。 我们只需要构建位置值 (后续称ticket_value
) 6, 分别和每一个位置的值做 位与
运算,如果结果为 6, 说明位置可用, 遍历一遍, 就可以得到剩余位置数。
购买
假设小王要购买 B➔D 的票,从可分配的位置里面选取第一个减去
数据库系统设计
trains
id
code
type
7
G77
高铁
8
D63
动车
sites
id
name
1
上海
2
济南
3
北京
site_train_xRef
site_id
train_id
site_nth
n_sro
1
7
0
3
2
7
7
3
3
7
9
3
site_nth 为 0 代表始发站。
n_sro 为站票剩余数目,跟着列车的站点走。
tickets
id
pos_id
n_remain
1 | 1A | int_max |
1 | 3F | int_max |
建议为每一辆列车建立一个
tickets
表, 这样也很容易分配到不同的机器上去查询。也可以在 tickets 中加入一个train_id
字段。
查票步骤
根据
site_train_xRef
找到符合要求的的列车班次。分别各列车计算出票值
ticket_value
。分别在找出的班次中检索余票, 判断该座位可用:
(n_remain & ticket_value) = ticket_value
。
锁定票
用户购买,生成订单后,锁定票。
解释步骤不如直接上代码:+
update tickets set n_remain = n_remain - ticket_value where id in
(select id from tickets where n_remain & ticket_value = ticket_value
order by id
limit 1);
和公司 CTO 讨论一下后发现上述 sql 不是个好方法:
上述语句的原子性不清楚,如果不是原子性, 那就是 bug 了。
如果上述 sql 是原子性的, 上述原子操作锁的粒度比较大, 性能有问题。
我们得出方案, 改成两步操作, 第二步如果失败, 就重试:
select id as oid from tickets where n_remain & ticket_value = ticket_value
order by id
limit 1;
update tickets set n_remain = n_remain - ticket_value where id = oid and n_remain & ticket_value = ticket_value;
其他业务场景暂时交给读者们了
本人仅仅给出了出票算法一种设计而已, 离实用性还差很远,铁道部的具体卖票措施也不知道。比如说会有站点限制什么的。