日期时间与时区处理实战

系统梳理 date 命令的格式化与解析、GNU 与 BSD 的参数差异、UTC 与本地时区换算、epoch 时间戳的比较,以及跨时区定时调度的常见陷阱与解法。

1. 时间的三重表示

一句话总结: 计算机里只有一种真实时间——UTC 时间戳,本地时间和格式化字符串都只是它的投影,混淆投影与本体是一切时间 bug 的根源。

一个时间点可以有三副面孔:epoch 秒数(绝对、无歧义)、UTC 时刻(绝对、可读)、本地时刻(带时区偏移、可读但相对)。脚本里凡是需要比较、排序、做差的场合,都应该先归一到 epoch;凡是需要展示给人类看的场合,才转成本地字符串。

# 当前 epoch 秒
date +%s

# 当前 UTC 时刻(带 Z 后缀)
date -u +%Y-%m-%dT%H:%M:%SZ

# 当前本地时刻(带偏移)
date +%Y-%m-%dT%H:%M:%S%:z

1.1 格式化字段速查

一句话总结: %Y-%m-%d %H:%M:%S 是可排序、可比较的黄金格式,%s 给机器,%F 与 %T 是它的简写。

date +%F                    # 2026-10-02
date +%T                    # 11:00:00
date +%Y-%m-%dT%H:%M:%S%z   # 2026-10-02T11:00:00+0800
date +%s                    # epoch 秒
date +%s%3N                 # 毫秒(GNU 的 %N 是纳秒)
date +%j                    # 一年中的第几天
date +%u                    # 星期几(1=周一)

1.2 格式化在日志中的价值

一句话总结: 日志时间戳一律用 ISO 8601 带偏移的格式,可排序、可解析、跨时区无歧义。

# 日志行前缀,固定宽度便于 sort 与 awk 切分
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
log "服务启动"

# 按时间排序日志(ISO 8601 字典序等于时间序)
sort -k1,1 access.log

2. GNU 与 BSD 的差异

一句话总结: 同一个 date,macOS(BSD)与 Linux(GNU)的解析参数完全不同,跨平台脚本必须先探测实现再选参数。

最典型的冲突是「把字符串解析成时间」:GNU 用 -d,BSD 用 -j -f。写一份能在 CI(Linux)和开发机(macOS)同时跑的脚本,必须做分支。

# GNU:-d 接受丰富的自然语言与格式
date -d '2026-10-02 11:00:00' +%s
date -d 'next monday' +%F
date -d '-7 days' +%F

# BSD:-j 表示不设置系统时间,-f 指定输入格式
date -j -f '%Y-%m-%d %H:%M:%S' '2026-10-02 11:00:00' +%s
date -j -v-7d +%F

2.1 探测实现的兼容层

一句话总结: 用 date -u -d @0 是否成功来判定 GNU,再封装一个 to_epoch 函数屏蔽差异。

# 探测 GNU date
if date -u -d @0 +%s >/dev/null 2>&1; then
  DATE_GNU=1
else
  DATE_GNU=0
fi

# 统一的字符串转 epoch 接口
to_epoch() {
  if (( DATE_GNU )); then
    date -d "$1" +%s
  else
    date -j -f '%Y-%m-%d %H:%M:%S' "$1" +%s
  fi
}
to_epoch '2026-10-02 11:00:00'

2.2 相对时间的跨平台写法

一句话总结: 相对时间不要依赖 -d 'next monday',直接用 epoch 做算术最稳。

# 三天后的日期,纯算术,两个平台通用
echo $(( $(date +%s) + 3 * 86400 )) | xargs -I{} date -r {} +%F 2>/dev/null \
  || date -d "@$(( $(date +%s) + 3 * 86400 ))" +%F

# 更简洁:只算 epoch,格式化时再转
target=$(( $(date +%s) + 3 * 86400 ))
if (( DATE_GNU )); then date -d "@$target" +%F; else date -r "$target" +%F; fi

3. 时区换算

一句话总结: 时区转换靠 TZ 环境变量对单条命令生效,永远不要在脚本里改全局时区,也不要手算偏移。

TZ 可以前缀在命令前,只影响这一次调用。这是做时区换算最干净的方式:源时刻用带偏移的字符串给出,目标时区用 TZ 指定。

# 北京时间对应的纽约时间
TZ=Asia/Shanghai date -d '2026-10-02 11:00:00' +'%F %T %Z'
TZ=America/New_York date -d '2026-10-02 11:00:00 +0800' +'%F %T %Z'

