sixty

面向 Python 服务

是你的代码,还是在等?

别的 agent 都能告诉你某个函数变慢了。这一个会告诉你是变慢的哪一半 —— 是正在计算的代码,还是它花在等一把锁、等一个连接池位置、或者等一个拿着 GIL 不放的 C 扩展上的时间。这两者需要完全相反的修法,而在后者发生的整个过程中,其他所有按次统计的数字都仍然是对的 —— 这正是别的东西抓不到它的原因。

它测量什么

每次调用的 CPU
time.thread_time() 是按线程算的,读一次大约 100 纳秒,而一个同步 span 在它的整个时长内独占自己的线程 —— 所以这是一个精确的数字,而不是把整个进程的时钟在同时在跑的东西之间摊出来的一份。
每次调用的等待
一个函数自身时间里剩下的那部分:一把比以前多锁住了一些工作的锁、一个没有空位的连接池、一个拿了 GIL 又不还的 C 扩展。它对这里其他所有测量都是隐形的,因为代码并没有变慢 —— 它在排队。
每一条 psycopg 查询
2 和 3 两个版本,打补丁的位置是游标而不是连接,所以架在 psycopg 之上的 SQLAlchemy 也会被测到。返回行数、列数、响应大小,以及规范化后的语句。
哪个函数发出了哪条查询
一条查询会记在它上面那个函数头上,所以 N+1 会落在带着循环的那个函数上,而不是摊在一条单独看一直都没问题的查询上。
你的 HTTP 路由,按它们被写出来的样子
Flask 按匹配到的 url_rule,Django 按 urls.py 里写的那个路由,ASGI 或 WSGI 的东西则按你包住的对象。于是动态里显示的是 GET /users/<int:user_id>/orders,而不是每个用户 ID 一个操作。

它能找到什么

这里的每一项都会和上一个版本作比较,所以发现指向的是那次改动,而不是某一天。

你自己的代码变慢了,而且拆成了两半

这条发现会告诉你该伸手去拿哪一种修法。计算时间涨了、等待没涨:去看函数内部。等待涨了、CPU 没动:去看连接池、锁,或者这个进程现在在和谁共享线程。

上个版本还没有的 N+1

一个原本每次请求发一条查询的函数,现在发二十条。每一条都很快、索引也都对,所以没有任何耗时会变化到让人察觉 —— 变的只有次数。

一条开始读整张表的查询

每次调用的行数从 30 变成了 30,000。在一个数据量像开发环境、又已经热起来的数据库上,这几乎不占时间;等到后面堆了一年的数据,它就会把生产压垮。

某个东西现在被放在循环里调用

每次调用没有变化 —— 同样的速度、同样的数据 —— 但被调用了几百次,而以前只调用一次。

一条悄悄什么都不返回的查询

成功了,没有抛异常,也不慢,只是从原本有行变成了空。这类服务出问题的方式,多数都是让数字变小而不是变大。

错误,按它们的来处归类

按代码位置而不是按报错文案归类,所以一个 bug 就是一条记录,不管它会被格式化成多少种不同的字符串。

你的动态里会出现什么

不是一块需要你去读的仪表盘。每条发现一张卡片,上面有数字、有改变了它们的那个版本,还有你的编码助手写出修复所需要的证据。

这是 apps/demo-python 故意带上的两个回归,对着它的 seed 脚本创建的数据集 —— 大约 1,000 个用户和 30,000 条订单。你可以跑起来,看着它们出现。

行数orders.get_user_orders
30 行30,000 行1000×

一次重构把 WHERE 子句弄丢了,于是每次调用都把整张表拉下来,再用 Python 过滤。时钟几乎没动;行数就是全部的信号。

自版本 v2 起,对比 v1

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

一次批量查找变成了逐个订单查。每条查询单独看都很快,索引也都对,而这恰恰就是它能通过评审和 staging 的原因。

自版本 v2 起,对比 v1

如何安装

把它交给你已经开着的编码助手。它会判断这个项目是 Flask、Django、FastAPI 还是一个裸的 WSGI callable,把中间件放到对的位置,并给服务层打上标记 —— 接着在同一段对话里,把找到的问题修掉。

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:
pip install sixty-sh

# wsgi.py, asgi.py or main.py — before any connection is opened
import sixty
sixty.init()

# then one of these, whichever this project is:
#   Flask   instrument_flask(app)
#   Django  SixtyMiddleware, first in MIDDLEWARE
#   ASGI    SixtyASGIMiddleware
#   WSGI    SixtyMiddleware around the callable

# and the code between the view and the database
@sixty.trace
def get_user_orders(user_id):
    ...

版本标识会自动从 Vercel、Render、Railway、Fly、Heroku 或 GitHub Actions 拿到,其他地方用 SIXTY_RELEASE 指定。第一次比较会在你下一次部署之后到来,因为一个版本没有可比的对象。

它不做什么

  • 一个跨越 await 的 span 会和事件循环期间跑的其他东西共享线程,所以它上报的是「没有 CPU」,而不是一个被夸大的数字。一个 asyncio 服务能拿到按同步函数和按查询统计的 CPU,但拿不到按请求统计的 —— 这是诚实的答案,而不是一个听起来合理的答案。
  • 不采集查询计划。Postgres 就在那儿,EXPLAIN 也能用;只是这个 agent 目前还不会发出它,所以「它不再走索引了」会在 Node 和 Ruby 上出现,在这里不会。
  • 只有 psycopg 被埋了点。架在 psycopg 上的 SQLAlchemy 能被测到,因为下面的游标被测到了 —— asyncpg 和 MySQL 的驱动完全测不到。
  • 在 gunicorn 或 uwsgi 下,每个 worker 各自上报,由收集端合并。这是对的,但当你在读一个描述单个进程的数字时,值得知道这一点。
  • 这里没有任何东西会替你标注函数。Node 靠一个构建期转换拿到这件事,而 Python 没有等价的钩子,所以那个装饰器或者 instrument_module 是你要走的一步 —— 跳过它,动态里就只剩下路由和查询,中间什么都没有。

如果你是因为下面某一条来的

看看你的上一次改动做了什么。

试试看!

在做别的东西?可以看看 Node 服务、Go 服务、Ruby 和 Rails 应用 —— 那些页面不一样,我们能看到的东西也不一样。

面向 Python 的性能监控 —— Django、Flask、FastAPI 和 Postgres — Sixty