第一版是照着电商抄的:房型表上一个 stock 字段,下单减一。这个模型在酒店业务里从根上就是错的,上线第一周就全乱了。
一是库存本来就是按天的。「大床房还有 5 间」这句话没有意义——必须说「10 月 1 日还有 5 间」。用户住 10 月 1 日到 4 日共三晚,要同时占用 1 日、2 日、3 日各一间(4 日是离店日不占)。一个总量字段压根表达不了这件事。
二是部分天数扣成功是灾难。改成循环扣三天之后,出现了「1 日扣成功、2 日扣成功、3 日没房了」的情况,前两天的库存被白白占住,而订单又下不了。并发下这种碎片会越积越多,最后房卖不出去但系统显示满房。
三是价格不能用均价。10 月 1 日是节假日价 800,2 日 600,3 日 400,总价是 1800。用「均价 × 天数」算出来是错的,而且用户会逐日核对。价格也必须是按日期的。
四是「日期」本身有歧义。用户在国内下单订东京的酒店,「10 月 1 日入住」指的是酒店当地的 10 月 1 日,不是用户时区的。这个搞错会导致订错日期,是直接的客诉。
所以核心设计是二维库存(房型 × 日期)+ 区间原子操作 + 分日定价 + 日期以酒店当地为准。
where room_id = ? and date between ? and ? 一次取回区间内所有天的库存和价格,在内存里校验。逐天查是 N 次数据库往返,住半个月就是十几次,而且没法保证读到的是同一时刻的快照。为什么不用「总量 - 已订」实时算余量。那样每次查可售都要扫订单表按日期聚合,区间查询会很慢,而且并发下算出来的值立刻就过期了。用 sold 字段直接记已售、用条件更新保证正确性,是性能和正确性都更好的选择。代价是要有对账任务定期以订单为准重算 sold,防止长期偏差。
Lua 脚本里循环的长度要限制。用户理论上可以查一个跨两年的区间,那脚本里就要循环七百多次,会阻塞 Redis 单线程。所以要在接口层限制最大预订跨度(比如 30 天),这既是业务合理性(没人订一年)也是技术保护。Redis 的 Lua 是原子的代价就是它会阻塞,脚本必须短——这个认知很重要。
为什么不把日历库存全放 Redis 不落库。Redis 快,但库存是业务核心数据,需要持久化、需要对账、需要审计(房态改动要留痕)。我们的分工是数据库是权威来源,Redis 是扣减用的高速副本,通过预热和变更同步保持一致,并且每日对账。
踩过的坑:跨月区间查询漏了月末。早期的区间生成代码是自己按「天数」循环加的,跨月时算错了天数(把 31 天的月份当 30 天),导致 10 月 31 日的库存没被检查就下单成功,实际那天没房,最后要人工联系客人改期。修法是用标准日期库做区间迭代,绝不自己算天数,并且加了单测覆盖跨月、跨年、闰年、夏令时切换这几个边界。日期计算自己动手写循环,几乎必然出错。
踩过的坑二:Redis 与数据库的日历库存长期偏差。Redis 里的余量因为若干次异常回补失败逐渐偏离,某天出现了「Redis 显示有房、数据库已经满了」,卖出去几单需要人工处理。修法是每日对账任务以订单为准重算每一天的 sold,与 Redis 比对,偏差超阈值告警并重建该日的 Redis 数据。注意重建要在低峰做且要加锁,否则重建过程中的扣减会丢。
没做的部分:没做「超售策略」。成熟的 OTA 会故意超售一点(因为总有取消),配合升级房型或换店补偿。这需要精算模型和补偿流程,风险高,当时没做——库存是严格不超售的。
预订接口 P95:压测 500 / 1000 QPS,请求要覆盖不同的区间长度(住一晚、住三晚、住七晚),因为 Lua 脚本的循环次数随天数增长,耗时不同。只压一晚的会偏乐观。1000 QPS 下 P95 约 70ms。要说明压测的房型数量和热点分布——同一个热门房型的同一天是热点行。
「部分天数扣成功」验证:构造一个「三天区间里第三天余量为 1」的场景,用高并发去抢,检查失败的请求是否一天都没扣(查 Redis 各天余量和预占记录)。改造前每轮都能造出碎片,改造后未再出现。这类正确性用确定性场景验证,不要用比率。
超售验证:某天余量设为 10,用 1000 并发抢,跑多轮,检查成功订单数不超过 10、且各天余量不为负。还要测异常场景——压测中途重启 Redis,验证对账能发现偏差。
不要报「库存准确率 100%」。异步链路和缓存副本必然有偏差窗口。正确表述是「每日对账偏差在个位数以内、均在当日处理完」,这是可核查的。
OTA 和电商的第二个根本差异:库存和价格有很大一部分不在自己手上。同一家酒店的同一个房型,我们可能同时接了三个供应商,谁便宜谁有房都要实时问。
第一版是串行调用:循环遍历供应商,一个个调接口。问题全面爆发。
一是列表页慢到不可用。一页 20 家酒店,每家 3 个供应商,串行就是 60 次外部调用,P95 到了两秒多。用户以为页面坏了。
二是一家慢全都慢。某个供应商接口偶发 5 秒超时,整个列表页就跟着 5 秒。而我们对供应商的稳定性完全没有控制权。
三是各家字段五花八门。价格有的含税有的不含、货币单位有的是元有的是分、房型名称完全对不上、错误码各自一套。这些差异散落在业务代码里,加一个供应商要改十几处。
四是价格不一致引发投诉。为了快,列表页用了缓存价格,用户点进去下单时供应商返回的是另一个价格,用户认为平台虚假标价。
所以四个设计:并行加整体超时预算、供应商适配层做归一、熔断隔离不稳定的供应商、价格分三层且下单前必须二次确认。
CompletableFuture 并行发起,每家设自己的超时,再设一个整体预算,到点就用已经拿到的结果返回。少一家供应商的报价用户几乎感知不到,慢两秒则明显。三层价格策略是「速度」和「准确」的显式取舍。纯实时查价最准但列表页会慢到不可用;纯缓存最快但价格不准会引发投诉甚至监管问题。三层的本质是按用户意向强度分配成本——浏览列表时意向弱,用缓存换速度;点进详情说明有意向,值得花几百毫秒查实时;准备付钱时必须绝对准确,再确认一次。这个「按意向强度分配成本」的思路可以迁移到很多场景,是这个模块最值得讲的部分。
整体超时预算导致结果不稳定,这是明确代价。同一个查询两次刷新可能出现的供应商不同(某家这次超时了),用户会看到价格跳动。缓解手段:把已成功拿到的供应商报价短时缓存,下次超时就用上次的值并标注;以及尽量把超时预算设得宽一点(在可接受的延迟内)。要承认这个代价,说「并行之后又快又稳」是不成立的。
踩过的坑:并行改造后 P95 反而变差。改成并行后压测发现 P95 比串行还高。原因是线程池配置不当——核心线程数太小、队列很长,请求量上来后任务在队列里排队,等待时间比串行还久。修法是把队列改短、核心线程数按「并发请求数 × 供应商数」估算,并且队列满了直接走降级而不是排队。并行化本身不带来性能,配置错了反而更慢,这个坑很典型。
踩过的坑二:某家供应商返回的价格单位从元变成了分。对方接口升级没通知,我们的适配器直接按原来的单位解析,价格一下变成了原来的百分之一,被下了几十单低价订单才发现。修法两个:适配层加合理性校验(价格偏离历史均值超过一定倍数直接拒绝这条报价并告警),以及和供应商约定接口变更通知机制。对外部数据做合理性校验是必须的,不能假设对方永远规范——这条经验适用于所有第三方对接。
没做的部分:没做供应商报价的预测缓存(根据历史规律预判哪些查询会发生,提前预热)。这对热门目的地的列表页速度有明显收益,但需要行为数据和调度系统,成本较大。
列表页查价 P95:压测时必须用挡板模拟供应商的真实延迟分布(包括慢响应和超时),不能用一个恒定快速返回的假接口——那样测出来的数字毫无意义。改造前约 2.1s(串行),改造后 400ms 上下(并行 + 500ms 整体预算)。要说明供应商数量、每家的超时设置、整体预算,这三个决定了结果。
「单家超时不影响整体」怎么验证:故障演练——把某家供应商的挡板改成固定 5 秒响应,验证整体仍在预算内返回、结果里少了这家、熔断按预期打开、告警触发、恢复后熔断关闭。最后一条最容易漏(我们在别的项目上踩过熔断不恢复的坑)。
价格一致性:可以报「下单前二次确认时发现价格已变的次数」——这个数字证明这道防线在真实工作,而且是绝对数、口径清晰。不要报「价格准确率」,那个分母说不清。
各供应商的可观测指标:响应耗时分位数、超时次数、拒单率。报出「我们按拒单率给供应商评级并参与排序」这件事本身就是加分项,说明你考虑了业务闭环而不只是技术指标。
OTA 和电商的第三个根本差异:下单不等于成交。电商付款成功货就是你的,OTA 付款之后还要等供应商确认,而供应商真的会拒单——房被其他渠道卖掉了、房态没及时更新、酒店临时关房。
第一版把它当电商做:支付成功就把订单置为「已成功」,然后异步通知供应商。结果是用户已经拿到确认单准备出行,我们才发现供应商拒单,只能临时联系客人改期或退款,客诉极其严重。
第二个问题是退改。电商的退款相对简单,OTA 的退改规则是阶梯的:提前 7 天免费取消、3 到 7 天收 50%、3 天内不可取消。而且规则会调整——用规则的当前版本去算三个月前下的订单,算出来的手续费和用户当时看到的条款不一致,这是直接的纠纷。
第三个问题是取消要调供应商接口,而这个调用会失败。我们这边订单标成已取消、退款也退了,但供应商那边房还占着,最后是我们承担损失。
所以三个设计:状态机显式包含「待确认」这个中间态、拒单要有自动补偿而不是只能人工、退改规则要快照到订单上。
为什么不做「即时确认」。即时确认(下单立刻成交)体验最好,但只有自营库存能做到——房在我们手上,扣了就是扣了。外部供应商的库存我们无法保证,硬做即时确认就等于把拒单风险变成「已成交后再取消」,那是更严重的问题。我们的处理是按库存来源分流:自营库存即时确认,外部供应商走待确认流程,并且在列表页就标注「即时确认」标签,让用户可以优先选。这个分流让体验和风险都得到了照顾。
换供应商补偿要吸收差价,这是有成本的业务决策。原价 500 被拒,另一家 550 有房,我们按 500 给用户、自己补 50。这个决策必须有上限和审批——差价超过一定比例或金额就不自动换,转人工判断。否则会被恶意利用(供应商故意拒单让平台买高价)。
踩过的坑:退改用了当前规则算历史订单。运营调整了阶梯规则之后,一批老订单的取消手续费按新规则算,收多了。用户拿出下单时的条款截图投诉,我们只能全额退还并道歉。修法就是规则快照,而且是快照完整内容而不是只存版本号——存版本号的话,如果规则表被修改(而不是新增版本),历史订单还是会算错。「历史数据要能用当时的规则复算」是所有涉及规则计算的系统的通用要求,不只是退改。
踩过的坑二:供应商回调重复导致重复补偿。供应商的拒单回调发了两次,补偿编排跑了两次,给用户退了两笔款。修法是补偿编排本身要幂等——以「订单号 + 补偿轮次」为幂等键,已执行过的直接跳过。补偿动作本身需要幂等,这是异步链路里最容易漏的一环,因为大家都记得给主流程加幂等,忘了给兜底逻辑加。
没做的部分:没做供应商拒单的预测(根据历史拒单率在下单前就规避高风险供应商)。目前只在择优排序时用了拒单率评级,没有做到「预测这一单会被拒」的粒度。这需要更多特征和模型,当时投入产出不划算。
供应商确认耗时:从「向供应商下单」到「收到确认结果」的时间差,从订单流转日志取,用中位数而不是平均值——少数超时的订单会把平均值拉到分钟级,掩盖大多数用户的真实体验。中位约 25 秒。要能给出分位数分布和各供应商的差异,只报一个中位数容易被认为在藏问题。
转人工的量:超时未确认最终转人工核实的订单数,每日个位数以内。用绝对数而不是比率——比率的分母(总订单数)一变数字就变,而「每天有几单要人工处理」是运营真正关心的。
拒单补偿的效果:报「拒单订单中通过换供应商或换房型解决、用户无需重新预订的比例或数量」。这个数字直接说明补偿编排的价值。要诚实——不可能全部覆盖,说「多数场景可覆盖」并给出实际数量比声称「全部自动解决」可信。
不要报「订单成功率 99%」。拒单主要由供应商的房态准确性决定,前端和我们的系统只能减少损失不能消除拒单。把不属于自己的指标往身上揽是最容易被问穿的。可以报的是「拒单后的处理时长」和「需要用户重新操作的订单数量」,这两个是我们能影响的。
没有匹配的内容,换个关键词试试。
项目拆解 · 旅游预订平台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据