# 第07章：酒店预订系统

在本章中，我们将设计一个酒店预订系统，例如万豪国际（Marriott International）。本章中使用的设计和技术也适用于其他流行的与预订相关的常见面试题：

* 设计 Airbnb
* 设计航班预订系统
* 设计电影票预订系统

## 第 1 步 - 了解问题并确定设计范围

酒店预订系统非常复杂，其组件因业务用例而异。在深入设计之前，你应该向面试官询问澄清问题以缩小范围。

**候选人**：系统的规模有多大？\
**面试官**：假设我们是为一个拥有 5,000 家酒店、共 100 万间客房的连锁酒店建立网站。

**候选人**：客户是在预订时支付，还是在到达酒店时支付？\
**面试官**：为了简单起见，他们在预订时全额支付。

**候选人**：客户仅通过酒店网站预订客房吗？我们需要支持其他预订方式（如电话预订）吗？\
**面试官**：假设人们可以通过酒店网站或 App 预订客房。

**候选人**：客户可以取消预订吗？\
**面试官**：可以。

**候选人**：还有其他需要考虑的事情吗？\
**面试官**：是的，我们允许 10% 的超额预订（overbooking）。如果你不知道的话，超额预订意味着酒店出售的房间数超过了其实际拥有的房间数。酒店这样做是考虑到部分客户会取消预订。

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

* 展示酒店相关页面。
* 展示客房相关详情页。
* 预订客房。
* 用于添加/删除/更新酒店或房间信息的后台管理面板。
* 支持超额预订功能。

**面试官**：听起来不错。

**面试官**：还有一件事，酒店价格是动态变化的。客房价格取决于该酒店在给定日期的预计入住率。对于本次面试，我们可以假设每天的价格可能不同。\
**候选人**：我会记住这一点的。

接下来，你可能想谈谈最重要的非功能性需求。

### 非功能性需求 (Non-functional requirements)

* 支持高并发。在旺季或重大活动期间，一些热门酒店可能会有很多客户尝试预订同一间房。
* 适度的延迟。用户进行预订时，最好能有快速的响应时间，但系统处理预订请求花费几秒钟也是可以接受的。

### 估算 (Back-of-the-envelope estimation)

* 总计 5,000 家酒店和 100 万间客房。
* 假设 70% 的房间被占用，平均入住时长为 3 天。
* 预估每日预订量：(100 万 × 0.7) / 3 = 233,333（向上取整为 \~240,000）。
* 每秒预订数 = 240,000 / $10^5$ 秒（一天约 $10^5$ 秒）= \~3。如我们所见，平均每秒预订事务数 (TPS) 并不高。

接下来，让我们粗略计算一下系统中所有页面的 QPS。典型的客户流程有三个步骤：

1. 查看酒店/客房详情页。用户浏览此页面（查询）。
2. 查看预订页。用户在预订前确认预订详情，如日期、客人数、支付信息（查询）。
3. 预订房间。用户点击“预订”按钮预订房间，房间被预订（事务）。

让我们假设大约 10% 的用户会进入下一步，而 90% 的用户在到达最后一步之前会流失。我们还可以假设没有实现预取（prefetching）功能（即在用户到达下一步之前预取内容）。图 1 展示了不同步骤 QPS 的粗略估算。我们知道最终预订的 TPS 是 3，因此我们可以沿漏斗逆向推导。订单确认页面的 QPS 为 30，详情页的 QPS 为 300。

![Figure7.1 QPS distribution png](/files/yiBGuafqYEtvlWtppPD9)

图 1 QPS 分布

## 第 2 步 - 提出高层设计并获得认可

在本节中，我们将讨论：

* API 设计
* 数据模型
* 高层设计

### API 设计 (API design)

我们探讨酒店预订系统的 API 设计。最核心的 API 使用 RESTful 约定列出如下。

请注意，本章重点探讨酒店预订系统的设计。对于一个完整的酒店网站，设计需要为客户提供直观的功能，以便根据大量标准搜索客房。这些搜索功能的 API 虽然重要，但在技术上并不具有挑战性。它们超出了本章的范围。

### 酒店相关 API (Hotel-related APIs)

