秒没售罄?!票务系统架构原来是这样设计的……

MobotStone 2024-03-17 11:25:00
作者介绍

MobotStone某AI大厂高级系统架构师,从事教育领域系统架构设计,ToG方向。

 

随着信息技术和互联网的发展,票务系统也在不断升级,比如实现了移动支付、电子票据、实时数据分析等先进功能。此外,许多票务系统还引入了人工智能和大数据技术,用于精准营销、个性化推荐和风险管理。

 

然而,票务系统也存在一些挑战,如何保护用户隐私,如何防止票务欺诈,以及如何提供更好的用户体验等。因此,票务系统的开发和运营需要考虑到这些问题,并持续改进和升级。

 

图片

 

项目简介:大麦网是中国的领先在线票务平台,提供多样化的活动票务,如音乐会、戏剧和体育赛事等。主要功能包括活动搜索、在线购票、电子票务、实时座位选择、退换票服务以及支付接口。其智能推荐系统可以根据用户兴趣推送相关活动,为用户提供方便、快捷的一站式购票体验。

 

类似的产品有:猫眼娱乐、永乐票务、bookmyshow.com、ticketmaster.com

 

难度级别:困难

 

一、什么是在线电影票预订系统

 

电影票预订系统为其客户提供在线购买影院座位的能力。电子票务系统允许客户浏览当前正在上映的电影,并在任何地方任何时候预订座位。

 

二、系统的需求和目标

 

我们的票务预订服务应满足以下需求:

 

功能需求:

 

  • 我们的票务预订服务应能列出其联盟影院所在的不同城市。

  • 用户选择城市后,服务应显示该特定城市已经上映的电影。

  • 用户选择电影后,服务应显示正在放映该电影的影院及其可用的放映时间。

  • 用户应能选择在特定影院的一场放映并预订他们的票。

  • 服务应能向用户展示影院大厅的座位布局。用户应能根据他们的喜好选择多个座位。

  • 用户应能从已预订的座位中区分出可用的座位。

  • 用户应能在付款以完成预订之前,将座位保留五分钟。

  • 如果有可能座位会变得可用,例如,当其他用户的保留到期时,用户应能等待。

  • 等待的客户应以公平的、先到先得的方式服务。

 

非功能性需求:

 

  • 系统需要具有高度并发性。在任何特定时间点,都会有多个对同一座位的预订请求。服务应能优雅且公平地处理这一情况。

  • 服务的核心是票务预订,也就意味着涉及到财务交易。这意味着系统应具有安全性,并且数据库应遵守ACID(原子性、一致性、隔离性、持久性)原则。

 

三、一些设计考虑

 

  • 为了简便,我们假设我们的服务不需要任何用户认证。

  • 系统将不处理部分票务订单。用户要么获得他们想要的所有票,要么一张也得不到。系统必须公平。

  • 为了阻止系统被滥用,我们可以限制用户一次预订不超过十个座位。

  • 我们可以假设在热门/备受期待的电影上映时,流量会激增,座位会很快被预订完。

  • 系统应具有可扩展性和高可用性,以应对流量激增。

 

四、容量估计

 

  • 流量估计:我们假设我们的服务每月有30亿次页面浏览,每月售出1000万张电影票。

 

  • 存储估计:假设我们有500个城市,平均每个城市有10家影院。如果每个影院有2000个座位,平均每天有两场放映。

 

我们假设每个座位预订需要50字节(ID、NumberOfSeats、ShowID、MovieID、SeatNumbers、SeatStatus、Timestamp 等)存储在数据库中。我们还需要存储关于电影和影院的信息;我们假设它会需要50字节。所以,要存储所有城市的所有影院的所有放映的所有数据一天:

 

500个城市 * 10家影院 * 2000个座位 * 2场放映 * (50+50) 字节 = 2GB / 天

 

要存储五年的这些数据,我们大约需要3.6TB。

 

五、系统API

 

我们可以有SOAP或REST API来公开我们服务的功能。以下可能是搜索电影放映和预订座位的API的定义。

 

  •  
