Salesforce负责全渠道库存服务的 Commerce Cloud 团队使用Redis作为远程缓存来存储适合缓存的数据。远程缓存允许我们的多个进程获得缓存数据的同步和单一视图。
使用模式是生命周期较短、缓存命中率高并在实例之间共享的条目。为了与 Redis 交互,我们使用了Spring Data Redis(带有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 "rate:block_00001" -1
如上所示,有些条目的 TTL 为 -1,表示未过期。虽然这显然是手头问题的嫌疑原因,并且修复它以明确设置 TTL 值以实践良好的软件卫生似乎是前进的方向,但由于相对较少的数量,有人怀疑这是问题的真正原因条目和内存使用。
添加 TTL 后,入口代码如下所示:
@RedisHash("rate") public class RateRedisEntry implements Serializable { @Id private String tenantEndpointByBlock; @TimeToLive private Integer expirationSeconds; ... }
关键问题聚焦到:
@RedisHash("rate")
为了检查它,我们使用了以下 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
除了增加内存增长外,“rate”条目上的 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 的内存不会继续增加,并清除过期条目
"rate" "rate:block_00001" "rate:block_00001:phantom" <--除了基础之外的幻影条目 ......
创建幻像 Phantom Keys 以便 Spring Data Redis 可以将带有相关数据的RedisKeyExpiredEvent传播到 Spring framework 的ApplicationEvent订阅者。Phantom(或Shadow)条目比它正在隐藏的条目存活时间更长,因此当 Spring Data Redis 接收到主条目过期事件时,它将从 Shadow 条目中获取值以传播RedisKeyExpiredEvent,该事件将容纳除了密钥之外的过期域对象。
Spring Data Redis 中的以下代码接收幻像Phantom 条目过期并从索引中清除该项目:
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到更有利。
通过连续执行 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 服务器交互。由于代码更透明且功能更直接,因此它提供了一种解决方案,其中包含的未知行为要少得多。
我们的代码最终看起来像这样:
public class RateRedisEntry implements Serializable { private String tenantEndpointByBlock; private Integer expirationSeconds; ... } @Bean public RedisTemplateredisTemplate() { 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); } }
通过以这种方式使用它,我们直接处理条目,因此不存在存储不需要的索引或结构的风险。
部署我们的解决方案后,我们的内存使用量完全下降并保持稳定,在条目的 TTL 达到 0 后,任何峰值都会下降。
结论
Spring Data Redis Crud Operations 的魔力是通过创建额外的数据结构(如用于索引的 SET)来实现的。当项目过期而不启用Spring Data Redis 以侦听 KEY 空间事件时,不会清除这些额外的数据结构。对于条目非常长或条目集易于处理且有限的缓存模式,带有 CrudRepositories 的 Spring Data Redis 为 Redis 的 CRUD *** 作提供了低代码解决方案。
但是,对于数据由多个进程缓存和共享的缓存模式,以及条目具有可以缓存它们的较小窗口的缓存模式,避免侦听 KEY 事件并使用RedisTemplate为所需的 CRUD *** 作执行 Redis *** 作似乎是最佳的。
小编为大家准备一份Spring Data 资料
需要的扫码免费领取
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)