| API                  | 详情                     |
| -------------------- | ---------------------- |
| GET /v1/hotels/ID    | 获取酒店的详细信息。             |
| POST /v1/hotels      | 添加新酒店。此 API 仅供酒店员工使用。  |
| PUT /v1/hotels/ID    | 更新酒店信息。此 API 仅供酒店员工使用。 |
| DELETE /v1/hotels/ID | 删除酒店。此 API 仅供酒店员工使用。   |

表 1 酒店相关 API

### 客房相关 API (Room-related APIs)

| 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 仅供酒店员工使用。   |

表 2 客房相关 API

### 预订相关 API (Reservation related APIs)

| API                        | 详情              |
| -------------------------- | --------------- |
| GET /v1/reservations       | 获取已登录用户的预订历史记录。 |
| GET /v1/reservations/ID    | 获取单笔预订的详细信息。    |
| POST /v1/reservations      | 发起新的预订。         |
| DELETE /v1/reservations/ID | 取消预订。           |

表 3 预订相关 API

发起新预订是一个非常重要的功能。发起新预订（POST /v1/reservations）的请求参数可能如下所示：

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

请注意，`reservationID` 被用作**幂等键 (idempotency key)**，以防止重复预订（double booking）。重复预订是指在同一天为同一间客房进行了多次预订。详细信息将在“深度探究”章节的“并发问题”部分进行解释。

## 数据模型 (Data model)

在决定使用哪种数据库之前，让我们先仔细审视数据访问模式。对于酒店预订系统，我们需要支持以下查询：

查询 1：查看酒店的详细信息。 查询 2：在给定日期范围内查找可用的房型。 查询 3：记录预订信息。 查询 4：查找某笔预订或预订的历史记录。

从估算中我们知道，系统的规模并不大，但我们需要为重大活动期间的流量激增做好准备。考虑到这些需求，我们选择关系型数据库，原因如下：

* 关系型数据库非常适合读密集型（read-heavy）且写频率较低（write less frequently）的工作负载。这是因为访问酒店网站/App 的用户数量比实际进行预订的用户数量高出几个数量级。NoSQL 数据库通常针对写入进行了优化，而关系型数据库对于读密集型工作负载表现得足够好。
* 关系型数据库提供 ACID 保证。ACID 属性对于预订系统至关重要。如果没有这些属性，就不容易防止诸如负余额、重复扣费、重复预订等问题。ACID 属性使应用程序代码变得简单得多，并使整个系统更容易被理解和推导。关系型数据库通常提供这些保证。
* 关系型数据库可以轻松地对数据建模。业务数据的结构非常清晰，不同实体（酒店、客房、房型等）之间的关系也很稳定。关系型数据库可以轻松地对这种数据模型进行建模。

既然我们选择了关系型数据库作为我们的数据存储，那么让我们来探讨一下表结构设计（schema design）。图 2 展示了一个简单的表结构设计，它是许多候选人对酒店预订系统进行建模时最自然的方式。

![Figure7.2 Database schema png](/files/2WbMQnJeTvRkQSgf1rk3)

图 2 数据库表结构

大多数属性都是自解释的，我们仅对 `reservation` 表中的 `status` 字段进行说明。`status` 字段可以处于以下状态之一：待定 (pending)、已支付 (paid)、已退款 (refunded)、已取消 (canceled)、被拒绝 (rejected)。状态机如图 3 所示。

![Figure7.3 Reservation status png](/files/Ki0zfUXZQsAj91u6inXT)

图 3 预订状态

这个表结构设计有一个重大问题。这种数据模型适用于像 Airbnb 这样的公司，因为用户在预订时会指定 `room_id`（可能被称为 `listing_id`）。然而，酒店的情况并非如此。用户实际上是在特定酒店预订**一种房型 (a type of room)**，而不是一间特定的客房。例如，房型可以是标准间、大床房、带两张双人床的双床房等。房号是在房客办理入住 (check-in) 时分配的，而不是在预订时。我们需要更新我们的数据模型以反映这一新需求。详见“深度探究”章节中的“改进的数据模型”部分。

## 高层设计 (High-level design)

