怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

项目背景设定 国际旅游预订平台的服务端,Java + Spring Cloud + MySQL + Redis。品类是酒店与门票,库存一部分自营、一部分来自多个第三方供应商实时接口。用户跨时区、多币种。
为什么选这三个模块 OTA 的技术模型和电商根本不同,这是它最好讲的地方:电商库存是「一个数字减一」,酒店库存是「按日期的二维模型,住三晚要同时占三天」;电商下单即成交,OTA 下单后供应商还可能拒单;电商价格是自己的,OTA 价格要实时问多个供应商。这三个差异每一个都能讲出独立的设计。

模块一:日历库存

  1. 日历库存(按日期二维模型 + 区间原子扣减 + 分日定价)★★★
    简历这样写 日历库存模型与区间并发扣减(MySQL 按日期存储 + Redis Lua 多日原子扣减 + 预占 TTL 回滚):把库存从「一个总量」改为「房型 × 日期」的二维模型,一次预订需连续占用多天且每天都要有余量,用 Lua 脚本保证多日扣减全成功或全失败;价格按日期分别配置、总价逐日累加而非均价乘天数,并支持最少连住与关房规则。压测 1000 QPS 下预订接口 P95 约 70ms,跨日预订的「部分天数扣成功」问题由脚本原子性消除,多轮压测未出现超售。
    展开完整拆解
    为什么要这么设计

    第一版是照着电商抄的:房型表上一个 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 日,不是用户时区的。这个搞错会导致订错日期,是直接的客诉。

    所以核心设计是二维库存(房型 × 日期)+ 区间原子操作 + 分日定价 + 日期以酒店当地为准

    整体链路
    数据模型(二维,这是和电商最根本的差异) │ ├─ room_inventory 房型日历库存,一天一行 │ room_id / date / total / sold / status(可售·关房) │ 唯一索引:room_id + date │ 一个房型一年就是 365 行,量级可控 │ ├─ room_price 房型日历价格,一天一行 │ room_id / date / price / currency │ 节假日、周末、淡旺季各自配置,不做均价 │ └─ room_rule 售卖规则 最少连住晚数 / 最多提前预订天数 / 不可入住日 查询可售与总价(入住 10-01,离店 10-04 → 占 10-01 / 02 / 03) │ ├─ 一次查出区间内每一天的库存与价格(按日期范围查,不要逐天查) │ ├─ 校验:区间内每天都有余量 且 每天都是可售状态 │ 任何一天不满足 → 整个区间不可订(不能只订能订的那几天) │ ├─ 总价 = 区间内每天价格逐日累加(绝不用均价乘天数) │ └─ 校验售卖规则:最少连住、提前天数、不可入住日 预订(区间原子扣减,这一步是并发正确性的核心) │ ├─ Lua 脚本内一次完成: │ 1 循环检查区间内每一天的余量 │ 2 全部满足才逐天扣减 │ 3 任何一天不足 → 一天都不扣,直接返回失败 │ → 全成功或全失败,不存在「扣了一半」 │ ├─ 写预占记录(订单号 + 房型 + 日期区间 + 过期时刻) │ ├─ 创建订单(待支付) │ └─ 预扣成功但建单失败 → 按订单号幂等回补整个区间 超时未支付 / 取消 │ ├─ 预占到期 → 补偿任务扫出 ├─ 校验订单确实未支付 └─ Lua 脚本按区间逐天回补(同样是原子的,按订单号幂等) 日期的时区处理 ├─ 库存与价格表里的 date 一律是「酒店当地日期」 ├─ 接口出入参明确标注这是当地日期,不做时区转换 └─ 客户端展示时提示「酒店当地时间」,避免用户误解
    分步拆解
    1. 库存必须建成「房型 × 日期」一天一行。看起来行数多(一个房型一年 365 行),但量级完全可控——一千个房型也才三十几万行,而且带唯一索引查询很快。用一个总量字段是无法表达日历库存的,这不是优化问题而是模型对错问题。
    2. 区间查询要一次拿完,不要逐天查。where room_id = ? and date between ? and ? 一次取回区间内所有天的库存和价格,在内存里校验。逐天查是 N 次数据库往返,住半个月就是十几次,而且没法保证读到的是同一时刻的快照。
    3. 区间扣减必须在一个 Lua 脚本里完成,这是整个模块的技术核心。脚本里先循环检查每一天的余量,全部满足才逐天扣。Redis 执行 Lua 是单线程原子的,所以不存在「检查通过后被别人抢走」的窗口,也不存在「扣了一半」的中间态。用多个 Redis 命令拼是做不到的。
    4. 离店日不占库存,这个边界最容易写错。10 月 1 日入住、4 日离店,占的是 1、2、3 三天,不是四天。区间是左闭右开。写错会导致库存凭空少卖一天,而且很难发现(数字看起来是对的)。
    5. 总价逐日累加。把区间内每天的价格加起来,不要算均价再乘天数——浮点均价会有精度误差,而且用户会逐日核对明细。同时要把每日明细返回给前端展示,这既是透明度也是减少客诉的手段。
    6. 关房是独立状态,不是把库存改成 0。酒店临时不接待(装修、包场)时要「关房」,这和「卖完了」是不同语义——关房期间不能卖,但已有订单要保留,而且恢复后库存要回到原值。用 status 字段表达,不要用 total = 0 来表达,否则恢复时不知道原来有多少间。
    7. 售卖规则要在预订前校验。最少连住两晚、最多提前 90 天、某些日期不可入住,这些规则如果只在前端校验,接口会被绕过。服务端必须独立校验一遍。
    8. 预占记录要落库并带过期时刻。Redis 存扣减后的余量(快),数据库存预占明细(可靠、可对账、可回滚)。只在 Redis 里的预占,一旦丢数据就既回滚不了也对不了账。
    9. 回补也要原子且幂等。取消或超时回补时,同样用 Lua 按区间逐天加回,并且按订单号做幂等——重复回补会导致库存虚增,虚增比少卖更严重(会超售)。
    10. 日期一律用酒店当地日期,不做时区转换。这是业务定义而不是技术选择。接口的出入参、数据库字段、前端展示都统一为当地日期,并且在界面上明确标注「酒店当地时间」。任何一处做了时区转换就会错开一天。
    关键决策与取舍

    为什么不用「总量 - 已订」实时算余量。那样每次查可售都要扫订单表按日期聚合,区间查询会很慢,而且并发下算出来的值立刻就过期了。用 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%」。异步链路和缓存副本必然有偏差窗口。正确表述是「每日对账偏差在个位数以内、均在当日处理完」,这是可核查的。

    面试追问
    Q:酒店库存和电商库存有什么本质区别? A:维度不同。电商库存是一维的——一个 SKU 一个数量,下单减一。酒店库存是二维的:房型 × 日期,「大床房还有 5 间」这句话不完整,必须说哪一天。由此带来三个连锁差异:一是一次预订要占多个单元(住三晚占三天,且必须全部有量,不能只订能订的那几天);二是价格也是按日期的,总价要逐日累加不能用均价;三是扣减必须是区间原子的,否则会出现「扣了前两天、第三天没房」的碎片,这些碎片会累积成「有库存但卖不出去」。另外还有个容易忽略的点——离店日不占库存,区间是左闭右开,写错会凭空少卖一天。
    Q:用户要住三晚,中间某一晚没房,怎么处理? A:整个区间不可订,不能只订有房的那两晚——用户不可能中间一晚睡马路。所以校验逻辑是「区间内每一天都必须有余量且可售」,任何一天不满足就整体失败。技术上靠 Lua 脚本保证「全成功或全失败」:脚本内先循环检查所有天,全部通过才逐天扣减,有一天不足就一天都不扣。产品上可以做得更友好:返回具体是哪一天没房,并推荐「换房型」或「拆成两段(前两晚 A 房型、后一晚 B 房型)」的方案,但那是产品功能,底层的库存校验必须是全或无。
    Q:为什么用 Lua 脚本?用分布式锁不行吗? A:能行但更差。分布式锁的粒度很难选:锁整个房型的话,不同日期的预订会互相阻塞,热门房型直接串行化,吞吐极低;锁「房型 + 日期」的话,一次预订要拿三把锁,就有死锁风险(两个用户的日期区间交叉、加锁顺序不同),得再规定加锁顺序,复杂度上去了。而 Lua 脚本天然解决这个问题——Redis 单线程执行脚本,脚本内的多次读写就是一个原子操作,不需要任何锁。代价是脚本会阻塞 Redis,所以循环次数必须有上限,我们在接口层限制了最大预订跨度。
    Q:用户在中国下单订东京的酒店,「10 月 1 日入住」是谁的 10 月 1 日? A:酒店当地的 10 月 1 日,这是业务定义。所以我们的处理是库存表、价格表、接口出入参、数据库字段一律存酒店当地日期,全链路不做任何时区转换,并且在界面上明确标注「酒店当地时间」。最危险的做法是把日期当成时间戳处理——一旦某一层做了 UTC 转换,跨时区就会错开一天,而且这类 bug 只在特定时区的用户身上出现,很难复现。正确的类型选择是「日期」而不是「时间戳」:日期是一个日历概念,本身不带时区,这个区分是这道题的关键。相关的还有入住时间(下午 3 点后)和离店时间(中午 12 点前),那些是时刻,要按酒店时区处理,和日期分开对待。

