第 2 章:理解 BLAST 内存模型——关键影响因素

内存溢出,说白了就是 BLAST 找系统要内存,系统给不起。但为什么有的任务给得起,有的就给不起?这背后是 BLAST 的一套内存分配逻辑。搞懂它,你就能预判什么情况下会炸,什么情况下稳如老狗。

我个人习惯先把一个工具的内存模型摸透,再去调参。不然你连 OOM 是怎么来的都不知道,谈何处理?

2.1 BLAST 内存分配的核心区域

BLAST 进程启动后,内存主要花在三个地方。我画了一张图,方便你理解它们的层次关系。

BLAST 内存分配核心模型 BLAST 进程内存分配模型 BLAST 进程虚拟地址空间 数据库索引区 - 序列数据 - 哈希表/后缀数组 - 体积索引 通常占据 60%-80% 查询序列区 - 单条/多条序列 - 序列缓冲区 - 分批读入队列 通常 < 5% 运行时工作区 - 局部比对结果缓存 | - 得分矩阵(如 BLOSUM62) - 线程私有权重表 | - 扩展/回溯临时数组 线程数 n 直接影响此区域大小 内存占比排序:数据库索引区 > 运行时工作区 > 查询序列区

从上图你能看明白:绝大部分的内存都被数据库的索引数据吃掉了。我在项目中遇到过很多次,新手把问题归咎于查询序列太大,其实查出来的内存大头根本不在那儿。

2.2 数据库索引 —— 真正的内存大户

BLAST 要快,就必须把数据库的索引结构全部加载到内存。不然每次比对都去读磁盘,速度会慢到让你怀疑人生。

具体来说,这些索引包含以下内容:

  • 序列数据本身:所有数据库序列的碱基/氨基酸序列,以紧凑格式存放。
  • 哈希表或后缀数组:这是 BLAST 快速定位“种子”匹配的索引结构,大小与数据库总字符数成正比。
  • 体积索引 (volume index):对于超大数据库,BLAST 会分卷建立索引,每卷的索引头也要占内存。

举个例子。我前几天处理一个 nr 数据库的格式化,本地建库后一看大小:

$ ls -lh nr.*
-rw-r--r--  1 user  group   25G Feb 18 10:00 nr.00.phr
-rw-r--r--  1 user  group   18G Feb 18 10:00 nr.00.pin
-rw-r--r--  1 user  group   12G Feb 18 10:00 nr.00.psq
-rw-r--r--  1 user  group   34G Feb 18 10:00 nr.01.phr
-rw-r--r--  1 user  group   22G Feb 18 10:00 nr.01.pin
-rw-r--r--  1 user  group   15G Feb 18 10:00 nr.01.psq

.phr 和 .pin 文件就是索引。你想想看,仅一个分卷的索引头就 25G,两个分卷加起来 59G。BLAST 要全量加载它们,机器没个 128G 物理内存,不 OOM 才怪。

这里有个重要参数控制了索引在内存中的组织形式:

关键参数:-db_soft_mask、-gil、-no_greedy、-ungapped

这些参数会改变搜索算法,间接影响索引的访问模式和缓存驻留量。但不改变“索引必须全部加载”这个前提。

2.3 查询序列缓冲区 —— 最小的一块

很多人误以为查询文件越大,内存占用越高。其实不是这样。BLAST 会把查询序列分批读入内存,一次只处理一批。

决定这批大小的参数是 -query_batch 或隐含的 -query_loc 设定。默认情况下,BLAST 会智能地划分批次,保证单批序列总碱基数不超过一个阈值。

可以这么理解:

# blastn 默认分批行为(简化逻辑)
每一批次的查询碱基总数 ≈ min(5e6, 单条序列平均长度 * 1000)

所以即使你给了 100 万条短 reads,每批也可能只处理几千条。这一块的峰值内存通常不会超过几百 MB。

一个小技巧:如果你想验证查询序列的实际缓冲区大小,可以在 blastn 命令行加上 -outfmt 11 并搭配 -verbose,日志里会打印出 batch 的划分细节。

2.4 运行时工作区 —— 线程数决定它的大小

这部分内存是 BLAST 在比对过程中动态申请的,用于存放比对得分矩阵、局部命中扩展数组、回溯路径等临时数据。

它的计算公式并不复杂:

运行时工作区 ≈ 线程数 × 每线程工作区大小

其中每线程工作区大小取决于:

  • 比对的序列长度:越长所需扩展数组越大。
  • 得分矩阵大小:例如 BLOSUM62 是 24×24 的矩阵,约 2.3 KB;而 PAM250 略大。
  • 字长 (word size):字长越小,命中数越多,扩展步骤越多,工作区消耗也越大。
  • 置换矩阵 (composition-based statistics):开启 -comp_based_stats 会引入额外的权重表,每个线程多出几十 KB 开销。

用一张表格来看不同线程数下的运行时工作区估算:

线程数 每线程开销(典型值) 运行时工作区总量 典型场景
1 ~500 MB ~500 MB 单核调试,避免 OOM
8 ~500 MB ~4 GB 日常中等规模比对
32 ~500 MB ~16 GB 高并发服务器,数据库大
64 ~500 MB ~32 GB 极限吞吐,小心内存瓶颈

我曾经在 64 核机器上做过一次测试,数据库索引占了 80 GB,运行时工作区又吃了 32 GB,加上系统开销,直接把 128 GB 物理内存吃满,触发了 OOM Killer。后来我把线程数降到 48,才稳定住。

注意:这里的“每线程开销”不是固定不变的。如果查询序列长度波动很大(例如同时有 50 bp 和 100 kbp 的序列),BLAST 会对长序列分配更大的临时数组。此时用平均值估算会偏小。

2.5 操作系统与虚拟内存的干扰

除了 BLAST 自己申请的内存,操作系统层面还有两个因素会影响 OOM 的发生:

  • mmap 机制:BLAST 在加载大数据库时会使用 mmap 将索引文件映射到虚拟地址空间。这个映射占用的虚存 (VSZ) 可能远大于物理内存 (RSS)。如果系统配置了 vm.overcommit_memory=2,BLAST 可能会因为虚拟地址空间不足而直接被拒绝分配。
  • Overcommit 策略:我建议你在生产环境中检查这个值。如果是 0 或 1,BLAST 可以超额申请;如果是 2,系统会严格限制。很多 OOM 其实是这里出的问题,而不是 BLAST 本身的 bug。

检查 Overcommit 策略很简单:

$ cat /proc/sys/vm/overcommit_memory
0
$ cat /proc/sys/vm/overcommit_ratio
50

如果 overcommit_memory 是 2,系统只允许分配物理内存+交换空间的 50%(由 overcommit_ratio 控制)。这时候 BLAST 想 mmap 一个 30G 的索引文件,系统说“不行,你已经超了”,BLAST 就直接报内存分配失败。

一句话总结这个章节的核心:BLAST 内存大头在数据库索引(60%-80%),其次是运行时工作区(与线程数成正比),查询序列本身占得最少。OOM 的根源,十有八九是数据库建得太大,或者线程数拉得太高。