fix: 健康检查探库超时 5s → 1s(Console 探活超时只有 2500ms)

上一版把探库超时写成 5s,比 Console 的探活超时(console/main/service.js:389
的 timeout: 2500)还长。数据库是单连接,备份 VACUUM INTO 或整包重算占住连接时,
探针会等满 5s,于是整个 /api/health 请求超时,Console 把服务标成「未就绪」——
状态灯变红、托盘提示异常,console/main/index.js:671 在未就绪时还会跳过大屏重排。
为了「避免误判健康」加的探库,反而制造了另一种误判。

- healthProbeTimeout 5s → 1s:正常 SELECT 1 是亚毫秒,走满超时只可能是连接被占。
- probeDatabases 返回三态 ok / busy / error:busy 表示超时前没拿到连接(未必是故障,
  多半是备份或重算占着),error 才是真报错,两者分的错因不同,别混成一句话。
- 新增 TestHealthProbeStaysBoundedWhenDatabaseBusy:用事务占住赛事库唯一连接,
  断言 /api/health 仍回 200、db:"busy"、耗时 < 2000ms(这个数就是挡住超时被调回 2.5s 以上的护栏)。
- 更正一处写错的理由:状态码保持 200 与「避免重启循环」无关——setHealthy(false)
  只改状态与 UI,重启只发生在进程真的退出时(service.js 的 handleExit)。真正理由是
  前端 health() 见到 ok:false 会抛错,「关于」页会显示「未知(/api/health 不可达)」,
  一次数据库抖动不该让界面宣称版本未知。

