面试知识库

08 · 可观测性与性能排查#

简历原话:可观测性与性能排查:基于 OpenTelemetry 实现跨 Redis Stream 的全链路追踪;发现同 worker 内多会话 span 同时变长,定位到同步回查 Qdrant 阻塞事件循环,改走线程池后其他会话停顿 P99 860ms→5ms;回查慢源于高峰时与向量检索争用磁盘,向量 int8 量化后常驻内存,回查 P99 1.3s→49ms。

30 秒口述版#

ShoppingX 的任务是 API 收请求、写进 Redis Stream,再由 worker 消费执行,一个 worker 进程用一个事件循环同时跑几十个会话。早期 trace 只覆盖 worker 里的那一段,排队和执行连不起来。我用 OpenTelemetry 把 W3C traceparent 写进 Stream 消息,worker 取出后接着同一条 trace 往下挂,API、排队、Agent 主循环、每次模型和工具调用就在一棵树上了。接上之后看到一个现象:同一个 worker 里互不相关的几个会话,span 在同一时间窗口一起变长。多个会话同时变慢,说明它们共用的东西被占住了,这个东西就是事件循环。最后查到是按商品 ID 回查 Qdrant 的同步调用把循环卡住了,改成丢线程池执行后,其他会话的停顿 P99 从 860ms 降到 5ms。回查本身为什么慢?高峰期它和向量检索抢磁盘,我给向量做了 int8 标量量化、常驻内存,回查 P99 从 1.3s 降到 49ms。

背景与问题#

系统形态:API 进程只负责鉴权、准入和入队,把任务写进 Redis Stream;worker 进程用消费者组拉任务,每个 worker 一个 asyncio 事件循环,靠并发槽同时跑几十个会话(每个会话是一轮 Agent 主循环:planner → 检索 → 比价精挑 → 收尾)。模型调用、Redis、MySQL 都是异步 IO,理论上一个循环扛几十个会话没问题。

问题 1:链路是断的。

  • 原来的 trace 从 worker 开始执行那一刻才开始,API 那边的入队、排队等待都不在 trace 里。消息里只带一个 request_id,两个进程的日志要靠它手动拼。
  • 用户反馈「点了发送好久没动静」,我们分不清是卡在准入闸、在队列里排队、在等模型限流,还是某个工具慢。

问题 2:高峰期会话会「集体顿一下」。

  • 链路接上后,在 Langfuse 瀑布图里看到奇怪的现象:同一个 worker 上三四个毫不相关的会话,模型流式输出的 span、写 Redis 的 span 在同一个时间窗口里一起被拉长了将近 1 秒。
  • 这些 span 本身各干各的:有的在等模型吐 token,有的在写事件,没有一个跟数据库或检索有关。前端表现是流式文字突然停住一下,再一口气吐出来。
  • 单看任何一个会话都找不到原因,因为慢的根源不在它自己身上。

问题 3:回查本身在高峰期也很慢。

  • 把阻塞修掉后,别的会话不受影响了,但发起回查的那个会话自己还是要等。高峰期回查 P99 有 1.3s,而空闲时同一个请求只要 4ms 左右。

做法与取舍#