我们为这个酒店预订系统采用微服务架构。在过去的几年中，微服务架构变得非常流行。使用微服务的公司包括亚马逊、Netflix、Uber、Airbnb、Twitter 等。如果你想了解更多关于微服务架构优势的信息，可以查看一些优秀的资源 \[1] \[2]。

我们的设计采用微服务架构进行建模，高层设计图如图 4 所示。

![Figure7.4 High-level design png](/files/61XXsX2a3X3Yp6SbJBv7)

图 4 高层设计

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

* **用户 (User)**：用户通过手机或电脑预订酒店房间。
* **管理员（酒店员工）(Admin)**：授权的酒店员工执行行政操作，如为客户退款、取消预订、更新客房信息等。
* **CDN (内容分发网络)**：为了获得更好的加载时间，CDN 被用于缓存所有静态资源，包括 JavaScript 包、图片、视频、HTML 等。
* **公共 API 网关 (Public API Gateway)**：这是一个完全托管的服务，支持限流、认证等功能。API 网关配置为根据端点将请求路由到特定的服务。例如，加载酒店主页的请求被定向到酒店服务 (Hotel Service)，而预订酒店客房的请求被路由到预订服务 (Reservation Service)。
* **内部 API (Internal APIs)**：这些 API 仅供授权的酒店员工使用。它们通过内部软件或网站访问。通常受 VPN（虚拟专用网络）的进一步保护。
* **酒店服务 (Hotel Service)**：提供有关酒店和客房的详细信息。酒店和客房数据通常是静态的，因此可以轻松缓存。
* **房费服务 (Rate Service)**：提供未来不同日期的房价。酒店行业的一个有趣事实是，房间的价格取决于该酒店在特定日期的预计入住率。
* **预订服务 (Reservation Service)**：接收预订请求并预订酒店房间。该服务还会在房间被预订或预订被取消时管理客房库存。
* **支付服务 (Payment Service)**：执行客户的付款操作，并在支付交易成功时将预订状态更新为“已支付 (paid)”，如果交易失败则更新为“被拒绝 (rejected)”。
* **酒店管理服务 (Hotel Management Service)**：仅供授权的酒店员工使用。酒店员工可以使用以下功能：查看即将到来的预订记录、为客户预订房间、取消预订等。

为了清晰起见，图 4 省略了微服务之间交互的许多箭头。例如，如图 5 所示，预订服务与房费服务之间应该有一个箭头。预订服务向房费服务查询房费，这用于计算预订的总房费。另一个例子是，酒店管理服务与大多数其他服务之间应该有许多连接箭头。当管理员通过酒店管理服务进行更改时，请求会被转发到实际拥有该数据的服务来处理这些更改。

![Figure7.5 Connections between services png](/files/uneoZmpRMjcEDqVnthA8)

图 5 服务之间的连接

对于生产系统，服务间通信通常采用现代且高性能的远程过程调用 (RPC) 框架，如 gRPC。使用此类框架有很多好处。要详细了解 gRPC，请查看 \[3]。

## 第 3 步 - 深度探究

现在我们已经讨论了高层设计，让我们深入探讨以下内容。

* 改进的数据模型
* 并发问题
* 扩展系统
* 解决微服务架构中的数据不一致性

### 改进的数据模型

如高层设计中所述，当我们预订酒店房间时，我们实际上预订的是一种房型，而不是一间特定的房间。我们需要对 API 和表结构进行哪些更改来适应这一点？

对于预订 API，请求参数中的 `roomID` 被替换为 `roomTypeID`。发起预订的 API 如下所示：

POST /v1/reservations 请求参数：

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

更新后的表结构如图 6 所示。

![Figure7.6 Updated schema png](/files/nHdjHp1qK2V1VtTUZq6b)

图 6 更新后的表结构

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

* **room**：包含有关房间的信息。
* **room\_type\_rate**：存储特定房型在未来日期的价格数据。
* **reservation**：记录房客预订数据。
* **room\_type\_inventory**：存储酒店房间的库存数据。这张表对预订系统非常重要，所以让我们仔细看看每一列。
* **hotel\_id**：酒店的 ID。
* **room\_type\_id**：房型的 ID。
* **date**：单个日期。
* **total\_inventory**：房间总数减去那些临时从库存中移除的房间。有些房间可能会因为维护而下架。
* **total\_reserved**：指定 hotel\_id、room\_type\_id 和日期下已预订的客房总数。

