Python 基础体系 · 第 71/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 调试:pdb、断点、异常现场、日志和远程诊断
调试不是“把程序停下来看看变量”这么简单。一个可复现的本地错误,可以用断点和单步执行定位;一个只在生产环境偶发的错误,通常需要异常现场、结构化日志、线程栈和进程级诊断信息共同还原。
可以把一次调试过程抽象为四类动作:
- 暂停:让程序停在某个执行位置。
- 观察:读取栈帧、局部变量、异常对象和调用路径。
- 干预:修改变量、跳转执行位置或继续运行,验证某个假设。
- 记录:把无法交互观察的状态保存为日志、traceback 或线程转储。
pdb 主要解决前两类问题,也允许有限度地干预;traceback 和日志负责保存异常证据;faulthandler 面向崩溃、死锁和卡住的进程;远程诊断则是在不能重新启动或复现程序时,将这些工具接入正在运行的进程。
一、先建立调试对象:代码、栈帧、异常和进程
1. 栈帧是调试器真正观察的对象
程序执行函数调用时,会为每一层调用建立一个栈帧。一个栈帧至少包含:
- 当前执行位置;
- 当前函数的参数;
- 局部变量;
- 全局变量引用;
- 指向调用者栈帧的关系。
例如:
def divide(total, count):
return total / count
def summarize(values):
return divide(sum(values), len(values))
summarize([])
执行到 divide() 时,调用关系可以表示为:
<module>
└── summarize(values=[])
└── divide(total=0, count=0)
└── total / count ← 异常位置
pdb 的 where、up、down 命令操作的不是“代码文件”,而是这些运行时栈帧。p 命令读取的是当前栈帧上下文中的表达式,args 读取当前函数参数,list 显示当前源代码位置。(docs.python.org)
这一区分很重要:源代码告诉你程序应该如何运行,栈帧告诉你程序此刻实际上运行到了哪里。
2. traceback 是异常路径,不是任意调用栈
异常发生后,Python 会把异常沿调用栈向上传播,直到找到匹配的 except。异常现场中的 traceback 记录的是这条传播路径:
Traceback (most recent call last):
File "demo.py", line 10, in <module>
summarize([])
File "demo.py", line 7, in summarize
return divide(sum(values), len(values))
File "demo.py", line 3, in divide
return total / count
ZeroDivisionError: division by zero
它和普通的当前调用栈有区别:
traceback:异常发生后,哪些调用层参与了异常传播;stack_info:记录执行到日志语句时,当前线程是如何走到这里的;pdb where:调试器当前暂停时的栈帧链。
logging 明确区分了 exc_info 和 stack_info:前者是异常处理过程中已经展开的栈,后者是从当前线程栈底到日志调用点的栈。(docs.python.org)
二、pdb 的基本机制:在执行事件上暂停
pdb 是 Python 标准库中的交互式源代码调试器,支持:
- 源码行级断点;
- 条件断点;
- 单步执行;
- 栈帧切换;
- 表达式求值;
- 事后调试,也就是 post-mortem debugging。(docs.python.org)
调试器需要知道程序何时执行了某一行。传统实现通常通过 tracing 机制接收执行事件;Python 3.14 的 pdb 支持 settrace 和 monitoring 两种后端。pdb.set_default_backend() 可以设置默认后端,但内联的 breakpoint() 和 pdb.set_trace() 始终使用 monitoring 后端。(docs.python.org)
这解释了为什么调试器会改变程序行为:它需要在执行路径中插入观察点,程序每执行一行,调试器可能获得一次检查机会。因此调试状态下的时序、性能和线程竞争,不能完全等同于正常运行状态。
三、设置断点:breakpoint()、pdb.set_trace() 和命令行
1. 使用 breakpoint()
最简单的断点是:
def total_price(items):
subtotal = sum(item["price"] for item in items)
breakpoint()
return subtotal * 1.1
print(total_price([{"price": 10}, {"price": 20}]))
运行:
python demo.py
程序会停在 breakpoint() 处:
> demo.py(4)total_price()
-> return subtotal * 1.1
(Pdb)
此时可以输入:
(Pdb) p items
[{'price': 10}, {'price': 20}]
(Pdb) p subtotal
30
(Pdb) p subtotal * 1.1
33.0
(Pdb) continue
33.0
内置函数 breakpoint() 本身并不直接写死为 pdb.set_trace()。它会调用 sys.breakpointhook(),默认情况下该 hook 再调用 pdb.set_trace();因此可以通过替换 hook 或环境变量 PYTHONBREAKPOINT 改变行为。(docs.python.org)
这使 breakpoint() 比显式写:
import pdb
pdb.set_trace()
更适合业务代码。开发环境可以启用它,测试或生产环境可以通过运行配置禁用或替换。
但断点仍然可能带来两个风险:
- 在没有交互式终端的进程中,程序可能停住并等待输入;
- 如果代码路径被高并发请求频繁触发,暂停一个线程可能造成请求堆积。
2. pdb.set_trace() 的适用场景
pdb.set_trace() 适合需要明确控制调试入口的场景:
import pdb
def parse_config(config):
if "timeout" not in config:
pdb.set_trace()
return int(config["timeout"])
Python 3.14 中,set_trace() 支持 header 和 commands 参数,并且新增了异步版本 pdb.set_trace_async()。set_trace_async() 应在异步函数中使用并通过 await 调用;只有使用该入口时,调试器才支持在调试过程中处理 await。(docs.python.org)
同步代码:
import pdb
def calculate():
value = 10
pdb.set_trace(header="即将检查 calculate()")
return value + 1
异步代码:
import asyncio
import pdb
async def fetch_value():
value = 10
await pdb.set_trace_async()
await asyncio.sleep(0)
return value + 1
print(asyncio.run(fetch_value()))
这里不能简单地把同步 pdb.set_trace() 当作异步断点的完全替代品。异步函数在 await 处会让出执行权,调试器若不能正确处理协程执行状态,就可能出现无法继续、无法单步进入下一次异步恢复的问题。
3. 不修改源码:命令行启动 pdb
可以让 pdb 在脚本开始前接管程序:
python -m pdb demo.py
也可以调试模块:
python -m pdb -m mypackage.worker
在调试器中常用的命令如下:
| 命令 | 作用 |
|---|---|
l / list |
查看当前位置附近的源代码 |
ll / longlist |
查看当前函数或栈帧的完整源码 |
p expr |
计算并打印表达式 |
pp expr |
使用 pprint 美化输出 |
a / args |
查看当前函数参数 |
w / where |
查看调用栈 |
u / up |
切换到调用者栈帧 |
d / down |
切换到被调用者栈帧 |
s / step |
单步执行,进入被调用函数 |
n / next |
单步执行,但不进入被调用函数 |
r / return |
执行到当前函数返回 |
c / continue |
继续执行到下一个断点 |
b / break |
设置断点 |
tbreak |
设置一次性断点 |
cl / clear |
清除断点 |
disable / enable |
暂时禁用或重新启用断点 |
q / quit |
退出调试器 |
step 和 next 的核心差异不是“执行一行还是多行”,而是是否进入被调用函数:
def inner():
result = 1 + 2
return result
def outer():
value = inner()
return value * 10
停在 value = inner() 时:
(Pdb) step
会进入 inner() 的第一条可执行语句;而:
(Pdb) next
会让 inner() 基本上正常执行,随后停在 outer() 的下一行。
四、断点不是只有“停在某一行”
1. 条件断点
假设错误只在某个订单出现:
def process(order):
total = sum(item["price"] for item in order["items"])
return total
在 pdb 中可以设置条件断点:
(Pdb) break process, order["id"] == "ORD-1001"
或者按源码行设置:
(Pdb) break worker.py:18, item["price"] < 0
断点只有在条件表达式计算为真时才会暂停。断点命令支持文件行号、函数以及条件表达式;tbreak 命中的第一次会自动删除。(docs.python.org)
条件断点的代价是:每次执行到断点位置,都要计算条件表达式。因此条件表达式应尽量只读取状态,不要调用有副作用的函数:
# 风险较高:可能修改状态、访问网络或抛出异常
(Pdb) break worker.py:18, refresh_cache()
# 更安全:只检查已有变量
(Pdb) break worker.py:18, order_id == "ORD-1001"
2. 忽略前若干次命中
循环中某个值只在第 10000 次附近出错时,可以使用:
(Pdb) break worker.py:25
(Pdb) ignore 1 9999
断点编号为 1 时,前 9999 次命中会递减忽略计数,第 10000 次才真正暂停。break 无参数可以查看断点的命中次数、忽略次数和条件。(docs.python.org)
3. 断点命令:把观察动作自动化
Python 3.14 支持在断点命中时自动执行命令:
(Pdb) break worker.py:25
Breakpoint 1 at worker.py:25
(Pdb) commands 1
(com) p order_id
(com) p retry_count
(com) continue
(com) end
这组命令会在每次命中时打印 order_id 和 retry_count,然后自动继续运行。任何恢复执行的命令都会结束当前断点命令列表,因为继续执行后可能遇到另一个断点。(docs.python.org)
它适合观察循环变量或采集少量现场,但不适合替代日志:
- 它依赖交互式调试器;
- 输出通常写到终端;
- 进程退出后证据不会自动归档;
- 观察本身会改变运行时行为。
五、异常现场:从 try/except 到 post-mortem
1. 不要只记录异常字符串
下面的写法信息不足:
try:
load_user(user_id)
except Exception as exc:
logger.error("load user failed: %s", exc)
输出可能只有:
load user failed: invalid token
这丢失了:
- 异常类型;
- 文件和行号;
- 调用路径;
- 原始异常链;
- 当前请求上下文。
应使用:
try:
load_user(user_id)
except Exception:
logger.exception("load user failed", extra={"user_id": user_id})
logger.exception() 通常用于 except 块中,它会以错误级别记录消息并附带当前异常信息。底层的 exc_info 参数可以接受异常实例、异常元组,或者在为真时从当前异常上下文中获取异常。(docs.python.org)
2. traceback 的三种用途
直接打印当前异常
import traceback
try:
1 / 0
except Exception:
traceback.print_exc()
print_exc() 是 print_exception(sys.exception(), ...) 的简写,用于当前正在处理的异常。(docs.python.org)
获取字符串
import traceback
try:
1 / 0
except Exception:
text = traceback.format_exc()
print(text)
format_exc() 返回字符串,适合交给日志系统或错误上报系统。(docs.python.org)
分离结构化信息
import traceback
def format_error(exc):
return {
"type": type(exc).__name__,
"message": str(exc),
"traceback": "".join(traceback.format_exception(exc)),
}
try:
int("not-a-number")
except Exception as exc:
print(format_error(exc))
traceback.format_exception() 返回可拼接的字符串列表,并保留与 print_exception() 相同的异常链语义。(docs.python.org)
3. 异常链不能随意丢弃
下面的代码保留了原始原因:
class ConfigError(Exception):
pass
def load_timeout(raw):
try:
return int(raw)
except ValueError as exc:
raise ConfigError("timeout 必须是整数") from exc
异常输出会先显示 ValueError,再显示由它引起的 ConfigError。这是因为新异常的 __cause__ 指向原始异常。
相反,下面的写法会隐藏原始上下文:
try:
return int(raw)
except ValueError:
raise ConfigError("timeout 无效") from None
from None 适合确实不希望向调用者暴露内部实现的边界,但如果用于诊断代码,会降低根因可见性。默认情况下,traceback.print_exception() 会显示异常的 __cause__ 或 __context__ 链。(docs.python.org)
4. TracebackException:记录异常而不是长期持有栈帧
异常对象和 traceback 可能引用局部变量、请求对象或大块数据。若把原始异常对象长期放进全局队列,可能意外延长这些对象的生命周期。
TracebackException 可以把异常现场转换为适合格式化的表示:
from traceback import TracebackException
def capture_exception(exc):
return TracebackException.from_exception(
exc,
capture_locals=False,
)
try:
data = {"secret": "do-not-log"}
1 / 0
except Exception as exc:
captured = capture_exception(exc)
captured.print()
如果打开 capture_locals=True,每个栈帧中的局部变量也会被捕获:
captured = TracebackException.from_exception(
exc,
capture_locals=True,
)
这对定位输入错误很有帮助,但也可能把密码、令牌、身份证号或完整请求体写入日志。标准库文档明确展示了 capture_locals 会将局部变量加入格式化结果,因此生产环境不应无条件开启。(docs.python.org)
5. 事后调试:pdb.post_mortem()
当异常已经发生,但程序仍在 except 中,可以进入异常现场:
import pdb
def divide(a, b):
return a / b
try:
divide(10, 0)
except Exception as exc:
pdb.post_mortem(exc)
进入 (Pdb) 后:
(Pdb) p a
10
(Pdb) p b
0
(Pdb) where
Python 3.14 的 pdb.post_mortem() 可以接收异常对象或 traceback 对象;不传参数时,它会使用当前正在处理的异常。pdb.pm() 则使用交互环境中的 sys.last_exc。(docs.python.org)
命令行方式更适合调试未捕获异常:
python -m pdb demo.py
当被调试程序异常退出时,命令行 pdb 会自动进入 post-mortem 调试;调试结束后还可能重新启动程序,并保留断点状态。(docs.python.org)
六、日志不是“打印更多字符串”
1. 日志事件的传播路径
标准库日志系统包含几个不同角色:
logger.debug(...)
│
▼
Logger:名称、级别、过滤器
│
▼
LogRecord:时间、级别、文件、行号、异常、上下文
│
▼
Handler:控制台、文件、网络或自定义输出
│
▼
Formatter:把记录转换为文本或结构化格式
日志记录会沿 logger 名称层级向父 logger 传播。例如 app.worker.http 会向 app.worker、app 和 root logger 传播,除非某一级将 propagate 设为 False。如果同一条记录在子 logger 和祖先 logger 上都挂了 handler,可能被重复输出。(docs.python.org)
因此模块通常只创建 logger:
import logging
logger = logging.getLogger(__name__)
而不要在每个模块都添加独立的控制台 handler。应用入口统一配置 handler,模块通过层级传播输出。
2. logger 级别和 handler 级别是两道门
一条日志要真正输出,至少要经过两个级别判断:
logger 是否允许?
│
▼
handler 是否允许?
│
▼
格式化并输出
例如:
import logging
logger = logging.getLogger("app")
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
logger.addHandler(handler)
logger.debug("debug message")
logger.info("info message")
这里 logger 接受 DEBUG,但 handler 只接受 INFO 及以上,因此 debug message 不会显示。logger 的级别决定事件是否继续处理,handler 的级别决定该输出目标是否接收。root logger 默认级别为 WARNING。(docs.python.org)
3. exc_info、stack_info 和 stacklevel
三者解决不同问题:
logger.error(
"request failed",
exc_info=True,
stack_info=True,
stacklevel=2,
)
exc_info=True:记录当前异常的 traceback;stack_info=True:记录当前线程到日志调用点的栈;stacklevel=2:让日志记录的文件名、函数名和行号跳过一层日志包装函数,指向真正的调用者。
例如:
def log_retry(logger, message, **fields):
logger.warning(
message,
extra=fields,
stacklevel=2,
)
def load():
log_retry(logger, "retrying request")
如果不设置 stacklevel=2,日志位置通常会指向 log_retry() 内部,而不是 load()。stacklevel 正是为日志辅助函数和包装器提供的。(docs.python.org)
4. 用字段表达上下文
字符串拼接不利于查询:
logger.info(f"user={user_id} request={request_id} action=load")
更好的方式是保留字段:
logger.info(
"load user",
extra={
"user_id": user_id,
"request_id": request_id,
"action": "load",
},
)
但 extra 中的字段必须与 formatter 使用的字段一致。如果 formatter 写了 %(request_id)s,而某条日志没有提供 request_id,格式化阶段可能失败。因此常见做法是使用 LoggerAdapter 为一组调用统一注入上下文:
import logging
base_logger = logging.getLogger("app.worker")
logger = logging.LoggerAdapter(
base_logger,
{
"service": "billing",
"request_id": "req-123",
},
)
logger.info("start charge")
LoggerAdapter 的作用就是在日志调用中方便地传递上下文信息;Python 3.13 还增加了 merge_extra 参数,用于控制调用级 extra 是否与 adapter 上下文合并。(docs.python.org)
标准库没有规定一种 JSON 日志格式,但可以自行实现结构化 formatter:
import json
import logging
import traceback
class JsonFormatter(logging.Formatter):
def format(self, record):
item = {
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"file": record.pathname,
"line": record.lineno,
"function": record.funcName,
}
for key in ("service", "request_id", "user_id", "action"):
if hasattr(record, key):
item[key] = getattr(record, key)
if record.exc_info:
item["exception"] = "".join(
traceback.format_exception(*record.exc_info)
)
return json.dumps(item, ensure_ascii=False)
配置:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
logger.addHandler(handler)
这里没有把所有 record.__dict__ 原样序列化,因为其中包含不可序列化对象、内部字段和潜在敏感信息。结构化日志的重点不是“格式变成 JSON”,而是字段具有稳定语义,便于按请求、用户、任务和错误类型聚合。
5. 日志格式化要延迟
推荐:
logger.debug("loaded %d records", len(records))
而不是:
logger.debug(f"loaded {len(records)} records")
前者把格式化工作交给 logging,并允许在日志级别未启用时避免构造最终字符串。对于昂贵计算,还应显式判断:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("payload=%s", expensive_debug_dump(payload))
isEnabledFor() 会检查全局禁用级别和 logger 的有效级别。(docs.python.org)
七、卡住、死锁和崩溃:使用 faulthandler
pdb 适合 Python 代码仍然能够执行并且可以与调试器交互的情况。以下问题需要更底层的工具:
- 进程卡在锁上;
- 线程长时间等待 I/O;
- C 扩展导致段错误;
- 解释器发生
SIGSEGV、SIGABRT等致命错误; - 没有机会进入正常的
except。
faulthandler 在这些场景下直接输出 Python traceback。它用 C 实现,故障处理路径只能使用信号安全操作,因此输出比普通 traceback 更有限:通常只有文件名、函数名和行号,字符串长度、线程数和栈帧数也有限制。(docs.python.org)
1. 启动时启用
python -X faulthandler app.py
也可以设置:
PYTHONFAULTHANDLER=1 python app.py
或者在代码中:
import faulthandler
import sys
faulthandler.enable(
file=sys.stderr,
all_threads=True,
c_stack=True,
)
Python 3.14 中,c_stack=True 可以在支持的平台和构建条件下同时输出 C 栈;C 栈展开可能非常慢,也可能因操作系统、编译器或二进制信息不足而失败。(docs.python.org)
2. 超时转储:定位卡住的线程
import faulthandler
import time
faulthandler.dump_traceback_later(
30,
repeat=True,
)
try:
while True:
time.sleep(10)
except KeyboardInterrupt:
faulthandler.cancel_dump_traceback_later()
这段代码每 30 秒输出一次所有线程的 traceback。dump_traceback_later() 使用 watchdog 线程;如果设置 exit=True,转储后会调用 _exit(),不会执行正常清理,也不会保证缓冲区刷新,因此不能随意用于普通超时处理。(docs.python.org)
3. 通过信号请求线程栈
在类 Unix 系统上,可以注册用户信号:
import faulthandler
import signal
import sys
faulthandler.register(
signal.SIGUSR1,
file=sys.stderr,
all_threads=True,
)
另一个终端发送:
kill -USR1 <pid>
进程会把线程栈输出到标准错误,不需要暂停到 pdb 交互界面。faulthandler.register() 在 Windows 上不可用;输出目标文件必须保持打开,否则文件描述符复用可能导致内容写入错误的文件。(docs.python.org)
八、Python 3.14 的进程附加:pdb -p PID
Python 3.14 的命令行 pdb 增加了 -p / --pid:
python -m pdb -p 1234
它尝试附加到指定 PID 的正在运行的 Python 进程。(docs.python.org)
一个最小实验:
server.py:
import os
import time
print(f"pid={os.getpid()}", flush=True)
counter = 0
while True:
counter += 1
time.sleep(1)
启动:
python server.py
假设输出:
pid=1234
另一个终端执行:
python -m pdb -p 1234
进入调试器后可以观察变量:
(Pdb) p counter
17
(Pdb) where
然后继续:
(Pdb) continue
这个功能的边界必须明确:
-p PID需要目标进程和调试器具备操作系统允许的附加权限;- PID 必须位于调试器可见的进程命名空间中,容器环境尤其要注意 PID namespace;
- 这不是一个自动暴露 TCP 端口的远程调试协议;
- 如果进程阻塞在系统调用或等待 I/O,附加动作要等到下一条字节码执行或进程收到信号后才可能生效。(docs.python.org)
- 一旦进入交互式调试,改变变量、调用函数或继续执行都可能改变线上状态。
因此,“远程诊断”更准确地分为两层:
网络远程操作
│
├── SSH / 容器 exec / 运维平台
│ └── 在目标环境执行 python -m pdb -p PID
│
└── 应用自身提供诊断接口
└── faulthandler、日志、指标、转储
如果生产进程位于另一台机器,通常先通过受控运维通道进入目标主机或容器,再使用 pdb -p。不应为了远程调试而让应用监听一个未经认证的调试端口,因为 pdb 能够读取变量、调用任意函数,甚至修改程序状态。
九、pdb、trace、性能剖析和日志的边界
1. pdb 适合因果链短、状态可交互观察的问题
例如:
输入订单
↓
解析金额
↓
计算折扣
↓
结果异常
可以在折扣计算前暂停,检查:
(Pdb) p user_level
(Pdb) p subtotal
(Pdb) p discount_rate
2. 日志适合保存跨时间、跨机器的证据
如果错误只在凌晨出现,不能依赖人工断点。此时应记录:
request_id
job_id
user_id
输入摘要
关键状态转换
重试次数
外部依赖耗时
异常类型和 traceback
日志回答的是:
这次请求在什么上下文中,以什么顺序经过了哪些状态,最终在哪里失败?
3. sys.settrace() 适合构建调试器和覆盖率工具
sys.settrace() 可以安装线程相关的 tracing 函数,用于实现调试器、覆盖率工具等;它是线程特定的,多线程程序需要为每个线程注册,或使用 threading.settrace()。文档同时说明,这部分行为属于实现平台细节,不是所有 Python 实现都必须提供的语言级保证。(docs.python.org)
因此不要把 sys.settrace() 当成普通业务埋点接口。它可能产生明显开销,也会改变执行时序。需要统计耗时时,应优先考虑 timeit、cProfile 或采样剖析;需要调查内存增长时,应使用 tracemalloc。这些工具回答的是性能和资源问题,而不是单步验证业务状态。
可以按问题类型选择工具:
| 问题 | 首选证据 |
|---|---|
| 某个变量为什么变成错误值 | pdb 断点、条件断点 |
| 异常从哪里传播而来 | traceback、exc_info |
| 请求在什么上下文中失败 | 结构化日志、LoggerAdapter |
| 线程为什么卡住 | faulthandler、线程栈 |
| C 扩展为何崩溃 | faulthandler Python/C 栈、核心转储 |
| 哪段代码耗时 | timeit、cProfile、采样剖析 |
| 哪些对象占用内存 | tracemalloc |
| 多线程执行路径如何追踪 | tracing 或专用观测工具 |
十、一个可运行的完整诊断示例
下面的程序同时展示:
- 结构化上下文;
- 异常链;
logger.exception();- 可禁用的
breakpoint(); - 超时线程栈转储。
import faulthandler
import logging
import os
import sys
import time
class RequestFormatter(logging.Formatter):
def format(self, record):
text = super().format(record)
request_id = getattr(record, "request_id", "-")
return f"request_id={request_id} {text}"
def configure_logging():
handler = logging.StreamHandler()
handler.setFormatter(
RequestFormatter(
"%(asctime)s %(levelname)s %(name)s "
"%(filename)s:%(lineno)d %(message)s"
)
)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
logger = logging.getLogger("billing")
def parse_amount(raw):
try:
return int(raw)
except ValueError as exc:
raise ValueError(f"金额不是整数: {raw!r}") from exc
def charge(raw_amount, *, request_id):
adapter = logging.LoggerAdapter(
logger,
{"request_id": request_id},
)
adapter.info("start charge")
amount = parse_amount(raw_amount)
if os.environ.get("DEBUG_CHARGE") == "1":
breakpoint()
if amount <= 0:
raise ValueError("金额必须大于 0")
adapter.info("charge accepted: amount=%d", amount)
return amount
def main():
configure_logging()
faulthandler.enable(
file=sys.stderr,
all_threads=True,
c_stack=False,
)
try:
faulthandler.dump_traceback_later(
60,
repeat=False,
)
charge("invalid", request_id="req-001")
except Exception:
logger.exception(
"charge failed",
extra={"request_id": "req-001"},
)
raise
finally:
faulthandler.cancel_dump_traceback_later()
if __name__ == "__main__":
main()
运行:
python app.py
预期会看到包含异常链的日志:
request_id=req-001 INFO billing ... start charge
request_id=req-001 ERROR billing ... charge failed
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'invalid'
The above exception was the direct cause of the following exception:
ValueError: 金额不是整数: 'invalid'
启用交互断点:
DEBUG_CHARGE=1 python app.py
程序会在 amount = parse_amount(raw_amount) 成功之后、金额校验之前停下。注意本例中传入 "invalid" 时会在 parse_amount() 内部先抛出异常,因此不会执行到 breakpoint();如果改为:
DEBUG_CHARGE=1 python -c \
'from app import configure_logging, charge; configure_logging(); print(charge("10", request_id="req-002"))'
才会进入断点。
这个例子体现了证据链的顺序:
start charge
↓
parse_amount 抛出原始 ValueError
↓
raise ... from exc 建立异常因果链
↓
logger.exception 记录 traceback
↓
调用方决定是否重新抛出
如果只写:
logger.error("charge failed: %s", exc)
就只能看到最终错误文本,无法可靠判断错误发生在哪个函数,也无法知道是否存在异常链。
十一、生产环境中的失败路径和恢复策略
1. 断点导致进程停顿
表现:
- 请求延迟突然升高;
- 工作线程处于等待输入状态;
- 容器健康检查失败;
- 进程没有崩溃,但业务不再推进。
诊断:
ps -ef | grep python
python -m pdb -p <pid>
或者直接使用:
kill -USR1 <pid>
让 faulthandler 输出线程栈。
恢复:
- 退出
pdb; - 必要时终止被暂停的进程;
- 回滚包含断点的版本;
- 检查
PYTHONBREAKPOINT和调试配置是否被错误注入。
2. 日志重复输出
表现:
INFO app.worker start
INFO app.worker start
原因:
同一个 logger 和祖先 logger 都配置了 handler,且 propagate=True。日志记录沿层级传播时被多个 handler 分别处理。(docs.python.org)
诊断:
logger = logging.getLogger("app.worker")
print(logger.handlers)
print(logger.propagate)
print(logger.parent.handlers)
恢复:
- 只在应用边界配置 handler;
- 子模块只获取 logger;
- 如果某个子树必须独立输出,再明确设置
propagate = False。
3. 日志中泄露敏感数据
表现:
capture_locals=True输出令牌;pprint(request)输出完整请求体;- 异常字符串包含数据库连接串;
pdb p settings暴露密钥。
原因:
调试工具的目标是最大化可见性,而生产安全通常要求最小化暴露面。两者天然存在冲突。
处理:
- 日志只记录输入摘要、哈希或业务标识;
- 对密码、令牌、Cookie、Authorization 头统一脱敏;
- 不要把原始局部变量直接写入日志;
- 使用
pdb时确认终端访问权限和审计记录; - 崩溃转储文件按敏感数据处理。
4. faulthandler 输出不完整
这不一定表示工具失效。故障处理路径受到信号安全约束,输出可能没有源代码,也可能只显示文件、函数和行号;C 栈还依赖操作系统、编译器和调试信息。(docs.python.org)
此时应把它作为定位入口,而不是完整诊断报告:
faulthandler traceback
↓
确认哪个线程、哪个 Python/C 调用点异常
↓
结合应用日志和部署版本
↓
必要时收集核心转储或复现环境
十二、调试证据链:从一个错误现场反推根因
一个可靠的诊断过程通常按以下顺序进行:
第一步:确认错误类别
先区分:
- Python 异常;
- 进程卡住;
- 线程死锁;
- C 扩展崩溃;
- 性能退化;
- 内存持续增长。
如果是性能问题,却在代码中插入大量 pdb,可能只会改变问题本身;如果是段错误,却只搜索 Python try/except,通常也不会得到结果。
第二步:保存最小现场
至少保留:
异常类型
异常消息
traceback
请求或任务标识
代码版本
运行环境
关键输入摘要
线程或协程上下文
第三步:判断是否需要交互
- 能稳定复现:使用
pdb; - 不能复现但有异常:使用日志和 post-mortem;
- 进程卡住:使用
faulthandler; - 进程仍在运行且不能重启:考虑
pdb -p PID; - 只关心耗时:使用性能剖析;
- 只关心内存:使用
tracemalloc。
第四步:验证假设,而不是盲目单步
例如怀疑折扣率来源错误,不要从程序第一行一直 step 到错误位置,而应:
(Pdb) break pricing.py:42, discount_rate < 0
(Pdb) continue
(Pdb) p discount_rate
(Pdb) p order_id
断点条件把“所有执行路径”缩小为“满足假设的路径”。这样调试器采集的是有判别力的证据,而不是大量无关步骤。
第五步:修复后删除临时干预
修复完成后检查:
- 源码中是否仍有
breakpoint(); - 环境变量是否仍启用调试;
.pdbrc是否带有自动继续或敏感命令;faulthandler的输出文件是否会无限增长;- 日志级别是否被临时调成
DEBUG; - 是否因为新增
extra字段导致 formatter 对旧日志失败。
调试工具的价值不在于让程序“停得更方便”,而在于让每个结论都能由现场证据支持。pdb 负责交互式验证,异常现场负责还原失败路径,日志负责保存上下文,faulthandler 负责处理解释器无法正常收尾的故障,远程诊断负责在程序已经运行起来之后继续获取证据。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 静态类型工程:mypy、Pyright、Stub、渐进迁移和 CI
- 下一篇:Python 性能剖析:timeit、cProfile、tracemalloc、采样和证据链
- 延伸:Python 日志工程:Logger、Handler、结构化字段、上下文和轮转
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论