当前位置: 首页 > 科技观察

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

时间:2023-03-18 01:35:12 科技观察

前言春节期间无意中看到一篇文章,说12306的业务复杂度远比淘宝天猫等电商网站复杂。后来想了想,果然如此。因此,我很想挑战一下12306系统的核心领域模型的设计。在一般的电子商务网站上,购买都是基于商品的概念,每一种商品都有一定的库存量,用户的购买行为是特定于商品的。当用户发起购买时,系统只需要生成订单,减少用户想要购买的商品的库存即可。然而,12306并没有那么简单。具体复杂度在哪里,下面我会进一步分析。写这篇文章的另一个原因是,我发现可能是因为12306目前核心领域模型设计的不够好,导致用户购票时需要处理的业务逻辑极其复杂,维护难度大数据一致性几乎是百倍提升,同时面对高并发预订也难以支撑高TPS。我认为业务越复杂,越要重视业务分析和领域模型的抽象和设计。如果不加思索地根据过去的经验行事,可能会被过去的设计经验先入为主,陷入死胡同。我发现技术人员往往更关注技术方案,比如分析如何集群、如何负载均衡、如何排队、分库分表、如何使用锁、如何使用缓存等技术问题,以及忽略业务层面最基础的Thinking,比如分析业务、领域建模等。我觉得越是复杂的业务系统,就越有必要设计一个健壮的领域模型。如果一个系统的架构设计错了,还有补救的余地,因为架构最终的积累只是代码,架构是可以调整的(一个系统的架构本身就是在不断进化的);修复的成本非常高,因为领域模型沉淀了数据结构及其对应的大量数据。对于任何一个大型系统,改变核心领域模型的成本都是非常高的。本文的重点不是如何解决高并发问题,而是从业务的角度来分析12306的理想模型应该是什么样子。现在网上12306的文章好像都一样,只讲技术,不讲业务分析,怎么建模。所以想把自己的设计写出来和大家交流学习。需求概述12306这个系统,要解决的核心问题是在线售票。使用该系统涉及两个角色:用户和铁道部。用户的核心需求是查看余票和购票;铁道部的核心需求是卖车票。购票和售票其实是一个场景,就是用户购票,铁道部售票。因此,我们需要设计一个在线网站系统来解决用户的三个核心需求,即查询剩余车票、购买车票、铁道部车票销售。似乎这三个场景都是围绕火车票展开的。查询剩余车票:用户输入出发地、目的地、出发日期三个条件查询可能的车次。用户可以看到每列火车经过的车站名称和每个座位的剩余票数。购票:购票分为订票和支付两个阶段。本文着重介绍机票预订的模型设计和实现思路。其实还有很多其他的要求,比如对不同列车的销售座位数设置限额,对不同的区间设置不同的限制等。但是相对于前两个要求,我觉得这个要求是比较次要的。需求分析确实,12306也是一个电商系统,貌似产品是机票。因为如果把票看成是商品,那么买票就类似于买商品,然后每张票都有存货,商品也有存货的概念。但是仔细想想,我们会发现12306要复杂的多,因为我们无法预先确定所有的选票。如果非要确定的话,只能用穷举法了。我们以北京西到深圳北的高铁G71次为例(这里只考虑南行的方向,不考虑深圳北到北京西的高铁G72次列车),有17站(北京西为01号1号站,深圳北为17号站),3种座位(商务舱、头等舱、二等舱)。从表面上看,这不就是3种商品吗?G71商务座,G71头等座,G71二等座。大部分轻松喷过12306的技术人员(包括专家和一些中型公司的CTO)都是第一次在这里跌跌撞撞。其实G71有136*3=408个商品(408个SKU),怎么算呢?如下:如果卖北京西发车的,有16种售卖方式(因为后面有16站),北京西发车的:保定,石家庄,郑州,武汉,长沙,广州,虎门,深圳。...它们都是独立的商品。同样,在石家庄上车,下车有15种可能。以此类推,有136种票:16+15+14...+2+1=136。每张票有3种座位,共有408种商品。为了方便后面的讨论,先明确一下ticket是什么?车票的核心信息包括:出发时间、出发地、目的地、车次、座位号。持有车票的人有一张凭证,这意味着持有它的人可以乘坐某列火车的某个座位号,从某个地方到某个地方。因此,一张车票对用户来说就是凭证,对铁道部来说就是承诺;系统有什么用?不知道。这就是为什么我们要分析业务和领域建模,我们继续思考。了解了车票的核心信息后,我们再来看看G71次列车的高铁。能卖多少票?讨论之前先说明一下,火车上的物理座位数(站票也可以算作座位的一种,因为站票也有配额)不等于最大可用座位数。通过12306网站不可能卖掉所有的实体席位,只能卖一部分,比如40%。其余的仍将在线下销售。不仅如此,可能有的站上车人多,有的站上车人少,所以我们会针对不同的路段配置不同的限制。比如D31有北京南到上海765票,北京南260票,杨柳青80票,泰安76票。杨柳青的80张票卖完就显示无票,其他站即使有票也显示无票。每趟列车肯定会有多种座位配额和配额配置。我目前无法预测这种配置,但我已经将这些规则封装在火车的聚合根中。所有配置策略均基于座位类型、站点和间隔配置。关于车票配置的抽象,我认为主要有3种: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个站点中任意两个站点之间形成的线段都可以作为车票出售。我数学不好,所以想不通,请数学好的人帮我算一下,哈哈。通过上面的分析,我们知道一张车票的本质是某个车次的某个区间(线段),这个区间包含了几个站。然后我们也发现,只要版块不重叠,就不会出现抢位,可以回收,也就是可以同时预售。此外,经过更深入的分析,我们还发现区间之间存在4种关系:1)不重叠;2)部分重叠;3)完全重叠;4)覆盖范围;我们已经讨论过不重叠的情况,覆盖也是重叠的那种。所以我们发现如果有重叠,比如两个区间重叠,重叠的区间(可能是一个或多个站点)在争夺席位。因为假设一列火车有100个座位,每个原子间隔(两个相邻车站之间的连接)最多允许重叠99次。那么,经过上面的分析,我们知道了火车可以卖票的核心业务规则是什么?即:这张车票包含的每个原子区间的重叠数加1不能超过火车的总座位数。其实重叠数+1也可以理解为一条线段的粗细。在上面的模型设计中,我分析了ticket的本质是什么。那么我们来看看如何设计一个模型来快速实现购票需求。关键是如何设计产品聚合和库存减少的逻辑。传统电商的思路是沿用普通电商的思路,将门票(站点区间)设计为商品(聚合根),然后为门票设计库存数量。我个人认为这很糟糕。因为一方面,这样的聚合根很多(上面的G71有408个);另一方面,即使是枚举,一次购票肯定会影响到很多其他聚合根的库存(只要是部分或完全重叠的区间都会受到影响)。这种订单处理的复杂性很难评估。而且这么多聚合根的更新必须在一个事务中,这不是给数据库丢脸吗?而且这种设计必然会带来大量的事务并发冲突,可能导致数据库死锁。总之,我认为这是一个典型的领域模型设计错误,导致高并发冲突,数据持久化难以实现。或者想解决并发问题,只能排队单线程处理,但还是解决不了在一个事务中修改大量聚合根的尴尬局面。听说12306用的是PivotalGemfire之类的高端内存数据库,但不是很了解。我无法想象如果他们不使用内存数据库(即确保所有售出的车票都符合上面讨论的业务规则),他们如何在火车内实现车票之间的强数据一致性?所以,我个人认为这种设计是固定思维,把火车票当成普通的电商产品。所以,有时候我们在设计的时候需要依靠经验,不被过去的经验束缚着实是一件很不容易的事情。关键是要结合具体的业务场景进行深入分析,尽量分析和提炼问题的本质,这样才能对症下药。有没有其他的设计思路?我的思想聚合设计通过上面的分析,我们知道,任何一次购票,其实都是针对某个车次的。我认为火车号是负责处理预订的聚合根。我们来看看一个车次包含哪些信息?列车包括:1)列车名称,如G71;2)座位数,按实际座位数划分,如商务舱20个,头等舱200个;500个二等座;为了简化这里的问题,我们可以暂时忽略类型,我认为类型不会影响核心模型设计决策。需要特别注意的是:这里的座位数不要理解为真实的物理座位数,很可能小于真实的座位数。因为我们不可能通过12306在网上把一次火车旅行的所有座位都卖掉,而只卖一部分。具体售卖座位数须由工作人员手动指定。3)经过的车站信息(包括站号、站名等),注:车次也会记录这些站之间的顺序关系;4)出发时间;看过GRASP九种模式中的信息专家模式的同学应该知道,将责任分配给拥有履行该责任所需信息的班级。在我们的场景中,车次号拥有所有出票的信息,所以我们应该把开票的责任交给车次号。另外,学过DDD的同学应该都知道,聚合设计有一个原则,就是:聚合内部强一致性,聚合之间最终一致性。经过上面的分析,我们知道生成一张票,其实会影响到与这张票对应的线段相交的其他票的可用数量。因为所有车站信息都在列车聚合内部,自然要维护所有原子区间和每个原子区间可用的车票数量(相当于库存数量)。当一个原子区间的可用车票数量为0时,表示该区间的火车票已售罄。因此,我们完全可以使用车次的聚合根来保证在发票时更新所有原子区间的可用票数的强一致性。对于trip聚合根来说,这个很简单,因为只是一些简单的内存操作,耗时可以忽略不计。如果一列火车有四个车站ABCD,那么就有3个原子区间。对于G71,就是16张。如何判断是否可以出票?基于上面的聚合设计,出票时扣除库存的逻辑是:根据订单信息,得到出发地和目的地,然后得到这个区间内的所有原子区间。然后尝试将每个原子区间的可用票数减1。如果所有原子区间都减到足够多,则购票成功;否则购票失败,提示用户票已售罄。是不是很简单?知道了出票的逻辑,退票的逻辑就很简单了,就是把这张票的所有原子区间的可用票数加1就OK了。如果我们从线段粗细的角度来考虑,那么出票的时候,每个原子区间的粗细是+1,退票的时候是负1。是相反的操作,但本质是一样的。因此,通过这种思路,我们将对一个预订的处理控制在一个聚合根中,利用聚合根中的强一致性特性来保证预订过程的强一致性,同时保证性能,免去并发冲突的可能性的需要。传统电商以票单为同类产品核心聚合根的设计,初见时觉得不妥。因为这违背了DDD强调的强一致性应该由聚合根来保证,聚合根之间的最终一致性由Saga来保证的原则。还有一个很重要的概念我想分享下我的看法,就是席位和section的关系。因为有朋友告诉我,考虑到座位号,虽然可以减1,但座位号必须一样。我认为座位是全局共享的,与section无关(也许我的理解完全错误,请指正)。座位是一个物理概念。用户购票成功后,会少一个座位。一张票只对应一个座位,但一个座位可能对应多张票;而间隔是一个逻辑概念。区间有两个作用:1)表示客票的出发地和目的地;2)记录票的可用金额。如果区间是连通的(即区间内每个原子区间的可用量都大于0),则表示允许有座。所以,我觉得座位和票(间隔)是二维的概念。车票怎么分配座位我觉得应该把所有已经售出的车票都维护在车的聚合根里面。已经售出的票的本质是间隔和座位的对应关系。当系统处理预订时,用户提交范围。因此,系统要做两件事:首先根据间隔判断是否有可用座位;如果有可用座位,则使用算法选择可用座位;当获得可用座位时,可以生成车票,然后将车票保存到行程的聚合根中。举个例子:假设现在的情况是有3个座位,站点有4个座位:1,2,3站点:abcd售票方式1:票1:ab,1票2:bc,2票3:cd,3票4:ac,3票5:bd,1这种选座方式应该效率更高,因为总是优先从席位池中获取席位,万不得已才回收可重复使用的席位票。以上4票和5票两票是考虑回收的结果。售票方式二:票1:ab,1票2:bc,1票3:cd,1票4:ac,2票5:bd,3这种选座方式应该是比较低效的,因为always优先级会就是扫描是否有可回收的座位,扫描的成本相对于直接从座位池取票来说是比较高的。以上2票和3票是考虑回收的结果。但是从座位池中优先取票的算法是有缺陷的,即虽然第一步判断有可用座位,但整个行程中的座位不一定是同一个座位。例子:假设当前情况是有3个座位,站点有4个座位:1,2,3站点:abcd售票方式3:票1:ab,1票2:bc,2票3:cd,3现在如果有人想买广告票,有2或3个座位。但无论是2座还是3座,乘客都必须中途更换车位。比如你卖给他2号位,那么他在ab处坐2号位,但在bc处他得坐1号位。否则,持2号票的人上车时,发现2号座已经有人了。但是通过优先回收算法,就不存在这个问题了。所以,从上面的分析,我们也知道了选座的算法怎么写,就是利用优先回收座位的算法。我觉得不管我们这里怎么设计算法,都不会影响全局,因为这一切只发生在车次的聚合根内部。这就是预先设计好聚合根,明确了哪个对象负责出票的好处。模型分析总结我认为车票不是核心聚合根,车票只是一张票的发行结果,只是一张凭证。12306真正的核心聚合根应该是车次。车次有出票责任。一次出票具体要做的事情是:判断是否可以出票;选择可用座位;发行票证时更新所有原子间隔的可用票证数量。用于判断下一次是否可以出票;维护所有已售票,为选座提供依据;通过这样的模型设计,我们可以保证一次出票过程只在一个列车聚合根内进行。这样做的好处是:不需要依赖数据库事务就可以实现数据修改的强一致性,因为所有的修改只发生在一个聚合根内;在保证数据强一致性的同时,还可以提供高并发处理能力,具体设计见下面的架构设计;架构设计(不是本文的重点,不感兴趣的朋友可以跳过)我觉得像12306这样的业务场景非常适合使用CQRS架构;因为首先是一个查多写少的业务,但是写逻辑上非常复杂的系统。因此非常适合在架构层面进行读写分离,即使用CQRS架构。并且您应该在数据存储也分开的地方使用CQRS。这样CQ的两端就可以在不考虑对方问题的情况下,完全优化自己的问题。我们可以在C端利用DDD领域模型的思想,用设计良好的领域模型来实现复杂的业务规则和业务逻辑。Q端使用分布式缓存方案来实现可扩展的查询能力。订票的实现思路,借助ENode这样的框架,我们可以实现in-memory+EventSourcing的架构。EventSourcing技术可以统一持久化领域模型的所有状态修改。本来聚合根的最新状态是用ORM方式保存的,现在只需要用简单通用的方式保存一个事件即可(一个booking只涉及一次trip的聚合根的修改只产生一个event,并且只需要持久化一个事件(一个JSON字符串),保证了高性能,不需要依赖事务,可以通过ENode解决并发问题)。我们只要把聚合根每次变化的事件保存下来(事件的结构怎么设计,本文就不过多介绍了,大家可以自己想想),就相当于保存了聚合的最新状态根。正是因为引入了EventSourcing技术,我们的模型才能一直存活在内存中,也就是可以使用in-memory技术。不要小看内存技术,内存技术在某些方面对提高命令处理性能很有帮助。例如,售票的逻辑是由我们火车的聚合根处理的。假设某列火车有大量命令发送到分布式消息队列,然后有一台机器订阅了这个队列的消息,然后这台机器处理了这列火车的订票。这时由于这一趟的聚合根一直在内存中,省去了每次去数据库取出聚合根的步骤,相当于少了一次数据库IO。这样做的好处是一趟火车实际能卖的车票数量是有限的,而且因为座位很少,比如只有1000个座位,估计会发2000张左右的车票一般情况下(具体能开多少张票取决于区间的交集程度,上面分析过了)。也就是说,这个聚合根只会产生2000个事件,也就是说只有2000个预订命令会产生事件和持久化事件;而其余的大量命令,因为经过内存计算发现火车数量没有剩余车票,则不会做任何修改,也不会产生领域事件,从而可以处理下一个订票命令直接地。这可以大大提高处理预订命令的性能。我觉得还有一个问题需要提一下,因为用户订票成功后,还需要付费。但用户有可能不支付或未能在规定时间内完成支付。在这种情况下,系统会自动放行用户之前订购的门票。所以,基于这样的需求,我们需要在业务上支持业务级的2pc。即先预留库存,即票被占用一定时间(比如15分钟),支付成功后才真正给你票,系统会让真正的库存修改。通过这样的预扣处理,可以保证不会出现超卖的情况。这个思路其实和淘宝等传统电商系统类似,就不展开了。我之前写的会议案例也是这个思路。有兴趣的可以看看我之前录制的视频。查询剩余票的实现思路我觉得查询剩余票的实现比较简单。虽然对于12306来说,查询请求占了80%,订单提交请求只占了20%。但是,由于查询不修改数据,我们可以使用分布式缓存来实现。我们只需要仔细设计缓存的key即可;缓存键的数量取决于成本。如果所有可能的查询都设计了对应的key,时间复杂度为1,查询性能自然就高了;但是成本也很高,因为钥匙太多了。如果想要更少的键,查询的复杂度自然会增加。所以缓存设计无非就是用空间换时间的思想。那么,缓存的更新无非就是:自动失效、定时更新、主动通知。通过CQRS架构,由于CQ的两端都是事件驱动的,当C端有任何状态变化时,都会产生相应的事件通知Q端,所以我们几乎可以做到准实时Q端更新。同时由于CQ两端完全解耦,我们可以在Q端设计多个存储,比如数据库和缓存(Redis等);数据库用于离线维护关系数据和缓存实时用户查询。数据库和缓存的更新速度互不影响,因为它们是并行执行的。同一个事件,可以有10台机器负责更新缓存,有100台机器负责更新数据库。即使数据库更新慢,也不会影响缓存的更新进度。这就是CQRS架构的好处。CQ的架构完全不同,我们可以随时重建一个新的Q端存储。不知道大家有没有体验过呢?关于cachekey的设计,我觉得主要是从查询剩余票时传递的信息考虑。12306的关键查询是:出发地、目的地、出发日期三个信息。我觉得关键的设计思路有两个:1)直接设计查询条件的key,然后快速获取车次信息,直接返回;这种方法需要我们的系统对所有车次(区间)的缓存key枚举出所有可能的车票,相信大家一定知道这样的key有很多。2)不枚举所有区间,而是以每个车次的每个原子区间(连接两个相邻车站的直线)的可用票数作为key。这样的话,key就很少了,因为如果有10000趟火车,每趟火车平均有15个区间,那么key就只有150000个。当我们要查询时,只需要找出用户输入的出发地和目的地之间的所有原子区间的可用票数,然后将原子区间与最小的可用票数进行比较即可。那么这个原子区间的可用票数就是用户输入的区间的可用票数。当然,这里我提到了考虑出发日期。我认为出发日期是用来确定它是哪个火车聚合根的。相同的车次,不同的日期,对应的聚合根实例是不同的,即使是同一天,也可能有多个列车聚合根,因为有的火车一天有好几班,比如某班9:00发车的火车上午、下午一般3点出发。因此,我们只需要将日期作为缓存键的一部分。总结本文完全是基于自己对12306网站核心业务的简单思考得出的一些设计结果。如果真正的DDD领域建模需要与一线业务人员和领域专家进行更深入的交流,才能对本领域的业务知识有更深入的了解,从而设计出更可靠的领域模型和架构设计。很惭愧,因为我从来没有买过12306的火车票。我家离得比较近,就算想买,家人也会帮我买:)所以,本文分享的内容难免只是纸上谈兵。但是我觉得12306系统的业务确实比传统电商系统复杂,并发度那么高。因此,我觉得这个系统确实值得大家关注模型的设计,而不仅仅是技术层面的实现。2016年打算基于ENode实现12306的一套核心功能,比如查询余票、订票等。