火车票网上订票12306数学建模(浅谈12306核心模型设计思路和架构设计)



要求概述

1306系统,要解决的核心问题是网络售票。使用该系统涉及两个角色:用户和铁道部。用户的核心诉求是查询余票和购票;铁道部的核心需求是售票。而售票其实就是一个场景,对用户来说就是售票,对铁道部来说就是售票。因此,我们需要设计一个在线网站系统来解决用户的三个核心诉求:查询余票、购票和铁道部售票。好像这三个场景都是围绕着火车票展开的。

查询余票:用户输入出发地、目的地、出发日期三个条件,查询可能的车次。用户可以看到每列火车经过的车站名称和每个座位的剩余车票数量。

购票:购票可以分为订票和支付两个阶段。本文重点介绍了预订模块的设计与实现。

其实还有很多其他的需求,比如针对不同的车次设置销售席位配额,针对不同的区段设置不同的配额。但相对于前两个要求,我觉得这个要求相对次要。

需求分析

的确,12306也是电商系统,看起来货就是票。因为如果把一张票看成一种商品,那么买票就类似于买商品,然后每张票都有库存,商品也有库存的概念。但仔细想想就会发现,12306要复杂得多,因为我们无法提前确认所有的车票。如果非要确认,只能走穷举法。

我们以北京西到深圳北的G71次高铁列车为例(这里只考虑南向,不考虑深圳北到北京西的那趟,那是另一趟列车,叫G72)。设有17个车站(北京西01站、深圳北17站),3种座位(商务、一等、二等座)。表面上看,这三种商品不都是吗?G71商务座,G71一等座,G71二等座。大部分轻易喷12306的技术人员(包括专家和一些中型公司的CTO)都是第一次在这里栽跟头。实际上G71有136*3=408种商品(408个SKU)。是如何计算的?如下所示:

从北京西卖,从北京西到保定、石家庄、郑州、武汉、长沙、广州、虎门、深圳有16个卖法(因为后面有16个站)。。。。都是独立的商品。同样,在石家庄上车也有15种可能,以此类推。如果算上单独下车的站数,一共有136种票:16+15+14...+2+1 = 136.每张票有三个座位,共408项。

为了方便后面讨论,我们先说清楚什么是票。

一张车票的核心信息包括:出发时间、出发地点、目的地、车次、座位号。持票人有凭证,是指持票人可以从某地乘坐某次列车的某个座位号到某地。所以,一张票对用户来说是凭证,对铁道部来说是承诺。这对系统来说是什么?不知道。这就是为什么我们应该分析业务和领域建模。让我们继续思考。

了解了车票的核心信息之后,我们再来看看高铁G71。你能卖出多少张票?

在讨论之前,我们先明确一下,一趟列车的物理座位数(站票也可以看作是座位的一种,因为站票也有数量配额)并不等于可用的最大协调数。所有的实体席位都不能通过12306网站销售,只能销售一部分,比如40%。剩下的还是会线下销售。不仅如此,有些车站上车的人可能会多一些,也可能会少一些,所以我们会针对不同的路段配置不同的限制。比如D31有北京南到上海的765张,北京南的260张,杨柳青的80张,泰安的76张。杨柳青的80张票卖完了,就算其他站有票也没票了。每趟列车肯定会有座位和配额的分配,这个我目前无法预测,但是我已经把这些规则封装在了near trains的聚合根中,所有的分配策略都是基于座位类型、车站和区间的。关于抽象出来的票的配置,我认为主要有三种:1)某个区间最多允许多少张票;2)某一部分至少允许多少张;3)某个车站的最大乘车票数量;用户订票时,将用户指定的区段与这三个配置条件进行对比。如果三个条件都满足,就可以出票了。如果不满意,则认为无票。这里有一个例子:

ABCDEFG,这是所有的网站。总席位配额是100。假设b站上车的人少,E站下车的人少,那么我们可以设定BE这一段最多只能有10张票。所以只要用户的订票在这个区间,最多也就10张票。再比如,一列火车,总共100个座位,希望全程至少有80张票。那么我们只需要为AG这一段设置至少80张票就可以了。那么任何订票请求,如果是子区间,不能超过100-80,也就是20张票。这两个条件必须同时满足,才能出票。

