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

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

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

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

相关推荐

开发者必看的八大Material Design开源项目

MaterialDesign是介于拟物和扁平之间的一种设计风格,自从它发布以来,便引起了很多开发者的关注,在这里小编介绍在Android开发者当中里最受青睐的八个MaterialDesign开源项...

另类插这么可爱,一定是…(另类t恤)

IT之家(www.ithome.com):另类插图:这么可爱,一定是…OSXMavericks和Yosemite打破了苹果对Mac操作系统传统的命名方式,使用加州的某些标志性景点来替换猫...

Android常用ADB命令(安卓adb工具是什么)

杀死应用①根据包名获取APP的PIDadbshellps|grep应用包名②执行kill命令...

微软Mac版PowerPoint测试Reading Order Pane功能

IT之家5月20日消息,微软公司昨日(5月19日)发布博文,邀请Microsoft365Insiders成员,测试macOS新版PowerPoint演示文稿应用,重点引入...

Visual Studio跨平台开发实战(4):Xamarin Android控制项介绍

前言不同于iOS,Xamarin在VisualStudio中针对Android,可以直接设计使用者界面.在本篇教学文章中,笔者会针对Android的专案目录结构以及基本控制项进行介绍,包...

用云存储30分钟快速搭建APP,你信吗?

背景不管你承认与否,移动互联的时代已经到来,这是一个移动互联的时代,手机已经是当今世界上引领潮流的趋势,大型的全球化企业和中小企业都把APP程序开发纳入到他们的企业发展策略当中。但随着手机APP上传的...

谷歌P图神器来了!不用学不用教,输入一句话,分分钟给结果

Pine发自凹非寺量子位|公众号QbitAI当你拍照片时,“模特不好好配合”怎么办?...

iOS文本编辑控件UITextField和UITextVie

记录一个菜鸟的IOS学习之旅,如能帮助正在学习的你,亦枫不胜荣幸;如路过的大神如指教几句,亦枫感激涕淋!细心的朋友可能已经注意到了,IOS学习之旅系列教程在本篇公众号的文章中,封面已经换成美女图片了,...

Android入门图文教程集锦(android 入门教程)

Android入门视频教程集锦AndroidStudio错误gradientandroid:endXattributenotfound...

如何使用Android自定义复合视图(如何使用android自定义复合视图)

在最近的一个客户应用中,我遇到了一个需求,根据选定的值来生成指定数量的编辑框字段,这样用户可以输入人物信息。最初我的想法是把这些逻辑放到Fragment中,只是根据选中值的变化来向线性布局容器中增加编...

原生安卓开发app的框架frida常用关键代码定位

前言有时候可能会对APP进行字符串加密等操作,这样的话你的变量名等一些都被混淆了,看代码就可能无从下手...

教程10 | 三分钟搞定一个智能输入法程序

一案例描述1、考核知识点网格布局线性布局样式和主题Toast2、练习目标掌握网格布局的使用掌握Toast的使用掌握线性布局的使用...

(Android 8.1) 功能与新特性(android的功能)

和你一起终身学习,这里是程序员AndroidAndroid8.1(API级别27)为用户和开发人员引入了各种新特性和功能。本文档重点介绍了开发人员的新功能。通过本章阅读,您将获取到以下内容:Andr...

怎样设置EditText内部文字被锁定不可删除和修改

在做项目的时候,我曾经遇到过这样的要求,就是跟百度贴吧客户端上的一样,在回复帖子的时候,在EditText中显示回复人的名字,而且这个名字不可以修改和删除,说白了就是不可操作,只能在后面输入内容。在E...

如何阻止 Android 活动启动时 EditText 获得焦点

技术背景在Android开发中,当活动启动时,EditText有时会自动获得焦点并弹出虚拟键盘,这可能不是用户期望的行为。为了提升用户体验,我们需要阻止...