监听Redis中key过期事件

在工作中偶尔会遇到这样一个场景:用户下单之后,若30分钟内未完成支付,则取消订单。

做过电商业务的同学,尤其是做统一下单业务的同学一般都会接触过这个场景的需求,一般的处理方式是将订单数据存储到数据库中(MySQL之类的),然后由一个定时Job不断的去扫描符合条件的订单,修改订单状态为已取消。当然这只是其中一个处理办法,我们还可以使用到延时队列来处理。

那么如果我们使用Redis是否可以实现这一功能?我们知道在使用Redis的过程中,大多是由客户端主动的去操作服务端,比如set、del、get、expire等操作。而当一个key过期被删除的时候,由服务端主动的去通知客户端,这个要怎么做?

之前在一个项目中,是自己写了一个定时Job不断是去轮询要监听的某些key,然后如果发现Redis中不存在要get的key,则执行一段业务逻辑,我们的扫描频率取决于Job的执行频率,所以并不能保证key在过期时被立即监听到,如果n秒执行一次,则key最大可能会在2n-1秒之后被执行,会有一定的延迟,那么我们能否让Redis主动的在缓存失效的时候通知我们呢?

解析

由服务端主动通知客户端,那么就是需要通过一个事件来触发某项通知,事件通过Redis的订阅和发布功能来进行分发,我们查看Redis的配置文件中有一个EVENT NOTIFICATION配置,也名键空间通知

image-20200512002406401

注释上说:Redis可以通知发布/订阅客户端关于键空间中发生的事件,如果Redis开启了键空间事件通知,且客户端订阅了某些键的事件,则在相应的键发生变动时,会通过发布/订阅向客户端发送两条消息:

1
2
PUBLISH __keyspace@0__:foo del
PUBLISH __keyevent@0__:del foo

客户端可以在一组类中选择Redis的通知事件,每个类都需要由唯一字符进行标识。

配置

当开启键空间通知功能时,需要额外的消耗一些CPU,所以此功能默认为关闭状态,可以通过修改redis.conf文件或者使用config set命令来开启或关闭键空间通知功能

  • notify-keyspace-events的值为空字符串时,功能关闭
  • 当参数的值不是空字符串时,功能开启,且参数的值的取值范围是固定的

参数的可选值

字符 通知事件
K 键空间通知,所有通知以 __keyspace@__ 为前缀
E 键事件通知,所有通知以 __keyevent@__ 为前缀
g DELEXPIRERENAME 等类型无关的通用命令的通知
$ 字符串命令的通知
l 列表命令的通知
s 集合命令的通知
h 哈希命令的通知
z 有序集合命令的通知
x 过期事件:每当有过期键被删除时发送
e 驱逐(evict)事件:每当有键因为 maxmemory 政策而被删除时发送
A 参数 g$lshzxe 的别名

输入的参数中至少要有一个KE来指定通知类型,否则配置不会生效

过期通知事件

在Redis中有两种方式将key删除:

  1. 当一个键被访问时,Redis会对这个键进行检查,如果键已经过期,则将该键删除
  2. Redis后台会定期删除那些已经过期的键

当过期键被删除时,Redis会产生一个expired通知。在此要理解一点,就是并不是当key的TTL变为0时就会立即被删除,所以Redis产生expired通知的时间为键被删除的时候而不是键的TTL变为0的时候。

依据上述表格,我们可以将notify-keyspace-events设置为Ex,表示键过期事件通知。

Java应用中通知监控

Spring Data Redis 实现发布订阅功能非常简单,只有这样的几个类:TopicMessageListenerRedisMessageListenerContainer。下面对它们进行解释:

org.springframework.data.redis.listener.Topic消息发送者与接收者之间的 channel 定义,有两个实现类:

  1. org.springframework.data.redis.listener.ChannelTopic:一个确定的字符串
  2. org.springframework.data.redis.listener.PatternTopic:基于模式匹配

org.springframework.data.redis.connection.MessageListener一个回调接口,消息监听器,用于接收发送到 channel 的消息,接口定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package org.springframework.data.redis.connection;

import org.springframework.lang.Nullable;

/**
* 监听Redis的订阅通知
*
* @author Costin Leau
* @author Christoph Strobl
*/
public interface MessageListener {

/**
* 当从Redis接收到通知后的回调方法
*
* @param message message must not be {@literal null}.
* @param pattern pattern matching the channel (if specified) - can be {@literal null}.
*/
void onMessage(Message message, @Nullable byte[] pattern);
}

org.springframework.data.redis.listener.RedisMessageListenerContainer用于消息监听,需要将 TopicMessageListener注册到RedisMessageListenerContainer中,当 Topic 上有消息时,由RedisMessageListenerContainer通知MessageListener,客户端通过onMessage()拿到消息后,自行处理。

  • 引入redis的依赖包

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.2.0</version>
    </dependency>
  • 创建RedisMessageListenerContainer实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import org.springframework.context.annotation.Bean;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.listener.RedisMessageListenerContainer;
    import org.springframework.stereotype.Component;

    @Component
    public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory redisConnectionFactory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(redisConnectionFactory);
    return container;
    }

    }
  • 创建key过期事件监听器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import org.springframework.data.redis.connection.Message;
    import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
    import org.springframework.data.redis.listener.RedisMessageListenerContainer;
    import org.springframework.stereotype.Component;

    @Component
    public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
    super(listenerContainer);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
    String key = message.toString();
    System.out.println("监听到key: " + key + " 过期!");
    }
    }