设计 `room_type_inventory` 表还有其他方法，但每个日期一行可以使管理日期范围内的预订和查询变得容易。如图 6 所示，(hotel\_id, room\_type\_id, date) 是复合主键。表中的行是通过查询未来 2 年内所有日期的库存数据预先填充的。我们有一个预定的每日作业，当日期进一步推进时，该作业会预先填充库存数据。

现在我们已经完成了表结构设计，让我们对存储容量做一些估算。正如在估算部分提到的，我们有 5,000 家酒店。假设每家酒店有 20 种房型。那么就是 (5,000 家酒店 x 20 种房型 x 2 年 x 365 天) = 7,300 万行。7,300 万行数据量并不大，单个数据库足以存储这些数据。然而，单台服务器意味着单点故障。为了实现高可用性，我们可以跨多个区域或可用区设置数据库复制。

表 4 显示了 “room\_type\_inventory” 表的样本数据。

| hotel\_id | room\_type\_id | date       | total\_inventory | total\_reserved |
| --------- | -------------- | ---------- | ---------------- | --------------- |
| 211       | 1001           | 2021-06-01 | 100              | 80              |
| 211       | 1001           | 2021-06-02 | 100              | 82              |
| 211       | 1001           | 2021-06-03 | 100              | 86              |
| 211       | 1001           | ...        | ...              |                 |
| 211       | 1001           | 2023-05-31 | 100              | 0               |
| 211       | 1002           | 2021-06-01 | 200              | 164             |
| 2210      | 101            | 2021-06-01 | 30               | 23              |
| 2210      | 101            | 2021-06-02 | 30               | 25              |

表 4 “room\_type\_inventory” 表的样本数据

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

* 输入：startDate (2021-07-01), endDate (2021-07-03), roomTypeID, hotelId, numberOfRoomsToReserve
* 输出：如果指定的房型有库存且用户可以预订，则为 True。否则，返回 false。

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

1. 选择日期范围内的行