1. 跨 Redis Stream 的 trace 传递#

  • 做法:
    • API 收到任务时开一个「入队」span,把当前 span 的上下文按 W3C Trace Context 格式序列化成 traceparent(00-<32位 trace_id>-<16位 span_id>-<flags>),作为一个字段和任务一起 XADD 进 Stream。
    • worker 拉到消息后解析这个字段,开本轮的根 span 时把它当父 span,于是 API 段和 worker 段是同一个 trace_id。本轮下面所有模型调用、工具调用的 span 由 AgentScope 的 tracing 中间件自动挂上来;同轮并发的多个工具调用走 asyncio Task,OTel 上下文本身存在 ContextVar 里,建 Task 时会被拷贝,所以子 span 天然挂在正确的父节点下。
    • 排队时长 = 入队 span 结束到 worker 根 span 开始之间的空档,另外在根 span 上记一个排队等待属性,方便按它筛。
    • 日志每行带 trace_id / request_id / thread_id,指标里补了队列积压、任务被接管次数、限流等待时长,积压超过 90% 触发告警(带滞回,防抖动)。
  • 为什么手动解析 traceparent,没用 OTel 自带的 propagator 提取:propagator 提取出来的是 OTel Context,要 attach 到当前上下文,采样器按父 span 的 flags 决定要不要采样。API 侧如果没开观测,发来的 flags 是 00,worker 会把整轮 span 全丢掉,症状是「worker 开着观测却一条 trace 都没有」。手动解析后自己判断:上游采样了就挂父 span;上游没采样就只沿用 trace_id、不挂一个不存在的父节点。
  • 观测关着也生成 traceparent:随机生成 trace_id、flags 记 00。这样即使不上报 span,两个进程的日志也能按同一个 trace_id 关联起来。
  • 否掉的方案:
    • 在 Stream 外另存一张 request_id → trace_id 映射表:多一次读写、多一个一致性问题,而 traceparent 本来就是为跨进程传递设计的标准格式。
    • 让 worker 自己开新 trace、事后用 request_id 做关联:Langfuse 里看不到一棵完整的树,排队时长还是得手算。
  • 原则:观测不能拖垮主链路。缺配置、客户端初始化失败、span 上报报错一律降级成「没有观测」,只记日志,不抛异常。

2. 从「多会话同时变慢」定位到事件循环阻塞#

  • 推理:一个会话慢,原因可能在它自己;多个不相关的会话在同一时刻一起慢,一定是它们共享的东西被占住了。同一个 worker 进程里它们共享的只有 CPU 和事件循环,而 CPU 使用率不高,嫌疑就落在事件循环上:某段代码在循环线程上同步执行,期间所有协程都得排队。
  • 补指标验证:给 worker 加了一个事件循环延迟探针:一个后台协程每 100ms 睡一次,醒来时看实际睡了多久,多出来的就是循环被占住的时间,按直方图上报。高峰时延迟探针的尖峰和那些「集体变长」的时间窗口一一对上。
  • 找阻塞者:探针发现延迟超过 100ms 时,采样打印当时循环线程的调用栈。栈顶反复出现的是按商品 ID 回查 Qdrant 的 scroll 调用。
  • 为什么会有同步调用:候选商品登记表只活一轮。用户下一轮说「买第 2 个」「把刚才那几件对比一下」时,比价、收尾、下单、对比这些工具要按 ID 从 Qdrant 把商品捞回来。检索路径早就丢线程池跑了,而这条回查路径用的是同一个同步 Qdrant 客户端,却直接在 async 工具里调用,平时 4ms 看不出问题,一到高峰就卡住整个循环。

3. 改走线程池#

  • 做法:回查调用丢到 asyncio 默认线程池里执行,事件循环只 await 一个 Future,等待期间照常调度别的会话。回查外面仍包着原来的断路器和超时,失败时只记日志,退回「登记表里已有的那些」,不让收尾链路报错。
  • 为什么选线程池,不换异步客户端:
    • Qdrant 有官方的异步客户端,但换它要改整个召回层:检索、回查、搜同款、断路器包装全得跟着改,还要两套客户端并存一段时间。检索路径早就是「同步客户端 + 线程池」,回查照同一个模式改,改动最小、行为一致。
    • 这里的阻塞是等网络和等 Qdrant 服务端,线程在等待时会释放 GIL,线程池足够用,不存在 CPU 密集任务被 GIL 卡住的问题。
  • 否掉的方案:
    • 给回查加进程内缓存:跨轮引用的商品 ID 很分散,同一批 ID 很少被第二次回查,命中率低;而且缓存只是让它不常发生,一旦未命中照样卡循环。
    • 调大 worker 数、减少每个 worker 的会话数:只是把被连累的会话变少,阻塞本身还在。
  • 防止再犯:延迟探针和调用栈采样常驻线上,循环延迟 P99 进了告警规则。之后谁在 async 路径里又写了同步 IO,第一时间能看到。

