Salesforce使用Spring Data Redis内存泄漏的经验教训


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<RateRedisEntry, String> {}

为了验证确实未设置 TTL 的数据,与 Redis 服务器实例建立连接,并使用 Redis 命令 TTL <name entry key> 检查列出的某些条目的 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 OverflowSpring 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<Void>) 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 RedisTemplate<String, RateRedisEntry> redisTemplate() {
   RedisTemplate<String, RateRedisEntry> template = new RedisTemplate<>();
        
   template.setConnectionFactory(getLettuceConnectionFactory());
        
   return template;
}
public class RedisCachedRateRepositoryImpl implements RedisCachedRateRepository {

    private final RedisTemplate<String, RateRedisEntry> redisTemplate;

    public RedisCachedRateRepositoryImpl(RedisTemplate<String, RateRedisEntry> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    public Optional<RateRedisEntry> 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 操作似乎是最佳的。