浅谈12306核心模型设计思路和架构设计

摘要:我发现也许是不是由于目前12306的核心领域模型设计的不够好,致使用户购票时要处理的业务逻辑异常复杂,维护数据一致性的难度也几百倍的上升,同时面对高并发的订票也难以支持很高的TPS。我以为,越是复杂的业务,就越要重视业务分析,重视领域模型的抽象和设计。若是不假思索,凭以往经验行事,则极可能会被以往的设计经验先入为主,陷入死胡同。我发现技术人员每每更注重技术层面的解决方案,好比一上来就分析如何集群、如何负载均衡、如何排队、如何分库分表、如何用锁,如何用缓存等技术问题,而忽略了最根本的业务层面的思考,如分析业务、领域建模。我认为越是复杂的业务系统,则越要设计一个健壮的领域模型。若是一个系统的架构咱们设计错了,还有补救的余地,由于架构最终沉淀的只是代码,调整架构便可(一个系统的架构自己就是不断演进的);而若是领域模型设计错了,那要补救的代价是很是大的,由于领域模型沉淀的是数据结构及其对应的大量数据,对任何一个大型系统,要改核心领域模型都是成本很是高的数据库

需求简述

12306这个系统,核心要解决的问题是网上售票。涉及到2个角色使用该系统:用户、铁道部。用户的核心诉求是查询余票、购票;铁道部的核心诉求是售票。购票和售票实际上是一个场景,对用户来讲是购票,对铁道部来讲是售票。所以,咱们要设计一个在线的网站系统,解决用户的查询余票、购票,以及铁道部的售票这3个核心诉求。看起来,这3个场景都是围绕火车票展开的。缓存

 

查询余票:用户输入出发地、目的地、出发日三个条件,查询可能存在的车次,用户能够看到每一个车次通过的站点名称,以及每种座位的余票数量。数据结构

 

购票:购票分为订票和付款两个阶段,本文重点分析订票的模型设计和实现思路。架构

 

其实还有不少其余的需求,好比给不一样的车次设定销售座位数配额,以及不一样的区段设置不一样的限额。我以为这个需求不是核心最重要的诉求,因此,本文针对这个需求不作具体讨论,也不是本文分析设计的重点。并发

 

需求分析

确实,12306也是一个电商系统,并且看起来商品就是票了。由于若是把一张票当作是一个商品,那购票就相似于购买商品,而后每张票都有库存,商品也有库存的概念。可是若是咱们仔细想一想,会发现12306要复杂不少,由于咱们没法预先肯定好全部的票,若是非要肯定,那只能经过穷举法了。负载均衡

咱们以北京西到深圳北的G71车次高铁为例(这里只考虑南下的方向,不考虑深圳北到北京西的,那是另一个车次,叫G72),它有17个站(北京西是01号站,深圳北是17号站),3种座位(商务、一等、二等)。表面看起来,这不就是3个商品吗?G71商务座、G71一等座、G71二等座。大部分轻易喷12306的技术人员(包括某些中等规模公司的专家、CTO)就是在这里栽第一个跟头的。框架

 

实际上,这个车次能够卖的票是很是多的。为了方便后面的讨论,咱们先明确一下票是什么?分布式

 

一张票的核心信息包括:出发时间、出发地、目的地、车次、座位号。持有票的人就拥有了一个凭证,该凭证表示持有它的人能够坐某个车次的某个座位号,从某地到某地。因此,一张票,对用户来讲是一个凭证,对铁道部来讲是一个承诺;那对系统来讲是什么呢?不知道。这就是咱们要分析业务,领域建模的缘由,咱们再继续思考吧。高并发

 

明白了票的核心信息后,咱们再看看G71这个车次的高铁,能够卖多少张票?性能

讨论前先说明一下,一辆火车的物理座位数(站票也能够当作是一种座位,由于站票也有数量配额)不等于可用的最大配合。全部的物理座位不可能都经过12306网站来销售,而是只会销售一部分,好比40%。其他的仍是会经过线下的方式销售。不只如此,可能有些站点上车的人会比较多,有些比较少,因此咱们还会给不一样的区间配置不一样的限额。好比D31北京南至上海共有765张,北京南有260张,杨柳青有80张,泰安有76张。若是杨柳青的80张票售完就会显示无票,就算其余站有票也会显示无票的。无论如何配置限制区段的配额和限额,咱们老是针对车次进行配置,这点只是车次内部售票时的一些额外的判断条件(业务规则),不影响车次模型的核心地位。因此,为了本文讨论的清楚起见,我后续的讨论都不涉及配额和限额的问题,而是认为任何区段均可以享受火车最大的物理座位数。

 

