做实验、跑数据、盯着进度条转圈的朋友,应该都经历过这种场景:一个不算复杂的 Bash 脚本,每天要在定时任务里跑上十几轮,每轮都要重新请求接口、重新解析同一批日志文件、重新算一遍同样的聚合结果。数据量小的时候感觉不到,等实验规模上来,单轮消耗几十秒甚至几分钟,问题就藏不住了。我最早接触这类问题是维护一套设备老化测试的全自动执行脚本,每天几十台设备的状态采集、日志解析和报告生成全靠它撑着。那时脚本还是"裸奔"状态,没有任何缓存机制,于是我开始系统地给 Bash 脚本加入缓存逻辑,把整个执行时间从分钟级压到了秒级。这篇就详细聊聊我这个过程的完整思路,包括缓存目录怎么规划、键名怎么生成、TTL 怎么判断、并发怎么写锁、以及脚本本身还能怎么做微观优化,希望能给正在被重复计算折磨的人一点参考。
1. 为什么实验脚本需要缓存:从一次半夜跑批说起
1.1 慢脚本的画像:时间都花在哪了
先说个我印象很深的场景。当时设备老化测试的脚本逻辑并不复杂,大致分三段:
- 通过 curl 请求每台设备的状态接口,拿到电压、温度、运行时长这些数据;
- 解析当天的日志文件,提取错误码、重启次数、关键事件;
- 把以上结果汇总成 CSV/JSON,供后续生成报告使用。
问题在于,设备多了以后,单轮跑完要将近一分钟。而调度策略是每 15 分钟跑一次,一天下来就是 96 次,其中绝大多数是重复劳动。接口返回的数据按 5 分钟一个周期在更新,日志文件可能半天才追加几行,但我每次都把几百 MB 日志从头到尾解析一遍。后来我给脚本加了耗时打点,结果显示真实新增的计算量很小,绝大部分时间都耗在三件事上:等待网络响应、重复解析大文件、把相同的结果算了一遍又一遍。这就是典型的"增量变化,全量计算"。实验类的 Bash 脚本普遍存在这个毛病,因为脚本执行完就退出了,进程内状态留不住,所以每次都是从头再来。
1.2 什么数据适合进缓存,什么数据不值得
给 Bash 脚本加缓存之前,先得判断哪些数据值得缓存。我从实际使用里总结了三类好缓存的场景。
第一类是外部请求结果。比如 curl 请求到的 JSON、状态码、接口返回的指标,这类数据有两个特点:获取成本高(要走网络,可能还会超时、被限流),且短期内容基本稳定。只要接口刷新周期远大于脚本运行频率,缓存就是净赚。
第二类是解析清洗后的结果。日志原文、CSV 里提取出的关键行、去重后的设备列表,这些计算本身不慢,但当原始文件很大时,反复全量解析会很痛。把解析结果按"输入文件指纹"缓存下来,文件没变就直接读上一次的成品。
第三类是计算代价高的中间结果。比如对大文件做 md5sum、对多个数据源做关联聚合,这类操作耗时明显,又经常在多个脚本里复用,适合单独缓存。
相反,有些数据放进缓存反而是给自己挖坑:临时生成的密钥、Token 这类敏感信息,写进磁盘会带来安全问题;实时性要求秒级一致的数据,比如故障告警状态,缓存会让处理逻辑失去时效性;还有本身一条命令瞬间算完的结果,比如简单的变量运算,缓存的开销可能比重新计算还大。判断标准就一句话:获取/计算的成本大于缓存读写的成本,且数据在一定时间窗口内允许复用,才值得缓存。
1.3 为什么不选内存变量或 redis,而是文件
这可能是很多人刚接触缓存时最纠结的点。做 Web 开发用 redis 顺手了,什么都要往 redis 里塞。但 Bash 脚本的场景不一样:脚本每次执行是一个独立进程,跑完就结束,你把数据存在一个内存变量里,下一次执行时进程早就没了,变量也随之蒸发。所以内存变量只能做单次执行内的短期复用,跨执行没有意义。
外部缓存服务呢?除非你的脚本集群真的需要多机共享一份缓存,否则为一个日常实验脚本去部署 redis 或者 memcached,纯属高射炮打蚊子。引入额外依赖意味着脚本无法在干净环境里直接跑,出错面也大。Bash 场景下最适合的缓存载体就是文件系统:读写成本低、跨进程可见、重启机器后文件还在(前提是没用 tmpfs),天然能支撑"上一次执行的结果留给下一次用"这个诉求。你甚至可以把缓存目录放到 /dev/shm 这类 tmpfs 上,把文件读写搬到内存里,速度进一步提升,代价是机器重启后缓存清空。这个取舍后面会细说。
2. Bash缓存机制的骨架:目录、键名与TTL
2.1 缓存目录怎么规划才不互相污染
我见过不少脚本直接在当前目录下写 cache.txt,或者往 /tmp 里丢文件。头几次跑没事,等到多个脚本共用一台机器、或者同一个脚本用不同参数跑多份实例时,文件名一冲突,数据就互相覆盖了。而且 /tmp 是个公共目录,任何用户都能读,把实验结果或者半敏感的数据放进去不太合适。
我现在统一的规范是,优先用 XDG 约定的缓存路径:
CACHE_DIR="${CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/exp-run}" mkdir -p "$CACHE_DIR" chmod 700 "$CACHE_DIR"这样每个脚本都有自己的独立目录,命名空间天然隔离。权限设成 700,只有当前用户能读写,既防止别人看到缓存内容,也避免其他用户在同目录下塞垃圾文件。如果你有多个实验项目,可以在 exp-run 下面再按项目名建子目录,比如$CACHE_DIR/device-check、$CACHE_DIR/report-gen。一旦目录规划好了,后面所有缓存文件都往这个目录里堆,清理时也只需要盯着这一块儿。
2.2 键名生成:从字符串拼接到哈希
缓存文件总得有个文件名,这个文件名就是"键名"。最朴素的做法是用参数直接拼接,比如curl_cache_$device_id.dat。但实际跑起来会发现三个坑:
第一个坑是参数里带特殊字符。设备 ID 可能含空格、斜杠、百分号,直接拼文件名会让路径层级乱掉,甚至造成路径穿越。第二个坑是参数太多时文件名直接失控,几十个参数拼成一个名字,读起来费劲,文件系统也可能不买账。第三个坑是键名里藏着参数的真实值,万一参数里带的是敏感信息,文件名直接暴露了。
所以正确做法是把"业务标识 + 参数内容 + 脚本逻辑版本"拼成一个字符串,做哈希,用十六进制摘要做文件名。我在脚本里封装了一个cache_key函数:
cache_key() { printf 'exp-run:v1:%s' "$*" | sha256sum | awk '{print $1}' }这里exp-run:v1:的前缀既标明了业务域,又标明了缓存数据的"版本号"。等脚本逻辑升级后,我把 v1 改成 v2,新的 key 和旧的 key 自然就不会命中了,旧缓存虽然还留在磁盘上,但已经变成了垃圾文件,可以被清理策略带走。这是防止"旧缓存污染新逻辑"最省事的做法。
2.3 TTL判断:mtime、ctime、atime该怎么选
缓存文件写好了,怎么判断它过没过期?这里涉及 Linux 文件系统里三个时间戳的差异,很多人一开始会搞混。
- mtime(修改时间):文件内容最后被修改的时间。对缓存来说,mtime 代表"缓存数据是什么时候生成的",是最适合用来判断 TTL 的指标。
- ctime(变更时间):文件 inode 最后被变更的时间。你 mv 覆盖一个文件、chmod 改权限、改属主,ctime 都会变,但它不代表内容生成时间,拿来做 TTL 会失真。
- atime(访问时间):文件最后被读取的时间。cat 一下文件 atime 就会变,读自己的缓存也会刷新 atime,所以 absolutely 不能用来判断过期。
TTL 判断的实现我一直用时间戳差值:
now=$(date +%s) mtime=$(get_mtime "$file") || return 1 (( now - mtime <= ttl )) || return 1date +%s输出的是从 1970-01-01 00:00:00 UTC 到当前的绝对秒数,和时区、跨月、跨年都没关系,是纯数值比较。这里唯一要处理的是跨平台差异:Linux 的 GNU stat 用的是stat -c %Y "$file",macOS 和 BSD 用的是stat -f %m "$file"。我在脚本里写了个get_mtime函数,先探测当前系统支持哪个参数,然后二选一。Git Bash 环境下用的也是 GNU 核心工具集,stat -c %Y基本可用,但手感上比原生 Linux 慢一些。
2.4 最小可用的缓存骨架
把上面的思路合并起来,就是我日常脚本里最基础的缓存骨架。一个完整的、能直接抄走的版本大概长这样:
#!/usr/bin/env bash set -uo pipefail CACHE_DIR="${CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/exp-run}" mkdir -p "$CACHE_DIR" chmod 700 "$CACHE_DIR" get_mtime() { if stat -c %Y "$1" >/dev/null 2>&1; then stat -c %Y "$1" else stat -f %m "$1" fi } cache_key() { printf 'exp-run:v1:%s' "$*" | sha256sum | awk '{print $1}' } cache_get() { local key=$1 ttl=$2 local file="$CACHE_DIR/$key" now mtime [[ -f "$file" ]] || return 1 now=$(date +%s) mtime=$(get_mtime "$file") || return 1 (( now - mtime <= ttl )) || { rm -f "$file"; return 1; } cat "$file" } cache_put() { local key=$1 local file="$CACHE_DIR/$key" local tmp tmp=$(mktemp "$CACHE_DIR/.tmp.XXXXXX") cat > "$tmp" chmod 600 "$tmp" mv "$tmp" "$file" } cached_exec() { local key=$1 ttl=$2 shift 2 local content if content=$(cache_get "$key" "$ttl"); then printf '%s\n' "$content" return 0 fi content=$("$@") || return $? cache_put "$key" <<< "$content" printf '%s\n' "$content" }这样调用时就非常清爽了:
data=$(cached_exec "sensor-status-${device_id}" 900 curl -s --max-time 10 "$API_URL")解释一下为什么这么设计。cached_exec接收三个参数:key、TTL、以及一条完整的命令。命中缓存就直接cat文件内容;没命中就执行后面的命令,把标准输出存成缓存文件,同时把内容返回给调用方。注意我用的content=$("$@")只捕获标准输出,标准错误会直接打在终端上,这有利于调试时第一时间看到 curl 或脚本本身的报错,而不是被吞进缓存里。
还有个小细节要注意:cache_put里我用了 here-string(<<<)写入,它会在内容末尾自动补一个换行。所以从缓存里读出来的内容和原始命令的输出在"尾部是否有换行"上可能有一点点差异。如果你的下游逻辑对换行敏感,读出来之后再用printf '%s' "$data"处理一下即可。我在做报告拼接时吃过这个亏,后来统一在关键位置显式处理,不再假设字符串的尾部状态。
3. 并发场景下怎么保证缓存可靠:锁、原子写与自愈
3.1 两个进程同时写缓存会发生什么
单进程脚本加缓存很简单,但实验场景经常是多个任务并行跑的。比如我有几台设备同时在执行老化测试,每台设备一个独立进程,同一时间可能有一堆进程都在尝试写同一个状态接口的缓存。如果大家只是"各自算了一遍然后写同一个文件",内容一致倒还好,但有两个问题绕不开:
第一,写入过程本身不保证原子性。你用重定向> "$file"写缓存文件时,另一个进程恰好在这个瞬间执行cat "$file",很可能读到半截内容,进而解析失败、生成脏数据。第二,回源计算这个过程不一定没有副作用。每次 curl 都是真实请求,如果同时有十个进程发现缓存过期,同时发起回源请求,接口的压力直接放大十倍,这就把缓存的"降低重复请求"意义完全抵消了。
3.2 原子写入:mktemp + mv
解决半截文件的办法很标准:先写临时文件,再用mv覆盖目标文件。同一文件系统内mv的 rename 操作是原子的,要么旧文件还在,要么新文件已经完整就位,不会出现中间态。这就是我在 2.4 节里cache_put用mktemp的原因:
tmp=$(mktemp "$CACHE_DIR/.tmp.XXXXXX") cat > "$tmp" chmod 600 "$tmp" mv "$tmp" "$file"mktemp会在缓存目录里创建一个带随机后缀的临时文件,文件名是唯一的,天然解决了多个进程同时创建临时文件的冲突。这里有个次序要刻意遵守:先chmod再mv。如果先mv后chmod,在权限还没设置好的窗口期里,其他进程可能已经读到这个文件了。虽然自己的缓存目录权限是 700,目录里的文件权限默认也 600,但养成好习惯,别把权限变更放在原子操作之后。
3.3 用 mkdir 实现最朴素的锁
原子写入解决的是"读这一刻的完整性",但解决不了"回源请求被重复触发"的问题。要解决后者,就得给回源计算加锁。Bash 里做锁最轻量、对系统依赖最小的方式是用mkdir的原子性:
acquire_lock() { local lock_dir=$1 timeout=$2 local start=$SECONDS while ! mkdir "$lock_dir" 2>/dev/null; do if (( SECONDS - start >= timeout )); then echo "lock timeout: $lock_dir" >&2 return 1 fi sleep 0.2 done echo $$ > "$lock_dir/pid" }为什么用mkdir而不是用创建普通文件来当锁?因为mkdir在操作系统层面就是个原子操作:多个进程同时执行mkdir "$dir",最终只有一个会成功,其余的都会收到"目录已存在"的错误。这种靠"创建一个不存在的目录"来占坑的思路实现最简单,也不需要装任何额外的软件。
释放锁也就是rm -rf "$lock_dir"。注意这里我写了 pid 文件,是为了后面做自愈判断,下面马上讲。
加锁的时机也有讲究。命中缓存的情况根本不需要拿锁,只有确认缓存失效、准备回源计算的那一刻才需要抢锁。抢到锁之后,最好再二次检查一次缓存内容。因为在你等待锁的几百毫秒里,第一个抢到锁的进程可能已经回源完成并写好了新缓存,你再算一遍等于白算。二次检查的代码就是在拿到锁之后再cache_get一次,如果这时有内容了,就直接放弃回源,读取现成结果。这是并发缓存里很经典的双检模式,Bash 里实现起来也不复杂,就是多一层 if 判断。
3.4 锁超时与残留锁的自愈
只用mkdir锁有个知名隐患:如果拿到锁的进程中途被 kill -9 或者机器突然断电,锁目录会残留,所有后来者都会卡在等待循环里。所以我做了两件事来解决。
第一是等锁必须有超时。上面acquire_lock里设了 timeout,超过设定秒数(我一般设 10 秒)就直接报错退出,而不是无限等下去。这样即使前面真的出了问题,也只是这次回源失败,后面调度还会继续跑,不至于整个任务挂死。
第二是锁自带"自愈"能力。我在创建锁目录时把持锁者的 PID 写进了$lock_dir/pid,其他进程在等待时检查这个 PID:用kill -0 "$pid"探测进程是否还活着,如果进程已经不存在了,说明持锁者已经死了,锁目录只是没人清理的僵尸,就可以直接rm -rf "$lock_dir"接管锁。这个技巧在脚本类任务里非常实用,因为脚本的崩溃远比服务程序常见,谁也不能保证 trap 一定执行到。
还要说明一点:flock这个工具本身也很优雅,Linux 自带,用文件描述符锁也很可靠。如果确定脚本只跑在 Linux 原生环境,用flock完全没问题。我在 Git Bash、macOS 上遇到过 flock 行为不一致的情况,所以更倾向于 mkdir 锁这种只依赖 POSIX 文件系统原子的方案,兼容性最好。
4. 缓存失效的艺术:TTL之外,还有内容指纹
4.1 TTL的适用边界:按数据源刷新周期设
固定 TTL 是最直观的失效策略,但怎么设这个参数其实有讲究。我给接口类数据设 TTL 的逻辑很简单:先搞清楚数据源本身的刷新周期,然后把 TTL 设得比刷新周期略短。
比如设备状态接口内部的指标是 5 分钟刷新一次,脚本又是每 15 分钟跑一轮,TTL 设 240 秒就够了。这样既保证任何一轮跑的时候拿到的数据不会太老,又能让 15 分钟内重复执行多次的调度基本全部命中缓存。如果 TTL 设得比刷新周期还长,比如上面场景设 10 分钟,那每一轮跑的时候可能都在用上一轮的老数据,实验结论就会失真。TTL 不是越大越好,它的上限由数据新鲜度要求决定,下限由回源成本决定。
对于本身没有固定刷新周期、但你又明确知道"短时间内不会变"的数据,比如某个配置文件的解析结果,TTL 设个一小时甚至一天都行。这种场景我通常会把 TTL 写在脚本头部一个常量里,方便按实验阶段调整,而不是散落在各段逻辑里。
4.2 内容指纹:文件变了才重新计算
TTL 在很多场景下是个"吃力不讨好"的方案。最典型的是日志文件解析:日志可能半天才追加几行,你却每 5 分钟完整解析一次。按 TTL 来,那就是每 5 分钟白算一次。按"文件是否变化"来决定要不要重算,才是对症下药。
实现思路也简单:把"输入文件的状态"作为 key 的一部分,而不是把"命令参数"作为 key。我常写一个fingerprint函数,用文件的 size 和 mtime 组合出指纹:
log_fingerprint() { stat -c '%s-%Y' "$LOG_FILE" } key=$(cache_key "log-summary-$(log_fingerprint)")这样只要日志文件的大小和修改时间没有变化,key 就不变,缓存命中。日志一旦新增内容,mtime 和 size 必然变化,key 跟着变,下次执行就会重新解析并写入新缓存,旧缓存文件自然变成无人问津的垃圾,等待清理。
用 size + mtime 组合做指纹,成本极低,但有个已知漏洞:touch命令可以篡改 mtime,同时把 size 保持原样,从而骗过指纹。对一般的实验数据来说,这不是什么大问题。但如果你的数据文件有被其他程序手动 touch 的可能,或者实验链路对数据真实性要求严格,那就应该直接对文件内容做哈希:
content_hash=$(sha256sum "$LOG_FILE" | awk '{print $1}')这会更可靠,但大文件哈希本身有成本,需要你在"可靠性"和"性能"之间做个取舍。我的习惯是:几百 MB 以上的大文件用 size + mtime,几 MB 以下的小文件直接上内容哈希,反正哈希也花不了多少时间。
4.3 版本号入key:防止旧缓存污染新逻辑
这个坑我是在一次升级解析逻辑时踩到的。当时改了一个日志解析的 awk 脚本,改完以后怎么跑结果都不对,查了半天发现是缓存里存的是旧版本的解析结果。因为 key 还是原来那个,TTL 还没到,缓存直接命中,新的解析代码压根就没执行。那次之后我就把"脚本版本号"作为 key 的一个固定前缀固化了下来:
cache_key() { printf 'exp-run:v1:%s' "$*" | sha256sum | awk '{print $1}' }以后每次改了解析逻辑、数据格式、输出结构,就把前缀里的 v1 改成 v2、v3,旧缓存全部"逻辑失效"。这是一种定向的缓存失效手段,比把 TTL 调成 0 再等缓存过期要干脆得多。配合清理策略,旧版本缓存会在几轮之后被自动删掉,不会堆积。
4.4 缓存目录的清理策略
缓存文件多了以后还有一个问题:目录越来越胖,磁盘空间被没人用的旧文件占着,找文件时也会变慢。我一般会在脚本的最后顺手做一次清理,主要清两类文件:超过最大 TTL 的过期文件,以及旧版本的缓存文件。
find "$CACHE_DIR" -type f -name 'v1-*' ! -name '*.tmp' -mtime +1 -delete这里要注意一个原则:清理阈值一定要大于这个缓存文件可能存活的最长时间,否则会把还在有效期的缓存误删,白白浪费一次回源计算。我一般设缓存 TTL 最长不超过 1 天,清理就按-mtime +7来,留足余量。如果整个脚本执行频率很高,每次跑都 find 一遍反而增加了开销,可以考虑"每 N 次执行清理一次",比如在缓存目录里放一个计数器文件,或者干脆用外部 cron 单独跑清理任务。小场景不用过度设计,几百个缓存文件的目录每次 find 一下,开销也就是几毫秒,无所谓。
5. 缓存之外:Bash脚本本身的四个提速细节
5.1 减少外部命令:能内置就不fork
缓存解决了"重复计算"的问题,但脚本里面很多慢点其实和缓存无关,纯粹是 Bash 写得不经济。Bash 脚本里每执行一个外部命令,都要 fork 出一个子进程,再让子进程去加载程序、执行、回传结果。这个开销单次看微乎其微,但在循环里放大 1000 次就很可观。
所以我的原则是:能用 Bash 内置能力解决的,坚决不叫外部命令。比如字符串替换用${var//old/new}而不是 echo 到 sed;取文件路径里的文件名用${path##*/}而不是 basename;数值运算用$((...))而不是开一个 bc。举两个最常用的例子:
# 慢:为了截个字符串还召唤了 sed dir_name=$(echo "$full_path" | sed 's|/.*||') # 快:参数展开是内置操作 dir_name="${full_path%%/*}"还有条件判断,尽量用[[ ]]而不是外部命令[ ],用(( ))做数值比较而不是test/[。Bash 的[[ ]]和(( ))是语法级实现,不产生子进程,性能差异在大量循环里会被放大。
5.2 别让管道和循环吃掉性能
管道在 Bash 里每一条|都要 fork 出一个子进程来连接两个命令。虽然写起来很舒服,但一份日志解析脚本里如果堆了七八个管道,每个管道还都在循环里执行,性能损耗就很明显了。
cat file | grep xxx这种是最经典的浪费,明明可以直接grep xxx file。同理cat file | awk ...可以直接awk ... file。多个连续的 sed/awk 管道,能合并成一个 awk 就合并,awk 内部做多步处理远比多次 fork 高效。
还有一类问题是循环里发起管道。比如下面这段:
# 慢:每个文件都跑一遍 awk for file in "$dir"/*; do result=$(awk '{sum+=$1} END{print sum}' "$file") done如果这几十个文件的统计逻辑可以合并,就应该:
cat "$dir"/* | awk '{sum+=$1} END{print sum}'一次 awk 吃完全部输入。即使不能合并,也尽量把循环外的固定管道去掉,让循环体里只剩下不可避免的操作。
5.3 循环外准备工作与批量操作
很多脚本的性能问题出在把本该只做一次的准备动作放进了循环。最典型的就是循环里反复mkdir -p、反复date +%s、反复读同一个文件。mkdir -p 这个命令看起来轻,但它要检查目录层级、可能要发起系统调用,循环跑几十次就是几十次无谓操作。
正确做法是先在循环外做好所有准备工作:
mkdir -p "$BASE_DIR" now=$(date +%s) header="timestamp,$(hostname)" log_lines=$(wc -l < "$LOG_FILE")循环里只复制这些已经算好的变量。如果你要根据一组路径批量建目录,mkdir -p本身支持一次性传多个参数,别在循环里一个一个来:
mkdir -p "${paths[@]}" # 一次调用处理所有目录5.4 数组、mapfile与跨平台兼容
还有一个 Bash 特有的性能陷阱是 while read 循环以及管道导致的变量丢失。很多人这样写:
cat file | while read line; do count=$((count+1)) done echo "$count" # 打印出来是 0!核心原因是管道右边的 while 跑在子 shell 里,count的修改只在子 shell 内生效,管道结束后就丢了。这个问题既影响正确性,也影响性能(每条管道都要 fork)。更高效的写法是一次性把文件读到数组里:
mapfile -t lines < file # Bash 4.0+ 的内置命令 for line in "${lines[@]}"; do ... donemapfile是 Bash 内置命令,一次读取整个文件,不需要逐行 fork 外部命令,性能和正确性都更好。如果你用的是较老的 Bash 或者严格 POSIX shell 环境,可以用进程替换< <(cmd)来解决子 shell 问题,但能换 Bash 4.0+ 就换。
跨平台兼容在这个话题里也很实际:Git Bash 下跑脚本,最常见的问题之一是脚本文件是 CRLF 换行,每行末尾带个\r,字符串比较永远失败、缓存 key 对不上。跑之前先sed -i 's/\r$//' script.sh或dos2unix转一次,能省下一整天的困惑。macOS 的stat参数和 Linux 不同这个点前面提过了,也需要在脚本里做兼容判断。
6. 实战复盘:一次设备老化测试脚本的缓存改造
6.1 改造前的脚本长什么样
为了把上面的思路串起来,我拿那套设备老化测试的自动化脚本当例子复盘一遍。改造前脚本的流程大概是这样:
#!/usr/bin/env bash for device in "${devices[@]}"; do status=$(curl -s --max-time 10 "http://monitor.local/status/$device") echo "$status" >> status_all.csv done summary=$(awk -f parse.awk app.log) # 每次全量解析几百MB日志 ./merge_report.sh > report.json问题很明显:每轮调度都要发起几十次 HTTP 请求,每轮都要把几百 MB 日志重新解析一遍。当时单轮耗时大概 60 秒,其中网络等待占了一半,日志解析占了另一半。调度周期是 15 分钟,也就是说每一天里有超过 90% 的执行时间在做毫无意义的重复劳动。
6.2 改造方案与实测对比
我按本文讲到的思路做了三处改动:
- 设备状态接口:用 TTL 缓存,key 包含设备 ID,TTL 设 240 秒;
- 日志解析:用"文件 size + mtime"做内容指纹,日志不变就直接复用上次解析结果;
- 所有缓存 key 加上 v1 版本前缀,并且回源时用 mkdir 锁防并发。
改造后的效果对比:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 单轮执行耗时 | 60 秒左右 | 2 到 5 秒 |
| 接口请求次数 | 每轮全量请求 | 同一设备 5 分钟内最多一次 |
| 日志解析次数 | 每轮全量解析 | 仅当日志文件变更时解析 |
| 并发脚本冲突 | 偶发半截文件 | 未再出现 |
| 缓存目录膨胀 | 无控制 | 超过 7 天的自动清理 |
最直观的感觉是调度任务从"大家排队等报告"变成了"刷一下就出来了"。60 秒降到 2 到 5 秒,主要省在命中了缓存;偶尔一次回源会让某一轮变慢,但整体体验完全不一样。
6.3 三个让我印象深刻的坑
改造过程中我踩了几个坑,值得单独写出来。
第一个坑是锁残留导致任务饿死。某次一个脚本进程被 kill -9 杀掉,它持有的缓存锁目录没被清理,后续所有任务都在等锁等到超时,整个调度链几乎停摆。后来加了 3.4 节的 PID 自愈逻辑,等待时发现持锁 PID 已经不存在就直接接管锁,问题才根治。
第二个坑是调试时手动 touch 缓存文件,把 mtime 污染了。当时为了"让缓存赶紧失效",我直接 touch 了一下缓存文件,结果 mtime 变成了当前时间,TTL 判断认为缓存永远新鲜,后面怎么改脚本都不生效。排查了很久才意识到缓存文件的 mtime 相当于"内容生成时间",不能随手改。这个坑也提醒我:TTL 缓存依赖时间戳,调试时必须用 rm 或改 key 来失效,而不是 touch。
第三个坑是mv覆盖文件后的 mtime 问题。用 mktemp 写临时文件再 mv,新缓存文件的 mtime 就是内容生成那一刻的时间,这是符合预期的。但如果你在 mv 之后再做一些收尾操作,比如 chmod、touch,mtime 就会变,TTL 判断也跟着失真。所以正确的次序是:在 mv 之前把临时文件的权限、内容都收拾好,mv 一锤定音之后什么都不动。
6.4 目前我沉淀下来的最佳实践清单
这些经验我在多个脚本项目里反复使用,整理成了一份清单,正好可以作为这次分享的收尾:
- 缓存目录统一放
${XDG_CACHE_HOME:-$HOME/.cache}/脚本名,权限 700; - key 用"脚本版本 + 业务标识 + 参数/内容指纹"组合后哈希;
- 接口类数据优先用 TTL,TTL 略短于数据源刷新周期;
- 文件类数据优先用 size + mtime 指纹,小文件可直接内容哈希;
- 回源计算必须加锁,锁带 PID 和超时,能自愈;
- 缓存写入统一 mktemp + mv 原子替换,mv 之前完成权限设置;
- 脚本逻辑升级时,手动提升 key 中的版本号,完成定向失效;
- 缓存目录按超过最大 TTL 的阈值定期清理,别把有效期内的文件误删;
- 最后,如果机器允许,临时缓存目录放到 /dev/shm 这种 tmpfs 上,读写延迟更低,代价是重启后清空,这正好适合那些"过期了本来就要重算"的场景。
我自己的习惯是,把永久性的跨天结果放在磁盘缓存目录,把那种"一两小时内有效"的临时结果直接丢进 /dev/shm 下建的目录。这样既吃到 tmpfs 的速度,又不会因为机器重启丢失重要中间结果。每个人实验环境不同,但缓存的思路是通用的:把重复的计算去掉,把每次回源的成本压到最低,脚本自然就快起来了。