第一版是照着电商的商品模型做的:商品加 SKU 两层,每一个可售的规格组合就是一条 SKU 记录。电商里这么做完全正确——一件衣服的「红色 / L 码」是一个有库存、有条码的实体。
但餐饮不是这样。一杯奶茶要选杯型(中/大)、甜度(无糖/三分/五分/七分/全糖)、冰量(去冰/少冰/正常)、加料(珍珠/椰果/布丁,可多选)。光这四组,组合数就是 2 × 5 × 3 × 加料的子集数——加料三种就有 8 个子集,合计 240 种。再加一个规格组就翻几倍。这就是组合爆炸。
预生成 SKU 带来三个具体问题。一是数据量:一家奶茶店几十个商品,每个几百种组合,一家店就是上万条 SKU,几十万家商家的量级不可接受。二是维护成本:商家想加一个「常温」的冰量选项,所有相关组合都要重新生成一遍,而这中间任何一步失败就是数据不一致。三是价格维护:珍珠加 3 元这件事,要在几十条 SKU 上各写一遍,改价就要改几十处。
所以核心决策是组合不落库:模型只存「商品 + 规格组 + 选项」三层,用户选出来的组合在下单那一刻才校验和计价。这一个决定同时解决了上面三个问题——数据量从组合数降到选项数,加选项只是新增一条配置,价格只在选项上维护一份。
但组合不落库带来一个新责任:合法性必须由服务端校验,而且不能信任客户端。因为组合不是从库里选出来的,而是客户端提交上来的一组选项 ID。如果不校验,客户端可以提交任意组合——我们真的遇到过提交「不属于该商品的选项」来凑一个低价的情况。要校验的至少有四类:必选组没选、超出多选上限、互斥选项同时选、选项不属于该商品。
第二个必须处理的是价格计算方式。既然组合不落库,价格也不能按组合存,只能算出来:基价加各选项的增量。大杯加 4 元、珍珠加 3 元,都挂在选项上。这也让「所有大杯统一涨 1 元」变成改一个数字,而不是批量改几千条记录。
第三个是快照。商家改价、改规格、下架某个选项都很频繁。如果历史订单只存选项 ID,商家把「珍珠」删了之后,那张订单就再也显示不出用户买了什么;改价之后订单金额也复算不出来。所以下单时必须把规格组合的名称和价格明细快照落单。这和旅游那边的分摊快照、退改规则快照是同一条原则。
组合不落库是这个模块唯一真正重要的决策,它的代价要说清楚。收益是数据量、维护成本、价格维护三方面的量级改善。代价有两个:一是合法性校验的责任转移到了服务端,客户端提交的是一组选项 ID,服务端必须完整校验,这部分逻辑不能有漏;二是没法给某个具体组合单独定价——如果商家想让「大杯加珍珠」这个特定组合有个特价,基价加增量的模型表达不了。我们的处理是把这类需求引导到「套餐」上(把特定组合做成一个套餐商品),而不是给组合模型开一个例外口子。这个选择的理由是:一旦允许组合级定价,就等于又要落组合了,模型会退回原点。
为什么价格用「基价 + 增量」而不是「组合价」。组合价更灵活(每个组合可以任意定价),但它必然要求组合落库。基价加增量牺牲了灵活性,换来的是「改一处生效全局」——这对商家侧的运营效率影响很大,尤其连锁品牌批量调价。取舍的判断依据是:商家真实的调价行为绝大多数是「某个选项统一涨价」而不是「某个具体组合单独调价」,模型应该让高频操作变简单,而不是为低频需求保留通用性。
踩过的坑:照搬电商模型预生成 SKU,一家连锁店的菜单同步跑了四十多分钟还没跑完。商家在总部加了一个规格组,系统要为所有门店的所有相关商品重建组合,任务跑到超时被杀,重跑又从头开始,商家那边看到的是「保存了但没生效」。修法是改为三层建模、组合不落库。教训是:模型选错了,后面所有的性能优化都是在给错误的模型擦屁股——当时我们第一反应是优化那个同步任务(加并发、分批),做了才发现问题在模型,不在任务。
踩过的坑二:信任客户端提交的组合,被提交了不属于该商品的选项。有人发现选项 ID 是全局唯一的,于是拿一个便宜商品的选项 ID 配到贵商品上提交,服务端照单计价,得到了一个不该存在的低价。修法是校验每个选项必须属于该商品的规格组,并把这条加入回归用例。教训是:当「合法值的集合」不再由数据库结构保证时(组合不落库就是这种情况),就必须在应用层显式校验——外键约束能挡住的东西,去掉外键之后要自己挡。
踩过的坑三:只存选项 ID 不存快照,商家删了选项之后历史订单显示不出内容。商家把「布丁」下架并删除,之前所有加了布丁的订单,详情页那一行变成空白,客服处理退款时不知道用户买了什么。修法是下单时快照规格组合的名称与价格明细。教训是:订单是历史事实的记录,它不能依赖仍在变化的商品配置——这条原则我在旅游的分摊快照和退改规则快照上是同一个判断。
踩过的坑四:接受了客户端传来的价格。早期为了少算一次,下单接口接收客户端计算好的金额,结果被改包提交了一分钱的订单。修法是服务端一律重算,客户端传的价格只用于比对并在不一致时拒单(顺带能发现前后端计价逻辑不一致的 bug)。教训是:客户端传价格等于把定价权交给客户端,这是没有任何讨论空间的红线。
没做的部分:没做规格组合的销量统计与推荐(「大杯七分糖去冰」是最热组合,可以做一键复购)。这需要按组合聚合统计,而组合不落库让聚合变复杂,当时只做了商品级销量。也没做跨商品的加料共享(同一个「珍珠」被多个商品复用、改一次价全部生效),当时加料是挂在商品上的,连锁品牌反馈过这个需求。
菜单同步耗时:报改造前后的结构性对比——改造前新增一个规格组需要重建所有相关组合(我们遇到过跑四十多分钟还没完、超时被杀的情况),改造后是新增一条配置记录。「从重建数据到新增配置」这个对比比耗时数字更能说明模型选对了,因为耗时数字依赖菜单规模。
数据量:报同一家商家在两种模型下的记录数对比(组合数 vs 选项数)。这个对比是量级的,很直观。要说明商家的规格组配置,因为倍数随规格组数量变化。
组合校验:确定性验证,写自动化用例覆盖每一类非法组合:选项不属于该商品、必选组未选、单选组选多个、多选超上限、互斥同选、选项已估清。报的是「覆盖了哪几类非法组合」而不是通过率——列出类型比给一个百分比有说服力。
价格计算一致性:报服务端重算与客户端计算不一致的拦截次数。这个数字有双重价值:证明服务端重算在生效;不一致次数突增说明前后端计价逻辑出现了分叉,是个有用的告警信号。
历史订单可复现:确定性验证——下单后删除某个选项并改价,检查订单详情仍能完整显示规格组合与当时的价格明细。改造前会变空白,改造后正常。
下单接口耗时:因为组合校验和计价都放到了下单时,要主动报这一段的耗时,否则会被追问「不预生成是不是把成本转移到了下单链路」。诚实地给出校验加计价的 P95,并说明它相比查一条 SKU 记录多了多少。
不要报「菜单准确率 100%」。菜单内容由商家维护,错了大多是商家配错,不是系统问题。正确表述是「模型从组合级降到选项级、新增规格从重建数据变为新增配置、非法组合由服务端强校验并覆盖哪几类、历史订单由快照保证可复现」——四个可核查的事实。
连锁品牌的诉求是矛盾的两句话:「菜单要总部统一管,不能各店乱来」和「门店必须能有差异,因为各地成本和供应不一样」。这两句都对——品牌形象需要统一,但一线城市的门店成本高要卖贵一点,某个门店的供应链拿不到某种原料就得下架那个商品。
第一版的实现是总部下发即整体覆盖:总部改完菜单点下发,所有门店的菜单被替换成总部版本。门店自己调过的价格、自己下架的商品,全被抹掉。门店店长发现之后就不再信任系统,改成每次总部下发后自己再改一遍——而这又会在下一次下发时被抹掉,形成一个持续的对抗。有个区域经理直接在群里问「能不能别下发了」。
问题的本质和之前遇过的一样:一份数据有多个写入方(总部和门店),而它们在抢同一个字段。「谁后写谁赢」在有自动化写入方(总部批量下发)参与时,人(门店)永远赢不了。
所以核心设计是继承 + 分层:
三级继承链:品牌模板 → 区域 → 门店。区域这一层是必要的——连锁品牌的差异化通常是按区域来的(华东区统一一个价),如果只有「总部」和「门店」两层,区域级的调整就要在几十家门店上各操作一遍。
门店的覆盖项独立分层,并且按字段标记「继承」还是「覆盖」。关键在于字段级而不是记录级:门店可能只覆盖了价格,商品名称、图片、规格配置仍然跟随总部。如果按记录级处理,门店一改价就等于整条记录脱离继承,之后总部改图片这家店就收不到了。
下发只更新继承字段,不触碰覆盖字段。这是解决对抗的关键一步。
第二个设计来自另一类事故:总部一次误操作影响了几百家门店,而下发之前谁都不知道会影响多少。总部想给某个商品调价,操作时选错了范围,把一整个品类都下发了,几百家门店的几十个商品价格全变。发现时已经卖了两小时。
所以加了下发前的影响面预演:这次下发会影响多少门店、多少商品、其中有多少存在覆盖冲突(门店覆盖过但总部这次要改同一个字段)。把「不可见的批量影响」变成下发前的一屏确认。
第三个是下发的可靠性。几百家门店的下发不可能是一个事务,中间必然有失败(某个门店的数据有问题、某次调用超时)。第一版失败之后不知道哪些成功哪些没成功,只能整个重新下发一遍,而重新下发又会把中间门店自己的操作覆盖掉。所以要批次化:每次下发一个批次,逐店记录结果,失败的可单独重试。
最后一个容易被忽略但很影响信任的点:界面上要能区分「这个值是继承来的」还是「本店覆盖的」。门店店长必须能一眼看出哪些是自己改过的,否则他不知道总部下发会不会影响自己。
字段级继承 vs 记录级继承,这是这个模块最实质的设计选择。记录级实现简单(一个标志位表示这条记录是否脱离继承),但它把继承变成了全有或全无:门店只想改个价格,结果整条记录脱离总部管理,之后总部更新商品图片、修正商品描述、调整规格配置,这家店全都收不到,日积月累门店菜单会和总部严重分叉。字段级的代价是存储和实现复杂度(要为每个可覆盖字段维护继承标记,合并读取时要逐字段判断),以及读取性能(一次菜单查询要合并三层数据)。缓解手段是把合并结果缓存成门店的「有效菜单」,继承层或覆盖层变更时失效。取舍很划算:复杂度是一次性的,而分叉是持续恶化的。
为什么保留「强制覆盖」这个能力,而不是绝对保护门店覆盖项。看起来「门店改过的就永远不动」最安全,但它会让总部失去管控能力——品牌统一调价、合规要求下架某个商品,这些必须能盖掉门店的设置。所以强制覆盖必须存在。关键是让它成为显式选择而不是默认行为:预演时把冲突项列出来,总部逐项或整体决定。这个设计的思路是「不禁止危险操作,但让它无法在不知情的情况下发生」——和审批引擎里「干预能力要有但需二次确认加留痕」是同一个思路。
踩过的坑:总部下发整体覆盖,门店的本地调价被反复抹掉,最后形成对抗。门店店长每次总部下发后自己再改一遍,又在下一次下发时被抹掉,有个区域经理直接在群里问「能不能别下发了」。这个坑最值得讲的是它的表现形式——它不是一次崩溃,而是系统与使用者之间的信任被慢慢磨掉,最后表现为「大家都绕过系统」。修法是分层加字段级继承。教训是:当系统的行为让用户的工作被反复浪费时,用户不会来提 bug,他们会放弃这个功能,所以这类问题很难从工单里发现,要靠观察使用数据(比如门店的重复修改率)。
踩过的坑二:总部选错下发范围,几百家门店几十个商品的价格被改,卖了两小时才发现。操作者以为只选了一个商品,实际选中了整个品类。问题在于下发前完全看不到影响面——点击下发之后就直接执行了。修法是加影响面预演。教训是:批量操作必须在执行前把影响面量化并要求确认,「影响 3 家门店」和「影响 400 家门店」应该给操作者完全不同的心理提示,而系统有责任把这个数字摆在他面前。
踩过的坑三:下发部分失败后只能整批重发,重发又覆盖了中间门店的操作。一次下发中有几十家门店失败,我们重新下发全部门店,而那期间有些成功的门店已经做了本地调整,重发把它们又盖了一遍。修法是批次化加逐店结果记录,失败的单独重试。教训是:批量操作必须记录每个个体的结果,否则失败后唯一的补救手段就是全量重做,而全量重做在有并发写入的场景里会造成二次伤害。
踩过的坑四:界面上看不出哪个值是继承的、哪个是覆盖的。店长改了价之后不知道自己已经脱离了总部对这个字段的管理,后来总部做了促销价下发,他没收到,还以为系统漏了。修法是界面上明确区分并提供「恢复继承」。教训是:继承类的模型必须把继承关系可视化,用户看不见的机制等于不存在,他会用自己的模型去理解系统,然后困惑。
没做的部分:没做门店覆盖的有效期(临时调价,到期自动恢复继承)。门店反馈过这个需求(做活动临时降价,活动完忘了改回来),但需要定时任务和覆盖项的生命周期管理,当时没做。也没做继承冲突的自动建议(「八成门店都覆盖了这个价格,建议总部调整模板价」),只做了数据展示。
门店覆盖项是否被下发覆盖:确定性验证。构造场景——门店覆盖某商品价格,总部下发同一商品(改的是名称),检查门店价格保持、名称跟随更新。这个用例必须精确到字段级,只验证「记录还在」是不够的。
门店重复修改率:这是个很有意思的指标,用来度量前面那个「信任被磨掉」的问题——同一个门店对同一个字段在短期内反复修改的次数。改造前这个数很高(每次下发后店长都要改回来),改造后应该显著下降。它比任何自述都能证明对抗消失了。
影响面预演的作用:报预演后被操作者主动取消的下发次数。这个数字直接证明预演拦住了误操作——「有 N 次下发在看到影响面后被取消」比「我们加了预演功能」有力得多。
下发的成功率与重试:报逐店成功数、失败数、失败原因分布、重试后成功数。关键是要能报出「失败的门店可以单独重试」这个能力,而不只是一个成功率。
菜单读取耗时:因为字段级继承要合并三层数据,必须主动报这一段的耗时,否则会被追问「三层合并不慢吗」。给出合并后缓存的命中率与未命中时的 P95,并说明缓存的失效策略。
覆盖率分布:报各字段被门店覆盖的比例。这个数据对业务有直接价值——如果某个价格字段八成门店都覆盖了,说明总部的模板定价不合理,该改的是模板而不是让门店各自改。
不要报「菜单一致性 100%」。门店差异化是业务要求的功能而不是缺陷,追求一致性本身就是理解错了。正确表述是「门店覆盖项在下发中的保留由字段级用例保证、门店重复修改率的下降、预演拦下的误操作次数、下发失败可逐店重试」。
「这个商品现在能不能买」看起来是一个布尔值,实际它由四个独立条件共同决定:门店在营业时间内、当前在可配送时段内、商品自身的售卖时段覆盖当前、商品没有被估清。任何一条不满足都不能买,而每一条的数据来源和变化频率都不同。
第一版最大的问题是判定逻辑散落在三个地方:列表页为了性能自己写了一套简化判断(只看营业时间),详情页写了一套完整点的,下单接口又写了一套。三套逻辑必然分叉,用户的体验是:列表里看得到、点进详情能加购、点下单被拒绝。这种「一步步往前走然后在最后一步被拦」是最挫伤转化的形态,而且客服解释不清。
所以第一个设计很朴素但很关键:把可售性判定收敛为单一入口,列表、详情、下单三处调用同一份逻辑。性能问题用批量接口和缓存解决,而不是用「简化版逻辑」解决。这一条是这个模块里最容易被忽略却收益最大的改动。
第二个是估清的实时性。估清(sold out)是餐饮特有的高频操作——厨房发现某个菜的原料用完了,店员在商家后台点一下估清。这个操作对实时性的要求极高:如果生效慢了几分钟,这期间下的单商家做不出来,只能打电话给用户道歉退款,而配送单可能已经派给骑手了。
第一版估清只写库,用户侧读的是列表缓存,缓存 TTL 是几分钟,也就是估清后最长几分钟内用户还能下单。修法是双通道:估清时直接写缓存(让用户侧立刻看到)加发变更事件(让依赖方如搜索索引同步更新)。两条通道互为兜底——缓存直写快但可能失败,事件可靠但稍慢。
第三个,也是必须承认的一点:无论多快都会有窗口,所以下单时必须最终校准。用户可能在页面停留了十分钟,期间商品被估清。所以下单接口必须重新判定一次可售性,而且以服务端判定为准,不信任客户端也不信任列表缓存。这和旅游那边「下单前二次确认价格」是完全同一个思路——展示可以用缓存,成交必须校准。
第四个是时段规则本身的复杂度,这里有两个真实踩过的坑。
跨天时段。夜宵菜单是「22:00 到次日 02:00」,如果简单地用「开始时间 ≤ 当前 ≤ 结束时间」判断,凌晨 1 点会判为不在时段内(因为 1 < 22)。夜宵商品在最该卖的时候卖不出去,商家投诉了才发现。
商家本地时区。连锁品牌跨时区经营时,「早餐时段 6:00-10:00」是商家当地的 6 点还是服务器时区的 6 点?第一版用服务器时区,导致某些门店的早餐菜单在错误的时间上下架。正确做法是时段一律按商家所在时区判定。
最后是营业时间与配送时段要分开。它们经常不同——到店可以营业到 22 点,但外卖只配送到 21 点(骑手要收工)。混成一个字段就无法表达这个业务事实。
判定收敛为单一入口,代价是列表页要为可售性付出额外开销。第一版之所以在列表写简化逻辑,就是因为完整判定要读估清状态、时段配置、营业配置,一次列表几十个商品就是几十次判断。收敛之后的解法不是回退到简化逻辑,而是做批量判定接口加缓存:一次请求把这一屏商品的可售性批量算出来,结果短 TTL 缓存。取舍是列表接口的耗时略增,换来的是三处结论一致。我认为这个取舍毫无疑问值得,因为「列表看得到但买不了」的转化损失远大于几十毫秒。
估清用「缓存直写 + 事件」双通道而不是只用一条。只用缓存直写:快,但缓存写失败就完全没生效,而且搜索索引不知道。只用事件:可靠,但要经过消息队列和消费者,延迟通常在秒级以上,而估清恰恰是最需要立刻生效的操作。双通道的代价是要处理两条路径的一致性(缓存写成功但事件消费失败、或反过来),缓解手段是事件消费时以数据库为准做一次校准,让事件通道成为最终一致的兜底而缓存通道负责即时性。
踩过的坑:三处判定逻辑分叉,用户「下单才被告知已售完」。列表为了性能只看营业时间,详情看得多一点,下单最完整。用户体验是一步步往前走,最后一步被拦,客服也解释不清为什么列表还显示着。修法是收敛为单一入口。教训是:同一个业务判断在多处实现,必然分叉——不是「可能」而是「必然」,因为需求变更时不可能每次都记得改三处。凡是发现「同一个问题有多个版本的答案」,第一件事应该是合并而不是分别修。
踩过的坑二:跨天时段判错,夜宵菜单在凌晨卖不出去。判定用的是「开始时间 ≤ 当前时间 ≤ 结束时间」,而夜宵是 22:00 到次日 02:00,凌晨 1 点时 1 < 22,直接判为不在时段。商家投诉「我明明配了夜宵为什么客人买不到」才发现。修法是结束时间小于开始时间即识别为跨天,拆成两段判定。教训是:涉及时间区间的判断必须覆盖跨天用例,而跨天是个很容易在测试时想不到的场景(白天测试永远测不出来)。
踩过的坑三:时段用服务器时区判定,跨时区门店的早餐菜单在错误时间上下架。连锁品牌的门店分布在不同时区,「早餐 6:00-10:00」被按服务器时区算,某些门店的早餐在当地凌晨就上了、上午就下了。修法是时段一律按商家所在时区判定,门店配置里必须有时区字段。教训是:任何面向本地业务的时间判断都要问一句「谁的时间」——这条在旅游的「距入住 24 小时」、在结算的「账期日」上都是同一个问题。
踩过的坑四:估清不会自动恢复,店员忘了取消导致商品整天卖不出去。晚上原料用完点了估清,第二天开店忘了取消,那个商品一整天零销量,商家到晚上核对营业额时才发现。修法是估清默认在次日开店时自动恢复,同时保留「长期下架」作为另一个独立操作。教训是:临时性状态必须有自动恢复机制,依赖人去撤销一个临时状态,一定会有忘记的时候,而忘记的代价全部落在商家身上。
没做的部分:没做基于库存的自动估清(对接商家的进销存,原料不足自动估清相关商品)。这需要商品与原料的配方关系,多数商家没有维护这个数据。也没做估清的预测提醒(「这个商品按当前售速一小时后会售完」),只做了手动估清。
「下单才被告知不可售」的比例:这是这个模块最该报的指标。报下单被可售性校验拒绝的次数占下单请求的比例,改造前后对比。但要诚实说明它不可能归零——用户在页面停留期间商品被估清是真实场景,能消除的是「逻辑分叉造成的」那部分,不能消除「时间窗造成的」那部分。把两者区分开报,比给一个总的下降幅度更可信。
估清生效时长:报从商家点击估清到用户侧不可见的时长,分两条通道分别报(缓存直写路径、事件路径)。改造前是缓存 TTL(几分钟),改造后是缓存直写的耗时。这个对比很直观。
三处判定的一致性:确定性验证——构造各种不可售场景(未营业、不在配送时段、不在售卖时段、已估清),检查列表、详情、下单三处的结论完全一致。这类正确性用确定性用例覆盖,而且要每个条件各测一遍。
跨天时段与时区:确定性验证,而且要写成固定用例——夜宵时段在凌晨 1 点判为可售、早餐时段按商家时区判定。这两个用例必须常驻回归,因为它们在正常开发时段测不出来。
列表接口耗时:因为收敛判定后列表要付出额外开销,必须主动报,否则会被追问。给出批量判定的 P95 与缓存命中率,并说明「用批量加缓存而不是简化逻辑」这个选择。
估清自动恢复:报自动恢复的次数,以及「因未及时取消估清导致整天不可售」的投诉是否消失。前者证明机制在跑,后者是业务结果。
不要报「可售性判定准确率 100%」。估清和时段配置由商家维护,商家配错了不是系统不准;而时间窗造成的偏差是物理存在的。正确表述是「判定收敛为单一入口后三处结论一致(由用例保证)、估清生效时长从缓存 TTL 降到直写耗时、跨天与时区由常驻回归用例覆盖」——说清机制与验证方式。
没有匹配的内容,换个关键词试试。
项目拆解 · 商家菜单与商品中台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据