为了讨论问题方便,咱们减小一些站点来讨论。假设某个车次有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个站中的任意两个站点之间所造成的线段,均可以出售为一张票。我数学很差,算不太清楚,麻烦有数学好的人帮我算算,呵呵。

 

经过上面的分析,咱们知道一张票的本质是某个车次的某一段区间(一条线段),这个区间包含了若干个站点。而后咱们还发现,只要区间不重叠,那座位就不会发生竞争,能够被回收利用,也就是说,能够同时预先出售。

另外,通过更深刻的分析,咱们还发现区间有4种关系:1)不重叠;2)部分重叠;3)彻底重叠;4)覆盖;不重叠的状况咱们已经讨论过了,而覆盖也是重叠的一种。因此咱们发现若是重叠,好比有两个区间发生重叠,那重叠部分的区间(可能夸一个或多个站点)是在争抢座位的。由于假设一列火车有100个座位,那每一个原子区间(两个相邻站点的连线),最多容许重叠99次。

 

因此,通过上面的分析,咱们知道了一个车次可以出售一张车票的核心业务规则是什么?就是:这张车票所包含的每一个原子区间的重叠次数加1都不能超过车次的总座位数,实际上重叠次数+1也能够理解为线段的厚度。

 

模型设计

上面我分析了一下票的本质是什么。那接下来咱们再来看看怎么设计模型,来快速实现购票的需求,重点是怎么设计商品聚合以及减库存的逻辑。

 

传统电商的思路

若是按照普通电商的思路,把票(站点区间)设计为商品(聚合根),而后为票设计库存数量。我我的以为是很糟糕的。由于一方面这种聚合根很是多,另外一方面,即使枚举出来了,一次购票也必定会影响很是多其余聚合根的库存数量(只要被部分或所有重叠的区间都受影响)。这样的一次订单处理的复杂度是难以评估的。并且这么多聚合根的更新要在一个事务里,这不是为难数据库吗?并且,这种设计必然带来大量的事务的并发冲突,极可能致使数据库死锁。总之,我认为这种是典型的因为领域模型的设计错误,致使并发冲突高、数据持久化落地困难。或者若是要解决并发问题,只能排队单线程处理,可是仍然解决不了要在一个事务里修改大量聚合根的尴尬局面。据说12306是采用了Pivotal Gemfire这种高大上的内存数据库,我对这个不太了解。我不可想象要是不使用内存数据库,他们要怎么实现车次内的票之间的数据强一致性(就是保证全部出售的票都是符合上面讨论的业务规则的)?

 

个人思路

经过上面的分析咱们知道,其实任何一次购票都是针对某个车次的。咱们看看一个车次包含了哪些信息?一个车次包括了:1)车次名称,如G71;2)座位数,实际座位数会分类型,好比商务座20个,一等座200个;二等座500个;咱们这里为了简化问题,能够暂时忽略类型,我认为这个类型不影响核心的模型的设计决策。须要格外注意的是:这里的座位数不要理解为真实的物理座位数,颇有可能比真实的座位数要少。由于咱们不可能把一个车次的全部座位都在网上经过12306来出售,而是只出售一部分,具体出售多少,要由工做人员人工指定。3)通过的站点信息(包括站点的ID、站点名称等),注意:车次还会记录这些站点之间的顺序关系;4)出发时间;看过GRASP九大模式中的信息专家模式的同窗应该知道,将职责分配给拥有执行该职责所需信息的类。咱们这个场景,车次具备一次出票的全部信息,因此咱们应该把出票的职责交给车次。另外学过DDD的同窗应该知道,聚合设计有一个原则,就是:聚合内强一致性,聚合之间最终一致性。通过上面的分析,咱们知道要产生一张票,其实要影响不少和这个票对应的直线相交的其余票的可用数量。由于全部的站点信息都在车次聚合内部,因此车次聚合内部天然能够维护全部的原子区间,以及每一个原子区间的可用票数(至关因而库存数)。当一个原子区间的可用票数为0的时候,意味着火车针对这个区间的票已经卖完了。因此,咱们彻底可让车次这个聚合根来保证出票时对全部原子区间的可用票数的更新的强一致性。对于车次聚合根来讲,这很简单,由于只是几回简单的内存操做而已,耗时能够忽略。一列火车假若有ABCD四个站点,那原子区间就是3个。对于G71,则是16个。

 