# 一条命令里做换算:先解析成 epoch,再用目标时区格式化
epoch=$(TZ=Asia/Shanghai date -d '2026-10-02 11:00:00' +%s)
TZ=Europe/London date -d "@$epoch" +'%F %T %Z'

3.1 列出可用时区

一句话总结: 用 timedatectl list-timezones 或读取 zoneinfo 目录确认时区名,拼错会被静默当成 UTC。

# 校验时区名是否合法
validate_tz() {
  [[ -f "/usr/share/zoneinfo/$1" ]] || { echo "未知时区: $1" >&2; return 1; }
}
validate_tz Asia/Shanghai && echo OK

# 查看某时区当前偏移与夏令时状态
TZ=America/New_York date +'%Z %z'

3.2 夏令时的隐形陷阱

一句话总结: 夏令时切换日存在「不存在的时间」与「重复的时间」,任何基于本地时间加减一天的做法都会出错。

# 危险:本地时间加 86400 秒不等于「明天同一时刻」
TZ=America/New_York date -d '2026-03-08 01:30:00 -0500' +%s

# 安全:先转 epoch,加一天,再按需格式化
epoch=$(date -u -d '2026-03-08T06:30:00Z' +%s)
date -u -d "@$(( epoch + 86400 ))" +%Y-%m-%dT%H:%M:%SZ

4. 时间戳的比较与做差

一句话总结: 比较时间用 epoch 整数比较,做差用 $(( a - b )),绝不要比较格式化字符串(除 ISO 8601 外)。

# 计算两个时刻相差多少秒
start=$(date +%s)
do_work() { sleep 2; }
do_work
end=$(date +%s)
printf '耗时 %d 秒\n' "$(( end - start ))"

# 判断文件是否比阈值新
if [[ "$(date -r file.txt +%s 2>/dev/null || date -r file.txt +%s)" -gt "$(( $(date +%s) - 3600 ))" ]]; then
  echo '一小时内修改过'
fi

4.1 人类可读的时长格式化

一句话总结: 把秒数拆成天时分秒,用整数除法与取余,避免引入 bc 或 awk 的浮点。

# 秒数转 1d2h3m4s
human_duration() {
  local s=$1 d h m
  d=$(( s / 86400 )); s=$(( s % 86400 ))
  h=$(( s / 3600 ));  s=$(( s % 3600 ))
  m=$(( s / 60 ));    s=$(( s % 60 ))
  printf '%dd%dh%dm%ds\n' "$d" "$h" "$m" "$s"
}
human_duration 93784

4.2 日期边界计算

一句话总结: 「本月第一天」「上个月最后一天」用 epoch 归零法算,比字符串拼接可靠。

# 本月第一天
first_of_month=$(date +%Y-%m-01)

# 上个月最后一天:本月第一天减一天
prev_last=$(( $(date -d "$first_of_month" +%s) - 86400 ))
date -d "@$prev_last" +%F

# 今天零点(本地)
today_zero=$(date -d 'today 00:00:00' +%s)

5. 定时调度中的时间

一句话总结: cron 的字段语义、时区来源与脚本内部的 date 调用三者必须一致,否则任务会在错误的时刻运行。

cron 的时区取决于守护进程的环境,容器里常常是 UTC。脚本里如果再假设本地时间,就会出现「任务在凌晨三点跑」的事故。

# 查看 cron 生效的时区(多数系统看 /etc/timezone 或 cron 进程环境)
cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value

# crontab 顶部显式声明时区(支持的系统)
CRON_TZ=Asia/Shanghai
# 每天 09:30 执行
30 9 * * * /usr/local/bin/report.sh

5.1 在脚本内部固定时区

一句话总结: 调度脚本开头 export TZ,让脚本内所有 date 调用使用同一个时区,消除环境差异。

#!/usr/bin/env bash
set -euo pipefail
export TZ="${APP_TZ:-Asia/Shanghai}"

# 现在脚本内的 date 全部按 APP_TZ 解释
printf '运行时刻: %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)"

5.2 避免重复执行与跳过的窗口

一句话总结: 用「上次执行时间戳文件」加时间窗口判断,比单纯依赖 cron 更能应对补跑与重叠。

STAMP_FILE=/var/lib/report.last
now=$(date +%s)
last=$(cat "$STAMP_FILE" 2>/dev/null || echo 0)

