ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch数据清理实战:Shell脚本与索引生命周期管理

Elasticsearch数据清理实战:Shell脚本与索引生命周期管理

如果你在搜索引擎里敲下“shell删除es数据”,八成是遇到两种情况:要么ES集群磁盘告警要腾空间,要么测试环境里灌了一堆脏数据想快速清理。别看这个需求听起来简单,实际操作中翻车的人不少。有人用一条DELETE /*把整个集群的索引全删了,有人辛辛苦苦跑了半天_delete_by_query却发现磁盘空间一点没少,还有人直接rm掉了ES的数据目录导致节点起不来。

这篇文章不打算复读官方文档,而是把我实际处理ES数据清理时的完整思路、能直接抄的shell脚本,以及踩过的坑一次性讲清楚。适合运维、后端开发和所有需要跟ES打交道的人。

1. 先分清你要删的是“索引”还是“文档”:两种清理思路差别很大

很多人第一次接触ES清理时,脑子里想的还是MySQL那种DELETE FROM table WHERE ...的逻辑。但ES的存储架构决定了,删文档和删索引是两条完全不同的路线,性能和底层行为也天差地别。

1.1 为什么说删除文档不等于释放空间

ES底层是Lucene,数据是按“段(segment)”组织的。段文件一旦写入就是只读的,后续的更新删除操作不会直接改动段文件,而是靠标记删除(.del文件)来记录。这么说吧,删除一条文档,其实只是给这条数据打了一个墓碑标记,它占的空间还在那里。

真正的空间释放要等段合并(merge)发生,把有删除标记的段文件重写一遍,去掉那些死数据,才可能回收空间。但merge本身需要额外资源,ES不会因为你删了几条小数据就去触发全局merge。这就是为什么你跑了delete_by_query,返回的一堆任务都显示成功,但磁盘剩余空间几乎没变化的根本原因。

反观删除索引,它是直接把整个索引的全部分片文件都unreference掉,数据段文件从磁盘上彻底摘除。在大多数情况下,ES删除索引后空间会立即释放或者很快进入可复用状态。我对这个问题的态度很简单:如果只是要清理过期数据,删索引永远优先于删文档,除非业务对“索引还得保留”这件事有硬性要求,必须按条件剔除部分数据。

1.2 什么时候该删整索引,什么时候该按条件删

我平时判断的标准大概是这样:

清理场景推荐手段原因
日志型索引过期(logstash-2024.01.01)删除整索引索引维度天然按时间段切分,删起来干净利落,空间释放彻底
同一索引内混有多个业务的数据,只需清掉某一小块按查询删除(_delete_by_query)无法按索引隔离,只能按条件删,但要接受“空间不是立即释放”
测试环境灌了一堆随机数据删除整索引并重建比delete_by_query快得多,还能顺便重置mapping
需要保留索引结构但删除其中大量历史数据新建索引 + reindex,然后切换别名删索引再重建比在原索引上delete_by_query更可控

看了这个表格你应该能理解:删整个索引是ES亲儿子操作,delete_by_query更多是无奈之举。千万不要图省事在日志型索引里执行按查询删除,那相当于“用大炮打蚊子”,既慢又费资源。

2. 动手前的三道“安全锁”:备份、权限、影响面确认

我见过最离谱的一次事故,就是某运维同学连备份都没做,直接按通配符删了索引,结果发现通配符写窄了一位日期,删的数据比预期多了一个月。所以不管你有多急,下面三步别跳过。

2.1 先看一眼集群健康,再把快照备好

动手前先确认集群状态是不是健康的。

curl -s "http://localhost:9200/_cluster/health?pretty" | jq '.status'

返回green才能放心干。如果返回yellow,意味着一定有副本分片没分配,这时候删除索引会让问题更复杂。如果返回red,那更要先解决深层次问题,别急着清理数据。

接着做快照。已经注册过快照仓库的话直接执行:

curl -X PUT "http://localhost:9200/_snapshot/backup_repo/snapshot_before_cleanup_$(date +%Y%m%d%H%M)" \ -H 'Content-Type: application/json' \ -d '{"indices":"app-log-2024.01.*"}'

没注册过仓库的话,先注册一个本地文件系统仓库:

curl -X PUT "http://localhost:9200/_snapshot/backup_repo" \ -H 'Content-Type: application/json' \ -d '{"type":"fs","settings":{"location":"/data/es_snapshots"}}'

快照耗时跟数据量成正比。几十GB的索引一般也就几分钟到十几分钟,值得等。哪怕是测试环境,我也建议快照做一遍再开删,至少给自己留条后路。

2.2 权限和索引匹配半径的确认

如果ES集群开了安全认证,用curl请求时要带用户信息:

curl -s -u elastic:yourpassword "http://localhost:9200/_cat/indices?v"

删索引需要delete_index权限,执行_delete_by_query需要对应的索引写权限。实际工作中遇到最多的就是权限不足导致返回403,排查起来又慢又烦。所以先随手验证一下你当前账号能不能删:

curl -s -u elastic:yourpassword -X DELETE "http://localhost:9200/app-log-2024.01.15"

如果是403,别挣扎了,去找管理员给权限。注意删除动作不可逆,建议新建一个只读账号先做查询验证,确认清楚再换有权限的账号执行删除。

这里还有一个容易忽略的点。ES有很多点号开头的系统索引,比如.kibana_*、.security-*、.monitoring-es-*。这些索引被隐藏保护,但通配符包含.开头时也可能被误匹配到。你手动删除时觉得自己写的是业务前缀,万一前缀写错成了*,系统索引很难闪开。

2.3 通配符杀伤半径:宁可多打几次列出命令

ES的索引通配符设计得很强大,*、?都可以用。但也正因为强大,它成了误删事故的头号来源。我养成一个习惯:执行删除前,先用通配符把所有会匹配到的索引列出来看一遍。

curl -s "http://localhost:9200/_cat/indices/app-log-2024.01.*?h=index,store.size,docs.count&format=json"

只看名字不过瘾,还可以加jq过滤,把点开头的系统索引排除掉:

curl -s "http://localhost:9200/_cat/indices?format=json" \ | jq -r '.[] | select(.index | startswith(".") | not) | [.index, ."store.size"] | @tsv'

这一步花不了半分钟,但能避免所有关于“到底匹配到了什么”的焦虑。确认无误后,再走删除动作。那些敢直接执行DELETE /*的同学,一旦在生产集群上跑出来,那种惊悚感会让你终身难忘。

3. 按索引删除:一个能上生产环境的清理脚本

清了思路,也加了安全锁,接下来进入正题:写一个真正能用的shell清理脚本。

3.1 先手动删:单个索引与通配符

单个索引直接删,命令简单到不像话:

curl -s -u elastic:yourpassword -X DELETE "http://localhost:9200/app-log-2024.01.15"

批量用通配符的情况,比如要删掉2024年1月所有日志索引:

curl -s -u elastic:yourpassword -X DELETE "http://localhost:9200/app-log-2024.01.*"

这条命令会删除所有匹配的索引。ES的通配符默认会匹配所有分片状态为open的索引,正常业务下足够了。不过我严重不建议你在生产环境直接这么干,除非你已经百分之百确认过匹配范围。更稳妥的是用下面的脚本,先算清楚再删。

3.2 按保留天数批量清理:脚本逻辑详解

生产环境最常见的需求就一句话:app-log-开头的索引,只保留最近7天,之前的一律删除。

下面这个脚本我一直在用,逻辑不算复杂,但把名字解析、系统索引过滤、dry-run都考虑进去了。

#!/usr/bin/env bash set -euo pipefail ES_HOST="${ES_HOST:-http://localhost:9200}" ES_USER="${ES_USER:-elastic}" ES_PASS="${ES_PASS:-yourpassword}" INDEX_PREFIX="${INDEX_PREFIX:-app-log-}" RETAIN_DAYS="${RETAIN_DAYS:-7}" DRY_RUN="${DRY_RUN:-1}" # 默认打印不删除,确认没问题再置0 # 取当前时间戳 cutoff_timestamp=$(date -d "-${RETAIN_DAYS} days" +%s) # 用creation.date来判断比解析索引名更稳 curl -s -u "$ES_USER:$ES_PASS" \ "$ES_HOST/${INDEX_PREFIX}*/_settings?flat_settings=true&expand_wildcards=open" \ | jq -r ' to_entries[] | select(.key | startswith(".") | not) | [.key, .value["index.creation_date"]] | @tsv ' \ | while IFS=$'\t' read -r idx created_date_ms; do created_ts=$((created_date_ms / 1000)) if (( created_ts < cutoff_timestamp )); then if [[ "$DRY_RUN" == "1" ]]; then echo "[DRY RUN] would delete index: $idx" else echo "[DELETE] deleting index: $idx" curl -s -u "$ES_USER:$ES_PASS" -X DELETE "$ES_HOST/$idx" echo "" fi else echo "[KEEP] index: $idx" fi done echo "Cleanup job finished."