模块二:多供应商聚合查价

  1. 多供应商聚合查价(并行拉取 + 超时预算 + 结果归一 + 分层缓存)★★★
    简历这样写 多供应商实时聚合查价(CompletableFuture 并行编排 + 适配层归一 + 熔断降级 + 三层价格策略):同一房型可能来自多家供应商,查价时并行请求各供应商并按整体超时预算裁剪,慢的供应商直接放弃这一路;各家字段与错误码在适配层归一成统一模型后按价格与可售性择优;价格采用「列表用缓存价 → 详情实时查 → 下单再确认一次」三层策略。列表页查价 P95 由约 2.1s 降至 400ms 上下,单家供应商超时不影响整体返回,价格不一致由下单前的二次确认拦住。
    展开完整拆解
    为什么要这么设计

    OTA 和电商的第二个根本差异:库存和价格有很大一部分不在自己手上。同一家酒店的同一个房型,我们可能同时接了三个供应商,谁便宜谁有房都要实时问。

    第一版是串行调用:循环遍历供应商,一个个调接口。问题全面爆发。

    一是列表页慢到不可用。一页 20 家酒店,每家 3 个供应商,串行就是 60 次外部调用,P95 到了两秒多。用户以为页面坏了。

    二是一家慢全都慢。某个供应商接口偶发 5 秒超时,整个列表页就跟着 5 秒。而我们对供应商的稳定性完全没有控制权

    三是各家字段五花八门。价格有的含税有的不含、货币单位有的是元有的是分、房型名称完全对不上、错误码各自一套。这些差异散落在业务代码里,加一个供应商要改十几处。

    四是价格不一致引发投诉。为了快,列表页用了缓存价格,用户点进去下单时供应商返回的是另一个价格,用户认为平台虚假标价

    所以四个设计:并行加整体超时预算供应商适配层做归一熔断隔离不稳定的供应商价格分三层且下单前必须二次确认

    整体链路
    查价请求(酒店列表 / 房型详情) │ ├─ 先查本地自营库存(快,走前一个模块的日历库存) │ ├─ 并行发起各供应商查询(CompletableFuture / 线程池隔离) │ 每家独立超时(比如 300ms) │ 整体超时预算(比如 500ms)→ 到点就用已拿到的结果返回 │ 线程池按供应商隔离,一家打满不影响其他家 │ ├─ 熔断:某家连续失败率超阈值 → 打开熔断,直接跳过不再调 │ 半开探测恢复,熔断状态要单独监控告警 │ ├─ 适配层归一(每家一个适配器,业务代码只见统一模型) │ 价格:统一到「最小货币单位整数 + 币种 + 是否含税」 │ 房型:靠映射表把各家房型 ID 对到我们的标准房型 │ 错误码:统一成「无房 / 参数错 / 系统错 / 超时」四类 │ ├─ 择优:同一标准房型有多家报价 → 按「可售 → 价格 → 供应商评级」排 │ 供应商评级要参与,因为拒单率高的便宜也没用 │ └─ 写缓存(列表用)+ 返回 价格三层策略(这是体验与准确性的平衡) │ ├─ 列表页 用缓存价(秒级到分钟级过期) │ 标注「参考价」,允许有偏差,追求速度 │ ├─ 详情页 实时查供应商,拿准确价 │ 此时用户已表达意向,多花几百毫秒可接受 │ └─ 下单前 再向供应商确认一次价格与可售 价格变了 → 明确提示用户「价格已变为 X」并要求重新确认 绝不静默按新价扣款 监控(供应商是外部依赖,必须可观测) ├─ 各家的响应耗时分位数、超时次数、错误码分布 ├─ 各家的拒单率(下单后被拒的比例,影响评级) └─ 熔断开合事件(降级时接口是「成功」的,看成功率发现不了)
    分步拆解
    1. 并行的关键不是并行本身,而是「整体超时预算 + 允许缺失」。CompletableFuture 并行发起,每家设自己的超时,再设一个整体预算,到点就用已经拿到的结果返回。少一家供应商的报价用户几乎感知不到,慢两秒则明显。
    2. 线程池必须按供应商隔离。如果共用一个池,某家供应商全部超时会把线程占满,其他家也查不出来。这是舱壁模式的典型场景。共用线程池是并行改造里最常见的错误。
    3. 适配层是加供应商的成本从「改十几处」降到「加一个类」的关键。每家一个适配器,负责请求构造、响应解析、字段归一、错误码归一。业务代码只认统一模型,压根不知道有几家供应商。
    4. 价格归一要处理三件事:单位、含税、币种。统一成「最小货币单位的整数 + 币种码 + 是否含税」的结构。绝不能用浮点数传金额,也不能假设各家都含税——不含税的价格展示给用户,结账时多出税费是直接的投诉。
    5. 房型映射表是聚合的前提也是最脏的活。各家对同一个房型的命名完全不同(「豪华大床房」「Deluxe King」「大床房-无窗」),要靠映射表把它们归到我们的标准房型上。映射错了会导致用户订到不是他看的房型,所以映射要人工审核,不能靠字符串相似度自动匹配。
    6. 熔断要按供应商独立配置。某家挂了就跳过它,其他家照常。熔断状态必须单独监控告警——降级期间接口是「成功」返回的(只是少了一家报价),看接口成功率发现不了问题。
    7. 择优不能只看价格,必须带上供应商评级。拒单率高的供应商报价再低也是坑——用户下单后被拒,要退款要道歉,体验极差。把拒单率折算成评级参与排序,这是业务和技术结合的判断。
    8. 缓存只用于列表页,且必须标注「参考价」。列表页追求速度,用缓存价可以接受,但界面上要诚实标注,并且缓存过期时间要短(秒级到分钟级)。详情页开始就走实时。
    9. 下单前必须再确认一次价格和可售。这是防「价格不一致」投诉的最后一道,也是最重要的一道。价格变了要明确提示并要求用户重新确认,绝不能静默按新价扣款——那是监管风险不只是体验问题。
    关键决策与取舍

    三层价格策略是「速度」和「准确」的显式取舍。纯实时查价最准但列表页会慢到不可用;纯缓存最快但价格不准会引发投诉甚至监管问题。三层的本质是按用户意向强度分配成本——浏览列表时意向弱,用缓存换速度;点进详情说明有意向,值得花几百毫秒查实时;准备付钱时必须绝对准确,再确认一次。这个「按意向强度分配成本」的思路可以迁移到很多场景,是这个模块最值得讲的部分。

    整体超时预算导致结果不稳定,这是明确代价。同一个查询两次刷新可能出现的供应商不同(某家这次超时了),用户会看到价格跳动。缓解手段:把已成功拿到的供应商报价短时缓存,下次超时就用上次的值并标注;以及尽量把超时预算设得宽一点(在可接受的延迟内)。要承认这个代价,说「并行之后又快又稳」是不成立的。

    踩过的坑:并行改造后 P95 反而变差。改成并行后压测发现 P95 比串行还高。原因是线程池配置不当——核心线程数太小、队列很长,请求量上来后任务在队列里排队,等待时间比串行还久。修法是把队列改短、核心线程数按「并发请求数 × 供应商数」估算,并且队列满了直接走降级而不是排队并行化本身不带来性能,配置错了反而更慢,这个坑很典型。

    踩过的坑二:某家供应商返回的价格单位从元变成了分。对方接口升级没通知,我们的适配器直接按原来的单位解析,价格一下变成了原来的百分之一,被下了几十单低价订单才发现。修法两个:适配层加合理性校验(价格偏离历史均值超过一定倍数直接拒绝这条报价并告警),以及和供应商约定接口变更通知机制对外部数据做合理性校验是必须的,不能假设对方永远规范——这条经验适用于所有第三方对接。

    没做的部分:没做供应商报价的预测缓存(根据历史规律预判哪些查询会发生,提前预热)。这对热门目的地的列表页速度有明显收益,但需要行为数据和调度系统,成本较大。

    数字是怎么测的

    列表页查价 P95:压测时必须用挡板模拟供应商的真实延迟分布(包括慢响应和超时),不能用一个恒定快速返回的假接口——那样测出来的数字毫无意义。改造前约 2.1s(串行),改造后 400ms 上下(并行 + 500ms 整体预算)。要说明供应商数量、每家的超时设置、整体预算,这三个决定了结果。

    「单家超时不影响整体」怎么验证:故障演练——把某家供应商的挡板改成固定 5 秒响应,验证整体仍在预算内返回、结果里少了这家、熔断按预期打开、告警触发、恢复后熔断关闭。最后一条最容易漏(我们在别的项目上踩过熔断不恢复的坑)。

    价格一致性:可以报「下单前二次确认时发现价格已变的次数」——这个数字证明这道防线在真实工作,而且是绝对数、口径清晰。不要报「价格准确率」,那个分母说不清。

    各供应商的可观测指标:响应耗时分位数、超时次数、拒单率。报出「我们按拒单率给供应商评级并参与排序」这件事本身就是加分项,说明你考虑了业务闭环而不只是技术指标。

    面试追问
    Q:列表页展示的价格和用户下单时的价格不一致,怎么处理? A:这个问题在 OTA 里无法完全避免,因为价格在供应商手上、随时可变,所以设计的重点是「让不一致被拦住而不是让它不发生」。三层策略:列表页用缓存价并明确标注「参考价」(诚实告知);详情页实时查供应商拿准确价;下单前再向供应商确认一次,价格变了就明确提示「价格已变为 X,是否继续」并要求用户重新确认绝对不能做的是静默按新价扣款——用户看到一个价、账单是另一个价,这不只是体验问题,是监管和法律风险。反过来如果新价更低,可以直接按低价成交并告知用户,这是正向的。
    Q:有一家供应商接口经常超时,怎么办? A:技术上隔离,业务上降权。技术侧四件事:独立超时(这一路超时就放弃,不拖累整体);线程池隔离(避免它把线程占满影响其他家);熔断(连续失败率超阈值就直接跳过,不再浪费请求和等待);整体超时预算(兜底,保证接口总耗时可控)。业务侧:把超时率和拒单率折算成供应商评级参与择优排序,不稳定的供应商即使报价低也往后排;长期不达标的要商务介入。还有一点很重要:熔断状态必须单独监控告警,因为熔断期间接口是正常返回的(只是少一家报价),看接口成功率压根发现不了,我们在别的系统上踩过「熔断打开后几小时没人知道」的坑。
    Q:不同供应商的房型名称完全不一样,怎么知道是同一个房型? A:靠人工审核的映射表,不能自动匹配。各家的命名差异极大(「豪华大床房」「Deluxe King Room」「大床房-无窗-不含早」),而且细微差异是实质性的——含早不含早、有窗无窗、可否取消,价格差很多。用字符串相似度自动匹配的话,会把「含早」和「不含早」映射成同一个房型,导致用户订到的和看到的不一致,这是严重客诉。所以流程是:供应商接入时人工建立映射、新房型进待审核队列、映射变更要留痕。可以用相似度做候选推荐来提效,但最终必须人工确认。这是个典型的「不该追求全自动」的场景,能识别出来是加分的。
    Q:为什么不把所有供应商的库存价格都同步到本地,直接查本地? A:想过,但对酒店品类不可行。核心原因是数据量和变化频率的乘积太大:房型数 × 未来 365 天 × 供应商数,而且价格和可售状态随时在变(其他渠道也在卖同一批房),同步下来的数据秒级就可能过期,用过期数据下单会大量拒单,比慢一点更糟。但部分同步是有价值的:我们把「静态信息」(酒店基础信息、房型描述、图片、设施)全量同步到本地,只有「价格和可售」走实时查询。区分「静态可同步」和「动态必须实时」是这里的关键判断。另外对于自营库存(我们自己签的房),本来就在本地,走的就是日历库存那套,不需要问供应商——所以实际是「本地自营 + 外部实时」混合。

