一行 import 的真面目: sys.modules 缓存 → sys.path 搜索 → 字节码编译 → 模块对象绑定 — 模块是天然单例, 导入即执行
import 不是"把代码贴进来", 而是一次运行时查找 + 执行: 先查 sys.modules 缓存, 没有就沿 sys.path 找到源文件, 编译成 .pyc, 然后把模块体当脚本执行一遍, 把造出来的模块对象登记进缓存。所以同一个模块不管被 import 多少次, 顶层代码只跑一次 — 这既是"天然单例"的来源, 也是"导入即执行"一切副作用 (连库/读配置/耗时初始化) 的来源。
生产里 90% 的 import 事故是三件事: 搜索路径决定"到底导入了谁" (影子覆盖/PYTHONPATH 污染), 导入时机决定"什么时候炸" (循环导入/冷启动), 模块单例决定"状态被谁共享" (monkey patch/测试污染)。把这三件事想在前面, import 就从玄学变成可控的依赖装配。
sys.modules.pop(m) 是测试里"强制重新初始化"的手段。 import json import json # 第二次: 只查缓存, 模块体不重跑 json is sys.modules["json"] # 关键: 全进程同一对象 sys.modules.pop("app.cfg", None) # 测试强制"重新初始化"
import sys sys.path # ['/app', '/opt/legacy', '.venv/.../site-packages', ...] # 关键: 顺序即优先级 — [0] 脚本目录, 再 PYTHONPATH, 再 site-packages
b.cpython-312.pyc 存盘, 用源文件 mtime+size 校验失效。它只省编译时间, 不省模块体执行时间。 # 首次: 编译源码 → pkg/__pycache__/b.cpython-312.pyc # 校验: 源文件 mtime + size, 源码变了自动重编 # 关键: 只省编译时间, 模块体每个新进程都照跑一遍
# pkg/__init__.py — 包门面 from .core import Service # 导出公共 API __all__ = ["Service"] # 控制 import * 的导出面 # 关键: import pkg 先执行 __init__.py, 再进子模块
from pkg.mod import x 绝对路径最稳; from . import x 的锚点是 __package__ 而不是 cwd, 只在包内合法 — 直跑脚本时没有包上下文就报错。 from pkg.mod import x # 对: 绝对路径最稳 from . import sibling # 锚点是 __package__, 不是 cwd python pkg/mod.py # 直跑 → ImportError: no known parent package
ImportError: cannot import name。 import json from json import dumps # ① 导入 json ② 取属性绑定 dumps is json.dumps # → True, 同一函数对象 from json import nope # → ImportError: cannot import name
from importlib import import_module try: Handler = import_module(f"jobs.handlers.{name}") except ImportError: Handler = DefaultHandler # 关键: 动态导入必须配兜底
import requests requests.get = fake_get # patch 打在模块对象属性上 # 错时机: 别处早已 from requests import get → 旧引用不改 # 关键: patch 必须发生在任何使用之前 (conftest 最顶层)
def handler(event, ctx): import boto3 # 关键: 冷启动省 1.3s, 首调再付 return boto3.client("s3").list_buckets() # 注意: 别放进每秒百万次调用的热点循环
$ python -X importtime app.py 2> import.log # import time: self [us] | cumulative | imported package # import time: _ 128450 | 1327800 | boto3 ← 累计 1.3s # 关键: 自缩进树直接指出冷启动花钱大户
mod.__file__ 看物理路径, importlib.metadata.version() 看安装版本。 import requests requests.__file__ # 物理路径: 到底导了哪个文件 from importlib.metadata import version version("requests") # → '2.31.0', 安装版本
# 无 __init__.py 的目录 = namespace 包 (PEP 420) # 多个目录可拼出同一个包名: # /opt/a/plugins/x/ + site-packages/plugins/y/ # 关键: 两处都算 plugins.* — "幽灵模块"事故的来源
import importlib, mymod old = mymod.Task() importlib.reload(mymod) # 重跑模块体, 原地更新 isinstance(old, mymod.Task) # 关键: → False, 类身份变了
函数平台按冷启动收体验费, 而 90% 的冷启动就是 import: 用 -X importtime 找出花钱大户, 延迟到真正用它的那条路由里:
# $ python -X importtime app.py 2> import.log ← 分析树走 stderr # import time: self [us] | cumulative | imported package # import time: _ 9157 | 81240 | socket # import time: _ 128450 | 1327800 | boto3 ← 累计 1.3s, 一家占 40% def handler(event, ctx): # 冷启动 = 所有顶层 import 的总价 import boto3 # 惰性导入: 只在真正用到时付这 1.3s s3 = boto3.client("s3") return {"buckets": [b["Name"] for b in s3.list_buckets()["Buckets"]]} # 健康检查/预热路由绝不 import 重库 — 指标采不到"没发生的导入"
改完冷启动 -1.3s; 代价是首次业务请求慢约 50ms — 对低频管理类函数稳赚。
user.py 和 order.py 彼此 import 对方的类型做注解, 某次入口变更后开始偶发 partially initialized module。治本解法是让公共类型下沉, 依赖图恢复成 DAG:
# 重构前: models/user.py: from .order import Order # models/order.py: from .user import User ← 成环 # 重构后: 新建 models/schemas.py, 只放双方都需要的"薄"类型 from dataclasses import dataclass @dataclass(frozen=True) class Ref: id: int kind: "str" # 跨模块只传引用, 不传行为 # user.py / order.py 都只 import schemas → 单向依赖, 环消失 # 验收: python -c "import app.main" 在任意入口下都必须稳定通过
报表导出格式要做插件化: 装了就能用, 没装不报错。用标准库入口点协议注册, 主程序按名动态加载:
# 插件侧声明 (插件的 pyproject.toml, pip 装上即注册): # [project.entry-points."report.exporters"] # csv = "report_csv:export_csv" # json = "report_json:export_json" from importlib.metadata import entry_points def load_exporters(): return {ep.name: ep.load() for ep in entry_points(group="report.exporters")} def export(fmt, rows): try: return load_exporters()[fmt](rows) # 插件缺依赖在这里炸, 拦得住 except KeyError: raise ValueError(f"unknown exporter: {fmt}") from None except Exception as e: # 插件失败不拖垮主流程 log.warning("plugin failed", fmt=fmt, exc_info=True); raise
被测模块在 import 时读环境变量并缓存成单例, 测试之间互相污染。fixture 里把单例丢弃, 让模块体按新环境重跑:
# conftest.py — 模块单例让"改环境再测"变成不可能, 必须能重置 import sys, pytest @pytest.fixture def fresh_settings(monkeypatch): monkeypatch.setenv("APP_ENV", "test") # 先改环境再谈导入 sys.modules.pop("app.settings", None) # 丢弃单例, 下次 import 重跑模块体 import app.settings as s # 用新环境重新初始化 yield s sys.modules.pop("app.settings", None) # 不把状态漏给下一个用例 monkeypatch.undo() # 顺手还原被 patch 的属性
geo 包里 reverse 模块拖着 800ms 的 shapely 链, 但多数调用方只用 distance。门面只导出公共 API, 重子模块延迟到首次点名:
# geo/__init__.py — 公共 API 收口, 重库延迟到首次属性访问 __all__ = ["reverse_geocode", "distance"] def __getattr__(name): # PEP 562 模块级惰性属性 if name == "reverse_geocode": from .reverse import reverse_geocode # 800ms 的链此刻才进内存 return reverse_geocode raise AttributeError(f"module {__name__!r} has no {name!r}") # 调用方无感: from geo import reverse_geocode — 包导入 3ms 不变
import geo 从 823ms → 3ms; 只有真正调用 reverse 的路径付那 800ms。
同一个文件, 直跑报 ImportError, -m 却正常 — 差异全在 sys.path[0] 与包上下文。统一入口 + 护栏提示:
# ImportError: attempted relative import with no known parent package # ✗ python geo/reverse.py → sys.path[0]='geo/', __package__=None, 相对导入无锚点 # ✓ python -m geo.reverse → sys.path[0]=cwd, __package__='geo', 相对导入成立 # 部署脚本/systemd/Dockerfile CMD 统一走 -m; 文件尾加护栏防手滑直跑: if __name__ == "__main__" and __package__ is None: raise SystemExit("run as: python -m geo.reverse")
同一镜像的代码在灰度机行为不同: 新参数直接 TypeError。三行排查确认导的不是同一个包:
# 症状: 灰度机 requests=2.6.0, 其余机器 2.31 — 用到新参数直接 TypeError import requests, os print(requests.__file__) # /opt/legacy/.../requests/__init__.py ← 导的不是 venv 里那个! from importlib.metadata import version print(version("requests")) # 2.6.0 — 真身版本现形 print(os.environ.get("PYTHONPATH")) # /opt/legacy:... ← 污染源 # 修复: 清掉 systemd 单元 Environment=PYTHONPATH; requirements 锁版 + pip check 进 CI
根因: 早年运维往 /etc/profile 写的 PYTHONPATH 让 /opt/legacy 排到了 venv 前面 — 先到先得, 版本漂移。
工具模块从 utils.geo 迁到 geo.core, 三年的二进制存档 unpickle 全部 ModuleNotFoundError。迁移期加一个查找器垫片:
# 旧存档里类的路径是 utils.geo.Point — 模块没了, pickle 按路径找类 import sys, pickle from importlib.abc import MetaPathFinder from importlib.util import find_spec class LegacyAlias(MetaPathFinder): OLD2NEW = {"utils.geo": "geo.core"} # 旧路径 → 新家 def find_spec(self, name, path=None, target=None): if name in self.OLD2NEW: return find_spec(self.OLD2NEW[name]) # 让新模块顶上 sys.meta_path.insert(0, LegacyAlias()) # 迁移期垫片, 存档迁完即删 records = pickle.load(open("archive-2023.pkl", "rb"))
连接持有者怎么拿? 小服务用模块级惰性单例最省事; 多环境/重测试的 服务用显式注入, 依赖看得见才好替换:
# 写法A: 模块级惰性单例 — import 期零 IO, 首次调用才连接 _db = None def get_db(): global _db if _db is None: _db = create_engine(DB_URL, pool_size=10) return _db # 写法B: 显式注入 — 依赖出现在签名里, 测试不用 hack sys.modules # app = create_app(db=engine(DB_URL), cache=Redis("redis://cache:6379/0")) # 取舍: 单服务单环境用A; SaaS 多租户/多配置用B, 组合根统一装配
47 台机器 pip 装出 3 个小版本, 同一段代码行为漂移查无此案。用镜像锁死依赖层, 用 src 布局防 cwd 影子覆盖:
# Dockerfile — 依赖层与代码层分离, requirements 锁到精确版本 # COPY requirements.txt . # RUN pip install --no-cache-dir -r requirements.txt # orjson==3.10.7 # COPY src/ ./src/ # CMD ["python", "-m", "app.main"] # 入口统一 -m # pyproject.toml — src 布局: 项目包只在安装后可见, cwd 里的同名文件骗不到人 # [tool.pytest.ini_options] # pythonpath = ["src"] # 铁律: 生产进程的 PYTHONPATH 必须为空, sys.path 只有一条可信来源
ModuleNotFoundError, 上线当晚才炸, 本地测试根本走不到。正解: 必需依赖留在模块顶部; 函数内 import 只用于可选依赖并配 try/except ImportError。 def export_pdf(rows): import weasyprint # 错: 缺依赖首次调用才炸 import weasyprint # 对: 必需依赖放顶部, 启动即报 # 可选依赖才进函数体 + try/except ImportError 兜底
except Exception 当成业务错误静默降级, 功能悄悄消失还没日志。正解: 先单独 except ImportError 记日志再接业务异常; 或用 importlib.util.find_spec() 启动期探测。 try: import ujson as json except Exception: # 错: ImportError 被吞, 无日志 pass try: import ujson as json except ImportError: # 对: 单独接住并记日志 log.warning("ujson missing"); import json
import a.b 与 from a import b 并存, reload/热更新后一个引用更新一个不更新, 两处行为分叉。正解: 团队统一引用风格; 需要 reload 的场景一律 sys.modules['a.b'] 取同一个对象。 import a.b; from a import b sys.modules.pop("a.b"); import a.b # 模拟热更新 a.b is b # 错: → False, b 还是旧对象 use(sys.modules["a.b"]) # 对: 永远取当前对象
importlib.reload() 重跑模块体生成新的类对象, 旧实例还指着旧类, isinstance(x, NewCls) 返回 False, 分派逻辑全乱。正解: reload 后重建实例; from import 拿到的旧类要重新 import 再用。 import importlib, plugin old = plugin.Task() importlib.reload(plugin) # 模块体重跑, 类是新的 isinstance(old, plugin.Task) # 错: → False, 分派全乱 # 对: reload 后重建实例, 要用的类重新 import
requests.py/email.py/uuid.py 排在搜索路径前面把真库影子覆盖, 报莫名其妙的 AttributeError。正解: 起名先过 stdlib 清单; 报错第一反应打印 module.__file__ 看物理路径。 import email # 错: 导到的是你自己的 email.py # 真库的属性全没了 → AttributeError 诡异地炸 # 对: 起名避开 stdlib; 现场打印验明正身 print(email.__file__) # → /app/email.py ← 露馅
__import__(f"pkg.{mode}") 拼错模块名运行时才炸, 静态检查全瞎; 用户输入进 import 路径等于任意代码执行。正解: 白名单 dict 映射 name 到 callable; 动态加载必须 try/except 并翻译成业务错误码。 mod = __import__(f"pkg.{mode}") # 错: 拼错运行时才炸, 输入=RCE # 对: 白名单映射, 不让用户碰 import 路径 HANDLERS = {"csv": csv_export, "pdf": pdf_export} h = HANDLERS.get(fmt) or fail(400, f"unknown {fmt}")
from m import a, b; 库作者维护 __all__ 控制导出面。 from utils import * # 错: 名字灌入, 本地被覆盖 # 对: 显式列名 from utils import parse_ts, fmt_money # 库作者: __all__ = [...] 控制 import * 的导出面
ImportError: partially initialized module。正解: 依赖必须是无环 DAG, 公共部分下沉; 函数内 import 只是止血手段。 # a.py: import b b.py: from a import NAME ← 成环 # 错: 入口不同谁半初始化就不同, 生产偶发 ImportError # 对: 公共类型下沉 schemas, A/B 单向依赖 from .schemas import Ref # 依赖图恢复成 DAG
__pycache__ 属主是 root 写不进。正解: 发布脚本统一清 __pycache__; 镜像内构建期跑一次 import 让缓存与源对齐。 # 错: 只换源不换缓存 / 时钟回拨 / 属主 root 写不进 python app.py # 行为没变? 大概率旧 .pyc # 对: 发布脚本统一清缓存 find . -name __pycache__ -exec rm -rf {} +
__init__.py 只放导出与常量; 重活下沉到子模块并延迟到首次使用。 # 错: pkg/__init__.py 顶层连 DB db = create_engine(URL) # import pkg 就付一遍, 全员变慢 # 对: 门面只放导出; 重活下沉子模块延迟到首次用 from .core import get_db # 用到才连
ep.load() 抛异常没人接, Web 进程直接崩全局, 一个坏插件带走整站。正解: 插件加载循环 try/except, 失败记插件名+异常并跳过, 缺插件降级不拖垮主流程。 # 错: 一个坏插件抛异常, 全站 500 plugins = {ep.name: ep.load() for ep in entry_points(group=G)} # 对: 逐个 try/except, 记名跳过 for ep in entry_points(group=G): try: plugins[ep.name] = ep.load() except Exception: log.warning("plugin failed", ep.name)
if sys.platform == 'linux' 里 import 的依赖, 其他分支没覆盖, 换平台部署就 ModuleNotFoundError。正解: 各平台依赖进各自 extras, CI 矩阵全平台装一遍; 缺失时显式 raise 并带安装提示。 if sys.platform == "linux": import uvloop # 错: 其他平台部署直接缺 # 对: 平台依赖进各自 extras, 缺失显式提示 try: import uvloop except ImportError: uvloop = None # 或 raise 提示 pip install app[linux]
client = CloudClient() # 错: 模块顶层连云, 网未就绪卡死 # 对: 初始化推迟到显式 init()/首次请求 _client = None def client(): global _client _client = _client or CloudClient(); return _client
json.py/typing.py 排在 site-packages 前面, 第三方库 import 到你的文件后行为错乱且极难定位。正解: src 布局 + 命名查重; 现场用 __file__ 一眼识破。 import json json.__file__ # 错: → '/app/json.py' ← 你的文件, 不是 stdlib! # 对: src 布局 + 命名查重, __file__ 一眼识破
ModuleNotFoundError, 历史存档全废。正解: 迁移期保留 alias 模块或 sys.meta_path 垫片; 长期数据用版本化 schema 而非 pickle 裸类。 pickle.load(open("a.pkl", "rb")) # 错: 类路径 utils.geo.Point 已下线 → ModuleNotFoundError # 对: 迁移期留 alias 或 meta_path 垫片 (旧路径→新家) sys.meta_path.insert(0, LegacyAlias()) # 存档迁完即删
python mod.py 时 sys.path[0] 是文件目录且无包上下文, 相对导入直接报错; python -m pkg.mod 才有 __package__。正解: 入口统一 -m; 脚本内检测 __package__ is None 给出可执行提示。 python geo/reverse.py # 错: sys.path[0]=文件目录, 无包上下文 # → ImportError: no known parent package python -m geo.reverse # 对: sys.path[0]=cwd, __package__='geo' # 脚本内: if __package__ is None: 提示改用 -m
if TYPE_CHECKING: from .models import User 之后运行时引用 User 抛 NameError。正解: 文件头 from __future__ import annotations 让注解全变字符串; 运行时真要用就函数内局部 import。 if TYPE_CHECKING: from .models import User def find(u: User): ... # 错: 运行时 NameError # 对: 文件头 from __future__ import annotations # 运行时真要用 → 函数内局部 import
STATE = {} 被多线程/asyncio 任务同时读写, import 期看着无害运行期数据竞争, 计数丢更新。正解: 加锁或 threading.local/contextvars; 多进程下模块状态根本不共享, 别拿来当进程间通信。 STATE = {} # 错: 多线程裸读写, 计数丢更新
STATE = threading.local() # 对: 线程各自一份
# 多进程下模块状态根本不共享, 别当 IPC 用import 虽命中缓存也要走 sys.modules 查找, 每秒百万次调用白烧几个点 CPU。正解: 循环外 import 或首次用时缓存到局部变量; 惰性只该发生一次。 for row in rows: import json # 错: 每次都查 sys.modules, 白烧 CPU import json # 对: 循环外导入一次 for row in rows: process(row) # 惰性只该发生一次
-X importtime, 别用业务代码。 # 错: logging_conf.py 反手 import 业务模块 from app.models import User # 半初始化对象入局, 环提前引爆 # 对: 基础设施零业务依赖 python -X importtime app.py # 分析导入链用它, 不用业务代码