SearchMovies(api_dev_key, keyword, city, lat_long, radius, start_datetime, end_datetime, postal_code, includeSpellcheck, results_per_page, sorting_order)

 

参数:

 

  • api_dev_key (string):注册账户的API开发者密钥。这将用于包括限制用户基于其分配的配额等在内的事情。 

  • keyword (string):要搜索的关键词。 

  • city (string):用于筛选电影的城市。 

  • lat_long (string):用于筛选的纬度和经度。 

  • radius (number):我们想要搜索活动的区域的半径。 

  • start_datetime (string):用开始日期时间筛选电影。 

  • end_datetime (string):用结束日期时间筛选电影。 

  • postal_code (string):用邮政编码/邮编筛选电影。 

  • includeSpellcheck (Enum: "yes" or "no"):是否在响应中包含拼写检查建议。

  • results_per_page (number):每页返回的结果数。最大为30。 

  • sorting_order (string):搜索结果的排序顺序。一些可允许的值:'name,asc','name,desc','date,asc','date,desc','distance,asc','name,date,asc','name,date,desc','date,name,asc','date,name,desc'。

 

返回:(JSON) 以下是电影及其放映的示例列表:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
{    "MovieID": 1,    "ShowID": 1,    "Title": "Cars 2",    "Description": "About cars",    "Duration": 120,    "Genre": "Animation",    "Language": "English",    "ReleaseDate": "8th Oct. 2014",    "Country": USA,    "StartTime": "14:00",    "EndTime": "16:00",    "Seats":     [    {          "Type": "Regular"        "Price": 14.99        "Status: "Almost Full"    },    {          "Type": "Premium"        "Price": 24.99        "Status: "Available"    }]},{    "MovieID": 1,    "ShowID": 2,    "Title": "Cars 2",    "Description": "About cars",    "Duration": 120,    "Genre": "Animation",    "Language": "English",    "ReleaseDate": "8th Oct. 2014",    "Country": USA,    "StartTime": "16:30",    "EndTime": "18:30",    "Seats":     [        {          "Type": "Regular"        "Price": 14.99        "Status: "Full"    },        {          "Type": "Premium"        "Price": 24.99        "Status: "Almost Full"    }]}

 

  •  
ReserveSeats(api_dev_key, session_id, movie_id, show_id, seats_to_reserve[])

 

参数:

 

  • api_dev_key (string):与上面相同

  • session_id (string):用户的会话ID,用于跟踪此预订。一旦预订时间到期,将使用此ID在服务器上删除用户的预订。

  • movie_id (string):预订的电影。

  • show_id (string):预订的放映。

  • seats_to_reserve (number):包含要预订的座位ID的数组。

 

返回:(JSON)

 

返回预订的状态,其中包括以下之一:1) "预订成功" 2) "预订失败 - 放映已满",3) "预订失败 - 请重试,因为其他用户正在保留预订座位"。

 

六、数据库设计

 

以下是我们即将存储的数据的一些观察:

 

  • 每个城市可以有多个影院。

  • 每个影院将有多个影厅。

  • 每部电影将有多场放映,每场放映将有多次预订。

  • 一个用户可以有多次预订。

 

图片

 

七、顶层设计

 

在顶层面上,我们的web服务器将管理用户的会话,应用服务器将处理所有的票务管理,将数据存储在数据库中,以及与缓存服务器一起处理预订。

 

图片

 

八、组件设计

 

首先,我们试着建立服务,假设它是由一个单一的服务器提供的。

 

以下将是典型的票务预订流程:

 

1. 用户搜索一部电影。

2. 用户选择一部电影。

3. 向用户显示该电影的可用场次。

4. 用户选择一场放映。

5. 用户选择要预订的座位数量。

6. 如果需要的座位数可用,用户将看到一个剧院的地图以选择座位。如果不是,用户将进入下面的“步骤8”。

7. 一旦用户选择了座位,系统将尝试预订这些选定的座位。

