likes
comments
collection
share

《System Design》 23 — Hotel Reservation System

作者站长头像
站长
· 阅读数 51

在本章中,我们将为万豪这样的连锁酒店设计一个预订系统。本章中使用的设计和方案也适用于其他跟预订相关的面试话题,如:

  • 设计Airbnb
  • 设计一个航班预订系统
  • 设计一个电影票预订系统

第一步 - 理解问题并确定设计范围

酒店的预订系统比较复杂,而且系统功能模块的范围会随着业务场景的变化而变化。在深入到系统设计之前,你应该向面试官多确认问题以缩小需要考虑的业务范围。

候选人:系统需要考虑的酒店量级是多大?

面试官:一个拥有5000家分店和100万间客房的连锁酒店。

候选人:客户是在预订时付款还是到达酒店时付款?

面试官:为了简单起见,他们在预订时全额付款。

候选人:客户只通过酒店的网站预订酒店房间吗?我们需要支持其他预订选项,如电话预订吗?

面试官:假设客户仅可以通过酒店网站或App下单预定。

候选人:客户可以取消他们的预订吗?

面试官:是的。

候选人:我们还需要考虑什么其他事项吗?

面试官:是的,我们允许超额预订10%,超额预订意味着酒店会卖出比他们实际剩余量更多的房间,这样做的原因是多数场景下有些客户会取消他们的预订。

候选人:由于我们的时间有限,我假设酒店房间搜索不在范围内。我们专注于以下功能。

  • 查看酒店首页
  • 查看酒店房间的详细信息
  • 预订房间
  • 用于添加/删除/更新酒店或房间信息的B端管理面板
  • 支持超额预订功能。

面试官:对的。

面试官:还有一件事,房间的价格是动态变化的。实际的价格取决于酒店指定日期的入住率。在此次面试中,我们可以先简单假设房间每天的价格都可能不同。

候选人:好的。

接下来,你可能想确认最重要的非功能性需求。

非功能性需求

  • 支持高并发。在旺季或大型活动期间,可能会有很多客户同时尝试预订同一间房。
  • 适度延迟。理想状态下用户预订后的响应越快越好,但几秒钟时间是可以接受的。

量级预估

  • 5000家酒店,总共100万间客房。
  • 假设70%的房间是被预定的,每个房间预定的平均时间为3天。
  • 每日预订量预估:(100万 * 0.7) / 3 = 233,333(约240,000)
  • 每秒预订量 = 240,000 / 100000秒 ~= 3。如我们所见,平均每秒交易预订量(TPS)并不高。

接下来,让我们对系统中所有页面的查询QPS做一个粗略计算。客户有三个常见操作:

  1. 查看酒店/房间详情页。用户可以在此页面浏览信息(查询)。
  2. 查看预订页面。用户可以在预订前确认预订细节,如日期、预定人数、支付信息(查询)。
  3. 预订房间。用户点击“预订”按钮预订房间,随后房间被保留(交易)。

假设每个流程都有10%的用户到达下一步,90%的用户在到达最终步骤之前退出流程。我们还可以假设没有实现预加载功能(在用户到达下一步之前预加载下一步内容)。图1显示了每一步的预估QPS。我们知道最终预订的TPS是3,所以我们可以沿着漏斗向后计算。订单确认页的QPS是30,详情页的QPS是300。

《System Design》 23 — Hotel Reservation System

第二步 - 概要设计

在这一部分,我们将讨论:

  • API设计
  • 数据模型
  • 概要设计

API设计

我们将进行预订系统的API设计。下面基于RESTFUL原则列出了需求场景中最重要的API。

请注意,本章着重于酒店预订系统的设计。对于一个完整的酒店网站,为了便于用户基于大量的标准来搜索房间,酒店网站也需要提供更多直观易用的功能。这些搜索功能的API虽然重要,但技术上并不具有挑战性,因此它们不在本章的范围内。

