从 .pyc 头部到部署现场:Python 字节码缓存失效与排障指南
上周帮人排查一个挺典型的问题测试环境改了业务判断逻辑打包上线之后日志里跑的却还是三天前的分支git log干干净净cat源文件确实是新内容唯独程序行为是旧的。翻了二十来分钟最后ls -l看了一眼__pycache__里对应文件的时间戳——比.py还新。这就是 pyc 文件在背后起作用。很多人对它的认知停留在“Python 会自动生成的一个缓存”但它其实有一套非常明确的失效判定规则规则没被满足旧字节码就继续生效而且整个过程静默无提示。这篇东西我打算把.pyc从二进制头部一路拆到部署现场讲清楚它的物理结构、import 系统什么时候决定复用它、什么情况下会踩坑以及在分发和性能这两个方向上它到底能帮上多少忙。适合已经写过一段时间 Python、开始接触打包部署或者被“代码没生效”折磨过的读者纯新手也能看懂前半部分。1. 十六个字节的头部里装着版本、时间与校验三件事先把.pyc拆开看。它不是什么神秘格式本质上就是一个固定长度的头部 一段 marshal 序列化后的 code object。头部长度在 Python 3.7 之后固定为 16 字节在此之前的版本是 12 字节这个差异本身就构成了一类跨版本事故后面会单独说。1.1 magic number四个字节完成的版本隔离文件最前面的 4 个字节是 magic number其中前 2 字节是真正的版本标识后 2 字节固定是\r\n。这个设计挺有意思——加回车换行是为了防止某些文本模式传输或编辑器把二进制文件当文本处理时“顺手”改坏一旦\r\n对不上加载器立刻就知道文件被污染了。magic number 每个 Python 小版本都会变。3.8、3.9、3.10、3.11、3.12 各不相同因为它们之间的字节码指令集、栈帧布局、异常表结构都有调整marshal 出来的 code object 结构也不兼容。想拿当前解释器的 magic最直接的办法是import importlib.util print(importlib.util.MAGIC_NUMBER.hex()) # 例如 6f0d0d0a注意imp.get_magic()这个老接口早就废弃了别再往新代码里写。拿到 magic 之后和 pyc 文件头前 4 字节做一次字节比较就能判断这个 pyc 是不是当前解释器能吃的。对不上会抛ImportError: bad magic number in xxx如果你手上只有 pyc 没有源码这个错误基本等于“这个文件废了”只能找对应版本的 Python 重新编译。1.2 PEP 552 定义的两种失效模式紧接着 magic 的是 4 字节的 flags 字段小端序的 uint32。这个字段是 PEP 552 引入的决定了后面 8 个字节怎么解释flags 值模式后 8 字节含义0b00TIMESTAMP4 字节源文件 mtime 4 字节源文件大小0b01CHECKED_HASH8 字节源文件内容哈希0b11UNCHECKED_HASH8 字节源文件内容哈希但加载时不校验时间戳模式是历史默认行为兼容性最好也是绝大多数人无意中在用的模式。它的判定逻辑非常朴素拿源文件当前的st_mtime取整到秒和头部记录的 mtime 比再拿st_size和头部记录的大小比。两个都相等就认为 pyc 有效。哈希模式则是对源文件内容算一个 64 位的 SipHash-1-3 摘要importlib.util.source_hash就是干这个的内容变一个字节都会失配。CHECKED_HASH在加载时会重新读源文件算哈希UNCHECKED_HASH则跳过校验直接信任——后者在“只发 pyc 不发源码”的分发场景里很常见因为源文件根本不存在没法算哈希。提示想在项目里统一改成哈希模式python -m compileall --invalidation-mode checked-hash src/就能一次性重编译。这个改动在部署环境里价值很高原因在第四节会展开。1.3 头部之后marshal 出来的到底是个什么东西16 字节之后的部分是marshal.dumps(code_object)的产物。code object 是 CPython 对“一段可执行代码”的完整描述里面装着这些东西co_code真正的字节码指令序列一个字节一个操作码或者操作码加参数co_consts常量池字符串、数字、嵌套的 code object 都在这里co_names全局名字和属性名co_varnames/co_freevars/co_cellvars局部变量、闭包变量co_flags标记是否是生成器、协程、有没有*argsco_exceptiontable3.11 之后单独的异常处理表之前是混在字节码里的 SETUP_FINALLY 指令marshal 格式本身是 CPython 的内部实现细节官方文档明确写了“不保证跨版本兼容”甚至在同一个版本的不同构建之间也可能有差异。这就解释了为什么 pyc 不能跨版本复用——不是加载器“不愿意”兼容是底层数据结构真的对不上。你甚至可以在代码里手动marshal.loads一段字节但没太大意义dis模块才是正经的观察工具。2. import 走到哪一步才决定“用旧的还是重编译”理解了文件结构下一个问题自然是这套判定在 import 流程的哪个环节发生搞不清这个排查问题时只能靠猜。我把链路捋一遍重点放在几个容易出错的判断点上。2.1 从 import 到落盘的完整调用链import foo触发后先是sys.meta_path上的 finder 依次尝试。标准路径下最终落到FileFinder它按loader_details里注册的后缀顺序去目录里找先看foo.py再看foo.pycsourceless还可能有扩展模块后缀。找到foo.py之后用的是SourceFileLoader它的get_code方法才是关键。简化后的流程是这样的计算缓存路径。3.2 之后是__pycache__/foo.cpython-311.pyc其中cpython-311来自sys.implementation.cache_tag如果开了优化级别会变成foo.cpython-311.opt-1.pyc。尝试读这个 pyc。读不到就直接编译源码。读到了就调_classify_pyc校验 magic 和 flags 合法性。通过之后按模式调_validate_timestamp_pyc或_validate_hash_pyc。校验通过marshal.loads反序列化拿到 code object直接返回。任何一步失败退回到编译源码编译完再尝试把新 pyc 写回缓存目录。这里有个细节值得记住第 6 步的写入失败是被静默吞掉的。只读文件系统、目录权限不足、磁盘满都不会让 import 报错程序照常跑只是每次启动都重新编译一遍。这个“静默降级”在后面讲容器部署时会变成性能问题的隐藏来源。2.2 六类会让缓存失效的条件把上面流程拆细能触发重新编译的条件其实就这些pyc 文件不存在。最常见首次运行、清了__pycache__、换了解释器版本都会命中。magic number 不匹配。换了 Python 版本或者被别的版本编译过。flags 字段不合法。文件被截断、被文本编辑器改过、传输过程损坏。时间戳型下 mtime 不一致。源文件被touch、被重新 checkout、被cp覆盖。时间戳型下文件大小不一致。哪怕 mtime 相同大小变了也会失效。哈希型下内容哈希不一致。反过来__pycache__目录不存在本身不算失效条件——加载器会先尝试读读不到就当没有编译后再尝试mkdir创建。所以你在一个干净目录里跑 Python第一次 import 之后__pycache__就自动出现了。2.3 那些看起来该失效、实际上不会的场景这里才是真正常踩的地方。时间戳模式的 mtime 精度是秒因为头部里存的是 uint32 的整秒。如果某个构建脚本在同一个自然秒内完成“写源文件”和“生成 pyc”两个动作随后又在这个秒内再次修改源文件而修改前后文件大小恰好一样比如把A改成B或者把True改成Fals…… 好吧后者大小变了但把0改成1就很典型那么加载器会认为 pyc 完全有效继续加载旧字节码。这不是理论上的可能。CI 里用脚本批量替换版本号、配置常量、开关标记规模一大同一秒内改多个文件的概率并不低。加上有些构建工具会刻意把 mtime 统一设置成某个固定值以保证可复现构建那就更容易撞上。另一个隐蔽场景是时间戳被“倒退”。从压缩包解压、tar还原、某些同步工具拉取文件都可能让源文件的 mtime 变成一个很旧的值。加载器只比较“是否相等”不比较“谁更新”所以只要头部记录的 mtime 恰好等于这个旧值旧 pyc 就会被认为有效——哪怕源文件内容已经完全变了。这类问题在产品环境里表现为“代码改了但没生效”而且ls -l看时间戳一切正常非常迷惑。3. 手工摆弄 pyc生成、观察与逆向的可行工具日常开发基本不需要手动碰 pyc但部署、排障、做分发包的时候手上得有几把顺手的工具。这一节把我常用的几个整理一下包括参数差异和能力边界。3.1 py_compile 与 compileall 的分工单个文件用py_compileimport py_compile py_compile.compile( app/service.py, cfileNone, # None 表示按默认规则放到 __pycache__ dfileapp/service.py, # 写进 code object 的原始路径影响 traceback 显示 doraiseTrue, # 编译失败直接抛异常默认是静默返回 None optimize-1, # -1 跟随当前解释器0/1/2 对应 -O 级别 invalidation_modepy_compile.PycInvalidationMode.CHECKED_HASH, )doraiseTrue这个参数建议常开。默认行为是编译失败返回None不报错在构建脚本里很容易漏掉语法错误等到运行时才炸。整个目录用compileall我更常用它因为参数覆盖了绝大多数构建需求python -m compileall -q -f -j 0 \ --invalidation-mode checked-hash \ -d /app \ src/-q压掉逐文件输出-f强制重编译不加的话它会跳过已经新鲜的 pyc构建时你根本不确定哪些被跳过了-j 0用满所有 CPU 核心并行-d用来指定写进 code object 的路径前缀这样 traceback 里显示的是部署后的绝对路径而不是构建机上的路径。如果确实需要 pyc 和源文件放在同一目录某些老工具链或特殊分发格式要求这样加-b。这个选项会把 pyc 写到src/foo.pyc而不是src/__pycache__/foo.cpython-311.pyc。要提醒一句-b是 legacy 布局一旦源文件被删掉这个位置的 pyc 反而能被正常导入而__pycache__里的不行——原因在 4.3 节说。3.2 用 dis 看字节码比反编译更可靠想确认某个 pyc 里到底是不是你想要的那版逻辑反编译不一定靠谱下一小节解释但dis是一定准的。它读的是 code object不做任何猜测python -m dis src/app/service.py输出是逐行的指令列表左边是行号中间是偏移量右边是指令和参数。找关键字面量特别方便——如果新逻辑里有个v2的字符串常量在LOAD_CONST那一行就能直接看到。要检查一个已有的 pyc 文件可以这样import dis, marshal, importlib.util with open(src/__pycache__/service.cpython-311.pyc, rb) as f: f.read(16) # 跳过头部 code marshal.load(f) print(code.co_consts) # 常量池字符串和数字都在这 dis.dis(code)co_consts里会嵌套子 code object函数体、类体需要递归看。写个小函数遍历一下就能把整个模块的常量全打出来比对版本号、开关标记这类东西非常高效。3.3 反编译工具的真实能力边界反编译这块得实话实说越新的 Python 版本反编译越难成功率越低。大致的能力分布是这样的3.8 及以前成熟工具能恢复出接近原始结构的代码3.9 到 3.10 开始吃力控制流还原经常出错需要人工修补3.11 之后引入了自适应解释器、单独的异常表、更激进的指令融合公开工具多数只能做到部分还原遇到复杂控制流基本只能看个大概。所以如果有人拿“pyc 能防住源码泄露”来劝你可以先让他现场反编译一个 3.11 的 pyc 看看。能还原出函数名、字符串常量、大致调用关系这已经足够暴露业务逻辑了。至于完整的、可运行的源码反编译拿不到但看懂你在干什么完全够了。这一点在第五节会展开。4. 部署现场最常撞上的五个 pyc 坑前面都是铺垫这一节是真正会让人加班的部分。我按踩到的频率排个序每个都给出完整的排查链路而不是直接甩结论——因为排查过程本身比结论更有复用价值。4.1 第一个坑同一秒内改文件pyc 静默陈旧现象改了代码重启服务行为没变。cat源文件是对的ls -l时间戳也是刚才的删了__pycache__重跑就正常了。排查链路我是这么走的先确认 pyc 路径python -c import foo; print(foo.__cached__)直接告诉你加载器认为的缓存文件在哪。然后stat一下源文件和 pyc 的 mtime精确到秒看。如果两个 mtime 是同一个整秒值那基本就锁定了。再用xxd或者前面那段脚本读一下 pyc 头部第 8 到 12 字节转成整数和源文件的int(st_mtime)对比——相等就说明判定通过了。修复方案有两个层次。临时的python -m compileall -f强制重编译或者直接删__pycache__。根本的切换成CHECKED_HASH模式让失效判定基于内容而不是时间。这个改动在构建流水线里做一次就行之后内容变了一定失效内容没变一定复用逻辑上干净得多。注意CHECKED_HASH模式在加载时会读一遍源文件算哈希对超大项目会有一点额外 IO。实测下来几千个模块的项目启动开销增加在几十毫秒量级换取的是确定性我认为非常值。4.2 第二个坑只读文件系统上的写入失败被吞掉容器镜像里把代码目录设成只读这是安全加固的常规操作或者把 Python 装在一个只读的挂载点下import一切正常但每次启动都在重新编译。项目小的时候感觉不出来模块上千、依赖一堆的时候冷启动时间能差出好几秒。怎么确认在启动脚本里加一句日志import sys print(dont_write_bytecode , sys.dont_write_bytecode)如果是True说明要么环境变量PYTHONDONTWRITEBYTECODE1被设了要么启动时带了-B。这两个都会让 CPython 跳过所有写 pyc 的动作。很多人为了“保持目录干净”在 Dockerfile 里加了ENV PYTHONDONTWRITEBYTECODE1然后在性能敏感的服务上吃闷亏。另一种情况是dont_write_bytecode为False但目录确实不可写。这时得看加载器抛出的OSError有没有被吞——CPython 在写缓存失败时确实会捕获并忽略不会中断 import。要确认这一点可以手动试写python -c import pathlib p pathlib.Path(src/__pycache__/probe) try: p.write_bytes(bx); p.unlink(); print(可写) except OSError as e: print(不可写:, e) 如果真的不可写又想保留编译收益有两个办法。一是在构建镜像时就把 pyc 编译好并一起打进镜像构建阶段目录是可写的运行时只读也没关系因为 pyc 已经在那了。二是用PYTHONPYCACHEPREFIX3.8把缓存目录指到一个可写的临时路径比如/tmp/pycacheexport PYTHONPYCACHEPREFIX/tmp/pycache这样 pyc 不落在源码目录里只读限制就不影响了代价是容器重启后缓存丢失第一次启动还是得编译。4.3 第三个坑删了 .py 只留pycache导入直接失败这个误解特别普遍以为__pycache__里的 pyc 可以脱离源码独立使用。实际上不行。__pycache__里的文件命名是foo.cpython-311.pyc而FileFinder在查找模块foo时检查的路径是foo.py、foo.pyc、foo.so这些。它根本不会去__pycache__目录里按 sourceless 的方式找。__pycache__里的 pyc 只作为“源码的加速缓存”存在是SourceFileLoader内部使用的附属产物。要让 pyc 独立可导入必须把它放在源文件旁边命名成foo.pyc也就是 legacy 布局app/ foo.pyc - 这个能被 sourceless 导入 __pycache__/ foo.cpython-311.pyc - 这个不能所以做“只发字节码”的分发包正确姿势是先用compileall -b生成同目录 pyc再把.py全部删掉。顺序不能反——先删源码再编译是编不出来的。另外注意-b生成的 pyc 也是带完整头部的magic 和当前解释器必须匹配跨版本分发照样会bad magic number。还有个细节如果目录里同时存在foo.py和foo.pyc源码优先pyc 被当作缓存候选但不会优先于源码加载。所以别指望“放个旧 pyc 在旁边就能覆盖源码”——不成立。4.4 第四个坑优化级别和预编译产物对不上号python -O运行时会去找foo.cpython-311.opt-1.pyc-OO找opt-2。如果你在构建阶段只跑了默认的compileall生成opt-0或者说无后缀的那个运行时带了-O那构建阶段的所有编译工作就白做了运行时全部重新编译。更麻烦的是行为不一致-O会移除assert语句把__debug__置为False。如果某段逻辑依赖assert做参数校验虽然不该这么写但现实里到处都是在-O下这段校验就消失了。构建阶段用-O编译、运行时不带-O或者反过来都会造成“本地好好的、线上不对”的诡异现象。处理方式很直接构建时把所有可能用到的优化级别都生成一遍python -m compileall -q -f -o 0 -o 1 -o 2 src/代价是磁盘占用翻两三倍但换来的是运行时无论带什么参数都不会重新编译。如果确定线上只用默认级别那就只编-o 0同时在部署脚本里显式检查启动命令里没有-O。4.5 第五个坑CI 缓存把不同版本的 pyc 串到一起CI 里为了加速经常缓存依赖目录和构建产物。如果缓存键没把 Python 版本算进去很容易出现“上一个 job 用 3.10 编的 pyc 被 3.11 的 job 读到”的情况。这时候的表现取决于场景。有源码的情况下_classify_pyc发现 magic 不对静默重编译看不出问题只是缓存白做了。但如果你的分发流程里有“只保留 pyc”的步骤那就直接bad magic number起不来。排查方法很简单扫一遍所有 pyc 的 magicimport importlib.util import pathlib import struct current importlib.util.MAGIC_NUMBER bad [] for p in pathlib.Path(src).rglob(*.pyc): magic p.read_bytes()[:4] if magic ! current: bad.append((str(p), magic.hex())) print(f不匹配 {len(bad)} 个) for path, m in bad[:20]: print(path, m)缓存键的修复就是老老实实把python --version的输出拼进去。我一般还会在构建脚本开头打个断言如果发现__pycache__里有 magic 不匹配的文件直接rm -rf掉再重新编译比事后排查省事得多。5. pyc 做分发保护的边界防得住什么防不住什么字节码分发这个话题每隔一段时间就有人问。我先把结论摆出来pyc 不是加密它只是把源码换了一种表示形式可读性下降但信息量基本保留。把它当成“防止同事误改”的手段是合理的当成“防止竞争对手抄袭”的手段是不合理的。5.1 从 pyc 里能捞出多少信息前面提过dis能直接读常量和指令。具体能拿到什么所有字符串字面量包括 SQL 语句、API 路径、错误提示、内置的密钥——如果硬编码了的话、所有函数名和类名、属性名、模块名、常量数值、分支结构的大致形状。也就是说业务逻辑的骨架是完全暴露的。反编译工具在 3.8 及以前能恢复出接近原始的代码结构人读起来毫无障碍3.11 之后完整源码还原困难但配合dis输出人工阅读理解逻辑并不需要太长时间。所以任何“核心算法靠 pyc 保护”的方案本质上是在赌对方不愿意花时间。还有个更直接的风险点如果代码里硬编码了凭据、内部地址、密钥不论 pyc 还是源码都是明文可提取的。这类东西该放配置中心或者环境变量跟分发形式无关。5.2 源码剥离的实操流程与取舍如果目标只是“不让源码躺在服务器上被人随手cat”pyc 分发是够用的流程也不复杂# 1) 干净环境重新编译确保不复用旧缓存 find src -name __pycache__ -type d -exec rm -rf {} python -m compileall -q -f -b src/ # 2) 删掉源码只留同目录的 pyc find src -name *.py -delete # 3) 确认没有漏网的源码 find src -name *.py | wc -l # 应该是 0三步之后src里只剩.pyc。这个包拿到同版本的 Python 环境里就能直接 import。要提醒两个坑。第一-b生成的 pyc 头部仍然是时间戳模式源文件已经不存在了加载器在校验阶段会怎么处理答案是sourceless 加载走的是SourcelessFileLoader它只校验 magic 和 flags不做时间戳或哈希校验因为压根没有源文件可比。所以删源码不影响加载但这也意味着你失去了任何一致性保障——pyc 和“它对应的源码”之间已经没有关联了版本管理只能靠你自己。第二有些第三方库会检查module.__file__或inspect.getsource源码删了之后这些调用会失败抛OSError或者TypeError。上线前最好跑一遍完整的功能测试别只测主流程。5.3 真需要更强保护时的几条路如果确实需要“即使拿到产物也难以理解逻辑”pyc 这条路走不通得换思路。常见的几条一是编译成机器码把 Python 代码转成 C 再编译成扩展模块或者直接编译成独立二进制。这类工具的好处是逻辑变成了机器指令逆向门槛明显提高代价是构建复杂度上去了调试信息丢失出问题定位困难动态特性比如运行时的getattr反射也可能出状况。二是把核心逻辑挪到服务端客户端只保留调用协议。这个方案对架构侵入最大但保护效果最好而且能顺便做授权和限流。前提是你的业务形态允许这么做。三是接受现实把精力放在“代码质量”和“迭代速度”上而不是“防止被看”。这个结论听起来消极但很多团队算过账之后发现被抄走的成本远低于维护一套复杂保护方案的成本。我个人的取舍是内部服务不折腾直接发源码对外分发的工具包用 pyc 加一层“提高门槛”但不指望它防住有心人真正核心的、有商业价值的逻辑放到服务端。6. 一套可以固化进流水线的检查动作工具和原理讲完了最后落到“怎么让它不再成为问题”。我这几年形成的习惯是与其在事故发生后排查不如在构建阶段就把不确定性消掉。6.1 构建阶段的三条硬规则第一条构建前必须清缓存。find . -name __pycache__ -type d -prune -exec rm -rf {} 这行加在构建脚本最前面。理由很简单你永远不知道上一次构建留下了什么版本的 pyc清掉是最省事的确定性来源。第二条编译时必须带-f。不加的话compileall会跳过“看起来新鲜”的文件而复用判断本身就可能出错见 4.1。多花几秒强制重编译换来的是“构建产物一定反映当前源码”这个保证。第三条编译后立刻校验。加一个构建后置步骤扫描所有 pyc 的 magic 是否等于构建机 Python 的MAGIC_NUMBER不匹配就让构建失败。这个检查只需要几行代码但能拦住跨版本串味和传输损坏两类问题。# build_check.py import importlib.util import pathlib import sys target pathlib.Path(sys.argv[1]) magic importlib.util.MAGIC_NUMBER bad [str(p) for p in target.rglob(*.pyc) if p.read_bytes()[:4] ! magic] if bad: print(f发现问题 pyc {len(bad)} 个, filesys.stderr) for p in bad[:10]: print( p, filesys.stderr) sys.exit(1) print(pyc 校验通过)6.2 运行时的一致性自检上线之后还有一个检查值得做确认服务实际加载的 pyc 是不是本次发布的版本。做法很简单在服务的启动日志里打一行import myapp.core print(loaded from:, myapp.core.__cached__)__cached__是模块对象上的属性指向加载器实际使用的 pyc 路径。配合发布系统记录的构建 ID能快速判断“线上跑的是不是这次的包”。这个检查在回滚场景下特别有用——很多时候回滚了代码但没清缓存实际跑的还是新版本的字节码日志里看半天对不上。如果想让这层保障更强可以在构建时把 git commit 短哈希写进一个模块常量启动时打日志。这样比对的是内容而不是文件路径不会因为路径相同就误判。6.3 版本控制与目录约定.gitignore里加上__pycache__/和*.pyc是基本操作但我想强调一下原因把 pyc 提交进仓库不只是“仓库变大”这么简单。不同开发者机器上的 Python 版本不同pyc 会在 pull 之后互相覆盖导致一部分人拿到 magic 不匹配的文件有源码时静默重编译无源码时直接报错。更糟的是有些人本地跑的是-O提交上来的是opt-1的产物其他人拿到之后行为可能不一致。对 Docker 构建COPY是保留 mtime 的这一点既是好事也是坑。好事是构建机上编译好的 pyc 在镜像里依然有效坑是如果构建上下文里的源码 mtime 恰好和 pyc 头部记录一致而内容不同就会加载到旧字节码。用CHECKED_HASH模式可以彻底规避这个风险因为校验基于内容。我现在的默认配置是项目里统一用--invalidation-mode checked-hash构建脚本强制重编译加校验Docker 镜像里预编译好所有需要的优化级别运行时目录设只读。这套组合下来pyc 相关的问题基本绝迹了。7. 最后分享两个小工具片段平时排查用得最多的两个片段贴在这以备不时之需。一个是读 pyc 头部并判断新鲜度import struct from pathlib import Path def inspect_pyc(pyc_path: str, src_path: str | None None): data Path(pyc_path).read_bytes() magic, flags data[:4], struct.unpack(I, data[4:8])[0] print(fmagic{magic.hex()} flags{flags:#06b}) if flags 0b01: print(fhash{data[8:16].hex()} (hash 模式)) if src_path: from importlib.util import source_hash print(fsrc hash{source_hash(Path(src_path).read_bytes()).hex()}) else: mtime, size struct.unpack(II, data[8:16]) print(fmtime{mtime} size{size} (时间戳模式)) if src_path: st Path(src_path).stat() print(fsrc mtime{int(st.st_mtime)} src size{st.st_size})跑一下两边的数字摆在一起是不是同一秒、大小有没有变一目了然。另一个是批量统计项目里 pyc 的分布看看有没有历史遗留find . -name *.pyc -printf %T %s %p\n | sort -n | tail -20按修改时间排序取最近 20 个能快速发现“某些 pyc 比源码还新”或者“某个目录下全是几个月前的尸体”这类情况。清缓存之前先看一眼有时候能发现一些意料之外的东西比如某个第三方库自带的 pyc。上面这些是我在实际项目里反复用到的部分。pyc 这个机制本身设计得挺克制——规则简单、行为可预测问题基本都出在“构建流程没管住它”而不是机制本身有缺陷。把编译时机、失效模式、分发形态这三件事定下来剩下的就是照着执行不需要每次出事再翻一遍文档。