留言即下單:把聊天室變成下單通道

· tech

#war-story#live-commerce#fsm

📑 目錄

全景起手式鋪完,進主線第一戰:一則留言,怎麼變成一筆單。這章講「接進來」和「解析」兩段,佔庫存的攻防留給下一章。主角是把聊天室變成收銀機的那台有限狀態機——我們會直接讀它的程式碼。

當年的管線:一條迴圈,吃下三個平台

留言來自三個源:FB、IG、自建直播間,流量比例大約 100:10:1——FB 是絕對主戰場。三源的取得方式各不相同(全景篇提過的 webhook、輪詢、自家直推),但殊途同歸:全部落進同一張 message 表,由同一條批次處理鏈消化。所以這章的抓取故事以 FB 的輪詢為主——流量在這裡,坑也都在這裡。當年的接法樸素到有點可愛:

FB(100)・輪詢 IG(10)・webhook 自建(1)・直推 取回:各源各自的接法 FB 輪詢自適應:忙續抓、閒放慢 殊途同歸進同一張表 清洗 → 落地 統一 message 格式 append 去重鍵:source+message id batch 200・FSM 解析 → 查 bidding key 扣賣出數量+建立身分 raw 有落地・FB 可整場重抓——但從沒被動用 處理失敗 → 直接略過,沒有補救路徑 一切為了主播眼中最新鮮的庫存狀態;代價:尖峰 lag 可達幾分鐘、掉單無聲
當年的留言管線:三源各自取回、清洗落地,同一條批次鏈解析下單。兩處虛線是這章要算的帳。

三個細節值得放大:

  • FB 的輪詢,是一個自調的速率控制器。 一輪抓完看耗時:超過 2 秒代表留言正湧入,帶著 paging key 立刻接著抓;低於 2 秒代表場子冷,把間隔放慢一點。不用外部設定、不用監控儀表,迴圈自己感知流量、自己調速——小團隊憑直覺做出來的東西,事後看是很標準的 adaptive polling。要付的代價在下游:三源殊途同歸之後擠在同一條批次處理鏈上,FB 爆量時,IG 和自建的單也跟著在佇列裡排隊——FB 的洪峰,是所有人的延遲。
  • 去重鍵選對了:source + message id 輪詢一定會抓到重疊區間,靠平台原生的留言 ID 當唯一鍵,重抓幾次都不會重複入庫——這是接入層的冪等,做對了後面全順。
  • 落地即保險。 留言清洗成統一格式落地,raw 原文也跟著留了下來;而且 FB 的直播留言就是貼文底下的留言,事後還能整場重抓(順序會和直播中抓到的稍有出入)。事實層的保險,當年買得比記憶中齊全——這份保險最後有沒有派上用場,是這章結尾要算的帳。

解析:一個為拇指設計的迷你語言

客人在直播裡打的不是指令,是搶時間的縮寫。文法長這樣:2601+1 是 key 2601 下單 1 件;商品有款式(顏色、尺寸)時,2601藍+1紅+2 一句話對同一個 key 下兩種款式各自的數量——key 只打一次,款式接力,因為在搶單的幾秒裡,少打三個字就是贏。一句留言也可以下多個 key。

解析這個語法,當年沒有用 regex,而是手寫了一台逐字元的有限狀態機(節錄,註解是我現在加的):

class CommentsFSM:
    class State(Enum):
        KEYWORD = "keyword"
        STYLE = "style"
        NUMBER = "number"
        DETERMIN_KEYWORD_OR_STYLE = "determin_keyword_or_style"
        ERROR = "error"

    def __init__(self) -> None:
        # 狀態 → handler 的 dispatch table:主迴圈永遠只有一行
        self.fsm: dict[CommentsFSM.State, Callable[[str], None]] = {
            self.State.KEYWORD: self._handle_keyword,
            self.State.STYLE: self._handle_style,
            self.State.NUMBER: self._handle_number,
            self.State.DETERMIN_KEYWORD_OR_STYLE: self._handle_determin_keyword_or_style,
            self.State.ERROR: self._handle_error,
        }
        self._reset()
        self.separate_chars = set([" ", ",", ",", "、"])  # 半形全形逗號、頓號都算分隔

    def parse(self, text: str) -> dict[str, int]:
        self._reset()
        for c in text:
            self.fsm[self.state](c)              # 逐字元,一個字一步
        if self.current_number:
            self._add_result()                   # 收尾:結尾停在數字上
        return {k: q for k, q in self.results}   # 同 key 重複 → 最後一筆勝

    def _handle_keyword(self, char: str) -> None:
        if char.isdigit():
            self.current_str += char             # key 只累積開頭的連續數字
        elif char == "+" or char == "+":        # 全形加號也是加號
            self.keyword = self.current_str
            self.state = self.State.NUMBER
            self.current_str = ""
        else:
            self.keyword = self.current_str      # 其餘字元轉進款式
            self.state = self.State.STYLE
            self.current_str = char

    def _add_result(self) -> None:
        try:
            quantity = int(self.current_number)
            if quantity > 0:                     # +0 沒有意義:喊單禁止取消
                self.results.append((self.keyword + self.current_str, quantity))
        except ValueError:
            self.results.clear()                 # 中段壞掉:整則作廢
            self.state = self.State.ERROR
            return
        self.state = self.State.STYLE            # 收尾路徑用;途中結帳會被 _handle_number 蓋成 DETERMIN
        self.current_str = ""
        self.current_number = ""

