Shell脚本日期范围遍历:核心原理与避坑指南
简介面向Linux运维与自动化脚本开发者的Shell日期遍历实例PDF文档。内容以一份可直接运行的脚本为主演示如何接收起始日期与结束日期两个参数通过date -d将日期转为Unix时间戳再利用循环递减生成序列并支持将每个日期传给Python脚本做后续处理。文档还解释了%F、%s等格式化选项及循环边界设置便于读者根据需求调整日期格式与遍历次数。资源为单个PDF文件包体约27KB内容精炼目前已有2025人学习。对于需要处理日志分析、定时任务或按日期批量操作数据的场景这份实例能帮助快速掌握Shell日期遍历的核心写法与排错思路。1. 日期范围遍历是 Shell 脚本里最常写翻车的循环按日期遍历是 Shell 脚本里最常见也最容易写翻车的需求数据补跑要每天传一个日期日志清理要保留最近 N 天报表任务要按天调接口。每个场景说白了都是把起止日期区间拆成一天一个参数再交给后边的业务命令。真动手的时候很多人第一反应是for i in $(seq 1 30)配一个固定偏移结果一跨月就出现 32 号一跨年就断档跑出来的数据要么缺分区要么重跑一遍。这里的问题不在循环本身而在“日期增量”这件事没交给专业工具处理。本文用一个可直接复现的日期遍历实例把生成、边界、跨年、并发这些点一次说清。刚入门的新手能照着抄写过几年脚本的熟手也能拿来对一遍边界条件和坑。2. 日期遍历的底层逻辑字符串、时间戳和循环结构怎么选2.1 先弄清日期的三种表达字符串、时间戳、UTC日历Shell 里一个日期有三种长相。第一种是日历字符串比如2024-11-30人最容易看懂第二种是 epoch 时间戳比如1732953600计算机算起来最快第三种是带时区的 UTC 表达文件系统、容器日志里经常出现。日期区间遍历的本质就是在这三种表达之间做增量每次加“一天”直到越过终点。GNU date 的-d参数可以直接做字符串加法date -d 2024-11-30 1 day %Y-%m-%d会得到2024-12-01。它内部会先解析字符串、加一天、再格式化回字符串月末、闰年、跨年都由系统库处理。这是我在生产环境里最依赖的一个能力因为它把“下个月是几月”这种脏活全包了。另一种常见写法是时间戳加法先取date -d 2024-11-30 %s得到秒数加 86400 秒再date -d $ts转回来。表面上看都是“加一天”但在实行夏令时的地区当地真实的一天可能是 23 小时或 25 小时时间戳加 86400 秒之后显示的日历日期可能原地不动也可能直接跳两天。这个坑不常触发一触发就是数据缺失。第三种是在 UTC 下计算date -u -d ...。它会绕开本地时区但输出的是 UTC 日期和业务上按本地日分区对不上。我一般只在两种情况下用 UTC生成跨时区通用的文件名或者对象存储路径统一按 UTC 分区。对大多数内部脚本没必要引入这层复杂度。所以我的默认选择是循环变量保持在YYYY-MM-DD字符串层面每次用date -d $current 1 day做增量输出也强制%Y-%m-%d。这是最贴合人直觉的方案。等确认业务真的需要时间戳精度再换也不迟。还要注意 GNU date 和 BSD date 的差异。macOS 自带的 date 不支持-d语法是date -j -v1d -f %Y-%m-%d 2024-11-30 %Y-%m-%d。同样的脚本在 Mac 上能跑推到 Linux 服务器上就报invalid date。如果团队里两种环境都有要么统一用 GNU coreutils要么直接用第 5 章的纯 shell 算法。2.2 while 与 for 的取舍不是所有循环都适合算日期Shell 脚本入门时接触最多的是for i in {1..30}和for ((i1; i30; i))很多人自然就想先算出两个日期差多少天再按数字循环。这个思路绕了一大圈你得先解决“两个日期隔了多少天”这本身就要处理闰年和大小月GNU date 能一行搞定但很容易被写成又臭又长的判断。直接用 while 推日期字符串更干脆。核心逻辑是让当前日期在循环体里不断前进直到超过结束日期。bash 的[[ ]]里单写是字典序比较对YYYY-MM-DD这种格式字典序和时间序完全一致所以可以直接拿字符串比大小。但前提是格式必须固定补零2024-2-1在字典序上会排在2024-11-1前面一旦格式不统一条件判断就全错了。for 循环适合的是“次数已知、步长固定”的场景。比如确定要跑未来 7 天可以先把日期生成到一个数组里再 for 遍历或者已经有dates.txt文件for d in $(cat dates.txt)是干净写法。但只有起止日期、中间可能会跨月跨年时while 推进字符串是更稳的默认方案。有一点大多数教程不会专门提循环条件到底用还是。这直接决定结束日期那天跑不跑。业务说“从 11 月 1 日到 11 月 5 日”默认是包含 5 号的但如果写成[[ $current $end ]]5 号当天条件为假循环在 4 号就停了。2.3 变量输入与校验起止日期不能裸奔凡是会进 cron 的脚本起止日期都应该做成参数而不是写死。linux 的 shell 脚本变量与输入这两件事落到日期遍历上就是参数到达后先校验格式再确认 start 不大于 end。格式校验最简单的办法是让 date 自己解析一遍然后把规范化结果和原值比对if [[ $(date -d $start %Y-%m-%d) ! $start ]]; then echo 起始日期格式不合法: $start 2 exit 1 fi这段代码的意思是如果用户传进来2024-11-1或者2024/11/01date 虽然能解析但格式化后和原值不等说明不是我们约定的YYYY-MM-DD。我选择直接拒绝而不是默默转换因为下游可能用它拼文件名、拼分区字段格式一乱后续排查成本比当场报错高得多。还有一个很少被教但值得养成的习惯给 while 循环加保险丝。一旦某次增量因为环境问题没有前进比如date命令在某个容器里行为异常current会一直小于end脚本就在后台死循环。加一个计数器超过 3660 次强制退出只多三行代码但能避免大半夜爬起来杀进程的糟心事。3. 从脚本到业务一个可直接抄的日期范围遍历实现3.1 最小可运行脚本打印区间内的每一个日期先把骨架写出来这段脚本可以直接保存成loop_dates.sh运行#!/usr/bin/env bash set -euo pipefail start${1:?需要起始日期格式 YYYY-MM-DD} end${2:?需要结束日期格式 YYYY-MM-DD} # 规范化并顺带校验date 解析失败会触发 set -e start$(date -d $start %Y-%m-%d) end$(date -d $end %Y-%m-%d) current$start max_loop3660 count0 while [[ $current $end || $current $end ]]; do echo $current current$(date -d $current 1 day %Y-%m-%d) count$((count 1)) if (( count max_loop )); then echo 循环次数超过 $max_loop请检查起止日期 2 exit 1 fi done参数说明第一个参数是开始日期第二个是结束日期都要求YYYY-MM-DD格式。${1:?提示语}的意思是参数缺失时直接输出提示并退出提示语会打到标准错误输出。逻辑说明start$(date -d $start %Y-%m-%d)做了两件事一是校验二是把可能的合法缩写统一成标准格式。循环条件用了||把“小于”和“等于”合并等价于小于等于确保结束日期当天会被处理。计数器是最后一道保险默认 3660 次大约十年按业务范围自己调。运行./loop_dates.sh 2024-11-01 2024-11-05会输出五行从 2024-11-01 到 2024-11-05。这就是整个日期遍历引擎最核心的部分后边所有业务逻辑都是在这个循环里加自己的命令。3.2 把日期带进业务查数、补跑和日志处理的三种姿势打印日期本身没有价值价值在每一天附加的操作。最常见的三个场景数据补跑传参、按天查数、按日期清理历史文件。while [[ $current $end || $current $end ]]; do echo 处理 $current # 场景一数据补跑把日期作为参数传给下游 python train.py --date $current # 场景二查数kettle 批量遍历日期查数也是这个思路 # 注意用 defaults-extra-file避免密码出现在命令行 mysql --defaults-extra-file/etc/mysql_backup.cnf \ -e SELECT count(*) FROM dw.daily_log WHERE dt $current # 场景三清理日志先删到回收站而不是直接 rm echo mv /data/app/logs/app-${current}.log /tmp/trash/ current$(date -d $current 1 day %Y-%m-%d) done逻辑说明每个场景都是一条独立的业务命令。场景二里--defaults-extra-file从配置文件读账号密码而不是写在命令里这能避免ps命令把密码泄露出去。场景三用了mv而不是直接rm等确认当天日志不再需要再统一清空回收站相当于给了自己后悔药。grep 在 shell 脚本中的常见用法也会出现在这个循环里比如下载某天日志后统计错误数。这会带出一个容易忽略的坑grep -c ERROR /path/to/app-${current}.log在文件不存在时会输出一条报错并返回非零状态如果脚本开了set -e整个遍历会停在那一天。正确姿势是先判断文件存不存在再执行 grep。参数说明--date $current这里的引号不能省。虽然日期变量全是数字和横线但保不齐哪次 date 命令异常输出了带空格的字符串一旦出现引号能保证它仍被当成一个整体参数传给下游。3.3 跨月和跨年不是玄学但格式必须统一很多人担心“10 月 30 日加一天变成 11 月 1 日”这种跨月问题其实在date -d $current 1 day面前根本不存在因为我们从没手工拆过月份也没维护过“哪些月有 31 天”的列表每一次增量都是系统日历算出来的。跨年同理12 月 31 日加一天就是第二年的 1 月 1 日。真正会出问题的是日期格式在上下游之间不统一。比如业务库某个字段存的是2024-1-1脚本里另一个接口预期的是2024-01-01两者在开发环境碰巧能对上生产环境一排序就错位。我的做法是在脚本入口用一层%Y-%m-%d规范化后续所有变量、文件名、SQL 里的日期都只认这一种格式。另外建议每年年底固定跑一次跨年测试用例./loop_dates.sh 2024-12-30 2025-01-02四行输出里不应该出现 2024-12-32 这种值。这一分钟的成本能挡住很多节假日前夕才暴露的问题。4. 日期遍历踩坑排查五个现象背后的真实原因4.1 开头就报 invalid date或者跑到中间突然退出现象脚本在 Linux 上跑得好好的换到 macOS 或某个精简容器里第一行date -d就报invalid date还有一种更隐蔽的前几天正常跑到某一天突然退出。原因最常见是 GNU date 与 BSD date 的语法差异。macOS 自带的 date 不支持-d增量写法。另一种可能是current变量在某次赋值时被写坏了比如从文件或数据库读出的值带了\r字符串表面上一样实际对不上。解决方法分两步先定位是环境问题还是变量问题。环境问题用date --version或command -v date看路径把 Mac 上的 Homebrew coreutils 路径或者容器的基础镜像换掉。变量问题用bash -x loop_dates.sh跑一次观察最后一次执行的date命令行再用echo $current | od -c检查有没有不可见字符。排查这类问题时黑匣子式猜是没有用的必须把变量内容原样打印出来看。4.2 结束日期少跑一天或反过来多跑现象指定 1 号到 5 号每天打印一行数据结果只看到 1 到 4 号。月报里的 31 号分区永远缺席运维同学查了三天才发现是循环条件的问题。原因条件写成while [[ $current $end ]]当前日期等于结束时为假循环体不再执行结束日期当天被排除在外。解决统一写成包含等于的条件也就是while [[ $current $end || $current $end ]]。也可以反过来用 untiluntil [[ $current $end ]]语义更接近“一直跑直到超过结束日期”。经验是每次改完循环先拿结束日期当开始日期跑一遍确认能输出这一天才算数。4.3 夏令时和时区为什么日期会跳过一天现象脚本每天凌晨处理日志某两天的数据出现空洞或者重复排查时间戳时发现当天只有 23 小时或者第二天多了 1 小时。原因用了时间戳加 86400 的方式推进而不是字符串增量。夏令时切换当天当地从凌晨 2 点跳到 3 点实际只有 23 个小时时间戳加 86400 秒后落点还在同一个日历日的凌晨 1 点于是再跑一遍同一天。解决不要在设计时区的本地时间上做时间戳加法。要么用date -d $current 1 day这种基于日历的增量让系统决定一天的长度要么全程用 UTC 时间戳计算最后展示的时候再转本地。只要你的业务按日历日统计就永远不要碰 86400 这个魔法数字。4.4 循环越跑越慢每次 date 都是一次 fork现象遍历 10 年日期、共 3650 天的脚本跑了很久还没完。遍历一年的时候体感还能接受时间范围一大速度肉眼可见地变慢。原因每一次$(date ...)都会 fork 出一个子进程date要解析参数、查时区、走系统调用。3650 次 fork 加解析在普通虚拟机上可能就要几十秒如果循环里还要执行 mysql、grep 等外部命令时间长到可以去做别的事了。解决先分清慢在“日期生成”还是“业务命令”。只生成日期时可以改用第 5 章的纯 shell 算法完全不 fork 外部进程业务命令本身慢时考虑用并发。还有一个优化点循环体里尽量只调用一次 date把增量和格式化合并成一条命令不要既算增量又单独取时间戳。4.5 放到后台就卡住交互式输入密码和后台任务冲突现象nohup ./daily.sh 之后脚本没有输出ps看进程还在但就是不往前走仔细一看卡在 ssh 或 mysql 的密码提示上。原因后台任务没有控制终端读取密码的程序拿不到 tty会一直等待输入。哪怕脚本里某个命令只在个别日期触发也会卡住整个遍历。解决对 ssh 使用密钥认证并加上-o BatchModeyes确保没有密钥时直接报错而不是挂起对 mysql 使用--defaults-extra-file/path/to/secure.cnf保存账号密码对需要 sudo 的场景可以在启动后台任务前先用sudo -v缓存凭据。总原则是让脚本在无人值守时也能跑完任何需要人工输入的点都必须提前消除。5. 再往前一步步长控制、并发加速与纯 Shell 实现5.1 用 step 参数控制遍历节奏按周、按 N 天、按小时有些需求不需要逐天遍历每周跑一次的归档任务每个月结账后的对账任务或者每 3 小时拉取一次接口数据。把步长提取成参数是最干净的做法start${1:?} end${2:?} step${3:-1} # 默认 1 天可传 7 表示每周 current$start while [[ $current $end || $current $end ]]; do echo $current current$(date -d $current $step day %Y-%m-%d) done逻辑说明date -d $current $step day里的$step会被 date 当作天数解析传 7 就是每周推进。需要注意step和start是否对齐。比如本周二是 11 月 5 日步长 7 会依次命中 11 月 12 日、19 日正好都是周二如果业务想要“每周一”就要先算好周一作为起点。还有两个容易踩的边界step传 0 会死循环脚本里应加一行if (( step 1 )); then exit 1; fi按小时遍历时起止时间建议统一成%Y-%m-%d %H:%M:%S这样[[ ]]的字符串比较依然可靠。5.2 并发遍历用 xargs -P 把单线程变成并行队列业务命令本身耗时时串行是天敌。比如每天要调用一个 10 秒的接口365 天串行要跑一小时。常见做法是先把日期全部生成出来再交给 xargs 并发消费# 先输出日期序列再并发处理 ./loop_dates.sh $start $end /tmp/dates.txt cat /tmp/dates.txt | xargs -P 4 -I {} process_one_day {}参数说明-P 4表示最多同时跑 4 个进程-I {}表示把输入的每一行替换到命令中的{}位置。process_one_day是准备好的函数或外部脚本需要提前export -f process_one_day因为 xargs 会启动新的 shell 进程。并发有两个必须正视的问题第一输出顺序会乱。如果下游依赖“按日期顺序生成的文件清单”并发会把清单打散。解决方案是每个子任务把结果写到独立文件主进程等所有任务结束后再合并。第二每个子任务必须是幂等的因为并发失败重跑时不会只重跑失败的那一天很可能是整段区间。数据表要事先建好唯一键日志文件要用“追加”而不是“覆盖”。并发数不是越大越好。数据库任务受连接池限制文件任务受磁盘 IO 限制。我从 4 开始试探观察 CPU 和数据库连接数后再加一般不会超过机器核数的两倍。5.3 纯 Shell 日期递增没有 GNU date 也能跑GNU date 不是所有环境都提供。某些 Alpine 基础镜像只有 busybox date不支持-d字符串加法。这种情况再想遍历日期区间只能用 shell 算术自己推日历days_in_month() { case $1 in 1|3|5|7|8|10|12) echo 31 ;; 4|6|9|11) echo 30 ;; 2) if (( $2 % 4 0 ($2 % 100 ! 0 || $2 % 400 0) )); then echo 29 else echo 28 fi ;; esac } next_day() { local y$1 m$2 d$3 local last last$(days_in_month $m $y) if (( d last )); then d$((d 1)) else d1 if (( m 12 )); then m1 y$((y 1)) else m$((m 1)) fi fi printf %04d-%02d-%02d\n $y $m $d }逻辑说明days_in_month输出某年某月的天数2 月按闰年判断next_day先判断当天是否月末到月末就进位月份12 月则进位年份。调用方式是在 while 里循环cur$(next_day $y $m $d)再和结束日期做字符串比较。这个方案的优点是行为始终一致不受系统 date 版本影响缺点是逻辑都在自己手里边界要自己测。如果只是做日期序列生成不需要接时间戳它会比频繁 fork date 更快。我一般会把两种实现封装成同一个函数名在脚本开头用一个变量开关切换默认 GNU date容器环境自动落到纯 shell 版本。这样维护成本低生产行为也可预期。6. 最后的三分钟验证法三个样例守住所有边界6.1 必跑的三个样例跨月、跨年、闰年日期遍历脚本改完我不会直接丢进 cron。手动跑三个样例每次大约一分钟样例命令预期普通区间./loop.sh 2024-11-28 2024-12-025 行包含 11-28 和 12-02跨年./loop.sh 2024-12-30 2025-01-024 行无 2024-12-32闰年./loop.sh 2024-02-27 2024-03-014 行包含 02-29这三个用例覆盖了最常见的三类边界。普通区间验证基本逻辑跨年验证月份进位闰年验证 2 月 29 日。如果三个样例的日期序列都正确再进 cron 就不太会翻车。6.2 加一个 --dry-run 开关我习惯在日期遍历脚本里预留--dry-run。业务命令替换成echo 将执行: xxx循环逻辑完全不变但不会真的删文件、改数据库。上线前先干跑一遍把输出存到文件里数一下行数确认每一天都出现再放行正式执行。还有一个缓解重跑成本的技巧让每一天的任务先检查产物是否存在存在就直接跳过。比如日志处理脚本检查app-${date}.log.gz是否已经生成数据补跑脚本检查目标表的日期分区是否存在。这样即使遍历区间写大了重跑时也只会补缺失的部分不会把已有结果再算一遍。我自己吃过一次亏赶月末任务结束日期少写一天导致最后一天的数据没跑。那次之后立了规矩凡是日期区间脚本改完先干跑三个样例再上生产。这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取