```sql
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 选择行

此查询返回如下数据：

| date       | total\_inventory | total\_reserved |
| ---------- | ---------------- | --------------- |
| 2021-07-01 | 100              | 97              |
| 2021-07-02 | 100              | 96              |
| 2021-07-03 | 100              | 95              |

表 5：酒店库存

2. 对于每一条记录，应用程序检查以下条件：

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

如果所有记录的条件都返回 true，则表示日期范围内的每个日期都有足够的房间。

其中一个需求是支持 10% 的超额预订。有了新的表结构，这很容易实现：

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

此时，面试官可能会问一个后续问题：“如果预订数据量对于单个数据库来说太大了，你会怎么做？”有几种策略：

* 仅存储当前和未来的预订数据。预订历史记录不经常被访问。因此，它们可以被归档，有些甚至可以移至冷存储。
* 数据库分片。最频繁的查询包括进行预订或按名称查找预订。在两种查询中，我们都需要先选择酒店，这意味着 `hotel_id` 是一个很好的分片键（sharding key）。数据可以通过 `hash(hotel_id) % number_of_servers` 进行分片。

### 并发问题

另一个需要研究的重要问题是重复预订。我们需要解决两个问题：1) 同一个用户多次点击“预订”按钮。2) 多个用户尝试在同一时间预订同一间房。

让我们来看看第一种情况。如图 7 所示，进行了两次预订。

![Figure7.7 Two reservations are made png](/files/xQjnyXJjBsTEmktTGAPV)

图 7 进行了两次预订

解决这个问题有两种常用的方法：

* **客户端实现**。一旦请求发送，客户端可以将“提交”按钮置灰、隐藏或禁用。这在大多数情况下应该能防止双击问题。然而，这种方法并非十分可靠。例如，用户可以禁用 JavaScript，从而绕过客户端检查，或者由于网络问题无意中点击了两次按钮。
* **API 方案**：在预订 API 请求中添加一个**幂等键 (idempotency key)**。如果一个 API 调用无论被调用多少次都能产生相同的结果，那么它就是**幂等 (idempotent)** 的。图 8 展示了如何使用幂等键 (`reservation_id`) 来避免重复预订问题。详细步骤在下文说明。

![Figure7.8.png](/files/FiQY0BQfDwily0H2WRaN)

1. **生成预订订单**。在客户输入预订的详细信息（房型、入住日期、退房日期等）并点击“继续”按钮后，预订服务会生成一个预订订单。
2. 系统生成预订订单供客户核对。唯一的 `reservation_id` 由全局唯一 ID 生成器生成，并作为 API 响应的一部分返回。此步骤的 UI 可能如下所示：

![Figure7.9.png](/files/tYW9BXNdME5qLNgY6hVv)图 9 确认页面 (来源：\[4])

3a. **提交预订 1**。`reservation_id` 作为请求的一部分被包含在内。它是预订表（图 6）的主键。请注意，幂等键不一定要是 `reservation_id`。我们选择 `reservation_id` 是因为它已经存在，并且非常适用于我们的设计。

3b. 如果用户第二次点击“完成我的预订”按钮，则会提交**预订 2**。由于 `reservation_id` 是预订表的主键，我们可以利用该键的**唯一约束 (unique constraint)** 来确保不会发生重复预订。

图 10 解释了为什么可以避免重复预订。

![Figure7.10 Unique constraint violation](/files/h4X0XiOguTMBpILclKMu)

图 10 唯一约束冲突

**场景 2：如果当只剩下一间客房时，多个用户同时预订同一种房型会发生什么？** 让我们考虑如图 11 所示的场景。

![Figure7.11 Race condition](/files/Yaf0niJ3B7O33wSUsF9P)

图 11 竞态条件

让我们假设数据库隔离级别不是串行化（serializable）\[5]。用户 1 和用户 2 尝试同时预订同一种房型，但只剩下一间客房。我们将用户 1 的执行称为“事务 1”，用户 2 的执行称为“事务 2”。此时，酒店共有 100 间客房，其中 99 间已被预订。 事务 2 通过检查 `(total_reserved + rooms_to_book) <= total_inventory` 来确认是否有足够的剩余客房。由于还剩 1 间房，它返回 true。 事务 1 同样通过检查 `(total_reserved + rooms_to_book) <= total_inventory` 来确认是否有足够的客房。由于还剩 1 间房，它也返回 true。 事务 1 预订了房间并更新了库存：`reserved_room` 变为 100。 然后事务 2 也预订了房间。ACID 中的\*\*隔离性（isolation）\*\*属性意味着数据库事务必须独立于其他事务完成其任务。因此，在事务 1 完成（提交）之前，事务 1 所做的数据更改对事务 2 是不可见的。所以事务 2 看到的 `total_reserved` 仍然是 99，并通过更新库存预订了房间：`reserved_room` 变为 100。这导致系统允许两个用户都预订了房间，尽管只剩下一间客房。 事务 1 成功提交了更改。 事务 2 成功提交了更改。

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

* 悲观锁（Pessimistic locking）
* 乐观锁（Optimistic locking）
* 数据库约束（Database constraints）

在着手修复之前，让我们先看看用于预订房间的 SQL 伪代码。该 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
```

代码清单 2 预订房间

### 方案 1：悲观锁 (Pessimistic locking)

悲观锁 \[6]，也称为悲观并发控制，通过在用户开始更新记录时立即对其加锁来防止同时更新。其他尝试更新该记录的用户必须等待，直到第一个用户释放锁（提交更改）。

对于 MySQL，“SELECT ... FOR UPDATE”语句通过锁定查询选中的行来工作。让我们假设一个事务由“事务 1”启动。其他事务必须等待事务 1 完成后才能开始另一个事务。详细说明如图 12 所示。

![Figure7.12 Pessimistic locking](/files/niDe7hIsqoHnzbgvY3iZ)

图 12 悲观锁

在图 12 中，事务 2 的 “SELECT ... FOR UPDATE” 语句会等待事务 1 完成，因为事务 1 锁定了这些行。在事务 1 完成后，`total_reserved` 变为 100，这意味着用户 2 已无房可订。

