sixty

面向 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
30 行30,000 行1000×

重构时那个约束条件从查询里跑到了 PHP 里,于是每次调用都把整张表读进来。同样的答案,同样通过了代码评审,而在开发数据库上只是几毫秒的事。

自版本 v2 起,对比 v1

N+1OrdersQuery#enrich
1 条查询20 条查询20×

一次批量查找变成了逐个订单查。每条查询都很快、也都走索引,所以没有哪次请求看起来不对 —— 动的只有次数。

自版本 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 服务 —— 那些页面不一样,我们能看到的东西也不一样。

面向 PHP 和 Laravel 的性能监控 —— Postgres、MySQL 和 MongoDB — Sixty