實驗室已驗證

分號、&& 與 ||:每天敲一萬次、卻極少人真正理解的命令鏈

分號、&& 與 ||:每天敲一萬次、卻極少人真正理解的命令鏈 每天早上我都會收到「我的 shell 腳本寫崩了」的求助,八成問題不在邏輯,而在這一行: git pull && deploy.sh || echo "部署失敗" 看起來天經地義:拉程式碼成功就部署,…

分號、&& 與 ||:每天敲一萬次、卻極少人真正理解的命令鏈

驗證報告

分號、&& 與 ||:每天敲一萬次、卻極少人真正理解的命令鏈

每天早上我都會收到「我的 shell 腳本寫崩了」的求助,八成問題不在邏輯,而在這一行:

git pull && deploy.sh || echo "部署失敗"

看起來天經地義:拉程式碼成功就部署,失敗就喊一聲。但如果你實際測試過,會發現它有兩個坑——「部署成功」時那條「部署失敗」可能會喊出來,而「部署腳本自身崩潰」時也不一定會走那個分支。這是 CLI 裡最常用、也最容易被誤解的機制,值得用五分鐘講清楚。

核心規則:先看前一條指令的退出碼

三個連接符的差別只用一句話就能說完:

  • ;(分號):上一條跑完就跑下一條,不管成功失敗,都執行。它是「無條件接力」。
  • &&(與):上一條成功(退出碼 0)才執行下一條,失敗就整段停下來。
  • ||(或):上一條失敗才執行下一條,成功就跳過。

退出碼(exit code)是一切的基礎:0 代表成功,非 0 代表失敗。你可以隨時用 echo $? 查看上一條指令的退出碼,也可以用 # 在註解裡記下「為什麼要非 0」。

git pull
echo "git 退出碼:$?"   # 0 = 成功,128 開頭通常是致命錯誤

場景:三個操作串起來

假設你要完成「備份資料庫 → 跑遷移 → 發通知」,並且遷移失敗必須終止,否則會把壞程式碼發出去:

pg_dump mydb > backup.sql && migrate-v2.0 && notify.sh "遷移完成"

&& 的好處:pg_dump 失敗(磁碟滿、權限錯),後面的就不會跑,你把故障圈在最小半徑。set -e 可以讓整個腳本在任何失敗時中止,但它是全域的、容易誤傷(比如 grep 搜不到東西也是非 0),精細控制還是靠顯式 && 舒服。

什麼時候該用什麼

  • 需要「成功才繼續」:用 &&。建構、遷移、部署,每一步的失敗都會汙染下一步。
  • 需要「失敗也別停下來,先記錄」:用 ;。
    npm run build; echo "建構結束(成功與否都會列印)"
    
    或者更優雅地用 || 接失敗分支:
    npm run build || { echo "建構失敗" >&2; exit 1; }
    
  • 需要「二選一」:用 || 接兜底。
    • command1 || command2:command1 成功就跳過,失敗才跑 command2。
    • 但上面開頭那個 A && B || C 的寫法要慎用。直覺上「成功走 B,失敗走 C」是對的,但有第三種情況:A 成功、B 失敗時,C 也會跑。如果你的 B 是 deploy,你想用 C 做「部署失敗告警」,這倒是對的;但如果 C 是別的副作用,就很容易出現「部署成功還在執行 C」的詭異 bug。安全寫法:
    if git pull; then
      deploy.sh
    else
      echo "拉程式碼失敗,中止" >&2
      exit 1
    fi
    

    語義一目了然,沒有歧義。

一個真實的坑:echo 掩蓋了失敗

很多人寫:

$TOOL_MIGRATE || echo "次要資料遷移出錯"

