ARTICLE DETAIL

资讯详情

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

酒店订单交易系统架构:高并发库存控制与状态一致性实践

酒店订单交易系统架构:高并发库存控制与状态一致性实践 简介美团酒店订单交易系统架构实践文档面向互联网后端架构师、订单交易系统开发者及高并发场景设计人员聚焦酒店订单交易系统的整体架构演进与落地思路。文档共包含1个PDF文件压缩包大小3.26MB内容结构完整可直接按章节阅读便于快速查阅关键设计。已有265人学习或下载。文档从业务简介、系统发展、架构挑战、业务开发与系统重构、关键流程、服务调用关系、数据存储、业务架构梳理、技术架构、质量概念完整性等维度展开包含订单状态机、预订与取消流程、服务间调用关系、数据存储设计以及稳定性与扩展性指标并结合主流程梳理、应用架构、开发视图等给出可参考的架构实践方法适合用于系统学习订单交易系统设计或作为技术方案参考。1. 美团酒店订单交易系统架构实践先看清它要解决什么问题如果你做过OTA或电商的交易链路一定听过这句话订单系统是公司的钱袋子。而酒店订单和普通实物订单最大的不同在于它的库存是“按房间夜切分的”——同一天同一个房型只有那么几间卖超了客人到店无房就是重大客诉不超卖又可能卖不满直接影响收益。美团酒店这类业务在旺季大促时下单峰值是平时几十倍订单交易系统要在这种流量下做到不超卖、不丢单、状态一致难度全在架构取舍上。这篇不是对某份内部文档的解读而是我基于自己搭建和重构订单交易系统的经验把这个方向拆成一套可以照着推演和落地的方案从下单主链路拆解到库存控制、状态一致性、峰值流量处理再到压测和排障。适合正在做订单系统、交易中台或者准备从零搭建一套高并发交易系统的后端工程师和架构师。读完你至少能回答三个问题订单链路的核心节点该放什么逻辑、库存怎么扣才不超卖、支付回调和分布式事务怎么收口。2. 下单主链路从“房态查询”到“订单确认”的完整路径2.1 一次酒店下单到底经过哪些环节酒店订单的链路比普通电商多两个特点一个是库存维度带日期另一个是价格实时波动。普通商品SKU是静态的酒店的一个房型在7天内有7个不同的可售状态和价格所以用户在详情页看到的价格、下单时计算的价格、支付后确认的价格必须保持一致中间任何一步出现偏差都会带来差价纠纷。一条完整的酒店下单链路通常长这样用户从列表页进入详情发起房态查询查指定日期有没有房详情页展示房价和促销信息用户点击预订用户填写入住人信息并提交订单订单系统创建订单同时进入预扣库存环节用户跳转支付支付完成后支付网关回调订单系统更新支付状态向酒店系统发起确认Booking酒店确认后订单变为“已确认”通知用户预订成功。大部分流量高峰发生在第3到第5步之间。所以架构上通常把“创建订单”和“确认订单”拆成两个阶段订单先落库进入待支付状态支付回调后再做真正的库存确认和酒店端同步。这样设计的理由是支付是一个外部依赖时长不可控如果把库存扣减和支付放在同一个事务里数据库连接会被长时间占用一旦支付超时事务回滚的同时还要释放库存压力非常大。2.2 订单创建接口的最小可运行实现假设我们用Java和MySQL来搭建一个简化版的下单服务下面是下单接口的核心流程。这段代码不是美团线上实现的还原而是这类订单系统最常见的主链路写法你可以直接用来搭建最小原型public OrderCreateResult createOrder(OrderCreateRequest request) { // 1. 参数校验入住人、日期、房型、间数 validate(request); // 2. 生成订单号参考号/业务号不是数据库自增ID String orderId OrderIdGenerator.generate(request.getHotelId()); // 3. 预扣库存Redis Lua脚本原子扣减失败直接返回 boolean deducted inventoryClient.deductStock( buildStockKey(request.getHotelId(), request.getRoomTypeId(), request.getCheckInDate()), request.getRoomCount()); if (!deducted) { return OrderCreateResult.fail(该日期房量不足); } // 4. 写订单主表状态为待支付和订单明细表 OrderDO order new OrderDO(); order.setOrderId(orderId); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setPayDeadline(now().plus(15, ChronoUnit.MINUTES)); order.setTotalAmount(calculatePrice(request)); orderMapper.insert(order); orderDetailMapper.insert(buildDetail(orderId, request)); // 5. 发送MQ消息延迟队列15分钟后检查是否未支付 mqClient.sendDelayMessage(DelayTopic.ORDER_AUTO_CANCEL, orderId, 15 * 60 * 1000L); return OrderCreateResult.success(orderId); }这段代码里最关键的一点是先扣库存后写订单。为什么不是先写订单再扣库存因为库存是稀缺资源先落订单再扣库存会导致大量“幽灵订单”占据库存用户支付意愿低的话这些库存要等超时才会释放严重影响可售房量。另外注意第2步订单号必须用独立生成的业务号不能用数据库自增ID。原因有两点一是自增ID会暴露平台单量二是订单号会出现在支付网关回传和用户短信里一旦需要跨系统对账业务号的唯一性至关重要。常见做法是用“日期酒店ID随机序列”拼接。2.3 订单状态机的定义和状态流转订单系统最容易失控的就是状态。没有状态机约束开发可以随意把订单从“待支付”改成“已完成”时间长了没人说得清什么状态是合法的。订单交易系统的架构里状态机不是文档而是代码里的一道校验层。我在订单主表里会维护一个status字段并定义一张状态流转表当前状态允许的下一个状态触发动作待支付已取消 / 已支付用户取消 / 支付回调已支付已确认 / 退款中酒店确认 / 用户申请退款已确认已完成 / 退款中入住核销 / 售后退款已取消终态无退款中已退款退款成功回调每次状态变更不要直接执行update ... set status 新状态而是用带条件限制的SQLupdate hotel_order set status #{newStatus}, update_time now() where order_id #{orderId} and status #{expectStatus}这个expectStatus就是乐观锁的版本校验。执行后如果影响行数为0说明状态已经被别人改了当前线程必须抛异常回滚。这比先查后改再更新要可靠得多因为“查改之间”的时间窗口里状态完全可能被别的线程改掉。另外每次状态变更都要记录一条order_status_log包含从哪个状态变到哪个状态、操作人或系统、时间戳。等出了问题排查时这张日志表就是唯一的真相来源。我在生产环境里靠这个表定位过不下十起“订单状态异常”的问题。2.4 订单超时自动取消的动态配置订单创建后需要有一个超时自动取消机制这个超时时间不是写死的通常由运营按业务节奏动态调整。大促时可以延长到30分钟日常可以缩短到10分钟。这里会用到配置中心的动态开关——美团内部叫Lion配置业界也有其他同类框架。实现上在获取订单超时时间时不要硬编码而是每次从配置中心读取public long getPayTimeoutMillis() { // 动态配置中心的key运营改完秒级生效不用发版 return configCenter.getLong(order.pay.timeout.millis, 15 * 60 * 1000L); }注意这里不能把配置结果缓存太久否则运营改了配置不生效用户在下单页看到的倒计时和系统实际执行的不一致订单被误取消。常见做法是配置中心客户端自带的本地缓存默认10秒刷新一次足够满足业务需求。3. 高并发下的库存控制超卖、锁和Redis扣减的取舍3.1 为什么超卖是订单系统的第一根红线酒店超卖的代价比实物电商高一整个量级。实物电商超卖了可以告诉用户“缺货退款”酒店超卖了客人拖着行李箱到前台前台说没房这是服务事故不只是退款能解决的——需要平台出钱安排客人入住附近同等级酒店还要赔差价。所以做酒店订单系统库存红线比什么都重要。库存控制有三道闸门详情页展示的库存是缓存值、下单时扣减的是实时值、支付后确认的是锁定值。最怕的就是详情页显示有房下单时提示无房用户还能接受最怕的是下单成功、支付成功最后确认时告诉用户没房——这就是典型的超卖。所以扣减库存的时机必须放在用户点击“提交订单”的那一刻而不是支付成功之后。3.2 三种库存扣减方案对比做库存扣减遇到的第一问题是选型数据库乐观锁、分布式锁、还是Redis Lua脚本我把三种方案在实际项目里的表现整理成一张对比表方案并发上限一致性风险点适用场景数据库乐观锁update ... where stock count1000 QPS以内强一致数据库行锁竞争峰值时拖垮主库中小规模或者作为兜底方案分布式锁Redis SETNX / Redisson3000~5000 QPS锁释放逻辑复杂要小心死锁和误删锁粒度控制不好会串行化吞吐上不去并发不高且必须串行的场景Redis Lua 原子脚本10000 QPS强一致单机集群模式下要关注slot路由key设计、过期时间、失败后的回滚大促峰值流量的首选我在实际项目里默认使用Redis Lua脚本原因很直接它既是原子的又不需要额外维护锁的生命周期。Lua脚本在Redis里是单线程执行的不存在竞态条件。但要注意一个边界Redis集群模式下Lua脚本涉及的key必须落在同一个slot里否则会报CROSSSLOT错误。解决方式是在key里带上固定前缀的hash tag例如hotel:stock:{921}.{2024-08-01}.{1001}。3.3 Redis Lua 扣减脚本与参数调优下面是我在订单服务里常用的库存扣减脚本注意看key和参数的写法-- KEYS[1]: 库存key例如 hotel:stock:921:2024-08-01:1001 -- ARGV[1]: 本次扣减数量 -- ARGV[2]: 库存key的过期时间秒 if redis.call(exists, KEYS[1]) 1 then local stock tonumber(redis.call(get, KEYS[1])) if stock nil or stock tonumber(ARGV[1]) then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1]) else return -2 end这段脚本先检查key是否存在不存在直接返回-2由上游服务决定是初始化库存还是提示房量不足。存在的话判断剩余量是否足够不够返回-1够则原子扣减并返回扣减后的剩余量。调用时有两个参数调优经验。第一是过期时间ARGV[2]不能太长否则房价日历一变旧的库存key一直占着内存也不能太短否则用户支付过程中库存key过期了扣减的数据就丢了。我一般设置48小时覆盖“从下单到确认入住”的完整周期。第二是失败后的回滚如果扣减成功但后续创建订单失败必须立即回补库存。回补不能用incr要专门写一个反向的Lua脚本把回补和校验放在一起防止重复回补导致库存虚高。3.4 缓存与数据库一致性怎么避免库存对不上用了Redis做库存扣减就一定会遇到和MySQL对账的问题。Redis是高并发读写的执行业务方MySQL是数据的最终权威。常见做法是用MQ异步同步库存变更记录MySQL只记录流水不参与实时扣减。但异步就一定有延迟延迟期间如果Redis宕机重启后库存数据从哪恢复我见过翻车最多的方案是“Redis崩溃后直接查MySQL重建缓存”如果Redis里扣减的数据还没来得及同步到MySQL重建后的库存就会偏大造成超卖。所以正确顺序是Redis里每次扣减都产出一条流水消息发到MQMySQL消费后记录库存变更流水重建缓存时以MySQL的累计流水为准重放未同步的部分。这里要注意消息的幂等——消费端必须用(orderId, roomTypeId, date)作为唯一键去重否则一条消息重复消费库存就越加越多了。4. 订单与支付的状态一致性分布式事务怎么收口4.1 酒店订单为什么不能“先扣款再下单”如果支付成功后才创建订单整个链路会被拖得很长。用户在支付网关停留期间订单系统完全无感知支付成功回调到达后如果系统发现房已售罄钱已经扣了还得发起退款用户体验极差。所以行业里的通用做法是“先创建订单锁库存再让用户支付”这个顺序反过来问题只会更多。但这也带来一个新的难点订单状态和支付状态分布在两个系统里如何保证最终一致。这里不要听到“分布式事务”就上Seata或者TCC成本高、开发复杂。在订单交易场景里最终一致 对账兜底才是主流架构。4.2 本地消息表现在仍然可靠的状态收口方案我在订单系统里用的最多的方案是本地消息表它在工程上非常好落地而且可靠性完全够用。核心思路是把“业务操作”和“发消息”放在同一个本地事务里事务提交成功后消息才可见消费端拉到消息后再推进下游状态。简化代码如下Transactional public void handlePayCallback(PayCallbackDTO callback) { // 1. 校验签名和重复回调 if (payRecordMapper.checkDuplicated(callback.getOrderId()) 0) { return; } // 2. 更新订单状态为已支付同一个事务 orderMapper.updateStatus(callback.getOrderId(), OrderStatus.WAIT_PAY.getCode(), OrderStatus.PAID.getCode()); // 3. 写入本地消息表同一个事务 LocalMessageDO msg new LocalMessageDO(); msg.setMsgKey(callback.getOrderId()); msg.setPayload(JSON.toJSONString(callback)); msg.setStatus(MessageStatus.PENDING.getCode()); localMessageMapper.insert(msg); }这里的checkDuplicated非常关键。支付网关为了确保送达会重试多次回调如果没有幂等校验订单状态会被反复更新甚至触发两次酒店确认请求。校验时不要用select后再insert直接依赖订单主表的status字段做乐观锁更新——update ... where status 待支付影响行数为0说明已经处理过直接返回成功。这样天然幂等。另外本地消息表需要一个定时任务不断扫表把PENDING状态且重试次数未达上限的消息重新发到MQ里。扫表的SQL要注意加时间条件只扫最近10分钟的数据避免全表扫描拖垮数据库。4.3 支付回调的幂等设计与对账兜底支付回调的幂等是分布式事务里最容易被轻视的部分。很多团队以为回调有transaction_id就可以去重但transaction_id每次请求都是一样的问题在于你的去重表没有建唯一索引或者先查后插存在时间窗口。我比较推荐的做法是订单号就是幂等键。回调处理逻辑里对订单号做唯一约束配合乐观锁更新。但幂等设计得再好也不能保证回调一定送达。支付网关偶尔会漏掉回调或者回调包里业务字段缺失。所以必须有主动对账的机制每天的定时任务把“已支付但订单状态未推进”的订单捞出来调用支付网关的订单查询接口核对真实支付状态。不要只依赖被动回调这是金钱交易里比较稳妥的习惯。对账时还要注意一个场景用户支付成功后回调先到了但MQ消息还没被消费此时订单状态是“待支付”。对账任务如果正好在这时跑会把一笔正常支付的订单误判为未支付。所以对账任务必须在回调处理完成之后执行通常加一个延迟——只查支付时间早于30分钟之前的订单。4.4 退款、取消与超时关单的状态边界处理完正向链路要处理反向链路。酒店订单的取消和退款有严格的时间边界比如“入住当天18点前可免费取消之后取消扣首晚房费”。这些规则在订单状态机里是硬编码的逻辑不能只靠前端判断。我在数据库里会存一个cancel_policy_id订单创建时根据酒店和房型的退订规则生成快照之后任何取消行为都读快照计算违约金。这样避免酒店改了退订规则之后已下单用户被迫适用新规则。超时关单这一块有一个高频踩坑点订单超时任务检查到订单未支付把状态改成了“已取消”但用户其实刚刚完成支付支付回调还没到达。代码执行顺序不同结果不同如果关单逻辑先跑回调后到订单被取消但钱扣了只能走退款如果回调先到订单是已支付状态关单任务必须跳过。应对方式关单SQL一定要带条件where status 待支付并且检查支付回调的处理时间是否晚于关单时间时间窗口内的支付要保留订单。5. 峰值流量与订单洪峰排队、降级与常见排查避坑5.1 下单量暴涨时哪些服务先扛不住大促期间最容易先被打垮的不是订单服务本身而是它依赖的下游。我之前遇到过一次真实情况订单服务集群扩容了两倍QPS稳稳的但酒店资源服务提供房态查询和价格的服务先挂了。原因很简单订单服务把“下单前查价”和“下单时查价”都透传给了下游每次请求打两次下游下游的数据库连接池被打满。所以做订单交易系统从上到下要理清依赖关系订单服务自己能扛住的逻辑参数校验、订单号生成、库存预扣全部本地处理依赖下游的逻辑价格计算、酒店确认要加超时和缓存。查价结果缓存10秒是可接受的因为价格在10秒内变化的概率很低但库存不能只靠缓存必须以Redis实时值为准。5.2 排队削峰的三种常见做法峰值流量最好的处理方式不是硬扛而是把瞬时压力抹平。我在订单系统里常用的有三种削峰手段。第一种是MQ异步化用户提交订单后订单服务只做校验和落库然后把“酒店确认”这种不要求即时响应的操作发到MQ里异步执行。这样即使酒店确认服务处理能力只有每秒钟50单MQ也会自动排着队慢慢消费不会把上游拖死。第二种是滑动窗口限流在下单入口做。不需要引入复杂的Sentinel或HystrixRedis的ZSET就能实现。每个用户每秒钟最多提交一次订单每个酒店每分钟最多接受500单超出的直接返回“当前下单人数较多请稍后重试”。用户看到的是友好提示系统得到的是可控的压力。第三种是降级当依赖的短信服务超时或者通知服务不可用时先把消息写到本地表等恢复后再补发。用户最终能不能收到短信提醒其实不影响订单本身的正确性降级是划算的。但有一种情况不能降级——库存扣减和订单落库这两者降级了会直接造成资金或超卖事故。5.3 订单链路高频踩坑现象、原因、解决这一部分把我这几年在订单链路上遇到的高频问题按“现象 → 原因 → 解决”的方式整理出来每一类都值得在设计和代码评审时逐项对照坑一库存多扣用户反复提交订单后库存归零现象压测时发现每下一单库存至少扣2次。 原因用户在前端点了多次提交按钮请求重发前端做了防抖但没做幂等后端也没有对同一订单号去重。 解决下单接口增加请求幂等键前端生成一个requestId传过来后端用Redis的SETNX requestId 1 EX 30来判断是否重复提交已存在的直接返回上一次订单号不重复扣库存。坑二支付回调重复处理订单状态被覆盖现象支付成功后的订单偶尔变成“已取消”。 原因回调处理逻辑里先查订单状态再更新两个操作之间订单被超时任务取消了更新时没有带上条件直接覆盖了状态。 解决所有状态更新SQL必须带where status 期望的当前状态影响行数为0则说明状态已被其他线程修改需要人工介入或走补偿逻辑。坑三超时关单误杀已支付订单现象用户反馈“钱扣了但订单显示已取消”。 原因关单任务扫描的是“创建时间超过15分钟且状态为待支付”的订单但用户付款是在第14分59秒完成的支付回调有延迟关单先执行了。 解决关单任务执行前先查支付网关确认订单真实支付状态或者把待支付订单的关单延迟到“超时时间5分钟”给回调留出缓冲时间窗。坑四缓存穿透导致数据库被打垮现象某个热门酒店的库存key过期后大量请求直接打到MySQL数据库CPU飙升。 原因库存key没有设置热点保护过期后所有请求都去重建缓存。 解决重建缓存时加分布式锁只允许一个请求查数据库并回填Redis其他请求等待后读取缓存同时把空值也缓存起来避免恶意请求打一个不存在的房间。坑五MQ消息消费失败订单卡在中间态现象订单已支付但一直停留在“已支付”没有进入“已确认”状态。 原因酒店确认消费逻辑抛异常消息重试了几次后就进了死信队列没有配置告警和人工处理通道。 解决死信队列必须配置告警和可视化页面消费逻辑里所有异常都要捕获并区分“可重试”和“不可重试”不可重试的转人工处理。每隔1小时跑一次补偿任务把“已支付但超过30分钟未确认”的订单捞出来重新推送。6. 压测、全链路追踪和一个必查的坑订单交易系统上线前压测是不能省的环节。压测不是拿JMeter随便打打就算了要模拟真实的用户行为从房态查询、创建订单、支付回调到订单确认整条链路都要覆盖。我有一个固定习惯在预发环境压测时把下游服务全部打桩只测订单服务本身的吞吐上限得到的是纯订单服务水位再把打桩去掉做一次联压对比两次数据就能算出依赖项消耗了多少性能。如果差距超过40%说明依赖调用需要优化常见手段包括合并调用、加缓存、改异步。全链路追踪方面不管你是用SkyWalking还是自研的trace系统有一条比较重要订单ID必须贯穿所有子系统。从用户请求进来生成traceId到订单创建生成orderId再到MQ消息的msgKey这三者要在日志里能互相关联。排查线上问题时最怕的就是在订单服务日志里看到一个订单ID但在支付回调日志里搜索不到——因为回调日志打的是支付网关的流水号两者之间没有建立映射关系。我在代码里要求所有涉及订单的日志必须同时打印orderId和traceId这个习惯在排查问题时帮了大忙。最后说一个不太容易注意到的坑数据库连接池大小。很多人压测后觉得订单服务性能达标了但线上峰值时数据库先挂了。原因是连接池配置成了固定值比如maximum-pool-size50压测时并发均匀50个连接够用线上流量毛刺大瞬时100个请求同时来50个连接不够用后面50个请求在等待队列里排队等待超过数据库驱动默认的30秒超时就直接报错。我的经验是连接池不要配固定值配置minimum-idle10、maximum-pool-size100这样带浮动范围的并且预热连接池让它在流量上来之前就先建立好连接。这个坑不是技术多高深纯粹是线上流量特性和压测流量特性不同导致的但踩过的人都懂有多疼。从订单链路拆解到状态收口再到大促峰值处理做订单交易系统没有银弹靠的是一条一条可验证的边界条件。记住那句写在核心代码注释里的话订单状态的每一次变更都要能回答三个问题——谁改的、从哪改来的、改之前确认过什么。这里有我自己的教训也有团队拼出来的经验希望帮到你。本文还有配套的精品资源点击获取
返回列表