基于Redis Pub/Sub 刷新本地缓存

804 阅读3分钟

1.本地缓存需要的场景

在部分场景下,有些数据存储在Redis中,由于数据量的大小,每次读取传输时间相对较多,导致网络延迟较大;同时在并发下也会占用大量的带宽,影响其他接口性能。

因此,此时将这部分数据存储在服务自身是一个比较好的选择。

为避免缓存对服务造成影响,缓存的数据应当还具备以下特征:

  1. 数据量有限且不会频繁变化
  2. 无强一致性要求
  3. 高频次访问

2.如何实现

在集群环境下,每次请求是轮询请求集群中的一个节点。导致在数据发生变化时,其它节点感知不到数据的变化,无法更新服务自身的缓存。

image.png

因此,需要一个机制将数据变化的事件广播到所有节点,从而使所有节点执行刷新缓存动作。

目前可选的的广播方案:

  1. Redis的Pub/Sub
  2. MQ的广播消息

鉴于绝大多数系统都集成了Redis,同时为避免引入新的组件,提高系统复杂度,此处采用Redis Pub/Sub 实现。

3.Spring Boot实现

3.1.基本实现

10.0.2.15:6382> publish local_cache user
(integer) 0
127.0.0.1:6383> subscribe local_cache
1) "subscribe"
2) "local_cache"
3) (integer) 1
1) "message"
2) "local_cache"
3) "user
    redisTemplate.convertAndSend("local_cache","policy");
public class CacheFlushEventMessageListener extends KeyspaceEventMessageListener implements
        ApplicationEventPublisherAware {

    private static final Logger log = LoggerFactory.getLogger(CacheFlushEventMessageListener.class);


    private @Nullable
    ApplicationEventPublisher publisher;

    public CacheFlushEventMessageListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.publisher = applicationEventPublisher;
    }

    @Override
    protected void doHandleMessage(Message message) {
        String channel = new String(message.getChannel());
        String body = new String(message.getBody());
        log.info("CacheFlushEvent Channel: {}, Body: {}", channel, body);
        publishEvent(new CacheFlushEvent(message.getBody()));

    }

    protected void publishEvent(ApplicationEvent event) {
        if (publisher != null) {
            this.publisher.publishEvent(event);
        }
    }

    @Override
    protected void doRegister(RedisMessageListenerContainer container) {
        container.addMessageListener(this, new ChannelTopic("local_cache"));
    }
}

@Component
public class CacheFlushApplicationListener implements ApplicationListener<CacheFlushEvent> {

    @Override
    public void onApplicationEvent(CacheFlushEvent event) {
        // TODO refresh cache
    }
}

至此,可以实现一个基本通知。

3.2.完善

3.2.1.RedisMessageListenerContainer设置Executor

RedisMessageListenerContainer默认Executor为SimpleAsyncTaskExecutor,每次执行new Thread。


protected void doExecute(Runnable task) {
    Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task));
    thread.start();
}
    redisMessageListenerContainer.setTaskExecutor(redisKeyEventExecutor);

3.2.2.执行缓存刷新

考虑到极端场景下,可能数据更新较为频繁,短时间内触发了多次刷新操作。为避免资源浪费,将其刷新动作,转到任务队列中,过滤掉无效操作。

4. 本方案使用注意点

4.1.数据未刷新成功

有时候可能会发现代码成功执行,但是数据还是旧值。

示例代码如下:

@Transactional(rollbackFor = Exception.class)
public void add() {
    
    addOrUpdate();
    
    redisTemplate.convertAndSend("local_cache","policy");
}

代码所示顺序如下:

image.png

但是上述操作处于数据库事务中,实际顺序如下:

image.png

Redis发布消息在事务提交之前。

如果此时事务提交时间稍长,那么服务在执行刷新动作时,事务还未提交,查询的还是旧值,导致缓存更新失败。

可通过TransactionSynchronizationManager来保证我们的触发逻辑,在事务提交之后。

@Transactional(rollbackFor = Exception.class)
public void add() {

    addOrUpdate();
    
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            redisTemplate.convertAndSend("local_cache","policy");
        }
    });
}

4.2.缓存变化

如果缓存被调用,调用方修改了缓存内容,会导致全局的缓存发生变化,从而影响其他业务。

以List为例,首先可将其变为不可变对象Collections.unmodifiableList(list)

此时列表不可变,但是还可以操作列表中每一个对象的属性来修改列表的内容。

为进一步保证不可变性,可将缓存对象的每个属性修改改final。

或者由调用方在需要修改数据时,自己做深拷贝。

为什么不在api内部做深拷贝?

经测试,若每次都深拷贝,性能损失较多。并且这部分数据是读多写少的,需要修改的场景很少,每次都深拷贝收益不大。

4.3.本地缓存数据延时

由于本地缓存是异步刷新的,存在延时,因此不适用于实效性高的场景。

4.4.数据量大小

由于使用的是内存,需要考虑这部分数据的大小,是否可控,是否会对其水平拓展造成影响。 若数据对象集存在较多的重复字符串,可通过String.intern减少内存占用。