8. 如果无法预订座位,我们有以下选项:

 

  • 放映已满;向用户显示错误消息。

  • 用户想预订的座位已经没有了,但是还有其他座位可用,所以用户被带回到剧院地图页面以选择不同的座位。

  • 没有可预订的座位,但所有座位都还没有被预订,因为有些座位被其他用户在预订池中保留并且还没有预订。用户将被带到一个等待页面,在那里他们可以等待直到需要的座位从预订池中释放。这个等待可能会导致以下选项:

  • 如果需要的座位数变得可用,用户将被带到剧院地图页面,他们可以选择座位。

  • 在等待过程中,如果所有座位都被预订了,或者预订池中的座位数少于用户打算预订的座位数,用户将被显示错误消息。

  • 用户取消等待,返回到电影搜索页面。

  • 最多,用户可以等待一个小时,之后用户的会话将过期,用户将被带回到电影搜索页面。

 

如果成功预订了座位,用户有五分钟的时间支付预订。付款后,预订标记为完成。如果用户不能在五分钟内支付,他们所有的预订座位都将被释放,以供其他用户使用。

 

图片

 

服务器如何跟踪所有尚未预订的活动预订?服务器又如何跟踪所有等待的客户? 

 

我们需要两个守护服务,一个用来跟踪所有活动的预订并从系统中移除任何过期的预订;我们称之为ActiveReservationService。另一个服务将跟踪所有等待的用户请求,一旦需要的座位数变得可用,它将通知(等待时间最长的)用户选择座位;我们称之为WaitingUserService

 

 
1. ActiveReservationsService(活动预订服务)

 

我们可以在内存中保留一个与Linked HashMap或TreeMap相似的数据结构来存储一场“演出”的所有预订,除了在数据库中保留所有数据。我们需要一种Linked HashMap类型的数据结构,它允许我们在预订完成时跳转到任何预订以移除它。此外,由于我们将有与每个预订关联的到期时间,HashMap的头部将始终指向最旧的预订记录,以便在达到超时时过期预订。

 

为了存储每场演出的每个预订,我们可以有一个HashTable,其中'key'是'ShowID','value'是包含'BookingID'和创建'Timestamp'的Linked HashMap。

 

在数据库中,我们将在'Booking'表中存储预订,到期时间将在Timestamp列中。'Status'字段将有一个值为'Reserved (1)'的值,一旦预订完成,系统将更新'Status'为'Booked (2)'并从相关演出的Linked HashMap中删除预订记录。当预订过期时,我们可以从Booking表中移除它,或者将其标记为'Expired (3)',除此之外还要从内存中移除。

 

ActiveReservationsService也将与外部金融服务一起处理用户支付。每当预订完成或预订过期时,WaitingUsersService都会收到一个信号,以便可以为任何等待的客户提供服务。

 

图片

 

 
2. WaitingUsersService(等待用户服务)

 

就像ActiveReservationsService一样,我们可以将一个演出的所有等待用户存储在Linked HashMap或TreeMap的内存中。我们需要一个类似于Linked HashMap的数据结构,以便我们可以在用户取消请求时跳转到任何用户以从HashMap中移除他们。此外,由于我们是以先到先得的方式服务,Linked HashMap的头部总是指向等待时间最长的用户,因此每当座位变得可用时,我们都可以以公平的方式为用户提供服务。

 

我们将有一个HashTable用来存储每个Show的所有等待用户。'key'将是'ShowID','value'将是包含'UserIDs'和他们的等待开始时间的Linked HashMap。

 

客户端可以使用Long Polling来保持自己的预订状态更新。每当座位变得可用时,服务器可以使用这个请求来通知用户。

 

预订过期

 

在服务器上,ActiveReservationsService跟踪活动预订的过期时间(基于预订时间)。由于客户端将显示一个计时器(用于过期时间),这可能与服务器稍微不同步,我们可以在服务器上添加五秒钟的缓冲区以防止破碎的体验,从而确保客户端在服务器超时后永不超时,防止成功购买。

 

九、并发性

 

