
做实验、跑数据、盯着进度条转圈的朋友应该都经历过这种场景一个不算复杂的 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缓存机制的骨架目录、键名与TTL2.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 $filemacOS 和 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 21; 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 $filemktemp会在缓存目录里创建一个带随机后缀的临时文件文件名是唯一的天然解决了多个进程同时创建临时文件的冲突。这里有个次序要刻意遵守先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) headertimestamp,$(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$((count1)) 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 包含设备 IDTTL 设 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、touchmtime 就会变TTL 判断也跟着失真。所以正确的次序是在 mv 之前把临时文件的权限、内容都收拾好mv 一锤定音之后什么都不动。6.4 目前我沉淀下来的最佳实践清单这些经验我在多个脚本项目里反复使用整理成了一份清单正好可以作为这次分享的收尾缓存目录统一放${XDG_CACHE_HOME:-$HOME/.cache}/脚本名权限 700key 用脚本版本 业务标识 参数/内容指纹组合后哈希接口类数据优先用 TTLTTL 略短于数据源刷新周期文件类数据优先用 size mtime 指纹小文件可直接内容哈希回源计算必须加锁锁带 PID 和超时能自愈缓存写入统一 mktemp mv 原子替换mv 之前完成权限设置脚本逻辑升级时手动提升 key 中的版本号完成定向失效缓存目录按超过最大 TTL 的阈值定期清理别把有效期内的文件误删最后如果机器允许临时缓存目录放到 /dev/shm 这种 tmpfs 上读写延迟更低代价是重启后清空这正好适合那些过期了本来就要重算的场景。我自己的习惯是把永久性的跨天结果放在磁盘缓存目录把那种一两小时内有效的临时结果直接丢进 /dev/shm 下建的目录。这样既吃到 tmpfs 的速度又不会因为机器重启丢失重要中间结果。每个人实验环境不同但缓存的思路是通用的把重复的计算去掉把每次回源的成本压到最低脚本自然就快起来了。