4. 回查慢的根因:和向量检索抢磁盘#

  • 现象:修完阻塞后别的会话不受影响了,但回查自己在高峰期 P99 还是 1.3s。回查只是按 payload 索引 scroll 几十条记录,不做向量计算,空闲时 4ms。
  • 排查:压测时 vmstat 看到 iowait 约 51%、读盘约 200MB/s,用户态 CPU 只有 10% 左右,瓶颈在磁盘不在 CPU。召回库是 138 万商品 × 1024 维 float32,原始向量约 5.4GB,当时向量和 HNSW 图都放在磁盘上(全放内存会顶爆容器上限)。每次向量检索都要从磁盘读大量原始向量来打分,page cache 装不下,高峰期磁盘 IO 队列被检索占满,回查的读请求只能排在后面。
  • 做法:开 int8 标量量化,并让量化后的向量常驻内存;原始 float32 向量仍放磁盘,只在最后对少量候选做精确重打分(oversampling 后 rescore)。量化向量约为原来的 1/4,1.3GB 左右,放得进内存。检索的主要打分全在内存里完成,不再大量读盘,磁盘让出来后回查自然就快了。
  • 为什么是 int8 标量量化:
    • 二值量化(1 bit)压缩率更高,但 1024 维的 BGE-M3 向量不是为二值化训练的,召回损失会明显更大。
    • 乘积量化(PQ)压缩率高,但打分更慢、召回损失也更大,Qdrant 官方也把它定位成内存特别紧时才用。
    • int8 在 Qdrant 里切换不需要重新 embedding,可以在线改集合配置,后台重建量化索引即可。
  • 代价:Qdrant 进程内存从约 356MB 涨到约 2.0GB;召回有损失,用 200 条带线上同款过滤条件的固定查询对比量化前的 top-20,recall@20 平均 96.8%,靠 rescore 把排序精度拉回来。量化索引构建约 20 分钟。
  • 否掉的方案:
    • 把回查搬出 Qdrant、商品详情另存到 MySQL:回查确实不再和检索抢盘,但要双写两份商品数据,还有一致性问题;而且检索本身仍然慢,只是把问题藏起来了。
    • 换更快的盘 / 加 Qdrant 副本:部署在单台 VPS 上,成本高,也没解决「每次检索都要读几 GB 向量」这个根本问题。

流程图 / 架构图#

图 1:trace 怎么跨过 Redis Stream#

sequenceDiagram
    participant C as 前端
    participant A as API 进程
    participant S as Redis Stream
    participant W as worker 进程
    participant L as Langfuse / OTel 后端
    C->>A: POST 提交任务
    A->>A: 开「入队」span,生成 traceparent
    A->>S: XADD 任务 + traceparent 字段
    A-->>L: 上报入队 span
    A-->>C: 返回 thread_id
    Note over S: 排队等待<br/>= 入队 span 结束到根 span 开始的空档
    W->>S: XREADGROUP 拉到任务
    W->>W: 解析 traceparent
    alt 上游已采样
        W->>W: 根 span 挂在入队 span 下,同一 trace_id
    else 上游未采样或观测关闭
        W->>W: 只沿用 trace_id,不挂父 span
    end
    W->>W: 模型调用 / 工具调用 span 自动挂到根 span 下
    W-->>L: 上报整轮 span
    Note over A,W: 两个进程的日志每行都带同一个 trace_id

图 2:一个同步调用怎么拖慢整个 worker#

flowchart LR
    subgraph W["worker 进程:一个事件循环"]
        EL["事件循环线程"]
        S1["会话 A:等模型流式输出"]
        S2["会话 B:写事件到 Redis"]
        S3["会话 C:收尾工具按 ID 回查商品"]
        TP["线程池"]
    end
    Q[("Qdrant")]
    S1 --> EL
    S2 --> EL
    S3 -->|"改前:在循环线程上同步 scroll"| EL
    EL -->|"阻塞期间 A、B 都无法被调度"| Q
    S3 -.->|"改后:丢线程池,循环只 await"| TP
    TP -.-> Q

图 3:回查为什么慢、量化后怎么变#

