Files
t8y2--dbx/docs/pips/PIP-0001-database-connection-timeout-recovery.md
T
2026-07-13 13:09:14 +08:00

20 KiB
Raw Blame History

PIP-0001: 数据库连接空闲失效恢复机制

状态

Proposed

摘要

本 PIP 提议改进 DBX 在 PostgreSQL、openGauss、MySQL 连接空闲失效后的恢复机制,解决 Windows 环境下常见的查询永久执行中、取消无效、连接树持续加载、必须重启应用才能恢复的问题。

核心改动是将连接池检出、连接健康检查、SQL 执行、取消、清理和前端状态恢复纳入统一的超时与恢复模型,避免任一阶段永久挂起。

详细分析和实施计划见:

  • docs/pips/plans/2026-06-24-database-connection-timeout-recovery.md

背景

用户在 Windows 环境下反馈,数据库连接建立后一段时间不活动,再次执行查询时会出现:

  • 查询结果区域持续显示执行中。
  • 点击中断无法停止。
  • 连接状态持续转圈,刷新无效。
  • 退出并重启应用后恢复。
  • PostgreSQL、openGauss、MySQL 均受影响。

该问题不是单一数据库驱动或 SQL 语句问题。三个数据库共同受影响,说明问题边界位于 DBX 的连接生命周期管理、超时控制、取消机制和前端状态收敛层。

问题定义

当数据库连接空闲后,底层 TCP 连接或数据库会话可能被以下因素静默失效:

  • 数据库服务端 idle timeout。
  • VPN、NAT、防火墙、代理的空闲连接回收。
  • Windows 睡眠、网络切换、Wi-Fi/VPN 断开后恢复。
  • TCP 半开连接未及时被操作系统发现。

此时 DBX 仍可能持有旧连接池,并且前端仍认为连接有效。下一次操作可能卡在:

  • ensureConnected
  • checkConnectionHealth
  • pool checkout
  • pool recycle/verify
  • MySQL ping
  • PostgreSQL SET search_path
  • SQL 执行或结果读取
  • PostgreSQL cancel request
  • MySQL KILL QUERY
  • 连接树元数据刷新

当前系统缺少覆盖以上完整链路的统一执行预算,因此用户看到的现象是“前端一直执行中”,而真正的卡点可能在查询执行之前或取消请求本身。

目标

  1. 空闲连接失效后,查询、刷新、取消操作都必须在可预期时间内完成或返回明确错误。
  2. 用户无需重启应用即可通过清池、重连恢复。
  3. PostgreSQL、openGauss、MySQL 的恢复行为保持一致。
  4. 前端执行中、取消中、加载中状态必须有兜底清理机制。
  5. 日志能够定位卡住阶段,便于后续排查。

非目标

  1. 不保证所有服务端 SQL 一定被成功取消。客户端取消和服务端终止是两个不同结果。
  2. 不改变 query_timeout_secs = 0 表示禁用 SQL 查询超时的语义。
  3. 不重构所有数据库驱动,仅先覆盖 PostgreSQL、openGauss、MySQL 及共享连接管理链路。
  4. 不改变连接配置的存储结构,除非提供向后兼容默认值。

提案

1. 引入数据库操作执行预算

为每次数据库操作建立统一预算,覆盖:

  • 连接池等待。
  • 新建连接。
  • 回收验证。
  • 健康检查。
  • SQL 执行。
  • 取消请求。
  • 清理连接池。

建议模型:

pub struct DbOperationBudget {
    pub checkout_timeout: Duration,
    pub connect_timeout: Duration,
    pub recycle_timeout: Duration,
    pub query_timeout: Option<Duration>,
    pub cancel_timeout: Duration,
    pub cleanup_timeout: Duration,
}

其中 query_timeout = None 仅表示 SQL 执行不设上限;连接检出、健康检查、取消和清理仍必须有安全上限。

SQL 执行超时与基础设施超时的边界原则

本 PIP 区分两类超时,语义不可混淆:

1. SQL 执行超时 — 遵循用户配置

  • query_timeout_secs = 0:表示 SQL 执行本身不设超时。前端不得静默将其变为有限值(如 60s)。现有代码已正确处理:前端 withFrontendQueryTimeouttimeoutSecs === 0 时直接返回 promise 不加超时;后端 resolve_query_timeoutSome(0) 时返回 None
  • query_timeout_secs > 0:SQL 执行超时为用户配置值。前端兜底超时设为配置值的 2 倍,让后端先触发自己的超时,前端超时仅作为网络异常下的兜底。
  • query_timeout_secs 缺失或无效:使用默认值 60sDEFAULT_QUERY_TIMEOUT_SECS)。

2. 基础设施超时 — 始终有硬性上限

以下阶段与 SQL 执行无关,即使用户设置 query_timeout_secs = 0,也必须有硬性超时上限,否则可能导致应用永久挂起:

  • pool checkout(连接池等待)
  • pool create(新建连接)
  • pool recycle(回收验证)
  • 健康检查(ping
  • 取消请求(PostgreSQL cancel / MySQL KILL QUERY
  • 连接池清理

这些超时值由 DbOperationBudget 中对应的非 Option 字段控制,不受 query_timeout_secs 影响。

checkout 阶段的 cancel token 集成

deadpool-postgrespool.get() 不原生接受 CancellationToken。当前代码中 pool.get().await 为裸调用,无法在 checkout 阶段响应取消请求。

统一 checkout helper 应使用 tokio::select! 包装 pool.get() 以支持取消:

async fn checkout_with_cancel(
    pool: &Pool,
    cancel_token: Option<CancellationToken>,
    checkout_timeout: Duration,
) -> Result<deadpool_postgres::Object, String> {
    let get_future = tokio::time::timeout(checkout_timeout, pool.get());
    match cancel_token {
        Some(token) => {
            tokio::select! {
                biased;
                _ = token.cancelled() => Err("cancelled during pool checkout".to_string()),
                result = get_future => result.map_err(|e| e.to_string()).and_then(|r| r.map_err(|e| e.to_string())),
            }
        }
        None => get_future.await.map_err(|e| e.to_string()).and_then(|r| r.map_err(|e| e.to_string())),
    }
}

当取消触发时,pool.get() future 被 dropdeadpool 内部会正确回滚等待状态。checkout timeout 通过 tokio::time::timeout 实现,超时错误应归类为连接错误并触发清池。

2. 补齐 PostgreSQL/openGauss pool timeout

PostgreSQL/openGauss 共用 PoolKind::Postgres。创建 deadpool-postgres pool 时应显式配置:

  • wait timeout
  • create timeout
  • recycle timeout

同时在项目层统一包装 pool.get(),确保 checkout/recycle 阶段错误可被归类为连接池失效并触发恢复。

3. 统一 MySQL checkout 和 cancel 保护

MySQL 现有 get_conn_with_health_check 是有效的局部保护,但需要纳入统一执行预算。

需要补齐:

  • kill_query_with_opts 的建连 timeout。
  • KILL QUERY 执行 timeout。
  • 元数据、事务、导出等路径中的直接 pool.get_conn() 审计。
  • MySQL tcp_keepalive 默认配置。

4. 改进取消机制

取消请求必须覆盖两个阶段:

  • SQL 已经在服务端执行:发送 PostgreSQL cancel 或 MySQL KILL QUERY
  • 尚未进入 SQL 执行,例如卡在 checkout/health check:取消客户端任务,标记连接池可疑,并按策略清理 pool。

PostgreSQL TLS 连接取消必须使用与原连接兼容的 TLS connector,避免当前 cancel_query(NoTls) 在 TLS 场景下失败。

TLS cancel 改造说明

当前代码 cancel_postgres_query 使用 NoTls 调用 pg_cancel_token.cancel_query(NoTls)tokio_postgres::CancelToken::cancel_query 需要显式传入 MakeSslConnect 参数,不会自动升级到 SSL。若 PostgreSQL 服务器配置了 hostssl 或强制 TLSNoTls 的 cancel 请求会被服务器拒绝。

改造步骤:

  1. 验证阶段:在改造前先编写测试验证当前驱动版本在 TLS 连接场景下 cancel_query(NoTls) 的实际行为,确认是否确实失败(服务器是否接受非 TLS cancel 请求取决于 pg_hba.conf 配置)。
  2. 存储 TLS connector:在创建连接池时,将 MakeRustlsConnect 实例(或等价 TLS 配置)与 CancelToken 一并存储,使 cancel 时能重建正确的 TLS 连接。
  3. 重建 connector:由于 MakeRustlsConnect 不一定实现 Clone,可能需要在 cancel 时从原始 pg_config 和 SSL 配置重新构建 connector。
  4. 不降低证书校验cancel connector 必须使用与原连接相同的证书校验级别,不能为了 cancel 成功而降低安全性。

改造后的 cancel_postgres_query 签名应类似:

async fn cancel_postgres_query(
    pg_cancel_token: tokio_postgres::CancelToken,
    tls: tokio_postgres_rustls::MakeRustlsConnect,
) {
    match tokio::time::timeout(Duration::from_secs(5), pg_cancel_token.cancel_query(tls)).await {
        Ok(Ok(())) => {}
        Ok(Err(err)) => log::warn!("Failed to send PostgreSQL cancel request: {err}"),
        Err(_) => log::warn!("Timed out sending PostgreSQL cancel request"),
    }
}

5. 前端状态兜底恢复

前端需要对以下阶段设置超时并清理状态:

  • ensureConnected
  • checkConnectionHealth
  • executeMulti
  • cancelQuery
  • 连接树元数据刷新

各阶段超时值定义

阶段 超时值 计算规则 说明
ensureConnected(首次连接) connect_timeout_secs + 2s connectionAttemptTimeoutMs(config) 已有实现,复用现有逻辑。Agent 驱动类型有 30s 下限。
ensureConnected(健康检查快路径) 5s 固定值 hasRecentConnectionHealthCheck 为 true 时跳过;否则调用 checkConnectionHealth,需加 5s 超时。当前代码缺少此超时。
checkConnectionHealth max(connect_timeout_secs * 2, 5s) 基于连接超时倍数 健康检查应在连接超时的 2 倍内完成,但不低于 5s。当前代码无超时保护。
executeMultiSQL 执行) query_timeout_secs > 0 时为 query_timeout_secs * 2query_timeout_secs = 0 时为 0(不设超时) 前端超时是后端超时的 2 倍,让后端先触发自己的超时 现有实现已正确处理:queryTimeoutSecs === 0 ? 0 : frontendTimeoutSecs。前端超时仅作为兜底,不是 SQL 执行本身的超时控制。
cancelQuery 10s 固定值 取消请求应在 10s 内返回。超时后强制清理 isCancelling 状态。当前代码无超时保护。
连接树元数据刷新 max(query_timeout_secs + 5s, 15s) 复用 metadataLoadTimeoutMs 已有实现,query_timeout_secs = 0 时使用 60s。

所有超时触发后必须清理:

  • isExecuting
  • isCancelling
  • executionId
  • 连接节点 isLoading

若错误表明连接失效,应调用连接丢失处理,并提供重新连接入口。

6. 默认启用更适合桌面环境的 keepalive

当前默认值不一致问题

现有代码存在两层 keepalive 默认值不一致:

  • Rust 模型层default_keepalive_interval_secs() 返回 60(开启),通过 #[serde(default = "default_keepalive_interval_secs")] 生效。从 Rust API(web 后端、测试代码等)直接创建的 ConnectionConfig 默认 keepalive 为 60 秒。
  • 前端 UI 层ConnectionDialog.vueconfig.keepalive_interval_secs = ... ?? 0(关闭)。从前端保存的连接配置默认 keepalive 为 0(关闭)。

这意味着从前端 UI 创建的连接和从 Rust API 直接创建的连接在 keepalive 行为上不一致。P1-1 必须统一两层默认值策略。

统一策略

应用层 keepalive 统一默认开启:

  • 默认间隔:30 秒(前后端统一使用此值作为默认值)。
  • 修改 default_keepalive_interval_secs() 返回 30,同步修改前端 ConnectionDialog.vue 的 fallback 值为 30
  • 用户可以关闭(设为 0)。
  • 关闭时提示可能受 VPN/NAT/防火墙 idle timeout 影响。

驱动层建议:

  • PostgreSQL/openGauss:默认注入或设置短周期 TCP keepalive。
  • MySQL:设置 tcp_keepalive=30000 或等价 builder 参数。

keepalive 是预防手段,不能替代超时和清池恢复。

7. 增加阶段化日志

每个查询或刷新操作输出阶段日志:

  • ensureConnected
  • pool.checkout
  • pool.recycle
  • ping
  • schema.set
  • query.execute
  • result.fetch
  • cancel
  • cleanup

日志应包含:

  • trace_id
  • connection_id
  • database
  • db_type
  • pool_key
  • client_session_id
  • stage
  • elapsed_ms
  • timeout_ms
  • error

兼容性

查询超时

query_timeout_secs = 0 继续表示不限制 SQL 执行时间。这一语义在前后端必须保持一致:

  • 后端resolve_query_timeout(Some(0)) 返回 NoneSQL 执行不受 tokio::time::timeout 约束。
  • 前端withFrontendQueryTimeouttimeoutSecs === 0 时直接返回 promise,不施加前端超时。

本 PIP 不得在任何路径中静默将 query_timeout_secs = 0 变为有限 SQL 超时。连接检出、健康检查、取消、清理属于基础设施阶段,必须有独立于 query_timeout_secs 的硬性上限(见"SQL 执行超时与基础设施超时的边界原则")。

MySQL session-scoped pool

MySQL session-scoped pool 用于保留临时表、用户变量等会话状态。清理策略不得在正常连续查询时破坏该行为。只有明确空闲超时、连接失效、用户关闭 tab 或用户强制断开时才清理。

PostgreSQL search_path

PostgreSQL/openGauss 的 schema 执行上下文行为保持不变,但 SET search_pathRESET search_path 必须纳入超时和错误恢复。

清池操作的并发安全

清池操作(remove_pool_by_keyremove_connection_pools_detachedclose_database_pool)通过 RwLock 保护 connections HashMap。需要明确以下并发场景:

1. 清池对在途查询的影响

  • 已 checkout 的连接不受影响:当 pool.get() 成功返回 Object 后,调用方持有该连接的引用。即使 pool 从 HashMap 中移除,已 checkout 的连接仍可正常使用,直到 Object 被 drop 时归还到原 pool(此时原 pool 可能已被关闭,连接被直接丢弃)。
  • 新 checkout 请求失败pool 从 HashMap 移除后,新的 pool.get() 请求将找不到 pool,触发重连流程。
  • 结论:清池操作是安全的,不会中断在途查询,但新请求会触发重连。

2. base pool 与 session-scoped pool 的清理顺序

  • close_database_pool 先收集所有需要清理的 keybase pool key + session pool keys),然后统一获取写锁批量移除。
  • 清理顺序:先停止 keepalive task → 清理 pool_activity → 获取 connections 写锁批量移除 → 关闭移除的 pools。
  • 要求:不允许在持有 connections 写锁的同时执行 pool 内部操作(如 close_pool_kind),避免死锁。当前代码在 drop 写锁后才调用 close_pool_kind,符合此要求。

