登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go 服务健康检查失败但业务接口正常怎么办

来源:17golang原创

时间:2026-10-14 08:15:13 349浏览 收藏

我第一次碰到这个现象,是把一个 Go HTTP 服务交给容器平台后:业务接口能返回数据,健康检查却持续失败。最容易误判的地方是把“接口能访问”当成“实例适合继续接流量”。这两件事本来就不是同一个问题。

处理这类故障,我现在会先把检查拆成三层:/livez 只判断进程是否还活着,/readyz 判断实例是否已经准备好接收流量,/healthz 才检查数据库、缓存或其他业务依赖。三层各自返回明确的 HTTP 状态码,探针失败而业务接口正常就不再神秘了。

先定义三类检查的边界

Go 的 net/http 负责提供 HTTP 服务端和处理器注册能力,并不会替你规定健康检查语义。下面的分层是应用层约定,关键是让每个接口只回答一个问题。

接口回答的问题是否访问外部依赖失败含义
/livez进程和 HTTP 监听还在吗否进程可能需要重启
/readyz这个实例现在能接流量吗通常不直接访问暂时摘出流量
/healthz关键业务依赖是否可用是,但必须有超时依赖故障或服务降级

如果三个检查都复用同一个“访问数据库”的函数,数据库短暂抖动就可能让平台把整个进程杀掉;反过来,如果所有检查都只返回 200,实例明明还没有连接上依赖,却会继续接收请求。

Go 服务存活、就绪和业务依赖三类健康检查边界说明图
图1:健康检查分层说明图,展示存活、就绪和依赖检查的静态边界;这是说明图,不是运行截图。

用 net/http 搭一个最小检查服务

