8f19964e3f
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
5.6 KiB
5.6 KiB
SpacetimeDB 连接池租约 Drop 兜底与取消安全
- 日期:2026-06-11
- 关联故障:release 环境 api-server 周期性全量
spacetime_stage="pool_acquire" elapsed_ms=45000超时,/readyz503(reason=spacetime_unhealthy, stage=pool_acquire),重启后临时恢复。 - 涉及代码:
server-rs/crates/spacetime-client/src/active.rs
故障根因
修复前的连接池存在两个叠加缺陷:
- 租约没有 Drop 兜底。
PooledConnectionLease只能通过显式release_connection归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Drop:permit 因OwnedSemaphorePermit自动归还,但槽位的in_use标记永远不会复位。 - 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,不允许无限等待。
修复方案
PooledConnectionSlot改为in_use: AtomicBool + connection: Mutex<Option<PooledConnection>>,槽位占用标记不再依赖异步锁。PooledConnectionLease持有Arc<SpacetimeConnectionPool>并实现Drop:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位in_use再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。- acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过
pool_size,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。 release_connection退化为drop(lease),显式与隐式归还共用同一条兜底路径。
这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;dropped_lease_releases_slot_and_permit 只覆盖本地 Drop,不构成远端取消测试。
验收
cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib(44 通过,含上述连接池与缓存连接测试)cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz(2 通过)cargo check -p api-server --manifest-path server-rs/Cargo.toml
2026-07-14 缓存读连接拆分
HTTP 角色的连接拓扑调整为“pool_size 条调用连接 + 1 条缓存读连接”:
- 调用连接池只承接 procedure / reducer 回调,不订阅 read model,也不装载订阅行;
GENARRATIVE_SPACETIME_POOL_SIZE=8表示 8 条调用连接,不包含额外的缓存读连接。 - 缓存读连接只由
read_after_connect使用,并持有一份 required / optional read-model subscriptions。多个本地读取通过Arc共享同一 SDKClientCache,初始化和断线重建使用单飞锁,读取本身不经过单槽 semaphore 串行化。 - 缓存读连接断线后,下一次读取只重建一条连接并等待 required subscriptions 全部 applied;首次建连与 required / optional 订阅共用一次总超时预算,optional 阶段发生断线时禁止发布 broken 连接;旧连接由在途读取持有到结束后再断开。
/readyz对 HTTP 角色同时检查调用池连接和缓存读连接;required subscription 失败时不得报告 ready。worker / controller 关闭 read-model cache 时不创建额外连接。- 这里的“只读”是 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在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。 - HTTP 角色排查连接数时按“调用池 + 1 条缓存读连接”计算;外部生成唤醒和充值过期监听还有各自的窄订阅连接,不属于调用池或缓存读连接。