酒店相关API

API描述
GET /v1/hotels/ID获取酒店详情
POST /v1/hotels添加一个新酒店。此API仅对酒店员工可用
PUT /v1/hotels/ID更新酒店信息。此API仅对酒店员工可用
DELETE /v1/hotels/ID删除酒店。此API仅对酒店员工可用

房间相关API

API描述
GET /v1/hotels/ID/rooms/ID获取房间详情
POST /v1/hotels/ID/rooms添加房间。此API仅对酒店员工可用
PUT /v1/hotels/ID/rooms/ID更新房间。此API仅对酒店员工可用
DELETE /v1/hotels/ID/rooms/ID删除房间。此API仅对酒店员工可用

预订相关API

API描述
GET /v1/reservations获取已登录用户的预订记录
GET /v1/reservations/ID获取具体订单的详情
POST /v1/reservations预定房间
DELETE /v1/reservations/ID取消预定。此API仅对酒店员工可用

预订是一个非常重要的功能。创建新预订(POST /v1/reservations)的入参可能包含以下信息:

{
  "startDate":"2021-04-28",
  "endDate":"2021-04-30",
  "hotelID":"245",
  "roomID":"U12354673389",
  "reservationID":"13422445"
}

请注意 reservationID 被用于避免重复预定的幂等键,重复预定意味着同一天的同一个房间可能存在被多次预定的情况。详细方案我们将在下文的「深入设计」章节的「并发问题」中讨论。

数据模型

在我们决定使用哪种数据库之前,让我们仔细研究一下数据请求的场景。对于酒店预订系统,我们需要支持以下查询:

  • 查询1:查看酒店的详细信息
  • 查询2:基于日期范围找到可用的房间类型
  • 查询3:存储预订信息
  • 查询4:查询某个预订详情或预订历史

根据之前的估算,我们知道用户规模不大,但我们需要为大型活动期间的流量突然做准备。基于这些要求,我们选择关系型数据库,原因包含以下几个方面:

  • 关系数据库适合读多写少的场景。访问酒店网站和APP的用户数量比实际进行预订的用户数量要高几个数量级。NoSQL数据库通常针对写操作进行了优化,而关系数据库对于读多的场景表现足够稳健。
  • 关系数据库提供ACID的保证。对于预订系统来说,ACID属性至关重要,可以有效防止负余额、双重收费、双重预订等问题。ACID属性会使代码层便捷很多,从而整个系统更容易理解和维护。
  • 关系数据库可以轻松对数据进行领域建模。数据结构会非常清晰,而且不同实体(酒店、房间、房间类型等)之间的关系页足够稳定。这种数据模型很容易被关系数据库表示和建模。

现在我们选择关系型数据库作为我们的数据存储,让我们探索一下schema设计。图2展示了一个简单直观的schema设计,对于许多候选人来说,这也是酒店预订系统最自然的表示方式。

《System Design》 23 — Hotel Reservation System

上图中每个实体的大多数属性都是清晰的,我们只在解释下reservation预定表中的status字段。status字段可以处于以下几种状态之一:待处理(pending)、已支付(paid)、已退款(refunded)、已取消(canceled)、已拒绝(rejected),状态机如下图所示。

《System Design》 23 — Hotel Reservation System

这个架构设计存在一个关键的问题。这个数据模型适用于像Airbnb这样的公司,因为用户在预订时会给出明确的room_id(也可能被称为listing_id)。但是,对于酒店来说并不满足。用户预订实际上是预定一种房间类型,而不是特定的房间,比如预定的房间类型可以是标准房或大床房等。房间号是在客人入住时才会给出的,而不是在预订时给出。因此,我们需要更新我们的数据模型以满足这个需求。更多详情请参见「深入设计」部分的「数据模型改进」。

概要设计

我们采用微服务架构来设计这个系统。在过去的几年中,微服务架构获得了巨大的推广和流行。使用微服务的公司包括亚马逊、奈飞、优步、Airbnb、推特等。

我们的设计以微服务架构为模型,概要设计图如下图所示。

《System Design》 23 — Hotel Reservation System

我们将从上到下简要介绍系统的每个组件。

  • 用户:用户通过手机或电脑预订酒店房间。
  • 管理员(酒店工作人员):有权限的酒店工作人员执行管理员操作,如给用户退款、取消预订、更新房间信息等。
  • CDN(内容分发网络):为了更好的加载时长,所有的静态资源都可以被CDN缓存,包括JavaScript包、图片、视频、HTML等。
  • 公共API网关(Public API GateWay):这是一个支持速率限制、认证等的网关服务。API网关被配置为基于端点将请求指向特定的服务。例如,加载酒店主页的请求指向酒店服务,预订酒店房间的请求路由到预订服务。
  • 内部API(Internal API):这些API只对授权的酒店工作人员可用。它们通过内部软件或局域网访问,通常通过VPN(虚拟私人网络)进一步保护。
  • 酒店服务(Hotel Service):提供有关酒店和房间的详细信息。酒店和房间数据通常是静态的,所以可以容易地被缓存。
  • 价格服务(Rate Service):为未来日期的房间标价。酒店行业的一个有趣事实是,房间的价格取决于酒店的入住率。
  • 预订服务(Reservation Service):接收预订请求并预订酒店房间。如果房间被预订或预订被取消,该服务还会处理房间的库存。
  • 付款服务(Payment Service):接受客户付款,并在付款交易成功后将预订状态更新为“已支付”,或在交易失败时更新为“已拒绝”。
  • 酒店管理服务(Hotel Management Serice):仅对授权的酒店工作人员可用。酒店员工有资格使用以下功能:查看未来时间的预定信息,为客户预订房间,取消预订等。

为了图片的清晰,图4省略了许多微服务之间交互的箭头。例如,如下图所示,应有一个箭头从预订服务指向价格服务。预订服务查询价格服务以获取房间价格,用于计算预订的总房费。另一个例子是,酒店管理服务与其他大部分服务之间应该有很多箭头。当管理员通过酒店管理服务进行更改时,请求被转发到拥有对应领域数据的实际服务来处理变更。

《System Design》 23 — Hotel Reservation System 在生产环境,服务间通信常常采用现代高性能的远程过程调用(RPC)框架,如gRPC。使用这样的框架有很多好处。

第3步 - 深入设计

之前我们已经讨论了概要设计,现在让我们进一步深入地讨论几个方面:

  • 改进数据模型
  • 并发问题
  • 系统地拓展
  • 微服务架构下解决数据不一致

改进数据模型

如上所述,当我们预订酒店房间时,实际上是预订了一种房间类型,而不是一个具体的房间号。我们接下来探讨下怎么调整api?

对于预订接口API,请求参数中的roomID被roomTypeID替换。预订API的信息应该如下:

POST /v1/reservations

请求参数:

{  
    "startDate":"2021-04-28",  
    "endDate":"2021-04-30",  
    "hotelID":"245",  
    "roomTypeID":"12354673389",  
    "roomCount":"3",  
    "reservationID":"13422445"  
}

更新后的schema图如下所示。

《System Design》 23 — Hotel Reservation System

我们将简要介绍一些最重要的表。

  • room:房间的相关信息
  • room_type_rate:存储未来日期特定房间类型的价格数据
  • reservation:客人预订数据
  • room_type_inventory:存储有关酒店房间的库存数据。这个表对于预订系统来说非常重要,因此让我们仔细查看每个参数。
    • hotel_id:酒店的ID
    • room_type_id:房间类型的ID。
    • date:具体日期。
    • total_inventory:总房间数量减去临时从库存中拿出的部分。一些房间可能因维护而从下架
    • totalreserved:指定日期specified_hotel_id和room_type_id下的预订总数

