幂等脚本与带退避的重试逻辑
设计可安全重复运行的操作,并为不稳定的外部调用添加指数退避。
幂等脚本与带退避的重试逻辑 是 CoddyKit 上的免费 Linux Command Line & Bash Scripting Mastery 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Linux Command Line & Bash Scripting Mastery 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。
什么是幂等性,以及它为何重要
幂等性是指多次执行同一个操作所产生的结果,与执行一次该操作的结果相同。在 Bash 脚本中,这一点非常关键,因为脚本可能崩溃、网络可能中断,而人也可能意外地重复执行操作。
- 创建用户的非幂等脚本在第二次创建时可能失败或产生重复数据。
- 幂等脚本会先检查:该对象是否已经存在?
- 幂等脚本可以安全地用于 Cron 作业、持续集成流水线和重试循环。
黄金法则是:先检查,再执行。每个具有破坏性或创建性的操作都应由前置条件检查加以保护。
防止创建文件和目录
最常见的幂等模式是在创建资源之前检查它是否已经存在。Bash 为此提供了简洁的单行命令。
[ -d dir ]——如果目录存在,则为真[ -f file ]——如果普通文件存在,则为真mkdir -p——仅在目录不存在时创建目录(内置幂等性)
如果可用,请优先使用 -p 和 --no-clobber 等内置标志,而不是手动检查,因为它们是原子的,可以避免竞态条件。
#!/usr/bin/env bash
set -euo pipefail
CONFIG_DIR="$HOME/.myapp"
CONFIG_FILE="$CONFIG_DIR/config.ini"
# Idempotent: mkdir -p never fails if dir already exists
mkdir -p "$CONFIG_DIR"
# Idempotent: only write config if it doesn't exist yet
if [ ! -f "$CONFIG_FILE" ]; then
echo '[defaults]' > "$CONFIG_FILE"
echo 'timeout=30' >> "$CONFIG_FILE"
echo "Created $CONFIG_FILE"
else
echo "Config already exists, skipping."
fi幂等的用户和组管理
添加用户或组等系统管理任务必须具备幂等性:在同一台机器上重新运行脚本时,不应报错或创建重复对象。
id username在用户存在时返回 0getent group groupname检查组是否存在- 为每个操作添加保护条件,使脚本可以安全地重新执行
这种模式是 Ansible 等配置管理工具的基础——每个任务都是经过保护的幂等操作。
#!/usr/bin/env bash
set -euo pipefail
APP_USER="apprunner"
APP_GROUP="appgroup"
# Idempotent group creation
if ! getent group "$APP_GROUP" &>/dev/null; then
groupadd "$APP_GROUP"
echo "Group '$APP_GROUP' created."
else
echo "Group '$APP_GROUP' already exists."
fi
# Idempotent user creation
if ! id "$APP_USER" &>/dev/null; then
useradd -m -g "$APP_GROUP" -s /bin/bash "$APP_USER"
echo "User '$APP_USER' created."
else
echo "User '$APP_USER' already exists."
fi使用锁文件防止并发运行
即使脚本具备幂等性,两个实例同时运行仍可能造成问题。锁文件可确保同一时间只有一个实例运行。
- 在启动时创建锁文件,并在退出时删除它。
- 使用
trap清理锁,即使脚本被中断也能完成清理。 - 在大多数 Linux 文件系统中,对单一路径执行
mkdir是原子操作,比使用touch加锁更安全。
没有锁时,运行缓慢的 Cron 作业和手动重新运行的脚本可能发生冲突,并损坏共享状态。
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE="/tmp/myapp_deploy.lock"
# Atomic lock acquisition using mkdir
if ! mkdir "$LOCKFILE" 2>/dev/null; then
echo "ERROR: Another instance is running (lock: $LOCKFILE). Exiting." >&2
exit 1
fi
# Guarantee lock removal on any exit
trap 'rmdir "$LOCKFILE"; echo "Lock released."' EXIT
echo "Lock acquired. Running deployment..."
sleep 2 # simulate work
echo "Deployment complete."使用状态文件跟踪已完成的步骤
对于包含多个步骤的脚本(迁移、安装、配置),您可以使用状态文件跟踪哪些步骤已经完成。每个步骤在执行前检查状态文件,完成后将结果写入其中。
- 成本低且可移植,无需数据库。
- 脚本失败后可以从中断的位置继续执行。
- 将状态存储在可预测的位置,例如
/var/lib/myapp/或~/.myapp/state/。
apt、cloud-init 和数据库迁移框架等主要工具都使用这种模式。
#!/usr/bin/env bash
set -euo pipefail
STATE_DIR="/tmp/myapp_state"
mkdir -p "$STATE_DIR"
run_step() {
local step_name="$1"
local step_cmd="$2"
local marker="$STATE_DIR/${step_name}.done"
if [ -f "$marker" ]; then
echo "[SKIP] $step_name already completed."
return 0
fi
echo "[RUN] $step_name ..."
eval "$step_cmd"
touch "$marker"
echo "[DONE] $step_name"
}
run_step "install_deps" "echo 'Installing dependencies...'"
run_step "configure_db" "echo 'Configuring database...'"
run_step "start_service" "echo 'Starting service...'"重试逻辑简介
外部调用——HTTP 请求、DNS 查询和云 API 调用——本质上都不可靠。单次失败不应中止整个脚本。重试逻辑会自动再次尝试执行失败的命令。
- 朴素重试:循环执行 N 次,直到命令成功。
- 始终设置最大重试次数,以避免无限循环。
- 记录每次尝试,以便诊断失败原因。
最简单的重试函数会包裹任意命令,并以固定次数和固定延迟进行重试。这足以应对许多使用场景,但在高负载下存在一个严重缺陷——下一节将介绍这一点。
#!/usr/bin/env bash
set -euo pipefail
# Simple fixed-delay retry (3 attempts, 2s apart)
retry() {
local max_attempts=3
local delay=2
local attempt=1
until "$@"; do
if (( attempt >= max_attempts )); then
echo "ERROR: Command failed after $max_attempts attempts: $*" >&2
return 1
fi
echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2
sleep "$delay"
(( attempt++ ))
done
}
# Example: retry a curl call
retry curl --silent --fail --max-time 5 https://httpbin.org/get -o /dev/null
echo "Request succeeded."惊群问题
许多客户端在失败后同时重试,会形成惊群:所有客户端以完全相同的固定间隔重试,在同一时刻向服务器发送大量请求,使服务器无法恢复。
- 100 个脚本每 5 秒重试一次 → 每 5 秒产生 100 个同时到达的请求。
- 服务器本已不堪重负,同步产生的负载会使情况更加糟糕。
- 解决方案是指数退避:每次失败后将等待时间加倍。
- 加入抖动(随机扰动),使不同客户端的重试时间错开。
带抖动的指数退避是行业标准,AWS SDK、Google Cloud 客户端以及所有主要分布式系统都在使用它。
实现指数退避
指数退避会在每次失败后以指数方式增加等待时间:1 秒、2 秒、4 秒、8 秒、16 秒……这样既能给远程系统恢复的时间,又能减少总体负载。
公式:delay = base * (2 ^ attempt)
- 设置一个上限(最大延迟),避免等待时间无限增长。
- 需要调整的参数:
base_delay、max_delay、max_attempts。
此函数可以重复使用——将任意命令作为参数传递给它即可。
#!/usr/bin/env bash
set -euo pipefail
retry_with_backoff() {
local max_attempts="${RETRY_MAX_ATTEMPTS:-5}"
local base_delay="${RETRY_BASE_DELAY:-1}"
local max_delay="${RETRY_MAX_DELAY:-30}"
local attempt=0
local delay="$base_delay"
until "$@"; do
(( attempt++ ))
if (( attempt >= max_attempts )); then
echo "ERROR: '$*' failed after $max_attempts attempts." >&2
return 1
fi
echo "Attempt $attempt failed. Backing off for ${delay}s..." >&2
sleep "$delay"
# Double the delay, but cap it
delay=$(( delay * 2 ))
(( delay > max_delay )) && delay=$max_delay
done
echo "Command succeeded on attempt $(( attempt + 1 ))."
}
retry_with_backoff echo "Simulated success"为退避添加抖动
抖动会为退避延迟加入随机性。即使采用指数退避,如果所有客户端同时启动,它们仍会同步重试。抖动可以打破这种同步。
两种常见的抖动策略:
- 完全抖动:
sleep random(0, cap)——分散效果最大,峰值负载最低。 - 等量抖动:
sleep cap/2 + random(0, cap/2)——保证最短等待时间,避免立即集中发起请求。
在 Bash 中,使用 $RANDOM(0–32767)生成随机数。使用取模运算将其缩放到延迟范围内。
#!/usr/bin/env bash
set -euo pipefail
retry_with_jitter() {
local max_attempts="${1:-5}"; shift
local base_delay=1
local max_delay=32
local attempt=0
local cap=$base_delay
until "$@"; do
(( attempt++ ))
if (( attempt >= max_attempts )); then
echo "ERROR: Giving up after $max_attempts attempts." >&2
return 1
fi
# Full jitter: random value in [0, cap]
local jitter=$(( RANDOM % (cap + 1) ))
echo "Attempt $attempt failed. Sleeping ${jitter}s (cap=${cap}s)..." >&2
sleep "$jitter"
# Grow cap exponentially, bounded by max_delay
cap=$(( cap * 2 ))
(( cap > max_delay )) && cap=$max_delay
done
}
retry_with_jitter 4 curl --silent --fail --max-time 3 https://httpbin.org/get -o /dev/null
echo "Done."在实际工作流中结合幂等性与重试
在生产环境的脚本中,幂等性和重试逻辑会协同工作。一个典型的部署工作流可能会:
- 获取锁(防止并发运行)
- 检查状态文件(跳过已完成的步骤)
- 对外部调用使用带退避的重试(下载、API、DNS)
- 仅在确认成功后将步骤标记为完成
- 通过
trap释放锁
这种组合使脚本可以在任意时刻安全地重新运行——无论是在崩溃、超时还是手动中止之后,都不会让系统处于损坏状态。
#!/usr/bin/env bash
set -euo pipefail
STATE_DIR="/tmp/deploy_state" && mkdir -p "$STATE_DIR"
LOCK="/tmp/deploy.lock"
mkdir "$LOCK" 2>/dev/null || { echo "Already running."; exit 1; }
trap 'rmdir "$LOCK"' EXIT
step_done() { [ -f "$STATE_DIR/$1.done" ]; }
mark_done() { touch "$STATE_DIR/$1.done"; }
retry_backoff() {
local attempt=0 delay=1
until "${@:2}"; do
(( ++attempt >= $1 )) && { echo "Failed after $1 attempts."; return 1; }
echo "Retry $attempt in ${delay}s..."; sleep $delay; delay=$(( delay * 2 ))
done
}
if ! step_done "download_artifact"; then
retry_backoff 4 curl -fsSL https://httpbin.org/get -o /tmp/artifact.json
mark_done "download_artifact"
echo "[DONE] download_artifact"
else
echo "[SKIP] download_artifact"
fi
echo "Deployment finished successfully."处理不可重试的错误
并非所有错误都应该重试。重试 404 Not Found 或 403 Forbidden 没有意义——如果没有人工干预,它们永远不会成功。您的重试逻辑应区分以下错误:
- 暂时性错误——网络超时、503 Service Unavailable、DNS 故障 → 重试
- 永久性错误——401 Unauthorized、404 Not Found、无效输入 → 立即失败
使用 curl 时,请检查 HTTP 状态码,并对 4xx 响应跳过重试。使用 --write-out '%{http_code}' 将状态码与响应正文分开捕获。
#!/usr/bin/env bash
set -euo pipefail
fetch_with_retry() {
local url="$1"
local max_attempts=4
local delay=1
local attempt=0
local http_code
until http_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url"); do
: # curl itself failed (network error)
(( ++attempt >= max_attempts )) && { echo "Network error, giving up."; return 1; }
sleep $delay; delay=$(( delay * 2 ))
done
# Permanent client errors — do not retry
if [[ "$http_code" =~ ^4 ]]; then
echo "ERROR: HTTP $http_code for $url — not retrying." >&2
return 1
fi
# Transient server errors — retry
if [[ "$http_code" =~ ^5 ]]; then
(( ++attempt >= max_attempts )) && { echo "Server error, giving up."; return 1; }
echo "HTTP $http_code — backing off ${delay}s..."
sleep $delay; delay=$(( delay * 2 ))
fetch_with_retry "$url"
return
fi
echo "HTTP $http_code — success."
}
fetch_with_retry "https://httpbin.org/status/200"知识检查:选择退避策略
某个部署脚本会从 S3 存储桶下载发布构件。最近一次故障中,80 个管道实例因 S3 短暂中断而同时失败。10 秒后 S3 恢复时,80 个实例在同一时刻全部重试,导致再次过载,并使中断时间延长了 3 分钟。
未来发生类似故障时,哪种重试策略最能防止这种惊群级联?
回顾:幂等性与带退避的重试
在本课中,您学习了如何设计可以安全重新运行且能够抵御暂时性故障的 Bash 脚本。
幂等性模式:
- 通过检查是否存在来保护每个操作(
[ -f ]、[ -d ]、id、getent)。 - 在可用时使用
mkdir -p及其他内置的幂等选项。 - 使用锁文件(原子
mkdir)防止并发运行。 - 使用状态文件(每个步骤对应一个标记文件),以便在失败后继续执行。
带退避的重试模式:
- 始终设置最大尝试次数——永远不要无限重试。
- 使用指数退避:每次失败后将延迟加倍。
- 加入抖动(随机性),防止惊群同步。
- 区分暂时性(重试)和永久性(快速失败)错误。
结合这些模式,可以构建达到生产级别的脚本:安全、可观测且能够自我修复。
常见问题解答
「幂等脚本与带退避的重试逻辑」课时是免费的吗?
是的 — 「幂等脚本与带退避的重试逻辑」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Linux Command Line & Bash Scripting Mastery 课程的其余内容,请升级到 CoddyKit PRO。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。
「幂等脚本与带退避的重试逻辑」这节课中我会学到什么?
设计可安全重复运行的操作,并为不稳定的外部调用添加指数退避。 你通过在浏览器中直接运行的动手代码来练习 Linux Command Line & Bash Scripting Mastery,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Linux Command Line & Bash Scripting Mastery 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Linux Command Line & Bash Scripting Mastery 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「幂等脚本与带退避的重试逻辑」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Linux Command Line & Bash Scripting Mastery 课中编写并运行代码吗?
能。每节 Linux Command Line & Bash Scripting Mastery 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。