文件操作与时间处理:文件检测、时间戳与日期格式化

文件操作和时间处理,是自动化脚本里最基础也最容易翻车的环节。我见过太多脚本因为没检查文件是否存在,直接跑崩了。也见过因为时间戳理解偏差,导致备份脚本永远删不对文件。

说白了,你要让脚本像人一样去判断:文件在不在?什么时候改的?现在几点了?该不该处理它?

这一章,我把这些核心操作拆开揉碎了讲。都是我在生产环境里踩过坑之后总结出来的经验。

文件检测:-f 和 -d 的用法与陷阱

Shell 里判断文件类型,最常用的就是 -f 和 -d。但很多人用错了地方。

  • -f:判断是否为普通文件(regular file)。注意,它返回真,前提是文件存在且是普通文件。符号链接、设备文件、管道都不算。
  • -d:判断是否为目录。这个比较直观,但要注意符号链接指向目录时,-d 也会返回真。

核心区别:-f 只认普通文件,-d 只认目录。别拿 -f 去检查目录,也别拿 -d 去检查文件。

我习惯在脚本开头先做一轮文件检测,避免后续操作报错。举个例子:

#!/bin/bash
LOG_FILE="/var/log/app/access.log"
BACKUP_DIR="/data/backup/logs"

# 先检查日志文件是否存在
if [[ ! -f "$LOG_FILE" ]]; then
    echo "错误:日志文件 $LOG_FILE 不存在"
    exit 1
fi

# 再检查备份目录是否存在,不存在就创建
if [[ ! -d "$BACKUP_DIR" ]]; then
    echo "备份目录不存在,正在创建..."
    mkdir -p "$BACKUP_DIR"
fi

这里有个细节:[[ ! -f "$LOG_FILE" ]] 里的双中括号比单中括号更安全,能避免变量为空时语法错误。我个人建议统一用双中括号。

注意:-f 和 -d 对符号链接的处理不同。如果 /tmp/link 是一个指向普通文件的符号链接,-f /tmp/link 返回真,-d /tmp/link 返回假。如果指向目录,则相反。想判断链接本身,用 -L。

文件时间戳:mtime、ctime、atime 的区别

文件有三个时间戳,很多人搞混。我刚开始也记不住,直到有一次排查日志轮换问题,才发现自己理解错了。

时间戳 含义 触发条件 常用场景
mtime 修改时间(modify time) 文件内容被修改 判断文件是否被更新过
ctime 状态变更时间(change time) 文件权限、所有者、链接数等元数据变化 检测文件属性是否被修改
atime 访问时间(access time) 文件被读取(cat、less、grep 等) 很少用,因为频繁更新影响性能

你想想看,日志轮换脚本最关心什么?当然是文件内容什么时候被修改过。所以 mtime 是主力。ctime 在检测文件权限是否被篡改时有用,但日常轮换脚本基本用不到。

用 stat 命令可以查看这三个时间戳:

$ stat /var/log/syslog
  File: /var/log/syslog
  Size: 12845     	Blocks: 32         IO Block: 4096   regular file
Device: 801h/2049d	Inode: 654321      Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2025-03-10 09:15:22.123456789 +0800
Modify: 2025-03-10 08:30:45.987654321 +0800
Change: 2025-03-10 08:30:45.987654321 +0800

注意看,mtime 和 ctime 经常同时更新,因为修改内容通常也会触发状态变更。但反过来不一定:只改权限,ctime 变,mtime 不变。

经验之谈:在自动化轮换脚本里,我几乎只用 mtime。ctime 和 atime 容易让人迷惑,除非你有特殊需求,否则别碰它们。

日期格式化:date 命令的实战用法

date 命令是时间处理的瑞士军刀。格式化、计算、转换,全靠它。

先看最常用的格式化输出:

# 当前时间,格式:2025-03-10 14:30:00
date "+%Y-%m-%d %H:%M:%S"

# 只取日期,用于文件名
date "+%Y%m%d"          # 输出:20250310

# 带时分秒,用于日志时间戳
date "+%Y-%m-%d_%H-%M-%S"  # 输出:2025-03-10_14-30-00

我在项目中遇到过一个问题:备份文件名用了 date "+%Y-%m-%d",结果同一天多次备份时,后面的文件覆盖了前面的。后来我改成 date "+%Y%m%d_%H%M%S",才彻底解决。

日期计算也是高频操作:

# 7天前的日期
date -d "7 days ago" "+%Y-%m-%d"

# 30天前的日期
date -d "30 days ago" "+%Y-%m-%d"

# 明天的日期
date -d "tomorrow" "+%Y-%m-%d"

# 上个月的第一天
date -d "$(date +%Y-%m-01) -1 month" "+%Y-%m-%d"

嗯,这里要注意:-d 参数在 macOS 上不兼容,需要用 -v。如果你写跨平台脚本,得做兼容处理。不过 Linux 服务器上,-d 完全够用。

推荐格式:文件名里用 %Y%m%d_%H%M%S,不带分隔符,排序友好,也不会被空格坑。

时间比较逻辑:如何判断文件是否过期

时间比较是轮换脚本的核心逻辑。你要判断:这个文件是不是超过 7 天没改了?该不该删?

常用的方法有两种:

方法一:用 find 命令直接筛选

这是最推荐的方式,简单高效:

# 找出 /var/log 下 7 天前修改过的 .log 文件
find /var/log -name "*.log" -type f -mtime +7

-mtime +7 表示修改时间在 7 天之前。注意 +7 是大于 7 天,-7 是小于 7 天,7 是正好 7 天前。

配合删除操作:

find /var/log -name "*.log" -type f -mtime +30 -exec rm {} \;

但这里有个坑:-mtime 是按天计算的,精度不够。如果你需要精确到小时,用 -mmin:

# 找出 12 小时前修改过的文件
find /var/log -name "*.log" -type f -mmin +720

方法二:用 stat 获取时间戳,手动比较

有时候你需要更灵活的逻辑,比如只删除特定目录下、特定前缀的文件。这时可以自己写比较逻辑:

#!/bin/bash
BACKUP_DIR="/data/backup"
RETENTION_DAYS=7
CURRENT=$(date +%s)

for file in "$BACKUP_DIR"/*.tar.gz; do
    # 获取文件的 mtime(秒级时间戳)
    FILE_MTIME=$(stat -c %Y "$file" 2>/dev/null)
    if [[ -z "$FILE_MTIME" ]]; then
        continue
    fi

    # 计算时间差(秒)
    DIFF=$(( CURRENT - FILE_MTIME ))
    # 转换为天数
    DAYS_OLD=$(( DIFF / 86400 ))

    if [[ $DAYS_OLD -gt $RETENTION_DAYS ]]; then
        echo "删除过期文件:$file(已保留 $DAYS_OLD 天)"
        rm "$file"
    fi
done

这里 stat -c %Y 输出的是 mtime 的 Unix 时间戳(秒)。date +%s 是当前时间戳。两者相减,再除以 86400(一天的秒数),就得到天数差。

我曾经踩过的坑:直接用 stat -c %Y 时,如果文件被删除或路径不对,stat 会报错。所以一定要加 2>/dev/null 和空值判断,否则脚本会中断。

知识体系:文件操作与时间处理核心逻辑

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个决策流程:

文件操作与时间处理核心逻辑 文件操作与时间处理核心逻辑 开始处理文件 文件检测:-f / -d 获取时间戳:mtime 时间比较:mtime +7 是否过期? 是 执行删除/轮换 否 跳过,继续下一个 处理完成 核心流程:文件检测 → 获取 mtime → 时间比较 → 决策执行

实战组合:一个完整的日志轮换脚本

把上面所有知识点串起来,就是一个可用的日志轮换脚本。我直接贴一个我常用的模板:

#!/bin/bash
# 功能:按天轮换日志,保留最近 7 天
# 用法:./log_rotate.sh /var/log/app

LOG_DIR="${1:-/var/log/app}"
RETENTION_DAYS=7
DATE_TAG=$(date "+%Y%m%d_%H%M%S")
ARCHIVE_DIR="${LOG_DIR}/archive"

# 1. 检测目录
if [[ ! -d "$LOG_DIR" ]]; then
    echo "错误:日志目录 $LOG_DIR 不存在"
    exit 1
fi

# 2. 创建归档目录
mkdir -p "$ARCHIVE_DIR"

# 3. 轮换当前日志文件
for log_file in "$LOG_DIR"/*.log; do
    [[ -f "$log_file" ]] || continue

    # 跳过已归档的文件(文件名包含日期标记)
    basename=$(basename "$log_file" .log)
    if [[ "$basename" =~ [0-9]{8}_[0-9]{6}$ ]]; then
        continue
    fi

    # 压缩并移动到归档目录
    gzip -c "$log_file" > "${ARCHIVE_DIR}/${basename}_${DATE_TAG}.log.gz"
    if [[ $? -eq 0 ]]; then
        > "$log_file"  # 清空原文件,不删除
        echo "已轮换:$log_file"
    else
        echo "错误:压缩失败 $log_file"
    fi
done

# 4. 删除过期归档文件
find "$ARCHIVE_DIR" -name "*.log.gz" -type f -mtime +$RETENTION_DAYS -exec rm {} \;

echo "轮换完成,保留 ${RETENTION_DAYS} 天内的归档文件"

这个脚本里,文件检测、mtime 判断、日期格式化全用上了。你可以直接拿过去改改路径和保留天数就能用。

小技巧:生产环境里,我习惯在脚本开头加一个 set -e,这样任何一步出错都会立即退出,避免连锁故障。但如果你希望某些步骤失败后继续执行,就别加。

常见错误与避坑指南

  • 忘记引号:文件路径包含空格时,不加引号会炸。所有变量都要用双引号包起来。
  • mtime 精度问题:-mtime +7 是按整天算的,如果你需要精确到小时,用 -mmin 或手动计算时间戳。
  • stat 命令跨平台:Linux 用 stat -c %Y,macOS 用 stat -f %m。写通用脚本时要注意。
  • date -d 兼容性:Linux 支持 date -d "7 days ago",macOS 不支持。跨平台时用 date -v-7d。
  • 文件被占用:轮换日志时,如果进程还在写文件,直接 rm 或 mv 可能导致数据丢失。我习惯先 cp /dev/null 清空,或者用 logrotate 的 copytruncate 模式。

嗯,这些坑我基本都踩过一遍。你记住一条原则:先检测,再操作,最后验证。每一步都做检查,脚本就不会轻易翻车。