room_type_inventory表的设计有其他方法,但按行拆分不同日期的数据,会使指定日期范围内的预订和查询变得简单。如schema图所示,(hotel_id, room_type_id, date)是联合主键。我们通过查询未来2年内所有日期的库存数据并预先填充在表中。通常我们可以有一个定期任务,随着时间的推移定期填充新日期的库存数据。

现在我们已经确定了表结构的设计,让我们估算一下占用的存储空间。我们有5,000家酒店,假设每家酒店有20种房间类型。那就是(5000酒店 * 20种房间类型 * 2年 * 365天) = 7300万行。7300万数据量并不大,并且一台数据库足以存储这些数据。然而,单一服务器意味着单点故障。为了实现高可用性,我们可以在多个地区或可用区域保留多个数据库备份。

下表给出了"room_type_inventory"的一些样本数据。

hotel_idroom_type_iddatetotal_inventorytotal_reserved
21110012021-06-0110080
21110012021-06-0210082
21110012021-06-0310086
2111001......
21110012023-05-311000
21110022021-06-0120016
22101012021-06-013023
22101012021-06-023025

room_type_inventory 表用于检查用户是否可以预订特定类型的房间。预订的输入和输出示例如下:

  • 输入:入住日期 startDate (2021-07-01), 退房日期 endDate (2021-07-03), 房间类型 roomTypeId, 酒店 hotelId, 房间数量numberOfRoomsToReserve
  • 输出:如果指定类型的房间有库存并且用户可以预订,则返回 True。否则返回false。

从 SQL 的角度来看,它包含以下两个步骤:

  1. 选择指定日期范围内的行
SELECT date, total_inventory, total_reserved
FROM room_type_inventory
WHERE room_type_id = ${roomTypeId} AND hotel_id = ${hotelId}
AND date between ${startDate} and ${endDate}

该sql查询返回的数据如下:

datetotal_inventorytotal_reserved
2021-07-0110097
2021-07-0210096
2021-07-0310095
  1. 对于每行数据,判定以下条件:

if (total_reserved + ${numberOfRoomsToReserve}) <= total_inventory

如果所有判定的结果都是true,这意味着在指定范围内的每个日期都有足够的房间可以被预定。设计中有一个要求是允许10%的超卖。可以按照下面的语句简单支持:

if (total_reserved + ${numberOfRoomsToReserve}) <= 110% * total_inventory

此时,面试官可能会提出一个问题:"如果预订数据太大,无法使用单一数据库,你会怎么处理"?有几种方案:

  • 只存储当前和未来的预订数据。预订历史不经常被访问。所以它们可以被归档,甚至可以被移动到冷存储。
  • 数据库分片。最频繁的查询场景包含「预订」和「查找订单」。在这两种场景下,我们需要首先选择酒店,这意味着 hotel_id 是一个好的分片键。数据可以通过 hash(hotel_id) % number_of_servers 进行分片。

并发问题

另一个重要问题是重复预订。我们需要解决两个问题:

  1. 同一用户多次点击“预订”按钮
  2. 多个用户同时尝试预订同一间房

让我们先关注第一个问题。如下图所示,单个用户进行了两次预订。

《System Design》 23 — Hotel Reservation System 有两种常见的解决方案:

  • 客户端实现。客户端可以在发送预定请求置灰、隐藏或禁用“提交”按钮。这个能解决大部分的问题。然而,方法本身并不完全可靠。例如,用户可以禁用 JavaScript,从而绕过客户端检查。
  • 幂等 API。在预订请求中添加一个幂等键。对于一个幂等键,无论调用多少次,预定接口都返回相同的结果。下图显示了如何使用幂等键(reservation_id)来避免双重预订问题。详细步骤如下所述。