(節錄:省略了 _reset 和 STYLE、NUMBER、DETERMIN、ERROR 四個 handler——它們做的事都畫在下面的狀態圖裡。)

KEYWORD STYLE NUMBER DETERMIN 下一段是新 key 還是款式? ERROR 非數字→款式 + / + 非數字:先記一筆 數字→新 key 其他→續款式 數字壞掉→整則作廢 「2601藍+1紅+2」 → { 2601藍: 1, 2601紅: 2 } key 只打一次,款式接力;產出複合字串,拿去查 bidding key 表
FSM 的狀態轉移:KEYWORD 收數字、STYLE 收款式、NUMBER 收數量;DETERMIN 決定接力還是開新 key。

這台機器有幾個藏在細節裡的設計,值得一條一條拆:

  • 解析器不判斷「這是不是下單」——資料庫才是驗證器。 FSM 產出的是 keyword + style 拼起來的複合字串(2601藍),直接拿去查 bidding key 表(主播開賣時 start bidding API 已把所有複合 key 寫進 DB)。所以解析器可以放心寬鬆:閒聊留言沒有 +數字,解出空結果;就算解出 讚+1,查不到「讚」這個 key,自然丟棄。寬鬆的解析器+嚴格的查表,把「判斷下單意圖」這個模糊難題,收斂成一次精確的 lookup。
  • 單則留言內建 LWW。 最後一行的 dict 收斂,讓 2601+1 2601+3 自動只剩 +3——同一句話裡改主意,免費處理。
  • 台灣輸入法的現實全接住了:全形 有特判、分隔符收了空格和中文逗號頓號;而全形數字 12 能動則是個彩蛋——Python 的 isdigit()int() 原生就接受全形數字,當年可能根本沒人知道自己支援了這個。
  • +0 被靜默濾掉——因為喊單禁止取消,這是 feature。 直播喊單的張力就是「留言即承諾」,允許 +0 反悔,搶單就失去意義,還會被拿來惡意反覆佔庫存。要退?那是購物車和逾期釋放的事,不是留言的事。
  • 中段壞掉整則作廢、尾巴殘缺前段保留。 數字解析失敗會進 ERROR、把整則已解出的結果清空——整句可疑就寧可不下;但 2601藍+1紅+ 這種尾巴斷掉的,前面的 2601藍+1 照收。不對稱,但目標一致:盡量配對成功,可疑就整句放棄
  • 連「字母 key」都有解——而且不用改程式。 FSM 的 keyword 只認開頭數字,那想用 A01 這種 key 怎麼辦?把 product 的 keyword 留空、完整字串放進款式欄——複合字串照樣拼得出來、照樣查得到。文法的限制被資料層吸收掉了,又一次 single source of truth 的紅利。

寫法的三個精妙處

