☰
Shell条件语句全攻略:从[ ]到[[ ]]的实战对比与坑点排查
2026/10/22 4:03:26 网站建设 项目流程

1. 条件语句的整体设计思路

先聊句题外话。我入行头两年写Shell脚本,基本都是照着别人的模板改,只知道if后面跟个方括号,至于为什么要这样写、[ ]和[[ ]]到底有啥区别,完全是一笔糊涂账。后来做了一批批处理脚本,踩的坑多了,才慢慢摸清楚条件语句的底细。这篇文章就按我自己的理解,把Shell条件语句掰开揉碎讲一遍,不讲大而全,专讲你写脚本时真正会用到、会卡住的那部分。

1.1 条件语句在Shell编程里的定位

Shell脚本本质就是“命令的排列组合”,但如果只是顺序执行,那跟手动敲命令没区别。条件语句的存在,说白了就是给脚本装上一个“判断中枢”——参数对了走A分支,文件不存在就走B分支,服务没起来就报警。在我看来,Shell编程80%的可维护性取决于条件语句写得清不清晰、健不健壮。

很多人觉得条件语句简单,无非就是if一下,其实不然。条件语句真正难的地方不在语法,而在三个方面:一是你想判断什么(文件?字符串?数值?命令退出码?),二是用什么方式去判断([ ]、[[ ]]、test、还是直接用case),三是边界情况有没有兜住(变量为空、变量含空格、路径带特殊字符)。这三个问题想明白了,条件语句才叫真正掌握。

1.2 单一入口与多分支取舍

Shell支持的条件结构其实就那么几种:if、if-else、if-elif-else、case、三目运算式的&&和||。选型逻辑我一直遵循一条原则:能直读就别绕弯,能扁平就别嵌套。

举个例子,判断一个服务端口通不通,然后决定是启动还是报错,你当然可以用一串嵌套if,每层都判断一下退出状态;但如果你把判断逻辑拆成多个单层if,或者用case把端口号枚举出来,脚本的可读性和后续维护难度就是天壤之别。我自己写脚本的习惯是“主流程走if,多值枚举走case,简单布尔判断直接上&&和||”。这种思路不是拍脑袋定的,而是被线上故障逼出来的——嵌套超过三层,排查的时候日志都看不下去。

2. 核心语法结构与实操要点

2.1 if结构的三种基本形态

Shell的if结构分三档:单分支、双分支、多分支。我用一个模拟场景把它们串起来,假设脚本要检查某个应用包目录是否存在,并根据结果决定是否解压。

单分支是最简形态,只有“条件成立才做事”:

if [ -d /opt/app/package ]; then echo "目录存在,开始解压..." tar -xzf /opt/app/package.tgz -C /opt/app/ fi

注意,fi是这个单分支的收尾,漏了它脚本直接报语法错误。我见过不少刚写脚本的朋友,if写完了老忘fi,然后在奇怪的地方报错。

双分支就是加一个else,条件不成立时走另一边:

if [ -d /opt/app/package ]; then echo "目录存在,跳过解压" else echo "目录不存在,创建并解压" mkdir -p /opt/app/package tar -xzf /opt/app/package.tgz -C /opt/app/ fi

这里有个细节:else后面不需要再写fi,整个结构只收一次尾。多分支用elif,是“否则如果”的意思:

if [ -d /opt/app/package ]; then echo "目录存在,检查版本..." elif [ -f /tmp/package.tgz ]; then echo "目录不存在,但备份包存在,走恢复流程" else echo "目录和备份都不存在,需人工介入" exit 1 fi

这个结构我在实际工作中最常用,比如判断磁盘空间、检查日志目录、确认PID文件是否存在。核心心得是:所有if分支都必须用fi统一收口,且elif语句后不需要单独的fi,这是Shell语法里最容易搞混的点。

2.2 test命令、[ ]与[[ ]]的区别

为什么要单独拎出来讲?因为这是条件语句里最体现功力的地方。很多次帮同事排查脚本问题,最终的根源都出在“到底该用[ ]还是[[ ]]”上。