《System Design》 23 — Hotel Reservation System

  1. 生成预订订单。在客户输入有关预订的详细信息(房型,入住日期,退房日期等)并点击“继续”按钮后,预订服务会生成一个预订订单。
  2. 系统为客户生成一个预订订单以供确认。reservation_id 由全局唯一ID生成器生成,并作为接口响应的一部分返回。这一步的UI示例图如下图所示。
  3. 提交订单。
    • 提交第1次预订。reservation_id 作为请求的一部分,而且是预订表的主键。请注意,幂等键不一定是 reservation_id。我们选择 reservation_id 是因为它已经存在并且适合我们的场景。
    • 提交第2次预定。如果用户第二次点击“完成我的预订”按钮,由于 reservation_id 是预订表的主键,我们可以依靠键的唯一约束来确保不会发生双重预订。

《System Design》 23 — Hotel Reservation System

下图解释了为什么可以避免重复预定问题。

《System Design》 23 — Hotel Reservation System

问题2:当只剩下一间房时,如果多个用户同时预订同一类型的房间会发生什么?让我们考虑如下图所示的场景。

《System Design》 23 — Hotel Reservation System

  1. 假设数据库隔离级别不是可串行化的。用户1 和用户2 尝试同时预订同一类型的房间,但只剩下 1 间房。我们称 用户1 的操作为“事务 1”,用户2 的操作为“事务 2”。此时,酒店有 100 间房,但其中 99 间已被预订。
  2. 事务 2 通过检查 (total_reserved + rooms_to_book) <= total_inventory 来检查是否有足够的剩余房间库存。由于还剩 1 间房,它返回 true。
  3. 事务 1 通过检查 (total_reserved + rooms_to_book) <= total_inventory 来检查是否有足够的房间。由于还剩 1 间房,它也返回 true。
  4. 事务 1 预订了房间并更新了库存:reserved_room 变为 100。
  5. 然后事务 2 预订了房间。ACID 中的隔离性质意味着数据库事务必须独立于其他事务。因此,事务 1 所做的数据更改在事务 1 完成(提交)之前对事务 2 不可见。所以事务 2 仍然将 total_reserved 视为 99 并更新库存且成功预订了房间,此时reserved_room 变为 100。这导致只剩下 1 间房的情况下,系统仍然允许来两个用户成功地预订了房间。
  6. 事务 1 成功提交更改。
  7. 事务 2 成功提交更改。

解决这个问题通常需要某种形式的锁定机制。我们探讨以下技术:

  • 悲观锁
  • 乐观锁
  • 数据库约束

在进入具体修复操作之前,让我们看看预订房间的 SQL 伪代码。这个SQL 包含两部分:

  • 检查房间库存
  • 预订房间
# 步骤 1:检查房间库存
SELECT date, total_inventory, total_reserved
FROM room_type_inventory
WHERE room_type_id = ${roomTypeId} AND hotel_id = ${hotelId}
AND date between ${startDate} and ${endDate}

# 对于步骤 1 返回的每一行执行下面的判断
if((total_reserved + ${numberOfRoomsToReserve}) > 110% * total_inventory) {
  Rollback
}

# 步骤 2:预订房间
UPDATE room_type_inventory
SET total_reserved = total_reserved + ${numberOfRoomsToReserve}
WHERE room_type_id = ${roomTypeId}
AND date between ${startDate} and ${endDate}

commit

方案1:悲观锁

悲观锁,也称为悲观并发控制,在一个用户开始更新记录时通过对记录加锁来防止并发更新。尝试更新该记录的其他用户必须等到第一个用户释放锁(提交了更改)之后才能操作变更。

对于 MySQL 而言,"SELECT ... FOR UPDATE" 语句会锁定 SELECT 查询返回的行。让我们假设由“事务 1”启动一个事务。其他事务必须等待事务 1 完成之后才能开始另一个事务。详细说明如图所示。

《System Design》 23 — Hotel Reservation System

在上图中,事务 2 的 “SELECT ... FOR UPDATE” 语句因为事务 1 锁定了行而等待事务 1 完成。事务 1 完成后,total_reserved 变为 100,这意味着用户 2 没有空间可预订。