而后基于上面的聚合设计,出票时扣减库存的逻辑是:

根据订单信息,拿到出发地和目的地,而后获取这段区间里的全部的原子区间。而后尝试将每一个原子区间的可用票数减1,若是全部的原子区间都够减,则购票成功;不然购票失败,提示用户该票已经卖完了。是否是很简单呢?知道了出票的逻辑,那退票的逻辑也就很简单了,就是把这个票的全部原子区间的可用票数加1就OK了。若是咱们从线段的厚度的角度去考虑,那出票时,每一个原子区间的厚度就是+1,退票时就是减一。就是相反的操做,但本质是同样的。

 

因此,经过这样的思路,咱们将一次订票的处理控制在了一个聚合根里,用聚合根内的强一致性的特性保证了订票处理的强一致性,同时也保证了性能,免去了并发冲突的可能性。传统电商那种把票单作相似商品的核心聚合根的设计,我当时第一眼看到就以为不妥。由于这违背了DDD强调的强一致性应该由聚合根来保证、聚合根之间的最终一致性经过Saga来保证的原则。

 

还有一个很重要的概念我想说一下个人见解,就是座位和区间的关系。由于有些朋友和我讲,考虑座位号的问题,虽然都能减1,座位号也必须是同一个。我以为座位是全局共享的,和区段无关(也许个人理解彻底有误,请你们指正)。座位是一个物理概念,一个用户成功购买了一张票后,座位就会少一个,一张票惟一对应一个座位,可是一个座位有可能会对应多张票;而区间是一个逻辑上的概念,区间的做用有两个:1)表示票的出发地和目的地;2)记录票的可用数额。若是区间能连通(即该区间内的每一个原子区间的可用数额都大于0),则表示容许拥有一个座位。因此,我以为座位和票(区间)是两个维度的概念。

 

模型分析总结:我认为票不是核心聚合根,票只是一个计算的结果,一个凭证而已,票自己没有什么逻辑;12306真正的核心模型应该是车次,车次具备出票的职责,并以强一致性的方式维护一次出票(或退票)时全部原子区间的可用票数。

 

架构设计

我以为12306这样的业务场景,很是适合使用CQRS架构;由于首先它是一个查多写少、可是写的业务逻辑很是复杂的系统。因此,很是适合作架构层面的读写分离,即采用CQRS架构。并且应该使用数据存储也分离的CQRS。这样CQ两端才能够彻底不须要顾及对方的问题,各自优化本身的问题便可。咱们能够在C端使用DDD领域模型的思路,用良好设计的领域模型实现复杂的业务规则和业务逻辑。而Q端则使用分布式缓存方案,实现可伸缩的查询能力。

 

订票的实现思路

同时借助像ENode这样的框架,咱们能够实现in-memory + Event Sourcing的架构。Event Sourcing技术,可让领域模型的全部状态修改的持久化统一块儿来,原本要用ORM的方式保存聚合根最新状态的,如今只须要简单的通用的方式保存一个事件便可(一次订票只涉及一个车次聚合根的修改,修改只产生一个事件,只须要持久化一个事件(一个JSON串)便可,保证了高性能,无须依赖事务,并且经过ENode能够解决并发问题)。咱们只要保存了聚合根每次变化的事件(事件的结构怎么设计,本文不作多的介绍了,你们能够思考下),就至关于保存了聚合根的最新状态。而正是因为Event Sourcing技术的引入,让咱们的模型能够一直存活在内存中,便可以使用in-memory技术。不要小看in-memory技术,in-memory技术在某些方面对提升命令的处理性能很是有帮助。好比就以咱们车次聚合根处理出票的逻辑,假设某个车次有大量的命令发送到分布式消息队列,而后有一台机器订阅了这个队列的消息,而后这台机器处理这个车次的订票命令时,因为这个车次聚合根一直在内存,因此就省去了每次要去数据库取出聚合根的步骤,至关于少了一次数据库IO。这样的好处是,由于一个车次可以真正出售的票是有限的,由于座位就那么几个,好比就1000个座位,估计通常正常状况也就出个2000个左右的座位吧(具体能出多少张票要取决于区间的相交程度,上面分析过)。也就是说,这个聚合根只会产生2000个事件,也就是说只会有2000个订票命令的处理是会产生事件,并持久化事件;而其他的大量命令,由于车次在内存计算后发现没有余票了,就不会作任何修改,也不会产生领域事件,这样就能够直接处理下一个订票命令了。这样就能够大大提升处理订票命令的性能。

 

