Salesforce负责全渠道库存服务的 Commerce Cloud 团队使用Redis作为远程缓存来存储适合缓存的数据。远程缓存允许我们的多个进程获得缓存数据的同步和单一视图。
使用模式是生命周期较短、缓存命中率高并在实例之间共享的条目。为了与 Redis 交互,我们使用了Spring Data Redis(带有Lettuce),它一直帮助我们在我们的实例之间共享我们的数据条目,并提供一个与 Redis 交互的低代码解决方案。
我们应用程序的后续部署显示出一个奇怪的现象,Redis 上的内存消耗不断增加,而且没有减少的迹象。
随着时间的推移,内存消耗几乎呈线性增长,系统吞吐量增加,但随着时间的推移没有显着的回收。这种情况达到了如此极端,以至于当内存增加并接近 100% 时,需要手动刷新Redis 数据库。以上似乎表明 Redis 条目发生了内存泄漏。
调查
第一个怀疑是 Redis 条目要么没有配置生存时间 (TTL),要么配置了超出预期的 TTL 值。这表明我们用于速率限制的 Redis Repository 实体类没有任何 TTL 配置:
<span style="color:#333333"><span style="background-color:#f5f5f5">@RedisHash(<span style="color:#00bb00">"rate"</span><span style="color:black">)
<strong>public</strong> <strong>class</strong> RateRedisEntry implements Serializable {
@Id
<strong>private</strong> String tenantEndpointByBlock; </span><span style="color:#0000aa"><em>// A HTTP end point</em></span><span style="color:black">
...
}
</span><span style="color:#0000aa"><em>// CRUD repository.</em></span><span style="color:black">
@Repository
<strong>public</strong> <strong>interface</strong> RateRepository <strong>extends</strong> CrudRepository<RateRedisEntry, String> {}
</span></span></span>
|
为了验证确实未设置 TTL 的数据,与 Redis 服务器实例建立连接,并使用 Redis 命令 TTL <name entry key> 检查列出的某些条目的 TTL 值。
<span style="color:#333333"><span style="background-color:#f5f5f5">TTL <span style="color:#00bb00">"rate:block_00001"</span><span style="color:black">
-1
</span></span></span>
|
如上所示,有些条目的 TTL 为 -1,表示未过期。虽然这显然是手头问题的嫌疑原因,并且修复它以明确设置 TTL 值以实践良好的软件卫生似乎是前进的方向,但由于相对较少的数量,有人怀疑这是问题的真正原因条目和内存使用。
添加 TTL 后,入口代码如下所示:
<span style="color:#333333"><span style="background-color:#f5f5f5">@RedisHash(<span style="color:#00bb00">"rate"</span><span style="color:black">)
<strong>public</strong> <strong>class</strong> RateRedisEntry implements Serializable {
@Id
<strong>private</strong> String tenantEndpointByBlock;
@TimeToLive
<strong>private</strong> Integer expirationSeconds;
...
}
</span></span></span>
|
关键问题聚焦到:
<span style="color:#333333"><span style="background-color:#f5f5f5">@RedisHash(<span style="color:#00bb00">"rate"</span><span style="color:black">)
</span></span></span>
|
为了检查它,我们使用了以下 Redis 命令:
<span style="color:#333333"><span style="background-color:#f5f5f5">KEYS *
1) <span style="color:#00bb00">"rate"</span><span style="color:black">
2) </span><span style="color:#00bb00">"block_00001"</span> </span></span>
|
如您所见,有两个条目。一个是带有键名的条目“rate:block_00001”和一个带有键“rate”。
额外条目“rate:block_00001”是意料之中的,但另一个条目令人惊讶地发现。随着时间的推移监控系统还表明,与“rate” 密钥相关的内存正在稳步增加。
<span style="color:#333333"><span style="background-color:#f5f5f5">>MEMORY USAGE <span style="color:#00bb00">"rate"</span><span style="color:black">
(integer) 153034
.
.
.
> MEMORY USAGE </span><span style="color:#00bb00">"rate"</span><span style="color:black">
(integer) 153876
.
.
> MEMORY USAGE </span><span style="color:#00bb00">"rate"</span><span style="color:black">
(integer) 163492
</span></span></span>
|
除了增加内存增长外,“rate”条目上的 TTL为 -1,如下所示:
<span style="color:#333333"><span style="background-color:#f5f5f5">>TTL <span style="color:#00bb00">"rate"</span><span style="color:black">
-1
>TYPE </span><span style="color:#00bb00">"rate"</span><span style="color:black">
set
</span></span></span>
|
它清楚地指出了最有可能的嫌疑,即其增长没有随着时间的推移而减少的迹象。
那么,这个条目是什么,为什么它会增长?
Spring Data Redis 在 Redis 中为每个@RedisHash创建一个 SET 数据类型。SET 的条目充当 CRUD 存储库使用的许多 Spring Data Redis 操作的索引。
例如,SET 条目如下所示:
<span style="color:#333333"><span style="background-color:#f5f5f5">>SMEMBERS <span style="color:#00bb00">"rate"</span><span style="color:black">
1) </span><span style="color:#00bb00">"block_00001"</span><span style="color:black">
2) </span><span style="color:#00bb00">"block_00002"</span><span style="color:black">
3) </span><span style="color:#00bb00">"block_00003"</span><span style="color:black">
...
</span></span></span>
|
我们决定在 Stack Overflow和Spring Data Redis 的 GitHub 页面上发布我们的情况,请求社区就如何最好地解决这个问题提供一些帮助——要么阻止这个 SET 的增长,要么如何阻止它的创建,如我们真的不需要任何其他索引功能。
在等待社区响应的同时,我们发现启用Spring Data Redis 注释EnableRedisRepositories的属性实际上会使 Spring Data Redis 侦听KEY事件并随着时间的推移在收到KEY 过期事件时清理 Set 。
<span style="color:#333333"><span style="background-color:#f5f5f5">@EnableRedisRepositories( enableKeyspaceEvents
= EnableKeyspaceEvents.ON_STARTUP)
</span></span>
|
启用此设置后,Spring Data Redis 将确保 Set 的内存不会继续增加,并清除过期条目
<span style="color:#333333"><span style="background-color:#f5f5f5"><span style="color:#00bb00">"rate"</span><span style="color:black">
</span><span style="color:#00bb00">"rate:block_00001"</span><span style="color:black">
</span><span style="color:#00bb00">"rate:block_00001:phantom"</span><span style="color:black"> <--除了基础之外的幻影条目
......
</span></span></span>
|
创建幻像 Phantom Keys 以便 Spring Data Redis 可以将带有相关数据的RedisKeyExpiredEvent传播到 Spring Framework 的ApplicationEvent订阅者。Phantom(或Shadow)条目比它正在隐藏的条目存活时间更长,因此当 Spring Data Redis 接收到主条目过期事件时,它将从 Shadow 条目中获取值以传播RedisKeyExpiredEvent,该事件将容纳除了密钥之外的过期域对象。
Spring Data Redis 中的以下代码接收幻像Phantom 条目过期并从索引中清除该项目:
<span style="color:#333333"><span style="background-color:#f5f5f5"><strong>static</strong> <strong>class</strong> MappingExpirationListener <strong>extends</strong> KeyExpirationEventMessageListener {
<strong>private</strong> <strong>final</strong> RedisOperations<?, ?> ops;
...
@Override
<strong>public</strong> <strong>void</strong> onMessage(Message message, @Nullable byte[] pattern) {
...
RedisKeyExpiredEvent event = <strong>new</strong> RedisKeyExpiredEvent(channel, key, value);
ops.execute((RedisCallback<Void>) connection -> {
<span style="color:#0000aa"><em>// Removes entry from the Set</em></span><span style="color:black">
connection.sRem(converter.getConversionService()
.convert(event.getKeyspace(), byte[].<strong>class</strong>), event.getId());
...
});
}
..
}
</span></span></span>
|
这种方法的主要问题是 Spring Data Redis 必须使用过期的事件流并执行清理而产生的额外处理开销。还应该注意的是,由于 Redis Pub/Sub 消息不是持久性的,如果条目在应用程序关闭时过期,则不会处理过期事件,并且这些条目不会从 SET 中清除。
有效地使用 CRUDRepository 意味着为每个条目创建更多的影子/支持条目,从而导致更多的 Redis 服务器数据库内存消耗。如果条目过期时不需要 Spring Boot 应用程序中的过期详细信息,您可以通过对EnableRedisRespositories注释进行以下更改来禁用 Phantom 条目的生成。
<span style="color:#333333"><span style="background-color:#f5f5f5">@EnableRedisRepositories(.. shadowCopy = ShadowCopy.OFF )
</span></span>
|
上述的最终效果是 Spring Data Redis 将不再创建影子副本,但仍会订阅 Keyspace 事件并清除条目的 SET。传播的 Spring Boot 应用程序事件将只包含 KEY 而不是完整的域对象。
有了以上关于性能和额外内存存储的所有发现,我们认为对于我们正在处理的用例,Redis CRUDRepository 和 KEY Space 事件增加的额外开销对我们没有吸引力。出于这个原因,我们决定探索一种更精简的方法。
我们制作了一个概念验证应用程序来测试使用 CrudRepository 或直接使用RedisTemplate公开 Redis 服务器操作的类之间的响应时间差异。通过测试我们观察RedisTemplate到更有利。
通过连续执行 GET 操作五分钟并取完成操作所用时间的平均值来进行比较。我们看到的是,几乎所有使用 CRUDRepository 的 GET 操作都在毫秒范围内,而没有 CRUDRepository 的概念验证主要在纳秒范围内。我们注意到的另一件事是 CRUDRepository 在执行操作时也有更多上升的趋势,增加了执行其操作的延迟。
解决方案
根据研究,我们的前进方向如下:
- Spring Data Redis CrudRepository:启用Redis Repository的key space事件,启用Spring Data Redis清除过期条目的Set类型。这种方法的好处是它是一种低代码解决方案,通过在注解上设置一个值,让 Spring Data Redis 订阅 KEY 过期事件并在后台进行清理。不利的一面是,对于我们的案例,我们从未使用过的东西会额外使用内存,即 SET 索引和 Spring Data Redis 订阅 Keyspace 事件并执行清理所产生的处理开销。
- 使用RedisTemplate自定义Repository:在不使用CRUD Repository的情况下处理Redis I/O操作,使用RedisTemplate,构建基本需要的操作。好处是它导致只创建我们在 Redis 中需要的数据,即哈希条目,而不是其他工件,如 SET 索引。我们避免了 Spring Data Redis 订阅和处理 Keyspace 事件以进行清理的处理开销。不利的一面是,我们不再利用 Spring Data Redis 的 CRUD 存储库的低代码魔法及其在幕后所做的工作,而是使用代码来完成所有工作。
在考虑了我们所有的发现之后,尤其是围绕概念验证应用程序和我们的系统的指标,以及我们对团队的需求(更多的是关于快速响应时间和低内存使用率)之后,我们采用的方向不是使用CrudRepository,而是使用RedisTemplate与 Redis 服务器交互。由于代码更透明且功能更直接,因此它提供了一种解决方案,其中包含的未知行为要少得多。
我们的代码最终看起来像这样:
<span style="color:#333333"><span style="background-color:#f5f5f5"><strong>public</strong> <strong>class</strong> RateRedisEntry implements Serializable {
<strong>private</strong> String tenantEndpointByBlock;
<strong>private</strong> Integer expirationSeconds;
...
}
@Bean
<strong>public</strong> RedisTemplate<String, RateRedisEntry> redisTemplate() {
RedisTemplate<String, RateRedisEntry> template = <strong>new</strong> RedisTemplate<>();
template.setConnectionFactory(getLettuceConnectionFactory());
<strong>return</strong> template;
}
<strong>public</strong> <strong>class</strong> RedisCachedRateRepositoryImpl implements RedisCachedRateRepository {
<strong>private</strong> <strong>final</strong> RedisTemplate<String, RateRedisEntry> redisTemplate;
<strong>public</strong> RedisCachedRateRepositoryImpl(RedisTemplate<String, RateRedisEntry> redisTemplate) {
<strong>this</strong>.redisTemplate = redisTemplate;
}
<strong>public</strong> Optional<RateRedisEntry> find(String key, Tags tags) {
<strong>return</strong> Optional.ofNullable(<strong>this</strong>.redisTemplate.opsForValue()
.get(composeHeader(key)));
}
<strong>public</strong> <strong>void</strong> put(<strong>final</strong> @NonNull RateRedisEntry rateEntry, Tags tags) {
<strong>this</strong>.redisTemplate.opsForValue().set(composeHeader(rateEntry.getTenantEndpointByBlock()),
rateEntry, Duration.ofSeconds(rateEntry.getRedisTTLInSeconds()));
}
<strong>private</strong> String composeHeader(String key) {
<strong>return</strong> String.format(<span style="color:#00bb00">"rate:%s"</span><span style="color:black">, key);
}
}
</span></span></span>
|
通过以这种方式使用它,我们直接处理条目,因此不存在存储不需要的索引或结构的风险。
部署我们的解决方案后,我们的内存使用量完全下降并保持稳定,在条目的 TTL 达到 0 后,任何峰值都会下降。
结论
Spring Data Redis Crud Operations 的魔力是通过创建额外的数据结构(如用于索引的 SET)来实现的。当项目过期而不启用Spring Data Redis 以侦听 KEY 空间事件时,不会清除这些额外的数据结构。对于条目非常长或条目集易于处理且有限的缓存模式,带有 CrudRepositories 的 Spring Data Redis 为 Redis 的 CRUD 操作提供了低代码解决方案。
但是,对于数据由多个进程缓存和共享的缓存模式,以及条目具有可以缓存它们的较小窗口的缓存模式,避免侦听 KEY 事件并使用RedisTemplate为所需的 CRUD 操作执行 Redis 操作似乎是最佳的。
小编为大家准备一份Spring Data 资料
需要的扫码免费领取