5

Spring Data Redis两个问题:内存泄露和并发 - europace

 2 years ago
source link: https://www.jdon.com/57902
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Spring Data Redis两个问题:内存泄露和并发

我们最近将会话管理从 MongoDB 迁移到了 Redis。迁移本身是由我们使用 MongoDB 的经验推动的,它不能特别好地处理高频率更新和更频繁的读取。另一方面,Redis 被称为经过验证的存储,可以准确处理该用例。

数据库迁移并不总是那么容易,因为我们需要学习其他服务的新模式、最佳实践和怪癖。我们的目标是让我们的 Java 服务层尽可能简单,使其稳定且面向未来:会话管理当然是具有相当稳定功能集的服务之一,并且不会经常触及其代码。因此,对于几年后窥探它的任何人来说,保持它的简单易懂是一个重要方面。

我们面临两个问题:

  1. Spring Data 实现二级索引的概念以及失效问题,这些导致Redis内存使用量不断增长。 
  2. Redis 的原子性范围和 Spring Data 的更新机制

本文总结了我们在使用 Spring Data 作为持久层的瘦 Java 服务中采用 Redis 的经验。

带有二级索引和 EXPIRE/TTL 的 Spring Data Redis

在 Redis 中采用 Spring Data可直接开始:您需要的只是 Gradle 或 Maven 构建的依赖项以及@EnableRedisRepositoriesSpring Boot 应用程序中的注释。Spring Boot 的大多数默认设置都是有意义的,并且可以让您非常顺利地运行 Redis 实例。

但是会遭遇:Redis内存使用量不断增长的问题,下面看看这个认识过程:

不需要通用存储库的实际实现,因为 Spring Data 允许您interface在运行时声明一个简单的通向通用实例。我们的存储库是这样开始的:

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SessionDataCrudRepository extends CrudRepository<SessionData, String> {
}

我们由该存储库管理的实体也开始变得尽可能简单:

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

import java.util.concurrent.TimeUnit;

@RedisHash("SessionData")
public class SessionData {

  @Id
  private String sessionId;
  @TimeToLive(unit = TimeUnit.MINUTES)
  private Long ttl;

  ...
}

您会注意到我们选择对ttl属性建模,该属性被@TimeToLive转换为EXPIRE实体。我们不想手动跟踪过期会话,但希望 Redis 透明地删除过期会话。该ttl会定期刷新用户活动期间,如果手工删除,可能会误被注销。

当用户实际按下注销按钮时会发生什么,或者我们如何禁用用户帐户并使正在运行的会话无效?简单:我们也有一个userId作为会话数据SessionData的一部分,并且可以执行以userId查询查找每个会话。上述类所需的更改如下所示:

SessionDataCrudRepository:

@Repository
public interface SessionDataCrudRepository extends CrudRepository<SessionData, String> {

   List<SessionData> findByUserId(String userId);
}

SessionData:

+import org.springframework.data.redis.core.index.Indexed;

@RedisHash("SessionData")
public class SessionData {

    @Id
    private String sessionId;
    @TimeToLive(unit = TimeUnit.MINUTES)
    private Long ttl;

+    @Indexed
+    private String userId;

    ...
}

@Indexed注解在 Spring Data 中触发了一个特殊的行为:该注解实际上告诉 Spring Data在实体上创建和维护另一个索引,以便我们可以根据给定userId查询SessionData. 

但是,二级索引和实体自动到期的组合使设置变得更加复杂。当引用的实体被删除时,Redis 不会自动更新二级索引,因此 Spring Data 需要处理这种情况。

然而,Spring Data 不会经常查询 Redis 的过期实体(键),这就是为什么 Spring Data 依赖于R Redis Keyspace Notifications for expiring keys所谓的 Phantom Copies(幻影副本)来失效过期键:

当到期时间设置为正值时,将运行相应的 EXPIRE 命令。除了保留原始副本外,Redis 中还保留了一个幻影副本,并设置为在原始副本之后 5 分钟过期。这样做是为了使 Repository 支持发布 RedisKeyExpiredEvent,只要一个键过期expiring key,就会在 Spring 的 ApplicationEventPublisher 中保存过期的值,即使原始值已经被删除。

下一段有一个小细节需要注意:

默认情况下,初始化应用程序时禁用expiring keys侦听器。可以在 @EnableRedisRepositories 或 RedisKeyValueAdapter 中调整启动模式,以使用应用程序或在第一次插入具有 TTL 的实体时启动侦听器。有关可能的值,请参阅 EnableKeyspaceEvents。

遗憾的是,当时我们还没有阅读到这点。这就是为什么我们体验到启用EXPIRE禁用的expiring keys侦听器以及不断增长的二级索引的效果的原因。长话短说:我们观察到越来越多的键和不断增长的内存使用量 - 直到达到 Redis 的内存限制。

检查 Redis 键可以很明显地找到配置错误的位置,最终启用键空间事件的注释@EnableRedisRepositories使我们修复了内存泄露。

我们还禁用了 的自动服务器配置notify-keyspace-events property,因为我们在服务器端启用了该设置:

import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

import static org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP;

@EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP, keyspaceNotificationsConfigParameter = "")
@SpringBootApplication
public class SessionManagementApplication {

   ...
}

