ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

美团酒店订单系统架构实践:状态机、分库分表与最终一致性

美团酒店订单系统架构实践:状态机、分库分表与最终一致性 简介这份PDF是美团酒店订单交易系统架构实践的完整分享材料面向互联网后台研发、架构师及技术管理者。内容以酒店订单交易系统为案例梳理业务简介、系统发展、架构挑战、业务开发与系统重构、关键流程、服务调用关系、数据存储等模块重点讲解核心与非核心业务分离、主流程与辅流程异步、订单状态机设计以及可用性99.99%、TP99小于500毫秒等稳定性与扩展性目标同时给出模块化分层、事件驱动、读写分离、幂等补偿、服务降级与监控等落地手段。全包只有1个PDF文件大小3.26MB便于直接保存阅读。该分享为酒旅后台研发团队吴革的一次架构实践分享已有265人学习下载适合希望了解高并发交易系统演进路径、业务架构梳理与质量保障方法的读者参考。1. 美团酒店订单交易系统在解什么题不是下单快而是状态不乱做过电商订单的人切到酒店订单交易系统第一反应往往是「这有什么难的」。等真正把下单、支付回调、库存预占、取消改期、对账结算串起来才会意识到酒店订单的复杂度集中在状态流转和多方一致性上——一个订单可能跨多个入住日期每晚价格不同涉及平台、代理商、酒店三方结算还要扛住节假日流量洪峰。这套系统的架构实践核心不是把下单接口做到多少毫秒而是让订单在任何异常场景下都不丢、不乱、不错。本文会把订单数据模型、状态机、分库分表、异步化、库存防超卖这几条主线拆开讲配合可直接复用的脚本和参数最后一章给出压测与故障演练的具体打法。适合正在设计订单域、或者准备把单体订单系统拆成分布式架构的工程师。2. 订单数据模型与状态机先把地基夯到不出乱子2.1 订单主表与子单拆分为什么酒店订单要拆成订单 子单酒店订单和实物电商订单最大的区别在于一个订单里可以包含多个间夜每个间夜的入住日期、价格、房型、取消政策都可能不同。如果把这些信息全部塞进一条订单记录后续的改期、部分取消、按晚结算都会变成一场灾难。常见做法是把订单拆成两层订单主表order_main存用户维度、总金额、整体状态子单表order_sub存每个间夜的实际履约信息。一个订单对应多个子单子单独立流转自己的入住、离店、取消状态。-- 订单主表用户维度与聚合状态 CREATE TABLE order_main ( order_id BIGINT NOT NULL COMMENT 全局唯一订单号, user_id BIGINT NOT NULL COMMENT 下单用户, hotel_id BIGINT NOT NULL COMMENT 酒店ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, order_status TINYINT NOT NULL COMMENT 订单状态 10待支付 20已确认 30已入住 40已完成 50已取消, biz_date VARCHAR(10) NOT NULL COMMENT 入住日期范围如 2026-06-01_2026-06-03, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (order_id), KEY idx_user_id (user_id), KEY idx_hotel_id (hotel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; -- 子单表按间夜拆分履约粒度 CREATE TABLE order_sub ( sub_id BIGINT NOT NULL COMMENT 子单ID, order_id BIGINT NOT NULL COMMENT 关联订单ID, room_type_id BIGINT NOT NULL COMMENT 房型ID, stay_date DATE NOT NULL COMMENT 入住日期, night_price DECIMAL(10,2) NOT NULL COMMENT 当晚单价, sub_status TINYINT NOT NULL COMMENT 子单状态 1待确认 2已确认 3已入住 4已离店 5已取消, PRIMARY KEY (sub_id), KEY idx_order_id (order_id), KEY idx_stay_date (stay_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单子单表;这里有两个容易被忽略的设计点。第一order_status 存的是「聚合状态」由子单状态推导而来例如所有子单都确认了主单才能置为已确认存在任意子单取消主单要进入部分取消流程。第二biz_date 字段存入住日期范围是为了后续按日期维度做报表和对账不要逐个子单去扫。实际项目中我会把主单和子单写进同一个数据库事务保证聚合状态和明细数据不会撕裂。子单表按 stay_date 建索引是因为酒店业务里「查某天某酒店还有多少可售房」是最高频的查询之一。2.2 状态机的有限路径设计哪些状态迁移必须收敛订单状态最怕的不是状态多而是状态迁移路径不可控。常见翻车姿势是代码里到处if (status 已支付)直接改状态结果支付回调重复到达时把已取消的订单又改回已确认。订单状态机一定要把「允许的迁移路径」收敛成一张表每次状态变更都走同一套校验逻辑。public enum OrderStatus { PENDING_PAYMENT(10, 待支付), CONFIRMED(20, 已确认), CHECKED_IN(30, 已入住), COMPLETED(40, 已完成), CANCELLED(50, 已取消); private final int code; private final String desc; private static final MapString, SetOrderStatus TRANSITIONS new HashMap(); static { // 待支付可取消、可支付确认已确认可入住、可取消 TRANSITIONS.put(PAY, Set.of(PENDING_PAYMENT)); TRANSITIONS.put(CANCEL, Set.of(PENDING_PAYMENT, CONFIRMED)); TRANSITIONS.put(CHECK_IN, Set.of(CONFIRMED)); TRANSITIONS.put(CHECK_OUT, Set.of(CHECKED_IN)); TRANSITIONS.put(TIMEOUT, Set.of(PENDING_PAYMENT)); } public OrderStatus transit(String event) { SetOrderStatus allowed TRANSITIONS.get(event); if (allowed null || !allowed.contains(this)) { throw new IllegalStateException(非法状态迁移: this - event); } return targetStatus(event); } }这段代码的核心约束是状态迁移必须显式声明「哪个事件允许从哪个状态过来」。PAY 事件只允许待支付状态发起哪怕支付回调在网络上重试了十次已取消的订单也永远不会被改回已确认。CANCEL 事件允许待支付和已确认状态发起但已入住之后就不能直接取消要走售后流程。TIMEOUT 事件专门给待支付订单超时关闭用由延迟消息触发。参数层面要注意的是「事件来源」和「版本号」两件事。状态机的 event 不只表示业务动作还要携带操作来源用户主动取消、系统超时取消、客服后台取消三个来源的权限和后续补偿动作完全不同。另一个容易被忽略的点是状态更新 SQL 必须带乐观锁条件WHERE order_id ? AND order_status ?否则并发场景下两个线程都读到待支付一个超时关闭、一个支付成功后执行的更新会把前者覆盖掉。这两点配合状态机枚举能拦住绝大多数乱流转问题。3. 从单库到分库分表订单系统的扩容路线与 ID 设计3.1 订单 ID 生成为什么订单号必须全局唯一且带路由信息订单表一旦分库分表订单号就是路由的命根子。用数据库自增 ID 做订单号在分库分表后必然撞车用 UUID 虽然全局唯一但长度长、无序、无法路由。常见做法是用类雪花算法生成订单号64 位 long其中高位放时间戳中间位放机器 ID低位放自增序列。关键是把「路由键」嵌进订单号里这样拿到订单号就能直接定位到分片。public class OrderIdGenerator { // 定义位段1bit符号位 41bit时间戳 5bit业务类型 5bit机器ID 12bit序列号 private static final long TIMESTAMP_BITS 41L; private static final long BIZ_TYPE_BITS 5L; private static final long MACHINE_BITS 5L; private static final long SEQUENCE_BITS 12L; private final long machineId; // 0~31按机房/实例分配 private final long bizType; // 1酒店订单2退款单3取消单 private long lastTimestamp -1L; private long sequence 0L; public synchronized long nextId() { long now System.currentTimeMillis(); if (now lastTimestamp) { // 时钟回拨等待或直接用上次时间戳序列续用 now lastTimestamp; } if (now lastTimestamp) { sequence (sequence 1) 0xFFF; // 12bit 序列号最大4096 if (sequence 0) { // 同一毫秒序列溢出自旋到下一毫秒 while (now lastTimestamp) { now System.currentTimeMillis(); } } } else { sequence 0L; } lastTimestamp now; return (now (64 - TIMESTAMP_BITS - 1)) | (bizType (MACHINE_BITS SEQUENCE_BITS)) | (machineId SEQUENCE_BITS) | sequence; } }这段生成逻辑有几个参数值得细说。时间戳左移 22 位后订单号里的时间信息足够支撑约 69 年不回绕。5 位 bizType 的作用很多人会忽略它在订单号里标记了业务类型退款单、取消单、订单三者的号段天然隔离查询时一眼能看出单据类型。machineId 用 5 位最多 32 台机器如果后续机器数超过这个范围就要重新设计位段分配。路由信息藏在订单号里的实际收益是查询订单详情时WHERE order_id ?能直接用订单号算出分片位置一次定位、不用广播。如果订单号里没有路由位就只能拿 user_id 做路由而很多后台场景根本拿不到 user_id只能全分片扫描这在分表后是灾难。3.2 分片键选择与扩容避坑按 user_id 还是按 order_id订单系统分片键的选择通常要在「用户查询」和「订单查询」之间做取舍。按 user_id 分片用户查看自己的订单列表很舒服但客服按 order_id 查订单要广播到所有分片。按 order_id 分片则相反。我的建议是主链路用 order_id 做分片键用户订单列表用 userId 索引表辅助。订单主表和子单表必须用同一个分片键否则一次下单产生的多个子单会散落在不同库跨库事务和联表查询会让你怀疑人生。-- 分片后的建表模板同一个库内按 order_id 取模分 16 张表 CREATE TABLE order_main_%d ( order_id BIGINT NOT NULL, user_id BIGINT NOT NULL, hotel_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, order_status TINYINT NOT NULL, -- 其余字段与单表版本一致 PRIMARY KEY (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户维度查询辅助用户-订单映射表也按 user_id 分片 CREATE TABLE user_order_index_%d ( user_id BIGINT NOT NULL, order_id BIGINT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (user_id, order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户订单映射索引;分片数量定多少直接决定未来几年要不要扩容。常见做法是初期按 3~5 年数据量估算单表数据控制在 2000 万以内分片数取 2 的幂16、32、64因为取模路由对 2 的幂最友好。真正折磨人的是扩容从 16 片扩到 32 片取模基数变了存量数据要么双写迁移要么用一致性哈希把数据重新分布。业界比较成熟的方案是「双写 影子迁移」旧分片继续服务新分片同步接入写入流量历史数据分批导入导入完成校验订单号归属后再切读流量。这个方案周期长、细节多但比停机迁移稳妥得多。还有一个容易踩的坑是分布式交换机系统架构层面的——分片数量变多之后数据库连接数会成倍上涨。早期单库 200 个连接能扛住拆成 16 个库后就变成 3200 个连接如果中间的网络设备和连接池参数没有同步调整会出现大量连接超时。我见过一次故障表象是接口 RT 飙升背后其实是网关层到数据库的 TCP 连接被交换机端口缓冲打满。扩容之前先确认网络设备、连接池上限、负载均衡的转发能力这三层匹配别让数据库层扩容被基础设施短板卡住。4. 下单链路异步化把硬实时变成最终一致4.1 本地消息表最朴素可靠的最终一致性方案酒店下单链路里最核心的一致性问题是订单创建后要扣减库存同时要通知下游代理商/酒店确认预订。如果这两个动作放在一个数据库事务里同步做下游接口一慢用户下单接口就跟着超时。常见做法是把「必须同步的」和「可以异步的」拆开订单落库是强一致必须同步完成库存扣减和通知下游是最终一致通过消息异步推进。-- 本地消息表与订单事务同库写入保证不丢消息 CREATE TABLE order_message ( msg_id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, msg_type TINYINT NOT NULL COMMENT 1库存扣减 2通知代理商 3通知酒店, msg_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2确认成功 3重试中, retry_count TINYINT NOT NULL DEFAULT 0 COMMENT 重试次数, next_retry_at DATETIME NOT NULL COMMENT 下次重试时间, payload JSON NOT NULL COMMENT 消息内容, PRIMARY KEY (msg_id), KEY idx_order_id (order_id), KEY idx_next_retry (msg_status, next_retry_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT本地消息表;# 下单事务内订单落库 写本地消息表伪代码事务包裹 with db.transaction(): insert_order(main_order, sub_orders) insert_order_message(order_id, msg_type1, payload{room_type_id: 101, stay_date: 2026-06-01, qty: 1}) insert_order_message(order_id, msg_type2, payload{hotel_id: 8801, plan: CRF}) # 事务提交后由独立的消息投递线程扫表发消息这套方案的逻辑说明只有一句话订单数据和待发送消息在同一个数据库事务里要么一起成功要么一起失败。消息投递线程定时扫描msg_status0 AND next_retry_at NOW()的记录把消息发到 MQ 或直接调下游接口成功后置为已发送。下游消费时需要按 msg_id 做幂等MQ 消费方收到重复消息直接忽略。这个方案的缺点是每次投递都要扫表消息量大了之后 DB 压力不小所以它适合订单创建这种「低频但绝对不能丢」的场景不适合每秒上万条的热点消息。4.2 事务消息替代本地消息表减少一次 DB 轮询如果团队已经引入了支持事务消息的 MQRocketMQ、或者自研的消息中间件可以用事务消息替代本地消息表省掉轮询扫表。事务消息的流程是先发送 half message然后执行本地事务事务成功提交后 commit 消息此时消息才对消费者可见如果本地事务回滚则 rollback。// 事务消息的生产端伪代码 TransactionMQProducer producer new TransactionMQProducer(order_tx_producer); producer.setTransactionListener(new TransactionListener() { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { // 执行订单创建 子单创建的本地事务 OrderCreateRequest req (OrderCreateRequest) arg; try { orderDao.createOrder(req.getOrderItem()); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查如果订单已存在说明本地事务已提交消息可以投递 Long orderId Long.parseLong(msg.getKeys()); return orderDao.exists(orderId) ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } });事务消息的关键参数是回查次数和回查间隔。RocketMQ 默认回查 15 次间隔从 1 秒开始指数递增超过次数后消息进入死信队列。实务上要把 orderId 放进消息的 keys 里回查逻辑才能快速定位订单是否存在。事务消息对比本地消息表的优势是投递实时性更好、DB 少一轮轮询但代价是引入了 MQ 这个外部依赖MQ 挂掉会影响下单主流程。我的习惯是核心订单创建链路用本地消息表保证极端情况下不依赖外部组件周边通知类消息短信、APP Push用事务消息或普通消息挂了可以降级重发。4.3 库存扣减酒店超卖的根因与防超卖 SQL酒店库存和实物库存的差异在「维度」实物库存只是一个数量酒店库存是「房型 × 日期的二维矩阵」。超卖的本质是并发请求同时读到剩余库存大于 0然后都执行了扣减。解决超卖不能靠先 select 判断再 update必须用条件更新原子扣减。-- 原子扣减库存只有剩余可售量足够时才扣减成功 UPDATE room_inventory SET remaining remaining - 1, updated_at NOW() WHERE room_type_id 101 AND stay_date 2026-06-01 AND remaining 1;这条 SQL 的原理是让数据库在行锁粒度上做判断和扣减rowcount返回 1 表示扣减成功返回 0 表示库存不足。我一般会把remaining 1的阈值写为可配置参数针对高价房型可以放宽到超卖 1~2 间做缓冲通过后续有损取消弥补但默认必须严格等于剩余可售。热点房型的库存行在节假日会变成热点行行锁竞争严重时会出现大量锁等待。常见做法是在库存扣减前加一层 Redis 预扣DECR命令配合 Lua 脚本实现原子扣减超过阈值直接拒绝只有预扣成功的请求才落库执行上面的 update。还有一个细节酒店订单取消后库存要回补回补操作必须走消息异步执行并且要带上原始子单的sub_id做幂等。取消订单的消息被重复消费两次库存就多回补了一晚账就对不上了。幂等键建议用sub_id actionRedis 里 setnx 判重过期时间设 24 小时覆盖消息重试的完整窗口。5. 避坑订单交易系统架构实践里的五个典型翻车现场5.1 支付回调重复通知订单状态被「打回去」了现象用户已完成支付但订单状态偶尔从已确认跳回待支付甚至已取消的订单重新变为已确认。原因支付渠道的回调通知不是恰好一次而是至少一次。回调处理逻辑里如果只是简单判断当前状态不等于已支付就更新并发下两个回调线程同时到达状态被先后改写。加上状态机迁移路径没有约束「事件来源」一个取消事件和一个支付事件并发时后执行的覆盖了先执行的。解决状态更新 SQL 必须带上当前状态作为条件UPDATE order_main SET order_status 20 WHERE order_id ? AND order_status 10。同时把支付回调处理逻辑收敛到状态机枚举里不允许直接赋值。另外给回调处理加分布式锁按 order_id 加锁保证同一订单的回调串行执行。5.2 分库分表之后后台订单查询全部超时现象分库分表上线后用户端下单正常但运营后台的订单查询页面频繁超时客服提单率直线上升。原因后台查询条件往往是「酒店 ID 入住日期区间 状态」这些字段都不是分片键。按 order_id 分片后后台查询需要在全部分片上扫描再聚合分片越多扫描越慢。有的团队为了省事直接查全分片一次查询触发几十条慢 SQL。解决为后台查询场景建独立的查询索引库通过 binlog 或者 MQ 把订单主表同步到 Elasticsearch后台查询走 ES详情透传兜底查分片。同步链路的延迟可以接受但要注意 ES 的索引 mapping 里把 order_status、hotel_id、stay_date 都配置为 filter 字段避免大量 text 分词查询。这类方案上线后要加对账任务定时比对 ES 里的订单数和 DB 里的订单数是否一致防止同步链路丢数据。5.3 消息乱序取消单之后又收到了确认消息现象用户提交取消系统先处理了取消随后下游代理商的确认消息到达订单状态变成了已确认用户投诉。原因同一个订单的多种消息确认、取消、改期被发送到了 MQ但生产端不同消息没有保证顺序。消费者是并发线程池不同线程消费不同消息顺序被打乱。解决保证同一个订单的所有消息走同一个队列、同一个消费线程。MQ 的消息 key 用 order_id 做分区键所有订单相关消息按 order_id 哈希到同一个队列。消费端单线程消费该队列或者在消费端对 order_id 加锁串行处理同一订单的消息。这是典型的「用分区键解决消息顺序」场景把这套机制做成消息发送的基础组件所有订单事件都必须经它发送。5.4 事务里先 update 库存再 update 订单引发死锁连环现象大促期间数据库死锁日志暴涨接口失败率升高监控里看到大量Deadlock found。原因下单事务里更新库存表、订单表、消息表的顺序不一致。A 请求先更新库存再更新订单B 请求先更新订单再更新库存两个事务互相持有对方需要的行锁数据库检测到循环等待就会报死锁。解决所有涉及多表更新的事务必须按固定的表顺序加锁。例如统一先更新 order_main再更新 order_sub再更新 room_inventory最后写 order_message。这一点要在代码规范里定死并在 Code Review 时人工检查。更稳妥的方案是把库存扣减移出订单主事务用 4.3 的独立扣减逻辑在事务外执行事务里只做订单相关表的写入。5.5 大促扩容后连接池被慢 SQL 占满拖垮整个集群现象扩容加了 20 个订单服务实例接口 RT 不降反升数据库 CPU 100%大量连接处于 Sleep 状态。原因新实例启动后连接池默认配置发起大量预创建连接同时后台的订单列表查询没有走 ES 兜底全部打到数据库。慢 SQL 把连接池占满健康请求排队等连接超时后又重试雪崩效应把数据库压垮。解决连接池参数必须按实例数反推。比如数据库 max_connections200实例数 20连接池上限每实例不超过 10还要预留 20% 冗余给管理任务。另外所有查询入口都要按响应时间分级短查询走连接池主池批量查询和后台报表走独立连接池池之间隔离。最后在网关层配置读接口的熔断阈值数据库连接池使用率超过 80% 时直接拒绝非核心请求保护主链路。6. 验证订单系统好不好用压测与故障演练的具体打法订单系统的验证不能只看功能测试通过要按「流量模型 → 故障场景 → 数据一致性」三层来压。第一步是流量模型酒店业务的峰值特征是「节假日前的集中下单」和「秒杀房型的热点冲击」压测脚本要模拟这两个特征90% 的流量集中在 5% 的热门房型上而不是均匀分布。压测指标除了 RT 和 TPS还要盯死数据库的行锁等待时延、MQ 的积压数量、消息回查成功率三个指标。# 用 wrk 或自研压测工具按热点比例打流量 wrk -t 16 -c 400 -d 600s \ --scripthot_stay.lua \ --latency \ http://order.api.local/v1/order/create # hot_stay.lua 里实现热点随机90% 请求打向固定房型集合第二步是故障演练我一般会排一张清单MQ 集群宕机 10 分钟、数据库连接池耗尽、支付渠道回调延迟 2 小时、下游代理商接口超时、Redis 缓存雪崩。每一项演练完都要确认订单数据没有出现状态错乱、金额不一致、消息堆积超过恢复阈值。做下来就会发现订单系统最难缠的故障不是单个组件挂掉而是「多个故障叠加」比如 MQ 挂掉的同时数据库连接池也告警系统有没有降级开关能把非核心依赖摘掉。最后一步是数据一致性核对这是订单系统最该投入的验证。每天凌晨跑对账任务比对订单库和支付渠道的流水、订单子单和库存扣减流水、本地消息表和 MQ 消费记录三方的一致性任何一方的金额或状态对不上都要告警。对账脚本里有个参数值得强调比对的时间窗口要覆盖到上游回调的最大延迟不能只对当天数据否则大量「合法延迟」会被误报成差异。我自己做订单系统这几年最深的体会是状态机和幂等是订单的命其他都是围绕这两件事做辅助。很多线上故障回头看根因都是状态迁移没约束或者幂等键没设计好。压测和演练只能验证已知问题真正的未知风险要靠对账任务兜底。做订单架构不要追求炫技把消息不乱、数据不错、失败可重试这三件事做扎实系统就坏不到哪去。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进