百度360必应搜狗淘宝本站头条
当前位置:网站首页 > IT技术 > 正文

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

wptr33 2025-06-10 18:37 14 浏览

我们最近将会话管理从 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在运行时声明一个简单的通向通用实例。我们的存储库是这样开始的:

<b>import</b> org.springframework.data.repository.CrudRepository;
<b>import</b> org.springframework.stereotype.Repository;

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

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

<b>import</b> org.springframework.data.annotation.Id;
<b>import</b> org.springframework.data.redis.core.RedisHash;
<b>import</b> org.springframework.data.redis.core.TimeToLive;

<b>import</b> java.util.concurrent.TimeUnit;

@RedisHash(<font>"SessionData"</font><font>)
<b>public</b> <b>class</b> SessionData {

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

  ...
}
</font>

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

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

SessionDataCrudRepository:

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

   List<SessionData> findByUserId(String userId);
}

SessionData:

+<b>import</b> org.springframework.data.redis.core.index.Indexed;

@RedisHash(<font>"SessionData"</font><font>)
<b>public</b> <b>class</b> SessionData {

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

+    @Indexed
+    <b>private</b> String userId;

    ...
}
</font>

@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,因为我们在服务器端启用了该设置:

<b>import</b> org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

<b>import</b> <b>static</b> org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP;

@EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP, keyspaceNotificationsConfigParameter = <font>""</font><font>)
@SpringBootApplication
<b>public</b> <b>class</b> SessionManagementApplication {

   ...
}
</font>

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

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

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

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

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

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

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

@RedisHash(<font>"SessionData"</font><font>)
<b>public</b> <b>class</b> SessionData {

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

  @Indexed
  <b>private</b> String userId;

  ...
}
</font>

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

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

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

读:
SessionDataCrudRepository repository;

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

更新:
SessionDataCrudRepository repository;

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

  AtomicLong updatedTtl = <b>new</b> 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());
  }
  <b>return</b> 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;

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

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

概括

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

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

相关推荐

MySQL进阶五之自动读写分离mysql-proxy

自动读写分离目前,大量现网用户的业务场景中存在读多写少、业务负载无法预测等情况,在有大量读请求的应用场景下,单个实例可能无法承受读取压力,甚至会对业务产生影响。为了实现读取能力的弹性扩展,分担数据库压...

Postgres vs MySQL_vs2022连接mysql数据库

...

3分钟短文 | Laravel SQL筛选两个日期之间的记录,怎么写?

引言今天说一个细分的需求,在模型中,或者使用laravel提供的EloquentORM功能,构造查询语句时,返回位于两个指定的日期之间的条目。应该怎么写?本文通过几个例子,为大家梳理一下。学习时...

一文由浅入深带你完全掌握MySQL的锁机制原理与应用

本文将跟大家聊聊InnoDB的锁。本文比较长,包括一条SQL是如何加锁的,一些加锁规则、如何分析和解决死锁问题等内容,建议耐心读完,肯定对大家有帮助的。为什么需要加锁呢?...

验证Mysql中联合索引的最左匹配原则

后端面试中一定是必问mysql的,在以往的面试中好几个面试官都反馈我Mysql基础不行,今天来着重复习一下自己的弱点知识。在Mysql调优中索引优化又是非常重要的方法,不管公司的大小只要后端项目中用到...

MySQL索引解析(联合索引/最左前缀/覆盖索引/索引下推)

目录1.索引基础...

你会看 MySQL 的执行计划(EXPLAIN)吗?

SQL执行太慢怎么办?我们通常会使用EXPLAIN命令来查看SQL的执行计划,然后根据执行计划找出问题所在并进行优化。用法简介...

MySQL 从入门到精通(四)之索引结构

索引概述索引(index),是帮助MySQL高效获取数据的数据结构(有序),在数据之外,数据库系统还维护者满足特定查询算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构...

mysql总结——面试中最常问到的知识点

mysql作为开源数据库中的榜一大哥,一直是面试官们考察的重中之重。今天,我们来总结一下mysql的知识点,供大家复习参照,看完这些知识点,再加上一些边角细节,基本上能够应付大多mysql相关面试了(...

mysql总结——面试中最常问到的知识点(2)

首先我们回顾一下上篇内容,主要复习了索引,事务,锁,以及SQL优化的工具。本篇文章接着写后面的内容。性能优化索引优化,SQL中索引的相关优化主要有以下几个方面:最好是全匹配。如果是联合索引的话,遵循最...

MySQL基础全知全解!超详细无废话!轻松上手~

本期内容提醒:全篇2300+字,篇幅较长,可搭配饭菜一同“食”用,全篇无废话(除了这句),干货满满,可收藏供后期反复观看。注:MySQL中语法不区分大小写,本篇中...

深入剖析 MySQL 中的锁机制原理_mysql 锁详解

在互联网软件开发领域,MySQL作为一款广泛应用的关系型数据库管理系统,其锁机制在保障数据一致性和实现并发控制方面扮演着举足轻重的角色。对于互联网软件开发人员而言,深入理解MySQL的锁机制原理...

Java 与 MySQL 性能优化:MySQL分区表设计与性能优化全解析

引言在数据库管理领域,随着数据量的不断增长,如何高效地管理和操作数据成为了一个关键问题。MySQL分区表作为一种有效的数据管理技术,能够将大型表划分为多个更小、更易管理的分区,从而提升数据库的性能和可...

MySQL基础篇:DQL数据查询操作_mysql 查

一、基础查询DQL基础查询语法SELECT字段列表FROM表名列表WHERE条件列表GROUPBY分组字段列表HAVING分组后条件列表ORDERBY排序字段列表LIMIT...

MySql:索引的基本使用_mysql索引的使用和原理

一、索引基础概念1.什么是索引?索引是数据库表的特殊数据结构(通常是B+树),用于...