模块三:预订状态机与退改

  1. 预订状态机与退改(供应商异步确认 + 拒单补偿 + 阶梯退改规则)★★★
    简历这样写 预订单状态机与退改链路(状态机条件更新 + 供应商异步确认 + 规则快照 + 补偿编排):预订不是即时成交,设计覆盖「待确认 / 已确认 / 确认失败 / 已入住 / 已取消」的状态机,供应商拒单后走自动补偿(换供应商重下或全额退款并通知);退改按距入住时间的阶梯规则计算手续费,规则版本快照到订单以保证历史订单可复算,计算过程留痕。供应商确认的中位耗时约 25 秒,超时未确认转人工的每日在个位数以内,拒单后自动补偿可覆盖多数场景。
    展开完整拆解
    为什么要这么设计

    OTA 和电商的第三个根本差异:下单不等于成交。电商付款成功货就是你的,OTA 付款之后还要等供应商确认,而供应商真的会拒单——房被其他渠道卖掉了、房态没及时更新、酒店临时关房。

    第一版把它当电商做:支付成功就把订单置为「已成功」,然后异步通知供应商。结果是用户已经拿到确认单准备出行,我们才发现供应商拒单,只能临时联系客人改期或退款,客诉极其严重。

    第二个问题是退改。电商的退款相对简单,OTA 的退改规则是阶梯的:提前 7 天免费取消、3 到 7 天收 50%、3 天内不可取消。而且规则会调整——用规则的当前版本去算三个月前下的订单,算出来的手续费和用户当时看到的条款不一致,这是直接的纠纷。

    第三个问题是取消要调供应商接口,而这个调用会失败。我们这边订单标成已取消、退款也退了,但供应商那边房还占着,最后是我们承担损失。

    所以三个设计:状态机显式包含「待确认」这个中间态拒单要有自动补偿而不是只能人工退改规则要快照到订单上

    整体链路
    下单到确认(关键是「待确认」这个中间态必须存在) │ ├─ 预占库存(自营走日历库存 / 外部走供应商预占接口) │ ├─ 创建订单,状态 = 待支付 │ 同时把当前的退改规则快照写进订单(关键,见下) │ ├─ 支付成功 → 状态改为「待确认」,不是「已成功」 │ 前端明确展示「正在向酒店确认,通常几分钟内完成」 │ ├─ 向供应商下单(带幂等键,重试不会重复下) │ ├─ 等供应商确认(异步回调 + 主动轮询双通道) │ 只等回调不行:回调会丢 │ 只轮询不行:慢 │ 两条都要,靠幂等保证只处理一次 │ └─ 结果分三种 ├─ 确认成功 → 状态改「已确认」,发确认单,通知用户 ├─ 明确拒单 → 走补偿编排(见下) └─ 超时未响应 → 继续轮询到上限,仍无结果则转人工 不能自动判失败(可能对方已经确认了,只是没回) 拒单补偿编排(不能只能人工兜) │ ├─ 1 尝试换供应商:同房型是否有其他家可售且价格不高于原价 │ 成功 → 用户无感知,订单继续(差价由平台吸收) │ ├─ 2 尝试换房型:同酒店的相近房型 │ 需要用户同意 → 发通知让用户选择,给一个响应期限 │ ├─ 3 全额退款 + 通知 + 补偿券 │ 钱必须先退,不能等用户来催 │ └─ 全程留痕:为什么拒单、尝试了哪些补偿、最终结果 退改(规则必须快照) │ ├─ 下单时把退改规则的「版本 + 具体阶梯内容」写进订单 │ 之后运营改规则,不影响已下的订单 │ ├─ 用户申请取消 → 按订单上的快照规则算手续费 │ 阶梯按「当前时间距入住时间」判定 │ 时间基准用酒店当地时区(和日历库存一致) │ ├─ 展示明细:原价 / 手续费 / 实退 / 依据哪一条规则 │ ├─ 先调供应商取消 → 成功才退款 │ 顺序不能反:先退款后取消失败的话,房占着钱也退了 │ ├─ 供应商取消失败 → 订单进「取消中」,重试 + 转人工 │ 绝不能直接标成已取消 │ └─ 退款走支付域,幂等键 = 订单号 + 退款序号
    分步拆解
    1. 「待确认」这个状态必须显式存在,而且要如实告知用户。把它藏起来(支付成功就显示成功)是最糟的做法——一旦拒单,用户的预期已经建立,处理成本翻倍。如实展示「正在确认」反而降低客诉,因为用户有预期。
    2. 回调和轮询两条通道都要有。只依赖供应商回调,回调丢了订单就永久卡在待确认;只轮询则确认慢(要等下一个周期)。两条并行,靠幂等保证同一个确认结果只处理一次。轮询要退避,不能固定高频。
    3. 供应商下单必须带幂等键。网络超时后重试,如果对方没有幂等保护,会变成下了两单——这是真金白银的损失。幂等键用我们的订单号,并且要和供应商约定他们侧的去重规则。
    4. 超时未响应不能自动判失败。可能对方已经确认了只是响应丢了。要么继续轮询到上限,要么转人工核实。自动判失败并退款,然后对方其实确认了,就变成「我们退了钱但房还占着」的双重损失。
    5. 拒单补偿要分级尝试,不能只有「退款」一条路。先尝试换供应商(用户完全无感知,只是平台吸收差价),再尝试换房型(需要用户同意),最后才退款加补偿。换供应商这一步的价值极高——它把一次严重客诉变成了零感知。
    6. 退改规则必须快照到订单上,这是最容易漏但后果最严重的一条。用户下单时看到的条款是「7 天前免费取消」,三个月后运营把规则改成「14 天前免费」,如果用当前规则算,用户会被多收手续费,而他手里有当时的条款截图。做法是下单时把规则的完整内容(不只是版本号)写进订单,退改时按订单上的算。
    7. 阶梯判定的时间基准要和日历库存一致,用酒店当地时区。「距入住 3 天」这个判断,用服务器时区算和用酒店时区算可能差一天,正好跨过阶梯边界就是手续费差一大截。
    8. 退改计算要展示明细并留痕。原价、手续费、实退、依据哪一条阶梯,全部展示给用户并记日志。「为什么只退了一半」是客服高频问题,能回放才能解释。
    9. 取消的顺序必须是「先调供应商取消,成功才退款」。反过来的话,退款成功但供应商取消失败,房还占着而钱已经退了,损失由平台承担。涉及外部系统的操作,顺序原则是「先做不可逆的外部动作,再做自己这边的动作」。
    10. 供应商取消失败要有「取消中」中间态。不能直接标成已取消(那就丢了重试的依据),也不能标成失败让用户重新申请(体验差)。进「取消中」,后台重试,重试到上限转人工,并且要给用户明确的进度反馈。
    关键决策与取舍

    为什么不做「即时确认」。即时确认(下单立刻成交)体验最好,但只有自营库存能做到——房在我们手上,扣了就是扣了。外部供应商的库存我们无法保证,硬做即时确认就等于把拒单风险变成「已成交后再取消」,那是更严重的问题。我们的处理是按库存来源分流:自营库存即时确认,外部供应商走待确认流程,并且在列表页就标注「即时确认」标签,让用户可以优先选。这个分流让体验和风险都得到了照顾。

    换供应商补偿要吸收差价,这是有成本的业务决策。原价 500 被拒,另一家 550 有房,我们按 500 给用户、自己补 50。这个决策必须有上限和审批——差价超过一定比例或金额就不自动换,转人工判断。否则会被恶意利用(供应商故意拒单让平台买高价)。

    踩过的坑:退改用了当前规则算历史订单。运营调整了阶梯规则之后,一批老订单的取消手续费按新规则算,收多了。用户拿出下单时的条款截图投诉,我们只能全额退还并道歉。修法就是规则快照,而且是快照完整内容而不是只存版本号——存版本号的话,如果规则表被修改(而不是新增版本),历史订单还是会算错。「历史数据要能用当时的规则复算」是所有涉及规则计算的系统的通用要求,不只是退改。

    踩过的坑二:供应商回调重复导致重复补偿。供应商的拒单回调发了两次,补偿编排跑了两次,给用户退了两笔款。修法是补偿编排本身要幂等——以「订单号 + 补偿轮次」为幂等键,已执行过的直接跳过。补偿动作本身需要幂等,这是异步链路里最容易漏的一环,因为大家都记得给主流程加幂等,忘了给兜底逻辑加。

    没做的部分:没做供应商拒单的预测(根据历史拒单率在下单前就规避高风险供应商)。目前只在择优排序时用了拒单率评级,没有做到「预测这一单会被拒」的粒度。这需要更多特征和模型,当时投入产出不划算。

    数字是怎么测的

    供应商确认耗时:从「向供应商下单」到「收到确认结果」的时间差,从订单流转日志取,用中位数而不是平均值——少数超时的订单会把平均值拉到分钟级,掩盖大多数用户的真实体验。中位约 25 秒。要能给出分位数分布和各供应商的差异,只报一个中位数容易被认为在藏问题。

    转人工的量:超时未确认最终转人工核实的订单数,每日个位数以内。用绝对数而不是比率——比率的分母(总订单数)一变数字就变,而「每天有几单要人工处理」是运营真正关心的。

    拒单补偿的效果:报「拒单订单中通过换供应商或换房型解决、用户无需重新预订的比例或数量」。这个数字直接说明补偿编排的价值。要诚实——不可能全部覆盖,说「多数场景可覆盖」并给出实际数量比声称「全部自动解决」可信。

    不要报「订单成功率 99%」。拒单主要由供应商的房态准确性决定,前端和我们的系统只能减少损失不能消除拒单。把不属于自己的指标往身上揽是最容易被问穿的。可以报的是「拒单后的处理时长」和「需要用户重新操作的订单数量」,这两个是我们能影响的。

    面试追问
    Q:用户付款了,但供应商说没房了,怎么办? A:这是 OTA 和电商最大的差异,所以「待确认」这个中间态必须存在——支付成功后订单状态是「待确认」而不是「已成功」,前端如实展示「正在向酒店确认」。拒单后走分级补偿先尝试换供应商(同房型另一家有房且价格可控,平台吸收差价,用户完全无感知,这一步价值最大);再尝试换房型(同酒店相近房型,需要用户同意,给一个响应期限);最后才是全额退款加补偿券,而且钱要主动退、不等用户催。全程留痕:为什么拒单、试了哪些补偿、结果如何。换供应商要有差价上限和审批,否则可能被恶意利用。
    Q:运营改了退改规则,三个月前的订单怎么算手续费? A:按订单上快照的规则算,不能用当前规则。我们踩过这个坑——规则调整后一批老订单按新规则算,手续费收多了,用户拿出下单时的条款截图投诉,只能全额退还并道歉。修法是下单时把退改规则的完整内容写进订单(不是只存一个版本号——如果规则表被原地修改而不是新增版本,存版本号还是会算错)。这样运营改规则只影响新订单。这条经验可以推广:所有「下单时约定、事后执行」的规则(退改、佣金费率、保险条款、积分比例)都必须快照到单据上,历史数据必须能用当时的规则复算,否则一旦规则变更就会产生无法解释的差异。
    Q:取消订单时,是先退款还是先通知供应商取消? A:先调供应商取消,成功了才退款。顺序反了的后果是:退款成功但供应商取消失败,房还占着而钱已经退了,损失全在平台。而按正确顺序,如果供应商取消失败,订单进「取消中」中间态、后台重试、重试到上限转人工,用户的钱还没退所以没有资损,只是体验上要等一下。这里的通用原则是「先做不可逆的外部动作,再做自己这边可控的动作」——外部动作失败了我们还能不执行自己这边的;反过来自己先做了,外部失败就没法回头。另外「取消中」这个中间态必须存在:不能直接标已取消(丢了重试依据),也不能标失败让用户重新申请(体验差)。
    Q:供应商的确认回调一直没来,你怎么判断这单成没成? A:不能只等回调,必须有主动轮询——回调会丢(对方系统故障、网络问题、我们的接口临时不可用)。两条通道并行:回调来了就处理,同时定时主动查询供应商的订单状态,靠幂等保证同一个结果只处理一次。轮询要退避,不能固定高频打对方接口。关键是超时后的判断:绝不能自动判失败。因为可能对方已经确认成功了,只是我们不知道——这时自动判失败并退款,就变成「钱退了但房还占着」的双重损失。正确做法是轮询到上限后转人工核实(打电话给供应商或登录对方后台查),并且给用户明确的进度反馈。这和支付链路的「未知态」处理是同一个原则:超时只意味着不知道结果,唯一正确的动作是查询而不是判定。

没有匹配的内容,换个关键词试试。

项目拆解 · 旅游预订平台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据