test命令是条件判断的底层实现。你写if test -f /etc/passwd; then和if [ -f /etc/passwd ]; then,效果完全一样,因为[本身就是test命令的别名形式(你甚至能在/usr/bin下看到名为[的可执行文件)。区别只在于,[要求最后有个],test不需要。

而[[ ]]是Bash扩展的关键字,比[ ]强大得多。举几个最实用的差异点:

  • 变量为空时的安全性。[ $var = "abc" ],如果$var为空,展开后变成[ = "abc" ],直接报语法错误;但[[ $var = "abc" ]]不会,它会安全地当成空字符串比较。这个差异导致生产环境里大量脚本崩溃,原因就是没给变量加引号。

  • 支持正则与模式匹配。这是[[ ]]的独门绝技,[ ]做不到。判断字符串是否匹配某个模式时,[[ "$str" == *.log ]]直接搞定。

  • 运算符支持更丰富。[ ]里的>和<需要转义,而且比较的是字符串;[[ ]]里则可以直接用>和<做字符串比较,不必转义。

我现在的规则很明确:凡Bash脚本一律用[[ ]],只在写sh兼容脚本时才用[ ]。因为[[ ]]的失败模式少得多,能省掉一半的排查时间。但要注意,[[ ]]是Bash专属,sh环境不一定认。

3. 条件表达式与比较操作符详解

3.1 文件测试操作符

Shell条件判断里,文件系统相关占了大头。因为脚本跑起来以后,最常打交道的就是文件、目录、权限、时间戳。我把常用文件测试做成一张速查表,方便你写的时候直接对:

操作符含义典型场景
-e文件或目录存在判断路径是否有效
-f存在且是普通文件确认是不是文件而非目录
-d存在且是目录最常用,判断目录
-r存在且有读权限检查权限
-w存在且有写权限检查权限
-x存在且有执行权限检查可执行文件
-s存在且非空判断文件是否为空
-L存在且是符号链接检查软链接

举个例子,数据备份脚本里判断备份文件是否生成成功,最常用的就是-s:

if [[ -s /backup/app_$(date +%Y%m%d).tar.gz ]]; then echo "备份文件非空,校验通过" else echo "备份文件为空或不存在,告警" fi

我以前犯过一个错,只判断-f不判断-s,结果备份任务每天早上都报“成功”,实际上连续三天备份出来的都是0字节文件。从那以后,凡是涉及数据导出的场景,我一定用-s而非-f。

3.2 数值与字符串比较操作符

数值比较和字符串比较的写法是新手最容易混淆的地方。Shell里数值比较用的是-eq、-ne、-lt、-le、-gt、-ge,字符串比较用的是=、!=、<、>。注意,这些运算符不能混用。

# 数值比较 if [[ $count -ge 10 ]]; then echo "数量达到阈值" fi # 字符串比较 if [[ "$status" = "ok" ]]; then echo "状态正常" fi

这里有一个值得记住的坑:数值比较里,>是重定向符号,不是比较运算符。在[ ]里写[ $count > 10 ],Shell会把$count的值重定向到一个名为10的文件里,然后判断命令是否成功,结果必然是真——这个错误极其隐蔽,排查半天都未必能发现。用[[ ]]的情况下>可以用于字符串比较,但仍然不是数值比较的正确写法。

字符串比较方面,我强烈建议比较时给变量加双引号:"$status",尤其在[ ]里不加引号遇到空变量会直接崩;[[ ]]里其实可以不加,但养成加引号的习惯能兼容性更好。

3.3 复合条件的逻辑组合

实际工作中很少只判断一个条件,通常都是“目录存在并且文件非空”这种复合判断。两种写法:一是用逻辑运算符&&(与)、||(或)、!(非),二是用[ ]时代的-a(与)和-o(或)。

我的建议很简单,优先用&&和||,因为它们在[[ ]]和[ ]里行为一致,可读性也更好。-a和-o是历史遗留写法,容易踩优先级坑,不推荐在新代码里用。

if [[ -d /opt/app && -s /opt/app/config.yml ]]; then echo "目录和配置文件都已就绪" fi

再看一个体现!逻辑的实例,判断服务没在运行就走启动逻辑:

if ! pgrep -f "app_server" >/dev/null 2>&1; then echo "服务未运行,执行启动..." /opt/app/start.sh fi

这里pgrep的退出码为0表示找到了进程,加了!就是对“找到了进程”取反,等价于“没找到”。这个写法比pgrep ... || echo "未运行"更清晰,适合需要“取反后进分支”的场景。

4. 实操过程与核心环节实现

4.1 场景设计:一个带参数校验的部署脚本

理论讲了这么多,现在用一个完整的实战场景把它们串起来。假设我们要写一个应用部署脚本:该脚本接收环境名和版本号两个参数,校验参数合法性、检查部署目录,然后决定是全新部署还是增量更新。

第一步,先做参数合法性校验。这是条件语句最常见的用途之一。我的习惯是开头先“卡参数”,不满足条件直接退出,这样可以避免后续步骤在一个残缺的环境里执行。

#!/usr/bin/env bash APP_HOME="/opt/demo_app" DEPLOY_ENV="$1" APP_VERSION="$2" if [[ $# -ne 2 ]]; then echo "用法: $0 <env> <version>" exit 1 fi if [[ -z "$DEPLOY_ENV" || -z "$APP_VERSION" ]]; then echo "参数不能为空" exit 1 fi

这里用了||把“环境名为空”和“版本号为空”两个条件并在一起。-z表示“字符串长度为零”,是判断空参数的标准手段。

第二步,判断环境名是否合法。应用有开发、测试、生产三个环境,其他值直接拒绝。这里可以用if连环判断,但更推荐用case,因为这是典型的多值枚举场景:

case "$DEPLOY_ENV" in dev|test|prod) echo "环境合法: $DEPLOY_ENV" ;; *) echo "非法环境名: $DEPLOY_ENV,仅支持 dev/test/prod" exit 1 ;; esac

