Jason Pan

从ES性能优化到防御性编程

潘忠显 / 2026-03-31


凌晨收到「线上一个推荐服务的,服务耗时明显增加」的告警。

warning

简单排查了一下,发现是存在 ES 慢查询的问题。

本小文简单聊聊针对性的优化,实际工程中该如何做防御性的开发。

日志信息

下边是慢查询日志,这是一个分片上的查询,耗时 223 ms,命中了超过 100 万的文档

slow-query-log

服务会同时发起多种查询语句,而慢查询则只集中出现在这一种查询中:

凭经验我们基本上可以判断,是因为 hits 太多的,然后需要对所有的 doc 都计算 randon_score 造成了极大的性能负担。接下来就探究该语句的工作原理。

Query语句分析

这个 query 的功能一句话概括就是「随机找 32 个高质量玩家」。

具体地,最外层有要求返回的个数、返回的字段,主要还是 query 中的内容。query 中只有一个 function_score 意思是自定义打分逻辑,而不是用默认的 BM25 算法

function_score 又有 3 部分:

(原始语句中,有很多 "boost": 1.0,用于 Elasticsearch 的打分公式中作为 “权重系数”,1.0 是默认值。为了清晰展示,我这里就把它剔出掉。)

slow-query-in-dev-tool

is_high_quality="1" 的文档只有几千个时,ES 瞬间就能计算完。但当现在的业务中这个条件命中了 100 万 文档时:

  1. 内存占用:ES 需要把这 100 万个文档的 ID 拿出来。
  2. 哈希计算:对这 100 万个文档逐一读取 _seq_no 并做哈希运算(即 random_score)。
  3. 排序开销:对 100 万个随机分数进行大排序,选出最高的前 32 个。

通过上边的查询,我们可以看到总的文档数有 600 万左右,6个分片中每个分片处理 100 万个,跟我们前面看到的日志是一致的。

自定义分数的计算

我们查询条件只有一个打分函数:

"random_score": {
  "seed": -1092334812,
  "field": "_seq_no"
}

Elasticsearch 内部生成的随机分数(_score)大致遵循这样一个伪代码公式: $$ score = hash(seed + field_value) \times normalization_factor $$

根据上边的公式,我们可以得到一个推论:在所有文档没有变化的情况下,如果 seed 不变,每个 doc 对应的分数是固定的,查询出来的结果也是固定的!

使用固定 Seed 的场景,可以用来多次请求中的分页;而使用不同的 Seed,可以得到不同的随机序列,实现换一批的功能。

如果用户 openid + date 的 hash 值作为种子,那么这个人当天查询到的结果就是固定的一个序列,而每个人的结果却是不同的序列。

而我们的实现中,这里直接没有设置 seed,库中会给一个随机数作为 seed。

	var q elasticv7.Query = elasticv7.NewBoolQuery().Filter(filters...)
	if randomSort {
		q = elasticv7.NewFunctionScoreQuery().
			Query(q).
			AddScoreFunc(elasticv7.NewRandomFunction().Field("_seq_no")).
			BoostMode("replace")
	}

200ms 处理 100 万文档

尽管我们觉着 200ms 查询时间很长,但是考虑到每次请求需要处理 100 万级别的文档,是不是还会惊叹其效率之高?

在传统的业务代码(如 Go 或 Java 循环)中,处理 100 万个对象可能需要更久,但 Elasticsearch 做了极致的工程优化:

多策略组合实现随机性

我们已经理清了前面为了实现「随机取32个高质量玩家」,需要每次处理 100 万文档造成了性能浪费。那该如何优化实现这个目标呢?

随机还是要随机,但是要缩小打分的范围

terminate_after-5000

优化之后效果显著: CPU 使用率明显下降,慢查询降至0,耗时 P95 从 200ms~300ms 降至 10ms 以下。

cpu-cost-4-pics.png

为什么不替换 _seq_no

跟 Gemini 聊,它会建议把 _seq_no 改掉,使用 openid 等文档内部 keyword 作为 field。其出于以下考虑:

但是目前能使用的对应 doc 不变的,没有合适的 Long,有类似于 app_uuid 的字段。更换这个使用这个作为 field,时间大概接近原来的 4倍。这是因为 **_seq_no ** 是个 Long 类型的字段,占 8 字节,而 app_uuid 字段长度大概 36 字节,计算 hash 的时间也是增加约 4 倍

use-non-seq-no-field

工程上的反思

为什么上线之前没有压测出这个现象?

上线之前这个「高素质玩家」判断条件比较严苛,符合条件的 doc 比较少,因此每个请求的耗时会比较小。

而上线之后「高素质玩家」数量增加,当前查询条件的复杂度是 O(N) ,所以服务耗时也会线性相关的增加

但是上线之前仍然有方法可以避免后续这样的问题出现——“防御式架构设计”

很多工程师(包括资深开发者)在写代码时,习惯于实现功能:

我要随机选 32 个高质量用户,所以我写了 random_score

架构师的思维是:

我要随机选 32 个高质量用户。如果库里有 1 亿人,我的算法复杂度是多少?是 O(1)、O(log N) 还是 O(N)?