版本号 dev_0.1.2 → dev_0.1.3。
This commit is contained in:
AskaEth committed 2026-09-11 14:42:57 +08:00
1 parent 44353b2c4c
commit b4bdaef9ba
3 files changed
+85 -19

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.1.2 dev_0.1.3
+51 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
) )
@@ -110,13 +111,15 @@ func TestHealthReportsDatabaseProbe(t *testing.T) {
t.Fatalf("正常时应为 ok/db=ok/version=test,得到 %+v", healthy) t.Fatalf("正常时应为 ok/db=ok/version=test,得到 %+v", healthy)
} }
// 把赛事库关掉:HTTP 还活着,探库必须失败,且状态码仍然是 200。 // 把赛事库关掉:HTTP 还活着,探库必须报 error,且状态码仍然是 200 ——
// 前端 health() 见到 ok:false 会直接抛错,状态码再变 5xx 只会让「关于」页
// 从「版本未知」升级成「服务不可达」,对现场没有额外信息。
if err := env.ev.Close(); err != nil { if err := env.ev.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err) t.Fatalf("关闭赛事库: %v", err)
} }
status, body = env.doRaw(client, http.MethodGet, "/api/health", nil, "", false) status, body = env.doRaw(client, http.MethodGet, "/api/health", nil, "", false)
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("数据库故障时状态码仍应是 200(避免 Console 重启循环),得到 %d %s", status, body) t.Fatalf("数据库故障时状态码仍应是 200,得到 %d %s", status, body)
} }
var broken struct { var broken struct {
OK bool `json:"ok"` OK bool `json:"ok"`
@@ -130,3 +133,49 @@ func TestHealthReportsDatabaseProbe(t *testing.T) {
t.Fatalf("探库失败应报 db=error 并说明原因,得到 %+v", broken) t.Fatalf("探库失败应报 db=error 并说明原因,得到 %+v", broken)
} }
} }
// 探库被长操作占住时,健康检查自己不能跟着一起卡住。
//
// 数据库是单连接,备份(`VACUUM INTO`)或整包重算会把它占住一段时间;
// 如果探针的超时大于 Console 的探活超时(2500ms),这期间 /api/health 会整条超时,
// Console 就把服务标成「未就绪」(状态灯变红、托盘提示异常,`index.js` 还会跳过大屏重排)——
// 明明服务是好的,现场却看到红的。
func TestHealthProbeStaysBoundedWhenDatabaseBusy(t *testing.T) {
env := newTestEnv(t)
client, _ := env.client()
// 占住赛事库的唯一连接,模拟备份 / 重算。
tx, err := env.ev.Begin()
if err != nil {
t.Fatalf("开启事务: %v", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`SELECT 1`); err != nil {
t.Fatalf("占用连接失败: %v", err)
}
start := time.Now()
status, body := env.doRaw(client, http.MethodGet, "/api/health", nil, "", false)
elapsed := time.Since(start)
if status != http.StatusOK {
t.Fatalf("连接被占时健康检查仍应回 200,得到 %d %s", status, body)
}
if elapsed > 2000*time.Millisecond {
t.Fatalf("探库被占用时健康检查耗时 %v,已逼近 Console 的 2500ms 探活超时", elapsed)
}
var busy struct {
OK bool `json:"ok"`
DB string `json:"db"`
DBError string `json:"dbError"`
}
if err := json.Unmarshal(body, &busy); err != nil {
t.Fatalf("解析健康检查响应: %v (%s)", err, body)
}
if busy.DB != "busy" {
t.Fatalf("连接被占用应报 db=busy(而不是笼统的 error),得到 %+v", busy)
}
if !busy.OK || busy.DBError == "" {
t.Fatalf("busy 时 ok 仍应为 true 并说明原因,得到 %+v", busy)
}
}
+33 -16
View File
@@ -4,6 +4,7 @@ package httpapi
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
@@ -167,18 +168,20 @@ func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *htt
func (s *server) routes(mux *http.ServeMux) { func (s *server) routes(mux *http.ServeMux) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。 // 健康检查不走 {ok,data} 包装(05 第 7 节)。
// //
// 语义分两层,都写进响应体的不同字段,谁也不覆盖谁: // 语义分两层,写进不同字段,谁也不覆盖谁:
// ok —— HTTP 服务活着(Console 拿状态码当存活信号,这里恒回 200); // ok —— HTTP 服务活着(**恒为 true,状态码也恒 200**);
// db —— 数据库能应答(新增,之前不探库,「进程活着但连不上库」会被当成健康)。 // db —— 数据库能不能应答(ok / busy / error)。
// 状态码保持 200:Console 判定不健康会重启进程,而数据库故障重启治不了, // 状态码不做区分:Console 只在**进程真的退出**时才拉起(`service.js` 的 handleExit),
// 只会变成重启循环。让守护脚本读 db 字段自行决策,比替它做决定安全。 // 但前端 `health()` 一见到 ok:false 就直接抛错(管理端「关于」页会显示
// 不返回最近备份时间:本接口会被 Console 高频轮询,不做目录扫描。 // 「未知(/api/health 不可达)」),一次数据库抖动不该让界面宣称版本未知。
// 进程存活与依赖状态本来就是两件事,分字段表达比塞进一个布尔值诚实。
// 不返回最近备份时间:本接口会被 Console 每 5 秒轮询,不做目录扫描。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout) ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
defer cancel() defer cancel()
resp := map[string]any{"ok": true, "version": s.version, "db": "ok"} state, err := s.probeDatabases(ctx)
if err := s.probeDatabases(ctx); err != nil { resp := map[string]any{"ok": true, "version": s.version, "db": state}
resp["db"] = "error" if err != nil {
resp["dbError"] = err.Error() resp["dbError"] = err.Error()
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
@@ -301,14 +304,24 @@ func (s *server) notWired(name string) http.HandlerFunc {
} }
// healthProbeTimeout 是健康检查探库的超时。 // healthProbeTimeout 是健康检查探库的超时。
// 取 5s 与 busy_timeout 同量级:库被备份、重算这类长事务占着单连接时, //
// 探针宁可等一会儿,也不要报出一个假的「数据库故障」。 // **必须明显小于 Console 的健康检查超时**(`console/main/service.js` 里 `timeout: 2500`):
const healthProbeTimeout = 5 * time.Second // 数据库是单连接(`SetMaxOpenConns(1)`),被备份(`VACUUM INTO`)或整包重算这类长操作
// 占着的时候,探针会一直等到自己的超时——探针超时就是**整个 /api/health 请求超时**,
// Console 会把服务标成「未就绪」(状态灯变红、托盘提示异常;`console/main/index.js:671`
// 在未就绪时还会跳过重排大屏)。1 秒对一次 `SELECT 1` 绰绰有余——正常是亚毫秒,
// 走满超时只可能是「连接被占用」这一种情况,那就如实报 busy,别拖垮探活请求本身。
const healthProbeTimeout = time.Second
// probeDatabases 探活系统库与赛事库,返回第一个出错的原因。 // probeDatabases 探活系统库与赛事库,返回状态与原因:
//
// "ok" —— 两个库都答得上话;
// "busy" —— 超时前没拿到连接(多半是备份 / 重算占着,未必是故障);
// "error" —— 真的报错了(例如库文件被删、损坏)。
//
// 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回, // 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回,
// 探不到「文件还在、库已经读不动」这种情况。 // 探不到「文件还在、库已经读不动」这种情况。
func (s *server) probeDatabases(ctx context.Context) error { func (s *server) probeDatabases(ctx context.Context) (string, error) {
for _, target := range []struct { for _, target := range []struct {
name string name string
db *sql.DB db *sql.DB
@@ -318,10 +331,14 @@ func (s *server) probeDatabases(ctx context.Context) error {
} }
var one int var one int
if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil { if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
return fmt.Errorf("%s 库无响应: %w", target.name, err) if errors.Is(err, context.DeadlineExceeded) {
return "busy", fmt.Errorf("%s 库在 %s 内没应答(连接被备份/重算占用?): %w",
target.name, healthProbeTimeout, err)
}
return "error", fmt.Errorf("%s 库无响应: %w", target.name, err)
} }
} }
return nil return "ok", nil
} }
// staticHandler 把 URL 映射到多页面前端产物。 // staticHandler 把 URL 映射到多页面前端产物。