一台 OpenSearch 3.5.0 的开发环境单节点,装着 qa、stg、preprod 三个环境的应用日志,数据总量只有 47.5 GB,14 天滚动删除。按这个数据量,4 vCPU 的机器应该很闲,CPU 和磁盘写入却一直不低。

现象#

节点启动 7.9 天以来,OpenSearch 平均占用 0.88 核 CPU;一次 60 秒的采样窗口里是 1.2 核,占 4 核的 30%。实际负载很小:每秒写入 124 条文档,1,456 个索引里只有 33 个在写,没有一个被查询。磁盘只用了 18%,整盘写入却有 1.69 MB/s、120 IOPS。

排查:磁盘写入的大头在哪#

把 60 秒内 OpenSearch 各线程的磁盘写入量拆开,按线程名对应到内部的操作类型。占比按各线程合计的 1,225 KB/s 计算,表里只列了写入最多的四类:

线程 写入速率 占比 对应的操作
system_write 844 KB/s 69% ISM 任务锁索引和配置索引的写入、refresh 产生的新段
write 248 KB/s 20% 业务日志的 translog 和段写入
refresh 65 KB/s 5% 定时 refresh
management 50 KB/s 4% 管理类后台任务

将近七成的写入落在 system_write 线程上,业务日志的写入只占两成。同一窗口里 merge 输出 110 KB/s,其中 96% 也来自这两个系统索引。

根因:ISM 每 5 分钟检查一遍全部索引#

这台机器上有 1,388 个索引挂着同一条 ISM(Index State Management,OpenSearch 自带的索引生命周期管理插件)策略:创建 14 天后自动删除。ISM 默认每 5 分钟检查一遍所有挂着策略的索引(见 ISM settings),检查动作要靠两个系统索引配合:.opendistro-job-scheduler-lock 记任务锁,.opendistro-ism-config 记策略状态;上一节 system_write 线程写的正是这两个索引。

每次检查前,job scheduler 先读出锁文档,再带着 seq_no 条件更新它来取锁,检查完再更新一次,把锁标记为已释放(见 LockServiceImpl.java)。取锁和放锁的写入都带 RefreshPolicy.IMMEDIATE,写完立即 refresh(同一份源码)。1,388 个索引摊在 300 秒里,每秒约 4.6 次检查;实测 60 秒内锁索引有 625 次写入、626 次 refresh,每秒约 10 次。

这些更新反复落在同一批锁文档上,每次都留下一个软删除的旧版本。OpenSearch 会保留最近一次安全 commit 之后的全部操作,用于副本恢复(见 SoftDeletesPolicy.java);不 flush 就没有新的 commit,这些旧版本就一直攒着,merge 也清不掉。自动 flush 只看 translog 大小,默认攒到 512 MB 才触发,按时间触发的 index.periodic_flush_interval 默认关闭(见 IndexSettings.java 和 Flush API)。锁索引的 translog 每小时只增长约 9 MB,攒到 512 MB 要两天半,节点启动以来只 flush 过 2 次。复查时距上次 flush 已有 47.5 小时,未提交的操作有 185.8 万条(419 MB),和已删除文档数 185.4 万条基本相等,而有效文档只有 1,388 条。

软删除攒得越多,每次 refresh 越贵:锁索引平均每次约 65 毫秒,几乎没有软删除堆积的 .opendistro-ism-config 每次只要 18~24 毫秒。hot threads 的 3 次采样里,两个 system_write 线程各占 40%~43% 的 CPU,堆栈落在三处:Lucene 的 SoftDeletesRetentionMergePolicy.numDeletesToMerge 逐条扫描软删除文档,refresh 时打开新的 reader,以及等待另一个线程的 refresh 完成(见 hot threads API 和 SoftDeletesRetentionMergePolicy)。这个成本随软删除累积而上升、flush 之后回落,形成约 2.5 天一个周期的锯齿;采样窗口的 1.2 核高于平均的 0.88 核,可能是因为采样时正处在周期后段。

CPU 上能对上账。按节点启动以来各线程的累计 CPU(共 166.6 CPU·小时):system_write 占 64.8%,折合约 0.57 核;已经退出的线程约占 11.6%,主要是 merge,约 0.10 核。两项合计 0.67 核,是 OpenSearch 平均 0.88 核的 76%,约占 4 vCPU 整机的 17%;写业务日志的 write 线程只占 2.3%,约 0.02 核。system_write 线程池只有 2 个线程,采样时两个都在忙,队列积压 63 个任务(复测时 90),启动以来累计拒绝 1,066 次,部分 ISM 任务失败后要重试。这些 CPU 大多花在了维护「要不要删除索引」这件事本身。

方案:调大检查间隔,给锁索引 flush#

两条设置都能在线生效,不改数据,也不用重启:

PUT _cluster/settings
{"persistent": {"plugins.index_state_management.job_interval": 60}}
POST .opendistro-job-scheduler-lock,.opendistro-ism-config/_flush

PUT .opendistro-job-scheduler-lock,.opendistro-ism-config/_settings
{"index.translog.flush_threshold_size": "32mb"}

第一条把检查间隔从 5 分钟拉到 60 分钟,ISM 任务数降到十二分之一,每个任务在下次运行时换成新间隔。job_interval 是整数设置,单位是分钟,直接写 60,写成字符串 "60m" 会被拒绝;ISM 默认还会给每次运行加上最多 60% 间隔的随机抖动(jitter 0.6),所以两次检查最长相隔 96 分钟,14 天删除最多晚约 1.6 小时(见 ISM settings、ManagedIndexSettings.kt 和 JobScheduler.java)。

第二条手动 flush 一次锁索引,生成新的 commit,之后的 merge 才能把攒着的软删除版本清掉;再把 flush 阈值从默认的 512 MB 调小到 32 MB。按每小时约 9 MB 的增速,32 MB 大约三个半小时 flush 一次,已删除文档预计维持在 14 万条以内。执行后可以看锁索引的 docs.deleted 是否在几分钟内从 185 万降下来;如果没降,先确认 flush 产生了新的 commit、merge 在运行。

这两条的收益是按实测推算的估算,方案还没有执行:ISM 任务数降为十二分之一,每次 refresh 的成本从约 65 毫秒回落到约 20 毫秒,system_write 和 merge 合计从 0.67 核降到约 0.04~0.09 核,OpenSearch 整体预计从平均 0.88 核降到 0.2~0.25 核。

小结#

这台开发环境的 OpenSearch 单节点,CPU 的大头在 ISM 每 5 分钟对 1,388 个索引做的任务锁检查:锁索引每秒约 10 次写入,每次写完立即 refresh,两天多才 flush 一次,攒下 185 万条软删除文档,refresh 越来越贵;任务锁写入和它触发的 merge 合计占了 0.88 核平均值的 76%。把检查间隔调大、锁索引 flush 一次并调小阈值,不用重启也不改数据,OpenSearch 整体预计能降到 0.2~0.25 核。系统索引的已删除文档数远多于文档数时,可以先查它多久 flush 一次。