【Dev&Ops】使用RocksDB优化服务器启动时间实战记录
介绍
在我们的搜索基础设施中,基础服务Mustang管理着SOLR索引。目前,我们针对不同的业务单位在多个分片上运行。每个分片根据数据量和针对该分片的请求量托管不同数量的副本。
每个副本包含两个主要数据组件:存储在磁盘上的数据(由SOLR提供的产品相关数据)和存储在内存中的数据(针对快速变化的属性,称为NRT(近实时 )数据)。在应用程序启动时,通过从中央Redis集群获取数据来构建内存中的数据结构。这些内存中的数据结构也通过Kafka管道进行更新,以保持与Redis的同步。
平均来说,每个副本持有大约1500万条清单的数据。在启动过程中构建这些内存数据结构大约需要30-40分钟。这个过程的主要瓶颈是Redis,它在部署期间难以处理并发请求的涌入(因为每个数据中心的这个集群的大小接近400个虚拟机)。
整个程序大大降低了我们的部署速度,将其延长至少2天。这不仅影响了开发者的生产力,而且在及时部署错误修复方面也带来了挑战。
在这篇博客中,我们讨论了如何使用RocksDB优化Mustang的启动时间。
问题分析
每当Mustang服务器重新启动时,我们的Redis集群就会陷入困境。即使是10%的推出因子,也会有大约40个Mustang服务器同时向Redis发起超过30万次的调用。这种大量并发请求的原因是每个服务器中轮询线程的数量和它们的批处理大小。
此外,从Redis获取清单数据并不是一个简单的Redis GET操作。我们编写了一个库来抽象出从Redis构建清单POJO的逻辑,但它内部会向Redis发起多个并发调用以获取各种属性的数据,然后将它们合并以创建一个单一的POJO。
例如,与清单关联的优惠在Redis中作为SET存储,而不同服务区域的可获得性数据则作为BITFIELD存储。获取两者的数据都需要向Redis发送不同的查询,解析响应的方式也相应不同。
从下到上探索瓶颈似乎是一个不错的选择。所以我们从Redis开始。
调整Redis
我们尝试调整每个应用程序服务器中轮询线程的批处理大小和线程数,但总体性能并没有改善。尽管每个批次的延迟略有降低,但我们最终需要处理的总批次数增加了,这抵消了任何好处。
我们还发现了一些未使用的清单属性,但它们仍然是清单POJO的一部分。将它们从POJO中移除给了性能一个小幅提升,但这仍然不够。
我们还探索了在每个Redis集群的分片中增加副本数量的选项,以实现更好的负载平衡,但这对我们来说并不实际,因为集群在大多数时间都是空闲的,只在Mustang部署时使用。我们在这里看不到增加更多资源的价值。
由于进一步优化Redis比较困难,我们开始探索应用程序服务器端的选项。
生成文件缓存
我们的想法是从Redis中一次性检索数据来构建内存中的数据结构,然后将其本地缓存以供未来的部署使用。
为了快速验证这个想法,我们编写了一个关闭钩子,它会将所有内存中的数据结构序列化到本地磁盘上的单独文件中。在启动时,应用程序会反序列化本地存储的数据并加载内存中的数据结构。
带有文件缓存的Mustang架构
这个想法一开始看起来很有希望,但后来出现了各种问题。以下列举了一些问题:
- 我们的内存数据结构在代码(和内存中)本质上是不同的段,但它们都源自清单数据。如果在序列化其中一个段时出现问题,我们不能在不重新加载所有清单数据的情况下重新加载该段。这意味着即使只有一个文件损坏,我们也必须丢弃所有序列化的数据,并再次从Redis中引导整个数据。
- 清单数据对于每个分片来说是相同的,但这些内存数据结构即使在同一个分片中的应用服务器之间也可能有所不同。这种差异源于我们在这些数据结构中用于识别清单的序号的随机性质。这些清单序号是在异步加载SOLR索引文件的过程中按先到先得的原则生成的,使得它们具有不确定性。因此,不可能将序列化文件共享给同一分片的其它副本。
- 代码看起来很混乱,因为我们使用了Jackson进行序列化和反序列化。Jackson需要我们的代码中有特定的getter和setter才能正常工作。这特别是在处理继承或者当我们已经有了带有自定义逻辑的getter而不是简单地返回属性时,造成了复杂性。
这些限制需要一个更健壮和优雅的解决方案。基于POC的嵌入式数据库方法看起来很有希望,我们最终选择了RocksDB 。
RocksDB救援
为什么选择RocksDB?
让我们花点时间来理解为什么我们选择了RocksDB。
- 它是一个嵌入式数据库。这意味着你不需要在集中式服务器上运行它。它可以作为库直接在你的代码中使用。由于我们试图摆脱集中式解决方案,这对我们来说非常完美。
- 它提供了基于需求调整的配置选项。
- 我们在使用各种工作负载进行测试时看到了令人鼓舞的结果,这给了我们对其性能的信心。
- 它是一个非常流行的数据库,在业界有广泛的使用 Cloudflare 使用它,以及它与MySQL 作为存储引擎的集成等等)
- 它有非常活跃的社区 支持(由Facebook维护)
这些数据点确实有助于我们将好的嵌入式数据库的范围缩小到RocksDB。
RocksDB的存储模式
在我们决定使用嵌入式数据库之后,我们开始将其集成到我们的代码中。任务很简单。我们希望从Redis中一次性获取数据,然后将其存储在RocksDB中,以便任何后续部署都可以使用本地存储的数据。
带有RocksDB的Mustang架构
为了保持简单,我们设计了一个直接的存储模式。RocksDB中的每一行都保存了一个清单的数据。每行的键是清单的ID,而值是序列化的清单POJO。它看起来像这样:
“LISTING_1”: “{\”attribute_1\”: \”value_1\”, \”attribute_2\”: \”value_2\”}”
除了简单性之外,选择这种模式的主要原因之一是操作的具体性。有了这个模式,我们可以根据应用程序的需要更新或获取单个清单或一组清单。如果RocksDB中某些清单的数据不存在,那么我们可以在下一个引导阶段简单地从Redis中获取这些缺失的记录并更新RocksDB。系统以自我修复的方式运行。
这为我们解决方案奠定了基础,但还有许多事情需要解决。我们将在以下部分讨论一些其他挑战。
解决陈旧数据问题
从Redis中一次性获取数据并本地存储并不足够,因为我们的业务变化非常快。我们的大多数业务需求都需要对清单属性(以及一些类似的内存数据结构模式变化)进行更改。因此,当这种情况发生时,存储在RocksDB中的数据与应用程序的模式不兼容。为了处理这个问题,我们在RocksDB中存储了内存数据结构模式的哈希值,与清单数据一起。每次部署时,这个模式都会与最新的模式(存储在代码中)进行比较。如果存在模式不匹配,我们可以简单地丢弃RocksDB的数据,并用Redis中的最新数据刷新它。
这种方法还算有效,但结果发现我们60%的Mustang部署都涉及更改数据结构。这意味着我们仍然在大多数部署中依赖Redis,这并不是理想的情况。
因此,我们想出了一个更好的计划。我们知道同一个分片内的所有副本都有相同的清单数据。那么,为什么不从每个分片的一个随机副本创建RocksDB数据,将其存储在GCS上,然后让分片中的其他副本使用呢?
这个逻辑很容易实现。我们在代码中编写了一个验证层,用于在Mustang启动时比较模式哈希值。如果它们相同,应用程序就会从RocksDB加载数据;如果它们不同,应用程序就会删除本地的RocksDB数据,然后从GCS获取最新数据,并继续从RocksDB加载数据。
如果GCS上没有数据,那么它会回退到Redis来引导数据。我们的部署管道也被更改以处理这种类型的部署。每当模式发生变化时,CI管道就会从每个分片中挑选一个随机副本,在其上部署最新代码,让它从Redis引导,然后将本地的RocksDB数据上传到GCS。其余的副本在本地数据失效时,会自动从GCS获取数据。
处理Kafka更新
在处理Kafka的更新时,我们需要确保RocksDB中的数据与Redis中的数据保持同步。我们通过开发一个专用的类来拦截Kafka更新,并在反映内存数据结构变化之前将它们添加到RocksDB,从而简化了这个过程。
由于我们在RocksDB中存储了整个POJO,因此我们需要进行读-修改-更新操作来更新数据。主要的技术难题围绕着管理RocksDB中的潜在更新失败,以确保Mustang下次重启时,能够从RocksDB检索到最新的数据。
为了缓解这个问题,我们在try-catch块中实现了错误处理。如果由于任何原因更新失败,我们简单地从RocksDB中删除相应的清单。在删除失败的情况下(即使在重试之后),我们选择在关闭时清除整个RocksDB数据集。请注意,即使RocksDB更新失败,我们仍然会更新内存数据结构,以确保用户没有数据不一致。
还有一个问题我们需要解决。如果Mustang在从RocksDB引导后从最新的偏移量开始读取Kafka事件(这是现有系统的默认行为),那么可能会有数据丢失。这是因为Mustang重启时RocksDB不会收到更新。这以前对我们来说不是问题,因为Mustang通常从Redis引导,Redis是真实数据的来源,因此它可以安全地从最新的Kafka偏移量开始读取。
为了解决这个问题,我们在关闭时开始存储所有分区的Kafka偏移量。然后,在启动时,Mustang会回溯到相应分区的存储偏移量。如果由于应用程序崩溃等原因在RocksDB中找不到偏移量,我们会丢弃本地转储。此外,如果当前偏移量与存储的偏移量之间的差异很大,我们也会丢弃本地转储。这个决定是基于这样的理解,即填补如此大的数据缺口将花费大量时间。这个差异的阈值是通过估算Mustang通常可以在五分钟内处理多少更新来确定的。
针对我们的用例调整RocksDB
至此,我们能够成功地将RocksDB集成到我们的代码库中。Mustang引导内存数据结构所需的时间已经从30分钟减少到接近15分钟。我们相信,如果我们更好地理解我们的访问模式并深入研究RocksDB的内部结构,我们可以从RocksDB中获得更多。
我们的工作负载是读写混合的。从Redis引导数据并将其插入RocksDB是完全写密集型的,而对于后续部署,它总是读密集型的。我们只能优化这些模式中的一个,我们决定优化读取(原因显而易见)。
以下是一些对我们的用例有效的优化:1. 关闭缓存: 在任何RocksDB使用案例中,都建议使用基于LRU的块缓存,但它存在严重的锁竞争问题。鉴于我们的使用案例主要是在启动阶段读取清单(这是一种一次性读取模式),我们关闭了数据块的缓存。 2. 使用层级压缩: 与大小分层(又称通用)压缩策略相比,层级压缩策略在读取和空间放大方面表现更好。 3. 减少LSM树中的层级数量: LSM树中的层级数量是RocksDB中LSM树的一个重要属性。它决定了LSM树中将有多少层级。如果至少有大量被检索的数据是热数据,那么多个层级是有益的,否则它会影响到读取延迟。在我们的案例中并非如此,因为我们的访问模式是随机的。我们将num_levels从7(默认)减少到3。 4. 触发定期的完全压缩: 当RocksDB处于最佳状态时,其性能最佳。这意味着如果读放大最小,RocksDB的读取性能将最佳。为了减少因Kafka更新导致的读(和空间)放大,手动触发完全压缩会给我们带来最好的结果。我们编写了一个异步线程,在非高峰时段每天触发一次完全压缩。 5. 关闭WAL: 默认情况下,RocksDB将所有写入存储在WAL以及memtable中。鉴于我们的使用案例具有自我修复性质,不会丢失任何数据,因此我们关闭了WAL。 6. 使用multiGet()读取数据: RocksDB提供了多种从数据库读取数据的方式。你可以发出一个*get()命令或一个multiGet()命令。multiGet() 在多个原因下比循环中的多个get()*调用更高效,例如filter/index缓存上的线程竞争较少,内部方法调用次数较少,以及不同数据块在IO上的更好并行化。 7. 排序并分批处理清单: 我们对整个清单集合进行排序,然后在从RocksDB获取数据之前创建更小的批次。这减少了随机磁盘IO,因为排序后的清单在磁盘上是相邻的,这增加了它们位于磁盘上相同或相近页面的机会。
这些优化措施共同将启动时间从15分钟减少到大约6分钟,这是我们追求效率的重要里程碑。
将RocksDB投入生产
一切准备就绪后,我们急切地将RocksDB投入生产。然而,将新技术引入我们的堆栈意味着我们需要谨慎行事,以防止任何中断。我们实施了相关指标来监控Mustang与RocksDB的交互并减轻风险。然后我们在每个分片内的有限数量的副本上启动部署。
一个小插曲
在少数几个Mustang服务器上部署最新代码后不久,我们开始看到响应时间下降。深入挖掘后,我们发现整个虚拟机的内存逐渐在几天内被消耗掉。这种过度的内存使用阻碍了Solr缓存索引相关文件的能力(最终在运行时导致过度的磁盘利用率),导致延迟增加。很明显,仅Java应用程序本身不可能导致这种内存泄漏,因为它在启动时分配了一个固定大小的内存块(以堆内存的形式),并且它只使用了一点点额外的本机内存,以便JVM运行(使用this 指南查看JVM使用的本机内存)。
我们的怀疑转向了RocksDB,尽管它被归类为嵌入式数据库,但它不仅仅是一个Java库,还包含一个与Java应用程序通过JNI交互的C++组件。你在Java中与之交互的RocksDB相关的类,它们内部调用RocksDB中的C++对应部分。由于缺乏自动垃圾收集系统,C++中的内存管理是困难的。我们最初认为RocksDB可能有一个bug..
这时我们开始探索调试本机内存泄漏的选项。我们寻找本机内存泄漏的过程本身就可以写一篇博客,但为了简单起见,我们只谈谈在这里帮助我们的内容。在我们的探索中,为了调试这种类型的内存泄漏,我们遇到了一个名为Jeprof 的工具。这个工具用于追踪由JNI调用等引起的本机内存分配。因此,我们配置了Jeprof,每隔几分钟就对我们的应用程序的本机内存分配进行内存转储。后来,我们比较了两个随机生成的转储文件(它们相隔几个小时),以查看哪些对象的大小在增长。下面是同样的截断输出:
正如你在截图中看到的,我们发现了由RocksDB提供的*ReadOptions()和WriteOptions()*对象的重量级分配。
检查代码后,我们发现这两个类的新对象分配只发生在一个地方。为了处理代码中的RocksDB相关配置,我们创建了一个新的类,它保存了各种对象,如ReadOptions()和WriteOptions(),并将它们映射到映射中的各个列族名称。这些对象通常在Mustang启动时提供,并且永远不会再次创建。
然而,为了安全起见,我们设计了一个解决方案来处理在请求列族时这些对象尚不可用的场景。我们使用了映射的*getOrDefault()*方法,它会检索预先配置的对象,或者在运行时为指定的列族生成一个新的对象。这个实现的Java代码如下所示:
...
public ReadOptions getReadOptionsByColumnFamily(String columnFamilyName) {
return this.readOptions.getOrDefault(columnFamilyName, new ReadOptions());
}
public WriteOptions getWriteOptionsByColumnFamily(String columnFamilyName) {
return this.writeOptions.getOrDefault(columnFamilyName, new WriteOptions());
}
...
但是为什么这会导致内存泄漏呢?
在阅读RocksDB关于内存管理的文档 后,我们发现RocksDB的每个类都直接或间接实现了Autocloseable。每当使用完RocksDB的Java对象时,你需要显式调用close()(或者使用try-with-resources)来释放RocksDB的C++对象实际持有的内存。如果不这样做,可能会导致内存泄漏。
不幸的是,在实现上述代码片段中的逻辑时,我们未能意识到每次调用此方法时,无论给定的列族是否存在预先配置的对象,都会在Map的*getOrDefault()*中实例化新对象。这些新创建的对象没有被关闭,导致了内存泄漏。
一旦我们理解了内存泄漏的根本原因,解决它就很容易了。我们在实例化这个配置类时只创建一次默认的ReadOptions和WriteOptions对象,并在Map的*getOrDefault()*中使用它们,而不是创建新对象。
我们迅速将这个修复部署到受影响的Mustang服务器上,并监控了一段时间以确认内存稳定。
继续部署
一旦我们确信一切运行顺利,RocksDB没有产生不利影响,我们便开始部署剩余的Mustang服务器,同时特别注意系统资源。这种逐步的方法让我们能够确保无缝过渡并保持生产环境的稳定性。
结论
总之,RocksDB在Mustang的部署过程中带来了显著的改进。与之前依赖Redis相比,通过在本地存储数据,我们显著减少了构建内存数据结构所需的时间。
通过对各个分片进行广泛测试,我们观察到启动时间的实质性减少,平均仅需6分钟(之前为30-40分钟)。不仅每个服务器的启动时间减少了,而且我们现在可以增加部署的滚动系数,因为对Redis的依赖最小,我们可以并行部署更多服务器。
因此,现在所有分片上Mustang的整个部署过程耗时不到3小时——这是通过采用RocksDB实现的效率提升的证明。