写了5年Python才沉淀的7条工程习惯,从调包侠到扛住线上事故
发布时间:2026/10/24 5:50:47 锦皓数字建站

写了 5 年 Python从一个只会写脚本的“调包侠”到能在生产环境里扛住流量、排查线上事故的工程师中间真的踩过不少坑。有些坑是文档里查不到的有些坑只有你被 on-call 电话叫醒、盯着日志翻到天亮才会真正记住。今天不分享框架源码也不讲算法优化就聊聊我这 5 年下来最终留下的 7 条 Python 工程习惯。这些习惯不是从哪本书里抄的而是被线上事故、代码评审、团队协作逼出来的。如果你正在写 Python或者你团队里的 Python 代码总出幺蛾子这篇内容应该能给你一些参考。1. 先定牢“类型边界”再谈代码复用1.1 从一次接口事故说起先讲个真实经历。早年在某公司维护一个支付回调模块上游传来一个 JSON里面有个amount字段。当时图省事代码里直接if data[amount] 100判断也没做类型转换。某天上游改了字段格式把数字字符串100.00传了进来比较结果完全正常但后面做金额计算时字符串和浮点数混在一起出现了小数点精度问题直接导致一笔订单对账差了 1 分钱。排查了整整两个晚上最后发现是类型边界没管好。这件事给我的教训是Python 是动态语言但不代表你可以在工程代码里“放飞自我”。动态类型给你灵活也把责任推给了调用方和未来的维护者。你写函数时自己知道参数是什么类型但三个月后的你跟现在的你完全是两个人更别说团队里还有其他人。1.2 我最终留下的类型标注习惯我现在写任何新模块都会遵循这几条所有函数参数和返回值都加类型标注type hints即使只是def foo(x: int) - str这样的基础标注。数据类用dataclass定义而不是只靠 dict 传递结构。这样字段名、默认值、类型都一目了然也方便 IDE 补全和静态检查。涉及外部接口的数据结构用pydantic或dataclass做一层显式校验和转换绝不让“裸 dict”穿过服务边界。在 CI 里跑mypy把它当成强制检查项而不是可选的锦上添花。有人说 Python 加类型标注麻烦但我算过一笔账写标注的时间大概是写函数体时间的五分之一但能省掉排查类型 bug 的时间可能是十倍。尤其是服务多了之后调用链一长类型标注就是一张隐性的接口文档。1.3 动态语言的边界在哪里当然不是所有代码都要上类型。临时脚本、一次性数据处理我从来不用mypy因为不值得。我的判断标准是这段代码的生命周期是否超过一周是否需要被其他人调用是否要跟外部系统交互如果三个问题里有一个“是”就值得加类型标注和校验。另外还要注意类型标注不等于运行时校验。def foo(x: int)并不会在传字符串时自动报错。如果你需要严格校验必须靠pydantic或者在函数入口手动isinstance。我之前就用pydantic统一管理所有 HTTP 请求和响应的模型既做了类型约束又自动生成了文档还顺便处理了反序列化一举三得。提示如果你还在用裸 dict 传递业务数据趁早换成 dataclass 或 pydantic。这个习惯越早养成后面重构的痛越少。2. 配置不进代码环境与依赖严格分离2.1 那段被配置文件支配的岁月我见过最离谱的代码是把数据库密码直接写在 Python 文件里硬编码成全局变量然后整个团队共享。更离谱的是这个代码还被提交到了代码仓库最后因为一个内部工具扫描泄露了生产环境的连接信息不得不紧急改密码、重建实例。那是我第一次意识到配置管理不是“以后再说”的事而是工程底线。后来我接手过一套老系统配置全部堆在一个settings.py里里面写了开发、测试、预发、生产四套配置通过一个ENV环境变量来切换。看着好像没问题但每次上线都心惊胆战因为只要有人改了一行可能就会影响所有环境。某次预发环境连了生产库还跑了一次数据迁移差点酿成大事故。2.2 环境管理与依赖锁定的利器现在我的习惯是代码库里不出现任何环境相关的配置值只出现配置引用。环境变量来了填进去配置中心来了从远端拉代码本身永远不知道自己是跑在哪套环境。具体做法分三层12-factor 风格配置所有配置项从环境变量读取代码里只有os.getenv(DB_HOST)这类调用。本地开发用.env文件统一管理用python-dotenv加载但这个文件不提交到 git。依赖环境也要隔离用venv或poetry管理虚拟环境用requirements.txt或poetry.lock锁定精确版本。所有新依赖必须通过锁文件变更不能直接 pip install 到全局。我偏好poetry因为它把依赖管理、虚拟环境、打包发布统一起来了pyproject.toml 里直接写清依赖范围和 Python 版本比纯 requirements 文件更清晰。团队新人来了一条命令poetry install就能把环境拉起来省得互相问“你装的什么版本”。2.3 环境变量、配置中心怎么选小团队、单体服务环境变量完全够用。但服务多了以后环境变量管理会变得混乱——几十个服务每套环境几十个变量靠人工维护很容易出错。这时候就需要配置中心了比如 etcd、consul、或云厂商的配置管理服务。我的选择逻辑很简单服务数少于 5 个环境变量 .env 文件够用。服务数多于 5 个但配置量不大用配置中心拉取启动时拿不到配置就直接失败避免带病启动。配置变更频繁需要动态刷新选带推送能力的配置中心而不是重启服务。配置中心虽然好用但也会引入新依赖。我们在某个项目里跑了 consul但忘了处理它挂掉的情况结果配置拉不到服务全部起不来。后来加了本地缓存作为兜底即使配置中心挂了也用上一次的缓存启动再配合告警提醒人工介入。注意代码里尽量不要用try/except吞掉配置加载异常。配置缺失时要及时暴露出来最好在启动阶段就 fail fast否则线上服务可能半死不活看似在运行实际功能全坏。3. 日志不是打出来就完事要能“捞得回来”3.1 线上排障时日志的救命场景有一回线上告警说某个接口成功率骤降。我打开日志平台输入服务名和关键字结果刷出来的全是格式不一的文本有的行是print打出来的有的行是logger.info写的还有的日志里根本没带请求 ID完全没法把一次用户请求的完整链路串起来。最后只能靠猜先看 Nginx 日志找到出错的请求再根据时间戳去翻应用日志效率极低。那次之后我才明白日志不是“有就行”而是要有“可查询性”。所谓可查询性就是你能在短时间内通过一组固定维度把某一次请求的所有相关日志全部捞出来。3.2 结构化日志与日志追踪ID我现在打日志只用一个标准结构化 JSON 日志。每一条日志包含时间、级别、logger 名称、消息、上下文数据用 json.dumps 统一序列化或者直接用python-json-logger这类库。为什么用 JSON因为日志系统里可以按字段索引不用写正则去抠文本。最关键的是追踪 ID。每个请求入口生成一个trace_id或者叫 request_id通过中间件注入到 context 里然后所有业务日志都带着这个 ID。这样排查问题时只需要按 trace_id 搜索就能把一次请求的访问日志、业务日志、异常堆栈全部串在一起。Python 里实现这个有很多方式老项目我会用contextvars加一个日志过滤器在日志格式化时自动把 trace_id 塞进记录里业务代码根本不用手动传参。新项目直接上 OpenTelemetry 这类链路追踪框架一步到位。3.3 日志级别的工程约定级别这件事团队里必须有共同语言。我踩过最典型的一个坑是有人把重要业务操作放在logger.debug结果生产环境日志级别是 INFO关键信息全丢了也有人用logger.error打普通业务流程导致告警刷屏真正的故障被淹没。我现在定的约定是DEBUG开发调试线上默认关闭。INFO记录业务节点的关键事件比如下单、支付、状态变更带有可追踪的 trace_id。WARNING可能有风险但不影响主流程比如重试、降级、稍后处理。ERROR发生了会影响单次请求或功能的问题需要记录异常堆栈和上下文数据。CRITICAL系统级故障必须触发立即告警。这条清单我写了放在团队 wiki 里新人来了先读一遍。告警规则只匹配 WARNING 和 ERROR且必须有对应的 trace_id否则视为无效日志。心得日志里不要搜集身份证号、密码、token 这类敏感信息但可以把脱敏后的值放进去。我见过有人把完整手机号打进日志最后数据库被脱敏日志却没脱敏成了新的泄露出口。4. 单元测试是给未来合作者的“安全网”4.1 为什么写测试是对别人的负责很多人觉得“这段代码我自己跑通了不用写测试”但工程代码的目标不是“在我电脑上能跑”而是“在任何环境里、任何时间、任何人都能安全地改动”。有测试兜底你才敢重构没测试兜底每一次改动都是高空踩钢丝。我印象最深的一次某同事改了一个工具函数把默认参数从None改成了空列表结果下层模块直接复用了这个函数导致所有请求共享同一个列表内存越积越多最后服务 OOM。如果那个工具函数有一个简单的单元测试测试里多传一个值再断言两次调用互不影响这种问题根本走不到线上。4.2 测试金字塔与常用工具我现在写测试遵循金字塔原则底层单元测试最多覆盖纯函数和业务逻辑中间层集成测试验证模块间的交互顶层端到端测试只覆盖关键核心链路数量很少。Python 里我最常用的组合是pytest功能强大、fixture 灵活能跑参数化用例。pytest-cov统计覆盖率但我不追求 100%只要求核心模块 90% 以上。responses或respxmock HTTP 请求测服务调用。factory-boy生成测试数据比手写 fixture 清晰。fixture 的使用有个要点不要把 fixture 写成“一个函数造一个对象”而是尽量小而可组合。比如我定义一个userfixture一个orderfixtureorder 依赖 user。这样测试用例里可以自由组合不用的数据不会白造跑起来也快。4.3 如何让测试不拖累开发速度很多人拒绝写测试是觉得“写测试浪费时间”。但我发现只要把测试放进常规开发流程反而能省时间。我的做法是写完一个功能先写这个功能的最小测试用例然后跑一遍确认测试能不能抓住 bug。不需要一次写全先把主干覆盖了后面再迭代补充边界测试。测试能跑多快单元测试应该控制在几秒以内。如果测试超过几十秒团队就会不喜欢跑最终沦为“没人跑的摆设”。我有个原则所有单元测试全部内存化用 SQLite 或 mock 替代外部服务不依赖真实数据库和网络。集成测试单独用一个 marker 标记只在 CI 的特定 job 里运行。这样可以保证日常开发pytest -m unit在一分钟内跑完。CI 里面再加一道强制门槛覆盖率下降不能超过 1%新增代码的行覆盖率要高于 80%。一开始会觉得苛刻但跑一段时间后团队写代码时就会本能地思考“这段逻辑怎么测”反而促使他们写得更容易测试。提醒mock 要节制。过度 mock 会让测试变成“自嗨”根本测不到真实逻辑。比如把一个外部 HTTP 调用 mock 成返回值却忘了真实接口是异步还是同步这种测试毫无意义。宁可少写几个伪测试也要保证每个测试真的能代表一段真实行为。5. 异常与错误处理让失败也成为接口的一部分5.1 裸except的惨痛教训Python 的except特别容易滥用。我见过无数代码try: do_something() except: pass这个写法相当于告诉计算机“不管出了什么错你都别吭声。”结果就是错误被吞掉服务继续跑但状态已经错了。我处理过最惨的一个 case某定时任务里裸捕获了所有异常导致数据库写入失败也没人发现连续跑了三天产生了大量脏数据最后只能人工清洗。所以我的第一条铁律是永远不要用裸except。至少要写except Exception因为except:连KeyboardInterrupt和SystemExit也会捕获容易让程序无法退出。更合理的是按具体异常类型捕获比如except ValueError、except KeyError让错误处理有针对性。5.2 自定义异常体系与错误码为什么需要自定义异常因为内置异常只能表达“什么类型错了”不能表达“业务哪里错了”。我给你看一个反例某个服务里到处raise ValueError(用户不存在)然后调用方统一except ValueError结果参数校验错误也被当成用户不存在处理返回给前端的是完全错误的信息。现在我习惯为每个模块定义异常基类比如class ServiceError(Exception): 业务服务基础异常 class UserNotFoundError(ServiceError): code USER_NOT_FOUND message 用户不存在 class PaymentFailedError(ServiceError): code PAYMENT_FAILED message 支付失败上层捕获ServiceError然后根据code映射 HTTP 状态码和错误提示。不同模块的异常互相隔离即使同名也不会混淆。同时针对网络调用、数据库操作这类可能临时失败的场景我会定义重试策略。常用的库有tenacity可以配置重试次数、间隔、哪些异常可重试。重试只针对瞬时故障比如连接超时、数据库锁冲突业务逻辑错误比如余额不足不能重试否则会造成重复扣款。5.3 防御式编程与Fail Fast防御式编程不是到处加if判断而是在“边界入口”上做防御。我的习惯是外部输入、配置值、依赖服务的返回值这三个地方必须做校验。内部函数之间相信类型标注和测试不做多余检查。校验不通过时越早暴露越好。我宁可启动时因为配置为空而崩溃也不想让服务跑着跑着才在某个角落报错。这就是 fail fast 的原则错误发生的位置越接近发生点排查成本越低。再分享一个细节不要用assert做数据校验。Python 的assert在运行python -O时会被禁用线上环境如果开启了优化所有断言都会失效。我在老项目里见过一堆assert校验前端传参结果某人加了优化选项发布线上直接失去了校验能力传了非法参数也没人拦。所以只有开发者自测时可以用 assert对外输入校验必须用显式的 if raise。6. 函数式抽象与依赖注入解耦的思维练习6.1 全局变量和隐式依赖的坏味道我见过最让人头疼的代码是函数内部直接引用全局变量比如config load_config() def process(): timezone config[timezone] return ...如果哪天config变了或者没有全局定义这个函数就会莫名其妙地失败。更麻烦的是测试要为这个函数准备环境得先设置全局变量但pytest里全局变量的作用域很容易互相污染一组用例跑完另一组拿到的是残留状态。我后来学会一个判断标准如果函数离开“全局环境”就跑不起来说明它有隐式依赖。隐式依赖越多代码就越难理解、越难测试、越难复用。6.2 依赖注入的轻量实现依赖注入听起来很高大上但 Python 里根本不需要框架。最简单的做法就是通过函数参数传依赖def fetch_user(user_id: int, db: Database) - User: ...测试的时候传一个 mock 的db就行。不需要 singleton不需要 global不需要魔法。这个简单习惯能解决 90% 的耦合问题。有人会说“每个调用点都要传 db太麻烦。”我的回应是麻烦一点换来的是清晰的调用链和可测试性。如果参数传递过多可以用一个Context对象把这些依赖打包但不建议用一个万能参数到处传。对于类对象我通常使用构造函数注入class OrderService: def __init__(self, payment_client: PaymentClient, db: Database): self.payment_client payment_client self.db db这样写的好处是每个类的依赖都在构造签名里清清楚楚团队里做代码 review 时一眼就能看出这个类依赖了哪些外部服务方便评估风险和替换 mock。6.3 抽象接口与实现分离在 Python 里抽象有两种方式一种是写 ABC抽象基类一种是直接用“鸭子类型”只要实现了同名方法就可以。对于大型业务模块我倾向于定义接口协议用typing.Protocol来表明“这个模块需要什么样的依赖”而不是强制继承 ABC。举个实际例子某项目里有邮件发送服务最开始直接用了某个第三方 SDK 的MailClient到处from mail_sdk import MailClient。后来要换供应商所有引用点都要改。我重构时定义了一个协议class MailSender(Protocol): def send(self, to: str, content: str) - bool: ...然后将第三方 SDK 封装在一个 adapter 类里业务模块只依赖MailSender。换供应商时只需要新增一个 adapter业务代码零改动。这样做的另一层好处是测试时只需要 mock 一个符合MailSender协议的对象不需要 mock 任何第三方 SDK 的内部结构。抽象隔离了变化也隔离了外部库对我们代码的污染。心得不要一开始就抽象过度设计比重复代码更讨厌。我通常是在“第二次要用相同逻辑”时才开始提取接口。第一次用完就抽象往往假设错了方向反而让结构更乱。7. 代码审查与自动化把习惯固化成团队纪律7.1 代码审查不是找茬写好个人代码是一回事团队协作又是另一回事。我越来越发现代码审查Code Review不是为了揪出谁的 bug也不是为了证明谁更厉害而是为了“分享上下文”。我在 review 里最想看到的是这段代码为什么这么写有没有考虑边界评论区里能提出问题也能记录决定。我给自己定了一个 review 清单每次看别人代码都过一遍有没有裸except、空分支、被静默吞掉的异常有没有配置硬编码在代码里有没有全局可变状态在模块之间被共享有没有函数做了两件以上的事新增的依赖能不能避免有没有更轻量的方案日志有没有带 trace_id字段结构是否统一测试是否覆盖了成功路径和至少一种失败路径这个清单不在工具里而在脑子里。每次 review 都对着过一遍既快又稳。时间久了写代码的人也会自动先照这个清单自查。7.2 用自动化工具减少口舌之争Review 里最浪费时间的是“格式问题”和“命名问题”。比如有人用单引号、有人用双引号有人 tab 缩进、有人空格缩进为了这些吵架毫无价值。所以我的 CI 里直接集成了一整套自动化检查black统一代码格式拒绝手工调格式。isort统一 import 顺序。flake8或ruff检查未使用的变量、重复定义、潜在语法错误。mypy静态类型检查。pre-commit在本地 commit 之前就跑一遍上述工具。用pre-commit的最大好处是在代码进入仓库之前就把低级问题拦住了。CI 里的检查成了一个兜底真正需要人看的只有逻辑问题。配置pre-commit其实不复杂新建一个.pre-commit-config.yaml把black、isort、ruff加进去然后pre-commit install即可。团队统一配置谁机器上跑不过谁就不能提交。刚开始大家会抱怨“麻烦”但跑一周之后没人再愿意手工调格式了。7.3 提交信息与分支规范提交信息看似无关紧要但在回滚和追溯时特别重要。我坚持用 Conventional Commits 风格feat: 增加订单导出功能 fix: 修复金额精度丢失问题 chore: 更新依赖版本 refactor: 重构支付模块的异常处理 ^这个格式让git log变得可读也有人会用 changelog 自动生成版本记录。但更重要的是分支命名、提交信息要和任务系统关联起来。比如分支用feature/order_export提交信息里带#123这样的任务编号这样以后查一个问题能直接从 git 记录跳到任务上下文甚至翻到当初的讨论记录。还有一条铁律绝不直接向 master/main 推送代码所有变更都必须通过 Pull Request/Merge Request。这条规则的背后不是流程洁癖而是为了强制代码经过至少一次 review 和 CI 检查。哪怕是你自己的个人项目我也建议走一遍这个流程——至少能让你自己回看一遍。经验代码审查的回复里尽量用“建议”“可以考虑”“我担心”这样的语气而不是“你错了”“必须改”。工程协作本质上是在管理风险不是在争输赢。好的 review 文化决定了一个团队能走多远。7 条习惯讲完了都是我这几年流水线里真刀真枪趟出来的。写 Python 容易但把 Python 写出工程感靠的不是某一个炫技语法而是这些看似琐碎的日常约束。我个人最深的体会是这些习惯不是枷锁而是保护网。有了类型边界、结构化日志、单测兜底和自动检查我反而敢大胆重构代码了——因为我知道就算改错了测试和日志也会第一时间告诉我。如果你也刚开始带项目不妨从其中一两条开始推行不用一步到位。少踩一个坑就是赚到。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。