写 PHP 这几年,var_dump配exit这套组合拳我用得比谁都熟,直到有一次要追一个跑在队列消费进程里的空值问题——打出来的东西淹没在几万行日志里,改一次代码还得重启 worker 等半天。那次之后我才老老实实把 PhpStorm 和 Xdebug 这条调试链路彻底打通。这篇就是我把这件事从头到尾走一遍的完整记录:PHP 端怎么装 Xdebug、php.ini里到底要写哪几行、PhpStorm 那边 Servers 和 Debug 面板怎么配、断点为什么命不中、命中了为什么停在不该停的地方,全部按我自己踩过的顺序写下来。配置本身不复杂,难的是出错时不知道从哪一层开始查,所以排查链路我也一并铺开,让刚上手的人能照着抄,也让配过很多次的人在出问题时能快速定位到是 PHP 端还是 IDE 端的问题。
1. 在动手配置之前,先把这几个概念理清楚
很多人把 Xdebug 配不起来,不是因为操作步骤难,而是因为对"谁在跟谁说话"没有概念。Xdebug 是装在 PHP 里的一个扩展,它负责在代码执行到某一行时停下来,并把当前的状态通过一个网络连接发给监听方;PhpStorm 是监听方,它开了个端口等消息,收到之后把文件、行号、变量值映射到自己的编辑器界面上。这里有两个"映射"特别关键:文件路径的映射,以及请求到项目的映射。之所以强调这一点,是因为后面九成的故障都出在这两处,而不是出在"扩展装没装上"。
还有一个容易被忽略的点:Xdebug 是装给某一个具体的 PHP 进程用的。你在命令行里php -m能看到 Xdebug,不代表 Nginx 后面的 php-fpm 也加载了它——这两个进程读的可能是完全不同的php.ini。我见过太多人对着 CLI 的配置改了半天,结果浏览器请求走的是 FPM,配置压根没生效。所以在动手之前,先明确你要调试的是"命令行脚本"还是"网页请求",这两个场景的配置点不一样。
最后提醒一句心态问题。第一次配 Xdebug,一次成功是运气,来回折腾两三个小时是常态。把每一层单独验证——PHP 端装没装上、连接有没有发出去、IDE 有没有收到、路径映射对不对——比反复重启服务要高效得多。
1.1 断点调试相对于打印日志,省下来的到底是什么
用打印调试最大的问题是"你得先猜"。你猜错在哪一行,就得去那一行加var_dump,跑一遍,发现不对,删掉,再换一处。整个过程是串行试错,而且每次都要改动源码、重新加载。断点不一样,代码执行到你标记的位置会自动停下来,你可以看到那一刻整个作用域里所有变量的真实值,包括$this上挂着的一堆属性、闭包里捕获的外部变量,这些东西如果用打印方式,光写输出语句就得写十几行。
更实际的一点是"不侵入"。生产问题很多时候不好复现,你不想为了看一眼中间状态就往代码里塞一堆临时输出,更不想改完忘了删。断点是在 IDE 侧管理的,源码里不留痕迹,调试完直接关掉监听就行。还有几个打印做不到的能力:条件断点让代码只在满足某个表达式时才停,日志断点让它在不停下来的情况下把值写进日志,异常断点让程序在抛出某类异常的那一刻冻结。这几个能力配合起来,排查问题时能省掉大量反复加代码的动作。
当然,打印也不是没用。对于线上环境、对于跑在别人机器上的代码、对于没有调试扩展的容器,打印仍然是唯一手段。我的习惯是:本地开发一律用断点,线上排错用结构化日志加日志级别的临时调整。两者不是替代关系,是场景分工。
1.2 Xdebug 3 的几个 mode,选错了会很痛苦
Xdebug 从 2 升到 3 最大的变化之一,是把一堆布尔开关收拢成了一个xdebug.mode配置项,用逗号分隔可以同时开多个模式。常用的有这么几个:
debug:断点调试能力,也就是这篇要用的核心模式。develop:美化var_dump输出、增强错误页面的可读性,很多人的默认模式就是这个。coverage:代码覆盖率统计,给 PHPUnit 这类测试框架用。trace:函数调用追踪,输出到文件,适合看一个请求到底调了哪些函数。profile:性能剖析,输出能被 KCachegrind 之类的工具解析的文件。
这里有个坑需要单独拎出来说。默认情况下 Xdebug 3 装好后mode是develop,也就是说扩展是加载了的,但断点调试能力并没有打开。你在 PhpStorm 里点调试没反应,第一反应会去怀疑 IDE 配置,实际上根本原因可能是xdebug.mode里少了debug。我第一次遇到这个问题时对着 Servers 面板折腾了四十分钟,最后是看php --ri xdebug的输出才发现mode里压根没debug这一项。
另外提醒一句性能账:develop模式对性能有可见影响,trace和profile的影响更大。本地开发开着无所谓,如果要压测或者跑大批量数据处理,记得把mode临时关掉或者改成只留必要项。这个影响不是玄学,是实打实每一次函数调用都要多走一层处理逻辑。
1.3 PHP 版本、Xdebug 版本、PhpStorm 版本的对齐关系
这三者的版本关系如果对不上,会出现各种诡异现象。Xdebug 2 和 Xdebug 3 的配置项名字是完全不同的两套:Xdebug 2 用的是xdebug.remote_enable、xdebug.remote_host、xdebug.remote_port,Xdebug 3 全部改成了xdebug.mode、xdebug.client_host、xdebug.client_port。如果你拿着一篇 Xdebug 2 时代的教程去配 Xdebug 3,会发现配置项写了但完全不起作用,因为旧名字在新版本里已经被移除了,不会报错,只是被静默忽略。
端口也是一个高频混乱点。Xdebug 2 默认端口是 9000,Xdebug 3 改成了 9003。PhpStorm 里默认监听的是 9003,如果你 PHP 端写的是 9000,两边就对不上了。另外 9000 这个端口经常被 php-fpm 或者别的服务占用,这也是官方改默认值的原因之一。
版本对应上,Xdebug 3 系列需要 PHP 7.2 及以上,具体的小版本对应关系在官方文档的兼容性表格里写得很清楚:每个 PHP 小版本都有明确支持的 Xdebug 版本区间,装之前先去核对,别凭感觉下 dll 或者编译。PhpStorm 这边,2018.3 之后的版本对 Xdebug 3 支持得都比较好,更老的版本可能需要手动改端口配置。用一个对照表把这三者列出来,装之前先对一眼,能省掉很多来回:
| 组件 | 关键信息 | 常见坑 |
|---|---|---|
| PHP | 用php -v确认确切小版本 | CLI 和 FPM 版本可能不同 |
| Xdebug | 3.x 对应 PHP 7.2+,各小版本有明确支持表 | 装了不匹配的版本会加载失败 |
| PhpStorm | 2018.3+ 默认端口 9003 | 老版本默认 9000,需要手动调 |
| 端口 | Xdebug 3 默认 9003 | 9000 常被 php-fpm 占用 |
2. PHP 端:装 Xdebug 和写 php.ini,逐行拆开看
这一部分是我建议花最多时间的地方,因为 PHP 端配好了,IDE 端基本就是点点鼠标的事;PHP 端有问题,你在 IDE 里怎么调都是白费劲。核心动作只有两个:把扩展装进正确的 PHP、把配置写进正确的php.ini。听起来简单,但"正确"这两个字里藏着一堆细节。
先说一个判断原则,后面会反复用到:一切以phpinfo()的输出为准,而不是以你记得的路径为准。很多服务器上装了好几个 PHP 版本,/etc/php/8.2/下面有 CLI、FPM、Apache 三套配置,你改的那套不一定就是正在跑的那套。打开phpinfo()页面,看顶部 "Loaded Configuration File" 那一行指向的文件,那才是真正生效的php.ini。命令行场景则用php --ini看,输出的第一行就是 CLI 加载的文件。
配置写完之后别急着去 IDE 里试,先在 PHP 端做一次独立验证。验证的目标就一个:确认 Xdebug 加载了、模式对了、目标地址和端口写对了。这一步确认下来,后面出问题基本可以断定是 IDE 侧或者网络侧的事,排查范围一下子缩小一半。
2.1 先搞清楚你的 PHP 是 CLI、FPM 还是 Apache 模块
这一步看起来是废话,但它决定了你后面要改哪个文件。判断方法很简单:网页请求的 SAPI 用phpinfo()页面里的 "Server API" 那一栏看,通常会显示FPM/FastCGI或者Apache 2.0 Handler;命令行脚本直接跑php -r 'echo php_sapi_name();',输出一般是cli。
确认之后,对应的配置文件位置大致是:CLI 通常是/etc/php/8.x/cli/php.ini,FPM 是/etc/php/8.x/fpm/php.ini,Apache 模块是/etc/php/8.x/apache2/php.ini。这几个文件互相独立,各自加载各自的扩展列表。你在 CLI 里pecl install xdebug装好并加了zend_extension之后,FPM 那边还需要单独启用一次,否则网页请求依然没有调试能力。Ubuntu 系的发行版包管理装法一般是apt install php8.2-xdebug,它会把扩展放进扩展目录,但还需要在对应 SAPI 的配置目录里做个软链接或者写一行配置来启用。
我自己的习惯是把这几个 SAPI 的配置都确认一遍,因为日常既有跑脚本的需求也有跑网页的需求,两边都能调省得来回切。确认方式就是分别跑php --ri xdebug和打开一个phpinfo()页面看 Xdebug 段落,两边都出现版本信息才算真的装对了。
2.2 三种安装方式,按你的环境选一种
安装方式主要看你手上的环境是什么形态,没有必要追求某一种,能装上并且可复现就行。
第一种是pecl。在 Linux 或者 macOS 上,如果 PHP 是源码编译或者通过 Homebrew 装的,pecl install xdebug是最直接的路径。它会下载源码、编译、把扩展放进扩展目录,然后提示你在php.ini里加一行zend_extension=xdebug.so。这个方式的优点是版本可以自己指定,比如pecl install xdebug-3.3.2装一个特定版本;缺点是需要编译环境,缺php-dev或者编译工具链的话会失败。
第二种是用系统包管理器。Debian/Ubuntu 上是apt install php8.2-xdebug,CentOS 系上常见的是yum install php-xdebug。这种方式最省事,包管理器会自动处理依赖和扩展目录,但版本会滞后于官方发布,而且不同源里的版本质量参差。装完之后记得看对应 SAPI 的conf.d目录里有没有生成启用文件。
第三种是直接下预编译二进制或者 dll。Windows 上基本只能走这条路,从 Xdebug 官方下载页选对应 PHP 版本、线程安全模式、架构的 dll,丢进 PHP 的ext目录,然后写zend_extension=php_xdebug.dll。下之前一定要用页面上的版本检测工具确认,因为 Windows 上的 PHP 分 NTS 和 TS、分 x64 和 x86,选错了会直接加载失败。
注意:不要用
extension=xdebug.so,必须用zend_extension。Xdebug 是需要挂在 Zend 引擎层的扩展,写错了不会生效,而且有些 PHP 版本会直接报错退出。
2.3 php.ini 里那几行到底在干什么
把配置逐行拆开讲,是因为照抄容易,出问题的时候不知道哪一行在起作用就麻烦了。Xdebug 3 的典型配置是这样:
zend_extension=xdebug.so xdebug.mode=debug xdebug.start_with_request=trigger xdebug.client_host=127.0.0.1 xdebug.client_port=9003 xdebug.idekey=PHPSTORM xdebug.log=/tmp/xdebug.log xdebug.log_level=7xdebug.mode=debug是开关,决定 Xdebug 提供哪些能力,前面已经说过。这里想补一句的是debug可以和别的模式组合,比如xdebug.mode=debug,develop,同时拿到调试能力和好看的var_dump输出。
xdebug.start_with_request=trigger是最值得展开的一行。它有三个取值:yes表示每个请求都尝试连接调试器,no表示从不主动连接,trigger表示只有请求里带了触发标识时才连接。日常开发推荐trigger,因为yes会让你每一个页面请求都卡一下去等调试连接,即使你根本没打算调。触发标识可以是 URL 参数、Cookie 或者 HTTP 头,名字就是XDEBUG_SESSION,值对应idekey。浏览器插件做的就是帮你自动带上这个 Cookie 这件事。
xdebug.client_host是"连到哪台机器的哪个端口",本地开发写127.0.0.1就行。这里是本地回环地址,Xdebug 在 PHP 进程里往这个地址发连接请求,PhpStorm 在那个地址上监听。容器场景下这个值得改,后面单独说。
xdebug.idekey=PHPSTORM是给连接打一个标签,PhpStorm 收到后会按这个 key 来匹配。这个名字不是必须叫 PHPSTORM,但它和 PhpStorm 的默认设置一致,改它没有额外好处,除非你要在同一台机器上区分多个调试会话。
xdebug.log和xdebug.log_level是排查神器。把日志打开,Xdebug 会把"我拿到请求了""我尝试连接 127.0.0.1:9003""连接失败了"这些信息写进文件。调试配不通的时候,先看这个文件,能省掉大量猜测。
2.4 装完之后的三步自检,一步都不能省
第一步,php -m | grep -i xdebug,确认扩展列表里有 xdebug。如果没有,说明zend_extension那行没生效或者路径写错了。
第二步,php --ri xdebug,这个命令会把 Xdebug 的所有配置项和当前值全部列出来。重点看xdebug.mode是不是包含debug、xdebug.client_port是不是 9003、xdebug.start_with_request是不是你要的值。如果用 FPM,这一步要用网页版的phpinfo()来做,CLI 的输出不反映 FPM 的配置。
第三步,给一个真实请求打一次触发,观察xdebug.log里有没有连接记录。这一步是在验证"连接有没有真的发出去",比在 IDE 里瞎点更有针对性。日志里出现尝试连接的记录但 IDE 没反应,问题就在 IDE 侧或者网络侧;日志里压根没有尝试连接的记录,问题就在 PHP 端的触发条件上。
| 自检步骤 | 命令或方式 | 判断标准 |
|---|---|---|
| 扩展加载 | php -m找 xdebug | 列表里有 xdebug |
| 配置生效 | php --ri xdebug | mode 含 debug,端口对得上 |
| 连接触发 | 看xdebug.log | 有尝试连接的记录 |
3. PhpStorm 侧:Servers、端口和路径映射
PHP 端确认无误之后,IDE 侧其实只有几个地方要配,但每一个都容易配错。我的建议是先把 PhpStorm 的调试监听开起来,再去浏览器里触发请求,顺序反了会看到一堆无意义的连接失败记录。
PhpStorm 里跟调试相关的主要有三块:Settings > PHP > Debug管端口和监听行为,Settings > PHP > Servers管主机名和路径映射,工具栏上的电话图标管"现在要不要接电话"。很多人把前两块配得很认真,却忘了点电话图标,结果请求发过来没人接,自然什么都不发生。这个细节后面在故障排查里还会展开。
3.1 Servers 配置和路径映射,九成"断点不生效"都出在这
路径映射解决的问题是:PHP 在执行时告诉调试器"我停在了/var/www/html/app/Service/UserService.php第 88 行",而 PhpStorm 需要把这个路径翻译成你本地项目里的app/Service/UserService.php。如果这个翻译关系不存在或者写错了,IDE 收到断点信息也找不到对应的文件,只能忽略掉。
配置的位置在Settings > PHP > Servers,新建一个 Server,填几个关键字段:Name随便起但后面运行配置里要引用它;Host要和你浏览器里访问的域名或 IP 完全一致,这个匹配是字符串级别的,localhost和127.0.0.1会被当成两个不同的主机;Port填网页访问的端口,80 或 443 或者你自定义的;Debugger选 Xdebug。然后勾选Use path mappings,在下面的文件树里把本地项目根目录和服务器上的绝对路径对应起来。
Docker 场景下这个映射尤其重要。你的项目在宿主机上是/Users/you/project,容器里挂在/var/www/html,那映射关系就是左边/Users/you/project对应右边/var/www/html。写错一个字母,断点就是不生效,而且 IDE 不会给你明确的报错,只是安静地什么都不做。
如果本地直接跑 PHP 内置服务器或者 FPM,路径完全相同,映射关系就是左右一样,但依然要勾上这个选项并写出来,因为有些版本的 PhpStorm 在没有映射信息时会拒绝处理断点。
3.2 Debug 面板里那几项,每一笔都有讲究
Settings > PHP > Debug里最核心的是Debug port,Xdebug 3 下填 9003,和 PHP 端的xdebug.client_port保持一致。这个不一致是最典型的"两边都配了但连不上"。
Can accept external connections这个选项,只在 PHP 进程和 IDE 不在同一台机器上时才需要勾——比如 PHP 跑在虚拟机或者容器里。勾上之后 PhpStorm 会监听所有网卡而不只是本地回环,容器才能连进来。如果只是本地开发,不勾更安全。
Force break at first line when no path mapping specified和Force break at first line when a script is outside the project,这两个选项的作用是"找不到映射关系时先在入口文件停一下",方便你确认连接到底通没通。调试初期可以都勾上,等映射关系理顺了再取消,否则每个请求都会在入口多停一次,反而烦。
Notify about breakpoints in PHP 4.4 and older之类的老版本兼容选项,现代项目直接忽略就行。
这个面板里还有一个很多人不知道的功能:Validate按钮。点开它会给你一段可直接访问的 URL,你把它贴到浏览器里跑一次,PhpStorm 会在同一页面上告诉你哪一层出了问题——扩展有没有装、端口通不通、路径映射对不对。配不通的时候,这个自检工具比任何猜测都靠谱,建议养成习惯先在它这里过一遍。
3.3 调试命令行脚本、PHPUnit、后台进程的差异
网页请求走的路径是"用户访问页面,PHP 处理请求时触发调试连接",命令行脚本没有这个"请求"的概念,所以触发方式不一样,需要用环境变量主动告诉 Xdebug "这次要调试"。
最直接的方式是在运行命令时带上环境变量:
XDEBUG_SESSION=PHPSTORM XDEBUG_MODE=debug php artisan some:command或者在php.ini里把xdebug.start_with_request临时改成yes,跑完之后再改回来。这个方案简单但容易忘,改完忘了改回去会让后面的所有命令执行都变慢,因为每个命令都在尝试连接一个可能没开的调试端口。
更规范的做法是在 PhpStorm 里建一个PHP Script运行配置,勾上Use debugger相关选项,PhpStorm 启动进程时会自动注入必要的环境变量和调试标识。PHPUnit 的调试同理,在Run Configuration里给 PHPUnit 配置勾上调试器,或者直接在测试方法上右键选Debug。后台队列消费进程通常是常驻的,调试方式是启动时带上调试标识,然后在消费任务的分发代码里打断点,任务被消费到的时候就会停下来。
注意:常驻进程的调试连接只在启动那一刻建立一次,调试结束、进程退出之后连接就断了。如果你调试完让进程继续跑,它不会反复重连,需要重新启动进程才能再次调试。这个特性对长时间运行的服务来说有点反直觉,我第一次踩的时候以为是配置坏了。
3.4 四种断点,各管一类问题
行断点是最基本的,点一下行号旁边的空白处就出来了。它的局限是每次执行到这一行都会停,如果这一行在一个循环里或者被高频调用,你按断点继续的手会按到酸。
条件断点解决上面的问题。右键断点,在Condition里写一个 PHP 表达式,比如$orderId === 12345或者$user === null,只有表达式为真时才停下来。这一招在排查"某个特定 ID 的数据有问题"这类场景时非常高效,可以把断点留在公共方法里而不会打扰其它请求。
异常断点让你在抛出某类异常的那一刻冻结,而不是等到异常被 catch 之后。PhpStorm 里通过Run > View Breakpoints,在PHP Exception Breakpoints那一栏配置,可以按异常类型、按 Notice/Warning 等级来设。排查"某个地方抛了一个被吞掉的异常"时这个特别有用,因为平时你根本看不到它的存在。
日志断点是不停下来的断点。右键断点,把Suspend取消勾选,勾上Evaluate and log,写一个表达式比如'用户ID: ' . $userId。执行到这里时程序不会停,但会把表达式的结果写进 IDE 的调试控制台。它适合那种"我只想看几个关键值,但不想打断执行流"的场合,也能在一定程度上替代临时日志输出,而且改起来比改代码快得多。
4. 真正连上之后,调试界面上每个按钮在做什么
第一次成功命中断点时,面对工具栏那一排图标,人是懵的。这一节把每个按钮的含义讲清楚,配合我自己的使用顺序,让第一步不至于卡在界面上。
调试窗口一般由几个面板组成:左侧是Frames,也就是调用栈;中间是Variables,当前作用域的变量;右侧是Watches,你手动加的监控表达式。工具栏在顶部,控制执行流程。理解这几个面板的定位之后,操作逻辑就顺了:你在某个栈帧上,看这个帧里的变量,改一改,然后决定下一步往哪走。
需要提醒一个心态上的事:调试工具的价值不是"能停下来",而是"能任意回看和前进"。停下来只是手段,真正解决问题靠的是你在各个栈帧之间来回切换、观察状态变化的能力。如果只是单纯停车看两眼,还不如直接打印。
4.1 单步工具栏,每个图标对应什么操作
先把默认快捷键列出来,这些键位是可以改的,但默认值用熟了之后效率很高:
| 操作 | 默认快捷键 | 含义 |
|---|---|---|
| Step Over | F8 | 执行当前行,遇到函数调用不进入 |
| Step Into | F7 | 执行当前行,遇到函数调用进入函数体 |
| Step Out | Shift + F8 | 执行完当前函数并跳出到调用方 |
| Force Step Into | Alt + Shift + F7 | 进入包括内部函数在内的所有调用 |
| Run to Cursor | Alt + F9 | 一直执行到光标所在行 |
| Resume Program | F9 | 继续执行直到下一个断点 |
| Evaluate Expression | Alt + F8 | 打开表达式求值窗口 |
Step Over和Step Into的区别是使用频率最高的一对。当前行如果是个方法调用,你关心这个方法内部怎么走的,用Step Into;你不关心,只想看它返回什么,用Step Over。判断标准很简单:这个方法是框架代码或者第三方库,Step Over;是你自己写的、正在怀疑的代码,Step Into。
Force Step Into值得单独说。默认情况下,PHP 的一些内部函数(比如数组处理函数)是进不去的,按Step Into会直接跳过。用这个强制进入的变体可以钻进一些平时进不去的地方,排查那些"看起来没问题但结果不对"的内置行为时很有用。
Run to Cursor是我用得最多的一个。在函数末尾打一个断点,然后按这个键,程序会一路执行到那里;如果中间出了错或者卡住,你立刻就知道问题在中间某处。这招适合快速确认"这一段代码跑下来了没有",比一步步按Step Over高效得多。
4.2 Variables、Watches 和 Evaluate Expression 的实战用法
Variables面板显示的是当前栈帧作用域里的所有变量,展开对象可以看到它的属性,展开数组可以看到每一项。有个很好用的细节:右键一个变量可以选Set Value,直接改掉它的值然后继续执行。这在测试边界条件时特别好用,比如你想看看$count为 0 时的行为,不用去构造数据,直接在面板里改成 0,继续执行就看到了结果。
Watches面板用来挂你关心的表达式。它和Variables的区别是:后者只能看你当前帧里已经存在的变量,前者可以写任意表达式,包括调用方法。比如你想持续观察$order->getTotal()的值变化,就把它加到 Watch 列表里,每次停下来它都会重新求值。注意这个方法会被真实调用,如果方法有副作用(比如写日志、修改状态),观察本身会影响程序行为,这一点要留心。
Evaluate Expression(Alt + F8)是最强的一个。它打开了当前执行上下文,你可以在里面写任意合法的 PHP 代码并立刻执行。常见的用法是临时调一个方法看看返回什么:$repository->findById(1),或者拼一个字符串看看输出,或者检查isset($array['key'])。它相当于把你临时打印的需求现场解决掉,用完就走,不留在代码里。我经常用它来验证"是不是这个查询没查到数据",比在代码里加一行日志再跑一遍快得多。
提示:
Evaluate Expression里执行的方法是有副作用的,别在里面调删除、写入这类方法。观察行为不等于安全行为。
4.3 调用栈 Frames 和并发请求下的调试
Frames面板展示的是当前请求的调用链,从最外层一直排到当前停下来的一帧。每一帧都记录着当时这个函数里的局部变量,你点任意一帧,Variables面板就会切换成那一帧的状态。这是排查"参数是在哪一层被改坏的"最有效的手段:从当前帧往上点,一层层看参数怎么变的,很快就能定位到出问题的那一次修改。
有个操作容易忽略:Drop Frame(不是所有场景都可用,通常出现在较新的 PhpStorm 里)。它的作用是把当前栈帧丢掉,让执行回到调用方,相当于给了一次"重来"的机会。配合Set Value使用,可以在不改代码的情况下把参数改对,重新走一遍前面的逻辑。这个能力在调试复杂条件分支时能省下大量重启请求的时间。
并发场景下的调试要单独说。假设你调试的是一个队列消费进程,同时跑了多个 worker,或者你在浏览器里同时开多个标签访问页面,那么调试连接会是多个。PhpStorm 会为每个连接开一个单独的会话标签页,你在顶部标签栏能看到它们。这时候要注意别在错误的会话里操作,也不要同时让多个任务停在断点上——它们会互相占着连接,让排查变得混乱。我的习惯是并发调试时把 worker 数量临时降到 1,跑通逻辑之后再恢复。
4.4 用日志断点做到"看得见但不打断"
前面提到过日志断点,这里展开讲一下它的实战价值。有些代码路径不能停,一停请求就超时,或者一停就把整条链路堵死了。这种情况下日志断点几乎是唯一的选择。
配置方式是在断点上右键,取消Suspend,勾上Evaluate and log,然后在输入框里写你要输出的表达式。比如你想确认某个循环跑了多少次、每次的参数是什么,可以写'第 ' . $i . ' 次,参数:' . json_encode($params, JSON_UNESCAPED_UNICODE)。程序继续跑,输出会出现在Debugger Console面板里,按时间顺序排列。
它比临时写日志强在哪?强在不用改代码、不用重新请求、改表达式只是改一个输入框。而且它输出的内容会带调用栈信息,你能看到这一次输出是从哪一行、哪个函数发出来的。缺点是它只在调试会话活着的时候有效,会话结束日志也就没了。如果要把结果留下来,得用Watches或者干脆写文件。
我通常的用法是:先用日志断点快速扫一遍数据流,大概定位到问题区间后,再在那里加一个会停下来的行断点精查。两步走比一上来就单步跟踪效率高得多。
5. 出问题了怎么查:一条从外到内的排查链路
配置这种东西,一次成功的概率不高,大部分时间花在排查上。这一节我要强调的重点不是"最终答案是什么",而是"按什么顺序查"。顺序对了,通常三五个动作就能定位到问题层;顺序不对,会在无关的地方反复折腾。
排查的核心思路是二分法:先确定问题在"PHP 端"还是"IDE 端",再在确定的那一半里继续二分。判断问题在哪一端最快的办法就是看xdebug.log:日志里有尝试连接的记录,说明 PHP 端一切正常,问题在连接对接或者 IDE 侧;日志里什么都没有,说明请求压根没触发调试,问题在触发条件上。
5.1 点了调试没有任何反应,按这个顺序查
第一步,看 PhpStorm 工具栏上的电话图标有没有变绿。这个图标控制"是否监听调试连接",没点开的话,请求送过来也没人接。这是最常被忽略的一步,因为它不在设置面板里,而在主界面上。
第二步,确认端口一致。PHP 端的xdebug.client_port和 IDE 的Debug port必须一样,Xdebug 3 的约定值是 9003。顺手可以确认一下这个端口没有被别的进程占用,用系统命令查一下比较放心。
第三步,确认触发条件。如果xdebug.start_with_request=trigger,那请求里必须带XDEBUG_SESSION参数或者 Cookie。浏览器插件装了吗?插件的 idekey 设成 PHPSTORM 了吗?临时验证可以手动在 URL 后面加?XDEBUG_SESSION=PHPSTORM,能看到断点命中就说明插件那边有问题。
第四步,看xdebug.log。日志里出现Tried to connect之类的记录但 IDE 没反应,问题在 IDE 侧;日志里只有请求记录没有连接尝试,说明触发条件没满足;日志文件压根没生成,说明xdebug.log的路径不可写或者扩展没加载。
第五步,用 PhpStorm 自带的Validate工具过一遍。它会逐项告诉你哪里不对,比自己瞎猜快。
把这五步做完,绝大多数"点了没反应"的情况都能定位到具体位置。
5.2 断点命中了,但停在了意料之外的文件里
这种情况通常有两个原因。最常见的是路径映射配错,PHP 报的路径和 IDE 项目的路径没能正确对应,IDE 就退而求其次停到了它能找到的位置,通常是入口文件。判断方式是看停下来时Frames面板里的文件路径,如果是index.php的第一行,八成就是映射问题。
第二个原因是断点打在了 vendor 目录或者框架的自动加载代码里,而这些位置在很多配置下会被 IDE 忽略。PhpStorm 会在断点上显示一个小提示,说明它被当作"不在项目内的断点"处理了。解决办法是在Settings > PHP > Debug里调整"是否忽略未映射的断点"这个选项,或者明确地把 vendor 目录加进项目路径。
还有一种隐蔽的情况是 opcache 缓存。某些环境下修改了代码但 opcache 没刷新,调试时看到的行号和实际执行的行对不上,表现为"断点停的位置比源码偏了几行"。如果遇到位置诡异的情况,先把 opcache 关了或者清一次再说。
注意:
Force break at first line这类选项在调试初期很有用,但它会让你每个请求都先停在入口,如果发现"每次都停在 index.php",先去检查是不是这个选项开着。
5.3 请求变慢、超时甚至 502
Xdebug 对性能有影响,这是既定事实,但正常的调试不应该慢到超时的程度。如果开了调试之后请求明显变慢或者直接报网关错误,通常是几个原因叠加。
一个原因是 PHP 端在尝试连接一个没在监听的调试端口,每次连接都要等超时。Xdebug 有个连接超时配置(xdebug.connect_timeout_ms),默认值不算大,但如果每个请求都等一次,累积起来也很可观。解决方式就是不要把xdebug.start_with_request设成yes而不在 IDE 里监听,用trigger模式按需开启。
第二个原因是xdebug.mode里开了develop或者coverage这种对性能影响更大的模式。排查一下是不是忘了关。
第三个原因出现在容器或者远程场景:网络往返本身有延迟,加上调试连接的握手,整体响应时间被拉长。这种情况下,网关(比如 Nginx 的fastcgi_read_timeout)的超时值可能需要临时调大,但这是临时措施,根本还是要缩短调试会话的持续时间,把断点打在更精确的位置。
我踩过最典型的一次是队列消费任务,开了调试之后任务处理时间从几百毫秒涨到十几秒,原因是每个任务处理时都在尝试连接一个已经关掉的调试会话,等超时。后来改成只在需要调试时通过环境变量临时打开,问题就没了。
5.4 容器、WSL 和远程主机里的调试要点
本地直跑是最简单的情况,路径一致、网络回环、没什么坑。一旦进了容器或者虚拟环境,就多出"网络怎么打通"和"路径怎么映射"两件麻烦事。
容器场景下,PHP 跑在容器里,"本机"指的是容器自己,127.0.0.1指向的是容器内部,而 IDE 跑在宿主机上,所以xdebug.client_host必须指向宿主机的地址。Docker Desktop 环境(macOS 和 Windows)通常可以用一个内置的主机名来指代宿主机,Linux 上则需要用容器的默认网关地址或者启动容器时加上 host 映射参数。同时 PhpStorm 那边要把Can accept external connections勾上,让它监听所有网卡。
路径映射在容器场景下是必配项,因为项目在容器里的路径和宿主机的路径不一样,前面已经详细说过。比较稳妥的做法是把映射关系写清楚之后,用Validate工具跑一遍确认。
WSL2 场景下,PHP 通常在 WSL 里跑,IDE 在 Windows 侧跑。xdebug.client_host需要指向 Windows 主机的地址,这个地址在 WSL 的 DNS 配置里能查到。另外要注意 Windows 防火墙可能拦截来自 WSL 的连接,调试连不上的时候先排除这个因素。
远程主机场景下,通常的做法是通过连接转发把远端端口映射到本地,让client_host看起来还是127.0.0.1。这样配置最简单,网络链路也最可控。具体用什么工具做端口映射不重要,重要的是理解"让 Xdebug 认为调试器就在本地"这个思路。
6. 把调试环境管起来,别让它反过来拖慢你
调试能力配好之后,怎么管理它就成了新问题。我见过不少人因为图方便把调试相关的配置在生产环境也留着,结果性能下滑一大截还找不到原因。这一节聊几个习惯,都是踩过坑之后形成的。
核心原则是:调试能力应该是"按需开启、用完即关"的,而不是"一直开着、随时可用"。前者对性能影响可控,后者等于给每个请求都背了一个包袱。
6.1 生产环境里的调试扩展,代价比你想的大
Xdebug 挂载在 Zend 引擎上,每次函数调用、每次变量赋值它都有机会介入,即使你只是开了最基本的模式,性能开销也是实打实的。开了debug模式而没人在监听时,每个请求都要等一次连接超时;开了coverage模式跑测试,执行时间翻几倍是常见的。生产环境一旦不小心留着这些东西,表现就是"服务器没改什么但响应就是在变慢",而且很难通过看代码发现。
我的做法是从环境镜像层面就不装调试扩展。开发用的镜像和线上用的镜像分开构建,需要调试的时候在开发镜像里加上。如果因为某些原因必须在同一套镜像上切换,那就用环境变量控制,把它作为启动参数的一部分,而不是写死在配置文件里。
还有一种情况是排查线上问题不得不开调试。这时候的原则是:只在单台实例上临时开、只开搭好触发条件的trigger模式、排查完立刻关掉、同时盯紧这台实例的响应时间。整个过程要当成一次有风险的操作来对待,而不是随手改个配置。
6.2 用环境变量和触发参数控制开关
XDEBUG_MODE这个环境变量可以覆盖php.ini里的xdebug.mode设置,这是容器化环境里控制调试开关最灵活的方式。启动容器时不传这个变量,Xdebug 就是常规模式;需要调试时把XDEBUG_MODE=debug传进去,重启容器,调试能力就打开了。这样配置文件本身不用改,切换只体现在启动命令上,不容易忘。
同理,XDEBUG_SESSION环境变量是给命令行场景用的,跑脚本时带上它,这次执行就会尝试连接调试器。这比临时改php.ini再改回来干净得多。
浏览器场景下,触发的方式主要是 Cookie 和 URL 参数。开发插件负责在前端自动加上触发标识,我一般把插件的默认状态设成"关闭",需要调试的页面手动点开。这样日常浏览不会触发调试连接,只有明确要调的时候才开。听起来是多了一步操作,但省下来的是每个请求都在等连接的等待时间。
提示:如果你在团队里维护开发容器,可以在文档里写清楚"怎么打开调试"和"记得关掉",比每个人都记住一堆配置项靠谱。
6.3 用日志断点和 xdebug.log 留下排查证据
调试会话是短暂的,你从断点里看到的信息在关闭会话之后就没了。有些问题需要事后分析,这时候要么把关键值写进日志,要么用日志断点把输出落到能留存的地方。
xdebug.log本身是排查配置问题用的,但它偶尔也能帮你确认"某个请求到底有没有触发调试""连接有没有被拒绝",这些信息在排查配置问题时非常直接。日志级别调高一点能看到更详细的连接过程,包括握手、失败原因等。平时可以把它关掉减少 I/O,需要排查时再打开。
日志断点的输出除了出现在调试控制台,也可以配置成写到文件。对于那种"跑一次要很久、中途不能停"的任务,把感兴趣的变量用日志断点输出到文件,跑完之后再分析,比反复重跑要省时间。我处理大批量数据脚本时基本都用这个套路:先在几个关键节点布上日志断点,跑一轮拿到数据流,再根据数据流的异常点缩小范围,最后才用行断点精查。
这套流程走下来,调试不再是"出问题了才手忙脚乱去配",而是工具箱里随时能拿出来的一个常规手段。配一次、管好、按需开关,剩下的时间就可以留给真正的问题本身了。我自己后来把这些配置整理成了一个开发容器的启动脚本,新同事入职直接拉起来就能用,省掉了每个人各自踩一遍坑的过程。