先做一个可以独立运行的服务。重点不是把检查写得复杂,而是把路径和返回码固定下来:存活与就绪成功返回 200,未就绪返回 503,依赖检查失败也返回 503。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	// 存活检查只确认进程还能处理 HTTP,不访问数据库或缓存。
	mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			// 探针使用错误方法时返回 405,避免把路径问题伪装成服务故障。
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = fmt.Fprintln(w, "alive")
	})

	// 就绪检查只判断实例是否可以接收流量,状态由启动流程单独维护。
	mux.HandleFunc("/readyz", readyHandler)

	// 业务依赖检查集中到独立处理器,后续可以替换成真实依赖探测。
	mux.HandleFunc("/healthz", dependencyHandler)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	log.Printf("health demo listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

func readyHandler(w http.ResponseWriter, r *http.Request) {
	// 这里只展示最小成功路径,真实服务应读取共享的 ready 状态。
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	w.WriteHeader(http.StatusOK)
	_, _ = fmt.Fprintln(w, "ready")
}

func dependencyHandler(w http.ResponseWriter, r *http.Request) {
	// 依赖检查失败使用 503,让调用方知道服务暂时不可用而不是路径不存在。
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	w.WriteHeader(http.StatusOK)
	_, _ = fmt.Fprintln(w, "dependencies ok")
}

这段代码还有一个实际好处:业务路由可以继续挂在同一个 ServeMux 上,但健康检查不会复用业务接口的登录、复杂参数或长耗时逻辑。官方 net/http 文档也把处理器注册和 Server 配置分成了独立能力,适合用这种方式把边界留在应用代码中。

启动过程不要让就绪状态抢跑

“业务接口正常但就绪失败”还有一种相反的场景:进程已经监听端口,可是启动阶段的配置加载或依赖连接尚未完成。此时 /livez 应该成功,/readyz 应该暂时返回 503,等初始化结束再切换为成功。

package main

import (
	"fmt"
	"net/http"
	"sync/atomic"
)

type readiness struct {
	// atomic.Bool 让探针读取状态时不需要和启动 goroutine 争用普通布尔值。
	ready atomic.Bool
}

func (s *readiness) handler(w http.ResponseWriter, r *http.Request) {
	// 就绪检查只做快速内存读取,避免探针排队等待外部依赖。
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	if !s.ready.Load() {
		w.WriteHeader(http.StatusServiceUnavailable)
		_, _ = fmt.Fprintln(w, "not ready")
		return
	}
	w.WriteHeader(http.StatusOK)
	_, _ = fmt.Fprintln(w, "ready")
}

func initialize(s *readiness) error {
	// 这里完成配置、迁移或连接池初始化;失败时不要提前标记 ready。
	if err := loadConfiguration(); err != nil {
		return err
	}
	s.ready.Store(true)
	return nil
}

func loadConfiguration() error {
	// 示例用 nil 表示初始化成功,真实代码应返回具体错误。
	return nil
}

我会把“初始化失败”和“进程崩溃”分开处理:初始化失败时保持进程活着、让就绪检查返回 503,并记录原因;只有进程本身已经无法工作时,才让存活检查失败。这样平台可以先摘流量,而不是因为一次短暂依赖故障反复重启。

业务依赖检查必须带超时

真正的 /healthz 往往要访问数据库、缓存或下游 HTTP 服务。最危险的写法是直接复用没有 deadline 的客户端调用,因为探针本身会被拖住,最后看起来像“健康检查偶发超时”。检查函数应该接收请求上下文,并为单次探测设置较短的超时。

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

type dependencyChecker interface {
	Ping(context.Context) error
}

func healthHandler(checker dependencyChecker) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 每次探测使用独立 deadline,避免下游卡住健康检查连接。
		ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
		defer cancel()

		if err := checker.Ping(ctx); err != nil {
			// 不把下游错误细节直接返回给探针,只记录日志并给出 503。
			http.Error(w, "dependency unavailable", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = fmt.Fprintln(w, "healthy")
	}
}

这里的 800 毫秒只是示例,不是所有系统都适用的固定值。应根据依赖的正常延迟、探针间隔和服务的流量特点设定。健康检查的目标是快速告诉调度方“现在是否适合接流量”,不是把所有业务诊断都塞进一个 HTTP 请求。

Go 服务 ready 状态、依赖超时与业务接口隔离结构图
图2:启动与依赖隔离结构图,展示 ready 状态和超时依赖如何影响就绪检查而不直接改变业务处理器;这是说明图,不是运行截图。

按现象定位:为什么探针失败而业务接口正常

遇到这个现象时,我不会先改业务代码,而是按下面的顺序核对。每一项都对应一个相对明确的结论。

现象先看什么通常的修复方向
业务接口 200,探针 404探针路径和实际注册路径是否一致统一 /livez、/readyz、/healthz 的配置
本机成功,部署后失败服务是否监听在探针可访问的地址和端口检查 Addr、端口映射和网络边界
探针 405平台使用的 HTTP 方法是否为 GET放宽到约定方法或修正探针配置
就绪一直 503,存活是 200启动流程是否执行了 ready.Store(true)只在必要初始化完成后切换 ready
健康检查超时,业务偶尔正常依赖探测是否有 context deadline为每个依赖调用设置超时,并隔离重试

尤其要注意监听地址:服务在一个环境里绑定的地址、容器端口和探针请求的目标不一致时,业务请求可能通过另一层代理成功,而探针直接访问服务端口却失败。这个时候继续调整业务处理器没有帮助。

部署前的最后验收清单

我会把以下检查作为上线前的最小验收,而不是只看“页面能打开”:存活检查不依赖数据库;就绪检查能在启动阶段返回 503、完成初始化后返回 200;依赖检查有明确超时;错误状态使用 503 而不是把依赖故障伪装成 404;业务接口不需要为健康检查准备登录信息;每个接口的 GET/非 GET 行为都已约定。

如果业务接口正常而健康检查仍失败,结论通常不是“健康检查没用”,而是探针正在回答另一个问题。把存活、就绪和依赖拆开,再围绕路径、监听地址、状态码和超时排查,往往比增加更多日志更快找到真正的断点。

相关问题

健康检查可以直接调用业务接口吗?不建议。业务接口可能需要鉴权、参数或较长处理时间,探针应使用独立且快速的路径。

依赖不可用时一定要让存活检查失败吗?不一定。若进程仍能工作但暂时不能接流量,通常只让就绪检查失败,让调度方摘除实例并保留进程诊断机会。

为什么健康检查要返回 503 而不是 500?503 更直接表达“当前暂时无法提供服务”,便于调用方区分服务暂不可用与代码内部错误;具体策略仍应和部署平台约定。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>