美丽邂逅抢票系统的背后

今天抢了个极路由,用了一个小时才抢到,之前一直都在“人山人海”地排队。不禁想起去年给美丽邂逅做的抢票系统,有人问,我写了个脚本怎么抢不到票呢?谜底将在本文中揭开。

抢票系统要满足的条件是:

  1. 一人一票,一个人不能拿到两张票;
  2. 每天欲发行的票必须一张不多一张不少(假定来抢票的人足够多)地发出去;
  3. 用程序抢票成功的概率不能明显高于人工抢票成功的概率。

前两条不难做到,用数据库的事务即可(美丽邂逅的抢票人数没那么多,每天也就 100 人左右来抢票,MySQL 完全能撑得住)。第三条就不那么容易了。最容易想到的一种解决方案,也是之前很多互联网抢票活动采用的方案,就是先到先得,但抢票成功后要求输入验证码。机器不能自动输入验证码,因此抢到的票就作废了。这种做法有两个明显的问题:

  1. 刷票程序可以把抢票成功后的验证码图片、验证链接、cookie 等存储起来,用户收到抢票成功提醒后,手动输入验证码并完成后续操作,这样程序仍然有先发优势。
  2. 在抢票开始的一瞬间,大量请求涌入抢票系统。如果使用关系数据库,要保证“一人一票”和发行票数不多不少,每次抢票操作都是一次数据库事务,而关系数据库一般难以支持大量并发事务,导致系统过载。

另一种方案是取消“抢票”环节,直接从所有参加预订的用户中随机抽取。如果能保证用户的唯一性(例如美丽邂逅所依托的校园活动平台跟科大邮箱绑定),是最省事的而且没有程序刷票的可能。买车摇号、选课抽签等也都是这么做的。不过,“抽奖”总是容易让人质疑公平性。此外,抽中的用户可能到最后不去取票(网上购物则是不下订单),浪费大家的感情。为了营造一种公平的印象,也为了“饥饿营销”让抢到票的用户感觉来之不易,还是要在固定的时间点,有个“抢票”的流程。

有了“抽签”这个基本思路,就不难设计抢票系统了。既要保证机器在抢票成功的概率上不明显高于人类,又要保证已经放弃抢票的用户不会抽到票(不然这些人可能最后不去取票)。因此需要有一个网页让用户一直开着,每几秒钟发送一次抢票请求,这个间隔是前后端约定好的,也就是比这间隔更短的抢票请求会被服务器端认为是无效的。如果用户放弃抢票,关闭抢票网页,则以后的抽签就再也没他的事了。这跟买车摇号是一样的,过几个月就要“续”一次,否则就会被从摇号池中移除。

抢票请求可以是用户手工发送,也可以是前端代码定时自动发送。对美丽邂逅来说,由于抢票时间短(1~2 分钟内票就发完了),为了营造紧张的气氛,采用用户手工发送的方案,即倒计时到 0 后用户要手工点击抢票按钮。如果使用前端代码定时自动发送的方案,请注意添加一些随机延迟,否则第 0、5、10、15…… 秒会挤满了用户请求,中间却很空闲。

票自然是从这些抢票请求中抽签产生了。有三种抽签方案:

  1. 每次抢票,都以固定的概率抽中或不抽中。优点是实现简单,缺点是不能控制放票速率,以及参与人少时可能发不完票。美丽邂逅用的是这种方案。
  2. 以一定的时间间隔(如与倒计时相同的 5 秒)为一周期,每周期放固定数量的票。周期结束时从这个周期收到的请求中随机抽取。如果一个周期的请求数小于票数,则余票放入下一周期。这种方案我认为是最好的,不过用户不能立即得知抽取结果(可以到下一次抢票请求再通知用户)。
  3. 以一定的时间间隔(如与倒计时相同的 5 秒)为一周期,每周期放固定数量的票,先到先得。相比第 2 种方案,用户请求可以立即返回。有被发现规律的风险,不过周期内的开始放票时刻可以随机化以避免规律性。

为了惩罚刷票脚本,美丽邂逅的服务器端代码中,每收到一个合法抢票请求,不论它是否在 5 秒的静默期(前后端约定好的倒计时时间)内,都更新最后一次抢票时间,也就是延长静默期到(当前时刻 + 5 秒)。如果一个刷票脚本每秒刷一次票,则永远也得不到票。当然这种做法对开多个网页抢票的用户并不友好,因为有两个相互独立的倒计时,如果用户鼠标够快,每个倒计时到 0 时都点到了,则也永远抢不到票。

因此静默期内收到的抢票请求最好还是丢弃掉,不要更新最后一次抢票时间。这样用户不论开多少个网页刷票,都只能每 5 秒发出一次有效抢票请求。美丽邂逅抢票系统目前的问题是,倒计时时间是服务器返回的,如果开多个页面,用户会看到每次的倒计时比 5 秒短,可能会疑惑。一种简单的改进是不论现在距离这个用户的静默期结束有多久,一律从 5 秒开始倒计时。这样一些页面上虽然倒计时到 0,但抢票请求事实上是无效的。

最后附一张流程图:

ticket-processticket-process