对于12306,我的完整技术方案
12306主要就是卖票比较复杂,注册登录之类的功能就不说了。
有网友说,12306卖票系统比航空复杂,因为要分段卖,航空只有起点和终点,火车中间还有好多站。不过好消息是,这些站在售票时是连续的,不会出现1张票跳着站买的情况,这样就可以把一张票拆成N张只有起点和终点的票,和航空售票一样了。
卖票分为两部分,查询和购买。12306目前提供了单独的查询,我觉得这个其实挺好的,至少有读写分离的思想;不过延迟10分钟的数据,肯定没什么人愿意用,大家还是要挤进购买系统查询。单独的查询相当于摆设了,没有发挥作用。要让查询系统有效,尤其是春运期间,延迟应该在30秒之内。
查询剩余票数设计:
查询的特点是按照车次信息或者时间查,车次和时间一般都不会变,因此在设计时,可以把页面分成两部分。一是匹配的车次列表信息,如北京到上海有哪些车,这个结果可以用CDN缓存。车次基本是固定的,缓存设置为10分钟应该就能满足需要。不占用负载。在浏览器拿到这个页面后,通过异步请求,根据每趟车的编号二次查询剩余票数等实时数据,合并显示。
车次查询时,把车次信息分库存储,并作冗余存储。好比这个库提供所有北京->xxx的查询,这个库提供上海到xxx的查询,这个库提供广州到xx的查询,剩下的在一个大库中等。车次变化很小,库可以分散存,而且可以冗余存储。用内存表也行,总数据量也不多,性能上没什么可说的。
对于实时票数,确实是比较困难的,网上很多方案都不对,没有考虑中间站的问题。剩余票数缓存并不好做。我的想法是,提前分好票,然后用单独的数据结构做缓存。
例如,对于G113高铁,共有8站:北京、德州、济南、徐州、南京、常州、苏州、上海。假设它有两千张票,座位啊卧铺啊啥的。在发票前,创建新表20120113_G113,然后把2000张票提前插入到表中,每个票都有一个本表内唯一的数字编号。表结构基本上就是:编号、起始站、终点站、座位类型、车厢、座位号、乘客姓名、乘客证件号码、车票状态等实际业务模型需要。初始化时,起点站就是北京(根据顺序存储为1),终点站就是上海(根据顺序存储为8),乘客信息空着表示尚未绑定乘客,车票状态置为“待出售”。
这样我们要查询2012年1月13号G113 济南->南京 的剩余票数时,就查询20120113_G113起始站编号大于等于3并且小于等于5,并且状态为“待出售”的记录数就行了。
每张表2000条数据,对于非春运时节,性能完全足够。对于春运时节,非繁忙路段,性能应该也足够了。对于春运繁忙期,继续看下面的。
车票出售基本流程:
用户选择车票并要求购买,系统锁定票并标记状态为“锁定中”,让用户付款等。完成后标记车票为“已经发售”,并且更新用户信息到车票的持有人信息字段中。此票不再出售。
对于中间站购票,假设用户购买了 济南->南京 ,前面流程不变。但完成出票后,将车票起始和终点站改为“济南”和“南京”,并且自动插入两张新票可用。一张是“北京->济南”,一张是“南京->上海”,通知队列更新相关缓存。相当于车票做了自动分裂。这样我们设计时只需要把一张票设计为“只有起点和终点”就行了。
更高效的剩余票数查询设计:
数据库的count操作并不快,因此对于繁忙季节的繁忙表,每次都count是铁定不行的。我想到的一个办法是:把上面提到的预售车票表加载到内存中。我们用一个64位long类型数字表示一张车票,每趟车的每种座位类型是一个long数组。
对于每个long数字,前面的32位用来顺序存储32个车站(假设一趟车最多有32个站,没查过,不行可以放长点),每一位标记“车票是否包含此站”。如G113 济南到南京的票(车站顺序为第3到第5站),long数字的第2到第4位设置为1,其他前32位设置为0。后面的32位用来表示车票在车票出售表中的唯一编号。这样根据一个long类型数字,我们就能表述一张票的发售信息了。
比如2012年1月13号G113,有软卧500张和硬卧1500张。那就需要两个数组。20120113_G113_软卧 对应一个长度500的long数组;20120113_G113_硬卧 对应一个长度1500的long数组。存储所有售票信息。
有点类似BitSet的感觉,对空间要求不高。我们可以做个系统把所有车票信息按照这种结构加载到内存中。对于实时查票,如查询2012年1月13号G113车次 济南->南京 的余票,就是遍历两个数组,检查位数为2和4的long数字有多少个,就直接获得了软卧和硬卧的剩余票数。对于现代计算机,这点遍历,时间是纳秒级的。
当车票被出售后,从long数组中删除票信息,比如先置为-1表示已经无效。再用后台线程实际删除(避免写冲突,将删除延迟到重建一个数组所消耗的多少纳秒内刚好没有写请求的时间段中)。如果long数组长度为0,那就是没有车票了,直接返回0;用户再怎么刷,也不会干扰数据库。
更高效的剩余票数查询方案的扩展性和容错性:
本身车票是按照车次划分的,同时也有时间维度,横向扩展不存在任何问题。
long数组可以根据数据库票务信息重新构造,而且成本不高(一条“select * from xxx where 状态=待发售” SQL语句)。在硬件故障,扩容机器,或是发现数据不一致时,重新构造数组就行。而且可以从后台异步做。扩展性和容错性都不是问题。
售票交易与锁票:
用户查询到有票后,填写要购买的票数,提交。好比购买两张硬卧,流程如下:
1. 在20120113_G113_硬卧long数组中随机获取符合要求的顺序的两个long数字,并将其从数组中删除;这个速度很快,纳秒级;
2. 根据long数字后32位,从数据库中锁票。如果全部锁定成功,设置票为预定状态,更新用户预定信息,锁票时间等。然后记录日志等其他相关操作。如果锁票失败,说明在纳秒级的时间内,票还是有冲突,购票失败,直接返回报错。锁票就是数据库行锁,sql中的 select for update nowait。
3. 页面提示错误,或者进入下一步交易流程,如网银支付等。
在整个过程中,我们看到,用户请求就算集中爆发,事务的冲突性也能降低到“随机获取的long数组值刚好一样 + 在cpu执行2000个for循环的可能百万分之一秒内刚好同时提交”。我觉得冲突概率应该很低很低。一趟车2000个车票,1:100比例也就20万人同时抢,20万人同1秒提交cpu也算的过来。而实际上,怎么可能一秒钟有20万人同时抢一趟车的票哪……
在整个过程中,大部分请求都被long数组消耗完后,直接检查long数组长度为0,提示无票拦截。进入数据库阶段的,也就是比实际的票数多一点点的有效订单而已。
回票:
中间站买票的,在预定成功后,车票自动分裂,分裂的票可以通过队列调度实时的回到long数组中,继续服务。
预定后不买的,可以通过预定时间的超时检查,后台做个线程,让票回归。
欢迎讨论。
我的微博: http://weibo.com/guzzframework