但是,无论配额和额度怎么制定,我们总是配置一定的车次。这些配置只是内部销售车次号时的一些附加判断条件(业务规则),并不影响车次号模型的核心地位和暴露的功能。所以为了这个讨论的清晰,我的后续讨论不涉及名额和配额的问题,而是说任何一节都可以享受火车上最大物理座位数。

并且,为了方便讨论问题,我们减少了一些讨论的网站。假设一列火车有A、B、C、d四站,那么001这个人买了A、B区间,系统会给001分配一个座位X;但是因为001会在b站下车,相当于X的座位又出来了空,也就是说从b站出来,系统就可以认为X的座位又空了。所以我们得出结论,同一个座位其实可以同时卖AB和BC票。通过这个简单的分析,我们知道一辆火车只有有限的座位数,比如1000个座位。但是能卖出去的票远不止1000张。以A、B、C、D四个站为例。如果火车总共有1000个座位,AB可以卖1000个座位,BC可以卖1000个座位,CD可以卖1000个座位。也就是说,理论上最多能卖出3000张票。但如果换一种说法,大家都买ABCD的票,也就是说所有的票都经过所有的站点,也就是最多只能卖出1000张。实际场景必须在1000到3000之间。那么实际的G71次列车有17个车站。你能卖出多少张票?你应该能忘记它。理论上,这17个站中任意两个站之间形成的线段都可以作为车票出售。我数学不好,也算不清楚。请帮我学数学,呵呵。

通过以上分析,我们知道车票的本质是某一列车的某一区段(某一线段),它包含几个车站。然后我们也发现,只要区间不重叠,座位就不会竞争,可以循环使用,也就是可以同时预售。

此外,经过更深入的分析,我们还发现区间之间存在四种关系:1)不重叠;2)部分重叠;3)完全重叠;4)覆盖;我们已经讨论过不重叠的情况,覆盖也是重叠的一种。因此,我们发现,如果有重叠,例如,有两个重叠部分,那么重叠部分(可能拥有一个或多个站)正在竞争座位。因为一列火车有100个座位,每个原子间隔(两个相邻车站之间的连接)最多允许重叠99次。

所以,通过上面的分析,我们知道火车能卖出一张票的核心商业规则是什么?即:本票包含的各原子区间重叠次数加1不能超过列车总座位数。其实重叠次数加1也可以理解为线段的粗细。

模型设计

上面我分析了票的本质。接下来我们来看看如何设计模型快速实现购票需求,重点是如何设计商品聚合和库存减少的逻辑。

传统电子商务的思考