# 距上次不足 20 小时就跳过,防止重复补跑
if (( now - last < 72000 )); then
  echo '距离上次运行过近,跳过'; exit 0
fi
# ... 执行任务 ...
printf '%s' "$now" > "$STAMP_FILE"

6. 解析与校验外部时间

一句话总结: 外部时间字符串进脚本前必须校验格式与合法性,宽松解析会把拼写错误变成静默的零点。

# 严格校验 YYYY-MM-DD
is_iso_date() {
  [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1
  local back
  back=$(date -d "$1" +%F 2>/dev/null) || return 1
  [[ "$back" == "$1" ]]   # 拒绝 2026-02-31 这类被规整的日期
}
is_iso_date 2026-02-31 || echo '非法日期'

6.1 解析多种输入格式

一句话总结: 对常见格式逐一尝试,全部失败才报错,并记录原始输入便于排查。

parse_any_date() {
  local s="$1" f
  for f in '%Y-%m-%dT%H:%M:%S%z' '%Y-%m-%d %H:%M:%S' '%Y-%m-%d' '%Y/%m/%d'; do
    if out=$(date -d "$s" +%s 2>/dev/null); then printf '%s' "$out"; return 0; fi
  done
  echo "无法解析时间: $s" >&2; return 1
}

6.2 JSON 里的时间戳

一句话总结: 结构化日志里的时间字段用 jq 取出后交给 date 解析,注意 ISO 8601 与 epoch 两种形态。

# 从 JSON 里取 ISO 时间转 epoch
jq -r '.timestamp' event.json | xargs -I{} date -d {} +%s

# epoch 毫秒转可读(先除 1000)
jq -r '.ts_ms' event.json | awk '{printf "%d\n", $1/1000}' | xargs -I{} date -d "@{}" +%F

7. 实战:跨时区日志对齐脚本

一句话总结: 把多台机器的日志统一到 UTC 再按本地时区展示,是排查分布式问题的标准前置步骤。

下面脚本读取一份「时间 + 时区 + 消息」的三列日志,把所有时间归一到 UTC epoch,排序后再按指定时区输出,用于对齐多机房事件顺序。

#!/usr/bin/env bash
set -euo pipefail

SRC="${1:?用法: align.sh <日志> [展示时区]}"
OUT_TZ="${2:-Asia/Shanghai}"

# 逐行解析:时间字段、源时区、消息
while IFS='|' read -r ts tz msg; do
  [[ -z "${ts:-}" ]] && continue
  epoch=$(TZ="$tz" date -d "$ts" +%s 2>/dev/null) || {
    printf '解析失败: %s\n' "$ts" >&2; continue; }
  printf '%s\t%s\n' "$epoch" "$msg"
done < "$SRC" | sort -n -k1,1 > /tmp/aligned.tsv

7.1 按目标时区渲染

一句话总结: 归一后的 epoch 是时区无关的,渲染阶段才套用展示时区,这样同一份数据可以输出多种视图。

# 渲染为展示时区
while IFS=$'\t' read -r epoch msg; do
  printf '%s  %s\n' "$(TZ="$OUT_TZ" date -d "@$epoch" +'%F %T %Z')" "$msg"
done < /tmp/aligned.tsv

7.2 检测时钟漂移

一句话总结: 多机日志对齐后,若同一事件在不同机器的 epoch 相差超过阈值,就说明某台机器时钟不准。

# 取同一事件 ID 在各机器上的时间,求极差
awk -F'\t' '{print $1}' /tmp/aligned.tsv \
  | sort -n | awk 'NR==1{min=$1} {max=$1} END{print "跨度秒:", max-min}'

8. 总结

环节要点
表示只有 UTC epoch 是绝对时间,本地时间是投影
格式化日志用 ISO 8601 带偏移,字典序即时间序
兼容GNU 用 -d,BSD 用 -j -f,先探测再分支
时区TZ 前缀只影响单条命令,勿改全局时区
夏令时本地时间加减一天不可靠,一律走 epoch
比较整数比较 epoch,绝比格式化字符串
调度cron 时区与脚本内 TZ 必须显式统一
校验外部时间先严格校验,拒绝被规整的非法日期

日期时间处理的全部难度,都来自「绝对时间只有一种、可读表示有很多种」这一事实。把 epoch 当作内部唯一货币,只在输入解析和输出渲染两个边界上做转换,时区与夏令时带来的麻烦就会大幅收敛。时间理清之后,多进程同时操作同一批文件又会带来新的麻烦,这就是文件锁要解决的问题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战