酒店预订平台是一个在关键路径上有严格正确性要求的双边市场:双重预订一个房间是严重的用户体验失败,而错误地阻塞可用库存损失收入。核心设计挑战是在全球用户群跨多个日历天的住宿下、在必须挺过网络故障、支付超时和用户放弃的结账流程中,维护准确的房间可用性——全都绝不把同一晚卖给两个不同客人。除了那个正确性挑战,平台还必须跨数十万家酒店服务快速、过滤、地理感知的搜索结果,管理每酒店复杂的取消和退款政策,并近实时给酒店运营者提供业务分析。

⚡ 速览要点
  • 全程 SQL——酒店/房间目录有界且结构化;预订需要 ACID;量不足以证明 NoSQL 复杂度合理。
  • DB 级约束防超卖——room_availability 表上的 CHECK (available_count >= 0);并发递减到 -1 在数据库失败,而非应用层。
  • 低争抢用乐观锁——用一个 version 列;冲突时重试;用户搜索和预订确认之间不持行级锁。
  • Redis TTL 持有(~10 分钟)——结账期间软预留房间;若用户放弃未完成支付,TTL 过期自动释放。
  • Kafka → Elasticsearch——酒店/房间创建或更新事件异步喂搜索索引;售罄房间自动从结果移除。
  • 幂等预订 API——客户端提供的幂等键防止重试请求(双击、网络重试)的重复预订。
  • S3 + CDN 做媒体——酒店图片住在对象存储;CDN 边缘节点以低延迟全球服务它们。
tldr

全程用 SQL 以求 ACID 正确性——酒店和房间数据大小有界,且预订需要事务完整性。available_count 上的行级约束在数据库级别防超卖。带 ~10 分钟 TTL 的 Redis 持有桥接结账间隙而不永久阻塞库存。Kafka 把列表变更喂进 Elasticsearch 做搜索。CDN 全球分发酒店媒体。取消和退款是它们自己的幂等状态机路径。

酒店预订应用高层架构
酒店预订应用高层架构

第 1 步 — 澄清需求

画框前界定系统。酒店预订覆盖很广——把它收窄到一个可辩护、连贯的子集。

功能需求(酒店管理)

功能需求(用户)

非功能需求

面试提示

显式划出范围:忠诚度计划、第三方 OTA(在线旅行社)集成、动态房间升级逻辑、欺诈检测。这些是真实产品功能,但 45 分钟吃完整范围会让难的部分——并发控制和预订状态机——没时间。

第 2 步 — 容量估算

酒店预订是读多、写谨慎的系统。假设一个大 OTA(Booking.com / Expedia 规模):全球 50 万家酒店、每天 1M 预订

流量

存储

注解

数据量确认 SQL 是正确的主存储——没有规模理由用 NoSQL 复杂度。难问题不是存储而是可用性表上的并发写正确性。

第 3 步 — API 设计

跨酒店管理和面向用户服务的干净 REST 面:

REST API
# 酒店管理 — 列表管理
POST   /hotels              -- 创建酒店
PUT    /hotels/{id}         -- 更新酒店
GET    /hotels/{id}         -- 获取酒店详情
PUT    /hotels/{id}/rooms/{room_id}   -- 更新房间
GET    /hotels/{id}/rooms             -- 列出酒店房间
PUT    /rooms/{room_id}/availability  -- 按日期范围设 available_count

# 用户 — 搜索与发现
GET    /hotels/search?city=Paris&checkin=2025-07-01&checkout=2025-07-05&guests=2&max_price=200
GET    /hotels/{id}/availability?checkin=...&checkout=...

# 用户 — 预订(经 Idempotency-Key 头幂等)
POST   /bookings
       Idempotency-Key: "client-uuid-123"
       { hotel_id, room_id, checkin, checkout, guests, payment_method_id }
       → 201 { booking_id, status: "RESERVED", hold_expires_at }

GET    /bookings/{id}       -- 预订状态和详情
DELETE /bookings/{id}       -- 取消预订(触发退款)
PUT    /bookings/{id}/modify -- 改日期(若酒店政策允许)

POST /bookings 端点携带一个 Idempotency-Key 头。用同键重试的请求(双击、网络超时)返回已存在的预订而非创建第二个。这必不可少,因为预订流程涉及一次支付扣款——没有幂等,客户端和服务器间的网络故障能导致给客人卡上扣两次。

