第一版把打包产品当成一个普通商品做:建一张打包 SKU 表,配一个价格,维护一个库存数。上线之后立刻出事——卖出了子项已经售罄的组合。原因很直白:打包的库存是运营手填的一个数字,子项的库存在各自的系统里独立变化,两者压根没有关联。酒店那边房卖完了,打包这边还显示有货。
问题的本质是建模错了。打包产品不是一个有独立库存的商品,它是「一组子项的引用 + 一个打包价规则」。它的可售性不是自己的属性,而是各子项可售性的交集——任何一个子项不可售,整个包就不可售。想清这一点之后,打包 SKU 表里就不该有库存字段,有库存字段本身就是设计错误。
但「求交集」这件事在 OTA 里比听起来难,因为三种子项的库存模型完全不一样:机票是「某个航班的某个舱位还有几个座」,酒店是「某个房型在连续的每一天都要有余量」,门票是「某个日期的某个场次还有几个名额」。它们的查询入参、粒度、失效方式都不同。如果让打包服务去分别理解三套模型,这个服务会随着新品类的加入不断膨胀。
所以第二个设计是为异构库存抽象一个统一的可售性查询接口:入参统一成「出行日期区间 + 人数 + 子项标识」,出参统一成「是否可售 + 价格 + 一个可用于后续占用的凭据」。各品类各自实现适配,打包服务只面对这个抽象。
第三个坑是联动。打包不是把子项各自查一遍就完事,子项之间有约束:机票的到达日必须等于酒店的入住日,酒店的退房日必须等于返程机票的日期,门票日期必须落在入住区间内。人数上也有联动,而且不是等量映射——3 个人是 3 张机票,但酒店可能是 1 间三人房或 2 间房,取决于房型的占用规则。把人数直接当房间数是个很容易犯又很难发现的错。
最后是缓存策略。联合可售性要查多个子项,代价比单品高,很想缓存;但它比单品可售性失效得更快——任何一个子项变化整个组合就失效。所以只做短 TTL 缓存,并靠子项变更事件主动失效,同时在产品上把列表页的可售性表述为参考,下单前强制重新校验。
为什么不给打包产品维护独立库存,哪怕它更快。独立库存的诱惑很大:一次查询就知道可不可售,性能好得多。但它引入了一份需要同步的副本,而子项库存的变化来源太多(其他单品订单、供应商推送、运营调整、其他包的占用),任何同步方案都会有窗口,而这个窗口里卖出去的就是超卖。旅游业务里超卖的代价不是道歉退款——是用户带着行李到了酒店发现没房。所以这里的取舍很清楚:用性能换正确性。代价是联合可售性查询更慢,缓解手段是并行查询加短缓存加超时预算,而不是回到独立库存。
超时按「不可售」而不是「按可售放过」。这是个有明确方向的选择:宁可少卖,不可超卖。反过来的设计(超时先放过,下单时再校验)看起来能多卖,实际是把问题推到下单环节,用户走完选购流程后被拒绝,体验更差而且转化更低。把「不确定」当成「不可售」,是这类外部依赖场景的通用取向。
踩过的坑:打包 SKU 自维护库存,卖出了子项已售罄的组合。用户下单后进入待确认,供应商那边直接拒单,客服要一个个打电话解释。修法是删掉打包 SKU 的库存字段,改为实时求交。教训是:凡是「可以从别处推导出来的状态」就不要存一份副本,存了就要同步,同步就有窗口,窗口就是故障。这条判断在缓存、在冗余字段、在读模型上都成立。
踩过的坑二:人数直接当房间数,三人行只订了一间大床房。用户三个人出行,系统给酒店传了「1 间」(因为按人数换算的逻辑写错,把人数当成了取整后的房间数),到店才发现房间住不下。这个 bug 的隐蔽性在于:单人和双人出行时结果恰好是对的,只有三人以上才错。修法是把人数到房间数的换算做成房型的占用规则配置,并补上「三人及以上」的用例。教训是:涉及换算的逻辑,测试用例必须覆盖边界档位,而不只是典型值。
踩过的坑三:子项库存变更事件来了,不知道该失效哪些包。早期只做了 TTL 过期,运营反馈「明明关房了列表还显示有」。加事件失效的时候才发现缺一个从子项到包的反向索引,只能全量扫打包表,代价太大。修法是维护子项到包的反向索引,随打包 SKU 的创建与修改一起更新。教训是:缓存失效的设计必须和缓存构建同时做,先做缓存后补失效,往往会发现缺了必要的索引结构。
没做的部分:没做「自动替换不可售子项」——比如酒店不可售时自动换成同档次的另一家凑成一个新包。这需要可替换性判定(档次、位置、口碑是否可比)和价格重算,而且换了之后还是不是用户想买的那个包,是个产品问题。当时的做法是把不可售的包下架并提示运营。
联合可售性查询耗时:压测对比单品可售性和四子项打包可售性的 P95。要报的是倍数关系而不是绝对值——「控制在单品查询的两倍以内」比「P95 多少毫秒」更能说明并行编排是有效的(串行的话应该是四倍左右)。同时要说明子项数量,因为这个数随子项数变化。
超卖问题:用确定性场景验证,不要用比率。构造「某子项余量为 1,多个打包订单并发抢」的场景,检查成功的订单数不超过 1,且失败的订单没有占用任何子项。改造前每轮都能造出超卖,改造后未再出现。
缓存失效的及时性:手动关闭某个子项的库存,计时到列表页不再展示相关打包产品。这个数字要区分两条路径分别报——事件驱动失效的耗时,和事件丢失时靠 TTL 兜底的耗时(后者等于 TTL)。只报前者是选择性呈现。
「哪个子项挡住了」的分布:报各子项造成不可售的次数占比。这个数据的价值是双重的:证明埋点在真实生效;分布本身能指出选品问题。
不要报「超卖率降为 0」或「可售性准确率 100%」。外部供应商的库存本来就有推送延迟,端到端不可能没有偏差窗口。正确表述是「打包侧不再持有独立库存副本,超卖只可能来自子项自身的供应商延迟」——把责任边界说清,比给一个绝对化数字可信。
打包下单要跨多个供应商占用库存,天然是个分布式事务问题。但它和电商的分布式事务有一个根本差别,这个差别是整个模块的核心:补偿有成本。
电商里 Saga 的补偿是免费的——扣了库存要回滚,就是把数字加回去,不花一分钱,所以「失败就全部回滚」是无脑正确的策略。打包产品里不是这样:机票占位之后取消要付退票费,酒店预占释放是免费的,门票取决于供应商政策。于是「要不要回滚」不再是技术问题,它是一个花钱的决策。
第一版没意识到这点,编排顺序是按「业务直觉」排的:先订机票(用户最关心的)、再订酒店、最后订门票。结果机票出票成功、酒店拒单,回滚机票要付退票费,这笔钱当时没人预料到,财务月底才发现有一笔莫名的支出在涨。
意识到问题后的设计是按补偿成本排序执行顺序:可免费回滚的先做,有成本的最后做。这样一旦前面的子项失败,后面有成本的子项还没执行,压根不需要补偿。这个调整不需要任何新技术,只是把顺序换了一下,但它直接消掉了一类可避免的支出。面试里这一点比讲 Saga 框架有价值得多——它说明你理解补偿的业务代价,而不只是会用一个模式。
第二个设计是预占与确认两阶段。OTA 的子项确认不是同步的:酒店要等供应商确认、机票要走出票流程,都可能是几十秒到几分钟。所以不能在一个同步请求里完成,必须先做可撤销的预占(快,同步),再走不可撤销的确认(慢,异步)。整单状态是各子项状态聚合出来的,不是独立维护的。
第三个是幂等。异步加重试的链路里,同一个子项调用可能发生多次。幂等键用打包订单号加子项标识——注意不能用「本次请求 id」,那样重试就会被当成新请求,这是幂等做错的经典形态。
第四个,也是最需要产品参与的:部分成功怎么办。机票成了酒店拒了,技术上有三条路,但选哪条不能由技术决定,因为它涉及钱由谁承担。我们最后落地成三条明确的产品策略,写进配置而不是写死在代码里。
为什么按补偿成本排序,而不是按业务重要性排序。直觉上会先订用户最关心的(机票),但那正好是补偿最贵的。按补偿成本升序执行,等于把「可能白花钱的动作」尽量推后,前面任何一步失败都不用付代价。取舍是:这个顺序有时和业务直觉相反,可能出现「酒店订好了但机票没票」的情况,用户会觉得顺序奇怪。缓解手段是在下单前就把机票可售性查清(模块一的联合可售性),把真正失败的概率压低,让排序只在「查得到但订不到」这个小概率窗口里起作用。这个取舍我认为很划算:牺牲一点直觉合理性,换掉一类持续发生的可避免支出。
为什么部分成功不做自动决策。技术上完全可以写一套规则自动选策略,但它决定的是钱由谁承担,这超出了技术的授权范围。而且规则会随供应商合同变化——同一家供应商今年免费取消、明年收费,代码里写死的规则跟不上。我们的做法是把三条策略都实现,选哪条由配置决定,配置由业务维护。取舍是多了一层配置的复杂度和配置错误的风险,缓解手段是配置变更要审批并留痕。
踩过的坑:编排顺序按业务直觉排,产生了一类没人预料的退票费支出。先订机票、再订酒店,机票出票成功而酒店拒单,回滚机票要付退票费。这笔钱没有归口,财务月底才发现有一笔支出在涨,追了很久才追到是打包下单的补偿造成的。修法是按补偿成本重排顺序,并且给每笔补偿支出单独记账。教训有两条:一是分布式事务的补偿不一定是免费的,编排顺序要把补偿成本当成一等输入;二是技术动作产生的费用必须能归因到技术动作上,否则问题会以「财务发现一笔莫名支出」的形式暴露,而那时候已经花了很多钱。
踩过的坑二:幂等键用了请求 id,重试产生重复占用。消息重投后同一个子项被占了两次,库存多扣、供应商那边也多了一笔。修法是幂等键改为打包订单号加子项标识。教训是幂等键要绑「业务上的同一次操作」而不是「同一次网络请求」——这两者的区别就是幂等做对和做错的分界线,我在消息、支付、库存上都见过同一个错。
踩过的坑三:预占没有 TTL,异常中断把库存占死。下单流程在预占后崩了,那些预占记录既没有确认也没有释放,一直占着子项库存,直到运营发现「明明有房却卖不出去」。修法是预占一律带 TTL,并有独立的扫描兜底。教训是:任何「先占后用」的资源都必须有自动回收路径,不能只依赖正常流程去释放,因为正常流程一定会有中断的时候。
踩过的坑四:部分确认的订单靠用户投诉才发现。用户以为订好了,到出行前几天才发现酒店压根没确认,此时改期改签的成本已经很高。修法是定时扫描长期处于部分确认的订单推人工队列。教训是:中间状态必须有主动发现机制,「等用户来说」意味着发现时长等于用户的容忍时长,而在旅游业务里那可能是几周。
没做的部分:没做跨供应商的「智能改配」——比如机票订不到时自动搜索相邻日期或邻近机场的方案让用户选。这需要重新走一遍打包价计算和可售性求交,而且改了行程还是不是用户想买的产品是个产品问题,当时只做到「明确告知失败原因并保留已成功子项」。
可避免的补偿支出:这是这个模块最该报的数字,而且它本来是不可见的。做法是给每笔补偿支出单独记账并按原因归集,然后报「因编排顺序造成的退票费支出」这一类的金额与笔数。诚实的表述是「改造后这一类支出不再产生」——因为有成本的子项被排到最后,前序失败时不会执行到它。不要报「补偿成本下降 N%」,那需要一个改造前的准确基线,而改造前这笔钱压根没有归口。
部分确认订单的发现时长:报改造前后的发现路径变化而不只是时长——改造前是「等用户投诉」(发现时长等于用户容忍时长,可能是几周),改造后是「扫描主动发现」(发现时长等于扫描周期)。路径的变化比数字更有说服力,因为它说明的是「从被动到主动」。
重复占用:用确定性场景验证——对同一个子项调用重复投递多次,检查供应商侧只有一笔占用。要两端一起看,只看自己这边会漏掉「供应商那边多了一笔」的情况。
预占 TTL 的回收:人为在预占后中断流程,验证TTL 到期后库存被释放且可以重新售卖。这类正确性用确定性用例覆盖,不用比率。
下单链路耗时:报预占阶段的 P95(同步部分,用户能感知的)和确认阶段的中位耗时(异步部分,决定「多久能给用户确定答复」)。这两个必须分开报,混成一个数会让人以为用户要等几十秒。
不要报「打包下单成功率 99.9%」。成功率高度依赖选品质量和供应商稳定性,不是编排设计的成果,报了会被追问「那是你做的还是供应商本来就稳」。可以报的是「预占阶段的失败能全额免费回滚」和「部分确认的发现时长」——这两个是我们能控制的。
打包产品的卖点就是比分开买便宜——机票 2000、酒店 1500、门票 300,分开买 3800,打包卖 3500。这 300 的优惠是打包存在的理由。
但它立刻带来一个问题:退改必须按子项算,而子项没有独立的成交价。用户说「门票我不去了,退掉」,退多少钱?按门票原价 300 退?那平台就亏了——用户付了 3500,退 300 剩 3200,可他实际享受的机票加酒店按打包逻辑本来不该是这个价。这是打包产品在资金上最容易出错的地方,也是分摊必须存在的原因。
第一版没有分摊,退改直接按分项原价算。结果是「部分退改的订单,平台侧的账算不平」——退给用户的钱加上留下的商品价值,和用户实付对不上。财务对账时能看到差异但说不清原因,因为差异藏在每一笔部分退改里,金额都不大但笔数不少。
所以第一个设计是按分项原价比例把打包优惠分摊到各子项:门票占原价的 300/3800,就分摊 300 × (300/3800) 的优惠,得到门票的分摊价。退门票就退这个分摊价。这样无论退哪一部分,账都是平的。
第二个必须处理的是尾差。比例分摊必然有舍入——三个子项按比例分完,加起来可能是 3499 或 3501 而不是 3500。分钱不能丢,也不能多。所以规则是:全程用最小货币单位的整数运算(不用浮点,这是金额计算的铁律),最后的尾差按固定规则归集到指定子项(比如金额最大的那个,或配置指定的那个)。关键是「固定规则」——不能随机,否则同一笔订单重算两次结果不同。
第三个是分摊结果必须随订单快照落库。因为分项原价是会变的(机票价格天天变),如果退款时重新按当前原价算比例,历史订单会算出不同的结果。这和退改规则要快照是同一个道理:凡是参与金额计算的规则和参数,都要在成交时刻固化下来。
第四个是一个很容易混淆的边界:分摊只影响对用户的退款,不影响给供应商的结算。给供应商还是按原来的采购价结算——供应商压根不知道也不关心我们做了打包优惠,那 300 的优惠是平台自己让出去的。这两套账必须分开,混在一起会导致给供应商少付钱,那是违约。
最后是一个纯产品问题:部分退改后,剩余部分还享受打包价吗?打包价的前提是「买这个组合」,组合被破坏了,严格说剩余部分应该按分项价重算并让用户补差。但这体验很差。所以做成可配置:宽松策略不补差(平台承担),严格策略要补差。
为什么用按原价比例分摊,而不是其他分摊方式。还有几种选择:平均分摊(每个子项分同样多优惠)、按毛利分摊(毛利高的多让)、指定分摊(运营手填)。选比例分摊的核心理由是「可解释」——用户问「为什么退门票只退 276 而不是 300」,答案是「门票占总价的比例是多少,优惠也按这个比例分」,这个解释财务、客服、用户都能接受。平均分摊在子项价格差距大时会很怪(300 的门票和 2000 的机票分同样多优惠,门票可能被分成负数);按毛利分摊对用户不可解释,而且暴露了采购价信息。取舍是比例分摊不一定让平台毛利最优,但可解释性在资金类设计里比最优性重要得多。
尾差为什么必须用固定规则而不是随机或就近。尾差通常只有几分钱,很容易觉得无所谓。但如果归集规则不确定,同一笔订单重算两次会得出不同结果,那么对账、客诉复核、退款重试全都会出现无法解释的差异。规则确定性比规则合理性更重要——归给谁其实都行,但必须每次都归给同一个。
踩过的坑:没做分摊,部分退改按原价退,平台侧的账算不平。退给用户的钱加上留下的商品价值,和用户实付对不上。差异藏在每一笔部分退改里,单笔金额不大但笔数不少,财务对账能看到总额差异但说不清原因。修法是引入分摊并把对账等式写成自动校验。教训是:组合销售一旦允许部分退改,就必须有分摊,这不是优化项而是必需项;而且对账等式应该在代码里自动校验,而不是留给财务月底发现。
踩过的坑二:退款时按当前分项原价重新算比例,历史订单算出不同结果。机票原价天天变,用户三个月前下的单,退款时按现在的原价算比例,得到的分摊价和当初成交时不一样,退多退少都有。客诉时我们自己都复算不出用户当时看到的金额。修法是分摊结果随单快照,退款一律读快照。教训是:凡是参与金额计算的规则和参数,都必须在成交时刻固化——这和退改规则快照、汇率快照是同一条原则,只要涉及钱且规则会变,就要快照。
踩过的坑三:用分摊价给供应商结算,少付了钱。有一次结算跑批误用了分摊价,供应商收到的钱比合同约定的采购价少,对方直接找过来。这不是 bug 是违约。修法是把两套账在代码层面彻底分开:用户侧的金额字段和供应商侧的金额字段命名区分清楚,结算链路不引用分摊字段。教训是:同一个业务对象上存在两套口径的金额时,命名必须能一眼区分,靠注释和约定迟早会有人取错字段。
没做的部分:没做「动态打包」——用户自选子项实时组包并实时算优惠。那需要把优惠规则从「运营预设的包」变成「可组合的规则引擎」,价格计算的复杂度和不可解释性都会大幅上升,当时只支持运营预设的固定包。
分摊一致性:这是确定性验证不是比率。写自动化校验:随机生成大量子项价格组合与打包价,断言分摊价之和恒等于打包价,包括极端场景(子项价格差距极大、打包价接近原价之和、优惠额很小导致某子项分摊为 0)。要主动说出覆盖了哪些边界,这比给一个通过率更有说服力。
历史订单可复算:构造场景——下单后修改分项原价,再发起退款,检查退款金额与下单时快照算出的一致。改造前会算出不同结果,改造后一致。
对账等式:把「用户实付等于各分摊价之和」「已退加未退等于实付」这些等式做成跑批后的自动校验,报的是「校验覆盖了哪几条等式、发现并拦下多少笔异常」。发现的笔数用绝对数,不用比率——十万笔的分母下任何比率都好看得没有意义。
尾差的量级:可以报单笔尾差的最大值(应该在一个最小货币单位以内)。这个数字的作用是证明尾差被控制住了而不是在累积。
不要报「金额准确率 100%」或「零资损」。绝对化断言一问就穿,而且只要出一笔就被推翻。正确表述是「分摊之和与打包价的一致性由整数运算加尾差归集在代码断言层面保证,对账等式自动校验,历史订单在分项调价后仍可复算一致」——说清机制和验证方式。也不要报高精度的小数,金额讨论里出现三四位小数本身就说明没在用最小货币单位整数运算。
没有匹配的内容,换个关键词试试。
项目拆解 · 打包产品与组合履约(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据