Files
Genarrative/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md
kdletters 7ed99d7353 拆分SpacetimeDB调用池与缓存读连接
将HTTP调用池改为无订阅连接并增加单一共享缓存读连接

统一缓存重建总超时并拒绝发布断线连接

补充连接拓扑回归测试与生产运维说明
2026-07-15 15:11:11 +08:00

5.3 KiB
Raw Permalink Blame History

SpacetimeDB 连接池租约 Drop 兜底与取消安全

  • 日期:2026-06-11
  • 关联故障:release 环境 api-server 周期性全量 spacetime_stage="pool_acquire" elapsed_ms=45000 超时,/readyz 503reason=spacetime_unhealthy, stage=pool_acquire),重启后临时恢复。
  • 涉及代码:server-rs/crates/spacetime-client/src/lib.rs

故障根因

修复前的连接池存在两个叠加缺陷:

  1. 租约没有 Drop 兜底PooledConnectionLease 只能通过显式 release_connection 归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Droppermit 因 OwnedSemaphorePermit 自动归还,但槽位的 in_use 标记永远不会复位。
  2. acquire 在槽位泄漏后永久空转。后续请求拿到 permit 后进入 loop { 扫描槽位; yield_now },找不到空闲槽位就无限自旋,且这段自旋不受 procedure_timeout 约束,自旋期间 permit 不归还。

叠加效果:StDB 一旦变慢(请求占用连接接近 45 秒),客户端取消请求的概率大增,每次取消泄漏一个槽位并连带吞掉一个 permit;泄漏数量达到 pool_size(release 为 8)后,所有业务请求与健康检查全部在 pool_acquire 阶段 45 秒超时,服务表现为"连不上 StDB",只有重启能恢复。

本地复现

不需要真实 SpacetimeDB,单元测试即可复现机制(位于 spacetime-client tests 模块):

  • 修复前:将一个槽位置为 in_use=true 后调用 acquire_connection_with_timeout(200ms),acquire 在 5 秒守护窗口内不返回(永久自旋),测试红。
  • dropped_lease_releases_slot_and_permit:模拟"请求被取消、租约未经 release 直接 Drop",断言槽位与 permit 都被复位归还。
  • acquire_times_out_at_pool_acquire_when_pool_is_busy:池内 permit 全部被占用时,acquire 必须在超时窗口内返回 PoolAcquire + Timeout,不允许无限等待。

修复方案

  1. PooledConnectionSlot 改为 in_use: AtomicBool + connection: Mutex<Option<PooledConnection>>,槽位占用标记不再依赖异步锁。
  2. PooledConnectionLease 持有 Arc<SpacetimeConnectionPool> 并实现 Drop:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位 in_use 再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。
  3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 pool_size,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
  4. release_connection 退化为 drop(lease),显式与隐式归还共用同一条兜底路径。

验收

  • cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib(44 通过,含上述连接池与缓存连接测试)
  • cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz2 通过)
  • cargo check -p api-server --manifest-path server-rs/Cargo.toml

2026-07-14 缓存读连接拆分

HTTP 角色的连接拓扑调整为“pool_size 条调用连接 + 1 条缓存读连接”:

  1. 调用连接池只承接 procedure / reducer 回调,不订阅 read model,也不装载订阅行;GENARRATIVE_SPACETIME_POOL_SIZE=8 表示 8 条调用连接,不包含额外的缓存读连接。
  2. 缓存读连接只由 read_after_connect 使用,并持有一份 required / optional read-model subscriptions。多个本地读取通过 Arc 共享同一 SDK ClientCache,初始化和断线重建使用单飞锁,读取本身不经过单槽 semaphore 串行化。
  3. 缓存读连接断线后,下一次读取只重建一条连接并等待 required subscriptions 全部 applied;首次建连与 required / optional 订阅共用一次总超时预算,optional 阶段发生断线时禁止发布 broken 连接;旧连接由在途读取持有到结束后再断开。
  4. /readyz 对 HTTP 角色同时检查调用池连接和缓存读连接;required subscription 失败时不得报告 ready。worker / controller 关闭 read-model cache 时不创建额外连接。
  5. 这里的“只读”是 facade 用途边界。SpacetimeDB Rust SDK 2.6 没有连接级 read-only builder;缓存连接仍使用相同 runtime identity,但代码不向 procedure / reducer 调用路径暴露它。

该拆分把相同 read-model 行缓存从 pool_size 份降为 1 份;SDK 每条连接仍会注册空 table metadata,总 WebSocket 数在 HTTP 角色中会从 pool_size 增加到 pool_size + 1,因此不能把总 RSS 简单承诺为原来的 1 / pool_size

运维提示

  • 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 GENARRATIVE_SPACETIME_POOL_SIZE 与 StDB 负载排查,不要再怀疑池泄漏。
  • 健康检查 /readyz 在池被在途请求占满时仍可能短暂 503stage=pool_acquire),恢复后自动转好,无需重启。
  • HTTP 角色排查连接数时按“调用池 + 1 条缓存读连接”计算;外部生成唤醒和充值过期监听还有各自的窄订阅连接,不属于调用池或缓存读连接。