8.缓存
本章将深入探讨 Shiro 的缓存架构,并对核心组件的源代码进行解析。
8.1 Shiro 为什么引入缓存机制
随着用户规模的不断扩大,认证、授权和加密等模块的调用次数会迅速增加。例如,当每秒有 100 万用户尝试登录系统时,认证模块每秒会被调用 100 万次。此时, CPU 和 Memory 都会飙升,性能问题将不可避免地浮现出来。
那么,如何在架构层面解决这些可能出现的性能瓶颈呢?最常见的解决方案就是引入缓存机制。有很多数据实际上并不需要在每次请求中都重新计算,我们可以将计算结果缓存起来,至少在一个特定的时间段以内,都可以直接从缓存中捞出数据,从而显著降低系统资源的消耗。
提升性能正是 Shiro 框架引入缓存机制的一个重要原因。
在 Shiro 中,缓存主要用于以下 3 个方面:
- 认证缓存:存储用户的认证信息,避免每次请求都需要重新认证。
- 授权缓存:存储用户的角色和权限信息,避免每次访问资源都去查询数据库获取权限。
- Session 缓存: 用来缓存会话信息。
注意:默认情况下,Shiro 并不会启用任何缓存,开发者需要在 ShiroConfig.java
中显式配置缓存管理器,指定 Shiro 应该使用哪种缓存组件。
@Configuration
public class ShiroConfig {
//...
@Bean
public EhCacheManager ehCacheManager(){
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:ehcache-shiro.xml");
return cacheManager;
}
//...
}
8.2 Shiro 的缓存架构
8.2.1 核心组件
Shiro 的缓存架构由 3 个核心接口组成: CachingRealm
、CacheManager
、和Cache
,这些类型之间的依赖关系如下图所示:
这些类型的名称都带有 Cache 或者 Caching 前缀:
- CachingRealm:Realm 的实现类,它会持有一个 CacheManager 的实例。
- CacheManager:缓存管理器,负责管理缓存组件的生命周期,它会持有具体缓存组件的实例。
- Cache:缓存组件本身需要实现的接口。
8.2.2 源码分析
我们先分析整体的运行机制,然后再逐步解析核心组件的源代码。
8.2.2.1 整体运行机制
如上图所示,CachingRealm
中持有了一个 CacheManager
类型的实例,而 CachingSecurityManager
类中也持有了一个 CacheManager
类型的实例。那么,这两个 CacheManager
类型的实例之间是什么关系呢?是同一个实例吗?
我们来逐步分析源代码,我们从入口类 ShiroConfig.java 开始,在创建具体的 SecurityManager
实例时,开发者可以指定具体使用哪一种 CacheManager
,示例代码如下:
@Bean
public EhCacheManager ehCacheManager(){
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:ehcache-shiro.xml");
return cacheManager;
}
@Bean
public SecurityManager securityManager(){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(nicefishRbacRealm());
securityManager.setRememberMeManager(rememberMeManager());
securityManager.setSessionManager(sessionManager());
**securityManager.setCacheManager(ehCacheManager());**
return securityManager;
}
//...
从以上代码可以看到,开发者只需要调用 securityManager.setCacheManager 方法设置缓存管理器就可以了,并不需要手动调用 CachingRealm
类型上定义的 setCacheManager 方法。那么,CachingRealm
类型上定义的 setCacheManager 方法是何时被自动调用的呢?我们再次回顾以下 SecurityManager
相关的继承结构:
在 RealmSecurityManager
这一层,我们可以看到如下代码:
public void setRealms(Collection<Realm> realms) {
if (realms == null) {
throw new IllegalArgumentException("Realms collection argument cannot be null.");
}
if (realms.isEmpty()) {
throw new IllegalArgumentException("Realms collection argument cannot be empty.");
}
this.realms = realms;
afterRealmsSet();
}
protected void afterRealmsSet() {
applyCacheManagerToRealms();
applyEventBusToRealms();
}
protected void applyCacheManagerToRealms() {
CacheManager cacheManager = getCacheManager();
Collection<Realm> realms = getRealms();
if (cacheManager != null && realms != null && !realms.isEmpty()) {
for (Realm realm : realms) {
if (realm instanceof CacheManagerAware) {
((CacheManagerAware) realm).setCacheManager(cacheManager);
}
}
}
}
关键的方法调用轨迹是: setRealms->afterRealmsSet->applyCacheManagerToRealms 。在 applyCacheManagerToRealms 中,如果 Shiro 发现某个 Realm 的实例实现了 CacheManagerAware 接口,就会自动把 cacheManager 实例设置给它。
// CachingRealm 的构造方法
public abstract class CachingRealm implements Realm, Nameable, **CacheManagerAware** , LogoutAware
如上所示,由于 CachingRealm 实现了 CacheManagerAware 接口,所以在运行时 CachingRealm 和 RealmSecurityManager 上的 cacheManager 是同一个实例。这就意味着,开发者在配置缓存管理器时,应该调用 securityManager 对象上的 setCacheManager 方法,而不是调用 Realm 实例上的同名方法,否则在运行时 cacheManager 实例会被覆盖。
8.2.2.2 CacheManager 源码分析
CacheManager
是 Shiro 缓存系统的核心接口,它负责管理缓存组件的生命周期,以下是 CacheManager
相关的继承结构图:
Destroyable
接口:定义了destroy()
方法,用于在缓存管理器销毁时清理资源,确保缓存的数据和资源得以正确释放。CacheManager
接口:提供获取缓存的核心方法getCache(String name)
,是所有缓存管理器的顶层接口。通过实现CacheManager
接口,开发者可以自定义缓存管理器以适应不同的缓存需求。AbstractCacheManager
:作为抽象基类,它为CacheManager
提供了createCache(String name)
的基础实现,并实现了destroy()
方法。这意味着它具备缓存管理器的基础功能,可以销毁缓存,同时允许子类根据需要创建特定类型的缓存。MemoryConstrainedCacheManager
:Shiro 提供的轻量级缓存管理器,继承自AbstractCacheManager
。它将所有缓存数据保存在 JVM 内存中,适合小型应用或资源有限的环境。由于其缓存是基于内存的,一旦 JVM 重启,缓存数据将会丢失。这使得该缓存管理器在处理敏感数据时的持久化能力较差,因此主要适用于对数据持久性要求不高的场景。EhCacheManager
:此缓存管理器继承自AbstractCacheManager
,并实现了CacheManager
和Destroyable
接口。EhCacheManager
集成了开源的 EhCache 框架。EhCache 支持磁盘持久化、多级缓存(内存+磁盘缓存)、集群部署等功能,适用于中大型应用。通过cacheManagerConfigFile
配置文件,EhCacheManager 可以对缓存进行自定义配置,确保在高并发情况下提供更好的性能和可扩展性。
8.2.2.3 AbstractCacheManager 源码解析
AbstractCacheManager 是抽象基类,它实现了缓存管理的基本功能,包括:
- 缓存的惰性创建:只有在第一次访问某个缓存时,才会创建该缓存。
- 线程安全管理:通过使用 ConcurrentHashMap 确保缓存管理器在并发环境中的安全性。
- 缓存销毁机制:提供 destroy() 方法,用于清理资源并销毁所有缓存。
AbstractCacheManager 的源代码非常简单,去掉注释之后只有几十行。其中,createCache(String name) 是一个关键的抽象方法,具体的缓存创建逻辑由子类实现,例如 MemoryConstrainedCacheManager 和 EhCacheManager 会分别实现该方法,以创建特定类型的缓存实例。
8.2.2.4 MemoryConstrainedCacheManager 源码解析
MemoryConstrainedCacheManager
是基于内存的缓存管理器,它的源码非常简单,源代码全文引用如下:
package org.apache.shiro.cache;
import org.apache.shiro.util.SoftHashMap;
public class MemoryConstrainedCacheManager extends AbstractCacheManager {
@Override
protected Cache createCache(String name) {
//注意,这里创建的缓存实例类型是 MapCache
return new MapCache<Object, Object>(name, new SoftHashMap<Object, Object>());
}
}
从代码中我们可以看到,这个管理器持有了一个 MapCache 的实例,具体的数据就存储在这个实例里面。 MapCache 是 Shiro 自己实现的一个简单缓存,基于 JDK 内置的 Map 实现,我们在 8.2.3 中分析它的源代码。
MemoryConstrained 这个单词的字面意思是“内存受限” ,所以 MemoryConstrainedCacheManager
这个类名已经暗示了它的适用场景:
- 内存受限的环境:适合内存有限的应用场景,比如嵌入式系统、移动设备或需要在服务器端进行精确内存控制的应用。
- 简单缓存管理:不需要外部依赖(如 Redis)的简单缓存场景,能够快速使用内存缓存。
8.2.2.5 EhCacheManager 源码解析
EhCacheManager 的实现同样非常简单,就如同它的名字一样,主要负责创建并管理 EhCache 组件的实例,与此相关的代码在 EhCacheManager.ensureCacheManager 方法中:
private net.sf.ehcache.CacheManager ensureCacheManager() {
try {
if (this.manager == null) {
if (log.isDebugEnabled()) {
log.debug("cacheManager property not set. Constructing CacheManager instance... ");
}
//using the CacheManager constructor, the resulting instance is _not_ a VM singleton
//(as would be the case by calling CacheManager.getInstance(). We do not use the getInstance here
//because we need to know if we need to destroy the CacheManager instance - using the static call,
//we don't know which component is responsible for shutting it down. By using a single EhCacheManager,
//it will always know to shut down the instance if it was responsible for creating it.
//注意这里, new 出了 Ehcache 的实例。
this.manager = new net.sf.ehcache.CacheManager(getCacheManagerConfigFileInputStream());
if (log.isTraceEnabled()) {
log.trace("instantiated Ehcache CacheManager instance.");
}
cacheManagerImplicitlyCreated = true;
if (log.isDebugEnabled()) {
log.debug("implicit cacheManager created successfully.");
}
}
return this.manager;
} catch (Exception e) {
throw new CacheException(e);
}
}
Shiro 提供了一个独立的 jar 包用来封装对 Ehcache 的支持,包名为 shiro-ehcache.jar ,这个包非常小,里面只有 2 个类和一个默认的 ehcache.xml 配置文件:
EhCache 是一个轻量级、快速且功能强大的开源 Java 缓存框架,广泛应用于提高 Java 应用程序性能。它支持内存和磁盘存储、多种缓存失效策略和分布式缓存,非常适合需要频繁访问数据的高性能场景。 EhCache 的主要特性如下:
- 多级缓存:支持在内存和磁盘中存储缓存,内存满时可以自动将数据写入磁盘。
- 缓存策略:支持多种缓存失效策略,如最少使用 (LFU)、最近最少使用 (LRU)、FIFO 等。
- 分布式缓存:可以与 Terracotta 等分布式缓存框架集成,实现多节点共享缓存数据。
- 数据持久性:可以选择在应用重启后缓存数据是否保留。
- 可配置性:通过 XML 或 Java API 配置缓存的大小、存储方式、失效时间等。
EhCache 的官方网站是 https://www.ehcache.org/。
在该网站上,你可以找到有关 EhCache 的最新版本、文档、配置指南、使用示例等资源。
8.2.2.6 对接 Redis 缓存
当前,在分布式系统中,架构师一般会选择 Redis 作为缓存组件,但是,Shiro 并没有直接提供一个开箱即用的 RedisCacheManager (原因简单,因为当年 Redis 还没有出现。)。开发者可以自己实现 CacheManager 接口,也可以选择开源的实现,例如 shiro-redis ,它的主页在:https://github.com/alexxiyang/shiro-redis 。
8.2.3 Cache 源码分析
接下来我们分析缓存本身的实现,在 Shiro 中,Cache
接口相关的类继承结构如下图所示:
MapCache
是 Shiro 自己提供的一个非常简单的缓存实现类,它的内部实际上使用了一个 Map<K,V>
结构来存储数据,以下是MapCache
的关键源代码:
public class MapCache<K, V> implements Cache<K, V> {
private final Map<K, V> map;
//缓存的名称
private final String name;
//构造方法要求外部传递一个具体的 Map 实例进来, Shiro 默认使用自己实现的 SoftHashMap 类。
public MapCache(String name, Map<K, V> backingMap) {
if (name == null) {
throw new IllegalArgumentException("Cache name cannot be null.");
}
if (backingMap == null) {
throw new IllegalArgumentException("Backing map cannot be null.");
}
this.name = name;
this.map = backingMap;
}
//...
}
Shiro 自己实现了一个 SoftHashMap
来承担存储任务,这个类位于 shiro-core-xxx.jar 包中。SoftHashMap
基于软引用的哈希映射类实现,可以在内存不足时能够自动回收不再使用的缓存内容。以下是其核心功能:
- 软引用存储:使用
SoftReference
来存储值对象,这样在内存压力大的情况下,JVM 可以自动回收这些值,避免内存溢出。 - 强引用管理:维护一个强引用队列,允许开发者控制保留的强引用数量,以平衡内存使用和缓存命中率。
- 自动清理:在访问缓存时,会自动处理和清理已被回收的软引用,保持映射的有效性。
- 线程安全:使用
ConcurrentHashMap
和ReentrantLock
确保在多线程环境下的安全性和一致性。 - 接口实现:实现了
Map
接口,提供标准的 Map 操作(如put
、get
、remove
等)并支持批量操作。
也就是说,如果开发者指定 Shiro 使用 MemoryConstrainedCacheManager
作为缓存管理器,那么在运行时,最底层承担存储任务的是 SoftHashMap
类的实例。
8.2.4 CachingRealm 源码分析
由于 Realm
相关的继承结构比较深,我们需要再次回顾一下结构图:
CachingRealm
是 Realm
的实现类,它的类名带有 Caching 前缀,很明显它最大的特点就是带有缓存功能。由于 CachingRealm
在整个继承结构中位置非常高,所以在 Shiro 中,所有 Realm 都具备缓存功能,除非开发者自己编写一个全新的实现类直接实现最顶层的 Realm
接口。但是这种情况非常少见,因为 Shiro 在每一层 Realm 上都已经实现了很多功能,如果自己从头实现 Realm
接口,需要编写的代码太多了。
如上图所示,CachingRealm
的功能非常简单,实际上它自己几乎没有实现任何功能,把所有具体工作都交给内部的 cacheManager 对象去处理了:
public abstract class CachingRealm implements Realm, Nameable, CacheManagerAware, LogoutAware {
//...
private String name;
private boolean cachingEnabled;
private CacheManager cacheManager; //实际上是 cacheManager 在做事
//...
}
8.3 Session 缓存
在 Shiro 中,Session
默认并不会自动走缓存,但 Shiro 设计了缓存会话的机制。
8.3.1 启用 Session 缓存
Shiro 的 Session
默认存储在内存中,如果没有明确配置缓存,Shiro 不会自动缓存 Session
数据。
如果希望将会话信息缓存起来,可以配只 CacheManager
配置项,通常会使用开源的 EhCacheManager
或者RedisCacheManager
,以下是使用EhCacheManager
作为 Session 缓存的关键代码(已省略无关代码):
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:shiro-ehcache.xml");
CachingSessionDAO sessionDAO = new EnterpriseCacheSessionDAO();
sessionDAO.setCacheManager(cacheManager); // 配置 cacheManager
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(sessionDAO);
DefaultSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setSessionManager(sessionManager);
SecurityUtils.setSecurityManager(securityManager);
8.3.2 CachingSessionDAO 源码分析
如上图所示, Shiro 内置的抽象类 CachingSessionDAO
支持缓存机制,EnterpriseCacheSessionDAO
是 CachingSessionDAO
的子类,在 CachingSessionDAO
中,最关键的 4 个方法代码如下:
public Serializable create(Session session) {
Serializable sessionId = super.create(session);
cache(session, sessionId);
return sessionId;
}
public Session readSession(Serializable sessionId) throws UnknownSessionException {
Session s = getCachedSession(sessionId);
if (s == null) {
s = super.readSession(sessionId);
}
return s;
}
public void update(Session session) throws UnknownSessionException {
doUpdate(session);
if (session instanceof ValidatingSession) {
if (((ValidatingSession) session).isValid()) {
cache(session, session.getId());
} else {
uncache(session);
}
} else {
cache(session, session.getId());
}
}
public void delete(Session session) {
uncache(session);
doDelete(session);
}
- create: 先写持久层,然后再写缓存。
- readSession: 先尝试从缓存中读取 Session ,如果结果为 null ,调用父层的 readSession 去访问持久层。
- update: 先更新持久层,然后更新缓存。
- delete: 先删缓存中的数据,然后再删持久层数据。
从以上代码我们可以看到,CachingSessionDAO
对缓存的读写策略都非常简单,比如 readSession 方法,采用的是 Read-Through(读透)策略:如果没有能够从缓存中读取到数据,直接访问持久层,很容易形成系统瓶颈。
8.3.3 注意
在启用了 Session 缓存之后,系统的复杂度会显著增加。这是因为缓存中的数据与数据库中的数据可能会存在不一致的情况,容易导致潜在的逻辑错误。因此,开发者在设计系统时,需要谨慎处理 Session 缓存,以确保数据的一致性。
Shiro 内置的 CachingSessionDAO
提供了一种简单的实现方式,方便开发者快速集成缓存功能。然而, Shiro 毕竟是一个安全框架,并不是专业的缓存框架,开发者在面对更复杂的业务需求时,可能需要设计自己的缓存 DAO。这种自定义的缓存 DAO 可以提供对 Session 的缓存进行更细粒度的控制,从而优化系统性能,减少对数据库的压力,并提高响应速度。
在设计自己的缓存 DAO 时,开发者可以考虑以下几个方面:
- 缓存策略选择:根据业务场景选择合适的缓存策略,例如读透、写穿或写后失效等,以确保在性能和一致性之间找到平衡。
- 过期与失效管理:设置合理的缓存过期时间,以避免缓存中存储过期数据。同时,设计手动失效机制,以确保重要数据的实时更新。
- 监控与调优:通过监控缓存的使用情况,分析命中率和访问模式,从而不断调整和优化缓存策略,确保系统的高效运行。
在分布式系统中,推荐使用 Redis 作为 Session 的缓存解决方案。Redis 具有高性能、支持持久化和跨模块、跨系统共享数据的能力,能够有效地管理会话数据。在这种架构下,各个模块和系统之间可以复用会话信息,提升用户体验并简化系统管理。
综上所述,虽然 Session 缓存可以带来性能提升,但也引入了额外的复杂性。开发者应仔细权衡利弊,并在必要时实施更灵活和高效的缓存管理策略,以实现更高效、可靠的系统架构。
8.4 本章小结
为了提升系统的性能,Shiro 内置了对缓存的支持。特别是在频繁的权限验证过程中,缓存的引入能极大减少系统的负载。本章详细解析了 Shiro 的缓存架构,并解析了如何与外部缓存组件进行对接。
资源链接
- Apache Shiro 在 github 上的官方仓库:https://github.com/apache/shiro
- Apache Shiro 官方网站:https://shiro.apache.org/
- 本书实例项目:https://gitee.com/mumu-osc/nicefish-spring-boot
- 本书文字稿:https://gitee.com/mumu-osc/apache-shiro-source-code-explaination
版权声明
本书基于 CC BY-NC-ND 4.0 许可协议发布,自由转载-非商用-非衍生-保持署名。
版权归大漠穷秋所有 © 2024 ,侵权必究。