优点:

  • 防止系统更新正在被更新或刚已经被更新的数据。
  • 易于实现,并通过序列化更新来避免冲突。在数据竞争激烈时,悲观锁会很有用。

缺点:

  • 当多个资源被锁定时,可能会发生死锁。编写无死锁的系统代码可能是一个挑战。
  • 这种方法不可扩展。如果一个事务锁定时间过长,其他事务不能访问被锁定的资源。这对数据库性能有重大影响,特别是当事务生命周期较长或涉及多个实体时。

基于这些限制,我们不推荐为预订系统使用悲观锁定。

方案2:乐观锁

乐观锁,也称为乐观并发控制,允许多个并发用户尝试更新同一资源。

实现乐观锁定有两种常见方法:版本号和时间戳。一般认为版本号是更好的选择,因为服务器时钟随着时间的推移可能变得不太准确。我们阐述一下如何使用版本号来实现乐观锁。

下图分别列出了一个成功和失败案例。

《System Design》 23 — Hotel Reservation System

  1. 在表中添加一个名为“version”的新列。
  2. 用户修改数据库行之前,系统会读取该行的版本号。
  3. 当用户更新行时,系统将版本号增加 1 并写回数据库。
  4. 数据库需要相应的验证检查;下一个版本号应比当前版本号大 1。如果验证失败,事务中止,用户回退到步骤 2 再次尝试。

通常情况下,乐观锁要比悲观锁快,因为我们不锁定数据库。然而,当并发性高时,乐观锁的性能会大幅下降。

让我们解释一下原因。考虑许多客户端同时尝试预订酒店房间的情况,由于不限制客户端的数量,会有多个客户端都读取相同的可用房间数和当前版本号。当不同的客户端进行预订并将结果写回数据库时,只有一个会成功,其余客户端收到版本检查失败的消息。这些客户端必须重试,在随后的重试轮次中,又只有一个成功的客户端,其余必须重试。尽管最终结果是正确的,但重复的重试会导致非常不愉快的用户体验。

优点:

  • 防止程序编辑过期数据。
  • 不需要锁定数据库资源。从数据库的角度来看,实际上没有锁定发生。这完全由应用程序来处理版本号逻辑。
  • 通常用于写冲突少的场景。当冲突很少时,事务可以不管理锁。

缺点:

  • 当数据竞争激烈时,性能较差。

乐观锁是酒店预订系统的一个好选择,因为预订的 QPS 通常不高。

方案3:数据库约束

这种方法与乐观锁非常相似。在 room_type_inventory 表中,添加以下约束:

CONSTRAINT check_room_count CHECK((total_inventory - total_reserved >= 0))

如下图所示,当用户 2 尝试预订房间时,total_reserved 变为 101,违反了 total_inventory (100) - total_reserved (101) >= 0 的约束,因此事务将回滚。

《System Design》 23 — Hotel Reservation System

优点:

  • 易于实现。
  • 当数据竞争最小时性能很好

缺点:

  • 类似于乐观锁,当数据竞争激烈时,可能会导致大量失败。用户可能会看到有房间可用,但当他们尝试预订时,他们得到“没有房间可用”的响应。这种体验可能会让用户感到沮丧。
  • 数据库约束不能像应用程序代码那样容易地进行版本控制。
  • 并非所有数据库都支持约束。当我们的数据库选型发生变化,可能会造成额外的问题。

由于这种方法易于实现,而且酒店预订的数据竞争通常不高(QPS 低),这是酒店预订系统的另一个好选择。

可拓展性

通常,酒店预订系统的负载并不高。然而,面试官可能会有一个延展问题:“如果酒店预订系统不仅用于单个连锁酒店,还用于类似booking.com或expedia.com这样的热门旅游网站怎么办?”在这种情况下,QPS可能会高出1000倍。

