# 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 执行。 - 取消请求。 - 清理连接池。 建议模型: ```rust pub struct DbOperationBudget { pub checkout_timeout: Duration, pub connect_timeout: Duration, pub recycle_timeout: Duration, pub query_timeout: Option, pub cancel_timeout: Duration, pub cleanup_timeout: Duration, } ``` 其中 `query_timeout = None` 仅表示 SQL 执行不设上限;连接检出、健康检查、取消和清理仍必须有安全上限。 #### SQL 执行超时与基础设施超时的边界原则 本 PIP 区分两类超时,语义不可混淆: **1. SQL 执行超时 — 遵循用户配置** - `query_timeout_secs = 0`:表示 SQL 执行本身不设超时。前端不得静默将其变为有限值(如 60s)。现有代码已正确处理:前端 `withFrontendQueryTimeout` 在 `timeoutSecs === 0` 时直接返回 promise 不加超时;后端 `resolve_query_timeout` 在 `Some(0)` 时返回 `None`。 - `query_timeout_secs > 0`:SQL 执行超时为用户配置值。前端兜底超时设为配置值的 2 倍,让后端先触发自己的超时,前端超时仅作为网络异常下的兜底。 - `query_timeout_secs` 缺失或无效:使用默认值 60s(`DEFAULT_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-postgres` 的 `pool.get()` 不原生接受 `CancellationToken`。当前代码中 `pool.get().await` 为裸调用,无法在 checkout 阶段响应取消请求。 统一 checkout helper 应使用 `tokio::select!` 包装 `pool.get()` 以支持取消: ```rust async fn checkout_with_cancel( pool: &Pool, cancel_token: Option, checkout_timeout: Duration, ) -> Result { 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 被 drop,deadpool 内部会正确回滚等待状态。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` 或强制 TLS,`NoTls` 的 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` 签名应类似: ```rust 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。当前代码无超时保护。 | | `executeMulti`(SQL 执行) | `query_timeout_secs > 0` 时为 `query_timeout_secs * 2`;`query_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.vue` 中 `config.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))` 返回 `None`,SQL 执行不受 `tokio::time::timeout` 约束。 - **前端**:`withFrontendQueryTimeout` 在 `timeoutSecs === 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_path` 和 `RESET search_path` 必须纳入超时和错误恢复。 ### 清池操作的并发安全 清池操作(`remove_pool_by_key`、`remove_connection_pools_detached`、`close_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` 先收集所有需要清理的 key(base 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_key` 或 `drain_connection_pools` 统一路径,不绕过写锁直接操作 HashMap。 ## 分阶段实施 ### 阶段 1:止血 目标:避免永久卡死。 范围: - PostgreSQL/openGauss pool checkout、create、recycle 加 timeout。 - MySQL cancel 加 timeout。 - 前端 `ensureConnected`、`checkConnectionHealth`、`cancelQuery` 加兜底超时。 - 超时后清理 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/openGauss:pool checkout timeout、query timeout、cancel timeout。 - MySQL:ping 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/openGauss:`SELECT pg_sleep(60)`。 - MySQL:`SELECT 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`