第 4 步 — 酒店和房间列表服务

酒店和房间目录存在一个 SQL 数据库。理由刻意简单:世界上酒店数量有限且可预测地增长。关系数据库是正确契合——数据结构化、增长可预见、查询模式(按 ID 查酒店、按酒店列房间)很适合 SQL 索引。

酒店图片和视频存在 AWS S3(或等价对象存储)。SQL 数据库持有这些媒体资产的引用(URL)。一个 CDN 坐在 S3 前以低延迟服务全球用户群——从地理上邻近的 CDN 边缘节点取酒店图片比命中 us-east-1 的源桶快数个数量级。

酒店或房间被创建或更新时,变更作为事件发布到 Kafka。一个消费者读这些事件并异步更新 Elasticsearch 搜索索引,使搜索结果与真相来源最终一致。售罄房间随 available_count 降到零从结果移除——这个移除流经同一 CDC 管道,而非预订路径上的直接 Elasticsearch 写。

第 5 步 — 数据模型

核心表跨酒店目录和预订系统。全住在 SQL 以求 ACID 正确性:

SQL schema
CREATE TABLE hotels (
  id            BIGINT PRIMARY KEY,
  name          VARCHAR(255) NOT NULL,
  city          VARCHAR(100),
  country       VARCHAR(2),     -- ISO 3166-1 alpha-2
  lat           DECIMAL(9,6),
  lng           DECIMAL(9,6),
  star_rating   SMALLINT,
  description   TEXT,
  amenities     JSONB,            -- 泳池、wifi、停车、宠物友好...
  created_at    TIMESTAMP
);

CREATE TABLE rooms (
  id            BIGINT PRIMARY KEY,
  hotel_id      BIGINT REFERENCES hotels(id),
  room_type     VARCHAR(64),    -- STANDARD|DELUXE|SUITE
  max_guests    SMALLINT,
  base_rate_cents INT,
  description   TEXT
);

CREATE TABLE room_availability (
  room_id         BIGINT REFERENCES rooms(id),
  date            DATE,
  available_count INT NOT NULL CHECK (available_count >= 0),
  price_cents     INT NOT NULL, -- 这个特定日期的每晚费率
  version         INT NOT NULL DEFAULT 0, -- 乐观锁
  PRIMARY KEY (room_id, date)
);

CREATE TABLE bookings (
  id              BIGINT PRIMARY KEY,
  user_id         BIGINT NOT NULL,
  room_id         BIGINT NOT NULL,
  checkin_date    DATE   NOT NULL,
  checkout_date   DATE   NOT NULL,
  guest_count     SMALLINT,
  total_cents     BIGINT NOT NULL,
  status          VARCHAR(32),  -- RESERVED|BOOKED|CANCELLED|COMPLETED
  idempotency_key VARCHAR(64) UNIQUE,
  payment_intent_id VARCHAR(128),
  hold_expires_at TIMESTAMP,   -- Redis TTL 镜像到这做恢复
  created_at      TIMESTAMP,
  updated_at      TIMESTAMP
);

关键 schema 决策:CHECK (available_count >= 0) 约束是防超卖的数据库级守卫。version 列支持乐观并发控制。idempotency_key UNIQUE 约束在 DB 级别防止重复预订。room_availability 上的 price_cents 列捕获预订时的每晚费率——价格在预订后能变,但客人确认的费率锁在预订记录的 total_cents 里。

酒店和房间数据库 schema
酒店和房间数据库 schema

第 6 步 — 搜索服务

搜索由一个 Elasticsearch 集群驱动(Solr 是可比替代——两者都建在 Apache Lucene 上)。对列表持续更新(价格变化、可用性、新酒店)的酒店平台,Elasticsearch 因其实时索引和内建地理距离查询是更好契合。

特性ElasticsearchSolr
最适合时序数据、实时索引、地理查询带重缓存的静态数据集
模糊搜索优秀(Levenshtein 自动机)良好
地理距离过滤原生 geo_point 类型 + 距离过滤经空间模块支持
Type-ahead内建 completion suggester经 edge n-gram 支持

搜索查询流程