按照普通电商的思路,把票(站点区间)设计成商品(聚合根),然后为票设计库存数量。个人觉得很不好。一方面聚合根那么多(上面有408个G71另一方面,即使是枚举,一次购票也一定会影响到很多其他聚合根的库存数量(只要影响到部分或完全重叠的区间)。这种订单处理的复杂性很难评估。而且,这么多聚合根的更新都要在一个事务中,这对数据库来说不是很难吗?而且这种设计必然会导致事务的大量并发冲突,容易导致数据库死锁。总之,我认为这是典型的领域模型设计错误,导致高并发冲突,数据持久化难以落地。或者想解决并发问题,只能排队单线程处理,但还是解决不了一个事务修改大量聚合根的尴尬局面。听说12306用的是Pivotal Gemfire,高大上的内存数据库。不太了解。我无法想象在不使用内存数据库的情况下,他们如何在列车内的车票之间实现强数据一致性(即确保所有售出的车票都符合上面讨论的业务规则)。所以个人认为这种设计是一种思维定势,把火车票当成普通电商的商品。所以设计的时候有时候要靠经验,被过去的经验束缚真的不容易。关键是要根据具体的业务场景进行更深入的分析,尽量分析和抽象出问题的本质,从而对症下药。还有其他设计思路吗?

我的思路

聚集设计

从上面的分析我们知道,其实任何一次购票都是针对某个车次的。我觉得车次是负责处理购票的聚合根。我们来看看一列火车包含了哪些信息。一列火车包括:1)火车名称,如G71;2)座位数,实际座位数会分类型,比如20个商务座,200个头等舱;500个二等座;这里为了简化问题,可以暂时忽略类型。我觉得这种类型并不影响核心车型的设计决策。需要注意的是,这里的座位数量不应该理解为物理座位的真实数量,它很可能小于真实的座位数量。因为我们不可能通过12306在网上销售一个车次的全部座位,而只是销售一部分,具体销售多少座位要由工作人员手动指定。3)通过站信息(包括站ID、站名等。),注:车次号也会记录这些车站之间的先后关系;4)出发时间;读过九种抓取模式中信息专家模式的同学应该知道把责任分配给拥有履行责任所需信息的班级。在我们的场景中,车次号一次性拥有所有的出票信息,所以我们应该把出票的责任交给车次号。另外,学过DDD的同学应该知道有一个聚合设计的原则,就是聚合内部的强一致性和聚合之间的最终一致性。通过上面的分析,我们知道,要生成一张票,实际上影响的是与这张票对应的线段相交的许多其他票的可用数量。因为所有站点信息都在train聚合中,所以很自然地在train聚合中维护所有原子间隔和可用票(相当于库存号)。当一个原子范围内的可用票数为0时,说明这个范围的火车票已经卖完了。所以我们可以让车次的聚合根保证出票时更新所有原子区间可用票的强一致性。对于列车聚合根来说,这是非常简单的,因为只是一些简单的内存操作,耗时可以忽略不计。如果一列火车有四个ABCD站,就有三个原子间隔。对于G71,是16。

如何判断能否出票?

基于以上聚合设计,出票时扣除存货的逻辑如下:

根据订单信息,得到出发地和目的地,然后得到这个区间内的所有原子区间。然后尽量把每个原子区间的可用票数减1。如果所有的原子间隔都减少到足够的程度,那么购票就成功了。否则购票失败,提示用户车票已售完。这不是很简单吗?知道了出票的逻辑,退票的逻辑很简单,就是在这张票所有原子区间的可用票数上加1就OK了。如果从线段粗细的角度考虑,出票时每个原子间隔的粗细是+1,退票时是-1。是相反的操作,但本质是一样的。

所以通过这种思路,我们把一次性预订的处理控制在一个聚合根中,保证预订处理与聚合根中的强一致性,同时保证性能,避免并发冲突的可能。传统电商那种把票作为同类商品核心聚合根的设计,我一看就觉得不合适。因为它违背了DDD的原则,即强一致性应由聚合根来保证,聚合根之间的最终一致性应由Saga来保证。

还有一个很重要的概念我想说一下,就是座位和区间的关系。因为有朋友跟我说,考虑到座位号,虽然可以减1,但是座位号必须一样。我认为座位是全球共享的,与版块无关(可能我的理解完全错误,请指正)。座位是一个物理概念。用户成功购票后,会少一个座位。一张票只对应一个座位,但一个座位可能对应多张票。区间是一个逻辑概念,它有两个作用:1)表示车票的出发地和目的地;2)记录票的可用数量。如果区间可以连通(即区间中每个原子区间的可用量大于0),则表示允许一个席位。所以我觉得座位和票(区间)是二维概念。

火车票网上订票12306

如何分配门票的座位

