ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

酒店订单交易系统实战:状态机、库存预占与分布式事务设计

酒店订单交易系统实战:状态机、库存预占与分布式事务设计 简介美团酒店订单交易系统架构实践以PDF形式收录了酒旅后台研发团队的一次完整技术分享内容基于实际业务演进适合后端架构师、技术负责人以及对高并发交易系统感兴趣的开发者。分享围绕订单交易系统的设计展开从2012年直连、预付等系统发展阶段切入详细梳理了业务架构、应用架构、数据架构与技术架构覆盖下单/支付/预订/取消等关键流程、服务调用关系、数据存储与一致性方案并给出了稳定性、扩展性、质量概念完整性等架构挑战的应对思路。压缩包内为1个PDF文件大小3.26MB结构清晰包含系统发展脉络、业务架构梳理方法、模块化分层、灰度发布、监控压测等实战要点适合用来理解大型O2O交易系统的架构演进与重构经验。目前已有265人学习下载对正在做订单体系或交易中台设计的技术团队具备直接参考价值。1. 酒店订单交易系统为什么它比电商订单更难做做过电商订单的工程师第一次碰酒旅订单很容易被一个细节卡住用户下单不是买一个库存商品而是锁一段“不可再生的时间片”。同一家酒店同一晚的同一房型今天卖出去今晚就没了明天还得重新算。我在接手美团酒店订单交易系统时最直观的感受是电商的SKU库存是“数量”问题酒旅的库存是“日历”问题一个订单还可能横跨多个间夜取消策略、担保规则、价格日历全搅在一起。这篇笔记不讲PPT式的架构图直接拆订单状态机、库存预占、分布式事务、分库分表这些必须自己动手的部分以及每个环节最容易翻车的地方。适合正在设计订单交易系统或想重构已有系统的后端工程师照着复现能少走不少弯路。2. 订单状态机从下单到入住的流转约束2.1 先定义状态一个酒店订单到底有哪些环节酒店订单和电商订单最大的差异是状态多且长短不一。用户提交订单后可能还在待支付支付成功后平台要向酒店确认房态这时候是“已支付待确认”确认后是“已确认”到店办理入住是“已入住”离店后是“已退房”中间任何环节都可能取消或超时关闭。还因为有预付和现付之分有的订单支付发生在入住前有的发生在离店后状态机不能只画直线。我一般会把状态收敛到最少必要集合待支付、已支付、已确认、入住中、已退房、已取消、已关闭。不把“待酒店确认”和“支付成功”拆成两个状态而是用子状态或字段“confirm_status”去区分。这样做的好处是订单主状态只有七个所有下游系统支付回调、库存释放、发票系统都按主状态判断不会出现一个订单同时被两个流程操作成不同状态的乱局。状态一旦定义清楚接下来就是约束流转方向这是状态机存在的意义。如果没有状态机业务代码里会散落大量if (status A action B)的判断加一个分支要改七八处还容易漏。状态机把“允许谁到谁”集中在一个表里新需求来了增删一条边就行。public enum OrderState { CREATED, // 已创建 PENDING_PAYMENT, // 待支付 PAID, // 已支付待确认 CONFIRMED, // 已确认 CHECKED_IN, // 已入住 CHECKED_OUT, // 已退房 CANCELLED, // 已取消 CLOSED; // 已关闭终态 private static final MapOrderState, SetOrderState TRANSITIONS new HashMap(); static { TRANSITIONS.put(CREATED, EnumSet.of(PENDING_PAYMENT, CANCELLED)); TRANSITIONS.put(PENDING_PAYMENT, EnumSet.of(PAID, CANCELLED)); TRANSITIONS.put(PAID, EnumSet.of(CONFIRMED, CANCELLED)); TRANSITIONS.put(CONFIRMED, EnumSet.of(CHECKED_IN, CANCELLED)); TRANSITIONS.put(CHECKED_IN, EnumSet.of(CHECKED_OUT)); TRANSITIONS.put(CHECKED_OUT, EnumSet.of(CLOSED)); } public boolean canTransferTo(OrderState target) { return TRANSITIONS.get(this).contains(target); } }这段枚举把七个主状态和可跳转边收敛在一个对象里。参数说明TRANSITIONS的 key 是当前状态value 是允许跳转的目标状态集合比如PAID只允许去CONFIRMED或CANCELLED想在代码里加一个“支付成功后直接入住”的逻辑就必须先在这里加边编译不过就是天然的保护。实际项目里状态机配置常常存到数据库或 Apollo 里线上可以动态调整但核心思想一致。2.2 状态变更的幂等与并发控制一条SQL就能挡住重复回调状态机解决了“能不能跳”的问题但没解决“同时跳”的问题。支付回调、用户取消、后台确认这三个动作可能并发到达比如用户点了取消支付成功回调也同时到了。如果两个请求都先读状态再写状态就会有一个请求读到旧状态最后把终态覆盖掉。常见的做法是在状态更新语句里带上前置状态条件用数据库行锁保证原子性。UPDATE order_info SET status CONFIRMED, version version 1, confirm_time NOW() WHERE order_id #{orderId} AND status PAID AND version #{oldVersion};这条 SQL 是订单状态变更的通用模板。它把“校验旧状态”和“更新新状态”合并成一条 update数据库行锁保证同一时刻只有一个请求能更新成功。status PAID是前置状态条件version是乐观锁字段每次更新都加一。如果 update 影响行数为 0说明状态已经不是 PAID 或 version 不对直接丢弃本次请求或抛出明确异常。这里有个参数取舍version的作用是防止ABA问题。一个请求先读到 version3另一个请求把状态从 PAID 改到 CONFIRMEDversion4再有人把它改回 PAIDversion5某些场景下没有 version 就会让第一个请求拿旧 version 覆盖新状态。加了 version 后第一个请求带 version3更新条件是version 3实际版本已经是 5更新失败流程必须重新查询最新状态。2.3 状态机引擎为什么要独立于业务代码把状态机从业务代码里抽出来不是炫技是为了让审日志的人一眼看出问题。我做过的最丑的事故之一是线上订单状态到了 CHECKED_OUT但日志里没有任何一条代码路径允许这个跳转。查了半天才发现是一个绕过状态机的“内部直改”接口。界面上只有状态机不行所有写状态的入口都必须收口到一个服务方法里外部系统只能通过 RPC 或消息触发动作不能直接改库。收口后的状态变更服务建议在日志里记录orderId, fromState, toState, operatorId, sourceSystem五个字段。这样即使状态跳错了也能通过日志还原操作链。另一个值得做的点是给状态机加一个“状态变更历史表”每次 transition 成功就在历史表插一行和订单表分开存储。历史表数据量大但只做追加写入查询慢一点也没关系它是事后排查和给用户展示轨迹的唯一证据。3. 库存预占与扣减酒店防超卖的两种姿势3.1 酒店库存模型多了一个“日期”维度电商库存表通常长这样sku_id total_stock sold_stock。酒店不行一个房型在 10 月 1 日和 10 月 2 日可能分别是满房和空房所以库存的粒度必须是hotel_id room_type_id biz_date。用户订 10 月 1 日到 10 月 3 日两晚实际上是锁两个日期的库存。库存表一般有三类数量总库存total_stock、已售库存sold_stock、预占库存hold_stock。注意“已售”和“预占”的区别已售是用户支付成功、订单确认后占用的预占是用户下单但还没支付时暂时占住的。预占的目的一方面是防止用户付了钱却没房另一方面是防止有人下单但不支付导致超卖。预占必须有超时释放机制否则恶意用户下单不付款能把酒店房全部锁死。3.2 两阶段扣减预占Hold与确认Commit我把酒店下单拆成两个阶段。用户点击“提交订单”先做库存预占给用户 15 分钟支付时间此时hold_stock 1。用户支付成功后把预占转成已售即hold_stock - 1, sold_stock 1。如果用户取消或超时直接释放预占hold_stock - 1。这里有一个容易踩坑的边界支付成功并不等于订单确认中间还要等酒店确认有房。所以“预占转已售”应该发生在订单确认成功而不是支付成功。如果支付成功就转已售酒店确认满房后要取消订单还得再做一次库存回补多一次失败窗口。我的建议是支付成功只把订单状态改成 PAID等收到酒店确认房态的推送或查询到CONFIRMED后再执行预占转已售。-- 预占成功 UPDATE t_hotel_inventory SET hold_stock hold_stock 1, update_time NOW() WHERE hotel_id #{hotelId} AND room_type_id #{roomTypeId} AND biz_date BETWEEN #{checkInDate} AND #{checkOutDate} AND total_stock - hold_stock 1; -- 预占转已售确认成功时 UPDATE t_hotel_inventory SET hold_stock hold_stock - 1, sold_stock sold_stock 1 WHERE hotel_id #{hotelId} AND room_type_id #{roomTypeId} AND biz_date BETWEEN #{checkInDate} AND #{checkOutDate} AND hold_stock 1;两条 SQL 都用了BETWEEN一次锁多天库存因为一个订单跨多个日历日。参数说明biz_date BETWEEN的范围是 “checkInDate 到 checkOutDate”注意入住日包含、离店日不包含所以checkOutDate要减一天。total_stock - hold_stock 1是防超卖条件只有剩余可卖数大于等于 1 才能预占。第二条 SQL 里的hold_stock 1防止预占被释放后才执行转已售导致库存变成负数。3.3 Redis Lua 原子预占把多日期判断塞进一个脚本数据库行锁能防超卖但订单高峰期数据库行锁竞争会拖慢下单。很多系统会把热房型的库存预占放到 Redis 里做。Redis 的 Lua 脚本可以保证多个操作原子执行是替代数据库行锁的常用手段。-- keys[1] 房源库存 key格式 ht:{hotelId}:{roomTypeId}:{yyyy-MM-dd} -- keys[2] 预占 key格式 ht:{hotelId}:{roomTypeId}:hold:{yyyy-MM-dd} -- argv[1] 本次预占数量 local available tonumber(redis.call(GET, KEYS[1]) or 0) local held tonumber(redis.call(GET, KEYS[2]) or 0) local requested tonumber(ARGV[1]) if available - held requested then redis.call(INCRBY, KEYS[2], requested) return 1 end return 0这段脚本把“扣减”和“校验”合并成一个原子操作。参数说明KEYS[1]是当天房型的可售总库存KEYS[2]是当天房型的预占数。脚本先读总库存和预占数比较available - held是否大于等于请求量是则预占成功否则失败。这样即使多个请求同时到达Redis 单线程执行脚本也不会超卖。注意库存 key 要按日期拆开不能用一个总 key否则跨天库存无法单独控制。用 Redis 预占后数据库里的 hold_stock 可能不更新这没关系。最终结算以 Redis 为准数据库只保留下单时的库存快照。但这套方案要求 Redis 持久化和容灾必须够硬一旦 Redis 宕机丢了预占数据可能把库存放出去导致超卖。我的做法是给 Redis 预占设置 TTL最长 15 分钟超时自动释放并且关闭 Redis 的 RDB 快照或开启 AOF 持久化来降低丢失概率。更保险的做法是同时记录一份预占流水到数据库恢复时能重建。4. 分布式事务与最终一致订单、支付、履约怎么对齐4.1 本地消息表定时任务最稳的交易消息方案订单系统一旦拆成订单服务、支付服务、履约服务就不能再用数据库事务同步调用。一个订单支付成功要通知履约服务去确认房态还要通知积分服务发积分。任何一个下游失败都不能让主流程回滚。常见做法是本地消息表。CREATE TABLE t_message ( message_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, msg_type VARCHAR(32) NOT NULL, payload VARCHAR(2048) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2失败, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, create_time DATETIME NOT NULL, KEY idx_next_retry (next_retry_time, status) );这张表的核心思路是订单状态变更和消息写入同一个数据库事务。比如订单状态从 PENDING_PAYMENT 改到 PAID同时插入一条“订单支付成功”的消息二者要么同时成功要么同时失败。然后由独立的定时任务扫描status0或next_retry_time NOW()的消息发送到消息队列或直接调用下游接口。参数说明retry_count是重试次数超过上限要告警人工处理next_retry_time控制重试间隔一般按指数退避设置比如第一次 5 秒、第二次 30 秒、第三次 2 分钟。payload放业务消息的 JSON 内容最好包含orderId、msgType、timestamp下游可以根据orderId做幂等。这个方案本质上是把分布式事务变成“最终一致”。它的好处是依赖少一个消息表加一个定时任务就能跑坏处是侵入性强每个核心业务表都要插入这个消息表而且消息表成为新的数据热点。我当时在订单库单独建了一个消息库避免消息表和订单表抢占连接池。4.2 延迟队列做超时关单不要每分钟扫一次订单表未支付订单超时关闭是很常见的需求。网上有好多方案是定时任务每分钟SELECT * FROM order WHERE statusPENDING_PAYMENT AND create_time NOW() - 15min。这种全表扫描在订单量上来后必死而且每个分片都扫成本高还滞后。我一般用 Redis ZSET 做延迟队列。# 下单成功时把订单放入 ZSETscore 设置为“支付截止时间戳” import redis import time r redis.Redis(hostredis_host, port6379, db2) order_id 10001 pay_deadline int(time.time()) 15 * 60 r.zadd(delay:close_order, {order_id: pay_deadline}) # 定时任务每 30 秒取一次到期订单 current_ts int(time.time()) expired_orders r.zrangebyscore(delay:close_order, 0, current_ts, start0, num100) for order_id in expired_orders: # 抢锁执行关单关单成功后从 ZSET 移除 r.zrem(delay:close_order, order_id)参数说明zadd的 score 是绝对时间戳订单创建时计算当前时间 支付时限zrangebyscore的结束值是current_ts表示只取已经到期的订单。start0, num100是分批取防止一次取出太多打爆目标系统。这里有个关键点取出订单后必须先执行关单事务成功后再zrem删除。如果先删除再关单关单失败就会漏单。如果已经支付了要立刻zrem从延迟队列移除避免被关单吞掉。4.3 对账补偿最终一致的最后一道防线消息队列和本地消息表都可能在极端情况下丢消息比如下游接口卡住、消息已发送但状态没更新成 1。所以必须有一道定时对账的线下防线。常见做法是每天凌晨跑一次对账任务对比订单表和支付流水表找出“已支付但订单状态还是 PAID 未确认”的订单。对账任务不是简单的SELECT它需要双数据源比对。订单库按order_id查状态支付库按out_trade_no查支付结果。比对后生成差异列表再逐条补偿。补偿动作也要幂等比如对账发现一笔已支付订单没通知履约就重新插入一条消息但消息表里要带sourcereconcile和唯一的message_key下游用order_id msg_type去重避免重复确认。对账最容易忽略的边界是“时间边界”。比如凌晨 0 点在途的支付订单库显示未支付支付库显示已扣款这可能只是银行间结算延迟不能直接判定为不一致。我的经验是只对账当前时间往前推 24 小时以上的稳定数据带一个 1 小时的延迟容差。对账差异处理完要发告警哪怕只有一条也要人盯一下。5. 订单查询与存储分库分表、缓存与分页的实战5.1 分库分表键按用户ID切还是按订单ID切订单表的写入是有状态的查询场景也很明确用户查“我的订单”客服查“某个订单”后台查“某个酒店的订单”。分表键的选择直接决定能否避免跨库查询。我采用的做法是订单主表按user_id % 16分 16 个库每库再按订单号分 16 张表。用户查询只需要带user_id路由到对应分片即可。问题在于客服按order_id查单时不知道user_id没法路由。我的解决办法是再维护一张t_order_mapping表存order_id - user_id的映射同样按order_id分片。查详情时先查 mapping 表拿到user_id再定位订单。还有另一种做法是订单 ID 里直接内嵌分片键。比如订单号格式时间戳 用户ID末四位 随机数从订单号里解析出分片号。好处是不需要 mapping 表坏处是订单号会变长且用户 ID 隐含有泄露风险。我更推荐映射表方案虽然多一次查询但可维护性强。5.2 订单列表查询用游标翻页替代 limit offset分页是订单列表最常见的性能杀手。用户翻到第 50 页时SQL 是LIMIT 980, 20数据库要扫 1000 行再丢弃前 980 行慢是必然的。订单表数据量过亿后深翻页会让 MySQL 的扫描行数爆炸。-- 首页查询 SELECT order_id, hotel_name, hotel_address, status, create_time FROM t_order_0001 WHERE user_id #{userId} ORDER BY create_time DESC, order_id DESC LIMIT 20; -- 翻页查询传入上一页最后一条的 create_time 和 order_id SELECT order_id, hotel_name, hotel_address, status, create_time FROM t_order_0001 WHERE user_id #{userId} AND (create_time #{lastCreateTime} OR (create_time #{lastCreateTime} AND order_id #{lastOrderId})) ORDER BY create_time DESC, order_id DESC LIMIT 20;这段 SQL 把 offset 翻页改成了 keyset 翻页。参数说明lastCreateTime和lastOrderId是上一页最后一条记录的排序字段值。下一页查询条件create_time 上页最后时间如果时间相同则用order_id 上页最后订单ID来保证排序唯一。这样每次查询都只扫 20 条符合条件的记录深翻页的性能和浅翻页几乎一样。需要注意(create_time, order_id)必须有联合索引否则这个条件会变成文件排序。索引设计上t_order_0001表一定要建(user_id, create_time)联合索引这个索引既能支持首页查询也能支持 keyset 翻页。不要单独在user_id上建索引查询时用户一页一页翻单独索引回表太多次。5.3 缓存一致性订单详情写后失效列表缓存只存一页订单详情是读多写少的典型同一个订单会被用户反复查看但状态只会变更几次。我会把订单的静态信息酒店名称、房型、入住日期、金额放到 Redis 缓存key 是order:detail:{orderId}TTL 设 30 分钟。当订单状态变更时主动DELETE这个 key而不是更新缓存。原因很简单更新缓存需要先序列化整条订单而删除缓存成本低且下次查询会自动回填。列表缓存则更麻烦。用户订单列表是分页的如果缓存整页数据任何一页的订单状态变化都会让整页失效。我的做法是不缓存列表数据只缓存“用户是否有新订单”的标记每次列表查询直接走数据库用游标翻页保证性能。对于首页前 20 条这样的热点数据可以额外缓存user:recent_orders:{userId}只存 orderId 列表订单详情单独缓存这样任何一单状态变化只需更新该订单详情缓存最近订单列表可以继续用直到后台定期失效。这个方案还有一个坑缓存穿透。如果用户请求一个不存在的订单ID缓存查不到数据库也查不到就会反复打库。解决办法是空值缓存把不存在的orderId缓存一个短 TTL比如 60 秒的空标记同时用布隆过滤器拦截明显不存在的订单号。布隆过滤器的误判率不要设太低5% 就够能挡住大多数恶意请求。6. 订单系统改造避坑五个我亲手踩过的坑6.1 支付回调重复到达订单状态被覆盖成“处理中”现象支付服务因为网络超时重发了支付成功回调第一次回调把订单状态从PENDING_PAYMENT改成PAID第二次回调又执行了一次“状态变更”逻辑把PAID改成了CONFIRMED但实际上酒店还没确认。原因回调处理逻辑没有校验前置状态直接把传入状态无条件写入。解决给状态更新加前置条件WHERE status #{expectedStatus}而且每个状态变更动作必须明确指定期望的前置状态。比如支付回调只允许在PENDING_PAYMENT下生效如果已经是PAID直接返回“成功”给支付服务不重复处理。这个“幂等返回成功”非常关键否则回调方会一直重试导致下游重复收到消息。6.2 预占过期释放了Redis但数据库订单状态还是待支付现象Redis 预占 TTL 到期自动释放了库存但订单表里的状态还是PENDING_PAYMENT用户回来支付时发现支付成功但库存已经放给了别人导致无房可住。原因Redis 预占释放和订单状态变更不在同一个事务里。TTL 到期只是 Redis 的 key 消失数据库里并没有回滚操作。解决预占释放不能依赖 Redis TTL应该由专门的延迟任务扫描订单表和 Redis 预占流水先关单把订单状态改成 CLOSED再释放库存。Redis TTL 只作为兜底保护防止任务宕机后库存泄漏。任务处理顺序是先关单后释放库存否则库存放了但订单还是待支付用户支付时就会撞上无库存。6.3 分页深翻页把主库拖垮现象订单查询接口在深夜被大量调用慢 SQL 日志里全是LIMIT 49980, 20的查询占用大量 CPU连带下单接口变慢。原因所有订单查询都走了主库而且用 offset 翻页。数据量过亿后深翻页一次要扫 5 万行主库连接被查询占满。解决一是把订单查询切到从库至少要在从库上执行翻页查询二是把前台翻页改成游标翻页禁止用户跳到任意深页很多场景只需“加载更多”三是给分页接口加max_page限制超过 100 页直接走离线查询或拒绝。6.4 事务消息在Broker宕机后消息丢失现象使用事务消息发送订单支付事件某天凌晨 Kafka broker 滚动重启部分分区数据丢失下游没收到消息导致积分漏发、发票漏开。原因我们把事务消息的可靠性完全寄托在 broker 上没有做本地的消息表兜底也没有启动对账补偿。broker 丢消息的概率虽然低但重启时未刷盘的日志可能丢。解决把企业核心交易消息改成“本地消息表定时任务”方案同时保留消息队列做异步解耦。每次业务操作先插入本地消息表由独立任务投递到 MQ。MQ 投递成功后只更新本地消息表状态不删消息记录。然后每天对账扫描status1但超过 24 小时仍未在队列中被消费的消息重新投递。不要信任任何消息队列“至少一次”之外的承诺。6.5 缓存雪崩把数据库打崩现象订单详情缓存设置的 TTL 都是 30 分钟半夜 0 点大批 key 同时过期大量请求穿透到数据库数据库 CPU 瞬间飙到 100%。原因所有订单在同一时间点创建缓存 TTL 又固定导致同一时刻集中过期。解决在设置缓存 TTL 时加一个随机偏移量比如30 * 60 RandomUtils.nextInt(60, 300)秒。另外给热点订单的详情缓存设置永不过期只在订单状态变更时主动删除用“逻辑过期”代替物理过期缓存里存一个 expireAt 字段如果在逻辑上过期了则用一个后台线程去刷新数据查询请求暂时返回旧数据。这个做法能避免缓存击穿但要注意脏读窗口适合读多写少、允许短时间不一致的订单详情。7. 进阶订单系统的全链路压测与灰度验证技巧压测是订单系统改造最不能跳过的一环但不能直接拿生产环境流量打否则一压就超卖。我的习惯是先做影子压测用一台边缘机器复制线上订单流量到压测环境只读不写验证查询链路写路径用固定的测试用户 ID 和测试酒店投到独立的压测分片避免污染真实数据。压测指标要盯三个数下单接口的 P99 时延、库存预占的失败率、分布式事务消息的积压数。P99 超过 500ms 就要排查是 SQL 慢还是 Redis 慢库存预占失败率超过 1% 通常是缓存原子性没做好消息积压数持续上升说明消费者性能跟不上要切更多分区或增强消费逻辑。库存预占的瓶颈往往在 Redis 的INCRBY并发每台 Redis 实例每秒能扛 10 万次 INCRBY超过就要做库存分片。灰度发布要按“流量灰度”而不是“机器灰度”。让订单服务的路由规则根据userId % 10切分先放 1% 的用户走新系统其余走老系统。灰度期间要实时对比新老系统的订单状态变更成功率。我踩过一个大坑灰度只比较了接口成功率没比较状态机的流转路径结果新系统漏了一个“酒店取消后自动退款”的边灰度的 1% 用户订单卡在已支付状态退不了款。所以灰度比对要精确到“同一订单在新老系统最终状态是否一致”不一致就立即回滚。另一个值得做的验证是故障演练模拟 Redis 宕机、下游酒店接口超时、消息队列积压。我的习惯是每个月挑一个周末用一台测试机把 Redis 的某个分片直接 kill看订单系统是否会在不到 1 分钟内切换到数据库库存模型。如果切换超过 5 分钟说明预案还是不达标要重新设计降级开关。这套演练我每次发布都跑一遍跑完才敢说订单系统是稳的。很多问题都是一次次压测和回滚中逼出来的最后希望帮到你。本文还有配套的精品资源点击获取
返回列表