case语法里有几个细节一定不能错:)前是匹配值,;;收尾每个分支,*)兜底分支必须放最后。我见过有人漏写;;导致整个case直接串台。

第三步,判断当前环境对应的部署目录是否存在。这决定走全新安装还是增量更新:

ENV_DIR="$APP_HOME/$DEPLOY_ENV" if [[ -d "$ENV_DIR" ]]; then echo "检测到已有部署目录,走增量更新流程" INCREMENTAL=true else echo "全新部署,创建目录结构" mkdir -p "$ENV_DIR" INCREMENTAL=false fi

这里的INCREMENTAL=true/false其实是一种布尔标志用法,后续脚本可以用if [[ "$INCREMENTAL" = "true" ]]来决定是否备份旧配置。这种写法比在分支里直接执行全部逻辑要清晰,别人接手代码时可以一眼看出流程走向。

第四步,版本号比较。这里最有讨论价值——假设我们需要判断传入的版本号是否高于当前线上版本。版本号格式是1.2.3这种点分格式,Shell里没有原生的版本比较操作符,得用点号拆分后分段比较。

我用一个相对简洁的写法:

current_version=$(cat "$ENV_DIR/version.txt" 2>/dev/null || echo "0.0.0") if [[ "$APP_VERSION" != "$current_version" ]]; then echo "版本变化: $current_version -> $APP_VERSION" else echo "版本未变化,可跳过部署" exit 0 fi

严格来说这个判断只是“版本不同”,并不校验“新版本更高”。实际生产脚本里如果要严格比较,一般用sort -V来做,这个后面常见问题部分我会专门展开。

4.2 条件执行与退出码的配合

Unix规范里有一条被初学者忽略的常识:0表示成功,非0表示失败。条件语句里有很多情况不是判断“值是什么”,而是判断“上一条命令成没成功”。最典型的例子就是检查网络连通性、执行命令后判断结果。

if curl -s -o /dev/null --connect-timeout 3 "$HEALTH_CHECK_URL"; then echo "健康检查通过" else echo "健康检查失败,触发重启流程" /opt/app/restart.sh fi

这里curl命令自身就是条件。命令执行成功时退出码为0,if就会走进then分支;失败时非0,进else。这种写法比curl ... 2>&1 | grep ...去匹配输出文本要可靠得多,因为它判断的不是“输出内容”,而是“命令是否成功返回”。