一个典型搜索——"巴黎的酒店,2 个成人,7 月 1–5,最高 €200/晚"——执行如下:

  1. Elasticsearch 查询——按 city = "Paris"(或从"Paris"中心点 geo_distance)、最低星级、设施过滤。返回按相关性分(混合文本匹配、热度、转化率)排序的酒店 ID。
  2. 可用性过滤——对每个返回的酒店,查 room_availability SQL 表以验证至少一个房型对请求范围内每晚有 available_count > 0price_cents <= 200 × 100。过滤掉无合格房间的酒店。
  3. 价格计算——对合格酒店,跨住宿日期求和每晚 price_cents,应用促销和忠诚折扣,算出显示总价。
  4. 返回结果——带缩略图、每晚费率、住宿总价、可用性徽章的分页酒店列表。
搜索缓存

热门城市+日期组合(如"巴黎 7 月 4 日周末")的搜索结果能在 Redis 缓存配短 TTL(60–120 s)。可用性变化使受影响酒店 ID 的缓存失效。这显著减少高流量搜索查询的 Elasticsearch 和 SQL 负载,而不长时间服务陈旧可用性数据。

第 7 步 — 预订服务与并发控制

预订服务用 SQL 数据库求其 ACID 保证——双重预订不可接受,而 ACID 事务是防止它的干净方式。核心挑战是多晚可用性:5 晚住宿需要 room_availability 里五个独立行 available_count > 0,且所有五个递减必须原子地全成或全败——没有有效的部分预订。

悲观锁

一种方法是用 SELECT ... FOR UPDATE 提前锁住住宿的所有行,然后递减并提交。这安全但在事务期间持锁——包括事务边界内发生的任何外部调用(如支付处理)。等 Stripe API 响应(能花 2–5 秒)时持 DB 锁是负载下连接池耗尽的配方。

乐观锁(首选)

首选方法用 room_availability 上的 version 列做乐观并发控制。用户搜索和预订确认之间不持锁——事务只在实际写的那一刻打开:

SQL
-- 第 1 步:读当前状态(无锁)
SELECT date, available_count, version
FROM room_availability
WHERE room_id = 42
  AND date BETWEEN '2025-07-01' AND '2025-07-04';
-- → 返回 4 行,version=7,8,7,9,available_count=3,3,2,3

-- 第 2 步:开事务;对每个日期尝试条件递减
BEGIN;
UPDATE room_availability
  SET available_count = available_count - 1,
      version = version + 1
  WHERE room_id = 42
    AND date = '2025-07-01'
    AND version = 7        -- 乐观锁:若行被修改则失败
    AND available_count > 0; -- 防止变负
-- 对住宿所有日期重复
-- 若任何 UPDATE 返回 0 行 → ROLLBACK(有人抢先)
-- 若全成功 → INSERT 预订记录 → COMMIT

若 version 检查失败(另一个并发预订修改了其中一行),事务立即回滚,服务用新数据重试。低争抢下(大多数晚),首次尝试成功。高争抢下(热门酒店在高峰周末),服务可能重试 1–3 次再成功或返回"房间不再可用"错误。CHECK (available_count >= 0) 约束是数据库级别的第二道防线——即便应用逻辑有 bug,DB 也拒绝任何低于零的递减。

为什么不跨服务 2PC?

跨预订和支付服务的两阶段提交会给原子结账——要么都成要么都不成。但 2PC 在整个事务期间跨服务持锁,造成协调者瓶颈并在负载下使可用性暴跌。选的方法(Redis 持有 + 预订上的乐观锁 + 异步支付确认)用严格原子性换高可用,对失败情况带显式补偿动作。

第 8 步 — 结账期间防超卖

用户选房间和完成支付之间,其他用户可能预订同一房间。解法镜像电商购物车模式:一个带 ~10 分钟 TTL 的 Redis 持有在结账期间软预留房间。这创造一个两阶段预订流程:

flow
-- 阶段 1:预留(立即)
用户选房间 + 日期
  → 预订服务: BEGIN TX
      对每个住宿日期递减 available_count(乐观锁)
      插入 status=RESERVED、hold_expires_at=NOW()+10min 的预订
    COMMIT
  → Redis: SET hold:{booking_id} 1 EX 600(镜像 DB 过期)
  → 返回 booking_id + 结账 URL 给用户

-- 阶段 2:确认(10 分钟内)
用户输入支付细节 + 提交
  → 支付服务: 扣卡(Stripe PaymentIntent)
      → webhook: payment.succeeded
  → 预订服务: UPDATE booking SET status=BOOKED
  → 通知: 发确认邮件 + 日历邀请