flowchart TB
    subgraph Before["量化前:向量与 HNSW 全在磁盘"]
        R1["并发向量检索"] -->|"读大量 float32 原始向量打分"| D1[("磁盘 5.4GB 向量")]
        F1["按 ID 回查"] -->|"排在检索的读请求后面"| D1
        D1 --> X1["iowait 约 51%,回查 P99 1.3s"]
    end
    subgraph After["量化后:int8 向量常驻内存"]
        R2["并发向量检索"] -->|"主打分走内存"| M2[("内存 int8 向量 约 1.3GB")]
        R2 -->|"只对少量候选 rescore"| D2[("磁盘 float32 原始向量")]
        F2["按 ID 回查"] --> D2
        D2 --> X2["iowait 约 0%,回查 P99 49ms"]
    end
    Before --> After

图 4:排查思路#

flowchart TD
    A["用户反馈:流式输出偶尔停住一下"] --> B["接通跨队列 trace,看瀑布图"]
    B --> C{"慢的是单个会话还是多个?"}
    C -->|"同 worker 多个无关会话同时变长"| D["怀疑共享资源:CPU 或事件循环"]
    D --> E{"CPU 高吗?"}
    E -->|"不高"| F["加事件循环延迟探针"]
    F --> G["延迟尖峰与变长窗口对齐"]
    G --> H["超阈值时采样调用栈"]
    H --> I["栈顶是同步 Qdrant 回查"]
    I --> J["改走线程池:他人停顿 P99 860ms→5ms"]
    J --> K{"回查本身还慢?"}
    K -->|"P99 1.3s,vmstat iowait 高"| L["与向量检索抢磁盘"]
    L --> M["int8 量化常驻内存:回查 P99 1.3s→49ms"]

数字怎么来的#

测试环境:线上同一台 VPS(部署 demo 的那台),在后端容器里直连内网 Qdrant,库里是全量 138 万商品。不调真模型,只打 Qdrant 和事件循环。

负载:模拟高峰的向量检索,6 组并发、每组 5 条同时突发,检索带线上同款过滤条件(平台 + 价格不超过预算 + 一半请求加评分不低于 4);查询向量从库里随机抽。每种配置连续跑 90 秒。

同时跑两个探针:

  • 回查探针:每隔固定间隔按一批随机商品 ID 做一次回查,记录耗时,统计 P50 / P95 / P99。
  • 事件循环延迟探针:和回查探针在同一个进程、同一个事件循环里,每 10ms 睡一次,醒来时记录实际多睡的时间,这就是「其他会话会感受到的停顿」。

三组对照(除被测变量外条件完全相同):

配置回查 P99事件循环停顿 P99 / 最大检索 P9990 秒完成检索数iowait / 用户态 CPU
量化前 · 同步回查1319ms860ms / 1314ms2297ms139551% / 10%
量化前 · 线程池回查1340ms5ms / 96ms2336ms139551% / 10%
int8 量化后49ms21ms / 88ms90ms87120% / 35%

怎么读这张表:

  • 860ms→5ms 是第一行对第二行:只把回查从同步改成线程池,其他条件不变。回查本身还是 1.3s(1319ms 和 1340ms 基本一样),但这段时间事件循环不再被占,别的协程感受到的停顿 P99 从 860ms 降到 5ms。这说明线程池只解决「连累别人」,不解决「自己慢」。
  • 1.3s→49ms 是第一行对第三行:量化后磁盘不再被检索占满,回查 P99 从 1319ms 降到 49ms。顺带检索 P99 从 2.3s 降到 90ms,90 秒内完成的检索数从 1395 涨到 8712。
  • 第三行的循环停顿 P99 21ms 是同步回查的配置:回查只剩几十毫秒,同步调用的代价也跟着变小;线上两个改动同时生效,停顿维持在个位数毫秒。
  • 空闲基线:没有并发检索时回查 P50 4ms、P99 7ms,用来说明回查本身很便宜,慢全来自高峰争用。

判断是磁盘瓶颈的依据:压测期间 vmstat 每秒采样,量化前 iowait 约 51%、读盘约 200MB/s、用户态 CPU 只有 10% 左右;量化后 iowait 降到 0,用户态 CPU 升到 35%(检索吞吐涨了 6 倍,CPU 在干活了)。

召回损失怎么测:200 条固定的带过滤条件查询,以量化前 HNSW 的 top-20 为参照,算量化后 top-20 的重合率,平均 recall@20 为 96.8%。

为什么用 P99 而不是平均:用户感知的是「偶尔卡一下」,平均值会被大量几毫秒的正常样本冲淡,P99 和最大值才反映出卡顿。

追问 Q&A#

Q1:traceparent 具体放在消息的哪里?worker 怎么接上?

它是 Stream 消息里和任务其他字段并列的一个普通字段,格式是 W3C 标准的 00-trace_id-span_id-flags。 worker 拉到消息后用正则校验格式,拒掉全零 id 这种非法值,然后开本轮根 span 时把远端的 trace_id 和 span_id 作为父上下文传进去。 老消息没有这个字段,或者格式不对,就当没有,自己起一条新 trace,不报错。

Q2:为什么不直接用 OTel 的 propagator inject / extract?

inject 那一侧我其实可以用,问题在 extract 之后的采样判断。 extract 出来的 Context attach 上去以后,默认的 ParentBased 采样器看父 span 的 flags:API 侧没开观测时 flags 是 00,worker 这一整轮就全被丢掉了。 我们的场景是 API 和 worker 可能分别开关观测,所以自己解析,自己决定:上游采样了就挂父 span,没采样只沿用 trace_id 做日志关联。

Q3:一轮 Agent 里同轮并发好几个工具调用,span 父子关系不会乱吗?

不会。OTel 的当前 span 存在 ContextVar 里,asyncio 创建 Task 时会拷贝一份当前上下文。 同轮多个工具调用是框架 gather 出来的多个 Task,每个 Task 建出来时都带着「当前是这一轮的模型调用 span」,所以子 span 都挂在同一个父节点下,互不串。

Q4:你怎么从 trace 里看出是事件循环的问题?trace 上不会直接写「循环被阻塞」。

trace 上看到的只是现象:同一个 worker 里几个互不相关的会话,span 在同一个时间窗口里一起变长,而且变长的是等模型、写 Redis 这种和彼此毫无关系的操作。 一个会话慢可能是它自己的问题,多个无关会话同时慢,说明它们共用的东西被占住了。同进程共用的只有 CPU 和事件循环,CPU 不高,就只剩事件循环。 然后我加了事件循环延迟探针去验证,尖峰和变长窗口能对上,再用超阈值时的调用栈采样找到具体是哪一行。

Q5:事件循环延迟探针怎么实现的?会不会本身有开销?

一个后台协程循环 sleep 固定间隔,醒来时用单调时钟算实际过去了多久,减去预期间隔就是循环延迟,写进直方图。 开销就是每 100ms 一次调度,可以忽略。超过阈值才去抓循环线程的调用栈,而且按比例采样,不会每次都抓。 压测时为了精度把间隔调到 10ms。

Q6:检索早就丢线程池了,为什么回查漏了?

两条路径是不同时期写的。检索从一开始就知道慢,所以包了线程池;回查是后来为「跨轮引用商品」加的,空闲时只要 4ms,写的时候没意识到它也是网络 IO。 Python 里 async 函数里调同步阻塞函数不会报错,也不会有警告,只有高峰才会暴露。所以我在修完之后把循环延迟 P99 加进了告警,靠指标而不是靠人记得。

Q7:线程池默认大小是多少?高峰时线程池会不会被打满,变成新的瓶颈?

asyncio 默认线程池的上限是 min(32, CPU 核数 + 4)。回查、检索都在里面排队,打满了新的调用会在线程池队列里等。 但这种等待只影响发起回查的那个会话自己,事件循环照常调度别的会话,这正是我们要的隔离效果。 线程池排队时间也在回查 span 里,真成了瓶颈 trace 上能直接看到。根本解决回查慢靠的是后面的量化,不是加线程。

Q8:为什么不换成 Qdrant 的异步客户端?那才是「正解」吧?

异步客户端确实更干净,但要改整个召回层:检索、回查、搜同款、外面包的断路器和超时,还要两套客户端并存一段时间。 这个问题的本质是「同步 IO 别在循环线程上跑」,线程池已经解决了,而且和检索路径保持同一个模式,维护成本低。 等召回层有别的理由大改时再整体切异步,不为这一个点单独动。

