酒店订单交易系统架构实践:状态机、库存控制与高并发稳定性设计
简介一份围绕美团酒店订单交易系统架构实践的PDF讲义面向后端架构师、订单交易系统研发及对高并发业务设计感兴趣的技术人员旨在解读酒店订单交易系统的架构演进与落地方法。压缩包共1份PDF文件大小3.26MB资料已在平台被265人学习。内容涵盖酒店订单系统架构实践、业务简介、系统发展、架构挑战、业务开发与系统重构、关键流程、服务调用关系、数据存储、业务架构梳理、技术架构与质量概念完整性等模块。讲义以美团酒店后台实践为背景展开hotel-server直连、预付、团购到酒店订单X开环的发展脉络并结合下单、支付、预订、取消、退款等关键流程分析服务调用关系、数据存储与业务架构梳理思路同时给出主流程梳理、应用架构分层、灰度发布、服务降级、监控报警等稳定性与扩展性实践可作为后端团队了解酒店订单交易系统设计的参考。1. 美团酒店订单交易系统架构实践为什么一张订单会卡在“确认中”先说一个我踩过的坑大促刚开始下单接口的 RT 看着正常订单库里却突然出现了上千条“确认中”的订单压了半小时没动静最后发现是支付回调解读错了渠道的状态码。后来我把这套酒店订单交易链路完整重做了一遍核心就一句话订单系统不是靠接口串联而是靠状态机兜底。这篇笔记不转抄原稿只按我自己的实现路径把酒店订单从商品页到确认入住之间的核心链路拆开讲清楚。适合正在做 OTA、电商交易或者订单中台的后端工程师尤其是那种“支付成功了库存却对不上”的存量系统。2. 从下单到确认酒店订单核心链路怎么拆才不甩锅2.1 先算价还是先锁房酒店订单和商品订单的最根本区别酒店订单和普通商品订单最本质的区别是“先付钱后确认”。用户下单后系统需要向供应商确认这个房型当晚还有没有空房、价格有没有变化、是否接受连住规则。订单要经过“试算—下单—支付—确认”四个阶段任何一个阶段失败订单都可能回到取消状态。所以链路不能只做一个接口。常见的做法是拆成六步试算价格、创建主单、预占库存、支付回调、供应商确认、完成/取消。链路步骤核心动作数据变化失败处理试算价格读价格日历、活动规则生成价格快照写入临时的价格快照记录返回“价格已更新请重新选择”创建主单写主单和子单状态置为待支付写入 hotel_order_main / item返回订单号拉起支付预占库存按入住日期预占房型库存扣减 Redis 库存并落占用明细失败则取消订单释放链路支付回调更新支付单和订单状态为已支付状态待支付转已支付渠道重试幂等表保证不重复供应商确认把订单发给供应商等待确认结果状态已支付转确认中 / 已确认定时补单超时自动取消完成/取消确认后生成入住凭证退改由退款单承接状态已确认或已取消退款走独立流程为什么把“试算价格”独立出来因为酒店价格有价格日历、连住优惠、提前预订优惠价格不是在支付时算而是在详情页就用快照方式锁定。如果支付之后再次算价金额可能已经变了。另一个原因是“预占库存”要尽量晚先试价真到支付前才锁库存避免用户停留在支付页时库存被白白占用。这里的关键顺序是“先创建订单再扣库存”把预占失败放进取消补偿流程而不是像普通商品那样先减库存再建单。2.2 订单数据模型主单、子单、价格快照的拆法一个酒店订单里可能出现“大床房两晚 双床房一晚”的组合价格又分基础房费、税费、服务费、优惠分摊。把这些塞进一张订单表后续做退改、财务对账、供应商结算会非常痛苦。我一般拆三张表订单主表、子单表、价格快照表。先看主表create table hotel_order_main ( id bigint not null comment 内部自增主键, order_id varchar(32) not null comment 对外订单号全局唯一, user_id bigint not null comment 下单用户ID, hotel_id bigint not null comment 酒店ID, status tinyint not null comment 状态10待支付20已支付30确认中40已确认50已取消, total_amount decimal(10,2) not null comment 订单总额, currency varchar(8) not null default CNY, created_at datetime not null, updated_at datetime not null, primary key (id), unique key uk_order_id (order_id), key idx_user_id (user_id) ) engineinnodb default charsetutf8mb4;这里最关键的约束是uk_order_id。很多团队习惯用自增主键直接当订单号展示给用户结果前端重试、渠道回调时很难判断同一笔订单是不是同一个。红线是对外订单号必须全局唯一且由发号器生成内部自增主键只是聚簇索引不能暴露给业务。子单表按“每个酒店每晚一个子单”来建create table hotel_order_item ( id bigint not null, order_id varchar(32) not null, room_type_id bigint not null, check_in_date date not null, check_out_date date not null, rate_plan_id bigint not null, amount decimal(10,2) not null, primary key (id), key idx_order_id (order_id) ) engineinnodb default charsetutf8mb4;价格快照表单独存在是为了防止供应商改价后无法追溯create table hotel_order_price_snapshot ( id bigint not null, order_id varchar(32) not null, item_id bigint not null, room_type_id bigint not null, date date not null, sale_price decimal(10,2) not null comment 售卖价, tax_fee decimal(10,2) not null comment 税和平台服务费, cost_price decimal(10,2) not null comment 成本价不对用户展示, primary key (id), key idx_order_id (order_id) ) engineinnodb default charsetutf8mb4;为什么要分三张表因为状态机作用在主单上价格作用在具体日期上。如果只存一个订单总金额某一个晚上退改、某一个房型调价时必须另开一张变更流水查询和结算都很别扭。拆开之后财务拿订单号去两张子表汇总就能直接算出对账单。2.3 状态机设计待支付、支付中、已支付、确认中、已确认、已取消酒店订单的状态不能只有“待支付”和“已完成”因为支付成功之后还有供应商确认。这个阶段用户体验上非常敏感用户已经付了钱但房间到底有没有锁住需要等确认结果。我必须把“确认中”这个状态明明白白暴露给用户。常见状态机包含六个状态状态枚举值可转入状态说明待支付AWAIT_PAY(10)已支付、已取消创建订单后 15 分钟未支付自动取消支付中PAYING(15)待支付、已支付用户拉起收银台等待回调已支付PAID(20)确认中、已取消支付成功等待下发供应商确认中CONFIRMING(30)已确认、已取消供应商已收到请求等待确认已确认CONFIRMED(40)已取消仅退改供应商确认有房订单成立已取消CANCELED(50)—终态退款由退款单承接Java 里的状态流转可以用枚举约束避免到处改字段导致非法流转public enum OrderStatus { AWAIT_PAY(10), PAYING(15), PAID(20), CONFIRMING(30), CONFIRMED(40), CANCELED(50); private final int value; private static final MapOrderStatus, SetOrderStatus transitions new HashMap(); static { transitions.put(AWAIT_PAY, EnumSet.of(PAID, CANCELED)); transitions.put(PAYING, EnumSet.of(AWAIT_PAY, PAID)); transitions.put(PAID, EnumSet.of(CONFIRMING, CANCELED)); transitions.put(CONFIRMING, EnumSet.of(CONFIRMED, CANCELED)); transitions.put(CONFIRMED, EnumSet.of(CANCELED)); transitions.put(CANCELED, EnumSet.noneOf(OrderStatus.class)); } public boolean canTransitTo(OrderStatus target) { return transitions.get(this).contains(target); } }这段代码的关键在CANCELED是终态不允许再变回去以及PAID既可以进CONFIRMING也可以进CANCELED区别在于是否已经把确认请求发给供应商。请求没发出去用户申请取消可以直接取消请求已经发出就要走“取消申请—供应商确认取消—退款”的补偿链路。还有一个参数要单独说“确认中”这个状态不要设计超时字段。我习惯建一张order_confirm_task任务表记录下发确认请求的时间定时扫描超过 5 分钟还是CONFIRMING的订单自动取消或转人工。因为确认中本身是过渡态一旦增加“确认超时”状态状态机会迅速膨胀到十几个前后端联调和运营看订单都容易看花眼。3. 酒店房间库存和并发控制为什么不能照抄普通商品库存3.1 库存模型按房型 × 日期维度而不是“总库存”普通商品卖一件减一件总量递减即可。酒店房间库存是一个矩阵酒店有 100 间标准大床房但库存不是 100而是把每一夜拆开。3 月 1 日有 100 间3 月 2 日有 100 间一个订单连住两晚占用两个日期上的各一间。如果只按“总库存”扣跨天超卖会非常隐蔽。设计 Redis key 时我建议把日期直接放进 keyKey 示例含义hotel:room:501:20260301:20260302酒店 501 房型3 月 1 日剩余可用房间数日期到底写入住日还是离店日两种都有人用我建议按“入住夜”来写。订单从 3 月 1 日住到 3 月 3 日就扣20260301和20260302两个 key。一个订单动多个 key 并不可怕只要用 Lua 脚本保证原子性。Lua 扣减脚本-- KEYS: 每个入住夜的库存key -- ARGV[1]: 需要扣减的间数 for i 1, #KEYS do local left redis.call(get, KEYS[i]) if not left or tonumber(left) tonumber(ARGV[1]) then return -1 end end for i 1, #KEYS do redis.call(decrby, KEYS[i], ARGV[1]) end return 1为什么要用 Lua一个订单可能连续住多晚如果第一天扣成功、第二天扣失败就会出现“前半段有房后半段无房”的脏结果。两条命令之间有并发窗口用 Lua 包住后Redis 单线程执行整个脚本要么全成功要么全不变化。脚本返回 -1 时应用层要把已创建的待支付订单取消掉避免用户支付前发现房间已经没了。参数上有两个坑。第一decrby允许扣成负数所以脚本必须前面先做余量检查否则可能把库存扣成负数。第二这些库存 key 要设置 TTL我一般给 48 小时避免某些日期写了 key 之后没有清理造成内存堆积和数据陈旧。3.2 缓存扣减后的最终一致性先单据还是先减库缓存扣减永远不是终点。Redis 一重启、key 一过期、数据回源失败都会和数据库库存不一致。常见架构是“Redis 做售卖窗口数据库做最终账本”。下单时按下面顺序执行创建订单主单状态待支付。用 Lua 扣减 Redis 库存。扣减成功落一条room_inventory_occupy库存占用明细。明细写入数据库失败订单标记取消并异步回补 Redis 库存。定时对账任务每天扫描 Redis 库存和数据库占用明细不一致时以数据库为准。这里最容易翻车的顺序问题先扣 Redis再写数据库失败后要立刻回补 Redis如果先写数据库再扣 Redis失败后要删除数据库占用明细。两侧操作不是原子的必须有一个对账任务兜底。对账任务在凌晨执行一次扫出“Redis 有库存但数据库没有占用”或“数据库有占用但 Redis 没扣减”的记录生成补偿任务。我习惯把对账任务做成独立服务不跟主交易链路放在同一个应用里。因为夜间对账任务一旦写库逻辑写错会直接影响所有在线订单的库存扣减双方互相拖累排查起来非常痛苦。3.3 分库分表订单量级上来之后的三个决策点订单表涨到千万级之后分库分表是必经之路。动手之前有三个决策点必须确定分片键选用户 ID 还是订单 ID查询维度怎么覆盖用户查“我的订单”酒店查“近期订单”财务查“某天全部订单”全局唯一订单号怎么生成主流做法是按用户 ID 分 16 或 128 个库订单 ID 内部带上分片信息。用户查订单直接路由酒店后台查订单则通过“订单索引表”或同步到 Elasticsearch 解决。比如hotel_order_main以user_id作为分片键order_id前面加一个crc32(user_id) % 分片数的前缀。订单号长这样03-20260301120001-123456解析出03就能直接定位到第三个分片。第二个查询维度是“用户拿订单号查详情”。订单号前缀里嵌了分片号直接解析就能路由不需要遍历所有分片。如果运营只拿hotel_id查订单就要建一张酒店维度的二级索引表或者把 binlog 订阅到搜索引擎。交易系统不要把主表直接写入搜索引擎一般做法是订阅主库 binlog只同步“订单创建、支付成功、确认状态”这几个关键事件避免搜索引擎与主库强耦合。第三个全局订单号不能依赖自增一是全局唯一难保证二是要能解析分片。常见做法是“雪花算法 分片后缀”不需要把节点数设得太多平均分配到分片即可。4. 峰值流量下的稳定性先保支付还是先保确认4.1 容量评估先算清楚下单 QPS、支付回调 QPS、库存写 QPS谈性能绕不开三笔账下单 QPS、支付回调 QPS、库存扣减 QPS。很多团队只压测下单接口忽略了支付回调在支付成功后的 30 秒内会集中到达。假设大促有 2 万用户同时下单转化率按 30% 的人发起支付支付回调峰值通常会是下单峰值的 2 到 3 倍因为渠道会重试。容量规划必须按最坏情况估算。链路平时峰值大促估算主要压力点详情页 / 算价2000 QPS5000 QPS读缓存写快照创建订单300 QPS1500 QPS写主单、子单、扣库存支付回调150 QPS2500 QPS幂等表、状态流转供应商确认50 QPS300 QPSMQ 消费、外部接口计算时不要把“下单 QPS”和“缓存 QPS”混在一起。用户下单前要请求详情、试算价格、读库存这些接口的 QPS 是下单 QPS 的十倍以上限流阈值必须分开管控。我一般先找到压测时最先顶不住的那个节点再倒推合理容量。比如数据库连接池最大只有 500而支付回调也要用同一个连接池那必须在连接池之外做好排队和限流否则一个慢查询会把所有接口拖垮。4.2 限流降级网关层、交易层、库存层怎么分分层的意义是各层保护目标不同。网关层只做最粗的 QPS 限制拒绝超出阈值的请求不关心业务是否成功交易服务层针对某个用户、某个酒店、某个订单号的频率做防护库存层只允许通过缓存 Lua 脚本扣减防止数据库被打满。常见的规则配置如下resource: POST /v1/hotel/order/create gatewayRule: - grade: qps threshold: 1500 controlBehavior: fastfail serviceRule: - resource: createHotelOrder grade: concurrency threshold: 200 controlBehavior: block这里的threshold不能简单按“机器数 × 单机 QPS”估算。创建订单接口必须给支付回调留出余量。如果单机下单 QPS 是 500两台机器理论能扛 1000但支付回调也会占用数据库连接所以我会按“下单 : 回调 1 : 3”的比例预留容量。限流参数定死之后要通过压测回放验证不能拍脑袋。降级也一样。价格计算依赖的活动中心、优惠中心在大促时经常先被打挂这时候常见做法是降级成“基础价直减”舍弃复杂的阶梯满减。但库存扣减、支付回调不能降级——一个是房一个是钱。降级清单要按“核心链路不可降级非核心链路可降级”来排优先级现金交易、库存占用、订单落库属于核心链路优惠计算、积分、短信、发票属于可降级链路。4.3 异步化支付结果通知和供应商确认怎么削峰支付回调如果同步处理用户支付后要等很久而且渠道超时重试会和正常订单抢资源。常见的做法是两步走回调接口只做幂等检查和消息落地直接返回“成功”给支付渠道异步消费者再处理状态更新、发送供应商确认请求。MQ 的 Topic 可以这样划分Topic生产者消费者失败处理order-pay-callback支付回调状态更新服务重试 5 次后进死信队列order-supplier-confirm订单服务供应商适配层超时未确认进补单任务order-cancel-compensate取消接口取消协调服务退款单 库存回补消费者并发数不能拍脑袋。支付回调消费者要预留足够的数据库连接池一般先按 200 并发跑观察数据库活跃连接数。供应商确认消费者受外部接口限制并发过高会被对方限流所以我会把消费线程数压到 20 以下并把外部接口超时时间设置成 1.5 秒让连接尽快释放。刚开始容易犯的错误是“业务串行”一个消费任务里同时调供应商、更新状态、发短信一个慢环节卡住整个消费者。一定要拆成独立步骤订单状态先落库短信发失败不影响订单确认。5. 避坑与排查订单交易系统最容易翻车的 5 个真实现场5.1 现象支付回调重复到达用户被扣了两次钱渠道回调接口不稳定一笔支付可能重试十几次。没有幂等保障时第一次回调把订单置为已支付第二次回调又生成一笔支付流水财务对账就出现差额。原因回调处理没有做“同一订单同一支付流水”的唯一约束。解决建pay_callback_idempotent表给order_id payment_trade_no加唯一索引。回调进来时先insert插入冲突说明是重复回调直接返回成功插入成功才进事务更新订单状态。这里有个细节唯一索引要在事务外先插入拿到主键说明是首笔再进事务更新。如果先查询再插入并发重复回调会同时查不到然后都去更新状态又出现脏写。5.2 现象大促当天出现“幽灵锁房”库存扣了订单却没创建Redis 扣减成功但数据库事务提交失败用户看到订单创建失败重试又提示没房实际上库存已经占了。原因Redis 和数据库之间没有分布式事务单靠 try-catch 无法保证两侧原子性。解决把“库存占用”设计成订单的一部分。先在事务里插入一条inventory_occupy记录再发一条 MQ 给“真实库存扣减”消费者如果事务回滚MQ 消息不会发出Redis 不会被误扣。如果已经扣了 Redis则订单主单状态置为取消同时发一条补偿消息用数据库回补任务把 Redis 数量加回来。对账任务还是要配每小时扫一次 Redis key 和数据库 occupy 表差距超过阈值就告警。不要手动去改 Redis 值我踩过坑值班同学直接set恢复库存把别人刚扣掉的占用也覆盖了最后造成超卖。5.3 现象分库分表之后运营后台查订单查不到线上用户查订单正常但运营后台按酒店 ID 查“最近 1 小时下单数量”发现只返回了一个分片的数据。原因订单主表按用户 ID 分片酒店 ID 无法直接定位分片查询变成全分片聚合运营后台又没走索引表。解决给订单增加一张“酒店维度索引表”把hotel_id order_id status created_at冗余写入。后台查询先查索引表再按订单号回源主表。索引表和主表通过 binlog 订阅同步延迟超过 2 分钟时直接读主库查一次。这看起来多了一张表但可以避免后台请求把各分片连接池全部打满是必要成本。5.4 现象一个事务里同时更新订单和库存数据库死锁频发我见过一个团队把“创建订单、扣减库存、更新价格快照”全部写进一个大事务数据库超时率特别高。原因两个并发订单可能持有不同顺序的行锁更新库存表时互相等待形成死锁。解决把事务拆小。创建订单和更新价格快照放在一个事务里扣库存从数据库事务中移出去改为在缓存层完成。如果业务要求必须在数据库扣也要固定行锁顺序比如先锁入住日期小的行再锁入住日期大的行统一顺序后再更新。死锁发生后的重试次数不要超过 3 次且每次间隔指数退避否则会把数据库继续压垮。5.5 现象支付成功后确认接口超时用户以为没有下单成功支付回调正常但向供应商发起确认的接口把订单挂起了前端一直转圈。用户反复点“确认订单”按钮又生成一批新的待支付订单。原因确认环节是同步调外部接口没有把“已支付确认中”这个状态尽早返回给前端。解决调供应商的步骤必须异步化。支付回调成功后就返回“支付成功确认中”前端用轮询查订单状态而不是重复提交。状态机里PAID - CONFIRMING - CONFIRMED这条链路要可查订单详情接口对CONFIRMING订单返回“已支付正在确认房间预计 5 分钟出结果”。外部接口超时后由补偿任务重试不能依赖用户重试按钮。6. 压测和故障演练验证订单系统到底有没有“后悔药”6.1 压测不只压下单接口支付回调和库存回补才是隐藏大头我习惯把大促压测拆成三段正常下单峰值、支付回调乱序重试、库存回补高峰。下单接口压测通过不代表交易系统能扛住真正的风险是一个回调把几十万条确认结果消息同时推过来。先压“支付回调”再压“下单与取消并发的场景”。压测时记录三个指标下单 P99 RT、数据库活跃连接数、支付回调消息积压量。如果支付回调积压超过 5 分钟就要把消费者并发调大或者减少下单接口流量因为用户已经付完钱等确认比等下单更焦虑。压测方式用 20 个线程、每秒钟发 30 个请求把 QPS 控制在 600跑 10 分钟观察有没有慢查询或死锁。不要用“越压越快”做结论要看限量之后的回落曲线。容量评估最大的坑是把所有接口压在同一台机器上一台机器的资源抖动会让数据失真至少压两台到三台。6.2 故障演练刻意制造单点缓存挂了、消息积压了补偿任务能不能起来我每周五下午做一次“小规模演练”杀掉一个 Redis 实例看下单接口是否拒绝库存扣减数据库是否回落到兜底逻辑再把消息队列消费者的一个节点停掉看积压超过阈值时消费者线程有没有保护。这个过程不是把系统打挂而是验证“后悔药”是不是真的存在缓存挂了有兜底MQ 积压了有告警库存不一致了有对账。演练结束后我会把每个缓存 key 的 TTL 检查一遍把这次演练中新增的补偿任务跑完整。这个习惯救了我好几次有一次演练发现 Redis 集群切换后库存 key 没有自动预热结果所有下单请求直接打到数据库数据库连接池被打满。如果没提前演练大促当天就是事故。总结成一句话架构里永远不要出现“这次应该没问题”的黑匣子缓存、消息、对账任务都要用故障演练验证过才敢上线。希望帮到你。本文还有配套的精品资源点击获取