**优点：**

* 防止应用程序更新正在被更改——或已经被更改——的数据。
* 易于实现，且通过序列化更新来避免冲突。当数据竞争激烈时，悲观锁非常有用。

**缺点：**

* 当锁定多个资源时可能会发生死锁。编写无死锁的应用程序代码可能具有挑战性。
* 该方案不可扩展。如果一个事务被锁定太久，其他事务将无法访问该资源。这对数据库性能有重大影响，尤其是当事务耗时较长或涉及大量实体时。

由于这些限制，我们不推荐在预订系统中使用悲观锁。

### 方案 2：乐观锁 (Optimistic locking)

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

实现乐观锁有两种常用方法：版本号和时间戳。版本号通常被认为是一个更好的选择，因为服务器时钟可能会随时间产生偏差。我们通过版本号来解释乐观锁的工作原理。

图 13 展示了一个成功案例和一个失败案例。

![Figure7.13 Optimistic locking](/files/xkrD3v8vBOGRClavcBFy)

图 13 乐观锁

数据库表中添加了一个名为 “version” 的新列。 在用户修改数据库行之前，它会读取该行的版本号。 当用户更新该行时，它会将版本号加 1 并写入该版本号。 数据库会进行一次验证检查；下一个版本号应该比当前版本号大 1。如果验证失败，事务将中止，用户从步骤 2 开始重试。

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

为了理解其中的原因，考虑许多客户端尝试同时预订同一家特定酒店客房的情况。由于对有多少客户端可以读取可用房间数没有限制，所有客户端读取到的可用房间数和当前版本号都是相同的。当不同的客户端进行预订并将结果写回数据库时，只有其中一个会成功，其余客户端都会收到版本检查失败的消息。这些客户端必须重试。在随后的重试轮次中，依然只有一个客户端能成功，其余的仍需重试。虽然最终结果是正确的，但反复的重试会导致非常糟糕的用户体验。

**优点：**

* 它防止应用程序编辑陈旧数据 (stale data)。
* 我们不需要锁定数据库资源。从数据库的角度来看，实际上没有锁定。这完全取决于应用程序如何处理版本号逻辑。
* 乐观锁通常用于数据竞争较低的情况。当冲突很少发生时，事务可以在不支付管理锁的开销的情况下完成。

**缺点：**

* 当数据竞争激烈时，性能较差。

由于预订系统的 QPS 通常不高，因此乐观锁是酒店预订系统的一个不错选择。

### 方案 3：数据库约束 (Database constraints)

这种方法与乐观锁非常相似。让我们探讨它是如何工作的。在 `room_type_inventory` 表中，添加以下约束：

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

使用与图 14 所示相同的示例，当用户 2 尝试预订房间时，`total_reserved` 变为 101，这违反了 `total_inventory (100) - total_reserved (101) >= 0` 的约束。事务随后被回滚。

![Figure7.14 Database constraint](/files/6Dr8gM5sJfWhwxMxsMYR)

![Figure7.14 数据库约束](/files/6Dr8gM5sJfWhwxMxsMYR)

**优点：**

* 易于实现。
* 在数据竞争极小时效果良好。

**缺点：**

* 与乐观锁类似，当数据竞争激烈时，可能会导致大量的失败。用户可能会看到仍有房间可用，但当他们尝试预订时，却得到“无房可用”的响应。这种体验会让用户感到沮丧。
* 数据库约束不像应用程序代码那样容易进行版本控制。
* 并非所有数据库都支持约束。当我们从一个数据库方案迁移到另一个数据库方案时，可能会导致问题。

由于这种方法易于实现，且酒店预订的数据竞争通常不高（低 QPS），因此它是酒店预订系统的另一个不错选择。

## 扩展性 (Scalability)

通常，酒店预订系统的负载并不高。然而，面试官可能会提出一个后续问题：“如果酒店预订系统不仅用于一家连锁酒店，而是用于像 booking.com 或 expedia.com 这样热门的旅游网站呢？”在这种情况下，QPS 可能会高出 1,000 倍。

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

### 数据库分片 (Database sharding)

扩展数据库的一种方法是应用数据库分片。其核心思想是将数据拆分到多个数据库中，以便每个数据库仅包含部分数据。

