sixty

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

一次重构把 WHERE 子句弄丢了,于是每次调用都把整张表拉下来,再用 Go 过滤。在一个热的数据库上延迟几乎没动 —— 露馅的是行数,而且因为它是在行被读取时统计的,所以是实打实的。

自版本 v2 起,对比 v1

N+1orders.EnrichOrders
1 条查询20 条查询20×

一条批量查询变成了逐个订单查。每一条都很快、索引也都对,所以延迟图上什么都不动 —— 动的只有次数。

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

面向 Go 的性能监控 —— net/http、database/sql 和 Postgres — Sixty