我们还必须手动清理陈旧的数据,所以我们还要提一下,在处理大型数据集时,您应该总是更选择SCAN而不是KEYS。Netflix 的nf-data-explorer可能会有所帮助,如果您不喜欢使用本机redis-cli.

并发读取和写入期间缺少实体

随着内存使用量不断增长的问题得到解决,我们最终将新服务作为我们会话的主要来源。

当请求击中我们的安全链时,我们总是验证用户的会话是否有效。这些验证是在会话管理中的简单查找sessionId。通常,404 NOT FOUND会话管理的状态指示sessionId无效(未知)或会话已过期(并被 Redis 删除)。

除了使用新 API 的应用程序中的一些相关更改外,我们还观察到了另一种奇怪的行为:无法找到某些会话,尽管我们 100% 确定会话应该仍然有效(已知且未过期)。在会话查找失败后,大多数重试都成功了,所以我们知道数据没有丢失,只是无法找到。

我们无法主动重现错误行为,收集日志、指标和跟踪也没有起到作用。在此过程中,我们添加了缓存和其他解决方法,并进行了一些更改以改进整体行为,但我们实际上并未解决该问题。

如果您仔细阅读本文的第一部分,您可能还记得有关我们刷新ttl. 我们不仅刷新ttl,而且还刷新作为SessionData的一部分lastResponse时间戳:

@RedisHash("SessionData")
public class SessionData {

  @Id
  private String sessionId;
  @TimeToLive(unit = TimeUnit.MINUTES)
  private Long ttl;
  private LocalDateTime lastResponse;

  @Indexed
  private String userId;

  ...
}

因此,让我们更详细地了解有关会话管理的请求处理。用户发送一个请求,以及一个sessionId,表明他们已登录。我们使用它执行查找sessionId以验证用户的会话。如果会话被认为是有效的,则应用程序可以继续执行请求的操作。应用程序处理完请求后,安全链会定期更新会话,重置ttl和写入当前lastResponse时间戳。通常,用户执行多个请求——可能不是真正的人,而是在浏览器中运行的前端应用程序。该前端应用程序并不真正关心它发送新请求的频率,因此我们可以假设多个请求可能同时到达我们的后端。

正在验证多个请求。多个请求触发会话刷新以及SessionData的写操作.

我们仍然使用 Spring DataCrudRepository来读取和更新会话,使用以下代码:

读:
SessionDataCrudRepository repository;

public Optional<SessionDto> getSession(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);
  ...
  return session;
}

更新:
SessionDataCrudRepository repository;

public Optional<Long> refreshSessionTtl(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);

  AtomicLong updatedTtl = new AtomicLong();
  session.ifPresent(data -> {
    data.setLastResponse(LocalDateTime.now(clock).truncatedTo(SECONDS));
    data.setTtl(SESSION_TIMEOUT.toMinutes());

    SessionData saved = repository.save(data);
    updatedTtl.set(saved.getTtl());
  }
  return Optional.of(updatedTtl.longValue());
}

有时,repository.findById(...)没有产生任何东西,所以我们专注于那部分。不过,问题是由repository.save(...)电话引发的。经过几周的谷歌搜索并盯着日志和跟踪,我们发现了refreshSessionTtl和getSession调用之间的相关性。

互联网上的许多文章已经训练我们将 Redis 视为单线程服务,按顺序执行每个请求。谷歌搜索“spring data redis concurrent writes”,我们找到了stackoverflow和spring-projects/spring-data-redis/issues/1826中的问题,在那里描述甚至解释了我们的问题 - 以及修复.

长话短说:Spring Data 将更新实现为DEL和HMSET两个步骤时,没有任何事务保证。换句话说:通过 CrudRepositories 更新实体不提供原子性。我们的HGETALL请求有时恰好发生在DEL和之间HMSET,导致空结果,或者有时有结果,但结果为负ttl

我们的问题现在可以通过集成测试重现并使用PartialUpdate

所以上面的实现改为:

KeyValueOperations keyValueOperations;

public Optional<Long> refreshSessionTtl(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);

  AtomicLong updatedTtl = new AtomicLong(-3);
  session.ifPresent(data -> {
    PartialUpdate<SessionData> update = new PartialUpdate<>(data.getSessionId(), SessionData.class)
        .refreshTtl(true)
        .set("ttl", SESSION_TIMEOUT.toMinutes())
        .set("lastResponse", LocalDateTime.now(clock).truncatedTo(SECONDS));
    keyValueOperations.update(update);
    Optional<SessionData> saved = repository.findById(data.getSessionId());
    if (saved.isPresent()) {
      updatedTtl.set(saved.get().getTtl());
    }
  }
  return Optional.of(updatedTtl.longValue());
}

概括

过期键、二级索引和将所有魔法委托给 Spring Data Redis 的组合需要正确配置键空间事件侦听器。否则,由于幻影副本,您使用的内存会随着时间的推移而增长。考虑@EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP)在您的应用中使用类似的配置。

在并发读取和更新的环境,提防Spring Data的CrudRepository工具的更新的过程分为两个步骤DEL和HMSET。如果您观察到零星丢失的键或结果为负值TTL,则您可能遇到了并发问题。检查您的写入操作并考虑使用 PartialUpdate和 Spring Data 的RedisKeyValueTemplateupdate方法更新需要改变的属性


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK