我有一台 16G 的机器,一开始跑得挺好:推理 API 占 9G,向量库占 4G,还剩 3G 富余。
后来我加了一个功能,会在检索时把 top-20 的文档全文拼进 prompt。这一下,向量库的内存占用涨到了 7G,API 涨到 12G,机器开始随机地丢请求。
最诡异的是日志:向量库偶尔报 Out of memory,但看系统内存还有 2G 空闲。
先搞清楚"内存不够"是哪种不够
Linux 的内存账本比想象中复杂,OOM 有三种完全不同的表现:
第一种,容器 cgroup 超限。 容器内的进程触碰了 memory.max,被内核杀掉,但宿主机上可能还有大量空闲内存。
# 看容器实际的 cgroup 限制(cgroup v2 路径)
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events # 关键:看 oom / oom_kill 计数
memory.events 里的 oom_kill 计数是最直接的证据。它非零,就说明发生过 cgroup 级别的 OOM。
第二种,宿主机物理内存耗尽。 这时候 free -m 里的 available 会很低。
free -m
# available 才是关键,不是 free
第三种,内存没耗尽但被回收了。 内核为了给新分配腾地方,把进程的页换出到 swap 或者直接丢弃。这时系统"available"看起来正常,但应用的延迟会突然飙高——一个平时 5ms 的查询变成 800ms。
第三种最阴险,因为它不报任何错误。
我那台机器当时就是混合情况:宿主机还有内存,但向量库的 cgroup 超限了;而 API 那个容器因为大量 mmap 读向量文件,页缓存被反复回收,延迟开始抖。
一台机器上两个服务的内存账
先算账再部署,别反过来。推理 API 的内存大致是:
模型权重(量化后) + KV cache(随上下文 × 并发增长) + 激活值峰值 + 框架开销
向量库的内存是:
向量数据(常驻) + 索引结构 + 写缓冲 + 查询时的临时结构
我按 16G 机器做过的分配:
| 项目 | 分配 | 依据 |
|---|---|---|
| 系统 + Docker | 1.5G | 实测,非估算 |
| 推理 API | 9G | 权重 4G + KV cache 3G + 余量 2G |
| 向量库 | 4G | 数据 2.5G + 索引 1G + 写缓冲 0.5G |
| 预留 | 1.5G | 给页缓存和突发 |
预留这一栏不能省。 我一开始留了 0.5G,后来发现页缓存完全不够用——向量库靠 mmap 读索引文件,没有页缓存的话每次查询都要走磁盘,延迟直接翻十倍。
cgroup 限额怎么设才不互相拖垮
这是关键。我的原则是:每个容器都必须设上限,不能让任何一个服务吃光全机。
services:
api:
image: my-api:latest
restart: unless-stopped
mem_limit: 9g
memswap_limit: 9g # 关键:和 mem_limit 相同 = 禁用 swap
cpus: "4.0"
environment:
- TZ=Asia/Shanghai
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
vector:
image: qdrant/qdrant:latest
restart: unless-stopped
mem_limit: 4g
memswap_limit: 4g
volumes:
- vector-data:/qdrant/storage
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
vector-data:
memswap_limit 等于 mem_limit 这一行是重点。
默认情况下,Docker 的 mem_limit 只限制物理内存,容器还能用 swap。后果是:向量库触到限额时不会立刻被杀,而是进入换页状态,延迟从 5ms 飙到几百毫秒,而且表现为随机慢,不是失败。
把 memswap_limit 设成和 mem_limit 相同,等于告诉内核"这个容器不许用 swap"。超了就直接 OOM kill,症状明确,好排查。
这跟上一条那篇讲 OOM killer 会挑倒霉鬼是一个道理:快速失败比缓慢劣化好得多。
关于 swap 本身
如果机器上有 swap,我现在的态度是:留着,但别让服务用它。
理由是数据库和向量库对延迟敏感,换页对它们是灾难。系统层面的 swap 我会调到 1G 左右,专门用来接住那些"本来会 OOM 但只是一瞬间超了一点"的场景,让内核有时间收缩,而不是立刻杀掉。
# 查看 swap 使用情况
swapon --show
free -m
# 观察有没有在换页
vmstat 1
# si/so 列是 swap in/out,nonzero 说明正在换页
vmstat 的 si 和 so 是我判断"延迟抖动是不是换页造成的"的主要依据。只要它们持续非零,那就不该去查数据库参数,应该去查内存。
排查 OOM 该看什么
按这个顺序看,能省很多时间:
第一步,看 cgroup 事件。
# 系统范围的 OOM 记录
dmesg -T | grep -i "killed process" | tail -20
# 容器的
cat /sys/fs/cgroup/memory.events
dmesg 里那行 Killed process 12345 (python) total-vm:... 是黄金证据——它告诉你是哪个进程被杀的、当时占了多少。
第二步,看是不是被 cgroup 杀的。 如果 dmesg 里没有记录,但容器 memory.events 的 oom_kill 非零,那就是 cgroup 杀的,宿主机整体可能还很空闲。
第三步,看页缓存和 swap。
# 页缓存占用了多少
grep -E "^(Cached|Buffers|SReclaimable)" /proc/meminfo
# 谁在吃页缓存
vmtop - 2>/dev/null || pidstat -r 2 3
一个反直觉的做法:给向量库更多内存,而不是更少
我原本的直觉是"向量库很吃内存,给它少一点,剩下的给推理"。
实际上做反了。向量库的内存特征是大部分常驻且可预测——向量数据多少、索引多大,都是静态的。推理 API 的内存是突发型的——KV cache 随并发和上下文长度波动,激活值在请求高峰时冲高。
两者混在一台机器上,最危险的不是向量库吃多了,而是推理服务在高峰期把内存吃满,触发 OOM killer,然后内核按"坏分数"选倒霉鬼,杀掉的可能是向量库。
我遇到的就是这个:向量库被杀,API 反而活着,症状是"检索偶尔返回空结果"。
更糟的情况是内核不杀进程,而是回收页缓存——向量库靠 mmap 读索引,一被回收,每次查询都要走磁盘。表现是延迟毛刺,不是错误。
现在的做法是:
- 两个容器都设硬上限,且上限加起来不超过物理内存的 80%
- 留 20% 给页缓存
- 向量库的
mem_limit给足,因为它需要 mmap 的文件页缓存才能跑得快 - 推理 API 的上限略松一点,但仍然设死
# 容器启动后确认限额生效
docker inspect -f '{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}' api
# 输出应该形如:9663676416 9663676416(两个值相等 = 无 swap)
什么时候该拆开
讲真话:上面这些技巧都是在"一台机器上塞两个服务"的前提下做优化。
如果你的场景符合下面任何一条,拆开比调优省事:
- 向量库数据量还在增长(内存上限是个移动靶)
- 推理并发波动大,峰值能到平时的好几倍
- 两个服务的迭代节奏差别大(向量库升级一次要重build 索引,推理服务一天发布三次)
- 有合规要求需要隔离
我的最后一台共用机器,是个小语料库 + 中等并发的内部工具,那种规模下共用确实划算。但只要过了这个规模,拆开带来的稳定性收益很快就超过省下的那点机器钱。
收尾
内存这件事的教训:OOM 不是"内存不够",是"内存分配的权利没界定清楚"。
我在那台机器上花的时间,八成是在调三个参数:mem_limit、memswap_limit、以及预留比例。剩下的才是真正的容量规划。
具体的数字(几个 G、留多少余量)跟你的模型大小、语料规模强相关,得按实测算。结构性的做法是通用的:先算账、再设限、硬禁 swap、留页缓存空间,落到具体机器上以实际规格为准。