-- 超时路径(用户放弃)
Redis TTL 过期(10 分钟)
  → 过期监听器 / 后台作业检测 hold:* key 被删
  → 预订服务: UPDATE booking SET status=CANCELLED
      为每个住宿日期重新递增 available_count
  → 通知: "你的持有已过期"

Redis 持有 TTL 过期路径需要一个可靠机制在用户放弃结账时恢复可用性计数。两种方法:

预订 schema 和状态生命周期
预订 schema 和状态生命周期

第 9 步 — 预订状态机

一个显式状态机防止非法转换并使预订生命周期可审计。每个状态转换由业务事件驱动且幂等——在同一状态收到同一事件两次是 no-op:

states
RESERVED         ← 房间持有,结账进行中(Redis TTL 活跃)
  │ payment.succeeded webhook
  ▼
BOOKED           ← 已确认;房间为客人日期锁定
  │ 客人退房 / 住宿日期过去
  ▼
COMPLETED        ← 住宿已发生(终态)

  从 RESERVED:
  → HOLD_EXPIRED     (TTL 过期无支付;房间释放)
  → PAYMENT_DECLINED (支付失败;房间释放)
  → CANCELLED_BY_USER (用户在支付完成前取消)

  从 BOOKED:
  → CANCELLED_BY_USER   (取消政策窗口内取消)
  → CANCELLED_BY_HOTEL  (酒店因超订/运营问题取消)
  → NO_SHOW             (客人未到;收 no-show 费)

所有 CANCELLED_* 状态触发:
  → 为每个住宿日期重新递增 available_count
  → 发起退款(如适用,按酒店政策)
  → 通知客人和酒店管理

状态机作为 bookings 表的 status 列持久在 SQL 里,由应用级转换检查守卫。每个转换也发出一个 Kafka 事件,允许下游服务(通知、分析、收入报告)反应而不直接耦合到预订服务。

第 10 步 — 支付处理与幂等

酒店预订的支付处理遵循与电商相同的原则,但有一个关键时间差异:酒店支付常在预订时授权但在入住时捕获(或后付酒店在退房时)。这种授权-捕获分离给酒店资金保证、给客人灵活性——但给支付状态机加复杂度。

支付流程

  1. 结账期间,为整个住宿金额向支付处理器(Stripe)创建一个 PaymentIntent。这授权卡而不扣它——持有出现在客人卡账单上但无钱移动。
  2. 入住时(或预付酒店立即),捕获授权的 PaymentIntent。若卡授权已过期(通常 7 天),请求新授权。
  3. 取消时,按酒店取消政策(入住前 X 天免费取消;部分退款;不退款)释放授权或在已捕获时发退款。

每步幂等

边界情况

若捕获在支付处理器成功但确认成功的 webhook 丢失会怎样?预订卡在 RESERVED 状态而客人的钱实际被捕获。一个每晚对账作业把 RESERVED 状态的预订记录与处理器的已结算交易比较——若一个 PaymentIntent 在 Stripe 被捕获但预订在我们 DB 是 RESERVED,把预订提升到 BOOKED。这个模式(乐观本地状态 + 对账)对任何支付相关系统必不可少。

第 11 步 — 取消与退款

取消是预订生命周期内它自己的状态机。酒店取消政策差异很大——从"随时免费取消"到"入住前 48 小时内不退款"——预订服务必须在取消时强制这些规则,而非仅在预订时。

取消政策强制

取消时恢复可用性

预订取消时,必须为被取消住宿的每晚恢复可用性计数。这是预订 Saga 里的补偿动作:

SQL
-- 补偿动作:取消时恢复可用性
BEGIN;
UPDATE bookings
  SET status = 'CANCELLED_BY_USER', updated_at = NOW()
  WHERE id = :booking_id
    AND status IN ('RESERVED', 'BOOKED');  -- 幂等:已取消则 no-op

UPDATE room_availability
  SET available_count = available_count + 1
  WHERE room_id = :room_id
    AND date BETWEEN :checkin AND :checkout - INTERVAL '1 day';
COMMIT;
-- 然后:经支付服务发退款(异步)
-- 然后:发布 BookingCancelled 事件到 Kafka

第 12 步 — 可用性日历

