专业书籍精读 · System Design Interview 卷二 · 第 7 章
System Design Interview Vol 2 · Ch 7 · Alex Xu & Sahn Lam · 2022
你在订房 App 上挑好房间、点下「预订」,那一两秒里系统要办一件很凶险的事:确认这间房还在、记在你名下,同时不能把同一间房也卖给正在点同一个按钮的另一个人。这一章就是从零把这套系统搭出来。
你大概以为这种系统难在「人太多」。算一下:五千家酒店、一百万间房,就算住满七成、平均住三晚,一天也只有二十多万笔预订——摊到每秒大约三笔。三笔!比一个小论坛的发帖量还低。难的从来不是量,而是那三笔里可能有两笔在抢同一间房,而且抢错了要赔钱。
想象前台那本纸质登记簿。客人打电话订房,前台翻到 3 月 5 日那页,看见还剩一间,写下名字。麻烦在于同一秒另一个同事也翻着同一页、也看见「还剩一间」、也写了个名字。两个人都收了钱,房只有一间——这就是超卖。纸簿子如此,两台服务器同时读同一行数据,一模一样。
第一步是换个卖法。酒店卖的不是「301 号房」,而是「大床房 · 3 月 5 日 · 还剩几间」这一格额度——就像电影院卖的是「周六晚场还剩多少座」,具体坐哪儿进场再说。于是「在几万间房里找一间空的」变成了「把一格数字减一」,简单太多。
第二步是管住这一格。两种活法:一种是改之前先把那一页扣在手里,谁也别看,改完再放开——保险,但排队的人一多就堵;另一种是先记下这一页的版次,真要改时核对版次变没变,变了说明有人插了队、自己重来一遍——平时更快,抢得凶时会反复重来。
第三步治另一种重复:你手抖点了两下「提交」。办法是进下单页时先发你一张票号,同一张票号只兑一次订单——像排队取号,号在手里,重复喊也只算一个人。
平常的酒店,第二种(核对版次)就够了,甚至可以直接让数据库守着「订出去的不许超过总数」这条规矩;只有演唱会前一晚全城抢一家酒店那种场面,才值得给这一格单独排队。代价是:你偶尔会在最后一步看到「刚好被订走了」——系统宁可让你白点一次,也绝不把同一间房卖给两个人。
订房系统的难点不是流量(每秒只有几笔),而是有限库存上的正确性:把房间换算成「房型 × 日期」的一格额度,再用「一次只让一个人改这一格」和「一张票号只兑一次」把它守住。
想进到具体机制、记号和示意图? → 切到精读版
这一章表面在教你设计订房系统,真正教的是:有些系统难在正确性,不难在规模。粗略估算下来它每秒只有约 3 笔预订,却必须在同一行库存上顶住并发、还不能因为一次重试多卖一间房。骨架只有四件事:把可售资源抽象成「房型 × 日期」的库存行、用一次数据库事务把扣库存与写订单绑成原子操作、用幂等键(idempotency key)挡重复提交、用乐观锁或数据库约束挡超卖。拆成多个服务之后,才不得不请出 2PC / Saga / TCC 这些贵得多的工具。
SELECT ... FOR UPDATE。卷二第 7 章。前六章(附近服务、地图、消息队列、监控、广告聚合)清一色是吞吐与流式问题,这一章第一次转向事务型、库存有限的系统——全书从「扛得住」切到「算得准」的分界线;下启第 11 章支付、第 12 章数字钱包。现实对应:Booking.com / Airbnb / 携程的下单链路,以及任何「有限库存 + 钱」的系统:票务、航空舱位、电商秒杀、餐厅订座。
订房的全部难点集中在一个瞬间:两个人同时点了最后一间房的「预订」。不解决会怎样?超卖不是一条错误日志,是真金白银的赔付——客人到店没房,酒店得掏差价把他送去别家(业内叫 walk the guest),还赔上口碑。可怕超卖就把并发全串行化,又会让热门酒店在大促时订不动。这一章要走的是窄路:既不多卖一间,也别把系统锁死。
书里的场景是一家连锁:5000 家酒店、约 100 万间房。按 70% 入住率、平均住 3 晚估算,每天新增预订约 1,000,000 × 0.7 ÷ 3 ≈ 23 万笔;摊到一天 86400 秒,写入约 3 QPS。按漏斗往前推(看详情页的远多于真付钱的),读侧是数百 QPS量级。库存有多少行?5000 家 × 约 20 个房型 × 未来两年 730 天 ≈ 7300 万行,每行只有几个整数——几个 GB,一台关系库装得下。
结论很反直觉:这一章不需要分片、不需要 NoSQL、不需要消息队列削峰。估算在这里的作用不是证明「能扛」,而是劝你别过度设计——把全部注意力还给那 3 笔写。
这是整章的地基。数据模型只需两张关键表:订单表(reservation:幂等键、酒店、房型、入住 / 离店日、间数、状态)和库存表(room_type_inventory:hotel_id, room_type_id, date, total_inventory, total_reserved)——一家酒店、一个房型、一天,占一行。于是问「3/5 到 3/8 有没有房」就是看这三行是否都有余量;下单就是把这三行的 total_reserved 各加 1,再插一条订单。
为什么这样设计?① 照抄业务——酒店本来就按房型卖、房号入住时才分配,模型贴着现实走,后续改动最少;② 把搜索变成算术——「在几万间房里找一间空的」退化成「一行上加减一个整数」,并发控制才有明确落点;③ 把「多晚」变成「多行」——跨三晚就是三行写在同一个事务里,任何一晚满了整单失败,语义天然正确。代价也在这个乘法里:行数 = 酒店 × 房型 × 天数,所以必须设一个可售窗口(如未来两年)并定期滚动生成与清理。
看起来都是「订了两次」,病根完全不同——这是本章最值得记住的分类,也是面试里最容易被绕进去的地方。
第一种:同一个用户重复提交(超时重试、手抖双击)。治法是幂等:用户进入下单页时就生成一个预订号当幂等键随请求带上,订单表在这个键上建唯一索引;第二次提交撞上唯一约束,就不建新单、直接返回第一次的结果。最容易写错的是时机:键必须在「可能被重试的那一刻」之前就固定,每次点击都新生成一个,幂等就是假的。
第二种:两个用户抢同一格。两个请求都读到「余 1」、都算出「还能订」、都写回「已订 +1」——读-改-写竞态。幂等键在这里一点忙都帮不上:这是两个各自都合法的请求,只能靠并发控制。
悲观锁。SELECT ... FOR UPDATE 先把库存行锁住,改完提交才放开,后来者排队。正确、直观;代价是这一行上的并发被彻底串行化,持锁期间别人全在等,事务一长就拖出超时与死锁。
乐观锁。给库存行加一个 version 整数:读时带出来,写时用 UPDATE ... SET total_reserved = ?, version = version + 1 WHERE version = <读到的值>;受影响行数为 0 就说明有人先改过,重读重试。低争用下几乎没有开销,高争用下退化成重试风暴。
数据库约束。让库存行自己守规矩,如 CHECK (total_reserved <= total_inventory)(要留超售就写 <= total_inventory * 1.1)。最省代码,越界的写被数据库直接拒绝;代价是规则散在 schema 里不好随代码演进、各家数据库支持不一,应用仍得处理「被拒绝」这条路径。
按本章 3 QPS 的量级,作者选的是乐观锁或数据库约束——悲观锁在这里是拿最贵的工具去打最小的钉子。还有一条不能省的纪律:扣库存与写订单必须在同一个事务里,分开做、中间崩一次,就会留下「扣了库存却没有订单」(房间凭空消失)或「有订单却没扣库存」(超卖)。
把约束从「不超过总量」放宽成「不超过总量 × 系数」(书里用 110% 举例),系统就允许卖出比实际更多的房。为什么?取消与放空(no-show)是常态,严格按库存卖会让房间空着跑。代价是超售兑现不了就得把客人送去别家、承担差价与口碑。所以那个系数不是技术参数,是拿历史取消率换赔付成本的业务决策。
只要预订服务和库存服务各自拿着一个库,「扣库存 + 写订单」就跨了进程,ACID 不再免费。三条路:2PC 靠协调者两阶段拍板,强一致,但全程持锁、协调者挂掉会留下悬而未决的事务;Saga 把事务拆成一串本地步骤,失败时按相反顺序执行补偿,不持锁、能扩展,但只有最终一致性,而且补偿不是回滚——退款可能有手续费、取消可能有罚金,补偿自带业务语义;TCC 先 Try 预占库存、再 Confirm 或 Cancel,持锁比 2PC 短,代价是每个服务都要写三套逻辑。
这一章的立场很克制:能不拆就别拆。几个 GB、3 QPS 写,一个库的一次事务能解决的事,不值得用分布式事务换一张好看的架构图。
表 1 · 守住库存行的三种并发控制
| 悲观锁 | 乐观锁 | 数据库约束 | |
|---|---|---|---|
| 机制 | SELECT ... FOR UPDATE 锁住这一行 | 校验 version 未变才写,否则重试 | CHECK 让越界的写直接失败 |
| 低争用(3 QPS) | 能用,白付锁开销 | 几乎零成本 | 最省代码 |
| 热点行(抢房) | 串行排队、延迟陡升、可能死锁 | 重试风暴,成功率下滑 | 拒绝率上升,仍要应用侧重试 |
| 什么时候选 | 争用高且必须一次成功(如结算) | 本章默认解:写少、冲突罕见 | 规则简单、想让数据库兜底 |
表 2 · 「扣库存 + 写订单」放在哪:四种一致性方案
| 方案 | 一致性 | 持锁时间 | 代价 | 适用 |
|---|---|---|---|---|
| 单库一个事务 | 强一致(ACID) | 毫秒级 | 数据须同库,服务边界被绑住 | 本章的答案:几 GB、3 QPS 写 |
| 2PC | 强一致 | 整个两阶段 | 阻塞、协调者单点、延迟高 | 参与者少、事务短、必须强一致 |
| Saga | 最终一致 | 不持锁 | 补偿要自己写,且带业务语义 | 长流程(下单 → 支付 → 出票) |
| TCC | 最终一致(预占期近似锁) | Try 到 Confirm | 每服务写三套逻辑 + 边界处理 | 「先占住再慢慢付款」的库存 |
热点怎么办?平时不存在的问题,会在「演唱会前一晚、全城抢同一家酒店」时集中爆发。三条对策:把一格分桶(10 间的额度拆成 5 格各 2 间,并发打散到 5 行,代价是「有房但恰好在别的桶」);给这一格排队,把重试风暴换成可预测的等待;以及把读挡在外面——列表页读缓存与只读副本、拿到的余量只能当提示,只有真下单那一步回主库校验。
这套「库存行 + 一次事务 + 幂等键」的骨架,几乎是所有有限库存系统的通用解:票务(某个票档)、航空(航班 × 舱位 × 日期)、电商下单(SKU 库存)、餐厅与共享办公订座——换个名词就能搬。落地上:MySQL / PostgreSQL 的 SELECT ... FOR UPDATE 与唯一索引最常见;DynamoDB 用条件写与事务接口、并在事务写里附客户端令牌做幂等;Spanner 用悲观锁 + 自动重试;阿里与蚂蚁把 TCC / Saga 做成框架(Seata)开源。
面试里它的价值在于:这是少数能把事务、隔离与幂等考实的题目,面试官几乎一定追问三件事——「两个人同时订最后一间怎么办」「用户点了两次怎么办」「扣库存和写订单放在几个事务里」。答得上这三问,这一章就没白读。
① 这一章难在正确性不难在规模——约 3 QPS 的写、库存 7300 万行几个 GB,一台关系库就够。
② 核心抽象:卖的不是房号,而是「房型 × 日期」的一格额度(total_inventory 与 total_reserved),一晚一行。
③ 跨多晚的预订 = 多行库存各 +1 加一条订单,必须在同一个事务里,否则会漏出「房间消失」或「超卖」。
④ 两种「重复」分开治:同一请求被重发 → 幂等键 + 唯一索引;两人抢同一格 → 并发控制。互相替代不了。
⑤ 并发控制三选一:悲观锁(正确但串行化)、乐观锁(本章默认,高争用退化为重试风暴)、数据库约束(最省代码)。
⑥ 超售(如 110%)是业务决策:拿历史取消率换赔付成本,不是技术参数。
⑦ 拆服务后一致性要花钱买:2PC 持锁阻塞、Saga 只有最终一致且补偿 ≠ 回滚、TCC 每个服务写三套逻辑。能不拆就别拆。
⑧ 热点(大促抢房)才是真敌人:分桶、排队、把读挡在缓存与只读副本上,缓存余量只能当提示。