网上大家都在流传 如何更好的实现一个 12306 ,作为一个技术人员,碰到如此难得的机会,忍不住也想跟着忽悠一把。
纯粹从技术角度出发,来设计一个架构和实现方案是非常容易的。但我相信,真实的 12306 的架构师,需要考虑的不仅仅是技术实现,还有数不清的历史包袱,现实约束,甚至人为限制。所以,这里的描述,只是技术人员关起们来,自己的 YY ,大家姑且看看就行,别太当真。
12306 当前的问题,无非是僧多粥少加定时放票引起秒杀效应,造成拥堵。只要解决了这个问题,大家的意见就不会那么强烈了。
首先来看僧多粥少的问题。车票一共就那么多张,这个系统只需要考虑如何将这些票安全,公平的卖出去。至于是不是还有人没有买上票,这就不用技术人员考虑了。所以,我们的问题其实只有一个:如何解决拥堵问题。
在这个神奇的国度,每个地方都有拥堵问题,公交站台,景区门口,银行柜台,甚至食堂窗口,大家都用同一种办法来解决这个问题:排队。所以我一直很纳闷,12306 为什么一开始没有实现排队的功能,是领导拍了脑袋?还是有其它不为人知的原因?
废话少扯,还是说正经的吧。
Redis 用在这里,不仅仅是一个 NoSQL 存储工具,简直是一个完美的解决方案。下面我就来详细描述一下我想象中的一个架构:
1. LVS 接入,同时将 https 协议转成 http 协议。 按照网上流传的 2kw 的预算,F5 恐怕是用不起了,不过也没事,LVS 加硬件 SSL 卡,50w 预算绝对拿下。
2. varnish 反向代理。cache 住大部分的静态资源,每台机器 3w/s 的 rps 应该可以比较轻松的应对。即使按照 30w/s 的峰值能力计算,也就 10 台机器,30w 预算
3. Nginx 服务静态资源,有 2 台足够,6w 预算
4. Tomcat 服务动态请求,这个量会比较大。但可以从这几个方面入手去减少资源消耗:第一,使用 ajax 请求,而不是页面刷新,减少单个请求的 size;第二,做好频率检测和控制,对于部分用工具来刷的用户,甚至可以采取惩罚措施,以减少无谓的资源消耗。保守估计,20 台机器左右,这个数字要看领导们对于“用户体验”的要求程度。
5. Redis 服务器,96G / 台,价格也就 3w 出头。预算 10 台,将 2kw 注册用户全部加载到内存,将 2k 车次全部加载到内存,将每个车次的每个座位的状态都加载到内存,将每个在线用户都加载到内存,应该都还有内存剩余。为什么要写磁盘呢?大不了给 Redis 服务器配置双电源,再配一个电池!
6. DB 服务器。最重要的 db 服务器,“铁道部”的系统,如果不是 DB2 那就应该是 Orcale 了,应该是连硬件到软件一起买的解决方案吧。这个我没有用过,甚至都没有见过,所以不好估计预算了。如果用我们比较熟悉的 mysql,用户系统应该是一个单独的库;车次信息应该是一个单独的库,而且基本上是只读;车票信息应该有区分当前库和历史库,当前库用来存未售出的车票信息,或者近期刚刚售出的车票信息,数据量小,支持快速读写,以及更新,历史库用来存历史上的车票信息,存档性质;订单信息单独一个库,读写量都不会太大。除了车票信息的当前库建议用 SSD ,其它的库采用普通的硬件就行,预算不会超过 5w/台,按照 1M3S 的标准配置,总体的机器台数在 20 以内,预算 100w 左右。值得一提的是,车票信息的当前库,需要按照车次(甚至是车次+日期)进行分库分表。想提高实时性,提升用户体验,减少投诉?增加当前库的机器投入即可。
硬件设备应该就是这样了,接下来说说一些值得一提的实现细节。
当前 12306 系统一个很受人诟病的实现就是无法登录。用户打开登录页,输入了用户名密码,还耐心的填好了验证码,点击提交,再耐心的等了 30 秒,结果,弹出一个无比丑陋的对话框,说“当前访问用户过多,请稍后尝试”,太坑人了!以至于某公司甚至给做了一个“12306登录助手”出来。让用户登录进来,给他们能买到票的希望,是减少投诉的一个很重要的方面。这个其实一点也不难:将用户信息都加载到 Redis 内存,简单点,key 就是 email,value 就是密码加密串(亲,不能是明文哦!),用 cookie 而不是 session 进行身份验证,用 ajax 而不是刷新页面的方式提交数据和返回应答,这么一来,即使 2 kw 用户同时都登录进来,也只需要三五台 tomcat ,20分钟就搞定了。
用户登陆进来之后,不能立即就给出太多的输入框,让用户输入查询,那样对系统的消耗比较大,不划算。应该先让用户通过出发站和到站,查询好车次(单独的车次信息库,全部加载到内存供查询),选择好乘车日期,然后——点击“取号”按钮,拿一个号,进行排队。是不是很熟悉?去银行开卡,汇款啥的,不都是这么办的么!将每个“车次+发车日期”作为一个单独的队列进行排队,其实就是将火车站售票窗口给移动到互联网页面上而已!想提升用户体验?那就每个登录用户可以同时排 3 个队吧,比如我,想买 18 号或者 19 号的 K21,那我就同时在 “K21+18号发车” 和 “K21+19号发车” 2 个队伍里排着好了。
用户既然已经在排队了,那后台处理就很灵活了:根据 db 的承载能力,将用户有序的带到查余票和下订单的页面(后面命名为 “售票厅” 吧)即可。用户在排队页,用 ajax 请求定期轮询,server 端返回有 3 种:当还没有轮到该用户时,返回当前队列前面还有多少人,这个车次还剩多少张卧铺,多少张硬座等等,当然,这些信息都不是实时更新的,而是定期由后台线程或进程进行更新。读取的时候,只是一个缓存 get 操作而已。另一种就是已经轮到该用户,这个时候,给一个30秒或者60秒的订票时间窗口,要求在这个时间内完成查询和下单操作:查询的时候并不做任何的 lock 操作,而是仅仅显示 db select 的结果(这个地方,可能需要 select master 库),填写完票数,点击提交的时候,才做真正的 lock,随机选择座位,update db,update redis 。这个步骤是直接操作 db,不做任何的缓存,但因为是根据 db 的能力放进来的量,所以性能风险反而最小。还有一种状态,就是该用户的号已经过期,或者订票时间窗口过期,很简单,重新取号,重新排队。
具体到 Redis 操作,大约会是这样:
用户:key 为 user:abc@test.com ,value 就是 md5(email + password) ,用户登陆的时候,只需要做一次 get ,即可验证登陆是否成功。
用户参与排队的时候,给用户分配一个 token,token 中建议包含加入排队的时间。
为每个“车次+日期”建立一个队列:key 为 train:queue:k21:20120118 , value 是一个 list ,用户点击“排队”按钮,加入队尾,用 lpush token,后台进程根据 db 负载,从队伍头取出一个用户带入查票和下单页面用 rpop token 即可。后台一个定时任务,定期扫描队伍,更新每个人前面的人数,供查询使用。
为每个“车次+日期”建立一个“售票厅”:key 为 train:selling:k21:20120118 , value 是一个 hash ,field 即 token,value 为过期时间。后台进程定期从队列中 rpop 出一个或多个用户,hset 到 hash 里。并且还需要一个后台进程,定期从售票厅里清除出过期的 token
用户在排队页面,使用 token 作为参数来查询自己的状态:首先 hget 售票厅,确认是否已经轮到自己了,如果没有,则再获取前面还有多少人,预估还需要排队多久,车次还剩下多少票等等信息。如果已经轮到自己,则显示“下一步”按钮,将用户带到售票厅。这一步需要做好安全验证,防止有人采取技术手段绕过排队,直接进入售票厅。
如果不希望用户不停的按刷新按钮,消耗服务器资源?那就来一招狠的:用户每次刷新,则将他在排队中的位置往后排一个!不过采取这样的措施之前,别忘了在醒目位置提醒用户“注意节约我们的带宽资源”!
用户真正下单买票,这是纯粹的业务逻辑,这里就不再细说。下单买票完成后,记得更新一下余票信息缓存即可。
这样的系统很难做么?难,也不难。有的地方因为业务逻辑太复杂,不得不做一些改变,比如亚马逊为了应对圣诞购物季,建了一块云,比如淘宝为了双11双12的秒杀,买了一堆的机器;也有的地方因为各种其它的原因,无法做到完美,比如 12306,比如新浪微博。不过无论现实如何,在我们技术人员的心里,永远保留着一颗追求完美的心,永远保留一股追求更好的劲头,就够了。
谨以此献给我在 12306 上失败的购票经历,和明天的 K21 旅程。