Q9:int8 量化召回掉了 3 个多点,对业务影响多大?最低的单条掉到多少?

平均 recall@20 是 96.8%,这是和量化前的 HNSW 结果比,不是和精确搜索比。个别查询会掉得多,尤其是过滤条件很严、候选本来就少的查询,最差的单条只有四成多。 业务上影响不大,原因有两个:一是开了 rescore,用磁盘上的原始向量对量化召回的候选重新打分,排序精度基本拉回来;二是召回之后还有 reranker 精排和按偏好精挑,最终只推荐 3 件,top-20 里偶尔换掉几件对最终清单影响很小。 如果要更稳,可以把 oversampling 系数调大,用一点延迟换召回。

Q10:如果商品从 138 万涨到 1500 万,int8 也放不进内存了怎么办?

1500 万 × 1024 维 int8 大约 15GB,单机确实吃力。几个方向: 一是 Qdrant 分片,把集合拆到多个节点,每个节点的量化向量常驻各自内存; 二是降维,BGE-M3 这类模型可以截断或换用更小维度的 embedding,维度减半内存就减半; 三是在内存特别紧时考虑二值量化加大倍数 oversampling,再用原始向量 rescore,但要重新测召回。 回查这条路径可以再加一层:按 ID 取商品详情走 Redis 缓存,彻底和检索的 IO 分开。

Q11:量化是在线切换的吗?切换过程中服务受影响吗?

Qdrant 支持在线修改集合的量化配置,不需要重新 embedding,后台重建量化索引,约 20 分钟。 重建期间集合状态会变黄,检索照常能用,只是还走旧路径、延迟不变。重建完成状态变绿,新请求自动走量化索引。 切之前我先在压测环境跑了三组对照,确认收益和召回损失,再上线。

Q12:你说「高峰」,线上真实高峰多高?压测的负载有代表性吗?

压测用的是 6 组 × 5 条的突发检索,对应的是一个 worker 上同时有几个会话进入检索阶段、每个会话同轮并发发出好几条跨平台检索的情形。 我们的单轮普通会话一般同轮发 35 条检索,所以 30 路并发检索大约对应 610 个会话同时检索,这在一个 worker 几十个并发槽里是正常高峰。 过滤条件也照线上同款配,不是裸向量检索。

相关八股#

1. OpenTelemetry 的 trace、span、context 是什么关系?

  • trace 是一次请求的完整调用树,由一个 trace_id 标识;span 是树上的一个节点,代表一段操作,有开始结束时间、属性、事件、状态,以及 span_id 和父 span_id。
  • context 承载「当前活动的 span」,在进程内靠语言的上下文机制传递(Python 是 ContextVar,Java 是 ThreadLocal),跨进程靠 propagator 把它序列化进请求头或消息。
  • 采样分头部采样(入口处决定,比如按比例、ParentBased 跟随父节点)和尾部采样(collector 收齐后按错误、慢请求决定)。
  • 本项目:进程内靠 ContextVar 自动传,跨 Redis Stream 靠把 traceparent 写进消息字段手动传。

2. W3C Trace Context 的 traceparent 格式?

  • version-trace_id-parent_id-trace_flags,例如 00-<32 位十六进制>-<16 位十六进制>-01。
  • trace_id 16 字节、parent_id 8 字节,全零是非法值;trace_flags 最低位是 sampled 标志。
  • 另有 tracestate 头,放各厂商自定义的键值对。
  • 本项目:只认 version 00,拒全零 id,按 sampled 位决定 worker 要不要挂父 span。

3. 消息队列场景下 trace 怎么传递?

  • 生产者把 context 注入消息头(Kafka 的 headers、RabbitMQ 的 properties),消费者取出后接续。
  • 语义上有两种接法:消费 span 作为生产 span 的子节点(同一条 trace),或另起新 trace、用 span link 关联(批量消费、一条消费对应多条生产时更合适)。
  • 排队时长可以从生产 span 结束到消费 span 开始的间隔算出来。
  • 本项目:一个任务对一次执行,用父子关系;Redis Stream 没有消息头,就当普通字段存。