我觉得这个列车所有已经售出的车票都应该在列车聚合根里面维护。已经售出的车票的本质是区间与座位的对应。当系统处理预订时,用户提交一个时间间隔。因此,系统应该做两件事:

  • 第一,根据区间判断是否有空位;
  • 如果有空位,则通过算法选择空位;
  • 当获得一个可用座位时,可以生成一张票,然后可以将该票保存在列车聚合根中。这里有一个例子:

    假设站点:1,2,3
    站点:abcd有3个席位和4个
    席位。

    如何卖票1:
    票1: AB,1
    票2: BC,2
    票3: CD,3
    票4: AC,3
    票5:。
    以上四五票是考虑回收的结果。

    如何卖票2:
    票1: AB,1
    票2: BC,1
    票3: CD,1
    票4: AC,2
    票5:。
    以上两票,2票和3票,都是考虑回收的结果。

    但是从座位池中优先取票的算法是有缺陷的,即虽然第一步判断有空位,但这个座位可能不是一路的同一个座位。例:
    假设目前的情况是有三个席位,站点有四个席位
    : 1,2,3
    站点:如何销售abcd
    门票3:
    门票1: AB,1
    门票2:。但是不管是2号还是3号,这个乘客都要中途换车位。比如你卖给他2号座,那么他在ab会坐2号座,但在bc会坐1号座。否则,持2号票的人上车后,发现2号座位已经有人了。但是通过优先级回收算法就不存在这个问题了。

    所以从上面的分析,我们也知道了座位选择的算法,也就是优先回收座位的算法应该怎么写。我觉得这里无论怎么设计算法,都不会影响全局,因为这一切都只发生在列车的聚合根上,这就是提前设计聚合根,明确出票责任在哪个对象上的好处。

    模型分析和总结

  • 我觉得票不是核心聚合根。票只是一个出票的结果,只是一个凭证。
  • 1306真正的核心聚合根应该是车次,车次有出票的责任。在出票时要做的具体事情是:判断能否出票;选择可用座位;一次出票时更新所有原子区间的可用票,用于判断下次能否出票;维护所有售出的机票,为选择座位提供依据;
  • 通过这种模型设计,可以保证一个售票流程只在一个列车聚合根中进行。这具有以下优点:

  • 数据修改的强一致性可以在不依赖数据库事务的情况下实现,因为所有的修改都只发生在一个聚合根中;
  • 在保证强数据一致性的同时,还能提供高并发处理能力。具体设计见以下架构设计。
  • 架构设计(非本文重点,没兴趣的朋友可以略过)

    我觉得12306这样的业务场景非常适合用CQRS架构;首先是一个多查少写的系统,但是写的业务逻辑很复杂。所以非常适合架构层面的读写分离,也就是采用CQRS架构。此外,应使用具有独立数据存储的CQRS。这样,CQ的两端都可以优化自己的问题,而完全不用考虑对方的问题。我们可以在C端使用DDD领域模型的思想,用设计良好的领域模型实现复杂的业务规则和业务逻辑。另一方面,Q端使用分布式缓存方案来实现可扩展的查询能力。

    基于的订票实现思想

    同时借助像ENode这样的框架,可以实现内存中+事件源的架构。事件源技术可以统一领域模型的所有状态修改的持久性。原来用ORM保存聚合根的最新状态,现在只需要简单通用地保存一个事件(一个订票只涉及一个列车聚合根的修改,修改只生成一个事件,只需要持久化一个事件(一个JSON字符串),保证了高性能,不需要依赖事务,可以通过ENode解决并发问题)。只要我们保存了聚合根的每次变化的事件(事件的结构如何设计,本文就不详细介绍了,大家可以思考一下),就相当于保存了聚合根的最新状态。正是因为引入了事件源技术,我们的模型才能一直活在内存中,也就是说我们可以使用内存中技术。不要低估内存技术。内存技术在某些方面对提高命令处理性能很有帮助。举个例子,就拿我们火车聚合根用于出票的逻辑来说,假设某趟火车有大量的命令发送到分布式消息队列,然后有一台机器订阅了这个队列的消息,然后这台机器在处理这个火车的订票命令的时候,因为这个火车聚合根一直在内存中,省去了每次去数据库取聚合根的步骤,相当于少了一个数据库IO。这样做的好处是,一列火车上真正能卖出去的车票数量是有限的,因为只有几个座位,比如1000个座位。预计正常情况下会有2000张左右的票(具体数量取决于路段的交叉程度,上面分析过)。也就是说这个聚合根只会产生2000个事件,也就是说只有2000个预订订单会产生并持久化事件;至于其余的大量订单,因为在内存中计算完车次后没有剩余车票,所以不会做任何修改,也不会生成域事件,可以直接处理下一个订票订单。这可以大大提高处理预订订单的性能。

    我觉得还有一个问题需要提一下,因为用户订票成功后,还是需要付费的。然而,用户可能不支付或未能在指定时间内完成支付。在这种情况下,系统将自动释放用户先前订购的门票。所以基于这个需求,我们需要在业务层面支持2pc。也就是先扣压库存,也就是占用车票一定时间(比如15分钟),付款成功后再给你车票,系统会进行真实的库存修改。通过这样的预扣处理,可以保证不会出现超跌的情况。这个思路其实和淘宝等传统电商系统差不多,我就不多展开了。我之前写的会议案例也是这样的思路。有兴趣可以看看我之前录的视频。

    余票查询的实现

    我觉得查询余票还是比较简单的。虽然对于12306来说,查询的请求占80%,提交订单的请求只占20%。但是,由于数据没有被修改,我们可以使用分布式缓存来实现查询。我们只需要精心设计缓存的键;缓存中的键的数量取决于成本。如果所有可能的查询都设计了对应的键,时间复杂度为1,查询性能自然就高了。但是价格也高,因为钥匙比较多。如果想拥有更少的键,查询的复杂度自然会增加。所以缓存设计无非就是空之间换时间的思路。那么,缓存的更新无非就是:自动失效,定期更新,主动通知。通过CQRS架构,由于CQ两端都是事件驱动的,当C端有任何状态变化时,都会产生相应的事件通知Q端,所以在Q端几乎可以实现准实时更新。

    同时,由于CQ两端完全解耦,我们可以在Q端设计多种存储,比如数据库和缓存(Redis等。);该数据库用于离线维护关系数据,并缓存来自用户的实时查询。而数据库和缓存的更新速度是相互独立的,因为它们是并行的。对于同一事件,10台机器可以更新缓存,100台机器可以更新数据库。即使数据库的更新很慢,也不会影响缓存的更新进度。这是CQRS建筑的优势。CQ架构完全不同,我们可以随时重建新的Q端存储。不知道大家有没有意识到。

    关于cache key的设计,我觉得主要考虑查询余票时传递的信息。1306的关键查询是:出发地、目的地、出发日期。我认为有两个关键的设计思路:1)直接设计这个查询条件的关键,然后快速获取车次信息,直接返回;这种方式是要求我们的系统枚举所有车次所有可能车票(区间)的所有缓存键。相信你一定知道,这样的钥匙有很多。2)不是枚举所有区间,而是把每趟列车的每个原子区间(相邻两个车站连接的直线)的可用票作为关键。这样键就很少了,因为如果有一万列火车,然后每列火车平均15个区间,那么只有15W个键。当我们要查询时,只需要找出用户输入的出发地和目的地之间所有原子区间内的可用票数,然后将原子区间与最小的可用票数进行比较。该原子间隔中的可用投票是用户输入的间隔中的可用投票。当然,这里我提到了考虑出发日期。我觉得发车日期是用来决定哪趟列车是聚合根的。同样的车次,不同的日期,对应的聚合根实例是不同的。即使在同一天,也可能有多个车次聚合根,因为有些列车一天有几个班次,比如上午9点发车的和下午3点发车的,因此,我们只需要将日期作为缓存键的一部分。

    总结

    这篇文章完全是基于我自己对12306网站核心业务的简单思考和一些设计成果。如果做真正的DDD领域建模,更多的是与一线工作人员和领域专家的深入交流,让我们更深入的了解这个领域的业务知识,设计出更靠谱的领域模型和架构设计。我很惭愧,因为我没有在12306上买火车票,家里人给我买的,就算我买了:)所以,我在这篇文章里分享的,难免是纸上谈兵。但是我觉得12306系统的业务真的比传统电商系统复杂,并发这么高。所以我觉得这个系统真的值得大家关注模型的设计,而不仅仅是技术上的实现。2016年,我有计划基于ENode实现12306的一套核心功能,比如余票查询、订票等功能。

    您可以还会对下面的文章感兴趣

    使用微信扫描二维码后

    点击右上角发送给好友