第13章 优化解法
第13章 优化解法
学习目标
- 能对照 list 与 set 的成员查询,说出查询次数达到多少才值得先建集合
- 能为
lru_cache设计含全部影响结果输入的缓存键,并用cache_info判断缓存是否值得保留 - 自测:给定一个"批量计算产品价格"函数,你能按"定位瓶颈→换结构或并行→缓存并测量命中率"的顺序给出优化方案吗?
为什么先谈优化解法
写代码时常常遇到一种情况:功能是对的,但跑起来慢,或者一改就乱。背后往往是两件事在拉扯——一件是"做了多少不必要的工作",另一件是"把工作摊给谁做"。优化解法要做的,就是把这两件事理清楚,再决定动哪里。
想象一个柜台:顾客排长队,不是因为事情难,而是因为每个人都要回头翻一遍抽屉找单子。换个放单子的方式——按编号挂好、伸手就拿到——队伍一下子就快了。这就是优化的直觉:先看瓶颈在哪,再决定是少做、换做法,还是分给更多人同时做。
但提速不是没有代价。多派人手要付工资,把单子提前抄一份要占地方,抄错了还会拿到旧信息。所以本章不只讲"怎么变快",还要讲"提速带来的成本和风险怎么验证、怎么兜底"。
先做预测
先预测:一个示例在作者机器上运行成功,是否足以证明它可维护?不能。还要明确解释器与依赖、输入边界、失败路径、可观察结果和重建步骤。给出本章的第一个契约,把它放进可重复流程。
原书骨架与现代迁移
官方章节围绕复杂度与简化、集合与数据结构、减少外部调用、线程与多进程、缓存策略展开。原书出版于Python 2.5与早期敏捷工具生态,本章保留它解释"为什么"的结构;命令和安全默认按当前Python、标准库与PyPA维护文档迁移,不把EasyInstall、distutils安装命令、旧CI或旧项目平台直接当成新项目默认。
承接前两项,把局部语法或工具放回应用数据流。阅读时沿输入、协议、状态、输出和证据追踪,避免只背API名称。
把"换数据结构降复杂度"用代码固化下来,下面两种视角对比同一段逻辑:
allowed = {"ready", "running", "done"}
def valid(states):
return [state for state in states if state in allowed]list in O(n) —— 逐个比较,n 个元素最坏 n 次
set in O(1) —— 哈希表查找,平均常数时间
建集合本身 O(m) —— 一次性成本,查询次数多才值得
换结构前提 查询次数 >> m —— 否则建集合本身更慢机制一:责任与数据流
用来衡量分支路径数量的↡衡量一段代码中独立执行路径数量的指标,路径越多测试与维护成本越高提示分支测试成本,Big-O描述的↡描述运算量随输入规模增长的趋势,决定优化的理论上限决定规模上限;先删掉重复工作和不必要状态,再决定是否换算法,常数优化不能挽救错误的增长阶。 成员查询从列表换到集合可改变复杂度,collections提供更贴合语义的容器;建立结构本身也有时间和内存成本,应按查询次数衡量。 先写出公共行为和失败类型,再选择语法或工具。这样即使实现从原书工具迁到当前生态,调用者仍能依据相同契约判断结果。
提醒我们:抽象不能消除成本,只会改变成本出现的位置。包装、生成器、构建系统、CI或缓存都必须说明资源、顺序和异常传播。
用↡以独立进程绕过 GIL 并行 CPU 密集任务的方式,进程间靠序列化传递数据解释器并行 CPU 工作,下面两种视角对比同一段并行逻辑:
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(expensive_compute, batches))max_workers=4 进程池大小 —— 不超过 CPU 核数
pool.map 分发任务 —— 每批独立进程执行,绕过 GIL
传输成本 序列化/反序列化 —— 大对象往返开销大
启动成本 进程创建 —— 任务太轻量时启动费 > 计算费先删掉重复工作和不必要状态,再决定是否换算法。常数优化不能挽救错误增长阶。
机制二:失败与边界
批处理、连接复用和请求合并可降低往返,但会增加延迟、内存与部分失败复杂度;批大小和重试必须有上限。 线程适合等待型任务,多进程隔离解释器并行CPU工作;传输、序列化、启动、取消和汇总成本决定实际收益。 边界实验至少包含正常、空输入、上限附近、依赖失败和重复执行。涉及网络、并发或外部制品时,再加入超时、取消、部分完成与摘要校验。
历史工具不等于无价值。正确迁移是先提取声明式配置、隔离、持续反馈和可回滚发布等不变量,再用维护中的接口重写;错误做法是机械替换命令却保留隐式环境和不可追踪副作用。
缓存的↡决定缓存命中与否的输入指纹,必须包含影响结果的全部输入须含影响结果的全部输入,下面两种视角对比同一段缓存逻辑:
from functools import lru_cache
@lru_cache(maxsize=512)
def price(product_id, policy_version):
return load_and_compute(product_id, policy_version)product_id 键之一 —— 必须含影响结果的输入
policy_version 键之二 —— 策略变更时旧缓存自动失效
maxsize=512 容量上限 —— 满后 LRU 淘汰最久未用
缓存命中 无需重新计算 —— 命中率低则缓存无意义机制三:证据闭环
负责收束验收。确定性、非确定性和主动缓存需要不同失效规则;键必须包含影响结果的输入,作为衡量缓存是否值得保留的↡命中次数占总查询次数的比例,是判断缓存有无收益的硬指标、陈旧窗口和击穿保护都要测量。 保存解释器、依赖锁定、输入、命令、退出状态、关键输出和制品摘要,才能让另一台干净机器重放同一结论。
实战验收清单
- 在隔离环境运行三段示例,记录解释器实现、版本和依赖来源。
- 为核心行为增加正常、空输入、失败和重复执行测试,先看到失败再修改实现。
- 清理缓存和临时文件后重跑,证明结果不依赖工作区残留。
- 对历史命令写出当前替代路径,并说明保留的架构不变量与不再采用的安全默认。
迁移决策题
设想团队正在维护一个已经运行多年的Python服务:它仍依赖本章对应的历史工具,但业务不能停机。先不要直接重写。第一步列出复杂度与简化承担的真实输入和输出,再用集合与数据结构识别构建或运行时依赖;第二步把减少外部调用放进隔离实验,证明当前行为与失败类型;第三步用线程与多进程设计兼容层,让旧入口和新入口在同一组契约测试下运行;最后以缓存策略保存制品摘要、性能或行为差异与回滚条件。只有新路径在正常、边界和故障输入上都达到既定条件,才逐步切换流量或调用者。这样迁移的是可验证契约,而不是把一个旧命令盲目替换为一个新命令。
常见误区
误区 1
现象 → 换了集合后性能反而下降 原因 → 查询次数少,建集合本身 O(m) 的成本超过省下的查询时间 修法 → 按查询次数衡量:查询远多于元素数时才值得换结构
误区 2
现象 → 多进程并行后比串行还慢 原因 → 任务太轻量,进程创建与序列化开销大于计算节省 修法 → 增大单任务粒度,或先测启动+传输成本再决定是否并行
误区 3
现象 → 缓存加上了但命中率接近 0 原因 → 键设计不含唯一输入,或参数空间过大每次都 miss 修法 → 键含全部影响结果的输入,测量命中率,命中率低则移除缓存
误区 4
现象 → 缓存命中旧数据,策略改了仍返回旧价格
原因 → 键不含 policy_version,策略变更未让缓存失效
修法 → 将版本号纳入缓存键,版本变更自动淘汰旧条目
本章回顾
本章逐项覆盖复杂度与简化、集合与数据结构、减少外部调用、线程与多进程、缓存策略。方法是用可读接口表达责任,用边界测试证明失败语义,再以可重建环境和制品证据完成闭环。
小结
- 先删重复工作和不必要状态,再决定是否换算法
- 列表换集合降复杂度,查询次数多才值得建结构
- 线程等 I/O,多进程并行 CPU,传输与启动成本决定实际收益
- 缓存键含全部影响结果的输入,测量命中率而非凭感觉
- 确定性、非确定性与主动缓存需不同失效规则
练习与验收
练习
问题 1: 什么条件下把 list 换成 set 做成员查询才真正变快?
问题 2: lru_cache 的键为什么必须包含 policy_version?
问题 3: 为一个"批量计算产品价格"函数设计优化方案:先用 cProfile 定位瓶颈,再决定换数据结构还是并行,最后用 lru_cache 缓存,要求键设计正确并测量命中率。(独立实现)
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 圈复杂度
一段代码里有多少条可能走的独立路径。路径越多,要测的分支就越多,改起来越容易漏。
- 增长阶
输入翻倍时工作量是跟着翻倍、平方翻还是更猛。它决定优化的天花板,常数优化救不回错误的增长阶。
- 多进程隔离
开几个独立进程同时算 CPU 活儿,绕开 GIL 的限制;进程之间靠序列化传数据,所以大对象来回开销不小。
- 缓存键
用来认出"这条结果之前算过没"的输入指纹。键里漏掉任何一个会影响结果的输入,就会把旧答案当成新答案返回。
- 缓存命中率
一百次查询里有多少次直接从缓存拿到结果。命中率接近零,说明缓存没帮上忙,该撤掉而不是继续占内存。