当系统负载高时,我们需要了解什么可能成为瓶颈。我们的所有服务都是无状态的,因此可以通过添加更多服务器来轻松扩展。然而,数据库有状态,无法添加更多的数据库来扩展。让我们探讨如何扩展数据库。

数据库分片

扩展数据库的一种方式是分片,其思路是将数据拆分到多个数据库中,每个数据库只包含部分数据。

当我们对数据库分片时,需要考虑如何分配数据。从数据模型部分可以看出,大多数查询需要通过hotel_id来过滤。因此,一个自然的结论是我们通过hotel_id来分片。在下图中,负载被分散到16个分片上。假设QPS为30,000,经过数据库分片后,每个分片处理的 QPS 为 30,000 / 16 = 1875,这对单个MySQL服务器的负载来说问题不大。

《System Design》 23 — Hotel Reservation System

缓存

酒店库存数据具有一个有趣的特点;只有当前和未来的数据真正有用,因为用户只能预订近期的房间。

因此,关于存储选择,理想情况下我们希望有一个生存时间(TTL)机制来自动淘汰历史数据。历史数据如果有场景需要查询,可以存在在其他的数据库上。Redis是一个好选择,因为TTL和最近最少使用(LRU)缓存驱逐策略可以帮助我们优化内存的使用。

如果加载速度和数据库可扩展性成为瓶颈(例如,我们正在为booking.com或expedia.com这样的规模设计),我们可以在数据库上添加一个缓存层,并将检查房间库存和预订房间逻辑移至缓存层,如下图所示。在这种设计中,绝大多数不合格的请求被缓存拦截,只有少部分请求会命中库存数据库。值得一提的是,即使Redis中显示有足够的库存,作为预防措施,我们仍需要在数据库端重新检查库存,因此数据库才是库存数据的真实来源。

《System Design》 23 — Hotel Reservation System

先让我们回顾一下这个系统中的每个组件。

预订服务

支持以下库存管理API:

  • 查询给定酒店ID、房型和日期范围的可用房间数量
  • 通过执行 total_reserved + 1 来预订房间
  • 用户取消预订时更新库存

库存缓存

所有库存管理查询操作都移至库存缓存(Redis),我们需要将库存数据预先填充到缓存中。缓存是具有以下结构的键值存储:

key: hotelID_roomTypeID_{date}

value: 给定酒店ID、房型ID和日期的可用房间数量

对于酒店预订系统来说,读操作(检查房间库存)的量级比写操作高得多,大多数读操作由缓存响应。

库存DB

存储库存数据,是库存数据的真实来源。

缓存带来的新挑战

添加缓存层显著增加了系统的可扩展性和吞吐量,但它也引入了一个新的挑战:如何保持数据库和缓存之间的数据一致性。

用户预订房间时,正常情况会执行两个操作:

  1. 查询房间库存以确定是否还有足够的房间剩余。查询在库存缓存上运行
  2. 更新库存数据。首先更新库存数据库。然后异步将更改传播到缓存。这种异步缓存更新可以通过程序代码值执行,该代码在数据保存到数据库后更新库存缓存。除此之外,也可以使用变更数据捕获(CDC)来同步。CDC 从数据库读取数据更改,并将更改应用到另一个数据系统。一个常见的解决方案是Debezium,它使用源连接器从数据库读取更改,并将它们应用到Redis等缓存组件。

因为库存数据首先在数据库上更新,所以有可能缓存不反映最新的库存数据。例如,缓存中可能还有一个空房间,而数据库已经没有房间剩余,反之亦然。

如果你仔细思考,你会发现缓存和数据库之间的不一致实际上并不重要,只要数据库做最终的库存验证检查即可。