另一个问题我以为还须要提一下,由于用户订票成功后,还须要付款。但用户有可能不去付款或者没有在规定的时间内完成付款。那这种状况下,系统会自动释放该用户以前订购的票。因此基于这样的需求,咱们在业务上须要支持业务级别的2pc。即先预扣库存,也就是先占住这张票必定时间(好比15分钟),而后付款成功后再真实给你这张票,系统作真正的库存修改。经过这样的预扣处理,能够保证不会出现超卖的状况。这个思路其实和传统电商好比淘宝这样的系统相似,我就很少展开了,我以前写的Conference案例也是这样的思路,你们有兴趣的能够去看一下我以前录制的视频。

 

查询余票的实现思路

我以为余票的查询的实现相对简单。虽然对于12306来讲,查询的请求占了80%,提交订单的请求只占20%。但查询因为对数据没有修改,因此咱们彻底可使用分布式缓存来实现。咱们只须要精心设计好缓存的key便可;缓存key的多少要当作本,若是全部可能的查询都设计对应的key,那时间复杂度为1,查询性能天然高;但代价也大,由于key多了。若是想key少一点,那查询的复杂度天然要上去一点。因此缓存设计无非就是空间换时间的思路。而后,缓存的更新无非就是:自动失效、定时更新、主动通知3种。经过CQRS架构,因为CQ两端是事件驱动的,当C端有任何状态变化,都会产生对应的事件去通知Q端,因此咱们几乎能够作到Q端的准实时更新。

 

同时因为CQ两端的彻底解耦,Q端咱们能够设计多种存储,如数据库和缓存(Redis等);数据库用于线下维护关系型数据,缓存用户实时查询。数据库和缓存的更新速度相互不受影响,由于是并行的。对同一个事件,能够10台机器负责更新缓存,100台机器负责更新数据库。即使数据库的更新很慢,也不会影响缓存的更新进度。这就是CQRS架构的好处,CQ的架构彻底不一样,且咱们随时能够重建一种新的Q端存储。不知道你们体会到了没有?

 

关于缓存key的设计,我以为主要从查询余票时传递的信息来考虑。12306的关键查询是:出发地、目的地、出发日期三个信息。我以为有两种key的设计思路:1)直接设计了该查询条件的key,而后快速拿到车次信息,直接返回;这种方式就是要求咱们系统已经枚举了全部车次的全部可能出现的票(区间)的缓存key,相信你必定知道这样的key是很是多的。2)不是枚举全部区间,而是把每一个车次的每一个原子区间(相邻的两个站点所连成的直线)的可用票数做为key。这样,key就很是少了,由于车次假若有10000个,而后每一个车次平均15个区间,那也就15W个key而已。当咱们要查询时,只须要把用户输入的出发地和目的地之间的全部原子区间的可用票数都查出来,而后比较出最小可用票数的那个原子区间。则这个原子区间的可用票数就是用户输入的区间的可用票数了。固然,到这里我提到考虑出发日期。我认为出发日期是用来决定具体是哪一个车次聚合根的。同一个车次,不一样的日期,对应的聚合根实例是不一样的,即使是同一天,也可能有多个车次聚合根,由于有些车次一天有几班的,好比上午9点发车的一班,下午3点发车的通常。因此,咱们也只要把日期也做为缓存key的一部分便可。

 

总结

本文彻底是凭本身对12306这个网站的核心业务的简单思考而获得的一些设计结果。若是真正的DDD领域建模,更多的是要和业务一线的工做人员、领域专家进行深刻沟通,才能更深刻的了解该领域内的业务知识,从而才能设计出更靠谱的领域模型和架构设计。我本人很是惭愧由于没有上12306买过火车票,家离的比较近,就算要买也是家人给我买:)因此,本文所分享的内容不免是纸上谈兵。但我以为12306这个系统的业务确实比传统的电商系统要复杂,且并发又这么高。因此,我以为这个系统真的很值得你们重视模型的设计,而不仅是只关注技术层面的实现。

相关文章
相关标签/搜索