上面講的是「它做了什麼決定」,這裡講「它寫得好在哪」——這台機器有三個值得偷學的手法:

  1. 狀態表就是程式的骨架。 dict[State, Callable] 的 dispatch table,讓主迴圈永遠只有一行 self.fsm[self.state](c)——沒有 if/elif 森林。文法規則、狀態轉移圖、程式結構三位一體:review 時拿著轉移圖就能逐格對程式碼,加一個狀態=加一個 entry、一個 method,不會碰到既有邏輯。
  2. 單趟、零回溯,靠「轉手」做到一格前瞻。 每個字元恰好被讀一次,O(n)、沒有 regex 最壞情況的 backtracking。最漂亮的是 _handle_number 遇到非數字那一刻:先結帳、把狀態切到 DETERMIN,然後把同一個字元原地轉交給新狀態的 handler 再處理一次——效果等於向前看了一格,卻不需要 pushback buffer、不需要偷看下一個字。這是手寫 lexer 的標準招式,而它是被直覺寫出來的。
  3. 狀態少到可以用眼睛驗證,測試也真的到位。 整台機器的可變狀態只有三個字串加一個 enum;parse() 是純函式的形狀——字串進、dict 出,單元測試一行一個 case。而且當年它就被大量測試案例罩著——敢手寫解析器、敢在直播季一直改它,底氣就是這層保護。進了 ERROR 之後,每個後續字元都持續清空結果,壞掉的留言不可能漏出半筆——fail-closed 用最笨、也最不會出錯的方式做到了。

重來,解析器會改嗎?

核心不會。單趟 FSM+寬鬆解析+DB 驗證,對這個文法規模就是最適解——regex 到這個複雜度已經不可維護,parser generator 又是殺雞用牛刀。測試也不用從零開始:當年就有大量測試案例守著它。重來要補的,是三種當年還沒有的保護:

  • 把文法的邊界寫成測試。 款式名不能以數字開頭——2XL 這種尺寸,開頭的 2 會被 DETERMIN 判成新 key 的起點,解出錯的複合字串。當年的逃生門是建檔時把 keyword 留空、完整字串放款式欄,配對照樣成立;但這條規則只活在大家的默契裡。重來,它要變成建檔時的驗證與解析器的固定測試案例——默契不會隨團隊擴編而複製,測試會
  • 語法與語意分層。 +0 濾掉(禁止取消)、中段作廢(可疑不下)是產品規則,現在埋在 _add_result 的 try/except 裡——改產品規則得動解析器。重來會讓 FSM 只負責解析出「意圖列表」,產品裁決放在下一層,各自可測、各自可改。
  • 拿真實流量餵它。 解析器最怕的是改版 regression。raw 當年就留了,歷史留言本來就能離線重放:新舊解析器並排跑、diff 結果,改文法之前先知道會影響幾筆——缺的只是把這條回測管線真的建起來(後面的重來版會補)。再加一層 property-based testing——隨機字串灌進去,唯一要求的不變量是「絕不 crash、永遠回 dict」。

一句話收:好程式碼的標準不是聰明,是改它的人知道會發生什麼。 這台 FSM 當年已經及格了,重來補的不是重寫,是讓它「可以放心改」的那圈外圍。

順序與重複:LWW 的三個先決問題

「同一人重複留言,以最後一筆為準」——LWW 一句話就講完,但「最後」這個字要先回答三個問題:

  1. 以什麼時鐘為準? 跨 FB/IG/自建三個源,各平台的時間戳基準不可比。當年的答案很務實:以我們落地的順序為主、平台時間戳為輔——三源都寫進同一張 message 表,再由同一條批次處理鏈依序消化,入庫順序就是全域順序。單一消費者的意外好處:你自己就是時鐘。這條時間線的權威性有個旁證:同一場直播的留言,事後當成貼文留言整場重抓,順序會和直播中抓到的不完全一樣——平台自己都不給你一個穩定的順序,你落地的那份,就是唯一的那份。
  2. 覆蓋的粒度是什麼? 是 key+style 級:後留的 2601藍+1 只蓋藍色,先前的 2601紅+2 還在。而且這是一個帶副作用的 LWW——蓋掉 +2+1,購物車數量要改、賣出數量表要補回差額。一般系統的 LWW 丟掉舊值就完事,這裡的舊值佔著庫存,覆蓋即補償。
  3. 「最後」的邊界在哪一場? 主播有重喊的需求——同一個 key 重新開賣,舊場次的單全部清除、完整重算,客人要重新留言。所以 LWW 的有效鍵其實是「人+場次+style」:重喊即斷代。被清單的客人沒有系統通知,純靠主播口播——這也是 feature:主播就是這個平台的通知系統,「現在不留就沒了」的急迫感,正是直播銷售的引擎。

失敗的單去哪了:重來版的答案

當年最痛的取捨在管線末端:batch 沒有進度記錄,處理失敗的留言直接略過——那位客人的單無聲消失。這是刻意的:停下來救單,主播眼中的庫存就舊了;跳過,數字永遠最新。為了主播的新鮮度,犧牲顧客的完整性。

