優惠與金額:折扣算錯,比超賣還難查
· tech
#war-story#live-commerce#pricing
📑 目錄
超賣會炸、金流會叫,折扣算錯不會有任何聲音——客人多付了 13 元不會知道,少收了 13 元你也不會知道,直到對帳那天一筆一筆挖。這章講優惠:規則怎麼收納、聰明演算法怎麼輸給主播的一句話、以及讓分攤永遠加總相等的笨算術。
優惠的地圖:規則、慣例、實驗
當年的優惠分三個棲息地:
- 需要券的,收斂成三個正交參數:效果(折抵/免運)× 門檻(滿額多少,外加「哪些商品算進額度」的白名單)× 範疇(特定檔期/多個檔期/全檔期合算)。新券=填一組參數,不是寫一段 if——檔期第四次成為系統的自然邊界:券的生效範圍,直接用檔期表達。
- 不需要券的多件優惠(買多少送多少),掛在商品上,一個商品可以掛多種——它的套用順序是下一節的主角。
- 分類不了的,進 admin 試驗場:各種買 A 送 B 的花式組合,實驗性緊急套用——上一章的成熟度光譜,在優惠這裡有了最頻繁的用例:行銷的點子永遠比規則表快,試驗場讓點子先跑,站穩了才值得一個正式參數。
最優解輸給了順序
多件優惠可以疊,一個商品掛多種——那客人的購物車該套哪個組合?當年的第一版答案很工程師:寫一個最優惠組合演算法,幫客人算出全場最省的套法。這是組合優化,朝著 NP-hard 的方向長,但商品數不大,算是算得動。
然後主播說:不要。按照順序,每次採最大扣除就好。
我很久之後才真正理解這個要求有多對。最優解的問題不在算力,在它的答案沒辦法對人解釋:
- 不穩定:客人多加一件商品,整個最優組合可能重排——螢幕上的折扣數字跳來跳去,客人不會覺得你聰明,只會覺得被坑。
- 不能口播:主播在鏡頭前要能一句話講清楚規則。「按順序、每次挑折最多的」講得出口;「我們的演算法會為您求解全域最優」講出口就是客訴。
- 不單調:最優解下,多買有時反而讓某個舊折扣消失——「我多買一件,那邊怎麼變貴了」是客服解釋不完的災難。
主播要的規則精確地說是:優惠有固定順序,依序一個套到滿、再換下一個——先把買 5 送 3 套到不能再套,才輪到買 3 送 2。答案穩定、單調、可預期——它不是全域最省,但每一步都看得懂。這跟狀態機被主播拔掉是同一族的故事:工程師寫了聰明的,現場要求換笨的,而現場是對的。收一句:演算法的正確性標準由使用情境定義——直播的「正確」,是客人聽得懂、數字不跳。
岔題,但值得:它到底是不是 NP-hard?
砍掉它之前,這個演算法值得一次誠實的複雜度鑑定——答案分三層,而且最深的一層跟複雜度無關。
第一層:單一商品、單一價格——這是無界背包,不是 NP-hard。 把問題寫出來:客人下單付費 件;「買 送 」每套用一次,用掉 件付費商品、額外多送 件;每件付費商品只能被一個優惠算一次。目標是最大化送出件數 ,限制 。這是無界背包(找零錢問題的親戚):形式上 weakly NP-hard,但有 的 DP—— 是購買件數,現實裡是幾十,瞬間解完。有趣的是,greedy 在這個最簡單的情境就已經不是最優。拿「買 3 送 2」和「買 5 送 3」來算,greedy 依主播的順序先套大的:
- :買 5 送 3(用掉 5 件)+ 剩 3 件套買 3 送 2——送 5 件;最優也是 5。打平。
- :greedy 套買 5 送 3、再套買 3 送 2,剩 1 件閒著——送 5 件;最優是買 3 送 2 套三次——送 6 件。
greedy 輸的原因跟找零錢一樣:送得大方的優惠,「每付一件的送率」不一定高——買 5 送 3 的送率是 3/5=60%,買 3 送 2 是 2/3≈66.7%,而勝負常常取決於尾數(那 1 件掛空的付費商品)。所以精確地說,當年的 greedy 從來不是「近似最優」,它就是「規則簡單」——而這正是它被選中的理由,不必替它冠上最優的名。
而 DP 解這題有多便宜?設 為用 件付費商品的額度最多能送幾件:
答案是 ,時間 。拿 n=9 走一遍:
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 2 | 2 | 3 | 4 | 4 | 5 | 6 |
,正是「買 3 送 2 套三次」。但這張表藏著一個更重要的觀察:看 ——最優組合從「買 5 送 3」整組換成「買 3 送 2 × 2」。客人多加一件商品,螢幕上的優惠組合整個重排。DP 給你最優值,但最優解的組合會隨 n 跳動——這是最優性本身的性質,跟用什麼演算法無關。 可解釋性的問題,DP 解不掉:就算計算免費,當年砍掉最優解仍然是對的。
第二層:同一件商品有好幾種價格——「最優」的定義開始漂。 商品有直播價、商城價、style 各自的價格,cart item 上還有一個「特殊價」欄位給特殊優惠用——同一台購物車裡,同一件商品可能多種價格並存。優惠套下去,免的是哪一件?扣除額按哪個價算?當年沒有明確定義「哪個價一定最便宜」——此時最優解演算法最脆弱的地方不是算力,是規格:連「最優」都沒有唯一定義,演算法是在最佳化一個沒有人簽名的目標函數。而且未定義不只讓答案漂,它讓空間爆炸:在優化器眼裡,每個未定義處都是一個變數——m 種價格解讀 × n 件商品,就是 種世界,每一種世界還要各自解一次分組最優再互比。規格少寫一句話,搜尋空間就多乘一倍;greedy 按順序套用,本質是用定義消滅變數——順序是常數、每步規則是常數,空間坍縮成一條直線。它省的不是 CPU,是規格債。
第三層:跨商品的組合,才是 NP-hard 真正的門檻。 常規的多件優惠不跨商品,券又跟非券互不影響(獨立兩層)——所以常規問題其實整個可解。但 admin 實驗層的花式買 A 送 B 一旦跨商品,加上「每件只能被佔用一次」的互斥,問題就變成 weighted set packing:從所有可能的「優惠應用」(每個是一組帶權的商品集合)裡選出互不重疊的組合、最大化總折扣——strongly NP-hard,沒有 DP 可救。當年「應該是 NP-hard」的直覺,對的就是這一層。
「還能不能 DP」其實有一條好記的判準:看新條件問的是「多少件」還是「是哪幾件」。 只改變數量或金額的條件,DP 加一維就活(滿額門檻是這類);開始需要知道「是哪幾件」的條件(送最便宜、每件有自己的特殊價、跨商品綁定),單位失去可互換性,狀態就壓不動了——DP 死於異質性,不是死於規則數量。而工程上的死亡通常更早:每個新條件都逼你重新設計狀態、重新證明正確性,在理論宣判之前,維護成本已經先執行了死刑。
三層看完,greedy 各贏一次:第一層它輸最優、贏簡單;第二層它用順序定義了語意——「按順序、每次最大扣除」同時回答了「怎麼算」和「算什麼才算對」,在價格語意模糊的地方,過程就是規格;第三層它讓花式優惠不會把整台購物車捲進組合爆炸。所以砍掉最優解的理由,按重要性排序是:解釋不動 > 定義不清 > 算不動——算力反而是最不重要的那一個。
錢往哪放:三層各有欄位
優惠算完的結果,直接在 orders payment、order、order item 上開欄位——哪層合適放哪層。這不是隨性,是三層結構的正確用法:錢的欄位跟著它的語意住——商品自己的多件優惠寫在 item、檔期限定券寫在 order(它的範疇就是檔期)、全檔期合算的寫在 payment。金額一經結帳就定格(承諾點原則),發票、退款、對帳全部站在定格值上。
整數、捨去、減法:分攤的算術
金額全部用整數算——這是錢的程式碼的第一戒律。真正的考驗在分攤:一張檔期券折了 100 元,攤在兩件商品上;客人退其中一件,該退多少?
規則只有兩條:按比例算出的優惠後價格,無條件捨去小數(零頭讓給客人,公司吸收——爭議永遠往對客人有利的方向倒);最後一份不算比例,用減法補到總額(總和恆等是用結構保證的,不是用測試保證的)。這是 largest-remainder 分攤法的務實簡化,而且當年立了個好規矩:類似的情境一律照此處理——分攤算法全系統只有一種,對帳的人只需要理解一次。
重來會怎麼做
這章的當年設計大半保留:三軸參數表、admin 實驗層、固定順序套滿、floor-and-subtract 全是對的。重來的清單不長:
- 價格不是函數,是現場的決定。 直覺的修法是釘死一條價格解析優先序(特殊價 > 來源價 > style 價),把 的搜尋空間用規則消滅——但這是錯的:價格的優先序是主播的自由心證,系統管不了,也不該管。直播定價本來就是現場的藝術:貨是她談的、價是她喊的、要不要給這個客人特殊價是她的生意判斷。重來的正解不是消滅這個自由,是給它一個乾淨的落點——特殊價欄位就是「現場決定」的容器,系統的職責是把決定記成事實(誰、何時、定了什麼價),不是替現場推導價格。搜尋空間的變數,不是被規則消滅的,是被「定價那一刻」消滅的——人做出決定,變數就坍縮成常數。機制歸系統、政策歸人,又一次。
- 套用順序給明確的把手。 優惠掛明確的順序欄位,主播/營運指定;演算法簡化到極致——依序、逐一套滿,連「每步挑最大」都不需要,順序本身就是全部的規則:可口播、可預測、要改就改順序,不用改版。
- 分攤規則從慣例升級為一個 class。 floor-and-subtract 當年是「類似情況一律照此」的慣例;重來收斂成單一的 allocation policy class(Clean Architecture 的語意:它是一條領域規則,值得一個名字和一個家),配一條 property test——任何輸入,分攤加總恆等於整體。慣例會隨人員流動而漂,class 加測試不會。
- 發券前先 dry-run。 優惠引擎是純函式的形狀(購物車+規則 → 結果),天生可離線重放:新券上線前拿歷史訂單跑一輪,先看「這張券大概會花多少錢」——庫存章那次 migration 事故的教訓,同一招預防:動錢的規則,上線前先看數字。
- 花式買 A 送 B 不正式化。 規則表達力越強,規則引擎越接近一門程式語言;維持「參數表收斂已證明的、admin 隔離實驗的」雙層,就夠了。
反思
聰明演算法的墳墓,是解釋成本
最優組合演算法是我們寫過技術含量最高的程式碼之一,也是被砍得最快的之一。它輸給 greedy 的原因,值得每個工程師背下來:演算法的總成本=計算成本+解釋成本,而面向消費者的系統裡,解釋成本幾乎總是大頭。客人問「為什麼是這個折扣」時,客服要能答、主播要能講、工程師要能查——最優解在這三關全滅。這是主播第三次教我們設計(狀態機、重喊、greedy),而三次的教訓是同一條:現場智慧的核心是可解釋性,系統要嘛遷就它,要嘛被繞過。
參數化你會重複的,隔離你不確定的
優惠系統最怕長成 if 海——每檔活動加一段特判,兩年後沒人敢動。當年的結構避開了它:會重複的模式(滿額×範疇×效果)收斂成參數表,新券=填表;不確定的點子(花式買 A 送 B)隔離進 admin 試驗場,爛了就丟、站穩了才升格。規則引擎的真義不是「能表達一切」——是把重複的變便宜、把實驗的變安全。兩層各司其職,行銷的創意速度和系統的可維護性才能同時活著。
錢的正確性是會計性質,不是數學性質
floor-and-subtract 在數學家眼裡很醜:分攤不按精確比例、最後一份用減法硬補。但錢的程式碼要回答的從來不是「分得準不準」,是「加總相不相等、零頭歸不歸屬、事後查不查得清」——這是會計的標準,不是數學的標準。0.1 元分給誰根本不重要,重要的是每一分錢都有主、退款永遠退得出一個明確的數、對帳永遠對得平。錢的程式碼,優雅讓位給對帳——這句話值得貼在每個電商工程師的螢幕上。