看起來 || 只會在 $TOOL_MIGRATE 失敗時輸出告警。但如果你加過 set -x 或 debug 日誌,會發現 $TOOL_MIGRATE 真的失敗了,而且告警印出了——沒錯,那是對的;問題是很多團隊的告警通道在這台機器上根本不通(網路隔離、log 服務重啟),echo 只是「看起來兜住了」,實際上是靜默失敗。|| 不保證你的 fallback 也成功,它只保證「成功時不跑、失敗時跑」。所以告警要雙通道,且用 echo ... >&2,再配合 cron/atop/logwatch 做最終兜底。

管線與 ; 的結合

管線 | 傳遞的是 stdout,退出碼預設取最後一個指令的退出碼:

cat data.json | jq '.items' > out.json && echo "OK"

如果 jq 出錯了(欄位不存在),echo "OK" 還是會列印,哪怕 out.json 是空的或者報錯。要「任何一段失敗都整段失敗」,需要 pipefail:

set -o pipefail
cat data.json | jq '.items' > out.json && echo "OK"

現在 jq 失敗,管線整體非 0,&& 後面的就停了。這是處理資料鏈(日誌採集 → 轉換 → 入庫)最常被忽略的一行。

何時不該用命令鏈

  • 超過 3~5 步的流程不要寫成一行鏈。一行程式碼越長,可讀性越差,且退出碼一多你就追不清了。超過 3 步,寫個 .sh 檔案,每步一個函式,函式開頭 set -euo pipefail,每步結束打一條 [step] 日誌。
  • 鏈在前台跑的狀態、錯誤處理、重試:鏈式指令沒有重試、沒有超時、沒有分段狀態。如果這是生產腳本,至少加 timeout 和手動 for i in 1 2 3; do ... && break; done 結構。
  • 在互動式 shell 裡除錯鏈式指令:Ctrl-Z 會掛起整段鏈,恢復時容易亂序。除錯時拆成單步執行。

常見誤判

  1. 「失敗跳過」是 ||,不是 ;。 ; 永遠執行下一條,它沒有「跳過」的概念。
  2. set -e 不是萬能的。 它在全域層面對 &&、||、if 的條件陳述式有豁免(這些是正常控制流,不算錯誤),這在 make 的 recipe、Ansible 的 inline task 裡經常踩坑。顯式 &&/|| 永遠優先。
  3. 管線退出碼預設只看最後一個指令。 上面的 && jq ... && echo OK 不會在 jq 失敗時停,除非加 pipefail。
  4. $? / $status 取上一次指令的退出碼。 注意是「上一條」,寫了 echo 之後 $? 就變成 echo 的(echo 基本恆為 0)。取退出碼要立刻取,別中間插任何指令。

清單:寫鏈前過一遍

  • 這一步失敗,下一步有沒有必要繼續?是 → ;;否 → &&
  • || 的兜底分支本身會不會出錯?會 → 用函式封裝,函式內再設兜底
  • 有管線,是否加了 pipefail?
  • 關鍵錯誤碼是不是要單獨判斷(別只靠非 0/0 二分)?
  • 超過 3 步?考慮寫腳本,別繼續加 &&
  • 在帶 set -e / set -x 的環境裡跑嗎?是否會被靜默忽略(條件陳述式豁免)?

小結

命令鏈本身很輕,但它的靜默路徑也多。把 && 當「守護門」、; 當「無腦接力」、|| 當「兜底通道」,再用 explicit if/else 處理「二選一邊」、pipefail 處理管線,你可以把腳本從「能跑」推到「跑崩時一眼看出崩在哪一步」。

最後一句反直覺但很重要的話:能寫成 if/else 的分支,就不要用 || 的隱含語義。 下一位接手你腳本的人,包括六個月後的你,會感謝你的。

怎麼用

先在隔離環境中按記錄步驟重現,再決定是否採用這項技能。

實測效果

實驗室只記錄可重現結果,不把未經驗證的說法寫成結論。

踩坑

套用前核對權限、輸入、回復步驟和證據是否完整。

適用場景

適合環境條件與本報告證據範圍一致的任務。

不適用場景

缺少必要證據、隔離條件或回復控制時不要使用。