不過「掉單」不是一個洞,是三個不同的洞——把第一節那份保險逐格對上去,帳才算得清:

  • 抓漏(直播中輪詢漏了留言):罩得最好的一格。事後可以整場重抓,Django admin 裡甚至真的做了一顆「從 FB 貼文重抓」的 action——只是它從來沒被按過。回頭看不全是怠惰:這門生意處理漏單的方式本來就是現場的,主播再喊一次、客人再留一次,急迫感把傷害當場吸收掉;事後由系統悄悄補單,反而不是直播的節奏。
  • 清洗漏接(規則沒認出的留言):raw 在,理論上能拿歷史留言回測、重放修正——但這條回測管線沒建,漏接了什麼仍然只能猜。
  • 處理失敗(FSM 或扣庫存那一步炸了):真正的黑洞。訊息早已入庫,接入層做對的冪等去重,這時反而把重抓擋在門外;批次略過就不回頭。三個洞裡,唯一連理論上的救援都沒有的一個。

盤點的結論有點諷刺:能自癒的那格(主播重喊就解決)保險買好買滿,真正的黑洞一分保險都沒有。重來版不推翻「新鮮度優先」的優先序,要補的是讓救援自己會跑——那顆沒人按過的按鈕已經證明,靠人記得去按的補救,等於沒有補救:

  • 每源一條處理鏈(取回端本來就各自獨立),FB 的洪峰不再拖著 IG 和自建的單一起排隊;
  • 把留著的 raw 升格為正式的事件流資產——不只是存著,而是接上重放與回測的入口:清洗規則漏接,重放就能補;
  • 快路徑照樣跳過失敗——但失敗的事件進 dead-letter 佇列,一條慢速補救路徑自動事後重放(投遞語意那套在這裡全用得上),「處理失敗」從黑洞變成「晚點到」;
  • 解析與扣庫存拆成兩段:解析是純函式、可以平行,扣庫存才需要排隊——當年它們擠在同一個 batch 迴圈裡,慢的拖著快的。

一句話:快用「晚點處理」換,不用「放棄客人」換。 主播照樣看到最新的數字,而那位失敗的客人,幾秒後會被補救路徑撈回來。

反思

解析器的智慧,是把難題推給資料庫

這台 FSM 我現在回頭看,最精妙的不是狀態設計,是它拒絕回答困難的問題。「這句留言是不是下單?」——不答,解出複合字串查 DB,查到就是、查不到就不是。「字母 key 怎麼支援?」——不改文法,keyword 留空放款式欄。「主播重喊怎麼辦?」——mapping 在 DB 裡,改綁定就是新事實。每一個看似要改解析器的需求,最後都被資料層吸收了。當年我說這是運氣好,現在我會說:把唯一事實放對位置的人,會一直「運氣好」下去。

限制是設計出來的,而且常常是最好的設計

這章出現了三個「缺陷即 feature」:+0 無效(喊單禁止取消)、重喊不通知(主播即通知系統)、中段錯誤整句作廢(可疑就不下)。它們沒有一個是技術限制——每一個都是跟業務一起做出的產品決定,只是長成了程式碼的樣子。工程師常以為自己在妥協,其實是在定義產品的邊界;而好的邊界比功能更能定義一個產品。直播電商的本質是急迫感,系統每貼心一分,急迫感就漏掉一分。

誠實面對那條虛線

但我不想把當年美化成處處是智慧。圖上那條紅色虛線——失敗直接略過——是真實傷過客人的:失敗的單無聲消失,而我們連道歉都不知道要跟誰道。三個洞盤點下來,最刺的一課是保險的錯位:我們把保險買在商業節奏本來就會自己吸收的那格(重抓按鈕沒人按,因為主播重喊就解決了),而真正不會自癒的黑洞,一分保險都沒有——連受傷的人數都沒量過(沒收到抱怨,往往只是客人不知道自己該抱怨)。保險的價值不在買了多少,在有沒有蓋住不會自癒的傷口;而要知道哪裡不會自癒,得先承認系統會受傷,並且去量它。如果這系列只能帶走一句話,我希望是這句:留下事實是上半場,決定「誰、什麼時候動用它」才是下半場——躺在倉庫裡的 raw、沒人按的按鈕,對掉單的客人來說,和不存在是同一回事。