扁鵲見蔡桓公,立有間。扁鵲曰:“君有疾在腠理,不治將恐深。”桓侯曰:“寡人無疾。”扁鵲出,桓侯曰:“醫之好治不病以為功。”
居十日,扁鵲復見,曰:“君之病在肌膚,不治將益深。”桓侯不應,扁鵲出,桓侯又不悅。
居十日,扁鵲復見,曰:“君之病在腸胃,不治將益深。”桓侯又不應,扁鵲出,桓侯又不悅。
居十日,扁鵲望桓侯而還走,桓侯故使人問之。扁鵲曰:“疾在腠理,湯熨之所及也;在肌膚,針石之所及也;在腸胃,火齊之所及也;在骨髓,司命之所屬,無奈何也。今在骨髓,臣是以無請也。”
居五日,桓公體痛,使人索扁鵲,已逃秦矣。桓侯遂死。
“扁鵲見蔡桓公“曾入選中學課本,當年的教材節選刪去了原文后面的一句議論:
故良醫之治病也,攻之于腠理。此皆爭之于小者也。夫事之禍福亦有腠理之地,故曰:“圣人蚤從事焉。”
故而語文老師們在講授這篇文章時,將其中心思想落腳在“人要正視缺點,切莫諱疾忌醫”上。但實際上有些斷章取義,作者的中心思想其實是借扁鵲闡述的醫理來講解做事的方法,即要爭之于小、蚤(早)從事。
在互聯網公司的開發工作中,80%甚至90%的程序員都是在做業務開發。盡管所應用的技術深度和難度未必比的上基礎架構的研發,但隨著業務的成熟,在紛繁復雜的框架結構與業務邏輯之上持續進行快速且又穩定的迭代也絕非易事。尤其是在已經度過了快速迭代,野蠻生長的階段,進入互聯網行業的下半場之后,如何實現快遞迭代但又能保持持續穩定這一目標,是一個重要的課題。
在前司期間,我陸續負責了多個模塊的工程開發,每天都有大量算法同學在我管轄的模塊上進行策略迭代。隨著日積月累的迭代,代碼腐壞在所難免,并且其中有兩個模塊本身有就很重的歷史包袱,作為工程OWNER,我個人除了一些業務需求開發外,也需要對模塊的穩定性、性能問題以及算法同學的研發效率進行負責。經常面對各種core dump、內存泄露、耗時暴漲等問題實在是疲于奔命。對于代碼的治理必須馬上提上議程,彼時心中千頭萬緒卻不知該從何做起。這時“疾在腠理,湯熨之所及也,今在骨髓,臣是以無請也。“的詞句映入腦海。
從扁鵲這里延伸,不難理解經典的中醫學說:防病于未萌,治病于初起。并且治不如防。這和“未雨綢繆”一詞是類似的道理,更通俗一點的說法就是:
亡羊補牢不如防患于未然 。
“防病于未萌”是最理想的狀態,即在沒有風險沒有暴露的時候就考慮到進行預防。不過就如同棋手落子之前的計算,通常也會百密一疏,在實際工作中則有可能百密十疏。所以“治病于初起”其實也未嘗不可,即在風險初露的時候盡早拔除。留心觀察,見招拆招,以不變應萬變。思來想去個人感覺“防微杜漸”一詞或許更為簡明扼要,言簡意賅。下面我都會盡量使用“防微杜漸”一詞來給我心中方法論下定義。
總而言之就是風險越早發現越好。做到早發現,早治療。因為越早發現,它造成的影響也越小,通常越早發現也意味著越好排查和解決。作為后臺開發程序員,我個人總結了幾句箴言:
能在編譯時發現,不在開發時發現
能在開發時發現,不在測試時發現
能在測試時發現,不在上線時發現
能在服務啟動時發現,不在請求處理時發現
中國人講道與術,本文中我會介紹一些術,但是我需要大家明白,本文目的其實是讓大家對本文所述之道有更深刻的認知。因為術是有局限性的,比如我是寫C++的后臺程序員,對于編程語言的部分,就不會闡述到Jave/Go的防治之術,但是我認為理解本文所述之道,對所有程序員都會有所幫助。另外術是沒辦法通過一篇文章窮盡的,記住只要心中有道,術會自生。
為什么要在編譯時發現,因為這是最靠前的階段,如果能在編譯期間發現問題,能大大的節省我們開發自測的時間。往小了說,一些疏忽大意引發的問題,很可能在測試的時候排查很久才能找到問題,浪費自己的時間,白白加班!往大了說,這種問題可能在測試階段都測不出來,直接帶到線上。引發風險。
先說一個親身經歷。
這其實是大學時,老師就講過的一個好的代碼習慣,即:
if (a == 1) {}
應該寫成:
if (1 == a) {}
這樣主要是避免手誤,把 if (a == 1) 寫成 if (a = 1),因為在C++中 if (a = 1)是合法的,能通過編譯的,這個表達式的值為true,但 if (1 = a) 是不合法的語法,如果錯把 == 寫成 = 編譯會失敗。當然在Java中也是不合法的,可不遵守這一習慣。
盡管我早就知道右值放左邊是一個好的習慣,但是卻很少遵守它,感覺這樣代碼會不夠順手,a == 1 更符合左手到右的思維。就好像 if (vec.size() > 0) 比 if (!vec.empty()) 更順手一樣,因為后者經常會是先寫vec.empty(),再把光標移動到前面補!。另外感覺自己不會犯錯,寫出 if (a = 1) 這樣的代碼。
但多年以前我在百度工作期間,我就曾寫下了這樣的代碼。當時有某一個新策略只在部分請求中生效,而如何判斷是否滿足條件,是去檢查一個int類型的變量是否為1。當時應該是需求比較匆忙,最后這行代碼并沒測試就上線了。沒錯。我寫成了 = ,所以變成了全流量生效,因為是某個垂類業務,對大盤影響不大,自身對監控關注又不夠,所以終于釀成事故。好在由于廣告業務的特殊性,前期損失的收益在當日后面的時間可以補救回來,所以當日統計最終影響面并不大。但此次問題,我深感懊悔,因為這是一個十分低級的問題。
彼時回想起之前參加過的其他人的事故case study會議,經常聽到的反思和改進是:仔細、仔細、再仔細。但此次經歷讓我深刻認識到靠人的仔細是靠不住的,所以我認為:
不能依靠仔細,要依靠工具!
我所說的工具并不只是說依靠軟件或者Linux命令,也包括這種編程習慣。也許你會說,只要加強測試就可以了,是你測試不夠,但是一方面測試也可能百密一疏,另外如果編譯階段能發現,那么解決起來肯定比測試時發現邏輯不符合預期后再去排查問題原因要更節省時間!
從此以后我再也不去寫右值在左的代碼,盡管有時候比較對象是常量并非變量。雖然a是常量的時候 if (a = 1)編譯也會失敗。但是好的編程習慣就是這樣,就是只要你無腦遵守就好了,減少一些思維勞動去檢查比較對象是不是常量,讓自己變的機械有時候未必是壞事。
當然如果是 != 或者 > < 都不需要把右值放到左邊。
值得一提是,基本數據類型遵守右值在前這個規則其實被遵守的概率很高,但其他類型的比較可能更容易被忽視。比如迭代器,比如:
auto it = exp_map.find(key);if (it == exp.end()) { ...}
應寫作:
auto it = exp_map.find(key);if (exp_map.end() == it) { ...}
在第一種寫法中,如果 == 如果寫錯成 =,也是能編譯通過的。
還有哪些工具或者工具思維可以幫助我們避免風險呢?下面會介紹一些C++相關的“術”的部分。如果是其他編程語言的使用者也可以跳過。如前文所言,“術”有局限性,并且無法窮盡。
對于C++項目而言,首先我們要學會利用g++的編譯參數來幫我們規范風險,如果你使用其他編譯器,應該也有類似設置。
在條件允許的時候開啟-Werror是最理想的,它不放過任何語法錯誤。但有時候不太現實,因為我們也會依賴到外部代碼,開啟-Werror導致外部庫的代碼編譯不過,我們可能不能直接修改它們的代碼。你可能會說你是以庫(.a,.so)的形式依賴的,不會受-Werror的編譯參數影響,但是你別忘記頭文件,頭文件是直接include到自己項目中并且參與編譯的,頭文件中有時候也是會有語法問題的,盡管可能不如源文件多。并且這對于header only的開源庫來說可能是災難,因為他們都只有頭文件,沒有源文件,大量邏輯寫在頭文件中。比如rapidjson、taskflow
g++編譯問題千萬條,-fpermissive 第一條!
作為C++程序員,第一職業素養就是不要開啟-fpermissive編譯參數,如果老項目已經有開啟的時候,那么就要關閉!因為-fpermissive會放過很多不規范甚至不合法的C++語法。比如:
諸如此類的代碼問題竟然都能編譯通過,有時候你可能覺得對這些不規范語法零容忍僅僅是我個人的“代碼潔癖”。其實不然,這些很多都潛藏隱患。另外在針對老項目做后續進一步的穩定性優化、性能優化以及研效提升的時候,語法問題都是要首先修復的,后續的工作才好開展。比如某一個變量,你作為常量引用傳給了函數F,后續某次需要需要并行調用函數F兩次,但其他入參不同。如果這個常量引入傳入的變量在函數F中被修改了也能編譯通過在線上運行了很久也沒出問題,那么你當你誤以為函數F不會修改這個變量,然后改成并行調用兩次的時候,很可能會有coredump風險!
遵守代碼的規范是維護了一個每個代碼開發者的認知協議,不規范的代碼破壞了這一協議,使得代碼變成屎山,如果不能對老代碼面面俱到則極易踩坑!就好比大家都認為靠右行駛,但是突然迎面出現一個逆行,釀成車禍。
所以當我在前司接手了一個老模塊之后做的第一件事就是刪掉了這個編譯參數,然后不出意外收到了一堆編譯錯誤……,在這些都改完之后,才開啟的后續深度的優化之旅。
有些公司是mono repo的代碼倉庫管理模式,即很多服務/模塊的代碼不是在單獨的git上管理,而是在同一個git上,通過不同的二三級目錄來存儲不同模塊的代碼。這時候整個項目可能會有統一的編譯配置,比如整個項目都有一個bazel配置,會全局生效,當然每個子目錄中的BUILD中可以添加單獨的編譯參數。此時如果全局配置是開啟了-fpermissive的時候,我們可能是沒有權限修改的,但這也有辦法解決,那就是在我們自己的模塊目錄中的BUILD中加上-fno-permissive編譯參數!
這個可以防止函數在聲明了返回值的時候,但函數內卻漏了return的bug。如下代碼:
int foo(Bar* bar) { ... if (x) { return 0; }}
上面代碼明顯有問題,如果if沒命中,那么缺失其他的return。但默認情況下這個能編譯通過。但是加了-Werror=return-type 之后就能讓編譯失敗。從而減少bug。
這個用來減少變量未初始化的bug。好的編碼規范,都告訴我們基礎數據類型的局部變量要做初始化,否則是默認值。但是同樣,人的仔細是靠不住的,再有經驗的程序員也有可能犯錯。比如:
int foo(int a) { int x; if (a >= 10) { x = 2; } else if (a >= 0) { x = 1; } ... 這里的邏輯依賴x}
將x的初始化放到了下面的條件分支中,但是如果條件分支遺漏了條件。比如上例中 a 未負數,那么x的值將是不確定的。而 -Werror=maybe-uninitialized 可以發現這類錯誤。
比較的數字超出int范圍,if永遠false
int doc_type = doc->doc_type(); ... if (doc_type == 3808059108 || doc_type == 3856151248) { is_fresh_doc = true; }
doc_type聲明成了int類型,但是下面if中和doc_type比較是否相等的數字都超過了int范圍,所以永遠不可能為true。
其實在DocInstance的proto定義中,doc_type是uint32的,int doc_type = doc→doc_type(); 這段代碼相當于強轉了uint32的值,變成了int,導致這個 if 永遠不會為true,邏輯不對。
現在可以在編譯階段編譯失敗,然后發現該錯誤。
這個是防止變量的shadow,引發bug。比如:
class Config {public: void init(const std::string& filename); string param;};void Config::init(const std::string& filename) { string param; ... 解析配置文件,給 param 賦值。}
在init函數內定義了一個和成員 param 同名的變量(也是手誤),這個導致最終給局部變量param 賦值,并沒有給 成員 param 賦值。但這并不會造成編譯失敗,從而引發bug。
加上 -Werror=shadow ,就能在編譯期間報錯。
有一些看似平平無奇的語法,其實對于防范風險來說也是有奇效的。
先回顧一下C++的多態,父類函數用virtual關鍵字修飾(稱之為虛函數),子類可以覆寫(覆蓋/重寫)父類的虛函數,當使用父類指針調用該函數的時候,如果對象是子類對象,那么會自動調用子類的該函數,而不是父類的。但是有時候因為手誤,可能導致并沒有覆寫父類虛函數。從而出現邏輯錯誤。
從C++11開始引入的override就能幫你在編譯期間做這個校驗,從而發現問題。
當然不加override也是能覆寫父類函數的,這個override只是幫你做一下檢查而已,然而據我的經驗,override至少可以減少如下三種不仔細導致的問題。
第一種:參數列表不一致(比如類型,或參數個數寫錯)
// a.hclass A {public: virtual void foo(long x, int y);};// b.hclass B: public A { void foo(int x, int y) override; // 編譯失敗};// c.hclass C: public A { void foo(long x) override; // 編譯失敗};
第二種:函數名不一致
// strategy_base.hclass StrategyBase {public: virtual void run_strategy(int x);};// video_strategy.hclass VideoStrategy: public StrategyBase { void run_stratgy(int x) override; // 編譯失敗};
在函數名很長的時候,子類的函數名容易寫錯,你以為覆寫了父類虛函數,其實是自己新建了一個函數!
第三種:父類忘記加virtual
很多時候父類不是來自于標準庫或第三方庫,而是也是我們代碼的一部分,那么漏寫virtual也很場景(比較Java里就沒有virtual關鍵字)
// a.hclass A {public: void foo(int x);};// b.hclass B: public A { void foo(int x) override; // 編譯失敗};
比較基礎的C++語法,在不需要修改成員變量的成員函數上加上const聲明。為了這樣呢?很多人誤以為是可讀性,其實不盡然。假設有一個全局單利的管理類A:
class A {public: static A& inst() { static A inst; return inst; } // ... bool hit(const std::string& key);private: A() {} map<std::string, int> dict_;};
在每個請求中需要判斷是否命中,比如:
if (A::inst().hit("new_exp")) { ...}
如果實現成這樣:
bool A::hit(const std::string& key) { return dict_[key] != 0;}
會有問題,因為map的operator [] 在key不存在的時候,會自動在dict_中插入。但是在處理多個線程的過程中,并發的插入map會導致core dump。如果加上const就可以避免非預期的修改。
比如:
class A {public: static A& inst() { static A inst; return inst; } // ... bool hit(const std::string& key) const;private: A() {} map<std::string, int> dict_;};bool A::hit(const std::string& key) const { return dict_[key] != 0;}
會直接編譯失敗,這時候就會發現有并發修改map的風險,應改成:
bool A::hit(const std::string& key) const { auto it = dict_.find(key); if (dict_.end() == it) { return false; } return it->second > 0;}
C++原生是不支持反射的,但是通過宏和map可以實現通過字符串來創建對象,從而拿到模擬對象的功能。比如實現一個Node類的反射功能。
class Node;...class NodeFactory {public: static NodeFactory& instance() { static NodeFactory inst; return inst; } using create_node_fun_t = std::function<Node*()>; void put(const std::string& name, create_node_fun_t&& fun) { _node_create_fun_map.emplace(name, fun); } Node* create(const std::string& name) { auto it = _node_create_fun_map.find(name); if (it == _node_create_fun_map.end()) { return nullptr; } auto&& create_node_fun = it->second; return create_node_fun(); }private: std::unordered_map<std::string, create_node_fun_t> _node_create_fun_map; };#define REGISTE_NODE(class_name) /struct RegisterNode##class_name { / RegisterNode##class_name() { / NodeFactory::instance().put(#class_name, [](){ / auto node = new class_name; / return node; / }); / } /}; /
在定義了Node子類后,通過函數宏REGISTE_NODE進行注冊。
// foo.hclass Foo: public Node {public: ...};
// foo.cppREGISTE_NODE(Foo)...
然后通過字符串"Foo" 就創建出Foo的對象。 但是如果在項目龐大后,各種Node變多之后,可能在兩個文件中存在同名的Node! 比如在兩個文件中命名空間不同,但類名都叫Foo:
namespace A {class Foo: public Node {public: ...};} // namespace A
namespace B {class Foo: public Node {public: ...};} // namespace B
在各自的源文件中都使用REGISTE_NODE(Foo)進行注冊。
但最終在NodeFactory的map中存放的“foo”指向是是哪個Foo類型其實是不確定的,這就會出現潛在的風險! 如何解決呢?這時候可以利用extern "C"來消除C++命名崩壞的功能來達到。重新實現REGISTE_NODE()這一函數宏。
#define REGISTE_NODE(class_name) /struct RegisterNode##class_name { / RegisterNode##class_name() { / dipper::flow::NodeFactory::instance().put(#class_name, [](){ / auto node = new class_name; / return node; / }); / } /}; /extern "C" { / int reg_node_##class_name; /} /
在extern "C"中定義了一個全局變量,變量名包含REGISTE_NODE的參數class_name。這時候如果有在不同命名空間中出現了同名的類,進行了REGISTE_NODE注冊,那么在編譯的時候會因為出現了同名的全局變量而導致編譯失敗!這時候也就能在編譯期間發現問題了!
其他C++關鍵字,比如 static_assert 、 final 也能在編譯期間實現一些檢查,讓不符合預期的使用方式,直接編譯失敗。
前面提到的方法,都是編譯時發現。當然也并不是所有問題都能在編譯期間發現。
當我們使用第公共組件的時候,一般都需要初始化。這期間如果遇到初始化失敗一定要拋異常或者調用exit()讓程序無法啟動,從而在服務部署階段就發現問題。而不是在請求開始處理的時候,還在使用初始化失敗公共組件,這時候可能導致線上服務或者業務邏輯的種種非預期問題。
當我們對外提供組件庫的時候,也一定要提供初始化的函數,并且明確返回是否初始化成功的狀態。
另外有一些外部組件雖然有初始化函數,但是僅僅是做了一個簡單的變量存儲,并沒有進行過多的初始化檢查,這時候就需要我們自己來在自己服務的初始化階段補齊相關的初始化能力。
假設有一個RPC client的管理類,維護了多個RPC的client。初始化的時候是傳入了一個配置文件,配置文件中維護了一個name到服務地址的映射。它的初始化函數僅僅是做了讀取文件解析配置,然后存儲了映射關系。但是并沒有檢查里面的服務地址(命名服務或者ip:port)是不是正確,這時候就需要我們自己進行額外的檢查,比如讓每個client發起一次探測的rpc。因為確實可能存在服務的地址筆誤填錯的可能。
無監控不系統
在開發階段考慮到關鍵日志的輸出、新增監控與報警。打印關鍵信息。
在C++代碼中應該避免實現顯式地使用new和delete。但是有時候代碼中仍然可能無法避免。比如brpc的bthread_start_background()函數函數需要接收一個void*類型的指針用來傳參,這時候一般是在外部使用new來創建一個參數對象,然后在回調函數中進行delete。
void* callback(void* arg) { Args* args = (Args*)arg; ... delete arg; return nullptr;}void foo() { Args * args = new Args; // args中的成員賦值 bthread_start_background(callback, (void*)args);}
但是如果callback中有多處return,很難保證能夠在每次return之前都能進行delete。這時候就可以利用std::unique_ptr。
void* callback(void* arg) { Args* args = (Args*)arg; std::unique_ptr<Args> up(args); ... if (...) { return nullptr; // 漏了delete } return nullptr;}
比如代碼中使用noexcept關鍵字,當遇到C++異常導致的coredump的時候,通過更加精準的棧信息,來快速定位。詳情可以閱讀 《一劍破萬法:noexcept與C++異常導致的coredump》
前面提到的經驗和方法都是編程語言、后臺服務設計方面比較純粹技術。但實際工作中還有很多導致線上事故或者導致二次開發,工作返工的事情是在實現產品或策略需求的過程中,對需求分析不到位,或者遺漏了本次需求與歷史需求的沖突點,導致邊界情況無法自測到而導致的。
隨著時間的推移,需求開發的越來越多。每次新的產品需求、策略需求都需要考慮到是否要同時修改一些已有的流程或邏輯。雖然也會做測試,但難免會遺漏一些冷門的情況。稍有遺漏就會造成新的需求在某些偶條件下不符合預期,有時候會引發線上大面積的用戶體驗case以及模塊穩定性故障。
這時候該怎么辦呢?其實沒有高明的方法。我個人的經驗是使用清單,也就是Checklists。
有一本書《清單革命》講述了使用清單如何重塑了醫療、航空、建筑以及投資領域。
這本書中有一句話說的不錯:
“無知之錯”可被原諒,“無能之錯”不被原諒。
這里的“無知之錯”指的是因為自身技術、知識水平不夠導致的錯誤,這種通常是能被原諒的。“無能之錯”(感覺翻譯的不夠好)并不是因為技術、知識水平不夠,而是因為馬虎大意導致本不該犯錯的地方犯了錯,這種錯誤不能被原諒!
其實前面介紹的編譯期、啟動時、運行時的經驗方法,也是在利用工具來避免“無能之錯”。而對于需求,對于業務邏輯僅僅這些還有些欠缺。
受此啟發,我認為也需要維護一個需求分析的清單。比如:
檢查項 | |
XXX是否受影響 | |
YYY是否受影響 | |
是不是要AAA | |
是不是要BBB |
在接到一個產品需求或者策略需求之后,對照清單來逐一核對。當然清單不能又臭又長,需要簡明扼要,不要妄圖做到大而全,這樣反而可能使得檢查變得流于形式,變成無腦操作,更重要的是通過清單中的檢查項建立一個需求分析的框架,在接到需求之后進行一系列思考分析,最終實際的檢查和思考其實可能會超越清單本身覆蓋的內容。
另外清單中的檢查項也是要經常更新的,要加入新的或者刪去舊的。并且也可以有多種不同的清單,應對不同類型的需求。
魏文王問扁鵲曰:「子昆弟三人其孰最善為醫?」扁鵲曰:「長兄最善,中兄次之,扁鵲最為下。」魏文侯曰:「可得聞邪?」扁鵲曰:「長兄於病視神,未有形而除之,故名不出於家。中兄治病,其在毫毛,故名不出於閭。若扁鵲者,鑱(chán)血脈,投毒藥,副肌膚,閑而名出聞於諸侯。
魏文王曾詢問扁鵲,他們兄弟三人誰的醫術最高明。扁鵲回答說大哥醫術最高,二哥其次,自己居末。但若論名氣卻正好相反,因為大哥在病人病情發作之前就加以防范,他的醫術只被家人知道,鮮有人知。二哥在病情剛剛顯露的時候進行治療,在家鄉內聞名。而自己治療的疾病已經在病情末期,需要用手術開刀并且使用猛烈的藥物來治療,但也因此在聞名于諸侯。
這個故事和中醫名著《黃帝內經》有共同之處,《黃帝內經》有云:
上醫治未病 中醫治欲病 下醫治已病
即醫術最高明的醫生能夠預防疾病的人。
且不討論中醫學說的科學性,也不糾結“醫術高超”該如何定義。這則小故事在職場中卻也常常被言中。比如A程序員善于防微杜漸,所負責的業務有很多風險都早早根除,減少了很多出現線上事故的風險。而B程序員并不精于此道,所轄業務事故不斷。但久而久之在領導眼中卻是另一番觀感:A程序員負責的業務簡單,缺乏挑戰。若程序員A偶有事故會被抓住不放,時常拿來說事。領導只會看到半年出了一次事故,卻不知道在日益龐大的系統規模下預防了多少個事故;而在領導眼中B程序員負責業務難度大,有挑戰,事故發生時能做到熬夜通宵排查解決,如此負責任,有擔當,不辭辛苦,兢兢業業,值得嘉獎。另外會對該業務重點關注,接著立專項、搞封閉,日會周會不斷,略有改觀便是莫大進步。
停下來想象一下,相信很多人也會有類似感受:在事故發生時出現的救火隊長是“鑱血脈,投毒藥,副肌膚“的英雄。卻記不得有哪些是“湯熨療疾”、“未有形而除之”的平凡人。
《孫子兵法》亦有云:
善戰者無赫赫之功。
本文鏈接:http://www.www897cc.com/showinfo-26-34665-0.html防微杜漸!向扁鵲學習治理代碼
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: Python中的協程,你知道怎么用嗎