ES百万级数据全量处理与实时评估优化
400 万条 ES 数据全量拉取与实时评分:我们是怎么把 2 小时压到 5 分钟的
起因
线上有个 Elasticsearch 索引 sdb_indexv4,存了大概 400 多万条记录。业务需求不复杂——每条数据有个指标值 x_age,需要算出它在全量数据中的百分位排名,公式很直白:
1 | 1score = 100 * (比 x_age 小的数据条数) / 总条数 |
听起来就是个数数的事。但数据量一上来,问题就一个接一个。
最初的做法,和它是怎么崩的
第一版代码写得很”理所当然”:
1 | 1# 用 scroll 拉全量数据,每次取 1000 条 |
跑了一下,三个问题同时炸了:
- 内存直接打爆。400 万条
_source全塞进一个 list,单机内存吃紧,跑到后半段 OOM 了。 - Scroll 上下文超时。中间网络抖了一下,
scroll_id失效,400 万条从头再来。 - 太慢。scroll 本身要维护快照上下文,越往后越慢,跑完一次全量将近两小时。
这不是优化能解决的问题,是方案本身就不对。
重新想这件事
坐下来理了一下,这个任务本质上就两件事:
- 离线阶段:把 400 万条数据过一遍,构建每个指标的全局有序数组。
- 在线阶段:来一条数据,在有序数组里二分查找,算出百分位。
在线阶段其实没什么好优化的——bisect_right 在 400 万长度的数组里查一次也就二十几次比较,微秒级的事。瓶颈全在离线阶段:怎么又快又稳地把 400 万条数据从 ES 里搬出来,构建好有序数组。
所以问题收敛成:怎么可靠地、低内存地、可中断恢复地从 ES 拉取 400 万条数据。
第一件事:扔掉 Scroll,换成 search_after
Scroll API 的设计初衷是做短时间内的批量导出,它会在服务端维护一个快照上下文。这个上下文是有状态的——占内存、有超时时间、阻止段合并。400 万条数据要拉很久,scroll 上下文扛不住。
search_after 是无状态的。原理很简单:每次查询带上一页最后一条数据的排序值,ES 根据这个值定位下一页的起点。服务端不保留任何上下文。
1 | 1// 第一页 |
切到 search_after 之后,最大的好处不是速度,是心安。不用惦记 scroll 上下文过没过期,不用在低峰期赶时间跑完。它就是一个普通的查询,失败了重试当前页就行。
第二件事:别攒数据,边拉边处理
之前的代码把所有 _source 攒到 list 里再处理,400 万条 × 每条大概 1~2KB,内存直接吃掉好几个 G。
改法很朴素——用生成器,数据来了就处理,处理完就丢掉:
1 | 1def fetch_all(): |
注意 _source 里只取了评估需要的五个字段,把那些大段文本、嵌套对象全丢了。光这一步,网络传输体积就少了七成以上。
消费端也是流式的:
1 | 1import gzip |
内存里始终只有两样东西:当前这一批 2000 条文档,和一个不断增长的 float 数组。跑完全程内存峰值不到 200MB。
第三件事:加一层 Redis,让进程可以”死而复生”
流式拉取解决了内存问题,但还有一个隐患:进程要是挂了怎么办?400 万条跑了 380 万,突然被 kill 了,从头来?
加一层 Redis 记录进度:
1 | 1PROGRESS_KEY = "export:sdb_indexv4:progress" |
重启之后,进程读 Redis 拿到上次的游标,接着跑。不会重复,不会遗漏。
这个设计还有个额外好处——多个 Worker 可以共享同一份进度和缓存表。比如按日期范围拆成子任务并行跑,最后合并有序数组,速度可以再快一倍。
第四件事:构建有序数组,在线评估降维到 O(log N)
离线阶段跑完之后,我们手里有每个指标的有序数组,比如 sorted_x_age,400 万个 float,内存里占几十 MB。把它加载到 Redis 或者直接用共享内存:
1 | 1# 在线评估 |
来一条数据,x_age = 37,调一次 calc_score,二十几次内存比较,微秒级返回结果。
从 O(N) 的全库扫描,降到了 O(log N) 的二分查找。400 万条数据,log₂(4000000) ≈ 22。22 次比较,完事。
最终方案一览
把上面四件事串起来,整个流程是这样的:
1 | 1┌─────────────────────────────────────────────────────────┐ |
改造前后对比
| 改造前 | 改造后 | |
|---|---|---|
| 全量拉取耗时 | ~2 小时(经常中断) | ~5 分钟 |
| 内存峰值 | 4GB+,经常 OOM | < 200MB |
| 中断恢复 | 从头开始 | Redis 断点续传 |
| 单次评分耗时 | 秒级(扫全库) | 微秒级(二分查找) |
| ES 压力 | scroll 锁资源 | 普通查询,无状态 |
一些踩过的坑
1. search_after 的排序字段必须有唯一性。 如果你用 x_age 排序,大量相同值会导致分页结果重复或遗漏。用 _id 做排序字段最稳,它是全局唯一的。如果需要按 x_age 排序,可以加 _id 作为第二排序字段:"sort": [{"x_age": "asc"}, {"_id": "asc"}]。
2. Redis 存游标时注意序列化。 search_after 返回的 sort 值是个数组,可能是 [1234, "doc_id"] 这种混合类型。存 Redis 前 JSON 序列化,取出来反序列化,别直接 str() 转换。
3. 压缩写入时注意 flush。 gzip.open 默认有缓冲区,如果进程意外退出,缓冲区里的数据会丢。可以定期 f.flush() 和 os.fsync(),或者接受最后几百条数据的丢失(反正有断点续传)。
4. 有序数组的刷新策略。 我们是按日刷新,凌晨跑一次全量构建。如果数据更新很频繁,可以考虑增量方案——新数据插入有序数组(用 insort),但要注意 400 万条的数组 insort 是 O(N) 的,频繁插入不合适。折中方案是每小时全量重建一次,或者用两次数组(历史有序 + 新增无序),查询时两边各查一次再合并。
写在最后
回过头看,这个方案没有什么特别高深的技术。search_after、bisect_right、Redis 存状态,都是现成的东西。关键在于把事情拆开想:
- 离线能做的事,别放到在线做。
- 能用无状态解决的,别用有状态。
- 内存里只放必要的东西。
- 进程随时可能死,设计时假设它下一秒就会死。
400 万条数据不算大,但如果方案不对,它也够你喝一壶的。希望这篇文章能帮到遇到类似问题的朋友。
