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


我们最近将会话管理从 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方法更新需要改变的属性