工程实践 一个缓存把选片从十分钟压到一分钟

前几天我的自动发片脚本突然开始"无故死亡":跑起来一行输出都没有,几秒到几分钟后进程被 SIGTERM,退出码 1。第一反应是沙箱又在乱杀进程,第二反应是 shell 环境坏了。查了两轮才发现,两件事都不是------它只是慢,慢到被前台超时按掉了。真正的凶手是一行看起来很无害的循环调用:dedup_check() 里每次都重新扫一遍已发布库。

这篇文章把这条例路径摊开:怎么从"看着像环境问题"走到"这就是 O(N×M) 次外部进程调用",以及最后把它压到一轮探测的三步改法。

表象 无输出被杀 看着像沙箱

现象本身极具误导性。脚本没有任何 stdout,没有异常栈,没有 Python traceback,只有一条被信号杀掉的退出码。这种"干净的死"在经验上指向三种原因:解释器起不来、编码炸在 import、或者被外部信号终止。

我先排查了后两种:单独跑 python -c "import daily_publish" 正常;把脚本缩成只剩三行打印,也能正常输出。于是问题被定位到"执行到某一阶段之后"------而不是启动阶段。这一步很关键:没有输出不等于什么都没做,也可能是一直在做但一次都没打印。

把调用次数数清楚 问题就露出来了

顺着选片逻辑往下读,调用链是这样的:

python 复制代码
def select_next():
    files = [f for f in glob.glob(os.path.join(DOWNLOADS, "*.mp4"))
             if os.path.dirname(f) == DOWNLOADS]
    files.sort(key=ctime_of)
    chosen = files[0]
    dup, reason = dedup_check(chosen)     # 只判一条
    ...

def dedup_check(video):
    size = os.path.getsize(video)
    dur = video_duration(video)           # 起一次 ffprobe
    ledger = load_ledger()
    if dur:
        for path, s, d in published_lib():   # ← 每次调用都重扫
            if s == size and d and abs(d - dur) < 1.5:
                return True, f"与已发布文件同内容: {path}"
    ...

def published_lib():
    out = []
    for f in glob.glob(os.path.join(PUBLISHED, "*.mp4")):
        out.append((f, os.path.getsize(f), video_duration(f)))  # 每个文件一次 ffprobe
    return out

published_lib() 没有缓存,dedup_check() 每次都调它。于是单次判定的成本 = 已发布库文件数 × 一次 ffprobe 子进程。当时的批量扫描脚本对每个 候选视频都调一次 dedup_check(),于是总成本变成:

量 值
Downloads 顶层候选 44 条
已发布库 84 条
实际 ffprobe 次数 44 × 84 ≈ 3696 次
单次 ffprobe(含进程启动) 数十毫秒量级
合计 十几分钟

算术摆在这儿,就不需要再怀疑沙箱了。更要命的是这个结构会自我恶化:已发布库只增不减,候选积压只增不减,每次跑都是 O(N×M),积压一倍就是四倍时间。

判据按代价排序 昂贵的往后放

一眼看上去的修法是加缓存,但缓存只是把常数砍掉一层------真正让成本爆掉的是判据的顺序。

去重判据是"大小相同 且 时长差小于 1.5 秒"。这两个条件的代价差了三个数量级:os.path.getsize 是纯元数据读取,微秒级;video_duration 要 fork 一个 ffprobe、解码容器头、再回收,几十毫秒。原代码却是先取时长、再进循环比大小------把最贵的操作放在了最前面。

按代价升序重排之后,判断就变成了"先按大小分桶,只有落进同一个桶的才值得去问时长":

python 复制代码
def build_size_index(published):
    """size -> [path, ...],一次遍历建成,之后只读"""
    idx = {}
    for path, s, d in published:
        idx.setdefault(s, []).append((path, d))
    return idx

def dedup_indexed(video, size_index):
    size = os.path.getsize(video)          # 微秒级
    bucket = size_index.get(size) or []    # 不命中直接判否,一次 ffprobe 都不用跑
    if not bucket:
        return False, ""
    dur = video_duration(video)            # 只在有同尺寸候选时才付出这个代价
    ...

实际数据里,44 条候选和 84 条已发布重合的尺寸通常只有 0 到 1 个,分桶之后绝大多数候选连一次 ffprobe 都不需要。也就是说:不用把探测做快,只要让它在不需要的时候根本不发生。

缓存要写对 才不白写

只加索引不解决批量场景:扫描脚本还是要对 44 条候选逐个判定,而每条判定都要读一遍库。于是第二层是记忆化------把"已发布库"这个基线数据在进程内算一次:

python 复制代码
_cache = {}

def plib_cached():
    if "plib" not in _cache:
        _cache["plib"] = dp.published_lib()   # 整轮只探测一次
    return _cache["plib"]

这里有两个必须想清楚的点,否则缓存会变成新的 bug 源。

第一,缓存粒度只能是进程生命周期。 每一轮扫描都是独立进程,缓存随进程消失,天然不会读到隔夜数据;反过来,如果把它持久化到磁盘(比如落一个 json),就必须处理失效------因为归档、压缩替换、人工挪文件都会改 已发布/ 的内容。为了省一分钟而引入一个需要维护失效的跨进程缓存,不划算。

第二,缓存不能跨"会改动被缓存对象"的动作。 同一个进程里如果先算库、再归档、再算库,第二次拿到的是过期快照。这条在本项目里不是假设:压缩替换会重写文件、归档会把文件搬进 已发布/,两者都改变基线的成员。所以缓存的注释里必须写清"只读场景"。

判定范围要和消费范围对齐

改到这里性能已经不是问题了,但读代码时发现一个更值得说的结构问题:select_next() 只取 files[0] 一条,dedup_check() 却按"要为整批候选服务"的假设写。批量脚本照抄这个函数,于是出现了"给 44 条候选都算了一遍去重结论、最后只用得上 1 条"的浪费。

反方向的坑同样存在,而且更隐蔽:select_next() 遇到第一条重复就直接返回 suspected_duplicate,不会去看第二条。如果 Downloads 顶层堆了几条历史重复文件,选片就会连续多次空转。正确的写法是把判定挪进循环,按 ctime 顺序逐条试,直到找到第一条不重复的;或者像快速扫描脚本那样,只对最前面 3 到 4 条做判定------留一点缓冲,但不做全量。

这条经验可以抽象成一句话:循环的边界应该由"谁会消费这个结果"决定,而不是由"手头有哪些数据"决定。 前者是需求,后者只是便利。

别拿有没有输出当卡死判据

最后是一个观测层面的教训。这次排查绕了弯路,是因为我把"没有输出"当成了"没有进展"。脚本全程在做 3696 次子进程调用,但它一句话都不打印,从外部看和僵死完全一样。

改完之后顺手加了两行计量,成本几乎为零,收益是下次不用再猜:

python 复制代码
t0 = time.time()
n_probe = 0
...
log(f"[scan] candidates={len(vids)} probes={n_probe} elapsed={time.time()-t0:.1f}s")

probes 这个数字特别有用:它一眼就能告诉你判据排序有没有生效(应该接近候选数,而不是候选数乘库大小),也能在素材堆积时提前预警------当它开始随候选数线性上涨,说明某一层剪枝被绕过了。

结果

改动落在三处:published_lib() 的结果在进程内缓存一次;去重判定改成 size 分桶先行、时长探测延后;批量扫描只对 ctime 最早的 3 到 4 条候选做判定。

整轮耗时从十几分钟降到一分钟以内,稳定性也随之回来------同样的脚本,之前是"跑两次被杀两次",之后是一次跑完正常退出。

回头看不值得炫耀的部分:这次真正的错误不是"忘了加缓存",而是默认"判据很便宜"。去重判据看着是两个字段的比较,实际却是一个字段加一次外部进程;把这种判据放进双层循环,复杂度就从"看起来 O(N×M) 次比较"变成了"O(N×M) 次进程创建"。数量级差在这儿,加机器、调超时都救不回来,只有把贵的挪到后面、把不需要的剪掉,才是真的把它按下去。

相关推荐
前端小万1 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试
FRing031 小时前
JavaScript入门到精通二十四(正则表达式、元字符、修饰符)
开发语言·前端·javascript·正则表达式
葡萄城技术团队2 小时前
在 SpreadJS 里新增右键菜单,把业务动作放到用户手边
前端
大明二代2 小时前
从 RAG 到有记忆的 Agent:Mastra Memory 与 PostgreSQL 实战
前端·javascript·人工智能
deli0072 小时前
代码截图一键出 PNG 实测:3 种窗口风格,长行超 95 字符会被裁
前端
大哥43092 小时前
慢 ≠ 挂:AI 搜索降级判定的设计,与一次 reasoning 模型排查复盘
前端
UaoN2 小时前
作为全栈开发者,为什么看到 Shopify 回归原生,我依然选择 React Native
前端·后端
PBitW3 小时前
行内编辑,前端如何兜底?😭😭😭
前端·面试
小羊没烦恼!3 小时前
C#综合揭秘——Entity Framework 并发处理详解
java·开发语言·前端·javascript·数据库·mysql·c#