Python 基础体系 · 第 71/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 调试:pdb、断点、异常现场、日志和远程诊断

调试不是“把程序停下来看看变量”这么简单。一个可复现的本地错误,可以用断点和单步执行定位;一个只在生产环境偶发的错误,通常需要异常现场、结构化日志、线程栈和进程级诊断信息共同还原。

可以把一次调试过程抽象为四类动作:

  1. 暂停:让程序停在某个执行位置。
  2. 观察:读取栈帧、局部变量、异常对象和调用路径。
  3. 干预:修改变量、跳转执行位置或继续运行,验证某个假设。
  4. 记录:把无法交互观察的状态保存为日志、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  ← 异常位置

pdbwhereupdown 命令操作的不是“代码文件”,而是这些运行时栈帧。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_infostack_info:前者是异常处理过程中已经展开的栈,后者是从当前线程栈底到日志调用点的栈。(docs.python.org)


二、pdb 的基本机制:在执行事件上暂停

pdb 是 Python 标准库中的交互式源代码调试器,支持:

  • 源码行级断点;
  • 条件断点;
  • 单步执行;
  • 栈帧切换;
  • 表达式求值;
  • 事后调试,也就是 post-mortem debugging。(docs.python.org)

调试器需要知道程序何时执行了某一行。传统实现通常通过 tracing 机制接收执行事件;Python 3.14 的 pdb 支持 settracemonitoring 两种后端。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() 支持 headercommands 参数,并且新增了异步版本 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 退出调试器

stepnext 的核心差异不是“执行一行还是多行”,而是是否进入被调用函数:

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_idretry_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.workerapp 和 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_infostack_infostacklevel

三者解决不同问题:

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 扩展导致段错误;
  • 解释器发生 SIGSEGVSIGABRT 等致命错误;
  • 没有机会进入正常的 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

这个功能的边界必须明确:

  1. -p PID 需要目标进程和调试器具备操作系统允许的附加权限;
  2. PID 必须位于调试器可见的进程命名空间中,容器环境尤其要注意 PID namespace;
  3. 这不是一个自动暴露 TCP 端口的远程调试协议;
  4. 如果进程阻塞在系统调用或等待 I/O,附加动作要等到下一条字节码执行或进程收到信号后才可能生效。(docs.python.org)
  5. 一旦进入交互式调试,改变变量、调用函数或继续执行都可能改变线上状态。

因此,“远程诊断”更准确地分为两层:

网络远程操作
    │
    ├── 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() 当成普通业务埋点接口。它可能产生明显开销,也会改变执行时序。需要统计耗时时,应优先考虑 timeitcProfile 或采样剖析;需要调查内存增长时,应使用 tracemalloc。这些工具回答的是性能和资源问题,而不是单步验证业务状态。

可以按问题类型选择工具:

问题 首选证据
某个变量为什么变成错误值 pdb 断点、条件断点
异常从哪里传播而来 traceback、exc_info
请求在什么上下文中失败 结构化日志、LoggerAdapter
线程为什么卡住 faulthandler、线程栈
C 扩展为何崩溃 faulthandler Python/C 栈、核心转储
哪段代码耗时 timeitcProfile、采样剖析
哪些对象占用内存 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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。