面向 Go 服务
两行代码,查询就有了调用者。
这里的安装是显式的 —— 一个中间件、一层驱动包装,以及在值得测量的函数开头加两行 —— 因为 Go 没有可以挂钩的构建步骤,也没有办法在不被传入的情况下拿到调用方的 context。这两行换来的,正是其余一切所依赖的东西:一条上面有函数的查询,好让 N+1 有地方落脚。
它测量什么
- 每一个你标注的函数
- ctx, span := sixty.Start(ctx, "orders.List"),再加一个 defer 的 capture。两行,而且 context 必须一路传下去 —— 这是 Go 里没有隐式 context 的代价,也是这个数字一旦出现就值得信任的原因。
- 任意 database/sql 驱动上的每一条查询
- agent 包的是驱动而不是连接,所以 pgx、lib/pq、MySQL,以及任何注册到 database/sql 上的东西都会被测到 —— 而且真实驱动实现的每一个可选接口都会被镜像出来,所以它原本能做的事不会因此失效。
- 行数,在被读取时统计
- 不是从返回的切片里数的,因为 database/sql 根本没有切片。span 在 rows 关闭时关闭,这正是让这个计数是实测而不是推断的原因。
- 哪个函数发出了哪条查询
- context 带着当前的 span,所以一条查询会记在发出它的那个函数头上。正是这一点,把「这个接口变慢了」变成了「这个函数开始发二十次调用」。
- 你的 HTTP 路由,进和出
- sixty.Middleware 能包住任何说 net/http 的东西 —— chi、gorilla、echo、Go 1.22 的 pattern mux。在路由器知道、而路径本身不体现的模式上,用 sixty.SetRoute 给它命名。
它能找到什么
这里的每一项都会和上一个版本作比较,所以发现指向的是那次改动,而不是某一天。
上个版本还没有的 N+1
一个原本每次请求发一次数据库调用的函数,现在发二十次。锚定在部署上,所以这条发现点名的是版本,而不是某个小时。
一条开始读整张表的查询
每次调用的行数从 30 变成了 30,000 —— 而且这里的计数是在调用方遍历时取的,所以它是你的代码实际读到的数量,而不是这条语句「可能」返回的数量。
你自己的代码在变慢
函数自身里的时间,和它调用的东西里的时间分开,好让你第一次就打开对的文件。
某个东西现在被放在循环里调用
每次调用速度一样、数据一样,但被调用了几百次,而以前只调用一次。
一条悄悄什么都不返回的查询
没有报错,没有慢调用,只是原本有行的地方空了。Go 服务里没有任何东西会把这报告成失败,因为确实什么都没失败。
错误,按它们的来处归类
按代码位置而不是按报错文案 —— 一个 bug 就是一条记录,不管它往里塞了多少个值。
你的动态里会出现什么
不是一块需要你去读的仪表盘。每条发现一张卡片,上面有数字、有改变了它们的那个版本,还有你的编码助手写出修复所需要的证据。
这是 apps/demo-go 故意带上的两个回归,对着它的 seed 命令创建的数据集 —— 大约 1,000 个用户和 30,000 条订单。你可以跑起来,看着它们出现。
orders.GetUserOrders一次重构把 WHERE 子句弄丢了,于是每次调用都把整张表拉下来,再用 Go 过滤。在一个热的数据库上延迟几乎没动 —— 露馅的是行数,而且因为它是在行被读取时统计的,所以是实打实的。
自版本 v2 起,对比 v1
orders.EnrichOrders一条批量查询变成了逐个订单查。每一条都很快、索引也都对,所以延迟图上什么都不动 —— 动的只有次数。
自版本 v2 起,对比 v1
如何安装
把它交给你已经开着的编码助手。它会找到 main()、handler 和 sql.Open,在需要传 context 的地方把它传下去,并给值得测量的函数打上标记 —— 那正是说起来容易、手动做起来乏味的那一步。
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:
go get github.com/sixty-sh/sixty-go
// main(), before the server starts
defer sixty.Init(sixty.Config{}).Shutdown(context.Background())
// requests — anything that speaks net/http
http.ListenAndServe(":8080", sixty.Middleware(mux))
// queries — the driver you already use
db, err := sixty.Open("pgx", dsn)
// functions — the ones worth measuring
func (s *Store) GetUserOrders(ctx context.Context, id int64) (_ []Order, err error) {
ctx, span := sixty.Start(ctx, "orders.GetUserOrders")
defer span.Capture(&err)
...
}版本标识会自动从 Vercel、Render、Railway、Fly、Heroku 或 GitHub Actions 拿到,其他地方用 SIXTY_RELEASE 指定。第一次比较会在你下一次部署之后到来,因为一个版本没有可比的对象。
它不做什么
- context 必须一路传下去。一个没被传入 ctx 的 goroutine,它的工作发生在这个操作之外 —— 对后台任务来说这是对的,对一个你本想测量的扇出来说这是错的。这件事没有隐式 context 的版本,我们也不会去做一个,因为那个靠猜的版本,就是会把一个请求的查询算到另一个请求头上的版本。
- 不采集查询计划。那是那条点名原因而不是症状的发现 ——「这条不再走索引了」—— 而在 Go 服务上它不会出现。
- CPU 和等待没有分开。goroutine 会在线程之间迁移,所以按线程走的时钟无法描述一个 span,硬要上报,那就是一个恰好在关键时刻是错的数字。
- 检测器深入理解的是 Postgres。注册到 database/sql 上的其他数据库依然会上报耗时、行数和次数;只是语句层面的结论会弱一些。
- 这里没有任何东西会替你标注函数。跳过那一步,动态里就只剩下路由和查询,中间什么都没有 —— 而那正是让其余部分值得读的那一半。
如果你是因为下面某一条来的
看看你的上一次改动做了什么。
试试看!在做别的东西?可以看看 Node 服务、Python 服务、Ruby 和 Rails 应用 —— 那些页面不一样,我们能看到的东西也不一样。