如何处理并发性,以便没有两个用户能够预订同一座位。我们可以在SQL数据库中使用事务来避免任何冲突。例如,如果我们使用的是SQL服务器,我们可以利用事务隔离级别来锁定行,然后再更新它们。下面是样本代码:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
    -- Suppose we intend to reserve three seats (IDs: 54, 55, 56) for ShowID=99     Select * From Show_Seat where ShowID=99 && ShowSeatID in (54, 55, 56) && Status=0 -- free     -- if the number of rows returned by the above statement is three, we can update to     -- return success otherwise return failure to the user.    update Show_Seat ...    update Booking ...
COMMIT TRANSACTION;

 

'Serializable' 是最高的隔离级别,可以保证免受脏读、不可重复读和幻读的影响。这里要注意一点;在一个事务中,如果我们读取了行,我们会在这些行上加写锁,以防止它们被任何其他人更新。

 

一旦上述数据库事务成功,我们就可以开始在ActiveReservationService中跟踪预订情况。

 

十、容错性

 

当ActiveReservationsService或WaitingUsersService崩溃时会发生什么?每当ActiveReservationsService崩溃时,我们可以从‘Booking’表中读取所有的活动预订。请记住,直到预订完成,我们都将“Status”列保持为“Reserved (1)”。另一个选择是拥有主-次配置,这样,当主服务崩溃时,次服务可以接管。我们没有将等待的用户存储在数据库中,所以,当WaitingUsersService崩溃时,除非我们有主次设置,否则我们没有任何方式恢复那些数据。

 

同样,我们会为数据库设置主次配置,以使其具有容错性。

 

十一、数据分区

 

  • 数据库分区:如果我们按‘MovieID’进行分区,那么一部电影的所有场次都会在同一个服务器上。对于热门电影来说,这可能会给那台服务器带来大量负载。更好的方法是根据ShowID进行分区;这样,负载就可以分散到不同的服务器上。

 

  • ActiveReservationService 和 WaitingUserService 分区:我们的Web服务器将管理所有活动用户的会话,并处理与用户的所有通信。我们可以使用一致性哈希算法来根据‘ShowID’为ActiveReservationService和WaitingUserService分配应用服务器。这样,特定场次的所有预订和等待用户将由某一组服务器处理。假设为了负载平衡,我们的"一致性哈希"为任何场次分配了三个服务器,那么每当一个预订过期时,持有该预订的服务器将执行以下操作:

     

  • 如果需要的座位数变得可用,用户将被带到剧院地图页面,他们可以选择座位。

  • 更新数据库以移除预订(或标记为过期)并更新‘Show_Seats’表中座位的状态。

  • 从Linked HashMap中移除预订。

  • 通知用户他们的预订已过期。

  • 向所有持有该场次等待用户的WaitingUserService服务器广播消息,以找出等待时间最长的用户。一致性哈希方案将告诉我们哪些服务器持有这些用户。

  • 如果所需的座位已经变为可用,就向持有最长等待用户的WaitingUserService服务器发送消息以处理他们的请求。

 

每当一个预订成功时,将发生以下事情:

 

  • 持有该预订的服务器向所有持有该场次等待用户的服务器发送消息,以便这些服务器可以使所有需要的座位数多于可用座位数的等待用户过期。

 

  • 收到上述消息后,所有持有等待用户的服务器将查询数据库,以查找现在有多少个空闲座位。此处的数据库缓存将大大有助于只运行一次这个查询。

 

  • 使所有希望预订的座位数多于可用座位数的等待用户过期。为此,WaitingUserService必须遍历所有等待用户的Linked HashMap。

 

源丨juejin.cn/post/7252684331712692284
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
最新评论
访客 2024年04月08日

如果字段的最大可能长度超过255字节,那么长度值可能…

访客 2024年03月04日

只能说作者太用心了,优秀

访客 2024年02月23日

感谢详解

访客 2024年02月20日

一般干个7-8年(即30岁左右),能做到年入40w-50w;有…

访客 2023年08月20日

230721

活动预告