每個開發者都會經歷這樣一個時刻——通常是在凌晨兩點左右,沐浴在顯示器冰冷的燈光下,手指懸在鍵盤上方,就像鋼琴家即將演奏拉赫曼尼諾夫的作品——這時你突然意識到,你苦苦追尋了三個小時的bug竟然是由一個拼寫錯誤引起的。而且還不是什麼有趣的拼字錯誤。只是少了一個分號,或是把變數名寫成了uesr而不是user 。
那一刻,你會感覺到人類所有缺點的重擔都壓在你的肩上。
我寫程式的時間比我願意承認的要長得多,而我學到的是:我們都會犯錯。每個人都會。剛完成訓練營的初級開發人員,正在艱難地進行第三次微服務遷移的中級工程師,以及忘記的程式語言比大多數人一生要學的還要多的高級架構師——我們都在這片數字荒野中跌跌撞撞,身後留下了一堆漏洞、反模式和技術債務。
但妙處就在於:錯誤其實都是等待被發現的模式。一旦你發現了這個模式,一旦你真正理解了為什麼事情總是出錯,你就能解決它。不僅是程式碼,還有你的思考方式。
這篇文章記錄了我曾經犯過、目睹過、在深夜除錯過,最後學會避免的程式錯誤。文章按技能等級分類,但我鼓勵你通讀全文,因為事實是,即使是資深開發者,在疲憊、匆忙或身處不熟悉的領域時,也會犯一些「新手」才會犯的錯誤。而且,我們作為新手犯的錯誤,有時會以複雜的方式貫穿我們整個職業生涯。
所以,給自己倒杯咖啡(或茶,我不會評判),找個舒服的地方坐下,讓我們來聊聊我們稱之為軟體開發的這團美麗的混亂吧。
當你剛開始學習程式設計時,錯誤訊息就像電腦在對你大吼大叫。它們用晦澀難懂的語言寫成,充斥著行號、堆疊追蹤和你看不懂的術語。你的第一個反應是驚慌失措,或是立刻逐字逐句地搜尋整個錯誤訊息,或是胡亂修改程式碼直到錯誤消失(旁白:這從來沒用)。
原因如下:錯誤訊息之所以令人畏懼,是因為它們暴露了我們的無知。它們證明我們不懂某些東西,而這種感覺令人不舒服。
解決方法:學會接受錯誤訊息。我是認真的。錯誤訊息是禮物。它們是電腦在試圖幫助你,告訴你到底哪裡出了問題。
首先仔細閱讀錯誤訊息。不要略讀,要認真閱讀。最重要的資訊通常位於堆疊追蹤的頂部或底部。尋找以下內容:
錯誤類型(TypeError、SyntaxError 等)
發生此問題的文件和行號
實際的資訊解釋了出了什麼問題。
我舉個例子。你看:
TypeError: Cannot read property 'length' of undefined
at validateInput (app.js:42)
at processForm (app.js:89)
這是在告訴你一個故事:“嘿,在 app.js 的第 42 行,在 validateInput 函數中,你試圖存取某個物件的 'length' 屬性,但該物件未定義——它不存在。”
現在你知道該從哪裡入手,該找什麼了。是不是某個變數傳遞錯誤?是不是之前的函數回傳了未定義狀態?錯誤訊息就像一張藏寶圖,指引你找到問題所在。
專業提示:剛開始的時候,不妨記個「錯誤日誌」。遇到錯誤時,記下錯誤訊息、你最初的理解、實際意義以及你的解決方法。幾個月後,你就能擁有一本專屬的除錯百科全書了。
Stack Overflow 是個很棒的資源,GitHub 也是個解決方案的寶庫。但幾乎所有新手都會掉入一個危險的陷阱:找到一段能用的程式碼,就直接照搬到自己的專案裡,看到它解決了眼前的問題,然後就繼續做其他事,卻從未真正理解它是如何運作的。
我曾經和一位初級開發人員共事,他直接從教學中複製並貼上了一整套身分驗證系統。一開始執行完美…直到有一天我們需要加入一個新功能。他對著程式碼看了兩個小時,最後才承認自己完全看不懂。結果我們不得不從頭開始重寫整個系統。
為什麼會這樣:學習的時候,你會面臨壓力──要交作業的壓力、要跟上進度的壓力、要避免出醜的壓力。複製貼上會讓你感覺效率很高。但它也是一種偽裝成高效率的拖延行為。
解決方法:使用「向橡皮鴨解釋」測試。在整合任何非原創程式碼之前,逐行檢查並解釋每一行的作用。如果可以,最好大聲解釋。可以跟朋友、寵物,或者,沒錯,一隻真正的橡皮鴨解釋。
如果你無法解釋它,表示你不理解它。如果你不理解它,當它出錯時(而它肯定會出錯),你就無法進行除錯。
以下是更佳的工作流程:
在 Stack Overflow 或其他地方尋找解決方案
仔細閱讀
關閉瀏覽器
試著憑記憶自己實現一下。
將你的版本與原版做比較
了解這些差異
一開始可能會比較慢,但你的學習速度會呈指數級增長。另外,你也不會再引進那些你根本無法修復的莫名其妙的bug了。
我看過一些奇葩的備份方式。我看過一些開發者把整個專案資料夾複製一份,然後在檔案名稱後面加上日期。我看過像project_final 、 project_final_FINAL 、 project_final_FINAL_actually_final的名字,還有我個人最喜歡的project_final_FINAL_actually_final_this_time_i_swear_v2 。
我還見過一些初學者使用 Git,但他們把它當作一個黑盒子——只是機械地輸入他們記住的命令,卻不理解這些命令的作用,然後一旦出現問題就驚慌失措。
原因如下:版本控制系統,尤其是 Git,學習曲線非常陡峭。提交、分支和合併的概念模型一開始確實很難理解。因此,人們要么完全避免使用它,要么只是淺嚐輒止。
解決方法:花一個週末認真學習 Git。不只是死記硬背指令,而是要理解其底層模型。以下是我總結的思路:
把 Git 想像成一棵快照樹。每次提交都是專案在某一時刻的快照。分支只是指向特定提交的標籤。合併時,你實際上是將兩個分支的歷史記錄結合起來。
首先要養成以下這些基本習慣:
儘早提交,頻繁提交。不要等到功能「完成」才提交。每當你完成一個邏輯單元的工作時,就應該提交。修復了一個 bug?提交。新增了一個函數?提交。每次提交都應該是原子性的——它應該只做一件事,而且這件事應該可以用一句話來描述。
寫出有意義的提交訊息,不要寫「修復了一些東西」或「更改了一些東西」。例如,「為註冊表單新增電子郵件驗證」或「修復使用者服務中的空指標異常」。未來的你會感謝自己的。
凡事都建立分支。開發新功能?建立分支。嘗試實驗性重構?建立分支。這樣你就可以自由地進行實驗,而不用擔心分支失效,因為你可以隨時放棄分支並回到原處。
深入學習這些命令:
git status (發生了什麼變化?)
git diff (具體更改了什麼?)
git log (歷史記錄是什麼?)
git checkout (在分支或提交之間切換)
git reset (撤銷本地操作)
git revert (撤銷歷史記錄中的操作)
這裡介紹一種能幫你省下無數時間的除錯技巧: git bisect 。它允許你透過二分查找遍歷提交歷史,精準定位引入 bug 的提交。簡直就像是穿越時空的除錯魔法。
剛開始程式設計的時候,你覺得程式碼能運作就夠了。誰會在意縮排是否一致或變數名稱晦澀難懂?只要能執行就行,不是嗎?
但兩週後你再去看那段程式碼,卻完全搞不懂它是做什麼用的。更糟的是,別人讀了之後,看你的眼神就像你遞給他們一張用剪報拼湊的勒索信。
原因如下:初學者低估了自己會遺忘多少知識,以及程式碼的閱讀頻率遠高於寫作頻率。程式碼的閱讀頻率大約是編寫頻率的 10 倍,甚至可能更高。
解決方法:制定一套語言風格並堅持下去。更好的方法是,使用程式碼檢查工具和格式化工具來管理你的語言:
JavaScript/TypeScript:ESLint + Prettier
Python:Black + Flake8
Ruby:魯博戰警
Java:Checkstyle
Go: gofmt(內建!)
在編輯器中設定這些自動執行。一開始,你可能會被那些紅色波浪線弄得心煩意亂。但慢慢地,你會逐漸理解這些規則,並自然而然地開始寫出更簡潔的程式碼。
除了工具之外,也要遵循以下原則:
命名比你想像的更重要。一個名為x變數什麼也告訴我不了。而一個名為userEmailAddresses的變數則能告訴我一切。沒錯,它確實更長。沒關係。磁碟空間很便宜,但你的時間和精力很寶貴。
一致性至關重要。選擇一種程式碼風格(駝峰式命名法還是蛇形命名法,製表符還是空格等等),並且始終保持一致。不一致會增加認知負擔。每次有人閱讀你的程式碼時,如果他們看到不尋常的程式碼風格,他們的大腦就必須停下來理解。
空格是你的朋友。程式碼是詩,而詩需要呼吸的空間。在邏輯部分之間用空白行分隔長函數。在運算符周圍加入空格。讓你的程式碼自由呼吸。
初學者常常在不了解所用工具的情況下就開始編寫程式碼。他們知道 Python 有列表和字典,就用它們來處理所有事情,卻沒意識到集合的存在,而集合其實非常適合解決這個問題。他們知道 JavaScript 有陣列,就手動遍歷陣列,卻不知道 map、filter 和 reduce 等函數。
我曾經親眼目睹一個初學者花了一整個下午的時間自己實現字串反轉函數,測試、除錯,結果最後卻發現他使用的程式語言其實已經內建了反轉方法。他臉上的表情簡直是晴天霹靂。
原因:文件看起來很枯燥,似乎會拖慢你的速度。你想做的是動手實踐,而不是閱讀如何建構東西。此外,官方文件對於新手來說可能枯燥乏味、技術性強,難以理解。
解決方法:改變你與文件的關係。不要把它看作苦差事,而要把它看作解鎖超能力的途徑。你花在閱讀文件上的每一小時,都代表你少花一小時重複造輪子。
首先可以嘗試以下策略:
完整閱讀入門指南。不要略讀,要認真閱讀。大多數函式庫和框架都有指南,教你理解基本概念和最佳實踐。這可是寶貴的資源。這是工具開發者的智慧結晶,他們正是你正在使用的工具的創造者。
編寫程式碼時,請務必開啟 API 參考文件。把它想像成你的魔法書。你是巫師,文件就是你的魔法書。當你需要使用某個函數時,查閱它,看看它接受哪些參數、傳回什麼值、有哪些特殊情況。
閱讀程式碼範例。官方文件通常會提供範例。仔細研究它們,執行它們,修改它們,甚至破壞它們並觀察會發生什麼。這是主動學習,而且效果顯著。
使用速查表。對於常用工具(例如 Git、Vim、SQL 等),最好準備一份速查表放在手邊。隨著時間的推移,你會記住基本操作,但快速查閱可以減少操作上的不便。
初學者常常認為「優秀的程式設計師」把所有東西都記住了——每個函數、每個文法細節、每個演算法。所以他們也試著記住一切,結果忘記了就覺得自己很笨。
告訴你個秘密:資深開發者也常上網搜尋。我用 Python 好幾年了,每次寫字串格式化程式碼的時候還是會查語法。我寫過幾百條 SQL 查詢語句,但如果好久沒用過 JOIN 語句,還是會查一下具體的語法。
造成這種情況的原因:有一種誤解認為專家甚麼都懂。這是錯誤的。專家擁有的是模式辨識能力、問題解決能力以及快速尋找資訊的能力。
解決方法:專注於理解概念和模式,而不是死記硬背語法。學習事物運作的原理,而不僅僅是輸入什麼。
例如,不必死記硬背每種語言中 for 迴圈的確切語法。相反,要理解循環的本質是迭代——重複執行某項操作。一旦你了解這個概念,尋找你所用語言的具體語法就輕而易舉了。
建構「第二個大腦」。這可以是:
個人維基(我用的是 Notion,有些人喜歡 Obsidian)
GitHub 上的 gist 集合
一個整理良好的書籤資料夾
一個你寫自己學到的東西的博客
當你解決問題或學到新知識時,用自己的話並舉例子把它寫下來。這樣做有兩個好處:寫作本身有助於你更好地理解,而且你還能建立一個個人參考指南,方便日後查閱。
對於新手來說,測試感覺像是額外的工作。程式碼已經可以執行了(至少在你嘗試的那個場景下似乎可以執行),那麼為什麼還要花時間寫測試呢?
然後你做了一個小小的改動,突然間一切都莫名其妙地崩潰了。或者你部署到生產環境,發現你的程式碼在你的機器上運作完美,但在實際環境中卻完全失敗。
原因如下:學習過程中,測驗感覺抽象而理論化,其益處並不顯而易見。此外,測驗還會增加複雜性——除了其他所有知識之外,你現在還需要學習一套測驗框架。
解決方法:從小處著手。你不需要馬上實現 100% 的測試覆蓋率或實踐測試驅動開發。只要從養成這個簡單的習慣開始:
寫完函數後,要為其編寫一些測試。測試正常情況,測試邊界情況,測試無效輸入時的行為。
例如,假設你寫了一個用來驗證電子郵件地址的函數:
def is_valid_email(email):
return '@' in email and '.' in email
編寫測試:
def test_valid_email():
assert is_valid_email('[email protected]') == True
def test_invalid_email_no_at():
assert is_valid_email('userexample.com') == False
def test_invalid_email_no_dot():
assert is_valid_email('user@example') == False
def test_empty_string():
assert is_valid_email('') == False
現在,當你寫第三個測試時,你可能會意識到你的函數無法正確處理空字串。恭喜你——你剛剛在它進入生產環境之前就發現了這個 bug。
隨著你越來越熟練,逐漸擴大測試範圍:
針對各個功能的單元測試
整合測試用於檢驗元件之間的協同工作。
針對關鍵使用者工作流程的端對端測試
擁有一套完善的測試套件所帶來的自信令人陶醉。你可以毫無顧慮地進行重構。你可以放心地進行更改,因為即使你破壞了某些東西,測試也能將其捕獲。
這是一個典型的陷阱。你在編寫程式碼時,會開始想:“這段程式碼可能會很慢。如果我需要處理一百萬個用戶怎麼辦?如果這個循環成為瓶頸怎麼辦?”
所以你花了三天時間用 Redis 實現了一個複雜的緩存系統,仔細優化了每一個查詢,並使用位操作來節省幾個字節的內存……然後你的應用程式用戶從未超過十個,而你卻毫無益處地讓你的程式碼庫變得無限複雜。
唐納德·克努特說得最好:“過早優化是萬惡之源。”
原因何在:優化讓人感覺高明而複雜。但它也是一種拖延——與其面對建立實際功能的艱鉅工作,不如花些時間調整效能來得輕鬆。
解決方法:請依下列優先順序操作:
使其正常運作-確保功能正常運作。
改正錯誤-重構程式碼以提高清晰度和可維護性
加快速度-優化,但前提是你有證據顯示它速度慢。
從最簡單的可行方案著手。使用最直接的資料結構。編寫清晰易懂的程式碼。在出現效能問題之前,無需擔心效能問題。
當您需要進行最佳化時,請遵循以下步驟:
首先要進行測量。使用效能分析工具來辨識真正的瓶頸。你不能僅僅依靠直覺來判斷哪裡執行緩慢。程式碼中最慢的部分幾乎從來不是你想像的那樣。
優化正確的地方。 80 /20 法則在效能最佳化中特別適用。通常,20% 的程式碼佔用了 80% 的執行時間。找到這 20% 的程式碼並對其進行最佳化,其餘部分則無需考慮。
保持簡潔。有時候,「優化」的解決方案實際上更簡單。使用內建方法和函式庫——它們通常已經過最佳化。不要自己編寫排序演算法;使用標準庫的排序函數。它速度更快,並且經過數百萬開發者的測試。
你已經寫程式碼一段時間了。你開發過一些專案,為一些程式碼庫做過貢獻,可能也把一些功能部署到了生產環境。你現在很厲害——你能解決遇到的絕大多數問題,而且開始對架構和設計模式有了自己的見解。
這是一個很棒的階段。同時,你也會在這個階段開始犯一些更複雜的錯誤。
我經常在中級開發人員身上看到這樣一種模式:他們剛剛學習了設計模式、微服務或任何熱門的架構趨勢,就想在所有地方都使用它。
他們需要儲存一些使用者設置,因此採用了倉庫模式,包括介面、依賴注入和三層抽象。原本只需要 20 行簡單程式碼就能實現的功能,他們卻寫了 200 行複雜的架構程式碼。
我做過這種事。我們都做過這種事。我曾經搭建了一個包含 12 個不同服務的系統,但其實兩個就夠了。結果有一天,我們需要增加一個簡單的功能,卻不得不修改所有 12 個服務,我才真正體會到自己狂妄自大的後果。
為什麼會這樣:當你達到中級時,你會對所有新概念感到興奮。你想證明自己掌握了高級技巧。你也想證明自己不再是新手了。而且說實話,編寫複雜的解決方案比編寫簡單的解決方案更有成就感。
解決方法:接受 YAGNI 原則—「你不需要它」。這項原則指出,你應該只在真正需要的時候才實施某些措施,而不是在你預想可能需要的時候就實施。
在增加複雜性之前,請先問自己以下問題:
這解決的是我現在遇到的問題,還是將來可能會遇到的問題?如果是後者,請稍等。未來的問題往往不會出現,或即使出現,也與你想像的截然不同。
我可以用更簡單的方法來解決這個問題嗎?通常情況下,可以。最簡單的解決方案往往也是最好的解決方案。它更容易理解、更容易修改、更容易除錯。
這種複雜性的代價是什麼?每一個抽象概念、每一種模式、每一個架構決策都有其代價。你需要付出認知負擔、維護所需的程式碼行數以及新開發人員的上手時間。那麼,這種收益是否值得付出這些代價呢?
我舉個具體的例子。假設你正在搭建一個博客,需要展示文章:
過度設計的方法:
# post_repository_interface.py
class PostRepositoryInterface:
def get_all(self): pass
def get_by_id(self, id): pass
# post_repository.py
class PostRepository(PostRepositoryInterface):
def __init__(self, db_connection):
self.db = db_connection
def get_all(self):
return self.db.query("SELECT * FROM posts")
def get_by_id(self, id):
return self.db.query("SELECT * FROM posts WHERE id = ?", id)
# post_service.py
class PostService:
def __init__(self, repository: PostRepositoryInterface):
self.repository = repository
def list_posts(self):
return self.repository.get_all()
# Then in your controller, you inject dependencies...
簡單方法:
# posts.py
def get_all_posts(db):
return db.query("SELECT * FROM posts")
def get_post_by_id(db, id):
return db.query("SELECT * FROM posts WHERE id = ?", id)
兩種方法都可行。第一種方法顯示你了解倉庫模式和依賴注入。第二種方法則以最小的複雜度真正解決了問題。
倉庫模式本身並不壞——它確實有實際的應用場景。但對於一個資料庫查詢簡單的部落格來說,它就顯得太複雜了。只有在真正需要的時候才使用它:例如在多個資料來源之間切換時,需要模擬資料庫進行測試時,或當這種複雜性是合理的。
智慧在於懂得何時該行動。
這就是中級開發者遇到的瓶頸。你的應用程式會進行 API 呼叫、資料庫查詢、檔案操作——所有這些都需要等待。所以你聽過應該「使用非同步」或「並發」來提升效能。
你在 JavaScript 程式碼中大量使用async和await ,或在 Python 腳本中加入線程,然後…事情就開始變得奇怪。出現了競態條件,資料損壞,有時運作正常,有時卻失敗,而你卻完全不知道原因。
我除錯過一些耗時數天才能找到的競態條件錯誤,這些錯誤大約每執行一百次才會出現一次,而且只在配備多核心 CPU 的生產伺服器上才會發生。這些錯誤簡直是惡夢。
原因如下:並發程式設計確實很難。它需要與順序編程不同的思考模式。當多個事件同時發生時,你需要考慮它們互動時會發生什麼,而這很快就會變得非常複雜。
解決方法:首先要理解並發和並行之間的差異:
並發是指同時處理多個任務。它關乎結構——組織你的程序,使多個任務能夠同時進行而無需相互等待。
並行處理是指同時執行多項任務。它關乎執行——實際上是在多個 CPU 核心上同時執行程式碼。
你可以實現並發而沒有並行(一個 CPU 核心,但任務輪流執行),也可以實現並行而沒有並發(多個 CPU 核心執行獨立任務)。
對於非同步操作(例如網路請求),通常需要的是並發,而不是並行。在 JavaScript 中使用 async/await 或在 Python 中使用 asyncio 時,程式碼並非同時執行——而是組織程式碼,使得當一個任務在等待(例如等待網路回應)時,另一個任務可以執行。
這裡有一個對我理解很有幫助的思考模型:把非同步操作想像成餐廳。你(服務生)從1號桌接單(啟動一個非同步操作)。在廚房準備1號桌的餐點時(操作處於等待狀態),你可以去接2號桌和3號桌的單。你並不是同時烹飪多道菜——你只是在等待的時候不會閒著。
非同步程式碼的關鍵原則:
1. 要真正理解什麼是異步。 CPU密集型操作(數學運算、資料處理)無法從非同步中獲益。 I/O密集型操作(網路、磁碟、資料庫)則可以。
2. 注意共享狀態。如果多個非同步操作修改相同的資料,可能會出現競態條件。要么避免共享狀態,要么使用鎖/互斥鎖來保護它。
3. 正確處理錯誤。在非同步程式碼中,錯誤處理可能很棘手。非同步操作中的異常可能不會像你預期的那樣向上冒泡。務必將非同步操作包裹在 try-catch 程式碼區塊中。
4. 不要過度使用非同步。並非所有操作都需要非同步。如果你的程式碼本身就是順序執行的,那就保持順序執行。非同步會增加程式碼的複雜度;只有當其帶來的好處(例如更高的資源利用率、更快的反應速度)大於成本時才應該使用非同步。
以下是一個 JavaScript 範例:
非同步操作不當:
async function processUsers() {
const users = await getUsers(); // Get users from database
for (let user of users) {
await sendEmail(user); // Send emails one by one, waiting for each
}
}
這比同步程式碼還糟糕!你明明用了異步,卻仍然要等上一封郵件發送完畢才能開始發送下一封。
良好的非同步使用方法:
async function processUsers() {
const users = await getUsers();
// Start all email operations concurrently
const emailPromises = users.map(user => sendEmail(user));
// Wait for all to complete
await Promise.all(emailPromises);
}
現在你實際上是在利用並發性。所有郵件幾乎同時開始發送,你只需等待它們全部發送完畢即可。
你已經學會了 SQL,可以寫查詢語句。你建立了一個功能,可以從資料庫載入資料並顯示出來。它運作良好…使用包含 50 條記錄的測試資料。
然後你把系統部署到生產環境,那裡的資料庫表有 50 萬筆記錄,突然間每個頁面都要載入 30 秒。使用者都氣瘋了。老闆也開始問問題。你瘋狂地在谷歌上搜尋「為什麼我的資料庫這麼慢」。
原因如下:資料庫效能並非顯而易見。看似相同的查詢,其效能表現可能因索引、連接和表大小的不同而大相徑庭。在開發小型資料集時,這些問題往往難以察覺。
解決方法:從一開始就要考慮資料庫效能。以下是關鍵原則:
索引是你的好幫手。索引就像書裡的索引一樣——它能讓你無需翻閱每一頁就能找到所需內容。如果你經常需要按某一列進行搜尋或篩選,那麼該列就需要一個索引。
沒有索引:
SELECT * FROM users WHERE email = '[email protected]';
-- Database scans every row - O(n) operation
附電子郵件索引:
CREATE INDEX idx_users_email ON users(email);
SELECT * FROM users WHERE email = '[email protected]';
-- Database uses index - O(log n) operation
兩者之間的差異簡直是天壤之別。對於一個擁有百萬行資料的表,未建立索引的查詢可能需要幾秒鐘,而建立索引的查詢只需幾毫秒。
但不要為所有資料都建立索引。索引是有代價的——它們會佔用空間,而且會降低寫入(插入、更新、刪除)速度,因為索引本身也需要更新。只為那些經常查詢的欄位建立索引,特別是在 WHERE 子句、JOIN 條件和 ORDER BY 子句中。
N+1 查詢簡直是魔鬼。這可能是最常見的資料庫效能優化錯誤。這種情況發生在你載入一個專案列表,然後遍歷列表中的每個專案並執行一次資料庫查詢時。
# BAD: N+1 queries
posts = db.query("SELECT * FROM posts")
for post in posts:
author = db.query("SELECT * FROM users WHERE id = ?", post.author_id)
post.author = author
# If you have 100 posts, this makes 101 database queries!
# GOOD: Use a JOIN
posts = db.query("""
SELECT posts.*, users.name as author_name
FROM posts
JOIN users ON posts.author_id = users.id
""")
# One query, no matter how many posts
在第一個範例中,如果你有 100 篇文章,你需要執行 101 次資料庫查詢(一次查詢文章,然後 100 次查詢作者)。在第二個例子中,你只需要執行一次查詢就能將所有資訊合併在一起。這樣速度可以提升 100 倍。
使用 EXPLAIN 指令。大多數資料庫都提供 EXPLAIN 命令,它可以顯示資料庫將如何執行查詢。務必學會解讀 EXPLAIN 指令的輸出結果。它會告訴你是否使用了索引、掃描了多少行以及瓶頸在哪裡。
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
輸出結果會告訴你查詢的執行過程。如果在一個大型表上看到「全表掃描」的輸出,則表示你需要建立索引。
只載入你需要的資料。如果只需要幾列資料,就不要使用SELECT *查詢。資料庫仍然需要載入所有資料,然後你還要透過網路發送這些資料。務必明確你的請求:
-- Instead of this:
SELECT * FROM users;
-- Do this:
SELECT id, name, email FROM users;
分頁至關重要。如果要顯示專案列表,請不要一次加載所有內容。請使用 LIMIT 和 OFFSET 函數:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 0;
這樣每次只載入 20 篇文章。反正你的用戶可能也只會瀏覽第一頁。
中級開發人員編寫程式碼時常常隱含一個假設:一切都會正常運作。網路請求會成功。文件會存在。第三方 API 可用。
然後,生產環境開始出現故障,你才意識到你的應用根本無法應付。應用程式崩潰,資料損壞,使用者看到晦澀難懂的錯誤訊息。
原因如下:在本地開發時,一切通常都很正常。網路速度很快,服務也在運作。你不會遇到現實世界中的各種混亂情況。
解決方法:採取防禦心態。假設事情會失敗,並為此做好準備。
任何外部相依性都可能發生故障。網路呼叫、資料庫查詢、檔案操作——所有這些都可能以多種方式失敗。它們可能逾時,可能會回傳錯誤,服務可能宕機,連線可能在操作過程中斷開。
將外部操作放在 try-catch 程式碼區塊中:
async function getUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch user data:', error);
// Return a sensible default or rethrow with context
return null;
}
}
設定超時時間。網路操作絕不能無限期掛起。請設定合理的超時時間:
import requests
try:
response = requests.get('https://api.example.com/data', timeout=5)
except requests.Timeout:
print("Request timed out")
except requests.RequestException as e:
print(f"Request failed: {e}")
實現帶退避的重試機制。瞬態故障(例如網路暫時中斷、服務短暫不可用)通常可以透過重試解決。但不要立即重試——使用指數退避演算法:
import time
def fetch_with_retry(url, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(url, timeout=5)
return response
except requests.RequestException as e:
if attempt < max_retries - 1:
wait_time = 2 ** attempt # 1s, 2s, 4s
time.sleep(wait_time)
else:
raise # Give up after max retries
嚴格驗證輸入。永遠不要輕信任何來源的輸入——無論是使用者輸入、API輸入或其他任何來源。檢查類型、範圍和格式:
def process_age(age_str):
try:
age = int(age_str)
except ValueError:
return "Invalid age: not a number"
if age < 0 or age > 150:
return "Invalid age: out of range"
return f"Age is valid: {age}"
實現優雅降級。如果非關鍵服務發生故障,您的應用程式不應該完全崩潰。例如,推薦引擎可能宕機了——沒關係,只需顯示預設清單即可:
def get_recommendations(user_id):
try:
return recommendation_service.get_for_user(user_id)
except ServiceUnavailableError:
# Fallback to a default list
return get_popular_items()
監控並發出警報。你無法解決你不知道的問題。使用日誌記錄和監控工具。當出現故障時,你應該在用戶開始抱怨之前就了解情況。
你寫的程式碼在開發階段執行完美。部署後,隨著時間的推移,應用程式開始佔用越來越多的記憶體。最終,它崩潰並出現“內存不足”錯誤。
或者你打開一個文件,讀取其中的資料,然後忘記關閉它。你不斷重複這個過程,突然間你就無法再開啟任何檔案了,因為你已經達到了作業系統的檔案描述符限制。
原因如下:資源管理在系統正常運作時是看不見的。你看不到記憶體的分配,也看不到檔案句柄的消耗。直到資源耗盡為止。
解決方法:了解你的程式碼所使用的資源,並有意識地管理它們。
記憶體洩漏時有發生。即使是使用垃圾回收機制的語言,也可能發生記憶體洩漏。常見原因:
1. 永無止境成長的全球性國家:
// BAD: This cache grows without bound
const cache = {};
function getUserData(userId) {
if (cache[userId]) {
return cache[userId];
}
const data = fetchUser(userId);
cache[userId] = data; // Never removed!
return data;
}
修復方案:實現快取清除:
const cache = new Map();
const MAX_CACHE_SIZE = 1000;
function getUserData(userId) {
if (cache.has(userId)) {
return cache.get(userId);
}
const data = fetchUser(userId);
if (cache.size >= MAX_CACHE_SIZE) {
// Remove oldest entry
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(userId, data);
return data;
}
2. 未被移除的事件監聽器:
// BAD: Listener is never removed
element.addEventListener('click', handleClick);
如果不斷加入監聽器而不刪除舊的監聽器,它們就會在記憶體中不斷累積。
// GOOD: Remove when done
element.addEventListener('click', handleClick);
// Later, when the element is removed:
element.removeEventListener('click', handleClick);
3. 未清除的計時器:
// BAD: Timer keeps running even after component unmounts
setInterval(() => {
updateData();
}, 1000);
// GOOD: Clear timer when done
const timerId = setInterval(() => {
updateData();
}, 1000);
// Later:
clearInterval(timerId);
顯式關閉資源。檔案、資料庫連線、網路套接字——這些都會消耗系統資源。打開它們,使用它們,然後關閉它們。大多數程式語言都有上下文管理器來實現這一點:
# BAD: File might not get closed if an error occurs
file = open('data.txt', 'r')
data = file.read()
process(data)
file.close()
# GOOD: File is guaranteed to close, even if an error occurs
with open('data.txt', 'r') as file:
data = file.read()
process(data)
# File automatically closed here
處理大型資料結構時要格外小心。將 1GB 的檔案完全載入記憶體中無異於自取滅亡。建議改為串流:
# BAD: Loads entire file into memory
with open('huge_file.txt', 'r') as file:
contents = file.read()
for line in contents.split('\n'):
process(line)
# GOOD: Processes one line at a time
with open('huge_file.txt', 'r') as file:
for line in file: # This streams, not loads all at once
process(line)
分析記憶體使用情況。使用工具查看記憶體都流向了哪裡:
Python: memory_profiler
JavaScript:Chrome 開發者工具堆快照
Java:VisualVM、JProfiler
這些工具可以顯示哪些物件正在佔用內存,從而揭示你之前不知道存在的內存洩漏。
你在程式碼庫、教學或你欣賞的庫中看到某種模式。你並不完全理解為什麼要這樣做,但它似乎是“正確的方法”,所以你在自己的程式碼中處處複製它。
或許是某種特定的資料夾結構,或許是某種特定的類組織方式,或許是某種類似單例或工廠模式的設計模式。你使用它只是因為你看到資深開發人員在使用它,而不是因為你理解它在什麼情況下以及為什麼適用。
這是貨物崇拜式的洗腦——在不理解其目的的情況下進行儀式。
為什麼會出現這種情況:我們透過模仿來學習,這本身並沒有錯。但我們有時會忽略理解模仿物件背後的脈絡和邏輯這一步驟。
解決方法:當你發現某種模式或做法時,一定要問“為什麼?”,深入探究其背後的原因:
為什麼選擇這種模式?它解決了什麼問題?還有哪些替代方案,為什麼它們都被否決了?
權衡利弊是什麼?每個設計決策都有成本和效益。了解這兩者有助於你判斷何時應用該模式,何時不應用。
如果我不使用這種模式會怎樣?有時答案是「沒什麼大不了的」。有些模式解決的是你根本不存在的問題。
我舉個例子:單例模式。它確保一個類別只有一個實例,並提供一個全域存取點來存取它。
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = createConnection();
Database.instance = this;
}
}
// Only one instance ever created
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true
看起來很精緻!但究竟什麼時候才該用它呢?
在下列情況下使用單例模式:
你確實只需要一個實例(例如資料庫連線池)。
此實例需要全域可存取
建立多個實例會導致問題(資源衝突、資料不一致)。
以下情況請勿使用單例模式:
你只是想整理函數(建議使用模組)。
你用它是為了避免傳遞依賴項(這會使測試更難)。
你這是盲目照搬,因為你在一本設計模式書裡看到了它。
單例模式在許多情況下已經不再流行,因為它會建立隱藏的依賴關係,使測試變得困難。在現代程式碼中,依賴注入通常是首選:
// Instead of Singleton:
class UserService {
constructor(database) {
this.db = database; // Dependency is explicit
}
getUser(id) {
return this.db.query(`SELECT * FROM users WHERE id = ?`, id);
}
}
// In your app setup:
const db = new Database();
const userService = new UserService(db);
現在依賴關係是明確的、可測試的、靈活的。
教訓是:不要因為模式聽起來很聰明就使用它們,而應該因為它們能解決你遇到的具體問題而使用它們。
你的程式碼已部署。一位用戶報告了一個錯誤。你嘗試在本地重現該錯誤,但一切正常。你聳聳肩,認為可能是使用者操作有誤。
同時,你的生產日誌充斥著大量的錯誤訊息,告訴你到底出了什麼問題,但你卻視而不見。
原因:日誌記錄感覺像是無意義的重複勞動。它們資訊量很大,而且充斥著看似無關的資訊。此外,正確設定日誌記錄似乎也很複雜。
解決方法:日誌是發現漏洞的藏寶圖。學會有效地使用它們。
實作結構化日誌記錄。不要只是列印隨機字串。使用支援等級、上下文和結構的日誌框架:
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Use appropriate levels
logger.debug("Detailed info for debugging")
logger.info("General information about program execution")
logger.warning("Something unexpected but not critical")
logger.error("An error occurred, but program continues")
logger.critical("Critical error, program might crash")
# Add context
logger.info("User logged in", extra={'user_id': 12345, 'ip': '192.168.1.1'})
記錄正確的內容:
記錄錯誤時要包含完整的上下文資訊。不僅僅是“發生錯誤”,而是“無法獲取用戶 ID 為 12345 的用戶資料:連接逾時”。
記錄重要業務事件。例如用戶註冊、購買、重大狀態變更等。
記錄性能指標。該資料庫查詢耗時多久?處理了多少項?
請勿記錄敏感資料。請勿記錄密碼、信用卡資訊或個人辨識資訊。
請合理使用日誌等級:
除錯模式:用於診斷問題的詳細資訊。通常不會在生產環境中啟用。
訊息:確認一切運作正常。
警告:發生了一些意外情況,但應用程式可以繼續執行。
錯誤:發生嚴重問題。部分功能失效。
嚴重:非常嚴重的問題。應用程式可能無法繼續執行。
在生產環境中,您可以將等級設定為 INFO 或 WARNING,這樣 DEBUG 日誌就不會淹沒您的系統。
使日誌可搜尋。使用日誌聚合工具(例如 Elasticsearch、Splunk、CloudWatch 等),以便搜尋和篩選日誌。您需要能夠回答以下問題:
“請顯示過去一小時內的所有錯誤”
“顯示所有耗時超過5秒的API請求”
“顯示所有與使用者 ID=12345 相關的日誌”
設定警報。當出現特定錯誤情況時,您應該立即收到通知。不要等待用戶報告問題。
你現在是一名資深開發人員。你已經交付了多個大型專案。你指導過初級開發人員。你參與架構決策。你被委以重任,負責複雜而關鍵的系統。
然而,你依然會犯錯。不同的錯誤,更隱密的錯誤,但終究是錯誤。這些錯誤源自於經驗──只有當你累積了足夠的經驗,足以嘗試真正困難的事情之後,才會犯這樣的錯誤。
這是過度設計的邪惡孿生兄弟,而且更陰險,因為它看起來像是一種良好的實踐。你看到一些重複的程式碼,你的直覺會告訴你「DRY!不要重複自己!」於是你立刻把它抽象成一個共享函數或類別。
問題在於,這兩段看起來相似的程式碼實際上可能代表著不同的關注點,只是目前看起來相似。當你將它們抽象化時,你實際上是將兩個原本應該獨立的部分耦合在了一起。之後,當需求改變時(而需求總是會改變的),你需要修改抽象層來處理這兩種情況,這會增加複雜性和條件邏輯,最終抽象層比直接複製程式碼還要複雜。
我曾經建立過一個抽象層來處理系統中的「使用者操作」。它看起來完美無缺——登入、註冊、個人資料更新,所有操作都遵循相同的模式。六個月後,每種操作類型都發生了巨大的變化,導致這個抽象層變成了一團亂麻,充斥著各種 if 語句和特殊情況。最終,我們刪除了它,並分別重寫了每種操作。單獨的實作方式更加清晰,也更容易維護。
原因如下:我們都聽過「DRY」(Don't Repeat Yourself,不要重複自己)原則被奉為圭臬。我們從小就被教育要把重複程式碼視為壞事。此外,建立抽象層感覺很高級——感覺就像在編寫「簡潔的程式碼」。
解決方法:遵循「三法則」。在三次遇到相同模式之前,不要進行抽象。第一次寫出來的時候,你是在學習需要掌握的知識。第二次,你是在驗證模式。第三次,你才算真正了解模式,可以安全地進行抽象。
即使如此,也要問問自己:
它們真的是同一件事嗎?還是只是碰巧現在看起來很像?兩段程式碼可能結構相似,但代表不同的領域概念。
它們會一起演進還是各自獨立發展?如果它們可能因為不同的原因而發生變化,那就應該將它們分開。這其實就是單一職責原則的另一個體現——不同的職責應該分開,即使程式碼看起來相似。
抽象化是否比重複實現更簡潔?抽象化應該降低複雜性,而不是增加複雜性。如果你的抽象化需要大量的參數、配置選項和條件邏輯,那麼它可能比重複實作更糟。
請看這個例子:
# You have two similar functions:
def send_welcome_email(user):
subject = "Welcome to our platform!"
body = f"Hello {user.name}, welcome!"
send_email(user.email, subject, body)
log_email_sent(user.id, 'welcome')
def send_password_reset_email(user, token):
subject = "Reset your password"
body = f"Hello {user.name}, use this token: {token}"
send_email(user.email, subject, body)
log_email_sent(user.id, 'password_reset')
你的第一個反應可能是抽象思考:
# Premature abstraction:
def send_user_email(user, email_type, extra_data=None):
if email_type == 'welcome':
subject = "Welcome to our platform!"
body = f"Hello {user.name}, welcome!"
elif email_type == 'password_reset':
subject = "Reset your password"
token = extra_data['token']
body = f"Hello {user.name}, use this token: {token}"
# More elif blocks as we add email types...
send_email(user.email, subject, body)
log_email_sent(user.id, email_type)
這種抽象方式已經顯得有些混亂了。如果再增加第三種郵件類型(確認郵件、通知郵件等),情況會更糟。更好的方法或許是將它們分開,或創造一個更靈活的抽象層:
# Better: Keep them separate or use composition
class EmailTemplate:
def __init__(self, subject, body_template):
self.subject = subject
self.body_template = body_template
def render(self, **kwargs):
return self.body_template.format(**kwargs)
def send_templated_email(user, template, log_type, **kwargs):
body = template.render(name=user.name, **kwargs)
send_email(user.email, template.subject, body)
log_email_sent(user.id, log_type)
# Usage:
welcome_template = EmailTemplate(
"Welcome to our platform!",
"Hello {name}, welcome!"
)
send_templated_email(user, welcome_template, 'welcome')
reset_template = EmailTemplate(
"Reset your password",
"Hello {name}, use this token: {token}"
)
send_templated_email(user, reset_template, 'password_reset', token=token)
這種方式更靈活,不需要為每種電子郵件類型撰寫條件邏輯。
記住:適度的重複總比錯誤的抽像好。當你對領域有更深入的理解時,總可以再進行抽象。過早的抽像比重複的抽象更難撤銷。
您的系統正在生產環境中執行,並且分佈在多個服務中。現在出現了一些問題——用戶報告頁面加載緩慢、偶爾出現錯誤或行為異常——但您完全不知道系統內部究竟發生了什麼。
你加入了一堆列印語句並重新部署。現在你被日誌淹沒了,但你仍然無法弄清楚為什麼某個特定的請求失敗了,或者為什麼上週二凌晨 3 點延遲會飆升。
原因如下:我們在建置系統時,往往專注於「正常流程」-確保各項功能都能正常運作。可觀測性常常被我們視為事後才考慮的因素,彷彿是以後再加入的東西。但當你真正需要它的時候,再進行補救就困難得多。
解決方法:從一開始就進行可觀測性設計。可觀測性意味著能夠透過檢查系統的輸出來了解系統內部正在發生的事情。
可觀測性的三大支柱是:
1. 日誌記錄- 離散事件(「使用者 123 已登入」、「付款已處理」)
2. 指標- 隨時間變化的數值測量值(請求速率、錯誤率、CPU 使用率)
3. 追蹤- 追蹤請求在整個系統中的流轉過程
我們來逐一談談:
日誌記錄(我們之前已經介紹過,但讓我們深入了解):
在分散式系統中,關聯性至關重要。當一個請求流經多個服務時,你需要能夠追蹤它的整個路徑。使用關聯 ID:
import uuid
from flask import Flask, request, g
app = Flask(__name__)
@app.before_request
def before_request():
# Get correlation ID from header, or generate new one
g.correlation_id = request.headers.get('X-Correlation-ID', str(uuid.uuid4()))
@app.route('/api/users')
def get_users():
logger.info(
"Fetching users",
extra={'correlation_id': g.correlation_id}
)
# ... rest of handler
# When calling another service, pass the correlation ID
headers = {'X-Correlation-ID': g.correlation_id}
response = requests.get('http://other-service/data', headers=headers)
現在,您可以透過在日誌中搜尋關聯 ID 來追蹤所有服務中的單一請求。
指標:
指標可以幫助您發現趨勢和模式。可以追蹤以下方面:
請求速率(每秒請求數)
錯誤率(失敗請求的百分比)
延遲(請求所需時間)
資源使用情況(CPU、記憶體、磁碟)
from prometheus_client import Counter, Histogram
import time
# Define metrics
requests_total = Counter('requests_total', 'Total requests', ['method', 'endpoint'])
request_duration = Histogram('request_duration_seconds', 'Request duration')
@app.route('/api/users')
def get_users():
requests_total.labels(method='GET', endpoint='/api/users').inc()
start_time = time.time()
try:
# Handle request
return jsonify(users)
finally:
duration = time.time() - start_time
request_duration.observe(duration)
這些指標可以繪製成圖表,讓你看到隨時間變化的規律。凌晨 3 點錯誤率是否飆升?圖表顯示了這一點。過去一週延遲是否逐漸增加?你也能看到。
追蹤:
分散式追蹤可以顯示請求在系統中的傳輸路徑以及每個步驟所花費的時間。這對於除錯微服務至關重要。
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
@app.route('/api/order')
def create_order():
with tracer.start_as_current_span("create_order") as span:
span.set_attribute("user_id", user_id)
# This creates a child span
with tracer.start_as_current_span("validate_payment"):
result = validate_payment(payment_info)
# Another child span
with tracer.start_as_current_span("reserve_inventory"):
inventory.reserve(items)
return {"order_id": order_id}
透過追踪,可以看到create_order請求總共耗時 500 毫秒,其中validate_payment耗時 450 毫秒, reserve_inventory耗時 30 毫秒。現在你知道該從哪裡優化了。
儀錶板和警報:
如果無人查看,可觀測性資料就毫無用處。建立儀表板,讓系統健康狀況一目了然:
請求速率和錯誤率
潛伏期(p50、p95、p99 百分位數)
資源使用情況
關鍵業務指標(每分鐘訂單量、活躍用戶數)
設定異常警報:
錯誤率高於1%
延遲 p95 大於 2 秒
日誌中的任何嚴重錯誤
服務健康檢查失敗
目標是在用戶發現問題之前就了解問題。
你設計了一個漂亮的系統。架構優雅,程式碼簡潔,正常流程也經過了徹底測試。然後你部署了系統,結果卻出現了你從未預料到的種種問題。
使用者輸入帶有表情符號的姓名,導致資料庫崩潰。兩個請求同時到達,建立了重複記錄。你所依賴的某個服務開始傳回格式錯誤的資料。網路隨機丟包,系統進入不一致狀態。
原因在於:我們通常會考慮事物應該如何運作,而不是它們可能會發生哪些故障。極端情況看似不太可能發生,所以我們不會為此做好規劃。但在一個擁有數百萬用戶的分散式系統中,小機率事件會不斷發生。
解決方法:培養一種防禦性的、多疑的心態。假設一切都會失敗,並為此做好準備。
考慮邊界條件:
如果清單為空怎麼辦?
如果字串非常非常長呢?
如果這個數字是零呢?負數呢?無窮大呢?
如果日期是過去呢?或者遙遠的未來呢?
def calculate_average(numbers):
# What if numbers is empty?
if not numbers:
return 0 # or raise an exception, or return None—but handle it!
return sum(numbers) / len(numbers)
def process_text(text):
# What if text is None? What if it's enormous?
if text is None:
return ""
if len(text) > 10_000:
# Prevent DoS attack via huge input
raise ValueError("Text too long")
return text.strip().lower()
考慮競態條件:
當多件事同時發生時,它們可能會以意想不到的方式相互作用。
# BAD: Race condition
def increment_counter(user_id):
count = db.get_counter(user_id) # Read: count = 5
count += 1 # Increment: count = 6
db.set_counter(user_id, count) # Write: count = 6
# If two requests do this simultaneously:
# Request A reads: count = 5
# Request B reads: count = 5
# Request A writes: count = 6
# Request B writes: count = 6
# Final count is 6, not 7!
# GOOD: Atomic operation
def increment_counter(user_id):
db.atomic_increment('counters', user_id)
# This is handled atomically by the database
考慮一下部分失敗的情況:
在分散式系統中,操作可能會在進行過程中失敗,導致系統處於不一致的狀態。
def transfer_money(from_account, to_account, amount):
# What if we succeed in debiting but fail in crediting?
debit(from_account, amount) # Succeeds
credit(to_account, amount) # Fails! Now money vanished!
解決方案:使用交易或冪等性:
def transfer_money(from_account, to_account, amount, transfer_id):
# Use database transaction for atomicity
with db.transaction():
# Check if already processed (idempotency)
if db.exists('transfers', transfer_id):
return # Already processed
debit(from_account, amount)
credit(to_account, amount)
db.insert('transfers', {'id': transfer_id, 'status': 'completed'})
# Either everything succeeds or everything rolls back
想想連鎖故障:
當一項服務發生故障時,是否會導致其他服務也出現故障?
# BAD: Cascading failure
def get_user_profile(user_id):
user = user_service.get(user_id) # If this times out...
orders = order_service.get_orders(user_id) # ...we never get here
recommendations = rec_service.get_recs(user_id) # ...or here
return render(user, orders, recommendations)
# GOOD: Isolated failures
def get_user_profile(user_id):
user = user_service.get(user_id) # Critical
try:
orders = order_service.get_orders(user_id, timeout=1)
except TimeoutError:
orders = [] # Degrade gracefully
try:
recommendations = rec_service.get_recs(user_id, timeout=1)
except TimeoutError:
recommendations = get_default_recommendations()
return render(user, orders, recommendations)
使用斷路器:
如果某個服務反覆故障,請暫時停止呼叫該服務,讓它恢復正常:
class CircuitBreaker:
def __init__(self, failure_threshold=5, timeout=60):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.timeout = timeout
self.opened_at = None
self.state = 'closed' # closed, open, half-open
def call(self, func):
if self.state == 'open':
if time.time() - self.opened_at > self.timeout:
self.state = 'half-open'
else:
raise CircuitBreakerOpen("Service unavailable")
try:
result = func()
if self.state == 'half-open':
self.state = 'closed'
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.state = 'open'
self.opened_at = time.time()
raise
這樣可以防止級聯故障——如果某個服務宕機,你就不會再用注定會失敗的請求轟炸它了。
您正在建立一個包含多個服務、資料庫、佇列和快取的複雜系統。要使其在本地執行,新開發人員需要:
安裝 15 種不同的工具
按正確順序執行 10 個命令
編輯 6 個設定檔
祈禱一切順利啟動。
搭建一個可用的本地環境需要兩天。程式碼審查耗時極長,因為審查人員無法輕鬆測試變更。除錯也十分痛苦,因為沒有簡單的方法可以在本地重現生產環境的場景。
原因在於:我們優先考慮使用者導向的功能,而非開發者工具。讓開發者使用起來更方便,感覺像是“錦上添花”,而非必不可少。但糟糕的開發者體驗會不斷累積──最終拖慢一切。
解決方案:投資簡化開發流程。回報巨大。
簡化設定:
理想情況下,一名新開發人員應該能夠執行一個命令並獲得一個可執行的環境:
# Clone repo
git clone repo-url
cd project
# Single command to set up everything
make setup
# Single command to run everything
make run
使用 Docker Compose 打包所有相依性:
# docker-compose.yml
version: '3'
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- database
- redis
environment:
DATABASE_URL: postgres://db:5432/myapp
REDIS_URL: redis://redis:6379
database:
image: postgres:13
environment:
POSTGRES_DB: myapp
redis:
image: redis:6
現在只需執行docker-compose up就能啟動所有元件。無需安裝,無需配置,開箱即用。
編寫優秀的文件:
不僅僅是 API 文件——還要記錄整個開發者體驗:
如何建構開發環境
如何執行測試
如何除錯常見問題
建築設計決策及其原因
如何新增常見類型的功能
確保文件可搜尋並保持更新。過時的文件比沒有文件更糟糕。
建立除錯工具:
建構便於除錯的工具:
# Development-only endpoint that shows system state
@app.route('/debug/status')
def debug_status():
if not app.debug:
abort(404)
return {
'database': db.is_connected(),
'redis': redis.ping(),
'queue_size': queue.size(),
'active_users': session_store.count(),
'feature_flags': feature_flags.all()
}
快速回饋循環:
從做出改變到看到效果的時間應該越短越好:
快速測試(在幾秒鐘內執行單元測試,而不是幾分鐘)
熱重載(程式碼變更立即生效)
易於部署到測試環境
正確的錯誤訊息:
開發過程中出現問題時,錯誤訊息應該告訴你如何解決:
# BAD
if not config.api_key:
raise ValueError("API key missing")
# GOOD
if not config.api_key:
raise ValueError(
"API key missing. Set the STRIPE_API_KEY environment variable. "
"For local development, copy .env.example to .env and add your key."
)
安全問題在真正發生之前,往往給人一種抽象的感覺。因此,人們很容易將其推遲——“我們以後再加入身份驗證”,“我們最終會加密這些資料”,“我們規模太小,沒人會盯上我們”。
然後,你的系統就遭到入侵,用戶資料洩露,公司因為負面新聞而登上頭條。或者,你之後試圖增加安全措施,卻發現這需要重寫一半的系統。
原因何在:安全性似乎會拖慢開發速度,增加複雜性。此外,安全漏洞看似只是假設,直到它們成為現實。
解決方法:從一開始就建立安全機制。這比事後加裝安全裝置要容易得多。
永遠不要相信使用者輸入。這是黃金法則。驗證、清理、轉義——始終如此。
# SQL Injection vulnerability
def get_user(username):
# NEVER do this!
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query)
# If username is: "admin' OR '1'='1"
# Query becomes: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
# Returns all users!
# SAFE: Use parameterized queries
def get_user(username):
query = "SELECT * FROM users WHERE username = ?"
return db.execute(query, (username,))
XSS(跨站腳本攻擊)防護:
// DANGEROUS: Inserting user content directly into HTML
element.innerHTML = userData;
// If userData is: "<script>alert('hacked')</script>"
// The script executes!
// SAFE: Escape HTML entities
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
element.textContent = userData; // Also safe—textContent doesn't interpret HTML
身份驗證和授權:
使用成熟的函式庫(OAuth、JWT),不要自己編寫。
使用 bcrypt 或 Argon2 對密碼進行雜湊處理,切勿以明文形式儲存密碼。
所有地方都應使用 HTTPS,沒有任何例外。
實施速率限制以防止暴力破解攻擊
from werkzeug.security import generate_password_hash, check_password_hash
# Storing password
hashed = generate_password_hash(password)
db.save_user(username, hashed)
# Verifying password
stored_hash = db.get_user_hash(username)
if check_password_hash(stored_hash, provided_password):
# Login successful
最小特權原則:
只授予使用者和服務所需的權限,不多也不少。
# BAD: Application connects to database as admin
db = connect(user='admin', password='admin_pass')
# GOOD: Application has limited permissions
db = connect(user='app_user', password='app_pass')
# app_user can only SELECT, INSERT, UPDATE on specific tables
# Cannot DROP tables, CREATE users, etc.
保持依賴項更新:
庫中漏洞層出不窮,務必及時修補。
# Regularly check for vulnerabilities
npm audit
pip-audit
設定自動相依性更新(Dependabot、Renovate),以便在出現安全性問題時收到通知。
加密敏感資料:
對靜態資料(資料庫、備份)進行加密
傳輸中資料加密(HTTPS、TLS)
切勿記錄敏感資料(密碼、信用卡資料、社保號碼)
安全標頭:
@app.after_request
def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
return response
這些標頭可以防止常見的攻擊,例如點擊劫持和 XSS 攻擊。
您的應用程式每秒可以完美處理 100 個請求。您的資料庫查詢速度很快。您的 API 回應時間為 50 毫秒。一切都很棒。
六個月後,你的請求量達到了每秒 10,000 次。你的資料庫不堪負荷。你的 API 回應時間長達 10 秒。你的架構在小規模運作時完美無缺,但在高負載下卻搖搖欲墜。
原因如下:過早優化固然不好,但完全忽略可擴展性也同樣不可取。你需要找到平衡點——不要為了不需要的規模而過度設計,但也不要把自己逼入絕境。
解決方法:設計時要考慮下一個數量級。
如果今天有 100 個用戶,那就以 1000 個用戶來設計。當使用者達到 1000 個時,再按 10000 個使用者重新設計。不要從一開始就試圖設計成能容納 100 萬用戶——那會過度設計,浪費時間。但也不要設計得讓後續擴展變得不可能。
儘早發現瓶頸:
即使它們目前還不是問題,也要知道它們在哪裡:
掃描整個表格的資料庫查詢
無法線性擴充的操作(N+1 查詢、巢狀循環)
單點故障(一台伺服器,一個資料庫)
同步操作可以非同步執行
橫向擴展設計:
水平擴展(增加伺服器數量)比垂直擴展(增大伺服器容量)更容易。設計時應確保增加容量的方式是增加伺服器數量,而不是升級現有伺服器。
無狀態服務:將狀態儲存