当我们对数据库进行分片时，我们需要考虑如何分布数据。正如我们在数据模型部分所看到的，大多数查询需要按 `hotel_id` 进行过滤。因此，一个自然的结论是我们按 `hotel_id` 对数据进行分片。在图 15 中，负载被分散到 16 个分片中。假设 QPS 为 30,000。经过数据库分片后，每个分片处理 30,000 / 16 = 1875 QPS，这处于单个 MySQL 服务器的负载能力范围内。

![Figure7.15 预留位置：Database sharding](/files/pj3kusjAqw6gDyvt5Lgj)

图 15 数据库分片

### 缓存 (Caching)

酒店库存数据有一个有趣的特性：只有当前和未来的酒店库存数据是有意义的，因为客户只能预订近期未来的房间。

因此，对于存储选择，理想情况下我们希望有一种**生存时间 (Time-to-live, TTL)** 机制来自动删除过期数据。历史数据可以在不同的数据库中查询。Redis 是一个不错的选择，因为 TTL 和**最近最少使用 (Least Recently Used, LRU)** 缓存淘汰策略可以帮助我们优化内存利用率。

如果加载速度和数据库可扩展性成为问题（例如，我们正在设计 booking.com 或 expedia.com 规模的系统），我们可以在数据库之上添加一个缓存层，并将“检查客房库存”和“预订房间”逻辑移动到缓存层，如图 16 所示。在这种设计中，只有一小部分请求会到达库存数据库，因为大多数大多数请求都被库存缓存拦截了。值得一提的一点是，即使 Redis 中显示有足够的库存，出于预防考虑，我们仍然需要在数据库端重新检查库存。数据库是库存数据的**事实来源 (source of truth)**。

![图 16 缓存](/files/9hvP2LJSpHSU0CHuQUZB)

让我们首先介绍此系统中的每个组件。

**预订服务 (Reservation service)**：支持以下库存管理 API：

* 查询给定房型和日期范围内的可用房间数。
* 通过执行 `total_reserved + 1` 来预订房间。
* 当用户取消预订时更新库存。

**库存缓存 (Inventory cache)**：所有库存管理查询操作都移动到库存缓存 (Redis) 中，我们需要将库存数据预先填充到缓存。缓存是一个具有以下结构的键值对存储：

* `key`: `hotelID_roomTypeID_{date}`
* `value`: 给定酒店 ID、房型 ID 和日期的可用房间数。

对于酒店预订系统，读操作（检查客房库存）的量比写操作高出一个数量级。大多数读操作由缓存回答。

**库存数据库 (Inventory DB)**：存储库存数据，作为事实来源。

#### 缓存带来的新挑战 (New challenges posed by the cache)

添加缓存层显著提高了系统的可扩展性和吞吐量，但也引入了新的挑战：如何维护数据库和缓存之间的数据一致性。

当用户预订房间时，在正常流程 (happy path) 中会执行两个操作：

1. 查询客房库存，查明是否有足够的房间剩余。该查询在**库存缓存**上运行。
2. 更新库存数据。首先更新**库存数据库**。然后将更改异步传播到缓存。这种异步缓存更新可以由应用程序代码调用，在数据保存到数据库后更新库存缓存。它也可以使用**变更数据捕获 (Change Data Capture, CDC)** \[8] 进行传播。CDC 是一种从数据库读取数据更改并将更改应用到另一个数据系统的机制。一个常见的解决方案是 Debezium \[9]。它使用源连接器从数据库读取更改，并将其应用到 Redis \[10] 等缓存解决方案。

因为库存数据首先在数据库上更新，所以缓存可能无法反映最新的库存数据。例如，当数据库说没有房间剩余时，缓存可能会报告仍有空房，反之亦然。

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

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

**优点：**

* 减轻数据库负载。由于查询请求由缓存层回答，数据库负载显著降低。
* 高性能。读取查询非常快，因为结果是从内存中获取的。

**缺点：**

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

### 服务间的数据一致性 (Data consistency among services)

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

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

![Figure7.17 预留位置：Monolithic vs microservice](/files/wn1AMuTqLBvknLWCyiNx)

