写Shell脚本做批量任务的时候,有一个特别常见的头疼问题:某个任务执行着执行着就卡住了,既不成功也不退出。这时候timeout命令可以设一个上限,超时就把进程杀掉;但光杀掉还不够——我们需要的是让它超时后自动重试,而不是直接放弃。
timeout和while循环单独用各有各的局限,把它们组合起来才能实现「既不卡死、又不放弃」的理想重试模式。这篇文章讲清楚这个组合的核心思路、几种常见写法,以及实际用的时候要注意哪些坑。
为什么需要timeout + while的组合
先说清楚两个命令各自能做什么、做不到什么。
timeout:给命令设一个存活时间,超时就发送信号强制终止。适合防止命令永久挂起,但不负责重试逻辑。
while循环:根据条件反复执行命令,适合循环等待某个结果,但本身不内置超时概念,一个死循环可能永远跑下去。
把它们组合起来,while负责控制「重试多少次」,timeout负责控制「每次给多少时间」。两者各司其职,形成一个完整的重试框架。
基础写法:最直接的一种组合
先看一个最简单的模板:
#!/bin/bash
max_attempts=5
attempt=0
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
echo "第 $attempt 次尝试..."
# timeout 30秒执行目标命令,退出码124表示超时
timeout 30s curl -s http://example.com/api/check > /tmp/result.txt
if [ $? -eq 0 ]; then
echo "成功"
break
else
echo "失败,10秒后重试..."
sleep 10
fi
done
这个脚本的逻辑很清晰:用while控制总次数,timeout保护每次执行不超30秒。如果curl正常返回(退出码0),退出循环;如果超时或出错(退出码非0),等10秒再试,最多试5次。
区分超时和其他错误
上面这个写法把所有非0退出码都当成「失败」处理了。但实际场景中,超时(退出码124)和命令本身执行失败(其他退出码)应该区别对待。改进一下:
#!/bin/bash
max_attempts=5
attempt=0
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "第 $attempt 次:请求成功"
break
elif [ $exit_code -eq 124 ]; then
echo "第 $attempt 次:超时(timeout退出码124),10秒后重试"
else
echo "第 $attempt 次:命令失败(退出码 $exit_code),5秒后重试"
fi
[ $attempt -lt $max_attempts ] && sleep 10
done
timeout的退出码124是它特有的标识,表示进程被超时机制杀掉了。这个数字在排查脚本问题的时候很有用——看到124就知道是时间到了被强制停的,不是因为命令本身报错了。
带指数退避的写法
每次失败都等固定时间有时候不够优雅。当远程服务压力大的时候,拼命重试反而会把它冲垮。用指数退避让等待时间逐步拉长,效果更好:
#!/bin/bash
max_attempts=5
attempt=0
base_delay=5
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt
if [ $? -eq 0 ]; then
echo "成功"
break
fi
# 指数退避:5s -> 10s -> 20s -> 40s
delay=$(( base_delay * (2 ** (attempt - 1)) ))
echo "第 $attempt 次失败,${delay}秒后重试(最多 $max_attempts 次)"
[ $attempt -lt $max_attempts ] && sleep $delay
done
if [ $attempt -eq $max_attempts ]; then
echo "达到最大重试次数,任务失败"
exit 1
fi
这里用了一个简化的指数公式,每次失败后等待时间是上一次的2倍。如果担心多个客户端同时退避导致「重试风暴」,可以再加上一点随机抖动(jitter)来分散重试时间点。
while配合until:等条件满足为止
有时候重试的目标不是「执行命令」,而是「等某个条件变成真」。比如等远程服务上线、等某个文件出现、等数据库连接建立。这种场景下while和until配合timeout更自然:
#!/bin/bash
# 等服务就绪,超时则退出
timeout=120 # 最多等2分钟
elapsed=0
while [ $elapsed -lt $timeout ]; do
# 尝试连接一次
timeout 5s curl -s http://localhost:8080/health > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "服务就绪,继续执行后续步骤"
break
fi
echo "服务尚未就绪,等待中...(已等待 ${elapsed}s)"
sleep 10
elapsed=$((elapsed + 10))
done
if [ $elapsed -ge $timeout ]; then
echo "等待超时,服务启动失败"
exit 1
fi
这个写法用while控制一个总超时时间,每次循环等待10秒,累计超时2分钟就放弃。适合做启动依赖检查。
trap收尾:防止脚本被中断后留尾巴
重试脚本运行时如果被人按了Ctrl+C中断,子进程可能还挂在后台。要养成在脚本开头加上trap的习惯:
#!/bin/bash
trap 'echo "收到中断信号,清理退出"; kill 0 2>/dev/null; exit 1' INT TERM
max_attempts=5
attempt=0
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt
[ $? -eq 0 ] && break
echo "第 $attempt 次失败,等待后重试..."
[ $attempt -lt $max_attempts ] && sleep 10
done
trap设置了INT(Ctrl+C)和TERM(kill命令)两个信号的处理方式,收到任意一个就杀掉整个进程组再退出,防止留下孤儿进程。
几个容易踩的坑
1. timeout和wait的退出码不要混淆。timeout返回124表示超时杀掉,返回137表示被SIGKILL(128+9)杀掉,正常执行完返回命令本身的退出码。while循环里的$?是最近一条命令的退出码,在执行timeout之后立即读取才准确。
2. 后台进程不会被timeout杀掉。如果命令里用了&后台运行,timeout只能管到主进程,子进程会继续跑。需要用进程组的方式处理:timeout 30s bash -c 'command &'。
3. sleep在timeout之前还是之后要分清楚。重试循环里如果先timeout再sleep,每次失败后都会等。如果sleep的时候被中断就跳过一次等待。更好的做法是在循环开头就判断是否超过最大次数。
总结
timeout和while循环的组合,本质上是把「单次超时控制」和「多次重试循环」两个职责分开,各干各的。掌握这个思路之后,可以根据实际需求灵活调整:
- 需要限制总时长 → 在while外层加总超时计数
- 需要指数退避 → 每次失败后延长等待时间
- 需要区分错误类型 → 判断退出码124(超时)还是其他
- 需要安全退出 → 加trap清理子进程
把这几个要素组合起来,就能写出一个在任何批量运维场景下都靠得住的重试脚本。
相关文章:
发表评论