脚本里几个值得琢磨的点:

为什么用index.creation_date而不是解析索引名里的日期?因为索引命名不一定规整,有的人用点分隔,有的人用横杠分隔,还有人干脆不带日期。creation_date是ES创建索引时打的时间戳,不会骗人,判断是否超期更可靠。

为什么开头就置DRY_RUN=1?先跑一遍,看它打印哪些[DRY RUN] would delete index,人工核对无误,再设置DRY_RUN=0执行真实删除。这个习惯帮我挡掉了很多次“本来只想删30天前结果把昨天的也算进了删除范围”的乌龙。

如果你确定索引名里就是标准日期格式,也可以直接用索引名解析,速度更快。下面这个函数兼容app-log-2024.01.15和app-log-2024-01-15两种分隔方式:

parse_ts_from_name() { local name="$1" local date_part="${name#$INDEX_PREFIX}" date_part="${date_part//./-}" date -d "$date_part" +%s 2>/dev/null || return 1 }

把它替换掉脚本中的creation_date判断也可以。不过我还是更推荐前者,少绕弯子。

3.3 实测:删除后磁盘空间多久释放

我在一套模拟的日志集群上做过测试,索引总量80GB左右,一共120个分片,分布在三台数据节点上。执行完删除命令后,我从_cat/allocation观察磁盘变化:

curl -s "http://localhost:9200/_cat/allocation?v"

结果是这样的:删除命令返回后,每个节点上对应的分片文件开始清理。大约几十秒到一两分钟,磁盘剩余空间明显回升。这说明删除整索引的空间释放基本是即时的,不需要额外触发merge。但有一点要注意:如果集群有自动rebalance(默认开启),某些节点的分片会重新平衡,空间占用会有波动。比如你删了A节点上的大索引,B节点可能马上迁过来几个分片,导致看起来空间并没有完全腾出来,这是正常现象。

4. 按查询条件删数据:_delete_by_query的完整用法与调优

总有一些数据没办法按索引整体删除,比如用户误产生了大量错误文档,或者测试数据混进了核心索引但你又不想伤及整个索引。这时就要用_delete_by_query。

4.1 同步删与异步删:小任务和大任务要分开处理

最基本的请求是这样:

curl -s -u elastic:yourpassword -X POST \ "http://localhost:9200/app-log-2024.01/_delete_by_query" \ -H 'Content-Type: application/json' \ -d '{ "query": { "range": { "@timestamp": { "lt": "2024-01-10T00:00:00" } } } }'

小数据量(几百MB内)这么干没问题,请求会同步等待完成,返回后你能直接看到删除了多少条。但如果是几十GB甚至更大的数据量,同步等待会卡住HTTP连接,经常超时。这时候要改成异步模式:

curl -s -u elastic:yourpassword -X POST \ "http://localhost:9200/app-log-2024.01*/_delete_by_query?wait_for_completion=false&scroll_size=5000&slices=4" \ -H 'Content-Type: application/json' \ -d '{ "query": { "range": { "@timestamp": { "lt": "now-7d" } } } }' \ | jq -r '.task'

加了wait_for_completion=false之后,请求会立刻返回一个task id,真正的删除动作在后台执行。返回的task id长这样:

{ "task": "oGuC7ABgQqO4Zb9CQ6DvVQ:123456" }

拿这个task id,你就可以随时查看任务的执行进度。

4.2 观察任务进度不是用直觉,而是查_tasks

后台任务跑起来之后,总有同事问“删完了吗”“怎么还在删”。别看感觉,直接查:

curl -s -u elastic:yourpassword "http://localhost:9200/_tasks/oGuC7ABgQqO4Zb9CQ6DvVQ:123456?pretty" | jq '.task.status'

status里会有这些关键字段:

  • total:本次删除需要处理的总文档数
  • updated:更新过的文档数
  • deleted:已经删除的文档数
  • batches:完成了多少批
  • version_conflicts:版本冲突数
  • noops:空操作数

按照deleted/total就能算出进度条。还有更高阶的:

curl -s -u elastic:yourpassword "http://localhost:9200/_tasks?actions=*delete*&detailed" | jq '.nodes'

这条命令能列出集群内所有正在执行的删除任务,适合多任务并发时统一排查。

4.3 Scroll、slices、refresh这几个参数到底怎么设

_delete_by_query底层靠scroll游标分批扫数据再删。参数没设好,轻则慢,重则把集群拖垮。我调过不少集群,常用经验如下:

参数建议值理由
scroll_size5000~10000默认1000偏保守,数据量大时批次太小,网络交互次数太多
slices2~4或auto并发切片能明显加速,但不建议超过索引分片总数,开太多反而调度开销大
refreshfalsedelete_by_query默认就是false,不用额外设置,但你要知道它不会像普通删单条那样触发refresh
conflictsproceed(慎用)有并发写入时大概率出现版本冲突,proceed会跳过冲突继续删,但可能导致部分文档漏删
requests_per_second-1(不限速)生产集群谨慎不限速,建议设置合理的值,例如500~1000,给业务留点IO

当数据量真的很大,比如索引本身已经几十上百GB,我不会直接在原索引上跑_delete_by_query。正确做法是:新建一个干净索引,用reindex把需要保留的数据迁过去,然后切换别名,最后删除旧索引。这样做的效率远高于在原索引上delete,还能顺便压缩段文件。

我的个人判断阈值是这样的:删除量小于索引总量的20%,可以考虑_delete_by_query;超过20%,就值得走“reindex + 替换别名”的路线。别嫌麻烦,删几十GB的同段数据所消耗的IO和CPU,比你想象的大得多。

5. 定时自动清理:cron方案和ILM方案怎么选

一次性清理做完,还得考虑以后怎么防,不然磁盘再过一个月又要报警。自动清理有两条路:一条是shell + cron,简单直接;另一条是ES自带的ILM(Index Lifecycle Management),更加优雅。两条路我都部署过,说下差异。

5.1 cron + shell:适合“想尽快看结果”的清理

把上一节的脚本保存到/opt/scripts/es_cleanup.sh,加个cron任务:

0 3 * * * /opt/scripts/es_cleanup.sh >> /var/log/es_cleanup.log 2>&1

每天凌晨3点跑一次,把过期索引删掉,日志记录到/var/log/。选凌晨是因为业务低谷,IO压力小。

cron方案的好处是直观,出了什么问题看日志就行。坏处是它不知道ES的分片状态,也不了解数据生命周期。而且脚本里如果有bug,第二天可能就把不该删的也删了。所以这个方案适合数据量可控、索引命名规律、不复杂的环境。

5.2 ILM:适合长期稳定写入的日志型索引

ILM是ES官方的生命周期管理功能。定义一个30天后删除的策略:

curl -X PUT "http://localhost:9200/_ilm/policy/log_30d_policy" \ -H 'Content-Type: application/json' \ -d '{ "policy": { "phases": { "hot": { "min_age": "0d", "actions": {} }, "delete": { "min_age": "30d", "actions": { "delete": {} } } } } }'

然后把这个策略挂到索引模板上,后续新索引自动套用:

curl -X PUT "http://localhost:9200/_index_template/app-log-template" \ -H 'Content-Type: application/json' \ -d '{ "index_patterns": ["app-log-*"], "template": { "settings": { "index.lifecycle.name": "log_30d_policy" } } }'

ILM的优势很明显:不需要额外脚本,ES自己会在分片层面执行滚动和删除。缺点是策略本身有一定学习成本,而且遇到已经存在的历史索引,需要手动POST /app-log-2024.01.01/_ilm/explain确认它有没有被正确应用。

我的建议是:持续写入的日志型数据,上ILM才是正路;一次性清理历史脏数据,用shell脚本更顺手。两者并不矛盾,可以同时存在。

