文件操作与时间处理:文件检测、时间戳与日期格式化
文件操作和时间处理,是自动化脚本里最基础也最容易翻车的环节。我见过太多脚本因为没检查文件是否存在,直接跑崩了。也见过因为时间戳理解偏差,导致备份脚本永远删不对文件。
说白了,你要让脚本像人一样去判断:文件在不在?什么时候改的?现在几点了?该不该处理它?
这一章,我把这些核心操作拆开揉碎了讲。都是我在生产环境里踩过坑之后总结出来的经验。
文件检测:-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 和空值判断,否则脚本会中断。
知识体系:文件操作与时间处理核心逻辑
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个决策流程:
实战组合:一个完整的日志轮换脚本
把上面所有知识点串起来,就是一个可用的日志轮换脚本。我直接贴一个我常用的模板:
#!/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 模式。
嗯,这些坑我基本都踩过一遍。你记住一条原则:先检测,再操作,最后验证。每一步都做检查,脚本就不会轻易翻车。