同理,判断压缩包是否解压成功:

if tar -xzf "$APP_VERSION.tar.gz" -C "$ENV_DIR"; then echo "解压成功" else echo "解压失败,退出" exit 1 fi

这个写法的好处是保留了原命令的退出状态,不需要额外捕获输出再判断。代价是它无法区分“解压时哪个文件出错了”,如果遇到这种需要精确错误定位的场景,可以结合set -e或者把解压输出记录到日志来分析。

4.3 多条件脚本中的逻辑分层

复杂的部署流程往往先做环境检查,再做数据准备,最后才做服务操作。这一节我想展示一种“检查前置、动作后置”的脚本结构:

# 前置检查区——所有检查通过才继续 [[ -d "$ENV_DIR" ]] || { echo "目录不存在,无法继续"; exit 1; } [[ -s "$ENV_DIR/version.txt" ]] || { echo "版本文件异常"; exit 1; } [[ -x "$(command -v python3)" ]] || { echo "python3 不可用"; exit 1; } # 动作执行区——到这里环境已经可用 echo "执行部署动作..."

这种写法的核心思路是“前置检查失败立即退出”,用||把判断和退出连成一个原子操作。它比写一串if ... then ... else exit结构更紧凑,也更难出错——因为每个检查都独立一行,排查时可以很清楚地看到卡在哪个条件上。

这里有一个前提:脚本开头不要用set -e,否则即使条件语句内部是“合法的非零退出”,也会把整个脚本带着退出。如果用了set -e,就需要把这类前置检查改写为if包裹的形式,避免误伤。这也是很多团队把set -e当成一把双刃剑的原因。

5. 常见问题与排查技巧实录

5.1 条件语句中最容易踩的五个坑

这部分内容是我从大量线上故障和帮人debug的经历里攒出来的,每个坑都值得划重点。

坑一:[ $var ]未加引号导致空变量报错。

if [ $status = "ok" ]; then echo "正常" fi

如果$status为空,展开后变成[ = "ok" ],Shell直接报unary operator expected。解决办法是加引号:[ "$status" = "ok" ],或者改用[[ $status = "ok" ]]。这个坑的原理是:未加引号的变量会做分词(word splitting),变量为空时整个表达式结构被破坏。加引号的意义是告诉Shell“这是一个整体”,避免分词干扰。

坑二:整数比较错用=或>。

# 错误:这是字符串比较 if [[ $count = 10 ]]; then

当$count的值为10时,[[ $count = 10 ]]的结果其实也是真的,看起来碰巧能用。但$count为5时,[[ $count = 10 ]]判断的是“字符串5和字符串10是否相等”,显然不相等,行为看起来也正常。真正的隐患出现在$count为“010”这种值,或者你想要判断“大于”时还拿>当数学运算用。数值大小比较必须用-gt、-lt这套。

坑三:elif和else的搭配问题。

if condition1; then ... elif condition2; then ... else ... fi

这里真正容易出问题的不是语法,而是逻辑分支覆盖不全。我做评审时经常看到有人写了if-elif,但没有else兜底,于是条件全不满足时脚本什么都不做,后续还依赖“一定做了某事”,引发连锁故障。条件分支设计的原则是:每个可能的分支,要么明确处理,要么显式跳过,千万别留暗坑。

坑四:case分支漏掉默认兜底。

case的最后一个分支建议永远是*),匹配所有未命中的情况。没有*分支的case,一旦传入值不在枚举中,整个case就静默什么都不做,安全隐患很大。尤其在做环境名、操作名这类白名单校验时,没有*)等于没有校验。

坑五:if后直接跟grep管道时退出码被吞。

if echo "$text" | grep "abc"; then echo "包含" fi

这个看起来没问题,grep的退出码经过管道后,整条管道的退出码取的是最后一个命令——也就是grep的,所以逻辑上是正确的。但如果管道里还有其他命令:

if echo "$text" | grep "abc" | wc -l; then

wc -l永远返回0退出码,哪怕grep根本没匹配到任何行。这就是“管道后退出码被吞”的典型现象。正确的做法是用grep -q直接判断匹配与否,不用wc -l去数行数。