让我们看一个例子。假设缓存表示还有一个空房间,但数据库说没有了。在这种情况下,当用户查询房间库存时,他们发现还有房间可用,因此他们尝试预订,当请求到达库存数据库时,数据库进行验证并发现没有房间剩余。此时,客户端收到错误,表明有人在他们之前刚刚预订了最后一个房间。用户刷新网站时,他们可能会看到没有房间剩余,因为在他们点击刷新按钮之前,数据库已经将库存数据同步到了缓存。

优点:

  • 减少了数据库负载。由于读查询由缓存层响应,数据库负载显著减少。
  • 高性能。读查询非常快,因为结果是从内存中获取的。

缺点:

  • 维护数据库和缓存之间的数据一���性很难。我们需要仔细考虑这种不一致性对用户体验的影响。

服务间的数据一致性

在传统的单体架构中,共享关系数据库可以确保数据一致性。在我们的微服务设计中,我们选择了一种混合方法,让预定服务同时处理预定和库存API,以便将库存和预定数据库表存储在同一个关系数据库中。如“并发问题”一节所述,这种安排允许我们利用关系数据库的ACID属性,来优雅地处理预定流程中出现的许多并发问题。

然而,如果你的面试官是微服务纯粹主义者,他们可能会对这种混合方法提出挑战。在他们看来,对于微服务架构,每个微服务都有自己的数据库,如下图右侧所示。

《System Design》 23 — Hotel Reservation System

这种纯粹的设计引入了许多数据一致性问题。由于这是我们第一次涉及微服务,让我们解释它是如何以及为什么发生。为了便于理解,这次讨论只涉及两个服务。在现实世界中,一家公司可能有数百个微服务。在单体架构中,如下图所示,不同的操作可以被封装在单一事务中以确保ACID属性。

《System Design》 23 — Hotel Reservation System

然而,在微服务架构中,每个服务都有自己的数据库。一个逻辑上的原子操作可能跨越多个服务。这意味着我们不能使用单一事务来确保数据一致性。如下图所示,如果在预订数据库中的更新操作失败,我们需要在库存数据库中回滚已预订的房间数量。许多失败的情况都可能会导致数据的不一致。

《System Design》 23 — Hotel Reservation System

为了解决数据一致性问题,这里介绍了一些业内经过验证的技术概述。如果你想了解更多细节,需要再多查阅相关的资料。

  • 两阶段提交(2PC)。2PC是一种数据库协议,用于保证跨多个节点的原子事务提交,即要么所有节点成功,要么所有节点失败。因为2PC是一个阻塞协议,单个节点失败会阻塞进程直到该节点恢复。它的性能并不高。
  • Saga。Saga是一系列本地事务的序列。每个事务更新并发布消息以触发下一个事务。如果一个节点失败,Saga执行补偿事务以撤销之前事务所做的更改。2PC作为单一提交执行ACID事务,而Saga包含多个步骤并依赖于最终一致性。

值得注意的是,解决微服务之间的数据不一致性需要一些复杂的机制,这大大增加了整体设计的复杂性。作为架构师,取决于个人的权衡。关于这个问题,我们认为没必要引入更多的复杂度,因此选择了将预订和库存数据存储在同一个关系数据库下这种更实用的方法。

第四步 - 总结  在这一章中,我们介绍了一个酒店预订系统的设计。我们首先收集需求并进行规模预估。在概要设计中,我们给出了API设计、数据模型的初稿,以及系统架构图。在深入设计中,我们探索了更优的数据库模式设计,因为我们意识到应该在房型级别而非特定房间进行预订。此外,我们还深入讨论了数据行的竞争条件,并提出了几种可能的解决方案:

  • 悲观锁
  • 乐观锁
  • 数据库约束

然后,我们讨论了扩展系统的不同方法,包括数据库分片和使用Redis缓存。最后,我们解决了微服务架构中的数据一致性问题,并简单介绍了一些解决方案。

久经风雨见云帆!现在可以给自己鼓鼓气,真棒! 《System Design》 23 — Hotel Reservation System

转载自:https://juejin.cn/post/7382022102679945235
评论
请登录