可用性日历是驱动搜索过滤和房间详情视图("给我看这个房型哪些日期可用")的数据结构。带 (room_id, date) 主键的 room_availability 表是权威来源。对面向用户的日历 UI,一个预计算缓存必不可少:

多晚可用性检查

"巴黎,7 月 1–5"的搜索必须验证至少一个房型在所有四晚(7 月 1、2、3、4)有可用性。一个带 GROUP BY 和 HAVING count = 4 的 room_availability SQL 查询正确工作。为规模化的搜索时性能,预计算每房间的"从日期 X 到日期 Y 可用"范围并用日期范围字段在 Elasticsearch 索引它们。这允许 Elasticsearch 做过滤而无需每个搜索命中都打 SQL DB。

第 13 步 — 分析与报告

分析服务从 Kafka 消费两个事件流:搜索动作(用户搜索什么、点击哪些结果)和预订交易(预订、取消、收入)。这些馈送驱动酒店管理仪表盘和平台级报告:

分析事件从 Kafka 流入一个数据仓库(ClickHouse 或 Redshift),SQL 聚合在那运行而不碰运营 DB。酒店管理仪表盘查仓库;运营预订服务从不用于分析查询。

第 14 步 — 扩展与容错

酒店预订读主导(搜索远超预订)但写关键(预订写必须正确)。各自的扩展策略不同:

搜索扩展

预订写扩展

容错

第 15 步 — 为什么不用 Cassandra 做预订?

不像电商订单历史(高量、追加重、适合 Cassandra 归档),酒店预订量低到一个 SQL 主库能保留完整预订历史而无需专用归档服务。在此数据量,ACID 事务的正确性好处胜过 NoSQL 的可扩展好处。具体地:

第 16 步 — 关键取舍

决策选择接受的取舍
主存储全程 SQL比 NoSQL 更低横向写规模;由自然低的预订写率缓解
并发控制乐观锁冲突时需重试;热门房间高争抢下重试率飙升——加指数退避
搜索索引Elasticsearch(最终)新预订房间在搜索里可能显示可用数秒直到 Kafka 传播;用短 TTL 可用性缓存补偿
结账持有Redis TTL(~10 分钟)即便用户放弃房间也被阻塞 10 分钟;用更短 TTL(5 分钟)和清晰 UI 警告缓解
支付时机现在授权,入住时捕获远期预订的授权能过期(7 天);需要时必须重新授权
分析Kafka → 数据仓库分析最终一致(分钟到小时滞后);对报告用例可接受
总结

酒店预订设计全关于一致性:SQL + 行级约束在数据库级别防双重预订;Redis TTL 持有解决结账间隙而不永久阻塞库存;乐观锁让事务短且高并发;Kafka 异步喂 Elasticsearch 使搜索准确。"为什么不 NoSQL"的答案简单——预订量不足以证明牺牲 ACID 合理,且酒店数有界。预订 API 上的幂等键是正确系统和在网络重试时给客人双重扣款的系统之间的区别。

🎯 面试速答

并发请求下怎么防止双重预订?room_availability 表上的 CHECK (available_count >= 0) 约束使任何会递减到零以下的事务在 DB 级别失败。带 version 列的乐观锁确保同一房间+日期的两个并发预订不能都成功——一个会用新数据重试。
为什么不像电商用 Cassandra 做订单历史那样做预订?酒店预订量低到 SQL 能保留完整历史;ACID 事务干净防双重预订;Cassandra 的最终一致模型会需要复杂的应用级冲突解决。
用户选房间后放弃结账会怎样?Redis TTL(~10 分钟)自动过期,触发一个后台作业(或键空间通知处理器)为每个住宿日期重新递增 available_count——无需手动清理或 cron 作业。
怎么让预订 API 幂等?要求一个客户端生成的 Idempotency-Key 头;把它存在 bookings 表作 UNIQUE 列。用同键重试的请求返回已存在的预订而非创建重复——防止网络重试时双重扣款。
怎么处理预订确认时支付处理器超时?在捕获调用前把 PaymentIntent ID 存进预订记录。用同一 PaymentIntent ID 带指数退避重试捕获(Stripe 去重)。一个每晚对账作业抓住任何 PaymentIntent 已捕获但预订还在 RESERVED 状态的预订——把它们提升到 BOOKED。

← 上一篇
设计 URL 短链