的生產(chǎn)事故:邊界值治理與系統(tǒng)穩(wěn)定性實踐)
1. 凌晨兩點半我被一條999999999999999驚醒半夜兩點四十七分告警電話打過來的時候我正睡得不深。監(jiān)控平臺顯示支付回調(diào)接口的成功率從99.98%一路掉到92.7%失敗請求不報超時、不報空指針而是齊刷刷地卡在一個奇怪的地方order_no 999999999999999。剛開始我以為是日志框架把變量打丟了或者同事調(diào)試時隨手打印了一堆9。等我把這條訂單號拿去生產(chǎn)庫一查后背直接發(fā)涼——它真的在表里而且不止一條。更詭異的不是這條數(shù)據(jù)本身而是它引發(fā)的連鎖反應。這張訂單表有三千多萬行正常情況下按主鍵查一條數(shù)據(jù)毫秒級返回??上掠螌~系統(tǒng)拿到order_no999999999999999之后把它判成了異常單據(jù)需要人工復核然后走了一條幾乎沒人維護的審批分支。一個晚上下來消息隊列里積壓了十幾萬條待復核消息消費組不斷重試重試又不斷失敗最后把下游幾個核心服務的線程池全部拖滿。那會兒我腦子里只有一個念頭這串9到底是誰寫進來的。事后復盤我發(fā)現(xiàn)這串數(shù)字的來歷一點都不玄乎甚至有點諷刺——它是被人當成邊界值寫進去的。很多人覺得999999999999999只是測試環(huán)境隨手敲的占位符不可能跑到生產(chǎn)環(huán)境。但現(xiàn)實是它不單能跑進來還能順著一條條鏈路把訂單、對賬、支付、短信全禍害一遍。這篇就專門聊聊一長串9在真實系統(tǒng)里是怎么混進來的、會造成哪些讓你意想不到的后果以及我在排查和修復過程中總結(jié)出來的方法和教訓。2. 這串9的真實身份從測試腳本到生產(chǎn)庫的幾種常見來路2.1 測試環(huán)境里偷工減料的造數(shù)習慣先說最常見的來路——測試環(huán)境造數(shù)。自動化腳本跑接口經(jīng)常需要構(gòu)造一個非空且看起來像訂單號的字段。很多測試同學圖省事直接寫order_no 9 * 15或者干脆在參數(shù)文件里填999999999999999。這種數(shù)據(jù)在測試環(huán)境里跑一千遍都不會出問題因為測試環(huán)境的校驗邏輯和生產(chǎn)是一樣的都是只要不是空、只要是數(shù)字就放行。問題出在數(shù)據(jù)同步和腳本復用上。不少公司為了調(diào)試方便會把測試庫的數(shù)據(jù)定期同步到預發(fā)環(huán)境或者讓測試腳本直接連看起來像測試環(huán)境的庫。一旦這串9被同步過去它就不再是測試數(shù)據(jù)而是生產(chǎn)數(shù)據(jù)了。我見過最離譜的一次是同事把造數(shù)腳本里的庫連接配置從測試庫改成了生產(chǎn)庫因為他急著驗證一個接口忘了改回來。一條999999999999999的訂單就這樣誕生了。2.2 接口文檔里的示例值被直接復制第二種來路比測試數(shù)據(jù)更隱蔽也更常見——接口文檔。很多外部對接文檔里為了演示最大值或者無限制的場景會拿999999999999999當示例值。寫文檔的人本意是告訴下游這個字段允許很大的數(shù)可對接的研發(fā)往往沒看字段說明直接復制示例值就當真實入?yún)⒂昧?。我之前排查過一次故障上游調(diào)用我們系統(tǒng)時傳的客戶號全是999999999999999。追了半天發(fā)現(xiàn)上游開發(fā)是從對接文檔的請求示例里復制的。文檔里寫的注釋是客戶號最長15位數(shù)字示例值為999999999999999他理解成了這個接口的客戶號就該傳這個值。從那以后我在評審接口文檔時都會加一條要求示例值必須用一個明顯不真實但業(yè)務上合法的假數(shù)據(jù)比如210000198801010011而不是一長串整齊的9。2.3 兜底邏輯里隱晦的默認值第三種來路是代碼層面的坑而且是最難查的一種兜底邏輯把異常值寫成了默認值。很多團隊在實現(xiàn)RPC調(diào)用時喜歡給返回值一個兜底比如超時或異常時返回-1或0。但有些系統(tǒng)不走尋常路為了跟正常的負數(shù)或0區(qū)分開用了999999999999999來表示調(diào)用失敗。這個設計在單系統(tǒng)里跑沒什么問題問題出在跨團隊協(xié)作。下游系統(tǒng)只看到這個字段返回了一個巨大但合法的數(shù)字壓根不知道它其實是失敗的暗號。于是下游把它當成真實客戶號去查庫、去關聯(lián)、去入賬一條鏈路上的每個系統(tǒng)都在按規(guī)矩辦事合在一起就變成了災難。這類默認值往往藏在框架代碼或者公共SDK里排查起來非常費勁因為它沒有任何日志提示表面上一切都正常。2.4 用戶輸入與導入文件的漏網(wǎng)之魚最后一種來路是用戶自己輸入的或者從Excel導入的。別覺得用戶不會輸一長串9你會發(fā)現(xiàn)總有人為了測試你的校驗邏輯故意輸入最大位數(shù)的9也總有人從別處復制一段數(shù)據(jù)里面剛好帶著一串占位用的9。前端如果只控制了輸入框的長度沒控制內(nèi)容和業(yè)務語義后端的正則如果只寫了^\d$那15個9就是完全合法的數(shù)字串。Excel導入的情況更經(jīng)典。用戶在表格里填了999999999999999Excel顯示成科學計數(shù)法導入程序如果解析時沒處理好可能拿到的是999999999999999也可能是被截斷的99999999999999。不管是哪種它都堂而皇之地進了庫。我后來在導入程序里加了一條硬規(guī)則凡是手機號、身份證號、訂單號這類有明確格式的字段除了要校驗數(shù)字格式還必須校驗位數(shù)和業(yè)務前綴。來路典型特征為什么能混過校驗測試造數(shù)腳本里硬編碼一串9只校驗非空不校驗真實性文檔示例和下游對接文檔中的示例一致開發(fā)復制示例值未讀注釋兜底默認值公共SDK/框架代碼中返回對下游來說值合法但語義異常用戶輸入/導入Excel科學計數(shù)法或手輸正則只判斷是否為數(shù)字不判斷位數(shù)這四種來路沒有一種是靠高深黑客技術(shù)進來的全是普通工程習慣埋下的雷。3. 一串9如何讓整個業(yè)務鏈路集體翻車3.1 精度與類型15位9的臨界點效應在討論危害之前先糾正一個常見誤解999999999999999本身恰好是15位而JavaScript的Number.MAX_SAFE_INTEGER是9007199254740991所以這串9在JS里其實能被精確表示單看這一個數(shù)并不會因為精度問題直接爆掉。但問題恰恰出在恰好這兩個字上。你永遠無法保證生產(chǎn)環(huán)境里出現(xiàn)的都是干凈的15位9。系統(tǒng)里可能同時存在16個9、17個9甚至在某個極端情況下一個字段被填成了Long.MAX_VALUE。JS超過9007199254740991之后整數(shù)精度就開始不可靠了而Java的Integer.parseInt在遇到2147483647以上的數(shù)字時直接拋異常。我見過一個真實案例上游把訂單號用Long傳給前端前端用JS的Number去接收然后作為參數(shù)回傳后端再轉(zhuǎn)成String一頓操作下來原本19位的訂單號已經(jīng)面目全非。像999999999999999這種整齊劃一的數(shù)字往往就是這條精度鏈路上第一個被反復測試、反復拷貝的探路石。3.2 數(shù)據(jù)庫里的隱式轉(zhuǎn)換與偽熱點數(shù)據(jù)庫側(cè)的問題同樣不容忽視。如果表里的訂單號字段是varchar而查詢代碼里寫的是where order_no 999999999999999MySQL會把字符串列隱式轉(zhuǎn)換成數(shù)值再比較。一旦列里存在非數(shù)字的值轉(zhuǎn)換規(guī)則會變得非常詭異更重要的是這種比較方式會直接讓該字段的索引失效。一個本該走索引的查詢突然變成全表掃描量大的時候?qū)?shù)據(jù)庫來說就是一場災難。還有一種情況是字段本身被定義成了bigint999999999999999存進去沒任何報錯但它會成為一個偽熱點。所有異常數(shù)據(jù)、所有兜底路徑都指向同一個巨大數(shù)值數(shù)據(jù)庫里這一行的訪問頻率遠高于其他正常數(shù)據(jù)行鎖競爭、緩存失效、排序錯亂接踵而來。我們當時那個事故里對賬系統(tǒng)正好用這個訂單號做分組統(tǒng)計結(jié)果所有異常記錄擠在同一組里報表直接被撐爆。3.3 業(yè)務狀態(tài)機異常值被當成真單據(jù)相比技術(shù)和數(shù)據(jù)庫層面的問題更讓人頭疼的是業(yè)務語義被污染。真實業(yè)務系統(tǒng)里訂單號、用戶ID、流水號都是有業(yè)務含義的比如帶時間、帶機房、帶校驗位。而999999999999999不具備任何業(yè)務含義但它一旦進入系統(tǒng)就會像一個身份不明但證件齊全的人走到哪都能通過基礎校驗然后在業(yè)務狀態(tài)機里亂串。我復盤那次事故時發(fā)現(xiàn)真正的轉(zhuǎn)折點是定時任務。任務設計者為了避免重復處理用處理時間大于某閾值來判斷是否該執(zhí)行。碰巧有任務為了做時間判斷會從單據(jù)號里截取一段當作時間戳來用而999...這段截出來的數(shù)字遠大于真實時間任務邏輯認為這條記錄還沒到處理時間于是一遍又一遍地跳過。其他正常數(shù)據(jù)排隊等著處理這串9永遠插在前面擋路積壓就從這里開始。這種問題寫代碼時根本想不到但一旦發(fā)生了排查方向會非常隱蔽。3.4 安全視角可預測的數(shù)字等于半開的后門最后聊一個容易被忽視的角度——安全。唯一標識符的核心要求是不可預測性。如果系統(tǒng)里真實存在999999999999999這種整齊劃一的標識相當于告訴攻擊者這個系統(tǒng)的ID生成邏輯毫無隨機性甚至可能是固定值、順序值。配合一個沒做越權(quán)校驗的查詢接口攻擊者不需要猜只需要把參數(shù)改成999999999999999就能訪問到這條特殊數(shù)據(jù)萬一它恰好是管理員賬號或內(nèi)部單據(jù)問題就大了。即便沒有這么嚴重一串連續(xù)9也會污染統(tǒng)計口徑和風控模型。風控系統(tǒng)會學習正常用戶的行為分布一個突然出現(xiàn)的極端值會把平均值拉偏后續(xù)所有的異常檢測閾值都跟著失真。所以我說它是個半開的后門不是說它能直接拿權(quán)限而是它在層層系統(tǒng)里制造了一個又一個特例每個特例都是一次風險敞口。4. 面對滿屏9該怎么查、怎么修、以后怎么防4.1 從日志到數(shù)據(jù)源的完整排查鏈路如果你也遇到了類似情況先別急著刪數(shù)據(jù)更別急著改代碼。我的排查順序是這樣的從告警和日志里拿到發(fā)生異常的requestId和traceId把一條完整的調(diào)用鏈拉出來。定位第一個出現(xiàn)999999999999999的服務節(jié)點看它是作為入?yún)⑦M來的還是作為返回值生成的。如果是入?yún)⑼嫌握掖_認是誰傳的如果是返回值檢查代碼里的兜底邏輯和默認值定義。拿到數(shù)據(jù)后去生產(chǎn)庫做一次影響面掃描統(tǒng)計這個值在哪些表、哪些字段里出現(xiàn)過關聯(lián)了多少業(yè)務記錄。翻時間線確認第一批臟數(shù)據(jù)是哪個時間點出現(xiàn)的跟發(fā)布單、數(shù)據(jù)同步任務、腳本執(zhí)行記錄做比對基本就能鎖定來源。這套鏈路看起來簡單但真正執(zhí)行時最容易被卡住的是第二步。很多團隊沒有全鏈路追蹤日志里只有零散的片段根本不知道這串9是哪個服務塞進來的。所以平時把traceId打全、把關鍵入?yún)⒋蛉⒉皇强捎锌蔁o的要求真出事故時它就是救命的線索。4.2 修復三步走摘除、補償、固化定位到來源之后修復動作我建議分三步走不要一上來就UPDATE。第一步是摘除把臟數(shù)據(jù)從正常業(yè)務流程里摘出去??梢韵韧ㄟ^配置中心下發(fā)一個黑名單讓對賬、統(tǒng)計、定時任務在遇到999999999999999時直接跳過先恢復核心鏈路。第二步是補償確認這些臟數(shù)據(jù)是否有對應的真實業(yè)務。如果是測試數(shù)據(jù)按公司數(shù)據(jù)規(guī)范做歸檔或清理如果是兜底值誤入需要追溯這段時間內(nèi)受影響的下游單據(jù)逐一核對是否需要補單。第三步是固化把這次踩坑變成代碼和配置層面的約束。比如在配置中心增加一個保留值名單凡是在名單里的值任何接口都不允許作為業(yè)務單據(jù)入庫。這一步里最容易犯的錯是直接DELETE。生產(chǎn)庫里的數(shù)據(jù)哪怕看起來是垃圾也可能已經(jīng)被別的系統(tǒng)引用、歸檔甚至報送了。你先刪掉后面對賬對不上神仙也救不了。所以優(yōu)先摘除補償最后才考慮清理。4.3 源頭治理從參數(shù)校驗到環(huán)境隔離修復只能救急真正的解決方案在源頭。我在事后總結(jié)時把源頭治理拆成了四個層面參數(shù)校驗層前后端都要做后端尤其要校驗業(yè)務語義不能只判斷isNotEmpty和isNumeric。訂單號要有前綴、手機號要有號段、金額要有上下限把像不像真的納入校驗規(guī)則。環(huán)境隔離層測試環(huán)境、預發(fā)環(huán)境、生產(chǎn)環(huán)境的數(shù)據(jù)通道必須物理隔離。造數(shù)腳本、數(shù)據(jù)同步任務、Mock平臺在連接生產(chǎn)庫之前要做強制確認更理想的是通過統(tǒng)一的造數(shù)平臺生成看起來真實的測試數(shù)據(jù)從根上消滅硬編碼的一串9。契約測試層對外接口的文檔示例值要小心設計最好加一些業(yè)務上合法但不真實的樣例并在契約測試里加上非法值用例防止下游把示例值當真實值用。數(shù)據(jù)庫約束層對于關鍵業(yè)務字段能加CHECK約束就加能建唯一索引就建即使數(shù)據(jù)庫層擋不住所有臟數(shù)據(jù)也能在出現(xiàn)問題時更快報警。我為什么強調(diào)業(yè)務語義校驗因為大多數(shù)只做正則校驗的系統(tǒng)就是被^\d$之類的寬松規(guī)則坑的。一串15位9在正則眼里是完美的數(shù)字但它在現(xiàn)實世界里不具備任何可解釋性。給字段定義合法形態(tài)比定義非法字符更有效。4.4 監(jiān)控告警里最容易被忽略的一條最后說監(jiān)控。很多團隊的監(jiān)控體系很完善接口成功率、RT、錯誤碼、JVM內(nèi)存、數(shù)據(jù)庫慢查詢?nèi)加?。但?99999999999999這種問題這些指標幾乎都發(fā)現(xiàn)不了因為接口是成功的RT是正常的錯誤碼也是200。唯一暴露問題的是業(yè)務側(cè)的對賬失敗率等這個指標飆起來往往已經(jīng)造成了不小的影響。我建議在核心表上增加一種數(shù)據(jù)分布異常監(jiān)控。具體做法對訂單號、用戶ID這類高基數(shù)業(yè)務字段周期性統(tǒng)計其重復次數(shù)、最大最小值、是否出現(xiàn)連續(xù)相同數(shù)字的號碼。一旦發(fā)現(xiàn)某個值在短時間內(nèi)重復率異常上升或者出現(xiàn)了一個遠超正常范圍的極端大數(shù)立刻告警。這比事后翻日志高效得多。我們團隊后來用了一個簡單的定時任務每天掃描主表里字段長度超過正常范圍或等于保留值名單的記錄跑了大半年抓到了好幾起類似的事故苗頭。5. 換個角度看999邊界值設計里被低估的極限數(shù)字5.1 為什么999...是所有測試用例里最有價值的一組聊完了事故和修復我想把視角拉高一點。999999999999999在質(zhì)量保障和測試設計里其實是一個非常經(jīng)典的邊界值樣本。很多人做邊界值分析時只會測最小值和最大值比如金額的0和999999.99卻忽略了一個維度業(yè)務語義的邊界。一段數(shù)字從15位漲到16位、17位、19位每一擋都對應不同的類型邊界2147483647是Java Integer的上限9007199254740991是JS的安全整數(shù)上限9223372036854775807是Java Long的上限。999999999999999在Long里合法、在JS里也精確但它已經(jīng)站在了所有精度邊界和業(yè)務語義邊界的交叉點上。如果你在測試用例里填過這些數(shù)你其實是在幫整個系統(tǒng)做一次數(shù)字邊界體檢。我在給團隊做測試設計培訓時專門列過一個極端數(shù)字矩陣0、-1、2147483647、2147483648、9007199254740991、9007199254740992、9223372036854775807、999999999999999。每一組都代表一類語言或數(shù)據(jù)庫的邊界。把這些用例跑一遍很多類型轉(zhuǎn)換、精度丟失、隱式轉(zhuǎn)換的問題都能提前暴露出來。5.2 團隊數(shù)字紀律把魔法數(shù)字關進籠子經(jīng)歷過兩次類似事故后我在團隊里立了幾條數(shù)字紀律現(xiàn)在分享出來大家可以參考代碼里禁止硬編碼超過6位且全部相同的數(shù)字作為默認值或配置項排查時必須在配置中心統(tǒng)一管理。一切造數(shù)操作必須走造數(shù)平臺平臺生成的測試數(shù)據(jù)要帶明顯的測試標識比如以特定前綴開頭防止混入生產(chǎn)。業(yè)務字段的業(yè)務語義校驗不能省略尤其是ID類、號碼類、金額類字段位數(shù)、前綴、校驗位都要逐步補上。CodeReview時重點關注異常返回值和兜底邏輯看到Long.MAX_VALUE或者99999...這類寫法時一定要追問下游知道這個值代表什么嗎這些紀律看起來都是小事但它們確實能擋住大部分 一長串9 事故。特別是第一條我把它寫進了團隊的靜態(tài)檢查規(guī)則里用正則去掃代碼發(fā)現(xiàn)連續(xù)9以上的硬編碼數(shù)字就報警從一開始就不讓這類魔法數(shù)字溜進代碼庫。那次凌晨事故之后我把999999999999999這串數(shù)字設成了手機里一個特殊的備忘不是為了紀念什么而是每次看到它都會想起真正危險的從來不是那串數(shù)字本身而是寫了它卻不告訴別人它是什么意思的人以及對著它熟視無睹的校驗和監(jiān)控?,F(xiàn)在團隊里再有人拿一長串9當占位符他會被全組人追著改掉因為我們都知道它不是無所謂的數(shù)據(jù)它是所有隱形坑的集合。