0Pricing
Linux Command Line & Bash Scripting Mastery · 课时

幂等脚本与带退避的重试逻辑

设计可安全重复运行的操作,并为不稳定的外部调用添加指数退避。

幂等脚本与带退避的重试逻辑 是 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 在用户存在时返回 0
  • getent 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."

在实际工作流中结合幂等性与重试

在生产环境的脚本中,幂等性和重试逻辑会协同工作。一个典型的部署工作流可能会:

  1. 获取锁(防止并发运行)
  2. 检查状态文件(跳过已完成的步骤)
  3. 对外部调用使用带退避的重试(下载、API、DNS)
  4. 仅在确认成功后将步骤标记为完成
  5. 通过 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 反馈 — 无需本地设置。

此课程中的所有课时

  1. 使用 set -euo pipefail 启用严格模式
  2. 用于清理和处理信号的陷阱处理器
  3. 安全的临时文件与锁目录
  4. 幂等脚本与带退避的重试逻辑
← 返回 Linux Command Line & Bash Scripting Mastery