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 当作内部唯一货币,只在输入解析和输出渲染两个边界上做转换,时区与夏令时带来的麻烦就会大幅收敛。时间理清之后,多进程同时操作同一批文件又会带来新的麻烦,这就是文件锁要解决的问题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。