图 17 单体 vs 微服务

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

![Figure7.18 Monolithic architecture](/files/p4bQ9mQWCavg59P4i8X7)

图 18 单体架构

然而，在微服务架构中，每个服务都有自己的数据库。一个逻辑上的原子操作可能跨越多个服务。这意味着我们不能使用单个事务来确保数据一致性。如图 19 所示，如果更新操作在预订数据库中失败，我们需要回滚库存数据库中的预留客房计数。通常，正常路径（happy path）只有一条，但可能导致数据不一致的失败情况却有很多。

![Figure7.19 Microservice architecture](/files/kWBhwzyx47s4yrLwPjK2)

图 19 微服务架构

为了解决数据不一致问题，以下是业界认可的技术的高层总结。如果你想阅读细节，请参考参考资料。

* **二阶段提交 (Two-phase commit, 2PC)** \[12]。2PC 是一种用于保证跨多个节点进行原子事务提交的数据库协议，即所有节点要么全部成功，要么全部失败。由于 2PC 是一种阻塞协议，单个节点的故障会阻塞整个进程直到该节点恢复。它的性能并不理想。
* **Saga**。Saga 是一系列本地事务的序列。每个事务更新并发布一条消息，以触发下一个事务步骤。如果某个步骤失败，Saga 会执行补偿事务（compensating transactions），以撤销之前事务所做的更改 \[13]。2PC 作为一个单一提交来执行 ACID 事务，而 Saga 由多个步骤组成并依赖于**最终一致性（eventual consistency）**。

值得注意的是，解决微服务之间的数据不一致需要一些复杂的机制，这会大大增加整体设计的复杂性。作为架构师，你需要决定增加的复杂性是否值得。针对这个问题，我们认为不值得这样做，因此采取了更务实的方法，即将预订和库存数据存储在同一个关系型数据库下。

## 第 4 步 - 总结

在本章中，我们展示了一个酒店预订系统的设计。我们从收集需求和进行估算开始，以了解规模。在高层设计中，我们展示了 API 设计、数据模型的初稿以及系统架构图。在深度探究中，由于我们意识到预订应该在房型级别（room type-level）进行，而不是针对具体的房间，因此我们探索了替代的数据库表结构设计。我们深入讨论了竞态条件（race conditions）并提出了几种潜在的解决方案：

* 悲观锁 (Pessimistic locking)
* 乐观锁 (Optimistic locking)
* 数据库约束 (Database constraints)

接着，我们讨论了扩展系统的不同方法，包括数据库分片（database sharding）和使用 Redis 缓存。最后，我们应对了微服务架构中的数据一致性问题，并简要介绍了几种解决方案。

祝贺你学到这里！现在奖励自己一下。做得好！

## 参考资料 (Reference Material)

\[1] 微服务：<https://en.wikipedia.org/wiki/Microservices\\>
\[2] 微服务架构有哪些好处？：<https://www.appdynamics.com/topics/benefits-of-microservices\\>
\[3] gRPC: <https://www.grpc.io/docs/what-is-grpc/introduction/\\>
\[4] 来源：Booking.com iOS app\
\[5] 串行化：<https://en.wikipedia.org/wiki/Serializability\\>
\[6] 乐观和悲观记录锁：<https://ibm.co/3Eb293O\\>
\[7] 乐观并发控制：<https://en.wikipedia.org/wiki/Optimistic\\_concurrency\\_control\\>
\[8] 变更数据捕获：<https://docs.oracle.com/cd/B10500\\_01/server.920/a96520/cdc.htm\\>
\[9] Debizium: <https://debezium.io/\\>
\[10] Redis sink: <https://bit.ly/3r3AEUD\\>
\[11] 单体架构：<https://microservices.io/patterns/monolithic.html\\>
\[12] 二阶段提交协议：<https://en.wikipedia.org/wiki/Two-phase\\_commit\\_protocol\\>
\[13] Saga: <https://microservices.io/patterns/data/saga.html>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://learning-guide.gitbook.io/system-design-interview/xi-tong-she-ji-mian-shi-nei-mu-zhi-nan-di-er-juan/chapter-07-hotel-reservation-system.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
