继上文在完成了第一阶段 ES 搜索引擎的搭建后,已经能够实现对千万级别的商品索引的读写请求的支持。目前,单机房读流量在 500~1000 QPS 之间,写流量在 500 QPS 左右。
但随着业务的发展,问题也逐渐开始暴露,起源是在某次活动下线的时候,ES 集群某个机房 CPU 迅速被打满,读延迟上升,而其他机房却是正常的,之后仍然出现了多次 CPU 暴涨,多个机房的其中一个机房被打满或者同时打满的情形,然而读写流量波动却不大或者根本不及日常峰值。我们意识到此时出现的就是 ES 集群的性能问题,在第一阶段当系统依赖组件不可用时,为此系统拥有一定的容灾能力,暂时没有考虑业务使用姿势带来的风险,而这种风险是更可怕的,源于它随机,毫无规律,不可控制。
在此情况下,也许大家会考虑通过扩容来解决问题,但当前情况已经是在我们扩容后发生的问题了,所以很明显此时扩容已经解决不了问题了。程序员经常说的几句表达风险等级的话:
- 阶段一:不知道自己不知道(Unconscious incompetence)
- 阶段二:知道自己不知道(Conscious incompetence)
- 阶段三:知道自己知道(Conscious competence)
- 阶段四:不知道自己知道(Unconscious competence)
用在此时就是阶段一和阶段二,不知道自己不知道,所以也就无法在业务风险控制这里发力,当问题出现后,知道了自己不知道,但不知道发生了什么,不知道为什么会发生,不知道还会发生什么。看似充满随机性,但实际上却是一个很严肃的问题,招商平台对大促活动非常重要,由此也引发了我们新的思考,我们做 ES 稳定性的全局视角是什么,该怎么定义和归类?这些思考也为后续的治理提供了更好的解决思路和发现问题的角度。
治理思路
ES集群读写链路图
在治理思路中我们仍然从系统读写两个入口入手,分别细化读和写链路应该考虑的问题和风险以及需要达到的业务目标,下文将从具体的实施步骤进行介绍。
治理的目标是什么
治理目标有两个,分别是系统的可用性、稳定性和数据质量,系统可用性指的是稳定提供读写能力,数据质量即保证 ES 的数据和源数据完全一致,并且延迟符合业务预期,达到不仅有数据而且是有质量的数据标准。
如何量化目标
在量化目标中,系统可用性沿用了 ES 集群 SLA 进行衡量可用性。数据质量可以理解为数据最终一致性和数据延迟,目前我们核心的数据包含准实时数据流,报名记录 DB->ES,商品比价通过文档数据库->ES,并需要定时更新指标。DB->ES 设定的目标是 30s 内的一致率在 99.9% 以上,通过准实时对账进行监控报警监测。
随着商品控价越来越重要,比价的数据筛选和查询也尤为重要,文档数据库->ES 设定的是不存在超时小时级别的同步延迟,且将定时更新指标定为 T+1。
如何达成目标
原则:自上而下,逐层拆解,彼此独立,互为补充。
优化措施
此时回顾一下,上节我们提到的 ES CPU 暴涨问题最后是如何解决的?实际上,我们并没有走捷径,而是将 ES 读链路全部梳理了一遍,分析每次 CPU 暴涨的流量差异点。之前的分析仅仅是从 ES 集群监控上分析不同索引的流量趋势,由于差异点太小,无法进行有效分析。因此,我们仍然需要先完善监控报警机制,将 ES 上层的云引擎服务的接口流量监控全部聚合在一个监控看板上,并加入了 API /中间 RPC 层--> 数据中心 RPC 服务--> ES,从而找到了问题的突破口。我们发现,CPU 上涨的点 Scroll 流量偏高,因为 Scroll 流量比 Search 流量更耗 CPU,因此 Scroll 流量会被打满。在明确原因之后,我们也就开启了 ES 性能的优化之路。
为什么 ES Scroll 流量比 Search 流量更耗 CPU?
- Search 查询有数据缓存而 Scroll 没有:在Search API 中,ES 会执行查询并返回匹配的结果集。这些结果通常是直接从索引中检索的,并且在查询时可能会使用缓存来提高性能。一旦查询完成,ES 会将结果缓存在内存中,以便稍后进行排序、分页等操作。这样,在后续的请求中,如果只需要访问缓存中的数据,可以避免重新计算和访问磁盘,从而减少了 CPU 的消耗。相比之下,Scroll API 在处理流量时不会使用缓存。它的工作方式是创建一个游标(Cursor),并在服务器端维护一个快照,以便在后续的请求中能够继续从上一个请求的位置继续返回结果。这意味着每次请求都需要重新计算和访问磁盘上的数据,并且不能利用缓存。这会导致更多的 CPU 计算和磁盘访问,从而增加了 CPU 的消耗。
- Search 是无状态查询,Scroll 需要上下文维护:Scroll API 需要维护上下文信息,以便在后续的请求中能够正确地返回结果。这个上下文信息可能包括游标位置、排序信息、过滤条件等。为了保持这些上下文的一致性和完整性,ES 需要在服务器端维护和更新相关的状态。
- 这不意味着 Scroll API 一定比 Search API 更耗 CPU。实际的 CPU 消耗还受到多个因素的影响,包括查询的复杂性、数据量的大小、硬件配置等,需要结合实际情况观测。
ES 查询链路治理
将 ES 不合规的 Scroll 流量全部迁走
在某次大促活动之前,招商平台提供的某个活动下报名记录的全量获取接口走的全是 ES Scroll 流量,基本维持在 100+ QPS 水平,大多场景用于离线对账和首次数据拉取,我们通过跟业务沟通改离线对账或者走 DB 查询等方式,把不合理的 Scroll 查询迁移走。迁移了 Scroll 请求约 30+ 业务方, QPS 从 100+ 降到个位数,基本解决了 Scroll 场景的性能隐患。
ES 慢查询治理
慢查询是一个相对的概念,不是一个绝对的概念,不是说某种查询一定是慢查询,或者某种查询一定不是慢查询,他和数据规模等因素相关性很大。大多都是因为实现方式的原因,他的慢会随着数据规模增长而逐渐明显,所以支持亿级数据量和万级、百万级完全不是一回事,不到一定数据量,同样的实现可能也并不会产生问题。
在大规模数据场景下,慢查询的慢会越发明显,往往慢查询几十的 QPS 就能占用正常查询上千 QPS 所需要的资源。在其它流量突然增加的情况下,一般慢查询的耗时会成倍增加,也意味着它占用的资源一直得不到释放,给系统带来巨大的性能隐患。下面举例说明一些观察到的 ES 慢查询:
- Terms 查询在每次查询的数量过大时都会导致慢查询,系统当时存在每次 Terms 查询 万个商品的场景,耗时在 1s+,商品写流量进来后,查询耗时翻好几倍,CPU 被打满。
- 对 Double 类型字段做 Term 查询,因为检索方式和数据结构不匹配,同样还是因为数据量过大,导致慢查询。
- 高区分度字段 Terms 聚合。
慢查询的规避手段也已经相对比较成熟。可以完善慢查询的监控报警机制,在 CPU 使用率是偏高时制定合理的报警阈值。借此我们也梳理了 ES 查询可能存在的慢查询 Case,排查其他业务隐患,由此慢查询带来的 CPU 上涨问题也已经被排查解决。
Range 查询优化
缓存是提升 ES 查询性能的重要手段,如果查询缓存命中率低,则可以定向优化。ES Filter 查询的时候会缓存查询频次较高的请求结果,然而 Range 查询的特殊点在于,如果每次查询的时间区间不一样,会导致一直缓存,然而命中率极低,引发系统频繁 GC,从而造成稳定性问题。
优化方法:
- Range 查询走普通查询,不通过 Filter 过滤器缓存。
- 优化 Range 查询,比如指定时间区间查询,提高分片维度请求缓存命中率,并降低缓存频繁构建和垃圾回收频率。仅查询需要的字段
在我们的系统中就曾出现过获取活动列表活动的配置非常大,流量变高时迅速把 CPU 打满的问题。主要因为 ES 查询默认是 query_then_fetch 模式,如果业务的索引文档比较大,每次查询都返回整个索引文档的话,那么 Fetch 的耗时就会变高,造成慢查询,或者内存被打爆的情况,所以仅查询需要的字段可以节省带宽,和磁盘访问耗时,从而提升查询效率。
ES 写入链路治理
仅写入需要索引的字段
ES 的定位是搜索和统计,所以我们在后面的治理中也是非常谨慎对待需要写入 ES 的字段,仅写入索引和统计字段,其它数据则可以回表查询,该方式可以避免索引膨胀速度过快,影响查询和索引重建效率,也是更多资源的浪费。
Nested 索引优化
索引通常会面临父子文档关联文档这样的查询场景,有的还要求子文档能够独立搜索,Nested 类型就是 ES 帮甲方解决此类问题的。Nested 其实是非常好的一个设计,性能也很优越,但它的前提是子文档不能太大,子文档深度不能太深,文档膨胀相对可控,查询方式友好,总之大数据规模使用 Nested,需要多加前提,能不用就不用,小数据规模就不用太有负担。
Nested 的查询和索引性能都稍逊于普通索引类型,通常是普通索引好几倍的资源消耗,我们为了解决商品索引 SPU->SKU Nested 慢查询问题,以及降低索引膨胀速度,通过将 ES 的 SKU Nested 索引设置为 Object 类型,并且把 SKU 维度的信息计算结果作为 SPU 字段共同提供简单查询,满足业务查询需要,这样我们既做到了业务无损,也降低了系统压力。通过以上优化我们的写入膨胀系数降低了 20 倍左右,文档数从 40 亿缩减到了 2 亿,并通过压测佐证写入性能提升了 20%+,也不会再高频出现该类慢查询的情况。
消息乱序问题
RocketMQ 乱序问题:
- 通过 Client SDK 发送数据时,如果发送失败则会快速重试发送到其它 Queue,此时同一个 Key 的消息在不同的 Queue 中造成消息乱序到达 ES;
- RocketMQ 如果出现发生 Rebalance,可能会导致同一组消息同时给多个消费者消费,从而发生 ABA 覆盖写问题。
以上都会造成丢失更新的问题,所以需要利用 RocketMQ 来保证有序性,但也并不能达到 100% 的效果。我们在比价消费场景中就曾遇到问题,一个报名商品有 N 个 SKU,会分别进行站内外比价,以及自身比价结果计算,基本上都是并发进行的,这就导致多个比价结果在同一时刻到达,其中一个消息在写入时发生失败自动重试写入到其它 Queue,即发生了比价消息更新覆盖的问题。在具体的解决过程中我们设计了如下三个方案:
- 采用比价的 Version 乐观锁控制,采用 Script+Verison 写,但是由于 Script 的写入性能不高,而且比价目前的写入流量最高 2k+,未来随着商品量级增加会更多,所以未采用。
- 采用 ES 的 Version 版本号控制,写入时带上 Version 版本号,但是因为前面介绍过我们的一条报名记录会有多个写入入口,全局 Version 版本号的形式成本太高,也未采用。
- ✅ 采用批量聚合消费的方式。即 FaaS 单条消费改为批量消费,按照最大消息数或者聚合时间消费聚合,这样可以处理单条消息并发更新的 Case。采用该方式首先因为改动成本低,本身的 SDK 也能够支持,可以聚焦解决问题,少量 Case 依然使用对账 T+1 补偿的方式。
ES 资源隔离
目前我们的 ES 集群承载着所有招商需要的 ES 索引的流量,包括活动、企划、报名实体索引等,目前的稳定性保障预期总读流量可以支持 2000+ QPS,写流量 6000+ QPS。然而在压测时使用线上真实流量压测,我们在分别压读、压写,同时压读和写,通过控制变量的方式压测时发现读流量的资源倾斜非常严重,部分节点的 CPU 使用率很高,整体压不上去。通过分析后发现是因为不同索引的分片分布不一样导致的,所以读写流量分布不均,并且不同索引的重保等级是不一样的,介于此原因,我们认为资源隔离可以更好的规避风险,提高系统可用性,根据不同的分片分布特性,分配不同的 ES 集群规格,也有利于资源使用率最大化。
治理效果
- ES 集群资源使用情况符合预期,不再出现 CPU 暴涨、CPU 被打满、持续慢查询情况,基本解决了非预期的 CPU 增长问题,系统性能保持稳定。
- ES 索引文档数从 40 亿缩减到 2 亿+,ES 写性能提升 20%+,写入 QPS 最高可支持 1w+,性能上可以超出业务需求满足业务使用。
当然在每次活动之前,我们也都会结合稳定性的治理 House 来分析容量变化、流量变化、监控报警,并根据需求定向优化以上几个方面,保证每一次的系统变化都在预期范围内,把一切不确定因素变得确定。当然在未来的实践中仍需不断探索,挖掘 ES 在实践能力上的更多可能性。