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

+33 -16
View File
@@ -4,6 +4,7 @@ package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"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) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
//
// 语义分两层,都写进响应体的不同字段,谁也不覆盖谁:
// ok —— HTTP 服务活着(Console 拿状态码当存活信号,这里恒回 200);
// db —— 数据库能应答(新增,之前不探库,「进程活着但连不上库」会被当成健康)。
// 状态码保持 200:Console 判定不健康会重启进程,而数据库故障重启治不了,
// 只会变成重启循环。让守护脚本读 db 字段自行决策,比替它做决定安全。
// 不返回最近备份时间:本接口会被 Console 高频轮询,不做目录扫描。
// 语义分两层,写进不同字段,谁也不覆盖谁:
// ok —— HTTP 服务活着(**恒为 true,状态码也恒 200**);
// db —— 数据库能不能应答(ok / busy / error)。
// 状态码不做区分:Console 只在**进程真的退出**时才拉起(`service.js` 的 handleExit),
// 但前端 `health()` 一见到 ok:false 就直接抛错(管理端「关于」页会显示
// 「未知(/api/health 不可达)」),一次数据库抖动不该让界面宣称版本未知。
// 进程存活与依赖状态本来就是两件事,分字段表达比塞进一个布尔值诚实。
// 不返回最近备份时间:本接口会被 Console 每 5 秒轮询,不做目录扫描。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
defer cancel()
resp := map[string]any{"ok": true, "version": s.version, "db": "ok"}
if err := s.probeDatabases(ctx); err != nil {
resp["db"] = "error"
state, err := s.probeDatabases(ctx)
resp := map[string]any{"ok": true, "version": s.version, "db": state}
if err != nil {
resp["dbError"] = err.Error()
}
writeJSON(w, http.StatusOK, resp)
@@ -301,14 +304,24 @@ func (s *server) notWired(name string) http.HandlerFunc {
}
// healthProbeTimeout 是健康检查探库的超时。
// 取 5s 与 busy_timeout 同量级:库被备份、重算这类长事务占着单连接时,
// 探针宁可等一会儿,也不要报出一个假的「数据库故障」。
const healthProbeTimeout = 5 * time.Second
//
// **必须明显小于 Console 的健康检查超时**(`console/main/service.js` 里 `timeout: 2500`):
// 数据库是单连接(`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 可能只到连接池就返回,
// 探不到「文件还在、库已经读不动」这种情况。
func (s *server) probeDatabases(ctx context.Context) error {
func (s *server) probeDatabases(ctx context.Context) (string, error) {
for _, target := range []struct {
name string
db *sql.DB
@@ -318,10 +331,14 @@ func (s *server) probeDatabases(ctx context.Context) error {
}
var one int
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 映射到多页面前端产物。