4. Python asyncio 事件循环是怎么调度的?为什么同步调用会阻塞所有协程?

  • 事件循环是单线程的,不断从就绪队列里取回调执行;协程只有在 await 一个没完成的 Future 时才让出控制权。
  • 同步阻塞调用(requests、同步数据库驱动、time.sleep、CPU 密集计算)期间不会让出,整个循环停住,其他所有协程、定时器、IO 回调都得等。
  • 排查手段:asyncio 的 debug 模式会报告执行超过 slow_callback_duration 的回调;线上常用循环延迟探针。
  • 解法:run_in_executor / to_thread 丢线程池,或换原生异步驱动;CPU 密集的丢进程池。
  • 本项目:同步 Qdrant 回查卡住 worker 事件循环,改丢线程池。

5. Python GIL 对线程池有什么影响?什么时候线程池不管用?

  • GIL 保证同一时刻只有一个线程执行 Python 字节码。线程在做阻塞 IO(socket 读写、文件读写)时会释放 GIL,所以 IO 密集任务用线程池能并发。
  • CPU 密集的纯 Python 计算不释放 GIL,线程池没用,要用进程池或者把计算放进释放 GIL 的 C 扩展里。
  • asyncio 默认线程池上限 min(32, CPU 核数 + 4)。
  • 本项目:回查是等网络和等 Qdrant,线程等待时释放 GIL,线程池够用。

6. 向量量化有哪几种?各自的取舍?

  • 标量量化(SQ,int8):每个维度从 float32 映射到 8 位整数,内存降到 1/4,召回损失小,速度通常还更快。
  • 乘积量化(PQ):把向量切成若干子向量,每段用聚类中心的编号表示,压缩率可达几十倍,但召回损失和打分开销更大。
  • 二值量化(BQ):每维 1 bit,压缩 32 倍,适合为此训练过的高维模型,一般要配大倍数 oversampling 加 rescore。
  • 常见组合:量化向量放内存做粗筛,原始向量放磁盘对少量候选精排(rescore)。
  • 本项目:int8 常驻内存 + 原始向量放磁盘只做 rescore,recall@20 96.8%。

7. HNSW 的原理?内存和磁盘怎么分?

  • 多层可导航小世界图:上层节点稀疏、用来快速定位大致区域,下层稠密、做精细搜索;查询从顶层入口贪心下降,底层用 ef 大小的候选队列做 beam search。
  • 关键参数:M(每个节点的边数,影响内存和召回)、ef_construct(建图时候选队列大小)、ef(查询时候选队列大小,调召回和延迟的旋钮)。
  • 开销两部分:图结构本身(边)和节点的向量(打分要用)。百万级 1024 维时图只有几百 MB,大头是向量。
  • 本项目:图约 180MB,原始向量约 5.4GB,所以尾延迟大头是读向量打分,只有量化有效。

8. Linux page cache 和 iowait 怎么理解?怎么判断是磁盘瓶颈?

  • 读文件时内核先把数据页缓存在空闲内存里(page cache),再读命中就不用走盘;热数据超过可用内存就会不断被换出、反复读盘。
  • iowait 是 CPU 空闲且有未完成磁盘 IO 的时间占比。iowait 高、用户态 CPU 低、读盘吞吐接近磁盘上限,基本就是磁盘瓶颈。
  • 常用工具:vmstat 看 wa 和 bi、iostat 看 await 和 %util、iotop 看是哪个进程在读。
  • 本项目:量化前 iowait 约 51%、读盘约 200MB/s、用户态 CPU 10%,判定是检索读向量把磁盘打满。

9. P99 和平均延迟有什么区别?为什么尾延迟重要?

  • P99 是 99% 请求都不超过的值,反映最慢那 1% 的体验;平均值会被大量快请求冲淡,看不出偶发卡顿。
  • 一个页面或一轮任务要串行或扇出多次调用时,任何一次落在尾部都会拖慢整体,调用次数越多撞上尾延迟的概率越高。
  • 分位数不能简单相加或平均,多实例的分位数要用直方图合并后再算。
  • 本项目:用户感知的是「偶尔停一下」,所以停顿和回查都用 P99 和最大值衡量。