5.3 兜底:写一个删除前的“最后确认”

无论哪种自动方式,我都强烈建议加上一道“最后确认”的兜底逻辑。在脚本里加一个环境变量ALLOW_DELETE:

if [[ "${ALLOW_DELETE:-0}" != "1" ]]; then echo "Deletion is not allowed. Set ALLOW_DELETE=1 to execute." exit 1 fi

cron任务默认不传这个变量,所以即使脚本被误触发,也只会打印日志而不真正删除。只有人工手动确认后,才会带着ALLOW_DELETE=1执行。这个设计可能看起来多此一举,但在生产集群上救过我的命。

6. 踩过的坑别踩第二遍

6.1 误删全集群索引的一场“演练”

有次某同事给测试环境写清理脚本,想删掉前缀为test-的索引,结果命令写成这样:

curl -X DELETE "http://localhost:9200/test*"

这个写在命令里没问题,问题是当时测试环境里其他团队也创建了testing-、test-data-等前缀的索引,甚至还创建了.test-infra这种系统索引。通配符一下把所有匹配到的全删了。恢复过程非常痛苦,幸好有快照,但复盘时有个问题其实很容易提前规避:在删除命令里同时排除非业务前缀。

我个人后来的约定是:生产环境脚本里永远不用裸通配符删索引,只用脚本内的INDEX_PREFIX拼接匹配范围,并且跑之前先打印所有要删的索引名。

6.2 rm -rf 数据目录的严重性

这个坑我必须重点提。某同学觉得ES索引删得慢,直接登录数据节点,把data/nodes/下的索引目录rm -rf了。结果节点进程还活着,Lucene文件却被物理删除,索引状态直接变得一团糟,部分分片怎么都恢复不了,最后还是靠快照才把数据追回来。

ES的数据目录坚决不能直接动。它跟普通文件完全不一样,Lucene在节点上维护着大量的文件句柄和内存映射,rm删除之后,进程虽然不会立刻崩,但后续写入操作会在底层数据不完整的情况下继续运行,最终大概率导致集群red状态。如果你真的遇到“删不掉、磁盘要爆”的情况,别走极端。优先考虑关掉rebalance、先把数据迁到其他节点,再删除索引。如果集群已经无法响应,重启节点后再说。

6.3 删除后副本迁移引发雪崩

很多人不会想到这个:删除一个大索引后,集群的自动rebalance会被触发。假设一个节点上原有一大片分片,现在空出来了,ES会把其他节点的分片迁移过来补充平衡。这本身是好事,但如果一次性删了多个大索引,rebalance会在短时间内大量迁移分片,IOPS飙升,反而拖慢了业务查询。

我的处理办法是:删除大索引之前,在集群范围暂时关闭rebalance:

curl -X PUT "http://localhost:9200/_cluster/settings" \ -H 'Content-Type: application/json' \ -d '{ "transient": { "cluster.routing.rebalance.enable": "none" } }'

等删除完成、分片状态稳定下来,再恢复:

curl -X PUT "http://localhost:9200/_cluster/settings" \ -H 'Content-Type: application/json' \ -d '{ "transient": { "cluster.routing.rebalance.enable": "all" } }'

实测这个方法能显著减少删除大索引后的集群波动。

7. 一点操作心得

写到这里,把我在实际中摸索出来的几条心得分享一下。第一条还是那句老话:能用删索引解决的事,千万不要用删文档解决。ES的段合并机制决定了文档删除的空间释放是滞后的,删索引才是干净利落的做法。第二条是把所有清理脚本都做成“默认dry-run、显式确认才执行”的模式。初期可能觉得多此一举,但当你凌晨三点被报警电话吵醒,半睡半醒之间还能靠这层机制避免事故的时候,你就知道它有多重要。

最后一个小技巧:无论用哪种方式清理,记得在清理结束后顺手看一眼_cluster/health,确认集群状态恢复为green。如果返回的是黄色,大概率有副本分片还没分配,这时候别急着关监控,等几分钟再查一次。数据清理看似只是几条curl命令的事,但背后涉及索引生命周期、段合并、分片分配这些机制。摸熟了这些机制,你再遇到ES磁盘告警就不会慌,按本文的流程走,有备份、有确认、有脚本,基本不会出大问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进