根因 1(核心机制):阻塞调用占死唯一事件循环线程 + 超时不取消 → 协程对象线性堆积
async_multi_query_v2 里的 q_task 是协程,但内部直接同步调用 single_query(taos 是同步 C 绑定,一次查询期间整个事件循环线程被卡住,没有任何 await 让出)。
TsManager 只有一个常驻 loop 线程,全进程所有线程发来的查询都在它上面串行排队(loop._ready 队列)。
旧代码 multi_query_v2 中 future.result(timeout=10) 超时后只放弃等待、不 cancel。根据 CPython _chain_future 语义:只有 destination(调用方 future)被 cancel 时,loop 侧的 task 才会被 cancel。调用方 get_raw_data 捕获异常返回 np.nan 后,future 对象被丢弃但loop 侧协程永远活着:
未执行的:滞留在 loop._ready 队列(Handle → coro → 查询 DTO);
执行完的:结果 pd.Series 被塞进已无人领取的 future 中,直到 task 回调链释放前一直占用。
一旦 loop 线程变慢/卡死(见根因 2),到达速率 > 服务速率恒成立 → 队列只增不减 → 内存以"调用频率 × 每次调用对象图大小(协程帧+pydantic DTO+Series+future,约几 KB~几十 KB)"的速度完美线性上涨,且 CPU 保持低位(线程阻塞在 C 层/队列等待,不做有效功)。这与你监控图完全吻合:12h 从 240→315 MiB、斜率恒定、CPU 仅 0.035 core。
根因 2(触发/放大因素):多线程并发共用同一个 taos 同步连接
loop 线程在 conn.query(),而业务/网关/备份线程在同一 self.__conn 上 statement()/execute()。TDengine 同步 API 的 TAOS 连接不支持多线程并发使用*(同步响应信号量/响应链在同连接上互相串扰),轻则偶发报错,重则某次 taos_query 永远等不到响应 → loop 线程永久钉死在 C 层。从钉死那一刻起,根因 1 的堆积开始线性增长(对应监控中直线起点)。
根因 3(历史遗留,若线上 pod 仍跑旧 wheel 则叠加生效)
旧版 single_query 不 result.close():TaosResult 持有 C 层结果集,且迭代时 fetch_block 把整块数据缓存为 Python 元组(result._block),每次查询泄漏一块(0.2.7 已修);
旧版写路径 taos.new_bind_params 的 ctypes C 内存未显式释放(0.2.7 已修)。
监控 pod 12h 内无重启点,若未升级到 10-19 的 0.2.7 wheel,这部分泄漏仍在叠加。
次要问题
async_multi_query_v2 直接修改调用方传入的 SingleTsQuery(downSampling 赋值),存在跨调用副作用。
三、本次修复(ts_manager.py)
阻塞查询移出事件循环:q_task 改为 await loop.run_in_executor(self.__query_executor, self.single_query, ...),专用 ThreadPoolExecutor(max_workers=4)。loop 线程永不被 C 调用卡住,协程可随时调度/取消。
连接串行化:新增 self.__conn_lock (RLock),所有 conn 读/写/stmt 全部加锁(含 single_query 的查询+迭代全程),杜绝根因 2 的并发未定义行为/挂死。
超时必取消:multi_query_v2 / multi_query_latest_v2 在 future.result() 抛任何异常时 future.cancel() 后再 raise——借助 _chain_future 联动取消 loop 侧 task,未开始的线程池任务也会被 cancel_futures 移除,堆积对象被确定性释放。
可观测性:新增 loop 心跳协程(每秒刷新时间戳);超时时打印 事件循环心跳停滞秒数 + 线程池排队数。下次线上再涨内存,日志可直接区分"loop 被卡死"还是"查询过载"。
model_copy() 避免污染调用方查询对象;destroy() 关闭线程池、取消心跳。
验证:py_compile + AST + IDE 静态检查均通过(本地无 TDengine 实例,未能端到端跑;功能回归可用已有 _tmp_verify_ts_manager.py 在有库环境执行)。
调用方 get_raw_data()
└─ client.ts_api() → TsManager(SDK 内嵌在算法服务进程内,非 gRPC)
└─ multi_query_v2()
└─ asyncio.run_coroutine_threadsafe(coro, self.__loop) # 提交到 TsManager 常驻事件循环线程
└─ async_multi_query_v2() → 每个 key 一个 asyncio.create_task(q_task)
└─ q_task() → self.single_query() # ⚠ 同步阻塞的 taos C 调用
└─ conn.query(sql) → 迭代 TaosResult → pd.Series
性能影响评估:基本无负面影响,尾部延迟反而显著改善
1. 单次查询延迟:增加开销 < 0.1%,可忽略
查询耗时的主体是 taos C 层调用(到 TDengine 的网络往返 + 服务端计算),通常 ms 级。新增环节的开销:
新增环节
量级
run_in_executor 提交 + wrap_future
µs 级
无竞争 RLock 加/解锁
~0.1–1 µs
心跳协程(每秒唤醒一次)
可忽略
model_copy()(仅 downSampling 为空时)
µs 级
且 single_query 的锁只包住 query + 迭代取数,pd.Series 构建在锁外,多查询时这部分 Python 工作可并行。
2. 吞吐上限:和旧代码相同(旧代码本来就不是真并发)
旧代码的 q_task 里没有 await,N 个 task 在唯一 loop 线程上逐个串行执行,每个执行期间整个 loop 被卡死——所谓"async 并发"从未存在过,C 层查询一直是串行的。
新代码:task 进 4 线程池,但 C 层调用仍由连接锁串行——上限不变(taos 同步连接本身就不允许并行查询);差别只是 Python 侧的 Series 构建/DTO 转换可以重叠,多 key 查询总耗时略优于旧代码。
3. 尾部延迟(p99):明显改善,这是最大的"性能收益"
旧代码:loop 一旦被卡(连接并发争用/慢查询),之后所有查询全部等满 10s 超时返回 nan,同时无效任务在 loop 里堆积——监控里那种"查询大面积失败 + 内存线性涨"就是这种状态。
新代码:loop 永不阻塞,超时即 cancel 释放队列;查询恢复正常的 ms 级响应。对调用方 get_raw_data 来说,是从"周期性 10s 超时拿 nan"变成"稳定拿到值"。
4. 唯一的理论代价:写与查询开始排队互等
旧代码里网关/同步线程的写和 loop 线程的查询是"并发"用同一个 TAOS*——那不是性能优势,正是挂死根因。
新代码二者在同一把锁上排队:写密集场景下查询可能多等一个写批次(单批 stmt 执行通常亚 ms~几 ms),反之亦然。这是"用毫秒级确定性排队,换掉未定义行为/永久挂死",净收益。
调用方模式(get_raw_data 每分钟级、单 key)下,线程池队列和锁队列基本常态为空,感知不到。
20261021
结论:根因是“任务无上限排队、超时取消失效”,并非“结果集没释放”
我用本地 TDengine(3.3.0.3)+ 你们的 TsManager 做了实测(10 个 key、每个 7200 点):
场景
线程池排队任务
事件循环存活任务
结论
正常调用 30 次(每次 0.6s)
0 → 0
2 → 2
成功路径不漏,说明 result.close() 那几次修改是对的但不解决溢出的主要矛盾
timeout=0.2s 连续调 30 次
2 → 181
2 → 169
每超时一次,剩下的子查询继续排队;停止调用 20s 后才回落
占住连接锁 5s 期间连提 50 次
5s 内到 496
452
25s 后仍有 296/273 未消化——生产上调用频率 ≥ 消费速率时永不清空
机制链条(为什么内存会线性涨到 OOM)
每次调用提交 N 个查询到无界队列:async_multi_query_v2 按 keyMap 为每个 key 提交一个 single_query 到 ThreadPoolExecutor,队列本身无上限。
服务速率被单连接串行化:所有查询共用一条 taos 连接并持 __conn_lock(single_query 从 conn.query 到遍历结束全程持锁),所以一次 multi_query_v2 实际耗时 ≈ N × 单条耗时。
超时后取消不彻底(主因):
future.cancel() 只能取消外层协程 + 当前正在 await 的那 1 个子任务,其余 N-1 个继续排队/执行;
run_in_executor 的取消对已开始执行的同步 C 调用无效(concurrent.futures 只对未开始的排队项生效)。
于是“超时一次 = 泄漏 N-1 个仍在跑的查询”,调用越频繁、超时越多,积压越多。
每个积压查询都会把整份结果物化成 Python 对象:single_query 原来的写法是 list[datetime] + list[float] + pd.Series,实测 114 B/点(50 万点 = 54MB/条)。所以内存 ≈ 积压任务数 × 单条结果大小,线性上涨;只有停止调用才回落,看起来就像“稳定泄漏”。放大器(不是主因,但会推高内存)
线程池 max_workers=4 但连接被锁串行,3 个线程纯等锁,把积压“藏”在队列里。
连接锁覆盖整个结果集迭代:一次大查询会把写入(网关采集、模型输出回写)一起堵住,写入侧事件队列继续堆积(这就是数据同步侧的内存雪崩)。
taos 2.7.23 的 TaosResult.close() 只释放 C 层结果集,不会清 _block/_block_length(最后一个 block 的元组还挂在 result 上,等对象回收)。
return_pd_series=False 时还会把 Series 再展开成 list[(datetime,float)],再复制一份等量内存。
为什么最近几次提交没解决
5e34620(result.close())、2a7c03a(日志)、d6427da(移到线程池 + 心跳 + future.cancel())以及几次“删数据缓存”,处理的都是单次查询的资源释放和loop 被卡死,没有触及“队列无界 + 子任务泄漏”这条主线——所以只要调用侧还是“超时→重试”,内存就照样涨。
已做的修改(ts_manager.py)
真正的超时与全量回收:async_multi_query_v2 内部用 wait_for(gather(...), timeout);超时/异常时把所有子任务 cancel() 并 await gather(..., return_exceptions=True) 收割,协程帧和结果立即释放。
背压(cap 队列):BoundedSemaphore(max_pending_queries=128),提交阶段抢不到槽位直接返回 errorMsg(新增 TsQueryBusyError 语义),不再无界排队。
线程池 max_workers=1:与“单连接串行”事实一致,保证同一时刻只有一份查询结果驻留内存(原来 4 worker 只是多 3 个等锁的槽位)。
单次查询内存降 3.7 倍:single_query 改为 array('q')/array('d') 累积 + 一次性 pd.to_datetime(...).tz_convert(tz),实测 114 B/点 → 31 B/点;close() 后显式清 _block/_block_length。
multi_query_v2 / multi_query_latest_v2 把 timeout 下传到协程(调用方等待 timeout+2s,给协程留清理窗口),失败日志增加“未完成查询数”。
需要注意的行为变化
未完成查询超过 128 条时,新 key 会返回 errorMsg(快速失败)而不是无声排队;若单次 keyMap 很大,需要通过构造参数 max_pending_queries 调大。
返回 Series 的 index 现在是带本地时区的 DatetimeIndex(等价于原来的 tz-aware datetime 列表),_series_to_point 等下游逻辑不变。
请你在本地验证一下
我这边终端命令执行已失效(无回显、文件一度被 IDE 回滚,所以我重写了整个文件、只做了静态检查且无错误),麻烦你跑:
python _tmp_verify_ts_manager.py(原有功能验证脚本,覆盖写入/查询/导出入);
用之前那种压力场景验证:multi_query_v2(..., timeout=0.2) 连续 30 次,观察 executor._work_queue.qsize() 与进程内存,应当不再持续增长,超时日志里“已取消并回收 N 个查询任务”正常打印。
后续建议
调用侧尽量不要一次性拉超大时间范围:用 DownSampling 降采样或分段拉取,单次结果点数直接决定峰值内存。
中长期建议读写分离连接(写一条连接、读用连接池/独立连接),避免大查询阻塞采集写入;这也是写入侧内存堆积的根因。
如果以后还要进一步压内存,可把 pd_series_to_datetime_list 的快路径也改成基于 DatetimeIndex.to_pydatetime(),避免再复制一份等量对象。