Go expvar.Publish 怎么暴露自定义运行指标
来源:17golang原创
时间:2026-10-04 16:24:57 466浏览 收藏
expvar.Publish 的作用,是把一个实现了 expvar.Var 的对象按名称放进进程级全局注册表。随后,标准处理器会把这些变量连同 cmdline、memstats 一起以 JSON 暴露出来。自定义运行指标最省事的写法通常不是手写 Var,而是用 expvar.Func 包装一个返回结构体或 map 的函数,再用 Publish 注册。
官方文档:https://pkg.go.dev/expvar
先明确接入范围:expvar 是进程内 JSON 指标表
我在给一个小型 HTTP 服务补运行状态时,最初准备自己维护一个 /status JSON 接口。真正动手后发现,计数器的并发更新、字段注册和 JSON 输出都在重复造轮子。迁移到 expvar 后,业务代码只负责更新值,注册表和输出由标准库处理。
expvar.Var 的契约很小:只有一个 String() string,但返回内容必须是有效 JSON。包内置的 Int、Float、String、Map 和 Func 都已经满足这一要求。内置变量的 Set、Add 等修改操作是原子的,适合直接放在并发请求路径里。

先选变量类型,避免把所有状态塞进一个 Func
| 类型 | 适合内容 | 更新方式 | 注意点 |
|---|---|---|---|
expvar.Int | 请求数、进行中请求数 | Add、Set | NewInt 创建时已经注册 |
expvar.Map | 按状态码或任务类型分组 | Add、Set | 键空间要有上限,避免高基数 |
expvar.Func | 读取时计算的组合快照 | 调用回调函数 | 回调要快、无副作用、不能 panic |
自定义 Var | 已有特殊状态对象 | 自行实现 | String 必须返回有效 JSON |
如果只是单个数值,优先使用 NewInt 或 NewFloat。如果需要把几个已经存在的状态组合成一次读取快照,才使用 Func。这样可以看出哪些值持续累积、哪些值在请求到达时临时计算。
用 Publish 注册自定义动态快照
下面把构建标识、就绪状态和累计请求数组合成一个 app_snapshot。expvar.Func 会在变量被读取时调用函数,并用 JSON 编码返回值;因此无需手工拼接 JSON 字符串。
package main
import (
"expvar"
"net/http"
)
var requestsTotal = expvar.NewInt("app_requests_total")
func init() {
// Publish 注册动态快照,名称在整个进程内必须唯一
expvar.Publish("app_snapshot", expvar.Func(func() any {
return struct {
Build string `json:"build"`
Ready bool `json:"ready"`
RequestsTotal int64 `json:"requests_total"`
}{
Build: "dev",
Ready: true,
RequestsTotal: requestsTotal.Value(),
}
}))
}
func hello(w http.ResponseWriter, r *http.Request) {
// 内置 Int 的 Add 是原子更新,可用于并发请求计数
requestsTotal.Add(1)
_, _ = w.Write([]byte("ok"))
}
这里有一个容易重复注册的点:expvar.NewInt("app_requests_total") 内部已经调用 Publish。不要再对同一个指针和同一个名称调用一次 Publish,否则重复名称会触发 log.Panic。给名称加应用前缀,也能减少不同包之间的碰撞。
把请求计数拆成总量、并发量和状态码
单个总数只能说明服务收到过多少请求。为了看出当前压力和结果分布,可以再增加一个进行中计数器与一个有界状态码 Map。状态码的取值集合有限,适合作为 Map 的键;用户 ID、完整 URL、错误文本则可能持续膨胀,不应该直接成为键。
package main
import (
"expvar"
"net/http"
"strconv"
)
var (
requestsInFlight = expvar.NewInt("app_requests_in_flight")
responsesByStatus = expvar.NewMap("app_responses_by_status")
)
type statusWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (w *statusWriter) WriteHeader(code int) {
// 只记录第一次状态码,保持与 net/http 的响应语义一致
if !w.wroteHeader {
w.status = code
w.wroteHeader = true
}
w.ResponseWriter.WriteHeader(code)
}
func withMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 进入时加一,退出时恢复当前并发量
requestsTotal.Add(1)
requestsInFlight.Add(1)
defer requestsInFlight.Add(-1)
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
responsesByStatus.Add(strconv.Itoa(sw.status), 1)
})
}
这段中间件只记录稳定维度。若业务需要更细的路径统计,应先把路由归一成固定模板,例如 /users/:id,不要直接使用带参数的原始 URL。
把 Handler 放到内部路由,再读取目标字段
导入 expvar 会在默认 ServeMux 注册 /debug/vars。如果项目使用自定义 mux,调用 expvar.Handler() 可以安装到非标准位置。下面使用 Go 1.22 及以上的带方法路由,并单独监听本机回环地址,避免把运行参数和内存统计直接暴露到公网。
func main() {
appMux := http.NewServeMux()
appMux.Handle("GET /hello", withMetrics(http.HandlerFunc(hello)))
debugMux := http.NewServeMux()
// Go 1.22+ 使用方法路由,并仅监听本机内部端口
debugMux.Handle("GET /debug/vars", expvar.Handler())
go func() {
_ = http.ListenAndServe("127.0.0.1:9090", debugMux)
}()
// 业务端口和调试端口分离,便于设置不同访问策略
_ = http.ListenAndServe(":8080", appMux)
}
Go 官方文档说明,从 Go 1.22 开始,默认的 /debug/vars 请求必须使用 GET。读取时可以只筛选关心的字段,避免终端一次输出完整的 memstats:
# 读取内部端点,并只保留本文注册的三个指标
curl -fsS http://127.0.0.1:9090/debug/vars \
| jq '{app_requests_total, app_requests_in_flight, app_responses_by_status, app_snapshot}'

回归检查:迁移后重点看六个风险
- 名称唯一:同一个进程里的所有发布变量共用注册表,重复名称会直接 panic。
- JSON 有效:自定义
Var.String必须返回合法 JSON;优先用Func让标准库编码。 - 回调足够快:
Func在读取指标时执行,不要在里面访问慢数据库、发网络请求或修改业务状态。 - 键空间有限:
Map不会自动替你限制标签数量,避免把用户输入当键。 - 端点不公开:默认内容包含命令行参数和内存统计,应使用内部监听、反向代理鉴权或网络访问控制。
- 采集格式匹配:
expvar输出 JSON,不是 Prometheus 文本格式;接入现有采集系统前先确认适配方式。
常见问题
Publish 必须写在 init 里吗?
文档建议在包创建变量时从 init 调用,便于启动阶段完成固定注册。技术上可在其他位置调用,但并发动态注册会增加生命周期和重名判断的复杂度。
为什么已经 NewInt 还会重名 panic?
NewInt、NewMap 等构造函数会自动发布变量。通常是多个包用了同一个名称,或代码又手动调用了一次 Publish。
expvar 能替代完整监控系统吗?
它适合轻量进程状态、调试端点和 JSON 采集源,但不提供历史存储、告警、查询语言或仪表盘。生产监控仍要由采集与存储系统承接。
迁移清单
把自制状态接口迁移到 expvar 时,先把指标分成原子数值、有限分类和动态快照;分别使用 Int、Map、Func;确认名称唯一与 JSON 合法;再把 Handler 放进受限的内部路由。完成后,用一次 GET 只读取目标字段,并检查计数增长、并发量归零、状态码键集合稳定。这样 Publish 才真正减少维护成本,而不是把一个自制接口换成另一个不可控入口。
-
369 收藏
-
344 收藏
-
464 收藏
-
327 收藏
-
485 收藏
-
168 收藏
-
197 收藏
-
152 收藏
-
127 收藏
-
326 收藏
-
344 收藏
-
298 收藏
-
159 收藏
-
352 收藏
-
156 收藏
-
285 收藏
-
473 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习