#!/usr/bin/env bash set -euo pipefail # 将指定消费组在原始 Topic 上的 offset 重置到指定 Kafka 消息时间。 # 适用于“丢弃截止时间之前的历史积压,保留截止时间之后消息”的现场操作。 # # 本脚本不会停止/启动应用,也不会删除 Topic 或消息: # - 执行前必须由运维停止该消费组的全部 syslog-consumer-rule 实例; # - 自动导出原 offset,Kafka 数据仍在 retention 内时可用于人工回退; # - 先执行 dry-run,只有输入精确确认串才执行真正的 offset 重置。 SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) BOOTSTRAP="${BOOTSTRAP:-10.141.121.35:9092,10.141.121.50:9092,10.141.121.59:9092}" GROUP_ID="${GROUP_ID:-agent-01-syslog-group}" RAW_TOPIC="${RAW_TOPIC:-agent-01-syslog-topic}" CUTOFF_DATETIME="${CUTOFF_DATETIME:-2026-09-11T00:00:00.000}" KAFKA_TIMEZONE="${KAFKA_TIMEZONE:-Asia/Shanghai}" # 优先使用调用方显式指定的目录;否则自动识别脚本同目录、现场 Kafka 安装目录或 PATH。 if [[ -z "${KAFKA_BIN:-}" ]]; then for CANDIDATE_BIN in \ "${SCRIPT_DIR}" \ "/opt/kafka/kafka_2.13-3.6.1/bin"; do if [[ -x "${CANDIDATE_BIN}/kafka-consumer-groups.sh" ]] \ && [[ -x "${CANDIDATE_BIN}/kafka-topics.sh" ]]; then KAFKA_BIN="${CANDIDATE_BIN}" break fi done fi if [[ -z "${KAFKA_BIN:-}" ]] && command -v kafka-consumer-groups.sh >/dev/null 2>&1; then KAFKA_BIN=$(dirname -- "$(command -v kafka-consumer-groups.sh)") fi if [[ -z "${KAFKA_BIN:-}" ]]; then echo "ERROR: 未找到 Kafka CLI;现场预期路径为 /opt/kafka/kafka_2.13-3.6.1/bin" >&2 exit 1 fi CONSUMER_GROUPS_CMD="${KAFKA_BIN}/kafka-consumer-groups.sh" TOPICS_CMD="${KAFKA_BIN}/kafka-topics.sh" if [[ ! -x "${CONSUMER_GROUPS_CMD}" ]] || [[ ! -x "${TOPICS_CMD}" ]]; then echo "ERROR: ${KAFKA_BIN} 下缺少可执行的 kafka-consumer-groups.sh 或 kafka-topics.sh" >&2 exit 1 fi if [[ ! "${CUTOFF_DATETIME}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}$ ]]; then echo "ERROR: CUTOFF_DATETIME 格式必须是 YYYY-MM-DDTHH:mm:ss.SSS" >&2 exit 1 fi COMMON_ARGS=(--bootstrap-server "${BOOTSTRAP}") if [[ -n "${COMMAND_CONFIG:-}" ]]; then if [[ ! -r "${COMMAND_CONFIG}" ]]; then echo "ERROR: 无法读取认证配置 ${COMMAND_CONFIG}" >&2 exit 1 fi COMMON_ARGS+=(--command-config "${COMMAND_CONFIG}") fi # Kafka 3.6 的 --to-datetime 不携带时区,显式固定 JVM 时区,避免宿主机差异造成 8 小时偏移。 export KAFKA_OPTS="${KAFKA_OPTS:-} -Duser.timezone=${KAFKA_TIMEZONE}" RUN_ID=$(date '+%Y%m%d_%H%M%S') AUDIT_DIR="${OFFSET_BACKUP_DIR:-./kafka-offset-reset-${RUN_ID}}" mkdir -p "${AUDIT_DIR}" BEFORE_FILE="${AUDIT_DIR}/before-describe.txt" RESTORE_FILE="${AUDIT_DIR}/restore-current-offsets.csv" DRY_RUN_FILE="${AUDIT_DIR}/reset-dry-run.txt" EXECUTE_FILE="${AUDIT_DIR}/reset-execute.txt" AFTER_FILE="${AUDIT_DIR}/after-describe.txt" echo "目标 Kafka:${BOOTSTRAP}" echo "目标消费组:${GROUP_ID}" echo "目标 Topic:${RAW_TOPIC}" echo "保留起点:${CUTOFF_DATETIME} (${KAFKA_TIMEZONE})" echo "审计目录:${AUDIT_DIR}" echo "[1/6] 检查 Topic" TOPIC_INFO=$("${TOPICS_CMD}" "${COMMON_ARGS[@]}" --describe --topic "${RAW_TOPIC}") printf '%s\n' "${TOPIC_INFO}" PARTITION_COUNT=$(printf '%s\n' "${TOPIC_INFO}" | awk ' /PartitionCount:/ { for (i=1; i<=NF; i++) if ($i=="PartitionCount:") { print $(i+1); exit } } ') if [[ ! "${PARTITION_COUNT}" =~ ^[1-9][0-9]*$ ]]; then echo "ERROR: 无法确认 ${RAW_TOPIC} 的分区数" >&2 exit 1 fi echo "[2/6] 检查消费组是否已停止,并保存执行前快照" "${CONSUMER_GROUPS_CMD}" "${COMMON_ARGS[@]}" \ --describe --group "${GROUP_ID}" 2>&1 | tee "${BEFORE_FILE}" MATCHED_ROWS=$(awk -v topic="${RAW_TOPIC}" ' $2==topic && $3~/^[0-9]+$/ && $4~/^[0-9]+$/ && $5~/^[0-9]+$/ {count++} END {print count+0} ' "${BEFORE_FILE}") if [[ "${MATCHED_ROWS}" -ne "${PARTITION_COUNT}" ]]; then echo "ERROR: 快照只识别到 ${MATCHED_ROWS}/${PARTITION_COUNT} 个目标分区,拒绝继续" >&2 exit 1 fi if awk -v topic="${RAW_TOPIC}" ' $2==topic && $3~/^[0-9]+$/ && $7!="-" {active=1} END {exit(active ? 0 : 1)} ' "${BEFORE_FILE}"; then echo "ERROR: 消费组 ${GROUP_ID} 仍有在线成员。请先停止全部 syslog-consumer-rule 实例。" >&2 exit 1 fi CURRENT_LAG=$(awk -v topic="${RAW_TOPIC}" ' $2==topic && $6~/^[0-9]+$/ {sum+=$6} END {printf "%.0f", sum+0} ' "${BEFORE_FILE}") echo "当前积压:${CURRENT_LAG} 条" echo "[3/6] 导出原 offset 回退文件" "${CONSUMER_GROUPS_CMD}" "${COMMON_ARGS[@]}" \ --group "${GROUP_ID}" \ --topic "${RAW_TOPIC}" \ --reset-offsets \ --to-current \ --dry-run \ --export > "${RESTORE_FILE}" RESTORE_ROWS=$(awk -F, -v topic="${RAW_TOPIC}" ' $1==topic && $2~/^[0-9]+$/ && $3~/^[0-9]+$/ {count++} END {print count+0} ' "${RESTORE_FILE}") if [[ "${RESTORE_ROWS}" -ne "${PARTITION_COUNT}" ]]; then echo "ERROR: 回退文件只包含 ${RESTORE_ROWS}/${PARTITION_COUNT} 个分区,拒绝继续" >&2 exit 1 fi echo "原 offset 已保存:${RESTORE_FILE}" echo "[4/6] 预演按时间重置" "${CONSUMER_GROUPS_CMD}" "${COMMON_ARGS[@]}" \ --group "${GROUP_ID}" \ --topic "${RAW_TOPIC}" \ --reset-offsets \ --to-datetime "${CUTOFF_DATETIME}" \ --dry-run | tee "${DRY_RUN_FILE}" PLAN_ROWS=$(awk -v topic="${RAW_TOPIC}" ' $1==topic && $2~/^[0-9]+$/ && $3~/^[0-9]+$/ {count++; next} $2==topic && $3~/^[0-9]+$/ && $4~/^[0-9]+$/ {count++} END {print count+0} ' "${DRY_RUN_FILE}") if [[ "${PLAN_ROWS}" -ne "${PARTITION_COUNT}" ]]; then echo "ERROR: dry-run 只生成 ${PLAN_ROWS}/${PARTITION_COUNT} 个分区,拒绝执行" >&2 exit 1 fi EXPECTED_CONFIRM="RESET:${GROUP_ID}:${RAW_TOPIC}:${CUTOFF_DATETIME}" echo "[5/6] 等待人工确认" echo "即将永久跳过 ${CUTOFF_DATETIME} (${KAFKA_TIMEZONE}) 之前尚未消费的消息。" echo "原 offset 回退文件:${RESTORE_FILE}" echo "如确认执行,请完整输入:${EXPECTED_CONFIRM}" if [[ -n "${CONFIRM_RESET:-}" ]]; then CONFIRM_INPUT="${CONFIRM_RESET}" elif [[ -t 0 ]]; then read -r CONFIRM_INPUT else echo "ERROR: 当前不是交互终端;必须通过 CONFIRM_RESET 提供精确确认串" >&2 exit 1 fi if [[ "${CONFIRM_INPUT}" != "${EXPECTED_CONFIRM}" ]]; then echo "CANCELLED: 确认串不匹配,未修改任何 offset。" >&2 exit 1 fi echo "[6/6] 执行 offset 重置" "${CONSUMER_GROUPS_CMD}" "${COMMON_ARGS[@]}" \ --group "${GROUP_ID}" \ --topic "${RAW_TOPIC}" \ --reset-offsets \ --to-datetime "${CUTOFF_DATETIME}" \ --execute | tee "${EXECUTE_FILE}" echo "保存执行后快照" "${CONSUMER_GROUPS_CMD}" "${COMMON_ARGS[@]}" \ --describe --group "${GROUP_ID}" 2>&1 | tee "${AFTER_FILE}" echo "PASS: 已将 ${GROUP_ID}/${RAW_TOPIC} 重置到 ${CUTOFF_DATETIME} (${KAFKA_TIMEZONE})。" echo "未删除 Topic;现在可以启动 syslog-consumer-rule 并检查 LAG。" echo "回退文件:${RESTORE_FILE}(仅在 Kafka 原消息仍处于 retention 内时有效)。"