脚本每天雷打不动地完整下载一次大文件,哪怕本地明明就是最新的。加日志、加校验、手动比对,折腾半天才发现本地文件确实是新的——可脚本就是认为它旧。这种"永远需要更新"的毛病,根子常常不在逻辑,而在两个名字。
现象
一个数据更新脚本,每天无条件地把几百 MB 的大文件从头下载一遍,白白耗时耗带宽。它的判断逻辑看起来完全合理:
[INFO] 检查本地新鲜度...
[INFO] 本地版本过期,开始完整下载:xxx.bin (312MB)
[INFO] 下载完成,耗时 4m12s
但你手动检查过:本地文件的修改时间跟远端一致,根本不用重下。脚本却每次都判"过期"。
根因
问题出在"读"和"写"用了两个不一样的键名。
写入元数据时,用的是这个键:
meta["remote_last_modified"] = remote_mtime
而判断新鲜度的函数,读的却是另一个键:
if meta.get("last_modified") != remote_mtime:
need_update = True
meta 里根本没有 last_modified 这个键——真正的键叫 remote_last_modified。于是 meta.get("last_modified") 永远返回 None,None != remote_mtime 永远成立,need_update 就被永远置为 True。
这里之所以特别难查,是因为它不报错:dict.get() 取不到值时返回 None 而不是抛 KeyError,程序照常运行、照常走"需要更新"分支,日志也打印得理直气壮。它没有任何异常提示你去怀疑字段名,只会让你把注意力引向"是不是时间戳格式不对""是不是时区问题"这些错误方向。
解决
第一步,统一键名。 让读和写指向同一个名字。把读取处改成与写入一致:
if meta.get("remote_last_modified") != remote_mtime:
need_update = True
第二步,消除"手写字符串"这个隐患。 治本的做法是把键名定义成常量,两处都引用它,从源头杜绝拼写漂移:
KEY_LAST_MODIFIED = "remote_last_modified"
meta[KEY_LAST_MODIFIED] = remote_mtime # 写
if meta.get(KEY_LAST_MODIFIED) != remote_mtime: # 读
need_update = True
第三步,加断言或日志确认。 修好之后跑一次,命令应当明确输出"需要更新:否":
[INFO] 检查本地新鲜度...
[INFO] 本地已是最新,跳过下载(last_modified 一致)
如果还是"需要更新",就打印出实际读到的值,看它是不是 None——这样字段名对不对一眼便知。
延伸与预防
这条坑属于"静默失效"家族:不抛异常、不报错,只是悄悄走向错误分支。而其中最典型的一类,就是读写同一份数据的两个名字不一致。它的可怕之处在于,代码看起来处处正常,唯独结果不对。
预防上有几条经验值得记住:
- 凡是跨函数、跨模块共享的字典键,一律用常量或枚举,不要手写字符串。 拼错一个字母,静态检查也可能放过,但运行时就是取不到值。
- 取字典值时想清楚"取不到"意味着什么。
d.get(k)返回None会把"键根本不存在"和"键存在但值是 None"混为一谈。当"键必须存在"时,用d[k](会抛KeyError)反而更安全——至少它会把问题叫出来,而不是默默走错分支。 - 给"判断类"函数写测试。 像"是否需要更新"这种布尔判断,用固定的输入/输出对照测一遍,字段名写错当场就会被发现。
- 同一份数据只保留一种键名风格。
last_modified和remote_last_modified这类"同一个东西两个叫法"的情况,是 bug 的温床;命名上就统一成一种,读的人也不会认错。
最后一条通用心态:当脚本"行为不对但从不报错"时,优先怀疑数据字段而不是控制流程。把关键路径上读到的值原样打出来看一眼,往往比逐行读代码更快找到那个不一致的名字。