
重构代码前,如何判断问题出在哪里
代码看起来难读、某个接口偶尔变慢,或者修完一个问题又冒出两个问题,这些都可能让人想到“该重构了”。但重构不是诊断方法。若没有先弄清症状来自哪里,改动可能只是换一种写法,甚至把原有行为、性能或安全边界一并改坏。
动手之前,先把“这段代码不好”拆成可验证的问题:它是否产生了错误结果?是否超出性能预算?是否难以测试或修改?问题只在特定输入、特定环境或高并发时出现吗?不同答案会导向不同处理方式。本文以 Python 标准库为例,演示如何收集证据、判断边界,再决定是否重构。
一、先区分症状,不要把所有问题都叫作“代码质量”
“需要重构”通常不是原始症状,而是一个待验证的结论。常见问题可以先分成几类:
- 行为不正确:返回值、状态变化或副作用不符合约定。优先确认需求与输入,再用测试复现;此时直接改结构未必有帮助。
- 性能不达标:响应时间、吞吐量、CPU、内存或数据库调用超出预算。先确定慢在哪里、在哪类负载下慢。
- 修改风险高:改一个规则会影响多个模块,边界不清,测试难以覆盖。可能需要整理职责、依赖或数据流。
- 运行环境或配置问题:问题只出现在生产环境、某个版本、特定配置或某类机器上。应先核对环境差异,避免把配置故障误判为代码结构问题。
- 安全或可靠性问题:输入未经验证、权限判断散落、异常被吞掉等。这类问题需要按安全边界分析,不能只凭代码“看起来更整洁”来判断是否修好。
一个实用原则是:先写出可观察的事实,再提出原因假设。例如,“导入 10 万条记录时耗时从 2 秒升到 40 秒”是事实;“循环写得不好”是解释,仍需要验证。记录运行版本、输入规模、配置和测量方式,能减少后来反复猜测。
二、先定义问题和边界
在查看具体函数前,先回答几个问题:
- 期望行为是什么?是否有文档、协议、历史行为或兼容性要求?
- 失败如何表现?错误返回、异常、超时、资源耗尽,还是结果不稳定?
- 触发条件是什么?输入大小、数据分布、并发数、调用频率、部署环境是否相关?
- 问题发生在哪个范围?单个函数、一次请求、数据库、网络、队列,还是多个服务之间?
- 改动的允许范围是什么?是否要求保持公开接口、输出顺序、异常类型或副作用?
边界尤其重要。一个函数的调用者可能依赖它未写进文档的行为,例如对重复项的处理、空值的含义、结果顺序、异常时是否留下部分写入。重构前不一定要把所有历史行为永久保留,但要先识别它们,再由负责人决定哪些是契约、哪些是需要修正的缺陷。
当问题无法稳定复现时,不要急着大规模改写。先缩小触发范围,保留必要的诊断信息,并确认是否存在竞态、缓存、时钟、网络或外部服务因素。诊断日志应记录足以定位的信息,但不要记录密码、访问令牌、完整个人数据或其他不必要的敏感内容。
三、先固定行为:用测试划定“不能意外改变”的部分
测试不只是证明新代码通过,也是在重构前记录现状。为已确认的行为写测试,覆盖正常输入、边界输入和重要失败路径。对于尚不确定的行为,先标记为待确认,不要把当前偶然结果直接当成产品约定。
下面是一个可直接运行的 Python 示例。原实现对每个不同单词都重新扫描列表,输入较大时会很慢;测试同时固定了大小写、空白、空输入和结果内容等语义。
def count_words_slow(text):
words = text.lower().split()
result = {}
for word in words:
if word not in result:
result[word] = words.count(word)
return result
def test_count_words():
assert count_words_slow("Red blue red") == {
"red": 2,
"blue": 1,
}
assert count_words_slow(" ") == {}
assert count_words_slow("A a B") == {"a": 2, "b": 1}
if __name__ == "__main__":
test_count_words()
print("tests passed")
将代码保存为 diagnose.py,运行 python diagnose.py。示例中 split() 会按连续空白切分,lower() 会按 Python 的 Unicode 小写规则转换。若实际系统采用不同的分词、语言或大小写规则,优化时必须沿用既有契约,而不能从这个例子推断所有文本处理都应这样做。
如果原代码无法单独测试,先考虑增加一个小的测试入口,或把输入、输出边界抽出来。不要为了“可测试”一次性重写整个模块;改动越大,越难判断回归来自哪里。
四、性能问题:先测量,再定位热点
1. 明确测量对象
“感觉变慢”不足以指导优化。先说明测量的是函数耗时、端到端响应时间、吞吐量、内存占用还是外部调用次数。对线上问题,还应区分平均值与尾部延迟;平均耗时正常,不代表少数请求没有明显超时。
测量时使用接近真实的数据规模和分布。极小输入可能看不出算法差异;只用均匀数据也可能漏掉热点键、长文本或异常输入。记录重复次数,并在同一机器、同一版本和相近负载下比较。微基准适合回答局部问题,不等于真实系统的端到端表现。
2. 用 timeit 比较明确的局部操作
在文件末尾添加下面代码,执行 python diagnose.py,可以比较旧实现和基于字典计数的实现:
from collections import Counter
import timeit
def count_words_fast(text):
words = text.lower().split()
return dict(Counter(words))
def benchmark():
text = ("red blue green red " * 2000).strip()
for func in (count_words_slow, count_words_fast):
seconds = timeit.timeit(
lambda: func(text),
number=20,
)
print(f"{func.__name__}: {seconds:.4f} seconds")
if __name__ == "__main__":
test_count_words()
benchmark()
这里的 number=20 表示重复执行 20 次,并非适用于所有机器或所有函数的推荐值。执行次数太少,测量容易受调度和后台负载影响;太多则会延长运行时间。函数很快时,可以增加重复次数或使用 timeit 的默认校准方式。比较前应确认两种实现得到相同结果,避免把少做了工作误认为优化。
timeit 适合相对隔离的代码段。它不会自动复现真实并发、网络抖动、数据库缓存、进程启动成本或线上数据分布;计时结果也不应被当作跨机器的绝对性能承诺。若性能问题只在请求链路出现,应同时测量链路各阶段,而不是只测一个脱离上下文的函数。
3. 用 cProfile 找“谁占时间”
当不清楚耗时落在哪些函数时,可用 Python 自带的确定性分析器:
python -m cProfile -s cumulative diagnose.py
输出中的累计耗时可帮助发现某个函数及其调用子树占用了多少时间。也可以在运行参数足够明确的程序入口上使用:
python -m cProfile -s cumulative your_program.py --input sample.txt
将 your_program.py 和参数替换为实际程序。若程序会执行破坏性操作、发送真实消息或写入生产数据,不要为了分析而直接运行生产命令;应使用隔离环境和可控样本。分析器本身会带来开销,可能改变耗时比例,因此适合找候选热点,不适合把其测得的数字直接当作真实请求延迟。
不要只盯着“调用次数最多”的函数。高频小函数不一定是瓶颈;低频数据库查询或锁等待也可能主导响应时间。对包含网络、磁盘或数据库等待的程序,还应结合应用指标、链路追踪和依赖侧指标判断。
4. 内存问题用 tracemalloc 观察分配
若症状是进程内存持续增长,可以用 tracemalloc 查看 Python 跟踪到的内存分配差异:
import tracemalloc
def inspect_allocations():
tracemalloc.start()
before = tracemalloc.take_snapshot()
data = [f"item-{i}" for i in range(100_000)]
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:10]:
print(stat)
del data
tracemalloc.stop()
if __name__ == "__main__":
inspect_allocations()
将这段代码单独保存并运行,或在原文件中保留一个明确的入口。快照比较的是 tracemalloc 能追踪到的 Python 内存分配,不等于进程全部常驻内存:原生扩展、解释器和其他非 Python 分配可能不完整反映在结果中。快照本身也会占用资源,所以大程序应在受控环境中采样,不要在高负载生产进程里随意开启高成本诊断。
五、复杂度和代码结构:把疑点变成可检查的假设
性能画像指出热点后,再检查算法和数据结构。示例中的 words.count(word) 每次都遍历整个列表;若有 n 个单词,并且多数都不同,重复扫描的工作量可随 n² 增长。字典或计数器通常能把计数过程降为期望线性时间,但哈希操作仍有成本,也需要额外内存。对于小输入,优化未必有可见收益。
复杂度判断应结合数据规模,而不是凭“循环嵌套看起来不好”下结论。嵌套循环不必然是瓶颈:外层可能只有固定的少量元素;看似简单的一次查询也可能触发磁盘或网络访问。代码审查可以指出风险,性能测量则用来确认风险是否影响实际目标。
结构问题也可以用具体信号描述:一个函数同时解析输入、做权限判断、访问数据库和拼接响应;同一业务规则在多个模块复制;测试必须启动整个应用才能验证纯计算逻辑。这些信号提示依赖边界或职责可能不清,但是否拆分仍取决于变化频率、调用关系和维护成本。为了拆而拆会增加跳转和抽象层,未必更易维护。
六、从诊断结果决定是否重构
可以用以下顺序做决策:
- 行为错误且可复现:先确认预期,再修正逻辑并补测试。若错误由职责混杂或规则重复造成,可把重构作为修复的一部分,但要能区分结构变化和行为变化。
- 性能超出明确目标:用剖析定位热点,检查算法、I/O、锁和数据规模。优先改对瓶颈有证据支持的部分,并用同一负载复测。
- 变更困难或风险高:找出频繁变化的规则、隐含依赖和难以隔离测试的边界。从最小可验证的模块开始整理,不必一次重写整个系统。
- 环境相关或证据不足:补充观测、核对配置和依赖版本,先做小实验。此时大范围重构通常会扩大变量数量。
- 安全缺陷:先按威胁模型修补和验证安全边界,再考虑结构整理。重构不能替代权限检查、输入验证、输出编码或密钥管理。
重构前后的比较应采用相同测试和相近负载。除了结果,还要观察副作用:数据库写入次数、请求数量、异常行为、日志内容、顺序保证和资源释放情况。若变更涉及并发或事务,仅有单元测试可能不够,应补充集成或并发场景验证。
七、常见错误与边界提醒
错误一:看见长函数就立刻拆分
函数长度是线索,不是故障证据。盲目拆分可能把共享状态和控制流散落到许多小函数中,反而增加理解成本。先判断它是否有多个独立变化原因、是否难以测试,拆分后能否形成清晰接口。
错误二:只测一次,或只比较最小输入
一次计时容易受到系统调度、缓存和后台任务影响。微小输入也会掩盖复杂度差异。至少重复测量,使用有代表性的数据,并关注结果波动。性能目标若来自线上,应优先使用脱敏、可控且符合分布的样本。
错误三:为了通过测试而改变契约
测试写错、需求理解错,都会让错误行为被固定下来。测试应来自明确的业务规则或兼容要求。遇到顺序、时区、舍入、Unicode、异常类型等边界时,先核对契约,再决定实现是否应该保留当前行为。
错误四:把工具输出当作完整真相
Profiler 可能改变运行开销;内存快照有追踪范围限制;局部基准无法代表端到端负载。工具给出的是特定观测视角,多个信号要结合起来解释。不要因为报告中某行排名靠前,就立刻重写它。
错误五:把重构当作安全修复
代码更整齐不代表更安全。用户输入仍需按具体用途验证和处理;数据库查询应使用参数化接口,输出编码应按输出上下文选择,权限判断应放在可信边界。示例中的 timeit、cProfile 和 tracemalloc 是诊断工具,不提供安全防护,也不会自动保护敏感数据。
错误六:把诊断参数误当成普遍限制
本文的计时重复次数、样本数量和剖析命令都是示例,不是 Python 的通用上限或生产配置建议。实际限制取决于运行时间、内存、并发、部署环境和数据敏感性。运行分析前评估资源影响;没有证据支持时,不要为了得到“漂亮数字”而设置不切实际的输入规模或负载。
八、一个可执行的重构前检查清单
- 我能用一句话描述实际症状,并给出复现步骤吗?
- 预期行为和兼容边界是否明确?重要行为是否有测试覆盖?
- 问题与输入规模、数据分布、环境或并发之间有什么关系?
- 性能目标是什么,测量口径、数据集和负载是否具有代表性?
- 是否使用合适工具定位,而不是只根据代码外观猜测?
- 改动是否会影响顺序、异常、副作用、权限或资源释放?
- 能否用相同测试和测量方式验证改动有效,且没有引入回归?
重构前的诊断并不要求把所有问题都量化到小数点后几位。它要求的是把猜测与证据分开,明确行为边界,并选择足以验证假设的方法。若问题是算法热点,就优化热点;若问题是职责混杂,就整理边界;若原因还不清楚,就先增加观测。小步改动、及时验证,通常比一次性重写更容易控制风险,也更容易知道究竟是什么改动解决了问题。









