image.png本地缓存

系统使用本地缓存,提升公用逻辑的执行效率。例如说:

  • 租户模块module/system/service/tenant/TenantServiceImpl.java缓存租户信息,每次 RESTful API 校验租户是否禁用、过期时,无需读库。
  • 部门模块module/system/service/dept/DeptServiceImpl.java缓存部门信息,每次数据权限校验时,无需读库。
  • 权限模块module/system/service/permission/PermissionServiceImpl.java缓存权限信息,每次功能权限校验时,无需读库。

考虑到本地缓存的实时刷新,并未采用 Spring Cache 框架,而是使用 Map 缓存数据,通过 Redis Pub/Sub 实时刷新,通过 Job 兜底定时刷新。整体流程如下:
image.png
以 角色模块module/system/service/permission/RoleServiceImpl.java为例,讲解如何实现角色信息的本地缓存。

1. 初始化缓存

① 在 module/system/service/permission/RoleService.java接口中,定义 #initLocalCache() 方法。代码如下:

  1. // RoleService.java
  2. /**
  3. * 初始化角色的本地缓存
  4. */
  5. void initLocalCache();

为什么要定义接口方法?
稍后实时刷新缓存时,会调用 RoleService 接口的该方法。
② 在 RoleServiceImpl类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。代码如下:

  1. // RoleServiceImpl.java
  2. /**
  3. * 角色缓存
  4. * key:角色编号 {@link RoleDO#getId()}
  5. *
  6. * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向
  7. */
  8. @Getter
  9. private volatile Map<Long, RoleDO> roleCache;
  10. /**
  11. * 缓存角色的最大更新时间,用于后续的增量轮询,判断是否有更新
  12. */
  13. @Getter
  14. private volatile Date maxUpdateTime;
  15. /**
  16. * 初始化 {@link #roleCache} 缓存
  17. */
  18. @Override
  19. @PostConstruct // Spring 启动时,自动初始化执行
  20. @TenantIgnore // 忽略自动多租户,全局初始化缓存
  21. public void initLocalCache() {
  22. // 获取角色列表,如果有更新
  23. List<RoleDO> roleList = loadRoleIfUpdate(maxUpdateTime);
  24. if (CollUtil.isEmpty(roleList)) {
  25. return;
  26. }
  27. // 写入缓存
  28. roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId);
  29. maxUpdateTime = CollectionUtils.getMaxValue(roleList, RoleDO::getUpdateTime);
  30. log.info("[initLocalCache][初始化 Role 数量为 {}]", roleList.size());
  31. }
  32. /**
  33. * 如果角色发生变化,从数据库中获取最新的全量角色。
  34. * 如果未发生变化,则返回空
  35. *
  36. * @param maxUpdateTime 当前角色的最大更新时间
  37. * @return 角色列表
  38. */
  39. private List<RoleDO> loadRoleIfUpdate(Date maxUpdateTime) {
  40. // 第一步,判断是否要更新。
  41. if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据
  42. log.info("[loadRoleIfUpdate][首次加载全量角色]");
  43. } else { // 判断数据库中是否有更新的角色
  44. if (roleMapper.selectExistsByUpdateTimeAfter(maxUpdateTime) == null) {
  45. return null;
  46. }
  47. log.info("[loadRoleIfUpdate][增量加载全量角色]");
  48. }
  49. // 第二步,如果有更新,则从数据库加载所有角色
  50. return roleMapper.selectList();
  51. }

由于缓存的定时刷新,也是调用 #initLocalCache() 方法,所以这里会有 maxUpdateTime + roleMapper.selectExistsByUpdateTimeAfter(maxUpdateTime) == null 判断数据是否发生变化的逻辑。
疑问:为什么使用 @TenantIgnore 注解?
由于 RoleDO 是多租户隔离,如果不添加 @TenantIgnore 注解,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。
所以,如果缓存的数据不存在多租户隔离的情况,可以不添加 @TenantIgnore 注解。

2. 定时刷新缓存

为什么需要定时刷新缓存呢?原因有两点:

  • 【主要】数据库的数据被直接修改,程序无法识别到数据发生了变化。
  • 【次要】Redis 或者网络不稳定,导致数据在修改时,无法使用 Redis 发布或者订阅数据发生变化的消息,影响缓存的刷新。

① 在 /module/system/dal/mysql/permission/MenuMapper.java接口中,定义 #selectExistsByUpdateTimeAfter(maxUpdateTime) 方法,通过最后获取数据时的 maxUpdateTime 最后更新时间,不断轮询是否有 update_time 更新超过它的数据。如果有,说明数据发生了变化。代码如下:

@Select("SELECT id FROM system_role WHERE update_time > #{maxUpdateTime} LIMIT 1")
RoleDO selectExistsByUpdateTimeAfter(Date maxUpdateTime);

疑问:为什么使用 @Select 来手写 SQL 语句?
考虑到 MyBatis Plus 会自动拼接 WHERE deleted = 0 的过滤逻辑删除的条件,导致删除数据的场景,会查询不到数据的变化。
因此,只能通过 @Select 或者 XML 的方式来手写 SQL 语句来避免自动拼接 WHERE deleted = 0 条件。
② 在 RoleServiceImpl类中,实现 #schedulePeriodicRefresh() 方法,通过 @Scheduled 注解声明本地定时任务,每 5 分钟轮询数据是否发生变化。如果发生变化,重新初始化缓存。代码如下:

// RoleServiceImpl.java

/**
 * 定时执行 {@link #schedulePeriodicRefresh()} 的周期
 * 因为已经通过 Redis Pub/Sub 机制,所以频率不需要高
 */
private static final long SCHEDULER_PERIOD = 5 * 60 * 1000L;

@Resource
@Lazy // 注入自己,所以延迟加载
private RoleService self;

@Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
public void schedulePeriodicRefresh() {
    self.initLocalCache(); // self 是为了 @TenantIgnore 的 AOP 忽略租户失效
}

3. 实时刷新缓存

为什么需要使用 Redis Pub/Sub 来实时刷新缓存?考虑到高可用,线上会部署多个 JVM 实例,需要通过 Redis 广播到所有实例,实现本地缓存的刷新。
image.png
友情提示:
Redis Pub/Sub 的使用与讲解,可见 《开发指南 —— 消息队列》 文档。

3.1 RoleRefreshMessage

新建 module/system/mq/message/permission/RoleRefreshMessage.java类,角色数据刷新 Message。代码如下:

@Data
@EqualsAndHashCode(callSuper = true)
public class RoleRefreshMessage extends AbstractChannelMessage {

    @Override
    public String getChannel() {
        return "system.role.refresh";
    }

}

3.2 RoleProducer

① 新建 module/system/mq/producer/permission/RoleProducer.java类,RoleRefreshMessage 的 Producer 生产者。代码如下:

② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。如下图所示:
image.png

3.3 RoleRefreshConsumer

新建 module/system/mq/consumer/permission/RoleRefreshConsumer.java类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。代码如下:

@Component
@Slf4j
public class RoleRefreshConsumer extends AbstractChannelMessageListener<RoleRefreshMessage> {

    @Resource
    private RoleService roleService;

    @Override
    public void onMessage(RoleRefreshMessage message) {
        log.info("[onMessage][收到 Role 刷新消息]");
        roleService.initLocalCache();
    }

}