5.2 实战排查技巧:如何快速定位条件判断问题

排查条件语句问题,我有一套固定的方法论。第一步永远是先看变量值到底是什么。很多条件“不该成立却成立了”或者“该成立却不成立”,根因都是变量内容和你的预期不一致。调试手段是在if前加一行:

echo "DEBUG: status=[$status] count=[$count]" >&2

注意这里的>&2,把调试信息输出到标准错误,这样既不会被管道吞掉,也能和正常输出分开。

第二步是用bash -x慢跑脚本。-x参数会逐行打印实际执行的命令,展开所有变量和通配符,条件判断的结果一目了然。我遇到难的排查任务,一定会上bash -x,这比自己盯着代码来回推理高效得多。

bash -x deploy.sh test 1.2.3

执行后你会看到类似:

+ '[' -d /opt/demo_app/test ']' + echo '检测到已有部署目录,走增量更新流程'

+开头的行是实际执行的命令,'['和']'是方括号命令的展开形式。看到这里你就知道条件判断走到了哪个分支,以及变量展开成了什么。

第三步是缩小问题边界。如果你怀疑是某个条件判断错误,不要一个脚本从头到尾排查,把该条件单独抽出来写一个最小复现脚本测试。比如:

#!/usr/bin/env bash count="010" if [[ $count -eq 10 ]]; then echo "等于10" else echo "不等于10" fi

这个测试能帮你确认“010”这种前导零格式的数值比较是否有预期外的行为。顺带说一句,某次线上问题就是这个:版本号“010”在数值比较里被当成8进制处理,结果判断全乱套。这是Shell的老坑,在旧版Bash里存在。新版Bash虽然修正了部分行为,但保险起见,对于可能带前导零的字符串,建议统一按字符串比较,或者先转成十进制。

5.3 版本号比较的正确姿势

刚刚提到版本号比较,这确实是条件语句里很实际的痛难点。点分版本号比较,用sort -V最省心,不用自己拆分字符串。

version_gte() { [[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]] } if version_gte "$APP_VERSION" "$current_version"; then echo "新版本不低于当前版本,可以部署" else echo "新版本低于当前版本,拒绝部署" fi

这个函数的作用是:把两个版本号排序,取最大的那个,然后判断“最大的是不是第一个参数”。如果是,说明$1 >= $2。sort -V是“自然版本排序”,能正确处理1.10.0大于1.9.0这类点分版本号比较。这个技巧我在多个项目里反复用,屡试不爽,强烈推荐。

5.4 条件判断中的空值策略

空值处理是条件语句里最容易脏腑的地方之一。我见过好几类“空值翻车”场景,统一整理如下:

场景问题推荐处理
传入参数缺失-z "$1"判断为空,但后续赋值时可能产生错误路径缺参直接exit 1,不给后续机会
环境变量未设置$LOG_LEVEL未定义,[[ "$LOG_LEVEL" = "DEBUG" ]]会走进else,可能掩盖了没配环境变量的问题用${LOG_LEVEL:-info}给出默认值
列表变量为空遍历空列表时不进入循环,循环后依赖“已经有结果”的逻辑会坏遍历前判断列表是否为空,为空则显式跳过或报错

第二类和第三类场景非常典型。比如启动脚本里有JAVA_OPTS是从环境变量读取的,漏配时Java进程照常启动但JVM参数全没了,事后排查极费劲。用${JAVA_OPTS:--Xmx512m}这种默认值写法,能保证即使漏配也不会带出空参数。注意它和${JAVA_OPTS- -Xmx512m}的区别:前者连空字符串也一并替换成默认值,后者只在变量完全未定义时才替换。绝大多数场景里,前者更符合预期,因为空字符串和未定义对我们来说没有区别,都该用默认值。

6. 一些边角但实用的条件语法细节

6.1 反引号与条件语句的结合

也许有人觉得反引号是老古董,但它在条件判断场景里依然有妙用。判断一个命令的输出是否符合预期:

if [[ $(df -h / | awk 'NR==2 {print $5}' | tr -d '%') -gt 80 ]]; then echo "磁盘使用率超过80%,告警" fi

条件是“命令替换后的输出”。$(...)里的内容会被执行,然后以标准输出替换到原位置,最后参与数值比较。这里要注意的是,$(...)的返回值是一个字符串,如果它开头带空格或特殊字符,比较可能出意外。稳妥起见,可以用-z先做一层判断,或者把命令输出先赋给变量再比较:

disk_usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') if [[ -n "$disk_usage" && "$disk_usage" -gt 80 ]]; then echo "磁盘告警" fi

-n表示“字符串长度非零”,先确保有内容再比较,更稳当。

6.2 if和&&、||结合时的事故现场

我曾见过一个写得很“简洁”的脚本,最后却因为一句&& ||的组合出问题。典型写法是:

[[ -f "$conf" ]] && echo "配置存在" || echo "配置不存在"

这个写法的逻辑是:前一个测试为真,执行echo "配置存在";echo退出码为0,于是又执行echo "配置不存在"。最终两个echo都会执行!

为什么会这样?因为&&和||是左结合的,整句展开等价于:

( [[ -f "$conf" ]] && echo "配置存在" ) || echo "配置不存在"

如果[[ -f "$conf" ]]成立,它就执行echo "配置存在",此时整个&&部分的退出码由echo决定(0),于是||右侧又被执行,输出“配置不存在”。只有[[ -f "$conf" ]]失败,echo不执行,&&部分整体退出码非0,才会走到右侧输出。所以这个写法的实际行为是:条件成立时同时输出两句话,条件不成立时只输出一句话,跟预期完全相反。

正确的三目运算式写法是:

if [[ -f "$conf" ]]; then echo "配置存在" else echo "配置不存在" fi

条件语句就是这样,看起来越简短,越容易被运算符优先级暗算。三层以内的逻辑用&&和||确实方便,一旦涉及多个条件组合,老老实实写if-else才是维护友好。

6.3 利用退出码做条件短路

有一类情况值得单独记忆:命令的退出码可以用作短路的条件执行。比如启动服务前检查配置文件语法是否正确:

nginx -t -c /etc/nginx/nginx.conf && nginx -s reload

“检查语法”成功就“加载配置”,失败什么都不做。这就是利用退出码的条件短路——等价于if的简化版。写工具链脚本时很有用,但记住它只适合“成功就继续、失败就跳过”的透传语义,一旦失败时需要走单独的报错分支,还是要回归if。

7. 来自一线实践的模板建议

这篇文章从语法、操作符、实战场景、坑点排查到边角细节基本都过了一遍。我在实际写脚本时,条件语句部分的最终模板长这样:

#!/usr/bin/env bash # 脚本用途: 某跨平台系统组件部署 set -u # 变量未定义即报错,避免空值隐患 APP_HOME="/opt/demo_app" DEPLOY_ENV="${1:-}" APP_VERSION="${2:-}" # 参数校验 if [[ -z "$DEPLOY_ENV" || -z "$APP_VERSION" ]]; then echo "[ERROR] 参数缺失,用法: $0 <env> <version>" >&2 exit 1 fi

这段模板里有几个值得说明的选择:set -u让未定义变量的引用直接报错,可以从根上避免“变量为空还继续跑”的脏数据;${1:-}给参数一个空字符串默认值,让后续的-z判断可进行;>&2把错误信息送到标准错误流,重定向时不会被吞掉。

我做脚本评审时,会优先看这几个地方:条件判断是否覆盖了所有可能的失败路径;每个非零退出码有没有对应处理;变量引用是否都考虑到空值和特殊字符;日志信息能不能定位到具体分支。条件语句翻译成英文只有一个词——conditional,但写得好不好,差的可能就是一次线上事故的距离。

如果你刚接触Shell编程,我建议不要只背语法,而是顺手找几个真实的运维场景练手:写个带参数校验的备份脚本、写个按磁盘水位决策的清理脚本、写个根据进程存活状态决定重启还是报警的监控脚本。把条件语句在这些场景里反复揉一遍,坑踩过了,自然就内化成自己的经验了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询