我们最近将会话管理从MongoDB迁移到了Redis。迁移本身是由我们使用MongoDB的经验推动的,它不能特别好地处理高频率更新和更频繁地读取。另一方面,Redis被称为经过验证的存储,可以准确处理该用例。 数据库迁移并不总是那么容易,因为我们需要学习其他服务的新模式、最佳实践和怪癖。我们的目标是让我们的Java服务层尽可能简单,使其稳定且面向未来:会话管理当然是具有相当稳定功能集的服务之一,并且不会经常触及其代码。因此,对于几年后窥探它的任何人来说,保持它的简单易懂是一个重要方面。 我们面临两个问题:SpringData实现二级索引的概念以及失效问题,这些导致Redis内存使用量不断增长。Redis的原子性范围和SpringData的更新机制 本文总结了我们在使用SpringData作为持久层的瘦Java服务中采用Redis的经验。 带有二级索引和EXPIRETTL的SpringDataRedis 在Redis中采用SpringData可直接开始:您需要的只是Gradle或Maven构建的依赖项以及EnableRedisRepositoriesSpringBoot应用程序中的注释。SpringBoot的大多数默认设置都是有意义的,并且可以让您非常顺利地运行Redis实例。 但是会遭遇:Redis内存使用量不断增长的问题,下面看看这个认识过程: 不需要通用存储库的实际实现,因为SpringData允许您interface在运行时声明一个简单的通向通用实例。我们的存储库是这样开始的:bimportborg。springframework。data。repository。CrudRepository;bimportborg。springframework。stereotype。Repository;RepositorybpublicbbinterfacebSessionDataCrudRepositorybextendsbCrudRepositorySessionData,String{} 我们由该存储库管理的实体也开始变得尽可能简单:bimportborg。springframework。data。annotation。Id;bimportborg。springframework。data。redis。core。RedisHash;bimportborg。springframework。data。redis。core。TimeToLive;bimportbjava。util。concurrent。TimeUnit;RedisHash(fontSessionDatafontfont)bpublicbbclassbSessionData{IdbprivatebStringsessionId;TimeToLive(unitTimeUnit。MINUTES)bprivatebLongttl;。。。}font 您会注意到我们选择对ttl属性建模,该属性被TimeToLive转换为EXPIRE实体。我们不想手动跟踪过期会话,但希望Redis透明地删除过期会话。该ttl会定期刷新用户活动期间,如果手工删除,可能会被注销。 当用户实际按下注销按钮时会发生什么,或者我们如何禁用用户帐户并使正在运行的会话无效?简单:我们也有一个userId作为会话数据SessionData的一部分,并且可以执行以userId查询查找每个会话。上述类型所需的更改如下所示: SessionDataCrudRepository:RepositorybpublicbbinterfacebSessionDataCrudRepositorybextendsbCrudRepositorySessionData,String{ListSessionDatafindByUserId(StringuserId);} SessionData:bimportborg。springframework。data。redis。core。index。Indexed;RedisHash(fontSessionDatafontfont)bpublicbbclassbSessionData{IdbprivatebStringsessionId;TimeToLive(unitTimeUnit。MINUTES)bprivatebLongttl;IndexedbprivatebStringuserId;。。。}font Indexed注解在SpringData中触发了一个特殊的行为:该注解实际上告诉SpringData在实体上创建和维护另一个索引,以便我们可以根据给定userId查询SessionData。 但是,二级索引和实体自动到期的组合使设置变得更加复杂。当引用的实体被删除时,Redis不会自动更新二级索引,因此SpringData需要处理这种情况。 然而,SpringData不会经常查询Redis的过期实体(键),这就是为什么SpringData依赖于RRedisKeyspaceNotificationsforexpiringkeys所谓的PhantomCopies(幻影副本)来失效过期键: 当到期时间设置为正值时,将运行相应的EXPIRE命令。除了保留原始副本外,Redis中还保留了一个幻影副本,并设置为在原始副本之后5分钟过期。这样做是为了使Repository支持发布RedisKeyExpiredEvent,只要一个键过期expiringkey,就会在Spring的ApplicationEventPublisher中间保存过期的值,即使原始值已经被删除。 下一段有一个小细节需要注意: 默认情况下,初始化应用程序时禁用expiringkeys侦听器。可以在EnableRedisRepositories或RedisKeyValueAdapter中调整启动模式,以使用应用程序或在第一次插入具有TTL的实体时启动侦听器。有关可能的值,请参阅EnableKeyspaceEvents。 遗憾的是,当时我们还没有阅读到这点。这就是为什么我们体验到启用EXPIRE禁用的expiringkeys侦听器以及不断增长的二级索引的效果的原因。长话短说:我们观察到越来越多的键和不断增长的内存使用量直到达到Redis的内存限制。 检查Redis键可以很明显地找到配置错误的位置,最终启用键空间事件的注释EnableRedisRepositories使我们修复了内存泄露。 我们还禁用了的自动服务器配置notifykeyspaceeventsproperty,因为我们在服务器端启用了该设置:bimportborg。springframework。data。redis。repository。configuration。EnableRedisRepositories;bimportbbstaticborg。springframework。data。redis。core。RedisKeyValueAdapter。EnableKeyspaceEvents。ONSTARTUP;EnableRedisRepositories(enableKeyspaceEventsONSTARTUP,keyspaceNotificationsConfigParameterfontfontfont)SpringBootApplicationbpublicbbclassbSessionManagementApplication{。。。}font 我们还必须手动清理陈旧的数据,所以我们还要提一下,在处理大型数据集时,您应该总是更选择SCAN而不是KEYS。Netflix的nfdataexplorer可能会有所帮助,如果您不喜欢使用本机rediscli。 并发读取和写入期间缺少实体 随着内存使用量不断增长的问题得到解决,我们最终将新服务作为我们会话的主要来源。 当请求击中我们的安全链时,我们总是验证用户的会话是否有效。这些验证是在会话管理中的简单查找sessionId。通常,404NOTFOUND会话管理的状态指示sessionId无效(未知)或会话已过期(并被Redis删除)。 除了使用新API的应用程序中的一些相关更改外,我们还观察到了另一种奇怪的行为:无法找到某些会话,尽管我们100确定会话应该仍然有效(已知且未过期)。在会话查找失败后,大多数重试都成功了,所以我们知道数据没有丢失,只是无法找到。 我们无法主动重现错误行为,收集日志、指标和跟踪也没有起到作用。在此过程中,我们添加了缓存和其他解决方法,并进行了一些更改以改进整体行为,但我们实际上并未解决该问题。 如果您仔细阅读本文的第一部分,您可能还记得有关我们刷新ttl。我们不仅刷新ttl,而且还刷新作为SessionData的一部分lastResponse时间戳:RedisHash(fontSessionDatafontfont)bpublicbbclassbSessionData{IdbprivatebStringsessionId;TimeToLive(unitTimeUnit。MINUTES)bprivatebLongttl;bprivatebLocalDateTimelastResponse;IndexedbprivatebStringuserId;。。。}font 因此,让我们更详细地了解有关会话管理的请求处理。用户发送一个请求,以及一个sessionId,表明他们已登录。我们使用它执行查找sessionId以验证用户的会话。如果会话被认为是有效的,则应用程序可以继续执行请求的操作。应用程序处理完请求后,安全链会定期更新会话,重置ttl和写入当前lastResponse时间戳。通常,用户执行多个请求可能不是真正的人,而是在浏览器中运行的前端应用程序。该前端应用程序并不真正关心它发送新请求的频率,因此我们可以假设多个请求可能同时到达我们的后端。 正在验证多个请求。多个请求触发会话刷新以及SessionData的写操作。 我们仍然使用SpringDataCrudRepository来读取和更新会话,使用以下代码:读:SessionDataCrudRepositoryrepository;bpublicbOptionalSessionDtogetSession(StringsessionId){OptionalSessionDatasessionrepository。findById(sessionId);。。。breturnbsession;}更新:SessionDataCrudRepositoryrepository;bpublicbOptionalLongrefreshSessionTtl(StringsessionId){OptionalSessionDatasessionrepository。findById(sessionId);AtomicLongupdatedTtlbnewbAtomicLong();session。ifPresent(data{data。setLastResponse(LocalDateTime。now(clock)。truncatedTo(SECONDS));data。setTtl(SESSIONTIMEOUT。toMinutes());SessionDatasavedrepository。save(data);updatedTtl。set(saved。getTtl());}breturnbOptional。of(updatedTtl。longValue());} 有时,repository。findById(。。。)没有产生任何东西,所以我们专注于那部分。不过,问题是由repository。save(。。。)电话引发的。经过几周的谷歌搜索并盯着日志和跟踪,我们发现了refreshSessionTtl和getSession调用之间的相关性。 互联网上的许多文章已经训练我们将Redis视为单线程服务,按顺序执行每个请求。谷歌搜索springdataredisconcurrentwrites,我们找到了stackoverflow和springprojectsspringdataredisissues1826中的问题,在那里描述甚至解释了我们的问题以及修复。 长话短说:SpringData将更新实现为DEL和HMSET两个步骤时,没有任何事务保证。换句话说:通过CrudRepositories更新实体不提供原子性。我们的HGETALL请求有时恰好发生在DEL和之间HMSET,导致空结果,或者有时有结果,但结果为负ttl。 我们的问题现在可以通过集成测试重现并使用PartialUpdate。 所以上面的实现改为:KeyValueOperationskeyValueOperations;bpublicbOptionalLongrefreshSessionTtl(StringsessionId){OptionalSessionDatasessionrepository。findById(sessionId);AtomicLongupdatedTtlbnewbAtomicLong(3);session。ifPresent(data{PartialUpdateSessionDataupdatebnewbPartialUpdate(data。getSessionId(),SessionData。bclassb)。refreshTtl(btrueb)。set(fontttlfontfont,SESSIONTIMEOUT。toMinutes())。set(fontfontlastResponsefontfont,LocalDateTime。now(clock)。truncatedTo(SECONDS));keyValueOperations。update(update);OptionalSessionDatasavedrepository。findById(data。getSessionId());bifb(saved。isPresent()){updatedTtl。set(saved。get()。getTtl());}}breturnbOptional。of(updatedTtl。longValue());}font 概括 过期键、二级索引和将所有魔法委托给SpringDataRedis的组合需要正确配置键空间事件侦听器。否则,由于幻影副本,您使用的内存会随着时间的推移而增长。考虑EnableRedisRepositories(enableKeyspaceEventsONSTARTUP)在您的应用中使用类似的配置。 在并发读取和更新的环境,提防SpringData的CrudRepository工具的更新的过程分为两个步骤DEL和HMSET。如果您观察到零星丢失的键或结果为负值TTL,则您可能遇到了并发问题。检查您的写入操作并考虑使用PartialUpdate和SpringData的RedisKeyValueTemplateupdate方法更新需要改变的属性。