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