面向 PHP 和 Laravel 应用
没有后台线程。也不丢东西。
一个 PHP worker 跑不了定时器,而它学到的一切都会随着这次请求一起消失。这就是这个 agent 在能测量任何东西之前必须先解决的问题:窗口在共享内存里汇集并合并 —— 而且必须无损,否则百分位数就是编的 —— 而发送发生在响应已经出去之后,所以收集端哪天状态不好,对正在读页面的人来说一点代价都没有。
它测量什么
- 每一次请求,以及它下面的方法
- Laravel 和 Symfony 会自己接好请求的 span。Sixty::trace('OrdersQuery#forUser', fn () => …) 用来标记控制器和数据库之间的代码 —— 是一行而不是一个装饰器,因为 PHP 既没有能重写函数的构建步骤,也没有在方法被定义时触发的钩子。
- 每一条查询,而且不替换你的连接
- PDO 是通过在已经存在的连接上设置 statement 类来埋点的。一个 PDO 子类就得自己再开一个连接,等于悄悄把每次部署的连接数翻倍 —— 而这正是一个监控工具绝对不该做的事。
- Laravel 和 Doctrine,任意驱动
- Laravel 的连接在 ConnectionEstablished 时被接住,行数取自 rowCount()。Doctrine DBAL 则由 bundle 注册一个中间件,行数取自结果自己的 count。两者都能给到语句形状和归属。
- MongoDB,包括 Doctrine ODM
- 通过驱动的命令监控。identity 只用键来构造,所以你的值没有任何路径进得去 —— 而且会统计游标往返次数,那正是能抓到「一次读取分三百次送达」的信号。
- 数据库为了回答做了什么
- Postgres 上的通用计划 EXPLAIN,按语句缓存,并且发在调用方的 span 之外,所以「这条语句不再走索引了」能送到你面前,而你的参数一次都没有被绑定过。
它能找到什么
这里的每一项都会和上一个版本作比较,所以发现指向的是那次改动,而不是某一天。
一条不再走索引的查询
那条点名原因而不是症状的发现。它常常在一切都还很快的时候就到了,因为表还小 —— 然后在几周之后,当它不再小时,变成一次故障。
上个版本还没有的 N+1
每次请求一条查询变成了二十条。每一条都很快、每一条都走索引,唯一变了的是次数。
一条开始读整张表的查询
每次调用的行数从 30 变成了 30,000,通常是重构时一个 where 从查询里跑到了 PHP 里。
一次读取变成了几百次网络等待
MongoDB 是按批返回游标的。当一个结果集超过一批时,一次读取就变成几十甚至几百次连续往返 —— 文档完全一样,除了时钟什么都没动。
你自己的代码在变慢
方法自身里的时间,而不是它调用的东西里的时间,分开列出,好让你第一次就看对文件。
一条悄悄什么都不返回的查询
成功了,没有抛异常,也不慢,只是从原本有行变成了空。
你的动态里会出现什么
不是一块需要你去读的仪表盘。每条发现一张卡片,上面有数字、有改变了它们的那个版本,还有你的编码助手写出修复所需要的证据。
这是 apps/demo-laravel 故意带上的两个回归,对着它的 seeder 创建的数据集 —— 大约 30,000 条订单。你可以跑起来,看着它们出现。
OrdersQuery#forUser重构时那个约束条件从查询里跑到了 PHP 里,于是每次调用都把整张表读进来。同样的答案,同样通过了代码评审,而在开发数据库上只是几毫秒的事。
自版本 v2 起,对比 v1
OrdersQuery#enrich一次批量查找变成了逐个订单查。每条查询都很快、也都走索引,所以没有哪次请求看起来不对 —— 动的只有次数。
自版本 v2 起,对比 v1
如何安装
Laravel 会自动发现 service provider;Symfony 需要把 bundle 加进 config/bundles.php。之后两者都会接好请求的 span、查询埋点和发送 —— 没有 initializer 要写,也没有东西要调用。
claude mcp add sixty \
-e SIXTY_API_KEY=sixty_sk_… \
-e SIXTY_ENDPOINT=https://ingest.sixty.sh \
-- npx -y @sixty-sh/mcp
# or by hand:
composer require sixty-sh/sixty
SIXTY_API_KEY=sixty_sk_…
SIXTY_SERVICE=checkout-api
# On Laravel, that is the install — the provider is discovered.
# On Symfony, add the bundle to config/bundles.php.
# Anything else:
\Sixty\Sixty::init();
\Sixty\Instrument\Pdo::instrument($pdo);
# Your own code:
\Sixty\Sixty::trace('OrdersQuery#forUser', fn () => ...);再装上 ext-apcu,每个 worker 的窗口就会合并成每个间隔一份负载。没有它 agent 照样能跑,并且会在启动时说一次,而不是假装没事 —— 只是流量会大不少。版本标识会自动来自你的平台,或者来自 SIXTY_RELEASE。
它不做什么
- 在 Swoole 协程下它拒绝启用,并且会说明原因。在那里,很多请求共用一个 worker,并在每个 I/O 边界处切换,于是当前的 span 会把一个请求的查询算到另一个请求的控制器头上。FPM、CLI、RoadRunner 和 FrankenPHP 是每请求一个进程,完全支持。
- 没有 ext-apcu 时,每个请求都会发送自己的窗口。它照样能用,agent 也会在启动时告诉你一次 —— 但跨 worker 的无损合并,正是让百分位数等同于「一个测量一切的单进程会报出来的值」的东西,没有它,你就得在流量上付账。
- PDO::query() 和 PDO::exec() 永远到不了 statement 类,所以一个你自己构造、又在上面调用这两个方法的连接需要用 TracedPdo。prepare() + execute() —— Laravel 和 Doctrine 发出的每一条查询 —— 都是覆盖到的。
- 如果已经有别的东西占着 statement 类 —— 一个 profiler、一条调试栏 —— agent 会放手不管,宁可什么都不测也不把它弄坏。这是正确的取舍,而且它是静默发生的,所以值得知道有这种可能。
- CPU 和等待没有分开,查询计划也只有 Postgres 有。MySQL 和 MongoDB 只能解释一条还带着值的查询,而这个 agent 一看到值就丢掉。
如果你是因为下面某一条来的
看看你的上一次改动做了什么。
试试看!在做别的东西?可以看看 Node 服务、Ruby 和 Rails 应用、Python 服务 —— 那些页面不一样,我们能看到的东西也不一样。