Python 檔案與例外處理進階:落盤保證、編碼真相與例外繼承樹
當 write 之後資料還在半路上——深入緩衝、原子寫入、位元組與字元的邊界,以及例外型別背後的繼承結構與 traceback 機制
當「寫進去了」其實沒寫進去:一個讓資料庫工程師失眠的問題
你的程式跑完最後一行 f.write("已存檔"),視窗印出「完成」,你安心關機。隔天打開檔案——資料只有一半,最後幾筆憑空消失。程式沒崩潰、沒拋例外、with 也乖乖關了檔,那資料到底去哪了?
如果你讀過入門篇,已經知道用 with 開檔、用 try / except 接住意外。但那只是「能用」的層次。真正讓資料在斷電、當機、多程序競爭下都不出錯的,是底下一整層你看不見的機制:作業系統的緩衝(buffering)、位元組與字元的邊界、例外型別的繼承樹,以及錯誤發生當下那份 traceback 物件裡藏著什麼。這篇進階篇,我們把這層蓋子掀開。

緩衝與持久化:write 之後,資料還在半路上
開頭那個謎題的答案是:f.write() 不保證資料真的落到磁碟上。
當你呼叫 write(),資料先進到 Python 自己的緩衝區,再交給作業系統的頁快取(page cache),最後才由作業系統在它認為合適的時機寫入實體儲存裝置。這條路上有好幾層暫存,目的是把許多次小寫入合併成一次大寫入,換取效能。代價是:在資料真正落盤之前,只要程式被 kill -9、機器斷電,那些還在快取裡的位元組就一起蒸發。
f.close()(包含 with 自動關檔)會把 Python 緩衝區flush 給作業系統——所以入門篇教的 with 確實避免了「Python 層級」的資料遺失。但它不會強迫作業系統把頁快取寫進磁碟。要做到那一步,得明確呼叫 os.fsync():
import os
with open("critical.txt", "w", encoding="utf-8") as f:
f.write("這筆資料不能丟\n")
f.flush() # 把 Python 緩衝區交給作業系統
os.fsync(f.fileno()) # 命令作業系統真正寫入實體磁碟
# 走到這裡,資料才有「斷電也還在」的保證
flush() 和 fsync() 是兩個不同層級的動作,缺一不可:flush() 把資料從「Python 緩衝區」推到「作業系統頁快取」,fsync() 才把它從「頁快取」逼到「磁碟」。資料庫(MySQL、PostgreSQL)在每次 commit 時都會做 fsync,這也是為什麼 commit 比單純的 write 慢得多——它在用效能換取持久性(durability)。
你可以用 open() 的 buffering 參數調整這個行為。buffering=0 關閉緩衝(僅限二進位模式),每次 write 直接送往作業系統;buffering=1 是行緩衝(line buffering),每遇換行就 flush,適合即時的日誌輸出。
原子寫入:要嘛全有,要嘛全無
知道資料可能寫到一半,下一個問題就浮現了:如果程式在覆寫一份舊有檔案的中途崩潰,會發生最糟的情況——舊資料已經被清掉、新資料又沒寫完,你同時失去了新舊兩份。
專業的解法叫原子寫入(atomic write):先把完整內容寫到一個暫存檔,確認落盤後,再用 os.replace() 一口氣換掉目標檔。
import os, tempfile
def atomic_write(path, text):
"""寫入過程崩潰時,原檔案保持不變;成功時才整份替換。"""
directory = os.path.dirname(os.path.abspath(path))
# 暫存檔放在同一個目錄,確保 replace 是同一個檔案系統內的操作
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno()) # 確保暫存檔內容真正落盤
os.replace(tmp, path) # 原子替換:這一步在 POSIX 上保證不可分割
except BaseException:
os.unlink(tmp) # 出錯就清掉暫存檔,原檔毫髮無傷
raise
關鍵在 os.replace():在同一個檔案系統內,rename / replace 是作業系統保證的原子操作——任何時刻去讀目標檔,看到的不是完整的舊版,就是完整的新版,永遠不會是「寫到一半」的殘缺狀態。注意暫存檔必須和目標檔在同一個目錄(同一個檔案系統),否則 os.replace() 會退化成「複製+刪除」,原子性就破功了。這個模式是所有講究資料完整性的程式(編輯器存檔、設定檔更新、套件管理器)的標準做法。
位元組與字元:編碼是一道翻譯,不是一個開關
入門篇叮嚀你「永遠寫 encoding="utf-8"」。進階篇要解釋這行字背後到底在做什麼,以及不寫會出什麼事。
磁碟上的檔案本質是一串位元組(bytes),0 到 255 的數字序列。但你在 Python 裡操作的是字串(str),一串 Unicode 字元。從位元組到字元之間,需要一張翻譯表——這就是編碼(encoding)。用文字模式 "r" / "w" 開檔時,Python 在你和磁碟之間插了一層 TextIOWrapper,自動幫你做這道翻譯;用二進位模式 "rb" / "wb" 開檔,則完全不翻譯,你直接面對裸位元組。
text = "café 中文"
# 同一段文字,不同編碼產生完全不同的位元組
print(text.encode("utf-8")) # b'caf\xc3\xa9 \xe4\xb8\xad\xe6\x96\x87'
print(text.encode("utf-16")) # 每字元至少 2 bytes,且帶 BOM 位元組順序標記
# 用錯編碼解讀,就是亂碼的根源
raw = text.encode("utf-8")
print(raw.decode("big5", errors="replace")) # 用 big5 硬解 utf-8 → 一堆問號方塊
「中文亂碼」的真相是:寫入時用 A 編碼、讀取時用 B 編碼。檔案本身沒壞,只是讀的人拿錯了翻譯表。utf-8 之所以是預設首選,是因為它對 ASCII 完全相容、能表示所有 Unicode 字元,且沒有位元組順序的歧義。
當編碼真的對不上、又無法重來時,errors 參數決定遇到無法翻譯的位元組怎麼辦:
data = b"good\xff\xfedata" # \xff\xfe 在 utf-8 裡是非法序列
data.decode("utf-8", errors="strict") # 預設:直接拋 UnicodeDecodeError
data.decode("utf-8", errors="ignore") # 'gooddata',壞位元組被丟棄
data.decode("utf-8", errors="replace") # 'good��data',換成替代字元 �
處理來路不明的外部資料(爬蟲抓的網頁、老舊系統匯出的檔案)時,errors="replace" 能讓程式不因一個壞位元組就整批失敗——但你要清楚這是在「犧牲正確性換取存活」,被替換的字元已經永久遺失。
看一個例子:偵測並修復編碼錯置的檔案
假設你收到一份檔案,內容明明是中文卻顯示成亂碼。常見原因是「資料原本是 utf-8,卻被某個工具用 cp950(Big5) 存了一遍」。我們可以用「先用錯的編碼還原成位元組、再用對的編碼重新解讀」這招把它救回來:
def fix_mojibake(broken_text):
"""修復『utf-8 被誤當 cp950 讀取』造成的亂碼。"""
try:
# 步驟一:把亂碼字元還原回它們當初被誤讀的位元組
raw = broken_text.encode("cp950")
# 步驟二:用正確的 utf-8 重新解讀這串位元組
return raw.decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
# 還原失敗,代表猜錯了來源編碼,原樣退回
return broken_text
# 模擬一份被錯誤處理過的檔案
original = "資料科學"
mojibake = original.encode("utf-8").decode("cp950") # 製造亂碼
print("亂碼:", mojibake) # 看起來是一串無意義方塊
print("修復:", fix_mojibake(mojibake)) # 資料科學
這段程式碼之所以能運作,正是因為你理解了「字串 ↔ 位元組」是可逆的翻譯,而亂碼只是「翻譯方向接錯」。能在腦中清楚分離這兩個世界,是從「會讀寫檔案」進階到「能 debug 編碼問題」的分水嶺。
例外的繼承樹:抓對層級,不多不少
入門篇教你抓 FileNotFoundError、ValueError。進階篇要你看見:這些例外不是平行的清單,而是一棵繼承樹。理解這棵樹,你才知道一個 except 到底會接住哪些東西。
BaseException
├── SystemExit ← sys.exit() 觸發
├── KeyboardInterrupt ← 使用者按 Ctrl+C
└── Exception ← 「一般」錯誤的共同祖先
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── IsADirectoryError
├── ValueError
│ └── UnicodeDecodeError
└── ...
except 的比對規則是:抓父類,就會連同所有子類一起接住。所以 except OSError 會同時接住 FileNotFoundError 和 PermissionError;而 except Exception 幾乎接住一切「程式錯誤」——但故意漏掉 SystemExit 和 KeyboardInterrupt。
這就解釋了入門篇警告的「裸 except」為什麼危險:except: 等同 except BaseException:,連 Ctrl+C 和 sys.exit() 都吞掉,使用者按了中斷鍵程式卻不停。正確的「我要接住所有合理錯誤」寫法是 except Exception,它把 BaseException 那兩個「控制流程用」的特殊例外放過。
抓的層級要恰到好處:太細(只抓 FileNotFoundError)會漏掉「沒權限」這種同樣需要處理的情況;太粗(抓 Exception)會把真正的程式 bug 也一併掩蓋。實務上常見的折衷是抓 OSError,因為所有檔案系統相關的失敗都歸在它底下:
try:
with open("/etc/secret", "r") as f:
data = f.read()
except OSError as e:
# 一網打盡:不存在、沒權限、是目錄、磁碟壞軌……
print(f"檔案操作失敗:{e.__class__.__name__} — {e}")
設計自己的例外型別:讓錯誤可被精準捕捉
當你的程式變大,內建例外不夠用了。把「設定檔格式錯誤」硬塞成 ValueError,呼叫者就無法把它和「使用者打錯數字」區分開。專業做法是定義自己的例外類別,從 Exception 繼承:
class ConfigError(Exception):
"""設定相關錯誤的共同基底,呼叫者可以一次抓住所有設定問題。"""
pass
class MissingKeyError(ConfigError):
def __init__(self, key):
self.key = key
super().__init__(f"缺少必要設定項:{key}")
class InvalidValueError(ConfigError):
def __init__(self, key, value):
self.key, self.value = key, value
super().__init__(f"設定項 {key} 的值無效:{value!r}")
def load_port(config):
if "port" not in config:
raise MissingKeyError("port")
port = config["port"]
if not (1 <= port <= 65535):
raise InvalidValueError("port", port)
return port
# 呼叫者可以選擇精準處理,或用基底類別一網打盡
try:
load_port({"port": 99999})
except MissingKeyError as e:
print(f"設定不完整,請補上 {e.key}")
except ConfigError as e: # 攔截所有設定類錯誤(含 InvalidValueError)
print(f"設定有問題:{e}")
這裡有個關鍵設計原則:建立一個共同的基底例外(這裡是 ConfigError),讓所有相關的子例外都繼承它。如此一來,呼叫者既能精準抓某個特定錯誤,也能用基底類別一次處理整個「家族」。Python 標準函式庫到處是這個模式——OSError 就是檔案錯誤家族的基底。把例外當成「可被分類捕捉的型別」來設計,而不只是「印一段訊息」,是進階例外處理的核心思維。
traceback 物件:錯誤現場的黑盒子
當例外往外傳,它隨身攜帶一個 traceback 物件,記錄了「從哪個函式呼叫到哪個函式、最後在哪一行爆炸」的完整路徑。平常你看到終端機印出的那串紅字,就是它被格式化後的樣子。但 traceback 不只能印,還能被當成資料來檢視與記錄:
import traceback
def risky():
return 1 / 0
try:
risky()
except ZeroDivisionError as e:
# 取得結構化的呼叫堆疊,而不只是一段字串
tb_lines = traceback.format_exc()
print("=== 完整錯誤現場 ===")
print(tb_lines)
# 也能逐層走訪,取出每一格 frame 的檔名與行號
tb = e.__traceback__
while tb is not None:
frame = tb.tb_frame
print(f"檔案 {frame.f_code.co_filename},第 {tb.tb_lineno} 行,"
f"函式 {frame.f_code.co_name}")
tb = tb.tb_next
為什麼要在意這個?因為在真實服務裡,你不能把錯誤訊息原樣丟給使用者(會洩漏內部路徑、變數,是資安風險),但你必須把完整 traceback 寫進日誌,否則出事時無從追查。logging 模組的 logger.exception() 就是專門做這件事的——它在 except 區塊內自動抓取當前 traceback 並寫入日誌:
import logging
logging.basicConfig(filename="app.log", encoding="utf-8")
logger = logging.getLogger(__name__)
try:
risky()
except Exception:
logger.exception("處理請求時發生未預期錯誤") # 完整 traceback 進日誌
# 對使用者只回一句無害的話
print("系統忙碌中,請稍後再試")
「對外給乾淨訊息、對內留完整現場」這條原則,正是入門篇 app.py 範例裡 logger.exception(...) 配 return jsonify({'error': str(e)}) 的設計用意。理解 traceback 是一個可程式化操作的物件,你才能寫出既安全又可維運的錯誤處理。
重點回顧
write不等於落盤:資料先經 Python 緩衝、再經作業系統頁快取才到磁碟。要斷電也不丟,必須flush()後再os.fsync();with只保證前者。- 覆寫舊檔要用原子寫入:先寫暫存檔、
fsync後os.replace(),避免崩潰時新舊兩失。暫存檔必須與目標檔同一檔案系統。 - 亂碼是翻譯方向接錯:檔案是位元組、程式裡是字元,編碼是兩者間的翻譯表。寫入與讀取用同一套編碼,問題就消失。
- 例外是一棵繼承樹:抓父類連同子類一起接;用
except Exception(不是裸except)才能放過Ctrl+C與sys.exit()。 - 自訂例外要有共同基底:讓呼叫者既能精準抓特定錯誤,也能用基底類別一次攔截整個家族;對外給乾淨訊息,對內用
logger.exception()留完整 traceback。
深入探討(研究所視角)
為什麼 fsync 仍不是完整的保證:write barrier 與儲存裝置的謊言
os.fsync() 把資料逼出作業系統頁快取,但故事還沒結束。現代 SSD 與硬碟自己也有一層裝置內快取(on-disk cache),有些消費級裝置為了跑分,會在收到 flush 命令後「謊報」已寫入,實際資料還在裝置 DRAM 裡。要真正穿透到非揮發性媒介,作業系統與檔案系統需支援 write barrier 並正確下達 FLUSH CACHE 指令;資料庫工程師為此會特別測試 fsync 的真實語意(著名的 diskchecker.pl、PostgreSQL 的 fsync 爭議即源於此)。更嚴謹的論述會引入 crash consistency 模型:一個寫入序列在任意時間點被中斷後,系統能回到的合法狀態集合,是檔案系統(ext4 的 journaling、ZFS 的 copy-on-write)與應用層(WAL,預寫式日誌)共同設計的結果。換言之,「資料安全」不是單一 API 呼叫,而是一條從應用、作業系統、檔案系統到硬體韌體的完整信任鏈,任一環撒謊都會破功。
例外處理的計算成本與零成本模型
入門篇提過 EAFP 在「正常路徑」幾乎零開銷。從編譯器實作角度,這對應到例外處理的兩種策略。CPython 採用的是動態查找:直譯器維護一個 block stack,進入 try 時推入處理器位址,這有微小但非零的設定成本。相對地,C++ 與 Rust 採用零成本例外(zero-cost exception)模型:正常執行路徑完全不插入任何檢查指令,編譯器改在二進位檔的唯讀區段建立一張例外處理表(exception handling table,如 DWARF .gcc_except_table);只有當例外真正拋出時,執行期才去查這張表做堆疊展開(stack unwinding)。代價是:不出錯時零開銷,一旦拋出則昂貴(要解析表、逐格展開、執行解構子)。這從根本上印證了一條設計準則——例外只該用於「例外」情況,絕不該拿來當迴圈控制流程。一個會在熱路徑(hot path)裡頻繁拋接例外的程式,效能會被堆疊展開拖垮。
上下文管理器的數學本質:資源生命週期即括號匹配
入門篇示範了 __enter__ / __exit__。把它推到一般化,with 解決的是一個結構化資源管理問題:每個被取得的資源都必須被釋放,且釋放順序與取得順序相反——這在形式上等價於括號匹配(balanced parentheses),是一個 context-free 語言。contextlib.ExitStack 把這個抽象做到極致,讓你在執行期動態決定要管理幾個資源,仍保證全部正確釋放:
from contextlib import ExitStack
def merge_files(paths, output):
"""同時開啟未知數量的檔案,任一步出錯都保證全部關閉。"""
with ExitStack() as stack:
files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
with open(output, "w", encoding="utf-8") as out:
for f in files:
out.write(f.read())
# 離開時,所有 enter_context 進來的檔案以「後進先出」順序關閉
ExitStack 的釋放順序是嚴格的 LIFO(後進先出),這正是堆疊的語意,也對應 RAII(Resource Acquisition Is Initialization) 在 C++ 中由解構子依宣告逆序自動觸發的機制。把「資源管理」看成「具有良好巢狀結構的代數問題」,你會發現 Python 的 with、C++ 的 RAII、Rust 的 Drop、乃至函數式語言的 bracket 組合子,全是同一個數學結構的不同語法外衣。理解這層共通性,你帶走的就不只是 Python 語法,而是一套跨語言通用的資源安全思維——下一步,不妨試著用 ExitStack 改寫你手上任何需要同時管理多個檔案或連線的程式,親手感受這個抽象的威力。