3. 并发清池的竞态条件

  • 多个操作可能同时触发同一 pool 的清理(如超时清理 + 用户手动断开)。remove_pool_by_key 通过写锁保证幂等性:第二次调用时 remove 返回 None,不会重复关闭。
  • 要求P2 实施时确保所有清池入口都通过 remove_pool_by_keydrain_connection_pools 统一路径,不绕过写锁直接操作 HashMap。

分阶段实施

阶段 1:止血

目标:避免永久卡死。

范围:

  • PostgreSQL/openGauss pool checkout、create、recycle 加 timeout。
  • MySQL cancel 加 timeout。
  • 前端 ensureConnectedcheckConnectionHealthcancelQuery 加兜底超时。
  • 超时后清理 UI 执行状态。

验收:

  • 数据库不可达时,查询、取消、刷新都能在可预期时间内返回。

阶段 2:自动恢复

目标:空闲失效后可自动重连或明确失败。

范围:

  • 统一 checkout helper。
  • pool checkout/ping timeout 进入连接错误分类。
  • 连接错误后清理对应 pool。
  • 添加强制清池和重新连接入口。

timeout 错误消息格式与 is_connection_error 匹配

当前 is_connection_error 通过 lower.contains("timed out") 匹配超时错误,仅排除 starts_with("query timed out after ") 格式。需要确保以下各 timeout 场景的错误消息能被正确分类:

场景 错误来源 错误消息格式(lowercase 当前是否匹配 处理方式
DBX query timeout tokio::time::timeout "query timed out after {n} seconds" 已排除(is_dbx_query_timeout_error 保持排除,不触发清池
PostgreSQL pool checkout timeout deadpool-postgres PoolError::Timeout(TimeoutType::Wait) "pool wait timeout" 不匹配(是 "timeout" 不是 "timed out" 需新增匹配 "pool" + "timeout" 组合,或匹配 deadpool 的 TimeoutType 枚举变体
PostgreSQL pool create timeout deadpool-postgres PoolError::Timeout(TimeoutType::Create) "pool create timeout" 不匹配 同上
PostgreSQL pool recycle timeout deadpool-postgres PoolError::Timeout(TimeoutType::Recycle) "pool recycle timeout" 不匹配 同上
checkout helper 超时(tokio::time::timeout 包装) 自定义 tokio::time::timeout "elapsed" 或自定义消息 不匹配 checkout helper 应返回包含 "connection""checkout timed out" 的错误消息
MySQL ping timeout 自定义 tokio::time::timeout 自定义消息 取决于消息内容 确保消息包含 "connection""timed out"
Agent RPC timeout Agent 驱动 "agent rpc call timed out ..." 已排除(is_agent_rpc_timeout_error 保持排除

实施要求:

  1. checkout helper 返回的错误消息应包含 "checkout timed out""connection" 关键词,确保 is_connection_error 能正确匹配。
  2. 对于 deadpool 的 PoolError::Timeout,应在错误转换层(如 pg_pool_error_to_string)统一添加 "timed out" 关键词,或在 is_connection_error 中新增 "pool" + "timeout" 组合匹配。
  3. P2 实施时需编写单元测试覆盖以上每种 timeout 场景的错误分类。

验收:

  • 数据库恢复后,不重启应用即可重新查询。

阶段 3:预防

目标:减少空闲失效概率。

范围:

  • 默认开启应用层 keepalive。
  • PostgreSQL/openGauss 设置短周期 TCP keepalive。
  • MySQL 设置 tcp_keepalive
  • 对齐 idle timeout 和 driver inactive TTL。

验收:

  • 常规 Windows/VPN/NAT 环境下,空闲后连接可用或能自动恢复。

阶段 4:可观测性

目标:后续问题可定位。

范围:

  • 阶段化日志。
  • 连接诊断信息。
  • 断链复现脚本和 QA 手册。

验收:

  • 用户报告“执行中”时,可以从日志判断卡在具体阶段。

测试计划

自动化测试

  • PostgreSQL/openGausspool checkout timeout、query timeout、cancel timeout。
  • MySQLping timeout、kill query timeout、connection error 分类。
  • 前端:执行超时后清理 tab 状态;取消超时后清理 isCancelling;连接树刷新失败后清理 loading。

手工测试

Windows 环境验证:

  1. 建立 PostgreSQL/openGauss/MySQL 连接。
  2. 执行 SELECT 1
  3. 停止数据库或断开 VPN。
  4. 再次执行 SELECT 1
  5. 验证 UI 不永久执行中。
  6. 恢复数据库或 VPN。
  7. 验证无需重启应用即可重连恢复。

取消验证:

  • PostgreSQL/openGaussSELECT pg_sleep(60)
  • MySQLSELECT SLEEP(60)
  • 点击取消后,UI 必须在 2 到 5 秒内退出取消中状态。

风险

  • 过早清理 session-scoped pool 可能破坏会话状态。
  • TLS cancel 改造需要复用正确证书配置,不能降低证书校验。
  • 默认 keepalive 会增加少量后台请求。
  • 部分半开 TCP 场景难以稳定自动化复现。
  • 将所有 timeout 都视为连接失效可能误伤慢网络,需要区分 query timeout 与 checkout/ping/cancel timeout。

回滚策略

可独立回滚:

  • keepalive 默认值。
  • 新增 UI 诊断入口。
  • 连接参数默认注入策略。

不建议回滚:

  • pool checkout timeout。
  • cancel timeout。
  • UI 状态兜底清理。

这些属于防止应用永久挂起的基础安全边界。

验收标准

  1. PostgreSQL、openGauss、MySQL 空闲失效后,查询不会永久执行中。
  2. 中断操作不会永久取消中。
  3. 连接树刷新不会永久加载中。
  4. 数据库或网络恢复后,不重启应用即可重新连接。
  5. 日志能指出失败阶段。
  6. 自动化和手工测试覆盖查询、取消、健康检查、元数据刷新、空闲清理和重连恢复。

相关文档

  • docs/pips/plans/2026-06-24-database-connection-timeout-recovery.md